Skip to main content

dbmcp_postgres/tools/
list_triggers.rs

1//! MCP tool: `listTriggers`.
2
3use std::borrow::Cow;
4
5use dbmcp_server::pagination::Pager;
6use dbmcp_sql::Connection as _;
7use rmcp::handler::server::router::tool::{AsyncTool, ToolBase};
8use rmcp::model::{ErrorData, ToolAnnotations};
9
10use crate::PostgresHandler;
11use crate::types::{ListTriggersRequest, ListTriggersResponse};
12
13/// Marker type for the `listTriggers` MCP tool.
14pub(crate) struct ListTriggersTool;
15
16impl ListTriggersTool {
17    const NAME: &'static str = "listTriggers";
18    const TITLE: &'static str = "List Triggers";
19    const DESCRIPTION: &'static str = include_str!("../../assets/tools/list_triggers.md");
20}
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        Self::NAME.into()
29    }
30
31    fn title() -> Option<String> {
32        Some(Self::TITLE.into())
33    }
34
35    fn description() -> Option<Cow<'static, str>> {
36        Some(Self::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<PostgresHandler> for ListTriggersTool {
51    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
52        handler.list_triggers(params).await
53    }
54}
55
56/// Brief-mode SQL: `pg_trigger` join with optional `ILIKE` filter on trigger name.
57///
58/// `'public'::regnamespace` casts the schema name to a `pg_namespace.oid`
59/// at plan time, replacing what would otherwise be a `pg_namespace` join.
60/// Trigger names are not globally unique on Postgres — the same trigger
61/// name can appear on multiple relations — so `c.relname` is the secondary
62/// sort key, matching the detailed-mode `ORDER BY` and keeping `OFFSET`
63/// pagination stable across duplicate names.
64const BRIEF_SQL: &str = r"
65    SELECT t.tgname
66    FROM pg_trigger t
67    JOIN pg_class c ON t.tgrelid = c.oid
68    WHERE c.relnamespace = 'public'::regnamespace
69      AND NOT t.tgisinternal
70      AND ($1::text IS NULL OR t.tgname ILIKE '%' || $1 || '%')
71    ORDER BY t.tgname, c.relname
72    LIMIT $2 OFFSET $3";
73
74/// Detailed-mode SQL: per-trigger `json_build_object` projection.
75///
76/// `'public'::regnamespace` casts the schema name to a `pg_namespace.oid`
77/// at plan time, eliminating the otherwise-needed `pg_namespace` join.
78/// `schema` is hard-coded `'public'` in the output since the WHERE filter
79/// already pins it to that one namespace. Postgres defers SELECT-list
80/// evaluation past `LIMIT`, so `pg_get_triggerdef` and the events array
81/// only run for the page's rows.
82///
83/// `tgtype` bitmask values are stable since 8.4 and defined in
84/// `src/include/catalog/pg_trigger.h`:
85/// `TRIGGER_TYPE_ROW`=1, `TRIGGER_TYPE_BEFORE`=2, `TRIGGER_TYPE_INSERT`=4,
86/// `TRIGGER_TYPE_DELETE`=8, `TRIGGER_TYPE_UPDATE`=16,
87/// `TRIGGER_TYPE_TRUNCATE`=32, `TRIGGER_TYPE_INSTEAD`=64.
88const DETAILED_SQL: &str = r"
89    SELECT
90        t.tgname AS name,
91        json_build_object(
92            'schema',          'public',
93            'table',           c.relname,
94            'status',          CASE t.tgenabled
95                                   WHEN 'O' THEN 'ENABLED'
96                                   WHEN 'D' THEN 'DISABLED'
97                                   WHEN 'R' THEN 'REPLICA'
98                                   WHEN 'A' THEN 'ALWAYS'
99                               END,
100            'timing',          CASE
101                                   WHEN (t.tgtype & 2)  = 2  THEN 'BEFORE'
102                                   WHEN (t.tgtype & 64) = 64 THEN 'INSTEAD OF'
103                                   ELSE 'AFTER'
104                               END,
105            'events',          to_json(array_remove(ARRAY[
106                                   CASE WHEN (t.tgtype & 4)  = 4  THEN 'INSERT'   END,
107                                   CASE WHEN (t.tgtype & 16) = 16 THEN 'UPDATE'   END,
108                                   CASE WHEN (t.tgtype & 8)  = 8  THEN 'DELETE'   END,
109                                   CASE WHEN (t.tgtype & 32) = 32 THEN 'TRUNCATE' END
110                               ], NULL)),
111            'activationLevel', CASE WHEN (t.tgtype & 1) = 1 THEN 'ROW' ELSE 'STATEMENT' END,
112            'functionName',    p.proname,
113            'definition',      pg_get_triggerdef(t.oid)
114        ) AS entry
115    FROM pg_trigger t
116    JOIN pg_class c ON t.tgrelid = c.oid
117    LEFT JOIN pg_proc p ON p.oid = t.tgfoid
118    WHERE c.relnamespace = 'public'::regnamespace
119      AND NOT t.tgisinternal
120      AND ($1::text IS NULL OR t.tgname ILIKE '%' || $1 || '%')
121    ORDER BY t.tgname, c.relname
122    LIMIT $2 OFFSET $3";
123
124impl PostgresHandler {
125    /// Lists one page of user-defined triggers, optionally filtered and/or detailed.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ErrorData`] with code `-32602` if `cursor` is malformed,
130    /// or an internal-error [`ErrorData`] if `database` is invalid
131    /// or the underlying query fails.
132    pub async fn list_triggers(
133        &self,
134        ListTriggersRequest {
135            database,
136            cursor,
137            search,
138            detailed,
139        }: ListTriggersRequest,
140    ) -> Result<ListTriggersResponse, ErrorData> {
141        let database = database.as_deref().map(str::trim).filter(|s| !s.is_empty());
142        let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
143        let pager = Pager::new(cursor, self.config.page_size);
144
145        if detailed {
146            let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
147                .connection
148                .fetch(
149                    sqlx::query(DETAILED_SQL)
150                        .bind(pattern)
151                        .bind(pager.limit())
152                        .bind(pager.offset()),
153                    database,
154                )
155                .await?;
156            let (rows, next_cursor) = pager.paginate(rows);
157            return Ok(ListTriggersResponse::detailed(
158                rows.into_iter().map(|(name, json)| (name, json.0)).collect(),
159                next_cursor,
160            ));
161        }
162
163        let rows: Vec<String> = self
164            .connection
165            .fetch_scalar(
166                sqlx::query(BRIEF_SQL)
167                    .bind(pattern)
168                    .bind(pager.limit())
169                    .bind(pager.offset()),
170                database,
171            )
172            .await?;
173        let (triggers, next_cursor) = pager.paginate(rows);
174        Ok(ListTriggersResponse::brief(triggers, next_cursor))
175    }
176}