Skip to main content

database_mcp_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 database_mcp_config::DatabaseConfig;
9use database_mcp_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, ReadQueryTool, WriteQueryTool,
20};
21
22/// Backend-specific description for `SQLite`.
23const DESCRIPTION: &str = "Database MCP Server for SQLite";
24
25/// Backend-specific instructions for `SQLite`.
26const INSTRUCTIONS: &str = r"## Workflow
27
281. Call `listTables` to discover tables in the connected database.
292. Call `getTableSchema` with a `table` to inspect columns, types, and foreign keys before writing queries.
303. Use `readQuery` for read-only SQL (SELECT).
314. Use `writeQuery` for data changes (INSERT, UPDATE, DELETE, CREATE, ALTER, DROP).
325. Use `explainQuery` to analyze query execution plans and diagnose slow queries.
336. Use `dropTable` to remove a table from the database.
34
35## Constraints
36
37- The `writeQuery` and `dropTable` tools are hidden when read-only mode is active.
38- Multi-statement queries are not supported. Send one statement per request.";
39
40/// `SQLite` file-based database handler.
41///
42/// Composes one [`SqliteConnection`] (which owns the pool and
43/// the pool initialization logic) with the per-backend MCP tool router.
44#[derive(Clone)]
45pub struct SqliteHandler {
46    pub(crate) config: DatabaseConfig,
47    pub(crate) connection: SqliteConnection,
48    tool_router: ToolRouter<Self>,
49}
50
51impl std::fmt::Debug for SqliteHandler {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("SqliteHandler")
54            .field("read_only", &self.config.read_only)
55            .field("connection", &self.connection)
56            .finish_non_exhaustive()
57    }
58}
59
60impl SqliteHandler {
61    /// Creates a new `SQLite` handler.
62    ///
63    /// Constructs the [`SqliteConnection`] (which builds the
64    /// lazy pool) and the MCP tool router. No file I/O happens here.
65    #[must_use]
66    pub fn new(config: &DatabaseConfig) -> Self {
67        Self {
68            config: config.clone(),
69            connection: SqliteConnection::new(config),
70            tool_router: build_tool_router(config.read_only),
71        }
72    }
73}
74
75impl From<SqliteHandler> for Server {
76    /// Wraps a [`SqliteHandler`] in the type-erased MCP server.
77    fn from(handler: SqliteHandler) -> Self {
78        Self::new(handler)
79    }
80}
81
82/// Builds the tool router, including write tools only when not in read-only mode.
83fn build_tool_router(read_only: bool) -> ToolRouter<SqliteHandler> {
84    let mut router = ToolRouter::new()
85        .with_async_tool::<ListTablesTool>()
86        .with_async_tool::<GetTableSchemaTool>()
87        .with_async_tool::<ReadQueryTool>()
88        .with_async_tool::<ExplainQueryTool>();
89
90    if !read_only {
91        router = router
92            .with_async_tool::<WriteQueryTool>()
93            .with_async_tool::<DropTableTool>();
94    }
95    router
96}
97
98impl ServerHandler for SqliteHandler {
99    fn get_info(&self) -> ServerInfo {
100        let mut info = server_info();
101        info.server_info.description = Some(DESCRIPTION.into());
102        info.instructions = Some(INSTRUCTIONS.into());
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 database_mcp_config::DatabaseBackend;
136
137    fn handler(read_only: bool) -> SqliteHandler {
138        SqliteHandler::new(&DatabaseConfig {
139            backend: DatabaseBackend::Sqlite,
140            name: Some(":memory:".into()),
141            read_only,
142            ..DatabaseConfig::default()
143        })
144    }
145
146    #[tokio::test]
147    async fn router_exposes_all_six_tools_in_read_write_mode() {
148        let router = handler(false).tool_router;
149        for name in [
150            "listTables",
151            "getTableSchema",
152            "dropTable",
153            "readQuery",
154            "writeQuery",
155            "explainQuery",
156        ] {
157            assert!(router.has_route(name), "missing tool: {name}");
158        }
159    }
160
161    #[tokio::test]
162    async fn router_hides_write_tools_in_read_only_mode() {
163        let router = handler(true).tool_router;
164        assert!(router.has_route("listTables"));
165        assert!(router.has_route("getTableSchema"));
166        assert!(router.has_route("readQuery"));
167        assert!(router.has_route("explainQuery"));
168        assert!(!router.has_route("writeQuery"));
169        assert!(!router.has_route("dropTable"));
170    }
171
172    #[tokio::test]
173    async fn list_tables_annotations() {
174        let router = handler(false).tool_router;
175        let tool = router.get("listTables").expect("listTables registered");
176
177        let annotations = tool.annotations.as_ref().expect("annotations present");
178        assert_eq!(annotations.read_only_hint, Some(true));
179        assert_eq!(annotations.destructive_hint, Some(false));
180        assert_eq!(annotations.idempotent_hint, Some(true));
181        assert_eq!(annotations.open_world_hint, Some(false));
182    }
183}