Skip to main content

dbmcp_postgres/
handler.rs

1//! `PostgreSQL` handler: composes a [`PostgresConnection`] with the MCP tool router.
2//!
3//! All pool ownership and pool initialization logic lives in the
4//! [`PostgresConnection`]. This module wires the connection into
5//! the MCP `ServerHandler` surface and exposes a small set of thin
6//! delegator methods that the per-tool implementations call.
7
8use dbmcp_config::DatabaseConfig;
9use dbmcp_server::{Server, server_info};
10use rmcp::RoleServer;
11use rmcp::handler::server::router::tool::ToolRouter;
12use rmcp::handler::server::tool::ToolCallContext;
13use rmcp::model::{CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, ServerInfo, Tool};
14use rmcp::service::RequestContext;
15use rmcp::{ErrorData, ServerHandler};
16
17use crate::connection::PostgresConnection;
18
19use crate::tools::{
20    CreateDatabaseTool, DropDatabaseTool, DropTableTool, ExplainQueryTool, GetTableSchemaTool, ListDatabasesTool,
21    ListFunctionsTool, ListMaterializedViewsTool, ListProceduresTool, ListTablesTool, ListTriggersTool, ListViewsTool,
22    ReadQueryTool, WriteQueryTool,
23};
24
25/// Backend-specific description for `PostgreSQL`.
26const DESCRIPTION: &str = "Database MCP Server for PostgreSQL";
27
28/// Backend-specific instructions for `PostgreSQL`.
29const INSTRUCTIONS: &str = r"## Workflow
30
311. Call `listDatabases` to discover available databases.
322. Call `listTables` to see tables.
333. Call `listViews` to see views in the `public` schema.
344. Call `listTriggers` to see user-defined triggers in the `public` schema.
355. Call `listFunctions` to see user-defined functions in the `public` schema.
366. Call `listProcedures` to see user-defined procedures in the `public` schema.
377. Call `listMaterializedViews` to see materialized views in the `public` schema.
388. Call `getTableSchema` to inspect columns, types, and foreign keys before writing queries.
399. Use `readQuery` for read-only SQL (SELECT, SHOW, EXPLAIN).
4010. Use `writeQuery` for data changes (INSERT, UPDATE, DELETE, CREATE, ALTER, DROP).
4111. Use `explainQuery` to analyze query execution plans and diagnose slow queries.
4212. Use `createDatabase` to create a new database.
4313. Use `dropDatabase` to drop an existing database.
4414. Use `dropTable` to remove a table from a database (supports `cascade` for foreign key dependencies).
45
46Per-database tools default to the active database; pass `database` to target another.
47
48## Constraints
49
50- The `writeQuery`, `createDatabase`, `dropDatabase`, and `dropTable` tools are hidden when read-only mode is active.
51- Multi-statement queries are not supported. Send one statement per request.";
52
53/// `PostgreSQL` database handler.
54///
55/// Composes one [`PostgresConnection`] (which owns every pool
56/// and the pool initialization logic) with the per-backend MCP tool
57/// router.
58#[derive(Clone)]
59pub struct PostgresHandler {
60    pub(crate) config: DatabaseConfig,
61    pub(crate) connection: PostgresConnection,
62    tool_router: ToolRouter<Self>,
63}
64
65impl std::fmt::Debug for PostgresHandler {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("PostgresHandler")
68            .field("read_only", &self.config.read_only)
69            .field("connection", &self.connection)
70            .finish_non_exhaustive()
71    }
72}
73
74impl PostgresHandler {
75    /// Creates a new `PostgreSQL` handler.
76    ///
77    /// Constructs the [`PostgresConnection`] (which builds the
78    /// lazy default pool and the per-database pool cache) and the MCP
79    /// tool router. No network I/O happens here.
80    #[must_use]
81    pub fn new(config: &DatabaseConfig) -> Self {
82        Self {
83            config: config.clone(),
84            connection: PostgresConnection::new(config),
85            tool_router: build_tool_router(config.read_only),
86        }
87    }
88}
89
90impl From<PostgresHandler> for Server {
91    /// Wraps a [`PostgresHandler`] in the type-erased MCP server.
92    fn from(handler: PostgresHandler) -> Self {
93        Self::new(handler)
94    }
95}
96
97/// Builds the tool router, including write tools only when not in read-only mode.
98fn build_tool_router(read_only: bool) -> ToolRouter<PostgresHandler> {
99    let mut router = ToolRouter::new()
100        .with_async_tool::<ListDatabasesTool>()
101        .with_async_tool::<ListTablesTool>()
102        .with_async_tool::<ListViewsTool>()
103        .with_async_tool::<ListTriggersTool>()
104        .with_async_tool::<ListFunctionsTool>()
105        .with_async_tool::<ListProceduresTool>()
106        .with_async_tool::<ListMaterializedViewsTool>()
107        .with_async_tool::<GetTableSchemaTool>()
108        .with_async_tool::<ReadQueryTool>()
109        .with_async_tool::<ExplainQueryTool>();
110
111    if !read_only {
112        router = router
113            .with_async_tool::<CreateDatabaseTool>()
114            .with_async_tool::<DropDatabaseTool>()
115            .with_async_tool::<DropTableTool>()
116            .with_async_tool::<WriteQueryTool>();
117    }
118    router
119}
120
121impl ServerHandler for PostgresHandler {
122    fn get_info(&self) -> ServerInfo {
123        let mut info = server_info();
124        info.server_info.description = Some(DESCRIPTION.into());
125        info.instructions = Some(INSTRUCTIONS.into());
126        info
127    }
128
129    async fn call_tool(
130        &self,
131        request: CallToolRequestParams,
132        context: RequestContext<RoleServer>,
133    ) -> Result<CallToolResult, ErrorData> {
134        let tcc = ToolCallContext::new(self, request, context);
135        self.tool_router.call(tcc).await
136    }
137
138    async fn list_tools(
139        &self,
140        _request: Option<PaginatedRequestParams>,
141        _context: RequestContext<RoleServer>,
142    ) -> Result<ListToolsResult, ErrorData> {
143        Ok(ListToolsResult {
144            tools: self.tool_router.list_all(),
145            next_cursor: None,
146            meta: None,
147        })
148    }
149
150    fn get_tool(&self, name: &str) -> Option<Tool> {
151        self.tool_router.get(name).cloned()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use dbmcp_config::DatabaseBackend;
159
160    fn base_config() -> DatabaseConfig {
161        DatabaseConfig {
162            backend: DatabaseBackend::Postgres,
163            host: "pg.example.com".into(),
164            port: 5433,
165            user: "pgadmin".into(),
166            password: Some("pgpass".into()),
167            name: Some("mydb".into()),
168            ..DatabaseConfig::default()
169        }
170    }
171
172    fn handler(read_only: bool) -> PostgresHandler {
173        PostgresHandler::new(&DatabaseConfig {
174            read_only,
175            ..base_config()
176        })
177    }
178
179    #[tokio::test]
180    async fn handler_exposes_connection_default_db() {
181        let handler = PostgresHandler::new(&base_config());
182        assert_eq!(handler.connection.default_database_name(), "mydb");
183    }
184
185    #[tokio::test]
186    async fn router_exposes_all_fourteen_tools_in_read_write_mode() {
187        let router = handler(false).tool_router;
188        for name in [
189            "listDatabases",
190            "listTables",
191            "listViews",
192            "listTriggers",
193            "listFunctions",
194            "listProcedures",
195            "listMaterializedViews",
196            "getTableSchema",
197            "readQuery",
198            "explainQuery",
199            "createDatabase",
200            "dropDatabase",
201            "dropTable",
202            "writeQuery",
203        ] {
204            assert!(router.has_route(name), "missing tool: {name}");
205        }
206    }
207
208    #[tokio::test]
209    async fn router_hides_write_tools_in_read_only_mode() {
210        let router = handler(true).tool_router;
211        assert!(router.has_route("listDatabases"));
212        assert!(router.has_route("listTables"));
213        assert!(router.has_route("listViews"));
214        assert!(router.has_route("listTriggers"));
215        assert!(router.has_route("listFunctions"));
216        assert!(router.has_route("listProcedures"));
217        assert!(router.has_route("listMaterializedViews"));
218        assert!(router.has_route("getTableSchema"));
219        assert!(router.has_route("readQuery"));
220        assert!(router.has_route("explainQuery"));
221        assert!(!router.has_route("writeQuery"));
222        assert!(!router.has_route("createDatabase"));
223        assert!(!router.has_route("dropDatabase"));
224        assert!(!router.has_route("dropTable"));
225    }
226}