Skip to main content

dbmcp_sqlite/tools/
list_triggers.rs

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