Skip to main content

dbmcp_sqlite/
handler.rs

1//! `SQLite` handler: composes a [`SqliteConnection`] with the MCP tool router.
2//!
3//! All pool ownership and pool initialization logic lives in the
4//! [`SqliteConnection`]. This module exposes the MCP
5//! `ServerHandler` surface and one thin delegator method that the
6//! per-tool implementations call.
7
8use 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, GetTableSchemaTool, ListTablesTool, ListTriggersTool, ListViewsTool,
20    ReadQueryTool, WriteQueryTool,
21};
22
23/// Backend-specific description for `SQLite`.
24const DESCRIPTION: &str = "Database MCP Server for SQLite";
25
26/// Backend-specific instructions for `SQLite`.
27const INSTRUCTIONS: &str = r"## Workflow
28
291. Call `listTables` to discover tables in the connected database.
302. Call `listViews` to discover views in the connected database.
313. Call `listTriggers` to discover triggers in the connected database.
324. Call `getTableSchema` with a `table` to inspect columns, types, and foreign keys before writing queries.
335. Use `readQuery` for read-only SQL (SELECT).
346. Use `writeQuery` for data changes (INSERT, UPDATE, DELETE, CREATE, ALTER, DROP).
357. Use `explainQuery` to analyze query execution plans and diagnose slow queries.
368. Use `dropTable` to remove a table from the database.
37
38## Constraints
39
40- The `writeQuery` and `dropTable` tools are hidden when read-only mode is active.
41- Multi-statement queries are not supported. Send one statement per request.";
42
43/// `SQLite` file-based database handler.
44///
45/// Composes one [`SqliteConnection`] (which owns the pool and
46/// the pool initialization logic) with the per-backend MCP tool router.
47#[derive(Clone)]
48pub struct SqliteHandler {
49    pub(crate) config: DatabaseConfig,
50    pub(crate) connection: SqliteConnection,
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("connection", &self.connection)
59            .finish_non_exhaustive()
60    }
61}
62
63impl SqliteHandler {
64    /// Creates a new `SQLite` handler.
65    ///
66    /// Constructs the [`SqliteConnection`] (which builds the
67    /// lazy pool) and the MCP tool router. No file I/O happens here.
68    #[must_use]
69    pub fn new(config: &DatabaseConfig) -> Self {
70        Self {
71            config: config.clone(),
72            connection: SqliteConnection::new(config),
73            tool_router: build_tool_router(config.read_only),
74        }
75    }
76}
77
78impl From<SqliteHandler> for Server {
79    /// Wraps a [`SqliteHandler`] in the type-erased MCP server.
80    fn from(handler: SqliteHandler) -> Self {
81        Self::new(handler)
82    }
83}
84
85/// Builds the tool router, including write tools only when not in read-only mode.
86fn build_tool_router(read_only: bool) -> ToolRouter<SqliteHandler> {
87    let mut router = ToolRouter::new()
88        .with_async_tool::<ListTablesTool>()
89        .with_async_tool::<ListViewsTool>()
90        .with_async_tool::<ListTriggersTool>()
91        .with_async_tool::<GetTableSchemaTool>()
92        .with_async_tool::<ReadQueryTool>()
93        .with_async_tool::<ExplainQueryTool>();
94
95    if !read_only {
96        router = router
97            .with_async_tool::<WriteQueryTool>()
98            .with_async_tool::<DropTableTool>();
99    }
100    router
101}
102
103impl ServerHandler for SqliteHandler {
104    fn get_info(&self) -> ServerInfo {
105        let mut info = server_info();
106        info.server_info.description = Some(DESCRIPTION.into());
107        info.instructions = Some(INSTRUCTIONS.into());
108        info
109    }
110
111    async fn call_tool(
112        &self,
113        request: CallToolRequestParams,
114        context: RequestContext<RoleServer>,
115    ) -> Result<CallToolResult, ErrorData> {
116        let tcc = ToolCallContext::new(self, request, context);
117        self.tool_router.call(tcc).await
118    }
119
120    async fn list_tools(
121        &self,
122        _request: Option<PaginatedRequestParams>,
123        _context: RequestContext<RoleServer>,
124    ) -> Result<ListToolsResult, ErrorData> {
125        Ok(ListToolsResult {
126            tools: self.tool_router.list_all(),
127            next_cursor: None,
128            meta: None,
129        })
130    }
131
132    fn get_tool(&self, name: &str) -> Option<Tool> {
133        self.tool_router.get(name).cloned()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use dbmcp_config::DatabaseBackend;
141
142    fn handler(read_only: bool) -> SqliteHandler {
143        SqliteHandler::new(&DatabaseConfig {
144            backend: DatabaseBackend::Sqlite,
145            name: Some(":memory:".into()),
146            read_only,
147            ..DatabaseConfig::default()
148        })
149    }
150
151    #[tokio::test]
152    async fn router_exposes_all_eight_tools_in_read_write_mode() {
153        let router = handler(false).tool_router;
154        for name in [
155            "listTables",
156            "listViews",
157            "listTriggers",
158            "getTableSchema",
159            "dropTable",
160            "readQuery",
161            "writeQuery",
162            "explainQuery",
163        ] {
164            assert!(router.has_route(name), "missing tool: {name}");
165        }
166    }
167
168    #[tokio::test]
169    async fn router_does_not_advertise_backend_specific_tools() {
170        let router = handler(false).tool_router;
171        for absent in [
172            "listDatabases",
173            "listFunctions",
174            "listProcedures",
175            "listMaterializedViews",
176            "createDatabase",
177            "dropDatabase",
178        ] {
179            assert!(!router.has_route(absent), "SQLite must not advertise {absent}");
180        }
181    }
182
183    #[tokio::test]
184    async fn router_hides_write_tools_in_read_only_mode() {
185        let router = handler(true).tool_router;
186        assert!(router.has_route("listTables"));
187        assert!(router.has_route("listViews"));
188        assert!(router.has_route("listTriggers"));
189        assert!(router.has_route("getTableSchema"));
190        assert!(router.has_route("readQuery"));
191        assert!(router.has_route("explainQuery"));
192        assert!(!router.has_route("writeQuery"));
193        assert!(!router.has_route("dropTable"));
194    }
195
196    #[tokio::test]
197    async fn list_tables_annotations() {
198        let router = handler(false).tool_router;
199        let tool = router.get("listTables").expect("listTables registered");
200
201        let annotations = tool.annotations.as_ref().expect("annotations present");
202        assert_eq!(annotations.read_only_hint, Some(true));
203        assert_eq!(annotations.destructive_hint, Some(false));
204        assert_eq!(annotations.idempotent_hint, Some(true));
205        assert_eq!(annotations.open_world_hint, Some(false));
206    }
207}