Skip to main content

dbmcp_sqlite/tools/
list_triggers.rs

1//! MCP tool: `listTriggers`.
2
3use dbmcp_server::pagination::Pager;
4use dbmcp_server::types::ListEntriesResponse;
5
6use super::prelude::*;
7use crate::types::ListTriggersRequest;
8
9const NAME: &str = "listTriggers";
10const TITLE: &str = "List Triggers";
11const DESCRIPTION: &str = include_str!("../../assets/tools/list_triggers.md");
12
13/// Marker type for the `listTriggers` MCP tool.
14pub(crate) struct ListTriggersTool;
15
16impl ToolBase for ListTriggersTool {
17    type Parameter = ListTriggersRequest;
18    type Output = ListEntriesResponse;
19    type Error = ErrorData;
20
21    fn name() -> Cow<'static, str> {
22        NAME.into()
23    }
24
25    fn title() -> Option<String> {
26        Some(TITLE.into())
27    }
28
29    fn description() -> Option<Cow<'static, str>> {
30        Some(DESCRIPTION.into())
31    }
32
33    fn annotations() -> Option<ToolAnnotations> {
34        Some(
35            ToolAnnotations::new()
36                .read_only(true)
37                .destructive(false)
38                .idempotent(true)
39                .open_world(false),
40        )
41    }
42
43    fn input_schema() -> Option<Arc<JsonObject>> {
44        Some(input_schema::<Self::Parameter>(true))
45    }
46
47    fn output_schema() -> Option<Arc<JsonObject>> {
48        Some(output_schema::<Self::Output>())
49    }
50}
51
52impl AsyncTool<SqliteHandler> for ListTriggersTool {
53    async fn invoke(handler: &SqliteHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
54        handler.list_triggers(params).await
55    }
56}
57
58/// Brief-mode SQL: name-only column with optional case-insensitive `LIKE` filter.
59///
60/// `SQLite`'s `LIKE` operator is case-insensitive for ASCII by default and
61/// `dbmcp` does not toggle the `case_sensitive_like` PRAGMA, so a bare
62/// `LIKE` already matches case-insensitively. (A `COLLATE NOCASE` clause on
63/// the right-hand pattern would be a no-op — `LIKE` does not honor RHS
64/// collation; see <https://www.sqlite.org/lang_expr.html>.) User-facing
65/// `LIKE` wildcards (`%`, `_`) in `?1` flow straight through.
66const BRIEF_SQL: &str = r"
67    SELECT name
68    FROM sqlite_schema
69    WHERE type = 'trigger'
70      AND name NOT LIKE 'sqlite_%'
71      AND (?1 IS NULL OR name LIKE '%' || ?1 || '%')
72    ORDER BY name
73    LIMIT ?2 OFFSET ?3";
74
75/// Detailed-mode SQL: single SELECT projecting `(name, json_object(...))`.
76///
77/// `'main'` is hard-coded in the projection — `sqlite_schema` here is the
78/// `main` schema's catalogue, the only one the connection helper opens.
79/// `AND sql IS NOT NULL` enforces the homogeneous-shape contract: rows whose
80/// stored `CREATE TRIGGER` text is `NULL` (extension-generated, hand-edited)
81/// are silently omitted from detailed pages. Brief mode does not filter on
82/// `sql` so those triggers are still discoverable by name. `SQLite` trigger
83/// names are unique within a database, so `ORDER BY name` is sufficient
84/// for stable pagination — no secondary tie-breaker needed.
85const DETAILED_SQL: &str = r"
86    SELECT
87        name,
88        json_object(
89            'schema',     'main',
90            'table',      tbl_name,
91            'definition', sql
92        ) AS entry
93    FROM sqlite_schema
94    WHERE type = 'trigger'
95      AND name NOT LIKE 'sqlite_%'
96      AND sql IS NOT NULL
97      AND (?1 IS NULL OR name LIKE '%' || ?1 || '%')
98    ORDER BY name
99    LIMIT ?2 OFFSET ?3";
100
101impl SqliteHandler {
102    /// Lists one page of triggers in the connected database, optionally filtered and/or detailed.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`ErrorData`] with code `-32602` if `cursor` is malformed,
107    /// or an internal-error [`ErrorData`] if the underlying query fails.
108    pub async fn list_triggers(
109        &self,
110        ListTriggersRequest {
111            cursor,
112            search,
113            detailed,
114        }: ListTriggersRequest,
115    ) -> Result<ListEntriesResponse, ErrorData> {
116        let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
117        let pager = Pager::new(cursor, self.config.page_size);
118
119        if detailed {
120            let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
121                .connection
122                .fetch(
123                    sqlx::query(DETAILED_SQL)
124                        .bind(pattern)
125                        .bind(pager.limit())
126                        .bind(pager.offset()),
127                    None,
128                )
129                .await?;
130            let (rows, next_cursor) = pager.paginate(rows);
131            return Ok(ListEntriesResponse::detailed(
132                rows.into_iter().map(|(name, json)| (name, json.0)).collect(),
133                next_cursor,
134            ));
135        }
136
137        let rows: Vec<String> = self
138            .connection
139            .fetch_scalar(
140                sqlx::query(BRIEF_SQL)
141                    .bind(pattern)
142                    .bind(pager.limit())
143                    .bind(pager.offset()),
144                None,
145            )
146            .await?;
147        let (triggers, next_cursor) = pager.paginate(rows);
148        Ok(ListEntriesResponse::brief(triggers, next_cursor))
149    }
150}