1use dbmcp_config::DatabaseConfig;
9use dbmcp_server::{Server, server_info};
10use rmcp::RoleServer;
11use rmcp::handler::server::router::tool::ToolRouter;
12use rmcp::handler::server::tool::ToolCallContext;
13use rmcp::model::{CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, ServerInfo, Tool};
14use rmcp::service::RequestContext;
15use rmcp::{ErrorData, ServerHandler};
16
17use crate::connection::SqliteConnection;
18use crate::tools::{
19 DropTableTool, ExplainQueryTool, ListTablesTool, ListTriggersTool, ListViewsTool, ReadQueryTool, WriteQueryTool,
20};
21
22const DESCRIPTION: &str = "Database MCP Server for SQLite";
24
25const INSTRUCTIONS: &str = r"## Workflow
27
281. 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.
292. Call `listViews` to discover views in the connected database.
303. Call `listTriggers` to discover triggers in the connected database.
314. Use `readQuery` for read-only SQL (SELECT).
325. Use `writeQuery` for data changes (INSERT, UPDATE, DELETE, CREATE, ALTER, DROP).
336. Use `explainQuery` to analyze query execution plans and diagnose slow queries.
347. Use `dropTable` to remove a table from the database.
35
36## Constraints
37
38- The `writeQuery` and `dropTable` tools are hidden when read-only mode is active.
39- Multi-statement queries are not supported. Send one statement per request.";
40
41#[derive(Clone)]
46pub struct SqliteHandler {
47 pub(crate) config: DatabaseConfig,
48 pub(crate) connection: SqliteConnection,
49 tool_router: ToolRouter<Self>,
50}
51
52impl std::fmt::Debug for SqliteHandler {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("SqliteHandler")
55 .field("read_only", &self.config.read_only)
56 .field("connection", &self.connection)
57 .finish_non_exhaustive()
58 }
59}
60
61impl SqliteHandler {
62 #[must_use]
67 pub fn new(config: &DatabaseConfig) -> Self {
68 Self {
69 config: config.clone(),
70 connection: SqliteConnection::new(config),
71 tool_router: build_tool_router(config.read_only),
72 }
73 }
74}
75
76impl From<SqliteHandler> for Server {
77 fn from(handler: SqliteHandler) -> Self {
79 Self::new(handler)
80 }
81}
82
83fn build_tool_router(read_only: bool) -> ToolRouter<SqliteHandler> {
85 let mut router = ToolRouter::new()
86 .with_async_tool::<ListTablesTool>()
87 .with_async_tool::<ListViewsTool>()
88 .with_async_tool::<ListTriggersTool>()
89 .with_async_tool::<ReadQueryTool>()
90 .with_async_tool::<ExplainQueryTool>();
91
92 if !read_only {
93 router = router
94 .with_async_tool::<WriteQueryTool>()
95 .with_async_tool::<DropTableTool>();
96 }
97 router
98}
99
100impl ServerHandler for SqliteHandler {
101 fn get_info(&self) -> ServerInfo {
102 let mut info = server_info();
103 info.server_info.description = Some(DESCRIPTION.into());
104 info.instructions = Some(INSTRUCTIONS.into());
105 info
106 }
107
108 async fn call_tool(
109 &self,
110 request: CallToolRequestParams,
111 context: RequestContext<RoleServer>,
112 ) -> Result<CallToolResult, ErrorData> {
113 let tcc = ToolCallContext::new(self, request, context);
114 self.tool_router.call(tcc).await
115 }
116
117 async fn list_tools(
118 &self,
119 _request: Option<PaginatedRequestParams>,
120 _context: RequestContext<RoleServer>,
121 ) -> Result<ListToolsResult, ErrorData> {
122 Ok(ListToolsResult {
123 tools: self.tool_router.list_all(),
124 next_cursor: None,
125 meta: None,
126 })
127 }
128
129 fn get_tool(&self, name: &str) -> Option<Tool> {
130 self.tool_router.get(name).cloned()
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use dbmcp_config::DatabaseBackend;
138
139 fn handler(read_only: bool) -> SqliteHandler {
140 SqliteHandler::new(&DatabaseConfig {
141 backend: DatabaseBackend::Sqlite,
142 name: Some(":memory:".into()),
143 read_only,
144 ..DatabaseConfig::default()
145 })
146 }
147
148 #[tokio::test]
149 async fn router_exposes_all_seven_tools_in_read_write_mode() {
150 let router = handler(false).tool_router;
151 for name in [
152 "listTables",
153 "listViews",
154 "listTriggers",
155 "dropTable",
156 "readQuery",
157 "writeQuery",
158 "explainQuery",
159 ] {
160 assert!(router.has_route(name), "missing tool: {name}");
161 }
162 }
163
164 #[tokio::test]
165 async fn router_excludes_get_table_schema() {
166 for read_only in [false, true] {
169 let router = handler(read_only).tool_router;
170 assert!(
171 !router.has_route("getTableSchema"),
172 "getTableSchema must be absent (read_only={read_only})"
173 );
174 }
175 }
176
177 #[tokio::test]
178 async fn router_does_not_advertise_backend_specific_tools() {
179 let router = handler(false).tool_router;
180 for absent in [
181 "listDatabases",
182 "listFunctions",
183 "listProcedures",
184 "listMaterializedViews",
185 "createDatabase",
186 "dropDatabase",
187 ] {
188 assert!(!router.has_route(absent), "SQLite must not advertise {absent}");
189 }
190 }
191
192 #[tokio::test]
193 async fn router_hides_write_tools_in_read_only_mode() {
194 let router = handler(true).tool_router;
195 assert!(router.has_route("listTables"));
196 assert!(router.has_route("listViews"));
197 assert!(router.has_route("listTriggers"));
198 assert!(router.has_route("readQuery"));
199 assert!(router.has_route("explainQuery"));
200 assert!(!router.has_route("writeQuery"));
201 assert!(!router.has_route("dropTable"));
202 }
203
204 #[tokio::test]
205 async fn list_tables_annotations() {
206 let router = handler(false).tool_router;
207 let tool = router.get("listTables").expect("listTables registered");
208
209 let annotations = tool.annotations.as_ref().expect("annotations present");
210 assert_eq!(annotations.read_only_hint, Some(true));
211 assert_eq!(annotations.destructive_hint, Some(false));
212 assert_eq!(annotations.idempotent_hint, Some(true));
213 assert_eq!(annotations.open_world_hint, Some(false));
214 }
215}