1use 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::SqliteConnection;
19use crate::tools::{
20 DropTableTool, ExplainQueryTool, ListTablesTool, ListTriggersTool, ListViewsTool, ReadQueryTool, WriteQueryTool,
21};
22
23const DESCRIPTION: &str = "Database MCP Server for SQLite";
25
26const INSTRUCTIONS: &str = r"## Workflow
28
291. Call `listTables` to discover tables in the connected database. Pass `search` to filter by name (case-insensitive substring). Pass `detailed: true` to get columns, constraints, indexes, and triggers in the same call — this supersedes the legacy `getTableSchema` workflow.
302. Call `listViews` to discover views in the connected database.
313. Call `listTriggers` to discover triggers in the connected database.
324. Use `readQuery` for read-only SQL (SELECT).
335. Use `writeQuery` for data changes (INSERT, UPDATE, DELETE, CREATE, ALTER, DROP).
346. Use `explainQuery` to analyze query execution plans and diagnose slow queries.
357. Use `dropTable` to remove a table from the database.
36
37## Constraints
38
39- The `writeQuery` and `dropTable` tools are hidden when read-only mode is active.
40- Multi-statement queries are not supported. Send one statement per request.";
41
42#[derive(Clone)]
47pub struct SqliteHandler {
48 pub(crate) config: DatabaseConfig,
49 pub(crate) connection: SqliteConnection,
50 pub(crate) redactor: Option<Redactor>,
51 tool_router: ToolRouter<Self>,
52}
53
54impl std::fmt::Debug for SqliteHandler {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("SqliteHandler")
57 .field("read_only", &self.config.read_only)
58 .field("redact_pii", &self.redactor.is_some())
59 .field("connection", &self.connection)
60 .finish_non_exhaustive()
61 }
62}
63
64impl SqliteHandler {
65 #[must_use]
70 pub fn new(config: &Config) -> Self {
71 Self {
72 config: config.database.clone(),
73 connection: SqliteConnection::new(&config.database),
74 redactor: config
75 .pii
76 .enabled
77 .then(|| Redactor::with_operator_config(config.pii.operator.into())),
78 tool_router: build_tool_router(config.database.read_only),
79 }
80 }
81
82 #[doc(hidden)]
84 pub fn set_redactor_for_test(&mut self, redactor: Option<Redactor>) {
85 self.redactor = redactor;
86 }
87}
88
89impl From<SqliteHandler> for Server {
90 fn from(handler: SqliteHandler) -> Self {
92 Self::new(handler)
93 }
94}
95
96fn build_tool_router(read_only: bool) -> ToolRouter<SqliteHandler> {
98 let mut router = ToolRouter::new()
99 .with_async_tool::<ListTablesTool>()
100 .with_async_tool::<ListViewsTool>()
101 .with_async_tool::<ListTriggersTool>()
102 .with_async_tool::<ReadQueryTool>()
103 .with_async_tool::<ExplainQueryTool>();
104
105 if !read_only {
106 router = router
107 .with_async_tool::<WriteQueryTool>()
108 .with_async_tool::<DropTableTool>();
109 }
110 router
111}
112
113impl ServerHandler for SqliteHandler {
114 fn get_info(&self) -> ServerInfo {
115 let mut info = server_info();
116 info.server_info.description = Some(DESCRIPTION.into());
117 info.instructions = Some(INSTRUCTIONS.into());
118 info
119 }
120
121 async fn call_tool(
122 &self,
123 request: CallToolRequestParams,
124 context: RequestContext<RoleServer>,
125 ) -> Result<CallToolResult, ErrorData> {
126 let tcc = ToolCallContext::new(self, request, context);
127 self.tool_router.call(tcc).await
128 }
129
130 async fn list_tools(
131 &self,
132 _request: Option<PaginatedRequestParams>,
133 _context: RequestContext<RoleServer>,
134 ) -> Result<ListToolsResult, ErrorData> {
135 Ok(ListToolsResult {
136 tools: self.tool_router.list_all(),
137 next_cursor: None,
138 meta: None,
139 })
140 }
141
142 fn get_tool(&self, name: &str) -> Option<Tool> {
143 self.tool_router.get(name).cloned()
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use dbmcp_config::DatabaseBackend;
151
152 fn handler(read_only: bool) -> SqliteHandler {
153 SqliteHandler::new(&Config {
154 database: DatabaseConfig {
155 backend: DatabaseBackend::Sqlite,
156 name: Some(":memory:".into()),
157 read_only,
158 ..DatabaseConfig::default()
159 },
160 http: None,
161 pii: dbmcp_config::PiiConfig::default(),
162 })
163 }
164
165 #[tokio::test]
166 async fn router_exposes_all_seven_tools_in_read_write_mode() {
167 let router = handler(false).tool_router;
168 for name in [
169 "listTables",
170 "listViews",
171 "listTriggers",
172 "dropTable",
173 "readQuery",
174 "writeQuery",
175 "explainQuery",
176 ] {
177 assert!(router.has_route(name), "missing tool: {name}");
178 }
179 }
180
181 #[tokio::test]
182 async fn router_excludes_get_table_schema() {
183 for read_only in [false, true] {
186 let router = handler(read_only).tool_router;
187 assert!(
188 !router.has_route("getTableSchema"),
189 "getTableSchema must be absent (read_only={read_only})"
190 );
191 }
192 }
193
194 #[tokio::test]
195 async fn router_does_not_advertise_backend_specific_tools() {
196 let router = handler(false).tool_router;
197 for absent in [
198 "listDatabases",
199 "listFunctions",
200 "listProcedures",
201 "listMaterializedViews",
202 "createDatabase",
203 "dropDatabase",
204 ] {
205 assert!(!router.has_route(absent), "SQLite must not advertise {absent}");
206 }
207 }
208
209 #[tokio::test]
210 async fn router_hides_write_tools_in_read_only_mode() {
211 let router = handler(true).tool_router;
212 assert!(router.has_route("listTables"));
213 assert!(router.has_route("listViews"));
214 assert!(router.has_route("listTriggers"));
215 assert!(router.has_route("readQuery"));
216 assert!(router.has_route("explainQuery"));
217 assert!(!router.has_route("writeQuery"));
218 assert!(!router.has_route("dropTable"));
219 }
220
221 #[tokio::test]
222 async fn list_tables_annotations() {
223 let router = handler(false).tool_router;
224 let tool = router.get("listTables").expect("listTables registered");
225
226 let annotations = tool.annotations.as_ref().expect("annotations present");
227 assert_eq!(annotations.read_only_hint, Some(true));
228 assert_eq!(annotations.destructive_hint, Some(false));
229 assert_eq!(annotations.idempotent_hint, Some(true));
230 assert_eq!(annotations.open_world_hint, Some(false));
231 }
232}