diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index 074f70262d..d32992c987 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -69,7 +69,7 @@ use tracedecay_daemon_protocol::{ use tracedecay_daemon_service::application_surface::observe_surface_argument_rejection; use tracedecay_domain::UtcMicros; use tracedecay_domain::errors::{Result, TraceDecayError}; -use tracedecay_mcp::tool_errors::project_route_problem; +use tracedecay_mcp::tool_errors::{project_route_problem, tool_result_problem}; use tracedecay_mcp::tools::binding::tool_dispatches_registered_project_reader; use tracedecay_mcp::tools::response_trailers::{ CODE_GRAPH_FRESHNESS_TRAILER_PREFIX, REQUEST_COST_TRAILER_PREFIX, @@ -1198,9 +1198,7 @@ fn tool_result_process_outcome(result_value: &Value, tool_name: &str) -> Result< // returning the status-only error so the process boundary can drop its // profiling guard and then return the nonzero `ExitCode`. std::io::stdout().flush()?; - let problem = result_value - .get("problem") - .or_else(|| result_value.pointer("/structuredContent/problem")); + let problem = tool_result_problem(result_value); let problem_text = |key: &str| { problem .and_then(|problem| problem.get(key)) diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index ab23d654f7..0902ea3612 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -3028,7 +3028,8 @@ fn tool_waits_through_an_after_delay_unavailable_within_its_deadline() { ) }); assert_eq!( - printed["problem"]["code"], "application.retained.authority-unavailable", + printed["structuredContent"]["problem"]["code"], + "application.retained.authority-unavailable", "stdout must carry the mounted owner's answer, got:\n{stdout}" ); assert!( @@ -3047,7 +3048,7 @@ fn tool_waits_through_an_after_delay_unavailable_within_its_deadline() { // The mounting refusal and the final answer are both `after_delay` // problems built by the one problem-record authority, so the printed // delay is the delay each ridden-out refusal waited. - let retry_after_millis = printed["problem"]["retry_after_millis"] + let retry_after_millis = printed["structuredContent"]["problem"]["retry_after_millis"] .as_u64() .expect("an after_delay problem names its delay"); assert!( @@ -3090,10 +3091,14 @@ fn tool_returns_a_completed_authority_result_without_resending() { }); assert_eq!(printed["isError"], true); assert_eq!( - printed["problem"]["code"], "application.retained.authority-unavailable", + printed["structuredContent"]["problem"]["code"], + "application.retained.authority-unavailable", "the daemon's completed answer must be surfaced, got:\n{stdout}" ); - assert_eq!(printed["problem"]["retry"], "after_delay"); + assert_eq!( + printed["structuredContent"]["problem"]["retry"], + "after_delay" + ); let stderr = String::from_utf8_lossy(&output.stderr); assert_eq!( stderr.lines().last(), diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_first_touch_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_first_touch_test.rs index fe5e7bccaa..8ed2d9d479 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_first_touch_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_first_touch_test.rs @@ -279,7 +279,7 @@ fn code_graph_tools_do_not_first_touch_project_store() { let stdout = String::from_utf8_lossy(&output.stdout); let result: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|error| panic!("status --json prints its refusal: {error}\n{stdout}")); - let problem = &result["problem"]; + let problem = &result["structuredContent"]["problem"]; assert_eq!( (&problem["kind"], &problem["code"], &problem["message"]), ( diff --git a/crates/tracedecay-mcp/src/application_output/tool_result.rs b/crates/tracedecay-mcp/src/application_output/tool_result.rs index 85e0ff6b7d..2a4469c882 100644 --- a/crates/tracedecay-mcp/src/application_output/tool_result.rs +++ b/crates/tracedecay-mcp/src/application_output/tool_result.rs @@ -13,6 +13,7 @@ use super::markdown; use super::view::CanonicalHumanView; use crate::ToolResult; use crate::handlers::support::text_tool_result; +use crate::tool_errors::problem_structured_content; use crate::tools::render::finalize_with_format; use crate::tools::response_trailers::ResponseTrailer; @@ -62,8 +63,8 @@ pub fn problem_error(problem: ApplicationProblemEnvelope) -> TraceDecayError { } /// Renders one settled application call. A problem is a semantic failure -/// whose whole record rides beside the text as `problem`, which MCP carries -/// as structured content and `--json` prints with the result. +/// whose whole record rides beside the text as MCP +/// `structuredContent.problem`, which `--json` prints with the result. pub fn render_application_result( response_handle_root: Option<&Path>, operation: &str, @@ -108,8 +109,8 @@ pub fn render_application_result( Err(problem) => { if let Some(object) = rendered.value.as_object_mut() { object.insert( - "problem".to_string(), - serde_json::to_value(problem.problem.as_ref())?, + "structuredContent".to_string(), + problem_structured_content(&problem.problem)?, ); } } diff --git a/crates/tracedecay-mcp/src/handlers/edit.rs b/crates/tracedecay-mcp/src/handlers/edit.rs index a5638f8082..0ed1840181 100644 --- a/crates/tracedecay-mcp/src/handlers/edit.rs +++ b/crates/tracedecay-mcp/src/handlers/edit.rs @@ -493,7 +493,7 @@ mod tests { .await .expect("a refused source edit renders as a tool result"); assert_eq!(result.semantic_error(), Some(true), "{}", result.value); - result.value["problem"].clone() + result.value["structuredContent"]["problem"].clone() } fn problem_summary(problem: &Value) -> Value { diff --git a/crates/tracedecay-mcp/src/server/rmcp.rs b/crates/tracedecay-mcp/src/server/rmcp.rs index a7420f714f..bed13e3b4d 100644 --- a/crates/tracedecay-mcp/src/server/rmcp.rs +++ b/crates/tracedecay-mcp/src/server/rmcp.rs @@ -20,7 +20,6 @@ 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::{ @@ -787,12 +786,9 @@ where } let started = (self.timings_enabled || self.context.timings_enabled()).then(std::time::Instant::now); - let mut response = self + let response = self .dispatch(context, "tools/call", McpDispatchParams::ToolsCall(request)) .await?; - 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 diff --git a/crates/tracedecay-mcp/src/tool_errors.rs b/crates/tracedecay-mcp/src/tool_errors.rs index 6e85ae1c77..d06253381f 100644 --- a/crates/tracedecay-mcp/src/tool_errors.rs +++ b/crates/tracedecay-mcp/src/tool_errors.rs @@ -1,7 +1,7 @@ //! Semantic tool-failure classification and JSON-RPC error-response mapping. use serde_json::{Value, json}; -use tracedecay_contracts::ApplicationProblem; +use tracedecay_contracts::{ApplicationProblem, ApplicationProblemRecord}; use tracedecay_domain::errors::{ PROFILE_RESET_COMMAND, STALE_STORE_RESET_COMMAND, TraceDecayError, }; @@ -119,32 +119,21 @@ 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. +/// The MCP `structuredContent` member of a tool result that refuses with +/// `problem`. /// -/// `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 }), - ); - } - } +/// `CallToolResult` has no extension members, so structured content is the +/// only place a typed record reaches MCP clients. Every route, and the +/// `tracedecay tool --json` output, carries the refusal record there and +/// nowhere else; [`tool_result_problem`] is its one reader. +pub fn problem_structured_content(problem: &ApplicationProblemRecord) -> serde_json::Result { + Ok(json!({ "problem": serde_json::to_value(problem)? })) +} + +/// The typed problem record a tool result refuses with, if any. +#[must_use] +pub fn tool_result_problem(result: &Value) -> Option<&Value> { + result.get("structuredContent")?.get("problem") } pub fn mark_semantic_tool_error(result: &mut ToolResult) { diff --git a/crates/tracedecay/src/daemon/core_client.rs b/crates/tracedecay/src/daemon/core_client.rs index 74d457e26b..8356f7f96b 100644 --- a/crates/tracedecay/src/daemon/core_client.rs +++ b/crates/tracedecay/src/daemon/core_client.rs @@ -22,6 +22,7 @@ pub(crate) use tracedecay_daemon_protocol::connection::{ pub use tracedecay_daemon_protocol::daemon_tool_response_bound; use tracedecay_daemon_protocol::tool_request_deadline; use tracedecay_mcp::server::attach_stateless_request_context; +use tracedecay_mcp::tool_errors::tool_result_problem; use tracedecay_runtime_core::config::ProfileRoot; use super::{ @@ -539,19 +540,14 @@ fn daemon_tool_call_error(error: JsonRpcError) -> TraceDecayError { /// the publication-window mounting refusal. /// /// A project-scoped owner that registers behind the core publication answers -/// `application.runtime.mounting` while it is still mounting. A project -/// connection renders that record under the tool result's `problem` member; -/// the projectless route, which `tracedecay serve` relays to hosts verbatim, -/// renders it as MCP `structuredContent.problem`. An admitted terminal, and -/// every other completed problem (a retained authority that is unavailable, a -/// saturated owner, an observed diagnostic), is the answer: its `after_delay` -/// directive is for the caller, not a transport loop. +/// `application.runtime.mounting` while it is still mounting, on every route. +/// An admitted terminal, and every other completed problem (a retained +/// authority that is unavailable, a saturated owner, an observed diagnostic), +/// is the answer: its `after_delay` directive is for the caller, not a +/// transport loop. fn tool_result_retry_after_delay(result: &serde_json::Value) -> Option { - let problem = result - .get("problem") - .or_else(|| result.pointer("/structuredContent/problem"))?; let record: tracedecay_contracts::ApplicationProblemRecord = - serde_json::from_value(problem.clone()).ok()?; + serde_json::from_value(tool_result_problem(result)?.clone()).ok()?; record.owner_mount_resend_delay() } @@ -822,8 +818,9 @@ mod tests { ApplicationProblem, ApplicationProblemEnvelope, RequestId, ResultContractRef, SafeDiagnostic, }; - use tracedecay_mcp::tool_errors::structure_tool_problem; - use tracedecay_tool_catalog::SchemaId; + use tracedecay_mcp::application_output::tool_result::ApplicationRefusal; + use tracedecay_mcp::tool_errors::mark_semantic_tool_error; + use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingId, SchemaId}; use super::super::{ JsonRpcError, PROJECT_SERVER_CAPACITY_REASON_CODE, @@ -886,27 +883,29 @@ mod tests { ); } - /// A problem tool result as the daemon's projectless `tools/call` route - /// answers it: the canonical record, moved into MCP structured content. + /// A problem tool result as the daemon's projectless retained route + /// renders it. fn projectless_problem_result(problem: ApplicationProblem) -> serde_json::Value { - let envelope = ApplicationProblemEnvelope::new( - ResultContractRef::new( - SchemaId::new("schema.application.retained.message-search.result") - .expect("schema id"), - 1, + let refusal = ApplicationRefusal { + operation: ApplicationSurfaceOperation::MessageSearch, + binding_id: BindingId::new("binding.mcp.message_search.v1").expect("binding id"), + problem: ApplicationProblemEnvelope::new( + ResultContractRef::new( + SchemaId::new("schema.application.retained.message-search.result") + .expect("schema id"), + 1, + ) + .expect("result contract"), + RequestId::new("request.mcp.projectless-mounting").expect("request id"), + problem, ) - .expect("result contract"), - RequestId::new("request.mcp.projectless-mounting").expect("request id"), - problem, - ) - .expect("problem envelope"); - let mut result = json!({ - "content": [{ "type": "text", "text": "problem" }], - "isError": true, - "problem": serde_json::to_value(envelope.problem.as_ref()).expect("problem record"), - }); - structure_tool_problem(&mut result); - result + .expect("problem envelope"), + }; + let mut result = refusal + .render(None, &json!({ "format": "json" })) + .expect("rendered refusal"); + mark_semantic_tool_error(&mut result); + result.value } #[test] @@ -914,6 +913,21 @@ mod tests { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); let mounting = projectless_problem_result(ApplicationProblem::runtime_mounting()); assert_eq!(mounting.get("problem"), None); + assert_eq!( + ( + &mounting["isError"], + &mounting["structuredContent"]["problem"]["diagnostic"]["code"], + &mounting["structuredContent"]["problem"]["retry"], + &mounting["structuredContent"]["problem"]["retry_after_millis"], + ), + ( + &json!(true), + &json!("application.runtime.mounting"), + &json!("after_delay"), + &json!(250), + ), + "{mounting}" + ); assert_eq!( super::project_open_retry_wait(&Ok(mounting), deadline), Some(std::time::Duration::from_millis(250)) diff --git a/crates/tracedecay/src/daemon/production_harness/configuration_set_behavior_test.rs b/crates/tracedecay/src/daemon/production_harness/configuration_set_behavior_test.rs index 24acd53e99..e69121106d 100644 --- a/crates/tracedecay/src/daemon/production_harness/configuration_set_behavior_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/configuration_set_behavior_test.rs @@ -135,7 +135,7 @@ fn assert_completed_effect( answer.payload ); assert_ne!(answer.result["isError"], true); - assert!(answer.result.get("problem").is_none()); + assert!(answer.result.get("structuredContent").is_none()); let effect = &answer.payload["outcome"]["value"]; assert_eq!(answer.payload["outcome"]["outcome"], "effect"); assert_eq!(effect["effect_class"], "configuration_write"); @@ -164,19 +164,40 @@ fn assert_completed_effect( fn assert_conflict(answer: &McpAnswer) { assert!(answer.refused, "conflict must be an MCP isError"); assert_eq!(answer.result["isError"], true); - assert_eq!(answer.result["problem"]["kind"], "conflict"); - assert_eq!(answer.result["problem"]["code"], "configuration.conflict"); assert_eq!( - answer.result["problem"]["message"], + answer.result["structuredContent"]["problem"]["kind"], + "conflict" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["code"], + "configuration.conflict" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["message"], "The configuration request conflicts with current state" ); - assert_eq!(answer.result["problem"]["retry"], "after_revalidate"); - assert_eq!(answer.result["problem"]["retryable"], true); - assert_eq!(answer.result["problem"]["retry_scope"], "fresh_request"); - assert_eq!(answer.result["problem"]["terminality"], "pre_admission"); - assert_eq!(answer.result["problem"]["committed_receipt"], Value::Null); assert_eq!( - answer.result["problem"]["legal_actions"], + answer.result["structuredContent"]["problem"]["retry"], + "after_revalidate" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["retryable"], + true + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["retry_scope"], + "fresh_request" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["terminality"], + "pre_admission" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["committed_receipt"], + Value::Null + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["legal_actions"], json!(["refresh"]) ); assert_eq!(answer.payload["problem"]["kind"], "conflict"); @@ -199,25 +220,43 @@ fn assert_invalid_request(answer: &McpAnswer, message: &str) { ); assert_eq!(answer.result["isError"], true); assert_eq!( - answer.result["problem"]["kind"], "invalid_request", + answer.result["structuredContent"]["problem"]["kind"], "invalid_request", "problem record: {}", - answer.result["problem"] + answer.result["structuredContent"]["problem"] ); assert_eq!( - answer.result["problem"]["code"], + answer.result["structuredContent"]["problem"]["code"], "configuration.invalid_request" ); assert_eq!( - answer.result["problem"]["message"], message, + answer.result["structuredContent"]["problem"]["message"], message, "problem record: {}", - answer.result["problem"] - ); - assert_eq!(answer.result["problem"]["retry"], "never"); - assert_eq!(answer.result["problem"]["retryable"], false); - assert_eq!(answer.result["problem"]["retry_scope"], Value::Null); - assert_eq!(answer.result["problem"]["terminality"], "pre_admission"); - assert_eq!(answer.result["problem"]["committed_receipt"], Value::Null); - assert_eq!(answer.result["problem"]["legal_actions"], json!([])); + answer.result["structuredContent"]["problem"] + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["retry"], + "never" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["retryable"], + false + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["retry_scope"], + Value::Null + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["terminality"], + "pre_admission" + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["committed_receipt"], + Value::Null + ); + assert_eq!( + answer.result["structuredContent"]["problem"]["legal_actions"], + json!([]) + ); assert_eq!(answer.payload["problem"]["kind"], "invalid_request"); assert_eq!( answer.payload["problem"]["code"], @@ -388,22 +427,31 @@ async fn configuration_set_over_mcp_persists_the_boolean_replays_and_refuses_con ); assert_eq!(wrong_layer.result["isError"], true); assert_eq!( - wrong_layer.result["problem"]["kind"], + wrong_layer.result["structuredContent"]["problem"]["kind"], "not_found_or_not_authorized" ); assert_eq!( - wrong_layer.result["problem"]["code"], + wrong_layer.result["structuredContent"]["problem"]["code"], "not_found_or_not_authorized" ); assert_eq!( - wrong_layer.result["problem"]["message"], + wrong_layer.result["structuredContent"]["problem"]["message"], "The requested resource was not found or is not authorized" ); - assert_eq!(wrong_layer.result["problem"]["retry"], "never"); - assert_eq!(wrong_layer.result["problem"]["retryable"], false); - assert_eq!(wrong_layer.result["problem"]["legal_actions"], json!([])); assert_eq!( - wrong_layer.result["problem"]["committed_receipt"], + wrong_layer.result["structuredContent"]["problem"]["retry"], + "never" + ); + assert_eq!( + wrong_layer.result["structuredContent"]["problem"]["retryable"], + false + ); + assert_eq!( + wrong_layer.result["structuredContent"]["problem"]["legal_actions"], + json!([]) + ); + assert_eq!( + wrong_layer.result["structuredContent"]["problem"]["committed_receipt"], Value::Null ); assert_eq!( diff --git a/crates/tracedecay/src/daemon/project_open_handshake.rs b/crates/tracedecay/src/daemon/project_open_handshake.rs index 5651865f41..4e7795456d 100644 --- a/crates/tracedecay/src/daemon/project_open_handshake.rs +++ b/crates/tracedecay/src/daemon/project_open_handshake.rs @@ -238,7 +238,7 @@ pub(super) async fn write_project_open_error( /// when the refusal is the reset-required terminal, matching the canonical /// problem envelope CLI and HTTP callers receive for the same operation. /// Non-application tools and every other open failure keep the raw shape. -fn tool_call_open_refusal_response( +pub(super) fn tool_call_open_refusal_response( request: &JsonRpcRequest, connection_scope: &str, error: &TraceDecayError, @@ -259,13 +259,14 @@ fn tool_call_open_refusal_response( tool_name, request_id, detail, )?; let text = serde_json::to_string(&envelope).ok()?; - let problem = serde_json::to_value(envelope.problem.as_ref()).ok()?; + let structured_content = + tracedecay_mcp::tool_errors::problem_structured_content(&envelope.problem).ok()?; Some(JsonRpcResponse::success( id, json!({ "content": [{ "type": "text", "text": text }], "isError": true, - "problem": problem, + "structuredContent": structured_content, }), )) } @@ -367,9 +368,13 @@ mod tests { assert!(response.error.is_none(), "the refusal is a tool result"); let result = response.result.expect("tool result payload"); assert_eq!(result["isError"], serde_json::json!(true)); - assert_eq!(result["problem"]["kind"], "reset_required"); + assert_eq!(result.get("problem"), None); assert_eq!( - result["problem"]["legal_actions"], + result["structuredContent"]["problem"]["kind"], + "reset_required" + ); + assert_eq!( + result["structuredContent"]["problem"]["legal_actions"], serde_json::json!(["reset"]) ); let text = result["content"][0]["text"] diff --git a/crates/tracedecay/src/daemon/projectless.rs b/crates/tracedecay/src/daemon/projectless.rs index 51f91fc63f..decac2125b 100644 --- a/crates/tracedecay/src/daemon/projectless.rs +++ b/crates/tracedecay/src/daemon/projectless.rs @@ -9,7 +9,6 @@ use tracedecay_daemon_protocol::{ parse_application_surface_request, }; use tracedecay_domain::errors::Result; -use tracedecay_mcp::tool_errors::structure_tool_problem; use tracedecay_mcp::tools::catalog_discovery::{ catalog_discovery_tools_list_payload, default_catalog_discovery_authority, }; @@ -181,9 +180,6 @@ 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/feedback_impact.rs b/crates/tracedecay/src/daemon/tests/feedback_impact.rs index 3a49e58319..16d4ad4f83 100644 --- a/crates/tracedecay/src/daemon/tests/feedback_impact.rs +++ b/crates/tracedecay/src/daemon/tests/feedback_impact.rs @@ -234,7 +234,7 @@ fn assert_concealed_impact(response: &JsonRpcResponse) { "legal_actions": [], "coverage": null }); - assert_eq!(result["problem"], problem); + assert_eq!(result["structuredContent"]["problem"], problem); assert_eq!(envelope["problem"], problem); } @@ -256,16 +256,15 @@ async fn wait_for_feedback_owner( .await .expect("absent impact handle"); if response.error.is_none() - && response - .result - .as_ref() - .is_some_and(|result| result["problem"]["kind"] == "not_found_or_not_authorized") + && response.result.as_ref().is_some_and(|result| { + result["structuredContent"]["problem"]["kind"] == "not_found_or_not_authorized" + }) { return response; } let retryable_owner = response.result.as_ref().is_some_and(|result| { - result["problem"]["code"] == "feedback.owner_unavailable" - && result["problem"]["retryable"] == true + result["structuredContent"]["problem"]["code"] == "feedback.owner_unavailable" + && result["structuredContent"]["problem"]["retryable"] == true }); assert!( retryable_owner, @@ -334,8 +333,9 @@ async fn publish_advisory_cycle( ); } else { let retryable = response.result.as_ref().is_some_and(|result| { - result["problem"]["code"] == "feedback.advisory-cycle.unavailable" - && result["problem"]["retryable"] == true + result["structuredContent"]["problem"]["code"] + == "feedback.advisory-cycle.unavailable" + && result["structuredContent"]["problem"]["retryable"] == true }); assert!( retryable, diff --git a/crates/tracedecay/src/daemon/tests/replay.rs b/crates/tracedecay/src/daemon/tests/replay.rs index a4a7895f12..0376c6894b 100644 --- a/crates/tracedecay/src/daemon/tests/replay.rs +++ b/crates/tracedecay/src/daemon/tests/replay.rs @@ -42,9 +42,12 @@ async fn projectless_user_session_setup_failure_is_a_typed_unavailable_terminal( panic!("profile setup failure must be a typed tool result: {response:?}") }); assert_eq!(result["isError"], true, "{result}"); - assert_eq!(result["problem"]["kind"], "unavailable", "{result}"); assert_eq!( - result["problem"]["code"], "registered_authority_unavailable", + result["structuredContent"]["problem"]["kind"], "unavailable", + "{result}" + ); + assert_eq!( + result["structuredContent"]["problem"]["code"], "registered_authority_unavailable", "{result}" ); } @@ -107,12 +110,18 @@ async fn client_identity_startup_replays_retained_profile_receipts() { panic!("blocked canonical apply must be the owner's typed refusal: {response:?}") }); assert_eq!(result["isError"], true, "{result}"); - assert_eq!(result["problem"]["kind"], "unavailable", "{result}"); assert_eq!( - result["problem"]["code"], "canonical_admission_failed", + result["structuredContent"]["problem"]["kind"], "unavailable", + "{result}" + ); + assert_eq!( + result["structuredContent"]["problem"]["code"], "canonical_admission_failed", + "{result}" + ); + assert_eq!( + result["structuredContent"]["problem"]["retryable"], true, "{result}" ); - assert_eq!(result["problem"]["retryable"], true, "{result}"); assert_eq!(broker.pending_count().await, 1); assert!(!profile_root.join("host_receipts.json").exists()); first_admin.shutdown_host_admission_replay().await; diff --git a/crates/tracedecay/src/daemon/tests/socket.rs b/crates/tracedecay/src/daemon/tests/socket.rs index 652882c76b..2984f3b1cc 100644 --- a/crates/tracedecay/src/daemon/tests/socket.rs +++ b/crates/tracedecay/src/daemon/tests/socket.rs @@ -873,6 +873,118 @@ async fn projectless_project_list_reads_the_empty_profile_registry() { .expect("projectless client shutdown should be clean"); } +/// Every JSON pointer at which a tool result carries a `problem` member. +fn problem_placement(value: &Value, pointer: &str, found: &mut Vec) { + if let Value::Object(members) = value { + for (key, member) in members { + let path = format!("{pointer}/{key}"); + if key == "problem" { + found.push(path.clone()); + } + problem_placement(member, &path, found); + } + } +} + +fn problem_placements(result: &Value) -> Vec { + let mut found = Vec::new(); + problem_placement(result, "", &mut found); + found +} + +/// A projectless refusal and a project-route refusal carry the typed problem +/// record at the same single location, so one reader serves every route. +#[cfg(unix)] +#[tokio::test] +async fn projectless_and_project_route_refusals_place_the_problem_identically() { + let home = TempDir::new().expect("home"); + let home = home.path().canonicalize().expect("canonical home"); + let client_identity = test_client_identity_for(home.join("client")); + let engine = test_daemon_engine_for_profile(&client_identity.profile_root); + let _database_scope = + enter_test_daemon_database_scope(&client_identity.profile_root, "problem-placement-test"); + + let (client, server) = tokio::net::UnixStream::pair().expect("unix stream pair"); + let server_task = tokio::spawn(Box::pin(super::serve_authenticated_test_client( + server, engine, + ))); + let (reader, mut writer) = client.into_split(); + super::write_test_auth_preface(&mut writer).await; + let handshake = DaemonHandshake { + client_identity, + ..test_handshake_defaults() + }; + writer + .write_all(handshake.to_line().expect("handshake").as_bytes()) + .await + .expect("write handshake"); + writer.write_all(b"\n").await.expect("newline"); + writer + .write_all( + serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "tracedecay_project_list", + "arguments": {"format": "json", "limt": 5} + } + })) + .expect("tools/call json") + .as_bytes(), + ) + .await + .expect("write refused tools/call"); + writer.write_all(b"\n").await.expect("newline"); + writer.shutdown().await.expect("shutdown writer"); + let line = tokio::io::BufReader::new(reader) + .lines() + .next_line() + .await + .expect("read refusal") + .expect("projectless refusal"); + server_task + .await + .expect("server task should complete") + .expect("projectless client shutdown should be clean"); + let projectless: Value = serde_json::from_str(&line).expect("refusal json"); + let projectless = &projectless["result"]; + + let request: tracedecay_mcp::JsonRpcRequest = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "tracedecay_storage_status", "arguments": {} }, + })) + .expect("project tools/call request"); + let project = super::super::project_open_handshake::tool_call_open_refusal_response( + &request, + "connection.problem-placement", + &tracedecay_domain::errors::TraceDecayError::reset_required( + "project store", + "schema v26 is incompatible", + ), + ) + .expect("a project-route refusal") + .result + .expect("project-route tool result"); + + assert_eq!( + (&projectless["isError"], &project["isError"]), + (&json!(true), &json!(true)) + ); + assert_eq!( + serde_json::to_string(&problem_placements(projectless)).expect("placement bytes"), + serde_json::to_string(&problem_placements(&project)).expect("placement bytes"), + "projectless {projectless}\nproject {project}" + ); + assert_eq!( + problem_placements(&project), + ["/structuredContent/problem"], + "{project}" + ); +} + /// A fresh MCP host discovers tools through `tools/list` before `tools/call`. /// After a projectless session is admitted, `tools/list` must advertise the /// registry reads that dispatcher can serve and must not advertise diff --git a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs index 5f82eeb90d..771914c66f 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs @@ -965,7 +965,7 @@ mod tests { tracedecay_mcp::tool_errors::mark_semantic_tool_error(&mut rendered); assert_eq!(rendered.value["isError"], true, "{error}"); - let problem = &rendered.value["problem"]; + let problem = &rendered.value["structuredContent"]["problem"]; assert_eq!(problem["kind"], kind, "{error}"); assert_eq!(problem["code"], code, "{error}"); assert_eq!(problem["retry"], retry, "{error}"); diff --git a/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs index 24d3d7b110..cfe380ff2f 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs @@ -685,7 +685,10 @@ async fn fact_store_curate_rejects_a_partial_receipt_from_another_scope() { .await .expect("scope mismatch must render an unavailable result"); - assert_eq!(result.value["problem"]["kind"], "unavailable"); + assert_eq!( + result.value["structuredContent"]["problem"]["kind"], + "unavailable" + ); cg.close(); } @@ -732,7 +735,10 @@ async fn fact_store_curate_pre_commit_cancellation_does_not_mutate() { assert_eq!(executor.calls.load(Ordering::SeqCst), 1); assert_eq!(executor.mutations.load(Ordering::SeqCst), 0); - assert_eq!(result.value["problem"]["kind"], "cancelled"); + assert_eq!( + result.value["structuredContent"]["problem"]["kind"], + "cancelled" + ); cg.close(); } @@ -859,7 +865,7 @@ async fn a_stale_refresh_frontier_reaches_mcp_as_typed_detail() { .await .expect("a stale frontier renders a typed problem"); - let problem = result.value["problem"].clone(); + let problem = result.value["structuredContent"]["problem"].clone(); assert_eq!( ( &problem["kind"], diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs index 2e4d4a0e55..176b37ba68 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_behavior_test.rs @@ -600,7 +600,9 @@ async fn ingest_codex_transcripts(fixture: &ProductionCompositionFixture) { let ingest = tokio::time::timeout(std::time::Duration::from_secs(30), async { loop { let result = call_tool(fixture, "tracedecay_hook_runtime", arguments.clone()).await; - if result.value["problem"]["code"] == json!("application.runtime.mounting") { + if result.value["structuredContent"]["problem"]["code"] + == json!("application.runtime.mounting") + { tokio::time::sleep(std::time::Duration::from_millis(250)).await; continue; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs index 6e5c8988b4..b19b7c8e69 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs @@ -370,7 +370,7 @@ async fn ast_grep_rewrite_exact_retry_replays_and_a_different_input_conflicts() .await .expect("a daemon refusal renders as a tool result"); assert_eq!(conflict.semantic_error(), Some(true), "{}", conflict.value); - let problem = &conflict.value["problem"]; + let problem = &conflict.value["structuredContent"]["problem"]; assert_eq!( ( &problem["kind"], diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs index a5044e6ef2..4c11aaa102 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs @@ -13,7 +13,9 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; -use crate::support::{production_composition_fixture_with_sources, wait_for_current_graph}; +use crate::support::{ + production_composition_fixture_with_sources, refusal_problem, wait_for_current_graph, +}; const SYMBOL: &str = "missing_feedback_list_symbol"; const SOURCE: &str = "pub fn entry() { missing_feedback_list_symbol(); }\n"; @@ -149,7 +151,7 @@ async fn feedback_list_returns_the_published_compiler_finding_and_denies_other_h }), ) .await; - let unknown = problem_record(&unknown_response); + let unknown = refusal_problem(&unknown_response["result"]); assert_eq!(unknown["kind"], "not_found_or_not_authorized", "{unknown}"); assert_eq!(unknown["code"], "not_found_or_not_authorized", "{unknown}"); assert_eq!(unknown["retryable"], false, "{unknown}"); @@ -305,9 +307,9 @@ async fn published_cycle(fixture: &crate::support::ProductionCompositionFixture) fn retryable_mount(response: &Value) -> bool { let reason = response["error"]["data"]["reason_code"].as_str(); - let code = response["result"]["problem"]["code"].as_str(); + let code = response["result"]["structuredContent"]["problem"]["code"].as_str(); let retryable = response["error"]["data"]["retryable"] == true - || response["result"]["problem"]["retryable"] == true; + || response["result"]["structuredContent"]["problem"]["retryable"] == true; retryable && matches!( reason.or(code), @@ -369,7 +371,7 @@ async fn deny_list( }), ) .await; - let problem = problem_record(&response); + let problem = refusal_problem(&response["result"]); assert_eq!( problem["kind"], "not_found_or_not_authorized", "{reason}: {problem}" @@ -377,16 +379,3 @@ async fn deny_list( assert_eq!(problem["code"], "not_found_or_not_authorized", "{problem}"); assert_eq!(problem["retryable"], false, "{problem}"); } - -fn problem_record(response: &Value) -> &Value { - assert_eq!( - response["result"]["isError"], true, - "a refused list must be a tool error, not an empty success: {response}" - ); - let record = &response["result"]["problem"]; - assert!( - record.is_object(), - "refused list omitted its problem: {response}" - ); - record -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/unsealed_graph.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/unsealed_graph.rs index 3fe8ed0628..86b14b8301 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/unsealed_graph.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/unsealed_graph.rs @@ -87,7 +87,7 @@ async fn graph_reads_refuse_as_unavailable_until_the_graph_seals() { ), ] { let result = tool_result(&harness, &project, tool, arguments).await; - let problem = &result["problem"]; + let problem = &result["structuredContent"]["problem"]; assert_eq!( ( &result["isError"], diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hook_runtime_request_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hook_runtime_request_test.rs index 5f116acc12..b751444dcf 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hook_runtime_request_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hook_runtime_request_test.rs @@ -48,7 +48,7 @@ async fn answer(fixture: &ProductionCompositionFixture, arguments: Value) -> Val async fn refusal(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { let result = call(fixture, TOOL, arguments).await; assert_eq!(result["isError"], true, "hook action must refuse: {result}"); - let problem = &result["problem"]; + let problem = &result["structuredContent"]["problem"]; json!({ "kind": problem["kind"], "code": problem["code"], diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_health_request_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_health_request_test.rs index e66266cc27..065de2abae 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_health_request_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_health_request_test.rs @@ -87,7 +87,7 @@ async fn refusal( .result .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")); assert_eq!(result["isError"], true, "{tool_name} must refuse: {result}"); - let problem = &result["problem"]; + let problem = &result["structuredContent"]["problem"]; json!({ "kind": problem["kind"], "code": problem["code"], diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs index f34b5d5229..ad44396ff8 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs @@ -68,7 +68,7 @@ fn refusal(result: Result) -> String { fn problem_refusal(result: Result) -> String { let result = result.expect("a daemon refusal renders as a tool result"); assert_eq!(result.semantic_error(), Some(true), "{}", result.value); - let problem = &result.value["problem"]; + let problem = &result.value["structuredContent"]["problem"]; let actions = problem["legal_actions"] .as_array() .expect("legal actions") diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/type_hierarchy_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/type_hierarchy_test.rs index 7213fe439c..36235a9355 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/type_hierarchy_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/type_hierarchy_test.rs @@ -245,7 +245,10 @@ async fn type_hierarchy_reports_literal_trees_and_typed_refusals() { .as_ref() .unwrap_or_else(|| panic!("{absent:?}")); assert_eq!( - (&absent["isError"], &absent["problem"]["kind"]), + ( + &absent["isError"], + &absent["structuredContent"]["problem"]["kind"], + ), (&json!(true), &json!("not_found_or_not_authorized")), "an absent root is a typed miss, not an empty hierarchy: {absent}" ); @@ -339,7 +342,8 @@ fn assert_refused(response: &JsonRpcResponse, context: &str) { .and_then(|error| error.data.as_ref()) .is_some_and(|data| data["reason_code"] == "application_surface_invalid_request"); let refused_by_contract = response.result.as_ref().is_some_and(|result| { - result["isError"] == true && result["problem"]["kind"] == "invalid_request" + result["isError"] == true + && result["structuredContent"]["problem"]["kind"] == "invalid_request" }); assert!( refused_at_parse || refused_by_contract, diff --git a/crates/tracedecay/tests/mcp_suite/support.rs b/crates/tracedecay/tests/mcp_suite/support.rs index 9426d29a9b..d5fa11b82a 100644 --- a/crates/tracedecay/tests/mcp_suite/support.rs +++ b/crates/tracedecay/tests/mcp_suite/support.rs @@ -1235,18 +1235,14 @@ pub(crate) fn application_surface_refusal_error(tool: &str, detail: &str) -> Val /// Owner-served tools answer every refusal, including arguments their typed /// request parser rejects, as an `isError` tool result carrying the whole /// problem record (an MCP 2025-11-25 tool-execution error), not as a JSON-RPC -/// error. In-process dispatch keeps the record at `problem`; the rmcp -/// transport moves it under `structuredContent`. +/// error. Every route carries the record at `structuredContent.problem`. pub(crate) fn refusal_problem(result: &Value) -> &Value { assert_eq!( result["isError"], Value::Bool(true), "expected an isError refusal: {result}" ); - let problem = match &result["structuredContent"]["problem"] { - Value::Null => &result["problem"], - transported => transported, - }; + let problem = &result["structuredContent"]["problem"]; assert!( problem.is_object(), "refusal carries no problem record: {result}" @@ -1261,7 +1257,7 @@ pub(crate) fn tool_result_problem(result: &ToolResult) -> &Value { if result.semantic_error() != Some(true) { return refusal_problem(&result.value); } - let problem = &result.value["problem"]; + let problem = &result.value["structuredContent"]["problem"]; assert!( problem.is_object(), "refusal carries no problem record: {}", diff --git a/crates/tracedecay/tests/runtime_acceptance_suite/tool_client_transport.rs b/crates/tracedecay/tests/runtime_acceptance_suite/tool_client_transport.rs index 2c92dfccd3..1bf49990fa 100644 --- a/crates/tracedecay/tests/runtime_acceptance_suite/tool_client_transport.rs +++ b/crates/tracedecay/tests/runtime_acceptance_suite/tool_client_transport.rs @@ -170,7 +170,10 @@ fn assert_problem(result: &ChildResult, kind: &str) { let envelope: Value = serde_json::from_slice(&result.output.stdout).expect("typed problem JSON"); assert_eq!(envelope["isError"], true, "{envelope}"); - assert_eq!(envelope["problem"]["kind"], kind, "{envelope}"); + assert_eq!( + envelope["structuredContent"]["problem"]["kind"], kind, + "{envelope}" + ); } fn spawn_scripted_daemon( @@ -353,7 +356,7 @@ fn generic_tool_rejects_truncated_frame_as_typed_failure() { assert_problem(&result, "unavailable"); let envelope: Value = serde_json::from_slice(&result.output.stdout).expect("problem envelope"); assert_eq!( - envelope["problem"]["diagnostic"]["code"], + envelope["structuredContent"]["problem"]["diagnostic"]["code"], "daemon_unavailable" ); assert!(!String::from_utf8_lossy(&result.output.stdout).contains("partial-must-not-escape"));