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 = include_str!("../assets/instructions.md");
28
29const INSTRUCTIONS_READ_ONLY: &str = include_str!("../assets/instructions.readonly.md");
31
32#[derive(Clone)]
37pub struct SqliteHandler {
38 pub(crate) config: DatabaseConfig,
39 pub(crate) connection: SqliteConnection,
40 pub(crate) redactor: Option<Redactor>,
41 tool_router: ToolRouter<Self>,
42}
43
44impl std::fmt::Debug for SqliteHandler {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("SqliteHandler")
47 .field("read_only", &self.config.read_only)
48 .field("redact_pii", &self.redactor.is_some())
49 .field("connection", &self.connection)
50 .finish_non_exhaustive()
51 }
52}
53
54impl SqliteHandler {
55 #[must_use]
60 pub fn new(config: &Config) -> Self {
61 Self {
62 config: config.database.clone(),
63 connection: SqliteConnection::new(&config.database),
64 redactor: Redactor::from_config(&config.pii),
65 tool_router: build_tool_router(config.database.read_only),
66 }
67 }
68}
69
70impl From<SqliteHandler> for Server {
71 fn from(handler: SqliteHandler) -> Self {
73 Self::new(handler)
74 }
75}
76
77fn build_tool_router(read_only: bool) -> ToolRouter<SqliteHandler> {
79 let mut router = ToolRouter::new()
80 .with_async_tool::<ListTablesTool>()
81 .with_async_tool::<ListViewsTool>()
82 .with_async_tool::<ListTriggersTool>()
83 .with_async_tool::<ReadQueryTool>()
84 .with_async_tool::<ExplainQueryTool>();
85
86 if !read_only {
87 router = router
88 .with_async_tool::<WriteQueryTool>()
89 .with_async_tool::<DropTableTool>();
90 }
91 router
92}
93
94impl ServerHandler for SqliteHandler {
95 fn get_info(&self) -> ServerInfo {
96 let mut info = server_info();
97 info.server_info.description = Some(DESCRIPTION.into());
98 info.instructions = Some(if self.config.read_only {
99 INSTRUCTIONS_READ_ONLY.into()
100 } else {
101 INSTRUCTIONS.into()
102 });
103 info
104 }
105
106 async fn call_tool(
107 &self,
108 request: CallToolRequestParams,
109 context: RequestContext<RoleServer>,
110 ) -> Result<CallToolResult, ErrorData> {
111 let tcc = ToolCallContext::new(self, request, context);
112 self.tool_router.call(tcc).await
113 }
114
115 async fn list_tools(
116 &self,
117 _request: Option<PaginatedRequestParams>,
118 _context: RequestContext<RoleServer>,
119 ) -> Result<ListToolsResult, ErrorData> {
120 Ok(ListToolsResult {
121 tools: self.tool_router.list_all(),
122 next_cursor: None,
123 meta: None,
124 })
125 }
126
127 fn get_tool(&self, name: &str) -> Option<Tool> {
128 self.tool_router.get(name).cloned()
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use dbmcp_config::DatabaseBackend;
136
137 fn handler(read_only: bool) -> SqliteHandler {
138 SqliteHandler::new(&Config {
139 database: DatabaseConfig {
140 backend: DatabaseBackend::Sqlite,
141 name: Some(":memory:".into()),
142 read_only,
143 ..DatabaseConfig::default()
144 },
145 http: None,
146 pii: dbmcp_config::PiiConfig::default(),
147 })
148 }
149
150 #[tokio::test]
151 async fn router_exposes_all_seven_tools_in_read_write_mode() {
152 let router = handler(false).tool_router;
153 for name in [
154 "listTables",
155 "listViews",
156 "listTriggers",
157 "dropTable",
158 "readQuery",
159 "writeQuery",
160 "explainQuery",
161 ] {
162 assert!(router.has_route(name), "missing tool: {name}");
163 }
164 }
165
166 #[tokio::test]
167 async fn router_excludes_get_table_schema() {
168 for read_only in [false, true] {
171 let router = handler(read_only).tool_router;
172 assert!(
173 !router.has_route("getTableSchema"),
174 "getTableSchema must be absent (read_only={read_only})"
175 );
176 }
177 }
178
179 #[tokio::test]
180 async fn router_does_not_advertise_backend_specific_tools() {
181 let router = handler(false).tool_router;
182 for absent in [
183 "listDatabases",
184 "listFunctions",
185 "listProcedures",
186 "listMaterializedViews",
187 "createDatabase",
188 "dropDatabase",
189 ] {
190 assert!(!router.has_route(absent), "SQLite must not advertise {absent}");
191 }
192 }
193
194 #[tokio::test]
195 async fn router_hides_write_tools_in_read_only_mode() {
196 let router = handler(true).tool_router;
197 assert!(router.has_route("listTables"));
198 assert!(router.has_route("listViews"));
199 assert!(router.has_route("listTriggers"));
200 assert!(router.has_route("readQuery"));
201 assert!(router.has_route("explainQuery"));
202 assert!(!router.has_route("writeQuery"));
203 assert!(!router.has_route("dropTable"));
204 }
205
206 #[tokio::test]
207 async fn instructions_match_read_only_mode() {
208 let read_write = handler(false).get_info().instructions.expect("instructions present");
209 assert!(
210 read_write.contains("writeQuery"),
211 "read-write instructions mention writeQuery"
212 );
213
214 let read_only = handler(true).get_info().instructions.expect("instructions present");
215 for tool in ["writeQuery", "dropTable"] {
216 assert!(
217 !read_only.contains(tool),
218 "read-only instructions must not mention {tool}"
219 );
220 }
221 }
222
223 #[tokio::test]
224 async fn list_tables_annotations() {
225 let router = handler(false).tool_router;
226 let tool = router.get("listTables").expect("listTables registered");
227
228 let annotations = tool.annotations.as_ref().expect("annotations present");
229 assert_eq!(annotations.read_only_hint, Some(true));
230 assert_eq!(annotations.destructive_hint, Some(false));
231 assert_eq!(annotations.idempotent_hint, Some(true));
232 assert_eq!(annotations.open_world_hint, Some(false));
233 }
234}