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
15/// Marker type for the `listTriggers` MCP tool.
16pub(crate) struct ListTriggersTool;
17
18impl ListTriggersTool {
19    const NAME: &'static str = "listTriggers";
20    const TITLE: &'static str = "List Triggers";
21    const DESCRIPTION: &'static str = include_str!("../../assets/tools/list_triggers.md");
22}
23
24impl ToolBase for ListTriggersTool {
25    type Parameter = ListTriggersRequest;
26    type Output = ListTriggersResponse;
27    type Error = ErrorData;
28
29    fn name() -> Cow<'static, str> {
30        Self::NAME.into()
31    }
32
33    fn title() -> Option<String> {
34        Some(Self::TITLE.into())
35    }
36
37    fn description() -> Option<Cow<'static, str>> {
38        Some(Self::DESCRIPTION.into())
39    }
40
41    fn annotations() -> Option<ToolAnnotations> {
42        Some(
43            ToolAnnotations::new()
44                .read_only(true)
45                .destructive(false)
46                .idempotent(true)
47                .open_world(false),
48        )
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<ListTriggersResponse, 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(ListTriggersResponse::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(ListTriggersResponse::brief(triggers, next_cursor))
149    }
150}