Skip to main content

dbmcp_postgres/tools/
list_triggers.rs

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