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::{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
23/// Backend-specific description for `SQLite`.
24const DESCRIPTION: &str = "Database MCP Server for SQLite";
25
26/// Backend-specific instructions for `SQLite` in read-write mode.
27const INSTRUCTIONS: &str = include_str!("../assets/instructions.md");
28
29/// Backend-specific instructions for `SQLite` in read-only mode.
30const INSTRUCTIONS_READ_ONLY: &str = include_str!("../assets/instructions.readonly.md");
31
32/// `SQLite` file-based database handler.
33///
34/// Composes one [`SqliteConnection`] (which owns the pool and
35/// the pool initialization logic) with the per-backend MCP tool router.
36#[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    /// Creates a new `SQLite` handler.
56    ///
57    /// Constructs the [`SqliteConnection`] (which builds the
58    /// lazy pool) and the MCP tool router. No file I/O happens here.
59    #[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    /// Wraps a [`SqliteHandler`] in the type-erased MCP server.
72    fn from(handler: SqliteHandler) -> Self {
73        Self::new(handler)
74    }
75}
76
77/// Builds the tool router, including write tools only when not in read-only mode.
78fn 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        // Spec 046 US4: `getTableSchema` is retired on SQLite. Both read-only and
169        // read-write catalogues must no longer advertise it.
170        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}