Skip to main content

dbmcp_postgres/tools/
list_materialized_views.rs

1//! MCP tool: `listMaterializedViews`.
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::{
12    ListMaterializedViewsResponse, PinnedListMaterializedViewsRequest, UnpinnedListMaterializedViewsRequest,
13};
14
15const NAME: &str = "listMaterializedViews";
16const TITLE: &str = "List Materialized Views";
17const DESCRIPTION_PINNED: &str = include_str!("../../assets/tools/list_materialized_views/pinned.md");
18const DESCRIPTION_UNPINNED: &str = include_str!("../../assets/tools/list_materialized_views/unpinned.md");
19
20fn annotations() -> ToolAnnotations {
21    ToolAnnotations::new()
22        .read_only(true)
23        .destructive(false)
24        .idempotent(true)
25        .open_world(false)
26}
27
28/// Marker type for the `listMaterializedViews` MCP tool (pinned variant — no `database` field).
29pub(crate) struct PinnedListMaterializedViewsTool;
30
31impl ToolBase for PinnedListMaterializedViewsTool {
32    type Parameter = PinnedListMaterializedViewsRequest;
33    type Output = ListMaterializedViewsResponse;
34    type Error = ErrorData;
35
36    fn name() -> Cow<'static, str> {
37        NAME.into()
38    }
39
40    fn title() -> Option<String> {
41        Some(TITLE.into())
42    }
43
44    fn description() -> Option<Cow<'static, str>> {
45        Some(DESCRIPTION_PINNED.into())
46    }
47
48    fn annotations() -> Option<ToolAnnotations> {
49        Some(annotations())
50    }
51}
52
53impl AsyncTool<PostgresHandler> for PinnedListMaterializedViewsTool {
54    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
55        handler
56            .list_materialized_views(None, params.cursor, params.search, params.detailed)
57            .await
58    }
59}
60
61/// Marker type for the `listMaterializedViews` MCP tool (unpinned variant — carries `database`).
62pub(crate) struct UnpinnedListMaterializedViewsTool;
63
64impl ToolBase for UnpinnedListMaterializedViewsTool {
65    type Parameter = UnpinnedListMaterializedViewsRequest;
66    type Output = ListMaterializedViewsResponse;
67    type Error = ErrorData;
68
69    fn name() -> Cow<'static, str> {
70        NAME.into()
71    }
72
73    fn title() -> Option<String> {
74        Some(TITLE.into())
75    }
76
77    fn description() -> Option<Cow<'static, str>> {
78        Some(DESCRIPTION_UNPINNED.into())
79    }
80
81    fn annotations() -> Option<ToolAnnotations> {
82        Some(annotations())
83    }
84}
85
86impl AsyncTool<PostgresHandler> for UnpinnedListMaterializedViewsTool {
87    async fn invoke(handler: &PostgresHandler, params: Self::Parameter) -> Result<Self::Output, Self::Error> {
88        handler
89            .list_materialized_views(
90                params.database,
91                params.inner.cursor,
92                params.inner.search,
93                params.inner.detailed,
94            )
95            .await
96    }
97}
98
99/// Brief-mode SQL: `pg_matviews` scan with `ILIKE` filter on matview name.
100///
101/// `pg_matviews` already filters to `pg_class.relkind = 'm'`, and
102/// `schemaname = 'public'` keeps system-schema matviews out. The
103/// `($1::text IS NULL OR ...)` trinary lets one statement cover both filtered
104/// and unfiltered cases. Matview names are unique per schema (Postgres enforces
105/// this via `pg_class`'s unique index on `(relname, relnamespace)`), so
106/// `matviewname` alone is a stable sort key — no tiebreaker needed.
107const BRIEF_SQL: &str = r"
108    SELECT matviewname
109    FROM pg_matviews
110    WHERE schemaname = 'public'
111      AND ($1::text IS NULL OR matviewname ILIKE '%' || $1 || '%')
112    ORDER BY matviewname
113    LIMIT $2 OFFSET $3";
114
115/// Detailed-mode SQL: per-matview `json_build_object` projection.
116///
117/// `pg_matviews` already filters to `relkind = 'm'`. The `pg_namespace` +
118/// `pg_class` joins anchor the relation OID needed by `obj_description`.
119/// Postgres defers SELECT-list evaluation past `LIMIT`, so `obj_description`
120/// only runs for the page's rows — never the full schema.
121/// `pg_matviews.matviewowner` is already a role name, so no `pg_roles` join is
122/// needed. `populated` and `indexed` are projected directly from
123/// `pg_matviews.ispopulated` / `pg_matviews.hasindexes`. Matview names are
124/// unique per schema, so `matviewname` alone is a stable sort key.
125const DETAILED_SQL: &str = r"
126    SELECT
127        mv.matviewname AS name,
128        json_build_object(
129            'schema',      mv.schemaname,
130            'owner',       mv.matviewowner,
131            'description', pg_catalog.obj_description(c.oid, 'pg_class'),
132            'definition',  mv.definition,
133            'populated',   mv.ispopulated,
134            'indexed',     mv.hasindexes
135        ) AS entry
136    FROM pg_matviews mv
137    JOIN pg_namespace n ON n.nspname = mv.schemaname
138    JOIN pg_class     c ON c.relname = mv.matviewname AND c.relnamespace = n.oid
139    WHERE mv.schemaname = 'public'
140      AND ($1::text IS NULL OR mv.matviewname ILIKE '%' || $1 || '%')
141    ORDER BY mv.matviewname
142    LIMIT $2 OFFSET $3";
143
144impl PostgresHandler {
145    /// Lists one page of user-defined materialized views, optionally filtered and/or detailed.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`ErrorData`] with code `-32602` if `cursor` is malformed,
150    /// or an internal-error [`ErrorData`] if `database` is invalid
151    /// or the underlying query fails.
152    pub async fn list_materialized_views(
153        &self,
154        database: Option<String>,
155        cursor: Option<Cursor>,
156        search: Option<String>,
157        detailed: bool,
158    ) -> Result<ListMaterializedViewsResponse, ErrorData> {
159        let database = database.as_deref().map(str::trim).filter(|s| !s.is_empty());
160        let pattern = search.as_deref().map(str::trim).filter(|s| !s.is_empty());
161        let pager = Pager::new(cursor, self.config.page_size);
162
163        if detailed {
164            let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = self
165                .connection
166                .fetch(
167                    sqlx::query(DETAILED_SQL)
168                        .bind(pattern)
169                        .bind(pager.limit())
170                        .bind(pager.offset()),
171                    database,
172                )
173                .await?;
174            let (rows, next_cursor) = pager.paginate(rows);
175            return Ok(ListMaterializedViewsResponse::detailed(
176                rows.into_iter().map(|(key, json)| (key, json.0)).collect(),
177                next_cursor,
178            ));
179        }
180
181        let rows: Vec<String> = self
182            .connection
183            .fetch_scalar(
184                sqlx::query(BRIEF_SQL)
185                    .bind(pattern)
186                    .bind(pager.limit())
187                    .bind(pager.offset()),
188                database,
189            )
190            .await?;
191        let (materialized_views, next_cursor) = pager.paginate(rows);
192        Ok(ListMaterializedViewsResponse::brief(materialized_views, next_cursor))
193    }
194}