diff --git a/crates/tracedecay-mcp/src/server/rmcp.rs b/crates/tracedecay-mcp/src/server/rmcp.rs index 5da70ef729..a7420f714f 100644 --- a/crates/tracedecay-mcp/src/server/rmcp.rs +++ b/crates/tracedecay-mcp/src/server/rmcp.rs @@ -20,6 +20,7 @@ use serde::de::DeserializeOwned; use serde_json::{Value, json}; use tokio::sync::{RwLock, Semaphore}; +use crate::tool_errors::structure_tool_problem; use crate::transport::{JsonRpcError, JsonRpcRequest, JsonRpcResponse}; use super::{ @@ -789,27 +790,10 @@ where let mut response = self .dispatch(context, "tools/call", McpDispatchParams::ToolsCall(request)) .await?; - // `CallToolResult` has no extension members, so the typed problem the - // dispatcher attaches beside `content` travels as structured content; - // otherwise a markdown-format refusal would reach clients only as prose. - let problem = response - .result - .as_mut() - .and_then(Value::as_object_mut) - .and_then(|result| result.remove("problem")); - let mut result = rmcp_response_result::(response)?; - if let Some(problem) = problem { - match result - .structured_content - .as_mut() - .and_then(Value::as_object_mut) - { - Some(structured) => { - structured.insert("problem".to_owned(), problem); - } - None => result.structured_content = Some(json!({ "problem": problem })), - } + if let Some(result) = response.result.as_mut() { + structure_tool_problem(result); } + let mut result = rmcp_response_result::(response)?; if let Some(started) = started { result .meta diff --git a/crates/tracedecay-mcp/src/tool_errors.rs b/crates/tracedecay-mcp/src/tool_errors.rs index a002c7fbb7..d1b779602d 100644 --- a/crates/tracedecay-mcp/src/tool_errors.rs +++ b/crates/tracedecay-mcp/src/tool_errors.rs @@ -110,6 +110,34 @@ pub fn semantic_failure_reason(result: &ToolResult) -> Option { .map(|text| text.trim_start().to_string()) } +/// Moves a tool result's typed `problem` into its MCP structured content. +/// +/// `CallToolResult` has no extension members, so a problem left beside +/// `content` reaches MCP clients only as prose. Every route that answers +/// `tools/call` to a host renders the refusal record here. +pub fn structure_tool_problem(result: &mut Value) { + let Some(object) = result.as_object_mut() else { + return; + }; + let Some(problem) = object.remove("problem") else { + return; + }; + match object + .get_mut("structuredContent") + .and_then(Value::as_object_mut) + { + Some(structured) => { + structured.insert("problem".to_owned(), problem); + } + None => { + object.insert( + "structuredContent".to_owned(), + json!({ "problem": problem }), + ); + } + } +} + pub fn mark_semantic_tool_error(result: &mut ToolResult) { if !tool_result_has_semantic_error(result) { return; diff --git a/crates/tracedecay/src/daemon/projectless.rs b/crates/tracedecay/src/daemon/projectless.rs index 0c9f474e2e..c584c10336 100644 --- a/crates/tracedecay/src/daemon/projectless.rs +++ b/crates/tracedecay/src/daemon/projectless.rs @@ -9,6 +9,7 @@ use tracedecay_daemon_identity::authority; use tracedecay_daemon_protocol::DaemonClientIdentity; use tracedecay_domain::errors::Result; use tracedecay_mcp::server::{LiveTranscriptRefreshJoin, join_required_live_transcript_refresh}; +use tracedecay_mcp::tool_errors::structure_tool_problem; use tracedecay_mcp::tools::catalog_discovery::{ catalog_discovery_tools_list_payload, default_catalog_discovery_authority, }; @@ -187,6 +188,9 @@ async fn projectless_response( store_administration, )) .await; + if let Some(result) = response.result.as_mut() { + structure_tool_problem(result); + } attach_projectless_tool_timing( &mut response, started.map(|started| started.elapsed().as_micros() as u64), diff --git a/crates/tracedecay/src/daemon/tests/socket.rs b/crates/tracedecay/src/daemon/tests/socket.rs index 9036c1d10f..88b5980d66 100644 --- a/crates/tracedecay/src/daemon/tests/socket.rs +++ b/crates/tracedecay/src/daemon/tests/socket.rs @@ -799,7 +799,9 @@ async fn projectless_project_list_reads_the_empty_profile_registry() { ); // A request outside the typed contract is the owner's refusal, flagged as - // a tool error rather than served as a listing. + // a tool error rather than served as a listing. Its record travels as MCP + // structured content, as on a project connection, never as a result + // member MCP clients drop. let line = tokio::time::timeout(HALF_CLOSE_ROUND_TRIP_BOUND, lines.next_line()) .await .expect("refused project list should not time out") @@ -807,17 +809,20 @@ async fn projectless_project_list_reads_the_empty_profile_registry() { .expect("projectless refusal"); let refused: Value = serde_json::from_str(&line).expect("refusal json"); let result = &refused["result"]; + let problem = &result["structuredContent"]["problem"]; assert_eq!( ( &refused["id"], &result["isError"], - &result["problem"]["kind"], - &result["problem"]["code"], - &result["problem"]["message"], + result.get("problem"), + &problem["kind"], + &problem["code"], + &problem["message"], ), ( &json!(9), &json!(true), + None, &json!("invalid_request"), &json!("application.surface.invalid_request"), &json!(