From 52afd5e969baaefa5069d36c7ea9a4884603a7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=88=E7=A7=8B?= <49514152+yiqiui@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:48:46 +0800 Subject: [PATCH] feat(mcp): declare tool annotations on all static tools --- crates/dbx-mcp/src/server.rs | 144 ++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 28 deletions(-) diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index ac1a14f310..91dcfa01b7 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -1067,7 +1067,8 @@ impl DbxMcpServer { impl DbxMcpServer { #[tool( name = "dbx_plugin_list", - description = "List DBX plugins that contribute MCP tools. Returns each plugin's id, name, tool count, and the number of connections available for its tools. Use dbx_plugin_tools with a plugin id to inspect its tools; call them through dbx_plugin_call." + description = "List DBX plugins that contribute MCP tools. Returns each plugin's id, name, tool count, and the number of connections available for its tools. Use dbx_plugin_tools with a plugin id to inspect its tools; call them through dbx_plugin_call.", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn plugin_list(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_plugin_list").await { @@ -1107,7 +1108,8 @@ impl DbxMcpServer { #[tool( name = "dbx_plugin_tools", - description = "List the MCP tools of one DBX plugin by its plugin id (from dbx_plugin_list). Returns each tool's name, description, arguments schema, read-only hint, and whether a connection must be selected. Call the tool through dbx_plugin_call." + description = "List the MCP tools of one DBX plugin by its plugin id (from dbx_plugin_list). Returns each tool's name, description, arguments schema, read-only hint, and whether a connection must be selected. Call the tool through dbx_plugin_call.", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn plugin_tools(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_plugin_tools").await { @@ -1197,7 +1199,8 @@ impl DbxMcpServer { #[tool( name = "dbx_plugin_call", - description = "Call one MCP tool of a DBX plugin by plugin id (from dbx_plugin_list) and tool name (from dbx_plugin_tools). The host binds the connection from DBX settings and generates the credentials payload; pass dbx_connection only when dbx_plugin_tools lists several allowed connections." + description = "Call one MCP tool of a DBX plugin by plugin id (from dbx_plugin_list) and tool name (from dbx_plugin_tools). The host binds the connection from DBX settings and generates the credentials payload; pass dbx_connection only when dbx_plugin_tools lists several allowed connections.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = true) )] async fn plugin_call(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_plugin_call").await { @@ -1232,7 +1235,8 @@ impl DbxMcpServer { #[tool( name = "dbx_list_connections", - description = "List database connections configured in DBX. Returns connection IDs, names, group paths, database types, endpoints, selected databases, and saved connection notes." + description = "List database connections configured in DBX. Returns connection IDs, names, group paths, database types, endpoints, selected databases, and saved connection notes.", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn list_connections( &self, @@ -1262,7 +1266,8 @@ impl DbxMcpServer { #[tool( name = "dbx_list_databases", - description = "List database names available through a DBX connection. When the connection has a database allowlist, returns only the databases allowed by DBX MCP settings; use this before listing tables if the connection has no default database." + description = "List database names available through a DBX connection. When the connection has a database allowlist, returns only the databases allowed by DBX MCP settings; use this before listing tables if the connection has no default database.", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn list_databases(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_list_databases").await { @@ -1275,7 +1280,11 @@ impl DbxMcpServer { self.list_databases_for_resolved(&resolved).await } - #[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")] + #[tool( + name = "dbx_list_tables", + description = "List tables and views for a database connection", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) + )] async fn list_tables(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_list_tables").await { return error; @@ -1312,7 +1321,11 @@ impl DbxMcpServer { } } - #[tool(name = "dbx_describe_table", description = "Get column definitions for a table")] + #[tool( + name = "dbx_describe_table", + description = "Get column definitions for a table", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) + )] async fn describe_table(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_describe_table").await { return error; @@ -1336,7 +1349,11 @@ impl DbxMcpServer { } } - #[tool(name = "dbx_list_routines", description = "List stored procedures and functions for a database schema")] + #[tool( + name = "dbx_list_routines", + description = "List stored procedures and functions for a database schema", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) + )] async fn list_routines(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_list_routines").await { return error; @@ -1367,7 +1384,11 @@ impl DbxMcpServer { } } - #[tool(name = "dbx_get_routine_source", description = "Get the full source text of a stored procedure or function")] + #[tool( + name = "dbx_get_routine_source", + description = "Get the full source text of a stored procedure or function", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) + )] async fn get_routine_source(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_get_routine_source").await { return error; @@ -1408,7 +1429,8 @@ impl DbxMcpServer { #[tool( name = "dbx_execute_query", - description = "Execute a SQL query on a database connection (default 100 rows; pass max_rows to return up to 1000 rows; larger values are clamped; MongoDB shell commands and multi-statement scripts routed through the batch executor stay at 100 rows). For backwards compatibility, multi-statement scripts and stored-procedure scripts are routed through the dialect-aware batch executor." + description = "Execute a SQL query on a database connection (default 100 rows; pass max_rows to return up to 1000 rows; larger values are clamped; MongoDB shell commands and multi-statement scripts routed through the batch executor stay at 100 rows). For backwards compatibility, multi-statement scripts and stored-procedure scripts are routed through the dialect-aware batch executor.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn execute_query_tool( &self, @@ -1736,7 +1758,8 @@ impl DbxMcpServer { #[tool( name = "dbx_execute_batch", - description = "Execute a SQL script containing multiple statements in one call and return a result per statement. Statements are split with a database-dialect-aware parser, so semicolons inside strings, comments and stored procedures are handled. Stops at the first failing statement unless continue_on_error is true. Pass session_id (from dbx_open_session) when statements must share one connection (e.g. temporary tables, USE/SET). Pass cell_char_offset/cell_char_limit to expand long string cells in every statement's result, exactly like dbx_execute_query. When use_transaction is true and the script has multiple statements, the whole script runs in one transaction and the call returns a single merged result instead of one result per statement (single-statement scripts run normally and return that one result); use_transaction cannot be combined with session_id or continue_on_error, and is rejected when the backend cannot provide a rollbackable transaction or for MySQL-family DDL scripts (DDL implicitly commits and cannot be rolled back)." + description = "Execute a SQL script containing multiple statements in one call and return a result per statement. Statements are split with a database-dialect-aware parser, so semicolons inside strings, comments and stored procedures are handled. Stops at the first failing statement unless continue_on_error is true. Pass session_id (from dbx_open_session) when statements must share one connection (e.g. temporary tables, USE/SET). Pass cell_char_offset/cell_char_limit to expand long string cells in every statement's result, exactly like dbx_execute_query. When use_transaction is true and the script has multiple statements, the whole script runs in one transaction and the call returns a single merged result instead of one result per statement (single-statement scripts run normally and return that one result); use_transaction cannot be combined with session_id or continue_on_error, and is rejected when the backend cannot provide a rollbackable transaction or for MySQL-family DDL scripts (DDL implicitly commits and cannot be rolled back).", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn execute_batch_tool( &self, @@ -2127,7 +2150,8 @@ impl DbxMcpServer { #[tool( name = "dbx_open_session", - description = "Open a stateful query session pinned to a single backend connection. Returns a session ID for dbx_execute_query: USE, SET CATALOG, session variables and temporary tables persist across calls within the session. Close with dbx_close_session when done; idle sessions expire after 30 minutes by default (configurable with DBX_SESSION_IDLE_TTL_SECS)." + description = "Open a stateful query session pinned to a single backend connection. Returns a session ID for dbx_execute_query: USE, SET CATALOG, session variables and temporary tables persist across calls within the session. Close with dbx_close_session when done; idle sessions expire after 30 minutes by default (configurable with DBX_SESSION_IDLE_TTL_SECS).", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = false) )] async fn open_session_tool( &self, @@ -2210,7 +2234,8 @@ impl DbxMcpServer { #[tool( name = "dbx_begin_transaction", - description = "Begin a transaction on a transaction-enabled native MySQL session" + description = "Begin a transaction on a transaction-enabled native MySQL session", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = false) )] async fn begin_transaction_tool( &self, @@ -2253,7 +2278,8 @@ impl DbxMcpServer { #[tool( name = "dbx_commit_transaction", - description = "Commit the active transaction; committed is reported only after MySQL acknowledges COMMIT" + description = "Commit the active transaction; committed is reported only after MySQL acknowledges COMMIT", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn commit_transaction_tool( &self, @@ -2296,7 +2322,8 @@ impl DbxMcpServer { #[tool( name = "dbx_rollback_transaction", - description = "Roll back the active transaction. Cleanup remains available after write-policy revocation." + description = "Roll back the active transaction. Cleanup remains available after write-policy revocation.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn rollback_transaction_tool( &self, @@ -2330,7 +2357,8 @@ impl DbxMcpServer { #[tool( name = "dbx_close_session", - description = "Close a stateful query session and release its pinned backend connection" + description = "Close a stateful query session and release its pinned backend connection", + annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn close_session_tool( &self, @@ -2371,7 +2399,8 @@ impl DbxMcpServer { #[tool( name = "dbx_execute_redis_command", - description = "Execute a Redis command on a Redis connection. Use the db argument to select a logical database instead of sending SELECT. Use SCAN rather than KEYS when enumerating keys." + description = "Execute a Redis command on a Redis connection. Use the db argument to select a logical database instead of sending SELECT. Use SCAN rather than KEYS when enumerating keys.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn execute_redis_command( &self, @@ -2461,7 +2490,8 @@ impl DbxMcpServer { #[tool( name = "dbx_peek_messages", - description = "Read up to 100 Kafka messages without committing consumer offsets. Defaults to the latest 20 messages. Returns JSON with base64 payloads, UTF-8 previews, metadata, incomplete (partial broker read), and outputTruncated (256 KiB message output budget). Only Kafka is supported." + description = "Read up to 100 Kafka messages without committing consumer offsets. Defaults to the latest 20 messages. Returns JSON with base64 payloads, UTF-8 previews, metadata, incomplete (partial broker read), and outputTruncated (256 KiB message output budget). Only Kafka is supported.", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn peek_messages(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_peek_messages").await { @@ -2523,7 +2553,8 @@ impl DbxMcpServer { #[tool( name = "dbx_send_message", - description = "Send a base64-encoded message to a Kafka, RocketMQ, or RabbitMQ topic/queue" + description = "Send a base64-encoded message to a Kafka, RocketMQ, or RabbitMQ topic/queue", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = true) )] async fn send_message(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_send_message").await { @@ -2594,7 +2625,11 @@ impl DbxMcpServer { } } - #[tool(name = "dbx_get_schema_context", description = "Get compact table and column context for writing SQL")] + #[tool( + name = "dbx_get_schema_context", + description = "Get compact table and column context for writing SQL", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) + )] async fn get_schema_context(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_get_schema_context").await { return error; @@ -2645,7 +2680,11 @@ impl DbxMcpServer { text(format_schema_context(&connection.name, &database, &schema, &tables, truncated)) } - #[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")] + #[tool( + name = "dbx_add_connection", + description = "Add a new database connection to DBX", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = false) + )] async fn add_connection(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_add_connection").await { return error; @@ -2698,7 +2737,8 @@ impl DbxMcpServer { #[tool( name = "dbx_duplicate_connection", - description = "Duplicate a DBX connection with its complete settings, credentials, tunnels, and sidebar group" + description = "Duplicate a DBX connection with its complete settings, credentials, tunnels, and sidebar group", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = false) )] async fn duplicate_connection( &self, @@ -2764,7 +2804,11 @@ impl DbxMcpServer { } } - #[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")] + #[tool( + name = "dbx_remove_connection", + description = "Remove a database connection from DBX", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) + )] async fn remove_connection(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_remove_connection").await { return error; @@ -2829,7 +2873,11 @@ impl DbxMcpServer { } } - #[tool(name = "dbx_open_table", description = "Open a table in DBX desktop app. Requires DBX to be running.")] + #[tool( + name = "dbx_open_table", + description = "Open a table in DBX desktop app. Requires DBX to be running.", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = false) + )] async fn open_table(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_open_table").await { return error; @@ -2868,7 +2916,8 @@ impl DbxMcpServer { #[tool( name = "dbx_execute_and_show", - description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running." + description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn execute_and_show(&self, Parameters(request): Parameters) -> CallToolResult { if let Err(error) = self.ensure_tool_allowed("dbx_execute_and_show").await { @@ -2950,7 +2999,8 @@ impl DbxMcpServer { #[tool( name = "dbx_salesforce_current_user", - description = "Show the Salesforce identity behind a DBX connection: the connected user, their profile, whether that profile holds \"Modify All Data\" (which bypasses field-level security and record sharing), and the org. Read-only. Call it before preparing a write so the user knows who the change will be attributed to, and to tell a permission failure apart from a bad record Id." + description = "Show the Salesforce identity behind a DBX connection: the connected user, their profile, whether that profile holds \"Modify All Data\" (which bypasses field-level security and record sharing), and the org. Read-only. Call it before preparing a write so the user knows who the change will be attributed to, and to tell a permission failure apart from a bad record Id.", + annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false) )] async fn salesforce_current_user( &self, @@ -2979,7 +3029,8 @@ impl DbxMcpServer { #[tool( name = "dbx_salesforce_prepare_write", - description = "Step 1 of 2 for a Salesforce write: prepare an insert, update or delete of exactly ONE record. Nothing is sent to Salesforce. The call validates the request against DBX MCP policy and returns a human-readable summary plus a single-use confirm_token. Show that summary to the user and wait for their approval, then call dbx_salesforce_apply_write with the token. Salesforce has no transactions, so an applied write cannot be rolled back. Reads never need this: use dbx_execute_query with SOQL." + description = "Step 1 of 2 for a Salesforce write: prepare an insert, update or delete of exactly ONE record. Nothing is sent to Salesforce. The call validates the request against DBX MCP policy and returns a human-readable summary plus a single-use confirm_token. Show that summary to the user and wait for their approval, then call dbx_salesforce_apply_write with the token. Salesforce has no transactions, so an applied write cannot be rolled back. Reads never need this: use dbx_execute_query with SOQL.", + annotations(read_only_hint = false, destructive_hint = false, open_world_hint = false) )] async fn salesforce_prepare_write( &self, @@ -3065,7 +3116,8 @@ impl DbxMcpServer { #[tool( name = "dbx_salesforce_apply_write", - description = "Step 2 of 2 for a Salesforce write: execute exactly the write prepared by dbx_salesforce_prepare_write, identified by its confirm_token. The token is single-use, expires 5 minutes after it was issued, and is bound to that one statement — a changed write needs a new prepare. Call this only after the user has approved the summary they were shown. Returns the Salesforce result, or the org's own per-record error text." + description = "Step 2 of 2 for a Salesforce write: execute exactly the write prepared by dbx_salesforce_prepare_write, identified by its confirm_token. The token is single-use, expires 5 minutes after it was issued, and is bound to that one statement — a changed write needs a new prepare. Call this only after the user has approved the summary they were shown. Returns the Salesforce result, or the org's own per-record error text.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false) )] async fn salesforce_apply_write( &self, @@ -6488,6 +6540,42 @@ mod tests { assert!(names.contains(&"dbx_send_message")); } + #[test] + fn every_static_tool_declares_annotations() { + let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false); + let tools = server.tool_router.list_all(); + assert!(!tools.is_empty()); + for tool in &tools { + assert!(tool.annotations.is_some(), "tool {} is missing annotations", tool.name); + } + + let read_only_tools = [ + "dbx_list_connections", + "dbx_list_databases", + "dbx_list_tables", + "dbx_describe_table", + "dbx_list_routines", + "dbx_get_routine_source", + "dbx_get_schema_context", + "dbx_salesforce_current_user", + #[cfg(feature = "mq-admin")] + "dbx_peek_messages", + ]; + for name in read_only_tools { + let tool = tools.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("tool {name} not found")); + let annotations = tool.annotations.as_ref().unwrap(); + assert_eq!(annotations.read_only_hint, Some(true), "{name} should be read-only"); + assert_eq!(annotations.destructive_hint, Some(false), "{name} should not be destructive"); + } + + for name in ["dbx_remove_connection", "dbx_salesforce_apply_write", "dbx_commit_transaction"] { + let tool = tools.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("tool {name} not found")); + let annotations = tool.annotations.as_ref().unwrap(); + assert_eq!(annotations.read_only_hint, Some(false), "{name} should not be read-only"); + assert_eq!(annotations.destructive_hint, Some(true), "{name} should be destructive"); + } + } + #[tokio::test] async fn tools_list_hides_tools_the_policy_disallows() { let policy = McpGlobalPolicy {