diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index b687b5d37c..43af42b684 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -55,6 +55,7 @@ enum DbxResourceRequest { } #[derive(Debug, Deserialize, schemars::JsonSchema)] +#[schemars(extend("properties" = {}))] pub struct ListConnectionsRequest {} #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -3371,6 +3372,9 @@ impl DbxMcpServer { if self.plugin_tools_mode.advertise_flat() { tools.extend(self.plugin_tools_view(policy.as_ref()).await); } + for tool in &mut tools { + normalize_tool_input_schema(tool); + } tools } @@ -4003,6 +4007,23 @@ impl ServerHandler for DbxMcpServer { } } +/// Ensure object-typed input schemas publish a `properties` object. +/// OpenAI-compatible gateways (e.g. DashScope / 通义千问, DeepSeek Harness) +/// strictly validate tool schemas and reject all tool calls if any tool +/// with `type: "object"` lacks a `properties` map (issue #12464). +fn normalize_tool_input_schema(tool: &mut rmcp::model::Tool) { + let is_object = match tool.input_schema.get("type") { + Some(serde_json::Value::String(t)) => t == "object", + None => true, + _ => false, + }; + if is_object && !tool.input_schema.contains_key("properties") { + let mut object = (*tool.input_schema).clone(); + object.insert("properties".to_string(), serde_json::json!({})); + tool.input_schema = std::sync::Arc::new(object); + } +} + fn parse_dbx_resource_uri(uri: &str) -> Result { let parsed = Url::parse(uri).map_err(|_| ErrorData::invalid_params("Invalid DBX resource URI.", None))?; if parsed.scheme() != "dbx" @@ -6628,6 +6649,58 @@ mod tests { .is_some_and(|required| required.iter().any(|field| field == "tables"))); } + #[test] + fn all_advertised_tools_publish_properties_in_input_schema() { + let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false); + let tools = server.tool_router.list_all(); + + for tool in &tools { + let is_object = match tool.input_schema.get("type") { + Some(serde_json::Value::String(t)) => t == "object", + None => true, + _ => false, + }; + if is_object { + assert!( + tool.input_schema.get("properties").and_then(serde_json::Value::as_object).is_some(), + "tool {} inputSchema must publish a 'properties' object for OpenAI compatibility (issue #12464)", + tool.name + ); + } + } + + let list_connections = tools + .iter() + .find(|tool| tool.name == "dbx_list_connections") + .expect("dbx_list_connections should be registered"); + assert_eq!( + list_connections.input_schema.get("properties"), + Some(&serde_json::json!({})), + "dbx_list_connections must have empty properties object" + ); + } + + #[tokio::test] + async fn policy_filtered_tools_guarantee_properties_for_all_object_schemas() { + let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false); + let tools = server.policy_filtered_tools().await; + + for tool in &tools { + let is_object = match tool.input_schema.get("type") { + Some(serde_json::Value::String(t)) => t == "object", + None => true, + _ => false, + }; + if is_object { + assert!( + tool.input_schema.get("properties").and_then(serde_json::Value::as_object).is_some(), + "advertised tool {} inputSchema must publish 'properties'", + tool.name + ); + } + } + } + #[test] fn connection_selector_schema_uses_optional_strings() { let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);