Skip to main content

mcp_server_sqlite/tools/
list_triggers_tool.rs

1//! The `list_triggers` tool: returns all triggers in the database, or only
2//! those attached to a specific table. Each trigger includes its name, table,
3//! event, timing, and full SQL definition.
4
5use rmcp::model::{Content, IntoContents};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use super::ToolError;
10use crate::{mcp::McpServerSqlite, traits::SqliteServerTool};
11
12#[derive(
13    Clone,
14    Copy,
15    Debug,
16    PartialEq,
17    Eq,
18    PartialOrd,
19    Ord,
20    Hash,
21    Default,
22    Serialize,
23    Deserialize,
24    JsonSchema,
25)]
26/// List all triggers in the database, or only those for a given table. Returns
27/// each trigger's name, table, event, timing, and full SQL. Useful for
28/// understanding data-change side effects before issuing write queries.
29pub struct ListTriggersTool;
30
31impl SqliteServerTool for ListTriggersTool {
32    const NAME: &str = "list_triggers";
33
34    type Context = McpServerSqlite;
35    type Error = ToolError<ListTriggersError>;
36
37    type Input = ListTriggersInput;
38    type Output = ListTriggersOutput;
39
40    fn handle(
41        ctx: &Self::Context,
42        input: Self::Input,
43    ) -> Result<Self::Output, Self::Error> {
44        let conn = ctx
45            .connection()
46            .map_err(|source| ToolError::Connection { source })?;
47
48        let triggers = match &input.table_name {
49            Some(table) => {
50                let mut stmt = conn
51                    .prepare(
52                        "SELECT name, tbl_name, sql FROM sqlite_master \
53                         WHERE type = 'trigger' AND tbl_name = ?1 \
54                         ORDER BY tbl_name, name",
55                    )
56                    .map_err(|source| {
57                        ToolError::Tool(ListTriggersError::Query { source })
58                    })?;
59
60                stmt.query_map([table], |row| Ok(trigger_info_from_row(row)))
61                    .map_err(|source| {
62                        ToolError::Tool(ListTriggersError::Query { source })
63                    })?
64                    .collect::<Result<Vec<_>, _>>()
65                    .map_err(|source| {
66                        ToolError::Tool(ListTriggersError::Query { source })
67                    })?
68            }
69            None => {
70                let mut stmt = conn
71                    .prepare(
72                        "SELECT name, tbl_name, sql FROM sqlite_master \
73                         WHERE type = 'trigger' \
74                         ORDER BY tbl_name, name",
75                    )
76                    .map_err(|source| {
77                        ToolError::Tool(ListTriggersError::Query { source })
78                    })?;
79
80                stmt.query_map([], |row| Ok(trigger_info_from_row(row)))
81                    .map_err(|source| {
82                        ToolError::Tool(ListTriggersError::Query { source })
83                    })?
84                    .collect::<Result<Vec<_>, _>>()
85                    .map_err(|source| {
86                        ToolError::Tool(ListTriggersError::Query { source })
87                    })?
88            }
89        };
90
91        Ok(ListTriggersOutput { triggers })
92    }
93}
94
95/// Builds a `TriggerInfo` from a `sqlite_master` row containing the `name`,
96/// `tbl_name`, and `sql` columns (indices 0, 1, 2).
97fn trigger_info_from_row(row: &rusqlite::Row<'_>) -> TriggerInfo {
98    let name: String = row.get(0).unwrap_or_default();
99    let table_name: String = row.get(1).unwrap_or_default();
100    let sql: Option<String> = row.get(2).unwrap_or_default();
101    let sql_text = sql.unwrap_or_default();
102
103    let (timing, event) = parse_timing_and_event(&name, &sql_text);
104
105    TriggerInfo {
106        name,
107        table_name,
108        event,
109        timing,
110        sql: sql_text,
111    }
112}
113
114/// Parses the timing (`BEFORE`, `AFTER`, `INSTEAD OF`) and event (`INSERT`,
115/// `UPDATE`, `DELETE`) from a `CREATE TRIGGER` statement.
116///
117/// The expected SQL format is:
118/// ```text
119/// CREATE TRIGGER <name> {BEFORE|AFTER|INSTEAD OF}
120///     {INSERT|UPDATE|DELETE} ON <table> ...
121/// ```
122///
123/// Returns `("UNKNOWN", "UNKNOWN")` if the SQL cannot be parsed.
124fn parse_timing_and_event(trigger_name: &str, sql: &str) -> (String, String) {
125    let upper = sql.to_uppercase();
126    let search_name = trigger_name.to_uppercase();
127
128    let after_name_pos =
129        upper.find(&search_name).map(|pos| pos + search_name.len());
130
131    let remainder = match after_name_pos {
132        Some(pos) => &upper[pos..],
133        None => return (unknown(), unknown()),
134    };
135
136    let trimmed = remainder.trim_start();
137
138    let timing = if trimmed.starts_with("BEFORE") {
139        "BEFORE"
140    } else if trimmed.starts_with("AFTER") {
141        "AFTER"
142    } else if trimmed.starts_with("INSTEAD OF") {
143        "INSTEAD OF"
144    } else {
145        return (unknown(), unknown());
146    };
147
148    let after_timing = trimmed[timing.len()..].trim_start();
149
150    let event = if after_timing.starts_with("INSERT") {
151        "INSERT"
152    } else if after_timing.starts_with("UPDATE") {
153        "UPDATE"
154    } else if after_timing.starts_with("DELETE") {
155        "DELETE"
156    } else {
157        return (timing.to_owned(), unknown());
158    };
159
160    (timing.to_owned(), event.to_owned())
161}
162
163/// Returns the fallback string used when timing or event parsing fails.
164fn unknown() -> String {
165    "UNKNOWN".to_owned()
166}
167
168/// The input parameters for the `list_triggers` tool.
169#[derive(
170    Clone,
171    Debug,
172    Default,
173    PartialEq,
174    Eq,
175    PartialOrd,
176    Ord,
177    Hash,
178    Serialize,
179    Deserialize,
180    schemars::JsonSchema,
181)]
182pub struct ListTriggersInput {
183    /// Optional table name to filter triggers. When provided, only triggers
184    /// attached to this table are returned. When omitted, all triggers in the
185    /// database are returned.
186    #[schemars(description = "Optional table name to filter triggers by. \
187                        Omit to list all triggers.")]
188    pub table_name: Option<String>,
189}
190
191/// The result of listing triggers in the database.
192#[derive(
193    Clone,
194    Debug,
195    PartialEq,
196    Eq,
197    PartialOrd,
198    Ord,
199    Hash,
200    Serialize,
201    Deserialize,
202    schemars::JsonSchema,
203)]
204pub struct ListTriggersOutput {
205    /// The triggers found, each with full metadata and SQL.
206    pub triggers: Vec<TriggerInfo>,
207}
208
209/// Metadata for a single trigger in the database.
210#[derive(
211    Clone,
212    Debug,
213    PartialEq,
214    Eq,
215    PartialOrd,
216    Ord,
217    Hash,
218    Serialize,
219    Deserialize,
220    schemars::JsonSchema,
221)]
222pub struct TriggerInfo {
223    /// The trigger name as declared in `CREATE TRIGGER`.
224    pub name: String,
225    /// The table this trigger is attached to.
226    pub table_name: String,
227    /// The DML event that fires the trigger: `INSERT`, `UPDATE`, or `DELETE`.
228    pub event: String,
229    /// When the trigger fires relative to the event: `BEFORE`, `AFTER`, or
230    /// `INSTEAD OF`.
231    pub timing: String,
232    /// The full `CREATE TRIGGER` SQL statement that defines this trigger.
233    pub sql: String,
234}
235
236/// Errors specific to the `list_triggers` tool.
237#[derive(Debug, thiserror::Error)]
238pub enum ListTriggersError {
239    /// Failed to query `sqlite_master` for the trigger list.
240    #[error("failed to list triggers: {source}")]
241    Query {
242        /// The underlying rusqlite error.
243        source: rusqlite::Error,
244    },
245}
246
247/// Converts the list-triggers-specific error into MCP content by rendering the
248/// display string as text.
249impl IntoContents for ListTriggersError {
250    fn into_contents(self) -> Vec<Content> {
251        vec![Content::text(self.to_string())]
252    }
253}