dbmcp_postgres/
handler.rs1use dbmcp_config::{Config, DatabaseConfig};
9use dbmcp_pii::Redactor;
10use dbmcp_server::{Server, server_info};
11use rmcp::RoleServer;
12use rmcp::handler::server::router::tool::ToolRouter;
13use rmcp::handler::server::tool::ToolCallContext;
14use rmcp::model::{CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, ServerInfo, Tool};
15use rmcp::service::RequestContext;
16use rmcp::{ErrorData, ServerHandler};
17
18use crate::connection::PostgresConnection;
19
20use crate::tools::{
21 CreateDatabaseTool, DropDatabaseTool, DropTableTool, ExplainQueryTool, ListDatabasesTool, ListFunctionsTool,
22 ListMaterializedViewsTool, ListProceduresTool, ListTablesTool, ListTriggersTool, ListViewsTool, ReadQueryTool,
23 WriteQueryTool,
24};
25
26const DESCRIPTION: &str = "Database MCP Server for PostgreSQL";
28
29const INSTRUCTIONS: &str = include_str!("../assets/instructions.md");
31
32const INSTRUCTIONS_READ_ONLY: &str = include_str!("../assets/instructions.readonly.md");
34
35#[derive(Clone)]
41pub struct PostgresHandler {
42 pub(crate) config: DatabaseConfig,
43 pub(crate) connection: PostgresConnection,
44 pub(crate) redactor: Option<Redactor>,
45 tool_router: ToolRouter<Self>,
46}
47
48impl std::fmt::Debug for PostgresHandler {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("PostgresHandler")
51 .field("read_only", &self.config.read_only)
52 .field("redact_pii", &self.redactor.is_some())
53 .field("connection", &self.connection)
54 .finish_non_exhaustive()
55 }
56}
57
58impl PostgresHandler {
59 #[must_use]
65 pub fn new(config: &Config) -> Self {
66 Self {
67 config: config.database.clone(),
68 connection: PostgresConnection::new(&config.database),
69 redactor: Redactor::from_config(&config.pii),
70 tool_router: build_tool_router(config.database.read_only),
71 }
72 }
73}
74
75impl From<PostgresHandler> for Server {
76 fn from(handler: PostgresHandler) -> Self {
78 Self::new(handler)
79 }
80}
81
82fn build_tool_router(read_only: bool) -> ToolRouter<PostgresHandler> {
84 let mut router = ToolRouter::new()
85 .with_async_tool::<ListDatabasesTool>()
86 .with_async_tool::<ListTablesTool>()
87 .with_async_tool::<ListViewsTool>()
88 .with_async_tool::<ListTriggersTool>()
89 .with_async_tool::<ListFunctionsTool>()
90 .with_async_tool::<ListProceduresTool>()
91 .with_async_tool::<ListMaterializedViewsTool>()
92 .with_async_tool::<ReadQueryTool>()
93 .with_async_tool::<ExplainQueryTool>();
94
95 if !read_only {
96 router = router
97 .with_async_tool::<CreateDatabaseTool>()
98 .with_async_tool::<DropDatabaseTool>()
99 .with_async_tool::<DropTableTool>()
100 .with_async_tool::<WriteQueryTool>();
101 }
102 router
103}
104
105impl ServerHandler for PostgresHandler {
106 fn get_info(&self) -> ServerInfo {
107 let mut info = server_info();
108 info.server_info.description = Some(DESCRIPTION.into());
109 info.instructions = Some(if self.config.read_only {
110 INSTRUCTIONS_READ_ONLY.into()
111 } else {
112 INSTRUCTIONS.into()
113 });
114 info
115 }
116
117 async fn call_tool(
118 &self,
119 request: CallToolRequestParams,
120 context: RequestContext<RoleServer>,
121 ) -> Result<CallToolResult, ErrorData> {
122 let tcc = ToolCallContext::new(self, request, context);
123 self.tool_router.call(tcc).await
124 }
125
126 async fn list_tools(
127 &self,
128 _request: Option<PaginatedRequestParams>,
129 _context: RequestContext<RoleServer>,
130 ) -> Result<ListToolsResult, ErrorData> {
131 Ok(ListToolsResult {
132 tools: self.tool_router.list_all(),
133 next_cursor: None,
134 meta: None,
135 })
136 }
137
138 fn get_tool(&self, name: &str) -> Option<Tool> {
139 self.tool_router.get(name).cloned()
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use dbmcp_config::DatabaseBackend;
147
148 fn base_config() -> DatabaseConfig {
149 DatabaseConfig {
150 backend: DatabaseBackend::Postgres,
151 host: "pg.example.com".into(),
152 port: 5433,
153 user: "pgadmin".into(),
154 password: Some("pgpass".into()),
155 name: Some("mydb".into()),
156 ..DatabaseConfig::default()
157 }
158 }
159
160 fn handler(read_only: bool) -> PostgresHandler {
161 PostgresHandler::new(&Config {
162 database: DatabaseConfig {
163 read_only,
164 ..base_config()
165 },
166 http: None,
167 pii: dbmcp_config::PiiConfig::default(),
168 })
169 }
170
171 #[tokio::test]
172 async fn handler_exposes_connection_default_db() {
173 let handler = PostgresHandler::new(&Config {
174 database: base_config(),
175 http: None,
176 pii: dbmcp_config::PiiConfig::default(),
177 });
178 assert_eq!(handler.connection.default_database_name(), "mydb");
179 }
180
181 #[tokio::test]
182 async fn router_exposes_all_thirteen_tools_in_read_write_mode() {
183 let router = handler(false).tool_router;
184 for name in [
185 "listDatabases",
186 "listTables",
187 "listViews",
188 "listTriggers",
189 "listFunctions",
190 "listProcedures",
191 "listMaterializedViews",
192 "readQuery",
193 "explainQuery",
194 "createDatabase",
195 "dropDatabase",
196 "dropTable",
197 "writeQuery",
198 ] {
199 assert!(router.has_route(name), "missing tool: {name}");
200 }
201 }
202
203 #[tokio::test]
204 async fn router_hides_write_tools_in_read_only_mode() {
205 let router = handler(true).tool_router;
206 assert!(router.has_route("listDatabases"));
207 assert!(router.has_route("listTables"));
208 assert!(router.has_route("listViews"));
209 assert!(router.has_route("listTriggers"));
210 assert!(router.has_route("listFunctions"));
211 assert!(router.has_route("listProcedures"));
212 assert!(router.has_route("listMaterializedViews"));
213 assert!(router.has_route("readQuery"));
214 assert!(router.has_route("explainQuery"));
215 assert!(!router.has_route("writeQuery"));
216 assert!(!router.has_route("createDatabase"));
217 assert!(!router.has_route("dropDatabase"));
218 assert!(!router.has_route("dropTable"));
219 }
220
221 #[tokio::test]
222 async fn instructions_match_read_only_mode() {
223 let read_write = handler(false).get_info().instructions.expect("instructions present");
224 assert!(
225 read_write.contains("writeQuery"),
226 "read-write instructions mention writeQuery"
227 );
228
229 let read_only = handler(true).get_info().instructions.expect("instructions present");
230 for tool in ["writeQuery", "createDatabase", "dropDatabase", "dropTable"] {
231 assert!(
232 !read_only.contains(tool),
233 "read-only instructions must not mention {tool}"
234 );
235 }
236 }
237
238 #[tokio::test]
239 async fn router_does_not_expose_get_table_schema() {
240 let rw = handler(false).tool_router;
243 let ro = handler(true).tool_router;
244 assert!(
245 !rw.has_route("getTableSchema"),
246 "read-write router must not expose getTableSchema"
247 );
248 assert!(
249 !ro.has_route("getTableSchema"),
250 "read-only router must not expose getTableSchema"
251 );
252 }
253}