Skip to main content

database_mcp_mysql/
handler.rs

1//! MySQL/MariaDB handler: composes a [`MysqlConnection`] with the MCP tool router.
2//!
3//! All pool ownership and pool initialization logic lives in the
4//! [`MysqlConnection`]. This module exposes the MCP `ServerHandler`
5//! surface and a small set of thin delegators that per-tool
6//! 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::MysqlConnection;
18use crate::tools::{
19    CreateDatabaseTool, DropDatabaseTool, DropTableTool, ExplainQueryTool, GetTableSchemaTool, ListDatabasesTool,
20    ListTablesTool, ReadQueryTool, WriteQueryTool,
21};
22
23/// Backend-specific description for MySQL/MariaDB.
24const DESCRIPTION: &str = "Database MCP Server for MySQL and MariaDB";
25
26/// Backend-specific instructions for MySQL/MariaDB.
27const INSTRUCTIONS: &str = r"## Workflow
28
291. Call `listDatabases` to discover available databases.
302. Call `listTables` with a `database` to see its tables.
313. Call `getTableSchema` with `database` and `table` to inspect columns, types, and foreign keys before writing queries.
324. Use `readQuery` for read-only SQL (SELECT, SHOW, DESCRIBE, USE, EXPLAIN).
335. Use `writeQuery` for data changes (INSERT, UPDATE, DELETE, CREATE, ALTER, DROP).
346. Use `explainQuery` to analyze query execution plans and diagnose slow queries.
357. Use `createDatabase` to create a new database.
368. Use `dropDatabase` to drop an existing database.
379. Use `dropTable` to remove a table from a database.
38
39## Constraints
40
41- The `writeQuery`, `createDatabase`, `dropDatabase`, and `dropTable` tools are hidden when read-only mode is active.
42- Multi-statement queries are not supported. Send one statement per request.";
43
44/// MySQL/MariaDB database handler.
45///
46/// Composes one [`MysqlConnection`] (which owns the pool and
47/// the pool initialization logic) with the per-backend MCP tool router.
48#[derive(Clone)]
49pub struct MysqlHandler {
50    pub(crate) config: DatabaseConfig,
51    pub(crate) connection: MysqlConnection,
52    tool_router: ToolRouter<Self>,
53}
54
55impl std::fmt::Debug for MysqlHandler {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("MysqlHandler")
58            .field("read_only", &self.config.read_only)
59            .field("connection", &self.connection)
60            .finish_non_exhaustive()
61    }
62}
63
64impl MysqlHandler {
65    /// Creates a new `MySQL` handler.
66    ///
67    /// Constructs the [`MysqlConnection`] (which builds the
68    /// lazy pool) and the MCP tool router. No network I/O happens here.
69    #[must_use]
70    pub fn new(config: &DatabaseConfig) -> Self {
71        Self {
72            config: config.clone(),
73            connection: MysqlConnection::new(config),
74            tool_router: build_tool_router(config.read_only),
75        }
76    }
77}
78
79impl From<MysqlHandler> for Server {
80    /// Wraps a [`MysqlHandler`] in the type-erased MCP server.
81    fn from(handler: MysqlHandler) -> Self {
82        Self::new(handler)
83    }
84}
85
86/// Builds the tool router, including write tools only when not in read-only mode.
87fn build_tool_router(read_only: bool) -> ToolRouter<MysqlHandler> {
88    let mut router = ToolRouter::new()
89        .with_async_tool::<ListDatabasesTool>()
90        .with_async_tool::<ListTablesTool>()
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::<CreateDatabaseTool>()
98            .with_async_tool::<DropDatabaseTool>()
99            .with_async_tool::<DropTableTool>()
100            .with_async_tool::<WriteQueryTool>();
101    }
102    router
103}
104
105impl ServerHandler for MysqlHandler {
106    fn get_info(&self) -> ServerInfo {
107        let mut info = server_info();
108        info.server_info.description = Some(DESCRIPTION.into());
109        info.instructions = Some(INSTRUCTIONS.into());
110        info
111    }
112
113    async fn call_tool(
114        &self,
115        request: CallToolRequestParams,
116        context: RequestContext<RoleServer>,
117    ) -> Result<CallToolResult, ErrorData> {
118        let tcc = ToolCallContext::new(self, request, context);
119        self.tool_router.call(tcc).await
120    }
121
122    async fn list_tools(
123        &self,
124        _request: Option<PaginatedRequestParams>,
125        _context: RequestContext<RoleServer>,
126    ) -> Result<ListToolsResult, ErrorData> {
127        Ok(ListToolsResult {
128            tools: self.tool_router.list_all(),
129            next_cursor: None,
130            meta: None,
131        })
132    }
133
134    fn get_tool(&self, name: &str) -> Option<Tool> {
135        self.tool_router.get(name).cloned()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use database_mcp_config::DatabaseBackend;
143
144    fn base_config() -> DatabaseConfig {
145        DatabaseConfig {
146            backend: DatabaseBackend::Mysql,
147            host: "db.example.com".into(),
148            port: 3307,
149            user: "admin".into(),
150            password: Some("s3cret".into()),
151            name: Some("mydb".into()),
152            ..DatabaseConfig::default()
153        }
154    }
155
156    fn handler(read_only: bool) -> MysqlHandler {
157        MysqlHandler::new(&DatabaseConfig {
158            read_only,
159            ..base_config()
160        })
161    }
162
163    #[tokio::test]
164    async fn router_exposes_all_nine_tools_in_read_write_mode() {
165        let router = handler(false).tool_router;
166        for name in [
167            "listDatabases",
168            "listTables",
169            "getTableSchema",
170            "readQuery",
171            "explainQuery",
172            "createDatabase",
173            "dropDatabase",
174            "dropTable",
175            "writeQuery",
176        ] {
177            assert!(router.has_route(name), "missing tool: {name}");
178        }
179    }
180
181    #[tokio::test]
182    async fn router_hides_write_tools_in_read_only_mode() {
183        let router = handler(true).tool_router;
184        assert!(router.has_route("listDatabases"));
185        assert!(router.has_route("listTables"));
186        assert!(router.has_route("getTableSchema"));
187        assert!(router.has_route("readQuery"));
188        assert!(router.has_route("explainQuery"));
189        assert!(!router.has_route("writeQuery"));
190        assert!(!router.has_route("createDatabase"));
191        assert!(!router.has_route("dropDatabase"));
192        assert!(!router.has_route("dropTable"));
193    }
194}