1use dbmcp_config::{Config, DatabaseConfig};
9use dbmcp_pii::Redactor;
10use dbmcp_server::{Server, ToolRouterExt, ToolSpec, 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, ListDatabasesTool, PinnedDropTableTool, PinnedExplainQueryTool,
22 PinnedListFunctionsTool, PinnedListMaterializedViewsTool, PinnedListProceduresTool, PinnedListTablesTool,
23 PinnedListTriggersTool, PinnedListViewsTool, PinnedReadQueryTool, PinnedWriteQueryTool, UnpinnedDropTableTool,
24 UnpinnedExplainQueryTool, UnpinnedListFunctionsTool, UnpinnedListMaterializedViewsTool, UnpinnedListProceduresTool,
25 UnpinnedListTablesTool, UnpinnedListTriggersTool, UnpinnedListViewsTool, UnpinnedReadQueryTool,
26 UnpinnedWriteQueryTool,
27};
28
29const DESCRIPTION: &str = "Database MCP Server for PostgreSQL";
31
32const INSTRUCTIONS: &str = include_str!("../assets/instructions/default.md");
34
35const INSTRUCTIONS_READ_ONLY: &str = include_str!("../assets/instructions/read-only.md");
37
38const INSTRUCTIONS_PINNED: &str = include_str!("../assets/instructions/default.pinned.md");
40
41const INSTRUCTIONS_READ_ONLY_PINNED: &str = include_str!("../assets/instructions/read-only.pinned.md");
43
44const TOOLS: &[ToolSpec<PostgresHandler>] = &[
52 ToolSpec::async_tool::<ListDatabasesTool>(false, false),
53 ToolSpec::async_tool::<PinnedListTablesTool>(true, false),
54 ToolSpec::async_tool::<UnpinnedListTablesTool>(false, false),
55 ToolSpec::async_tool::<PinnedListViewsTool>(true, false),
56 ToolSpec::async_tool::<UnpinnedListViewsTool>(false, false),
57 ToolSpec::async_tool::<PinnedListTriggersTool>(true, false),
58 ToolSpec::async_tool::<UnpinnedListTriggersTool>(false, false),
59 ToolSpec::async_tool::<PinnedListFunctionsTool>(true, false),
60 ToolSpec::async_tool::<UnpinnedListFunctionsTool>(false, false),
61 ToolSpec::async_tool::<PinnedListProceduresTool>(true, false),
62 ToolSpec::async_tool::<UnpinnedListProceduresTool>(false, false),
63 ToolSpec::async_tool::<PinnedListMaterializedViewsTool>(true, false),
64 ToolSpec::async_tool::<UnpinnedListMaterializedViewsTool>(false, false),
65 ToolSpec::async_tool::<PinnedReadQueryTool>(true, false),
66 ToolSpec::async_tool::<UnpinnedReadQueryTool>(false, false),
67 ToolSpec::async_tool::<PinnedExplainQueryTool>(true, false),
68 ToolSpec::async_tool::<UnpinnedExplainQueryTool>(false, false),
69 ToolSpec::async_tool::<CreateDatabaseTool>(false, true),
70 ToolSpec::async_tool::<DropDatabaseTool>(false, true),
71 ToolSpec::async_tool::<PinnedDropTableTool>(true, true),
72 ToolSpec::async_tool::<UnpinnedDropTableTool>(false, true),
73 ToolSpec::async_tool::<PinnedWriteQueryTool>(true, true),
74 ToolSpec::async_tool::<UnpinnedWriteQueryTool>(false, true),
75];
76
77#[derive(Clone)]
83pub struct PostgresHandler {
84 pub(crate) config: DatabaseConfig,
85 pub(crate) connection: PostgresConnection,
86 pub(crate) redactor: Option<Redactor>,
87 tool_router: ToolRouter<Self>,
88}
89
90impl std::fmt::Debug for PostgresHandler {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("PostgresHandler")
93 .field("read_only", &self.config.read_only)
94 .field("redact_pii", &self.redactor.is_some())
95 .field("connection", &self.connection)
96 .finish_non_exhaustive()
97 }
98}
99
100impl PostgresHandler {
101 pub fn new(config: &Config) -> Result<Self, dbmcp_pii::RedactorInitError> {
111 Ok(Self {
112 config: config.database.clone(),
113 connection: PostgresConnection::new(&config.database),
114 redactor: Redactor::from_config(&config.pii)?,
115 tool_router: ToolRouter::from_specs(TOOLS, config.database.read_only, config.database.name.is_some()),
116 })
117 }
118}
119
120impl From<PostgresHandler> for Server {
121 fn from(handler: PostgresHandler) -> Self {
123 Self::new(handler)
124 }
125}
126
127impl ServerHandler for PostgresHandler {
128 fn get_info(&self) -> ServerInfo {
129 let mut info = server_info();
130 info.server_info.description = Some(DESCRIPTION.into());
131 info.instructions = Some(
132 match (self.config.read_only, self.config.name.is_some()) {
133 (false, false) => INSTRUCTIONS,
134 (true, false) => INSTRUCTIONS_READ_ONLY,
135 (false, true) => INSTRUCTIONS_PINNED,
136 (true, true) => INSTRUCTIONS_READ_ONLY_PINNED,
137 }
138 .into(),
139 );
140 info
141 }
142
143 async fn call_tool(
144 &self,
145 request: CallToolRequestParams,
146 context: RequestContext<RoleServer>,
147 ) -> Result<CallToolResult, ErrorData> {
148 let tcc = ToolCallContext::new(self, request, context);
149 self.tool_router.call(tcc).await
150 }
151
152 async fn list_tools(
153 &self,
154 _request: Option<PaginatedRequestParams>,
155 _context: RequestContext<RoleServer>,
156 ) -> Result<ListToolsResult, ErrorData> {
157 Ok(ListToolsResult {
158 tools: self.tool_router.list_all(),
159 next_cursor: None,
160 meta: None,
161 })
162 }
163
164 fn get_tool(&self, name: &str) -> Option<Tool> {
165 self.tool_router.get(name).cloned()
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use dbmcp_config::DatabaseBackend;
173
174 fn base_config() -> DatabaseConfig {
175 DatabaseConfig {
176 backend: DatabaseBackend::Postgres,
177 host: "pg.example.com".into(),
178 port: 5433,
179 user: "pgadmin".into(),
180 password: Some("pgpass".into()),
181 name: None,
182 ..DatabaseConfig::default()
183 }
184 }
185
186 fn handler(read_only: bool) -> PostgresHandler {
187 PostgresHandler::new(&Config {
188 database: DatabaseConfig {
189 read_only,
190 ..base_config()
191 },
192 http: None,
193 pii: dbmcp_config::PiiConfig::default(),
194 })
195 .expect("handler builds in test")
196 }
197
198 fn pinned_handler(read_only: bool) -> PostgresHandler {
200 PostgresHandler::new(&Config {
201 database: DatabaseConfig {
202 read_only,
203 name: Some("mydb".into()),
204 ..base_config()
205 },
206 http: None,
207 pii: dbmcp_config::PiiConfig::default(),
208 })
209 .expect("handler builds in test")
210 }
211
212 #[tokio::test]
213 async fn handler_exposes_connection_default_db() {
214 let handler = PostgresHandler::new(&Config {
215 database: DatabaseConfig {
216 name: Some("mydb".into()),
217 ..base_config()
218 },
219 http: None,
220 pii: dbmcp_config::PiiConfig::default(),
221 })
222 .expect("handler builds in test");
223 assert_eq!(handler.connection.default_database_name(), "mydb");
224 }
225
226 #[tokio::test]
227 async fn router_exposes_all_thirteen_tools_in_read_write_mode() {
228 let router = handler(false).tool_router;
229 for name in [
230 "listDatabases",
231 "listTables",
232 "listViews",
233 "listTriggers",
234 "listFunctions",
235 "listProcedures",
236 "listMaterializedViews",
237 "readQuery",
238 "explainQuery",
239 "createDatabase",
240 "dropDatabase",
241 "dropTable",
242 "writeQuery",
243 ] {
244 assert!(router.has_route(name), "missing tool: {name}");
245 }
246 }
247
248 #[tokio::test]
249 async fn router_hides_write_tools_in_read_only_mode() {
250 let router = handler(true).tool_router;
251 assert!(router.has_route("listDatabases"));
252 assert!(router.has_route("listTables"));
253 assert!(router.has_route("listViews"));
254 assert!(router.has_route("listTriggers"));
255 assert!(router.has_route("listFunctions"));
256 assert!(router.has_route("listProcedures"));
257 assert!(router.has_route("listMaterializedViews"));
258 assert!(router.has_route("readQuery"));
259 assert!(router.has_route("explainQuery"));
260 assert!(!router.has_route("writeQuery"));
261 assert!(!router.has_route("createDatabase"));
262 assert!(!router.has_route("dropDatabase"));
263 assert!(!router.has_route("dropTable"));
264 }
265
266 #[tokio::test]
267 async fn instructions_match_read_only_mode() {
268 let read_write = handler(false).get_info().instructions.expect("instructions present");
269 assert!(
270 read_write.contains("writeQuery"),
271 "read-write instructions mention writeQuery"
272 );
273
274 let read_only = handler(true).get_info().instructions.expect("instructions present");
275 for tool in ["writeQuery", "createDatabase", "dropDatabase", "dropTable"] {
276 assert!(
277 !read_only.contains(tool),
278 "read-only instructions must not mention {tool}"
279 );
280 }
281 }
282
283 #[tokio::test]
284 async fn router_does_not_expose_get_table_schema() {
285 let rw = handler(false).tool_router;
288 let ro = handler(true).tool_router;
289 assert!(
290 !rw.has_route("getTableSchema"),
291 "read-write router must not expose getTableSchema"
292 );
293 assert!(
294 !ro.has_route("getTableSchema"),
295 "read-only router must not expose getTableSchema"
296 );
297 }
298
299 #[tokio::test]
300 async fn router_hides_cross_database_tools_when_name_pinned() {
301 let router = pinned_handler(false).tool_router;
302 for present in ["listTables", "readQuery", "explainQuery", "dropTable", "writeQuery"] {
303 assert!(router.has_route(present), "missing tool: {present}");
304 }
305 for absent in ["listDatabases", "createDatabase", "dropDatabase"] {
306 assert!(!router.has_route(absent), "pinned router must not expose {absent}");
307 }
308 }
309
310 #[tokio::test]
311 async fn router_hides_list_databases_when_name_pinned_read_only() {
312 let router = pinned_handler(true).tool_router;
313 assert!(!router.has_route("listDatabases"));
314 assert!(!router.has_route("createDatabase"));
315 assert!(!router.has_route("dropDatabase"));
316 assert!(router.has_route("listTables"));
317 assert!(router.has_route("readQuery"));
318 }
319
320 #[tokio::test]
321 async fn instructions_match_pinned_mode() {
322 for read_only in [false, true] {
323 let instructions = pinned_handler(read_only)
324 .get_info()
325 .instructions
326 .expect("instructions present");
327 for tool in ["listDatabases", "createDatabase", "dropDatabase"] {
328 assert!(
329 !instructions.contains(tool),
330 "pinned instructions must not mention {tool} (read_only={read_only})"
331 );
332 }
333 }
334 }
335}