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