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, ListTablesTool, ListTriggersTool, ListViewsTool, 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. 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/// `SQLite` file-based database handler.
42///
43/// Composes one [`SqliteConnection`] (which owns the pool and
44/// the pool initialization logic) with the per-backend MCP tool router.
45#[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    /// Creates a new `SQLite` handler.
63    ///
64    /// Constructs the [`SqliteConnection`] (which builds the
65    /// lazy pool) and the MCP tool router. No file I/O happens here.
66    #[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    /// Wraps a [`SqliteHandler`] in the type-erased MCP server.
78    fn from(handler: SqliteHandler) -> Self {
79        Self::new(handler)
80    }
81}
82
83/// Builds the tool router, including write tools only when not in read-only mode.
84fn 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        // Spec 046 US4: `getTableSchema` is retired on SQLite. Both read-only and
167        // read-write catalogues must no longer advertise it.
168        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}