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