From f165e1b65d13b3967c0813cbf6903e8b71aa5ae4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 10:07:04 +0000 Subject: [PATCH] test(mcp): pin owner refusals as typed isError problems Since 21552e3764 every graph-tool-owner refusal, including arguments the typed request parser rejects, answers as an isError tool result carrying the owner's whole problem record (an MCP 2025-11-25 tool-execution error) instead of a JSON-RPC error. 74 mcp_suite tests still pinned the JSON-RPC shape; they now assert the problem's literal kind, code and message. Non-object arguments, retained tools, source edits and callers still answer JSON-RPC errors, and their pins say so explicitly. --- .../tests/mcp_suite/analytics_test.rs | 37 +++-- .../mcp_suite/changelog_behavior_test.rs | 44 +++--- .../analysis_request_contract_test.rs | 40 +++-- .../mcp_handler_test/ast_grep_search_test.rs | 22 +-- .../automation_run_list_behavior_test.rs | 27 ++-- .../mcp_handler_test/automation_runs_test.rs | 22 +-- .../automation_skill_request_test.rs | 84 +++++----- .../branch_diff_behavior_test.rs | 27 +++- .../mcp_handler_test/config_behavior_test.rs | 134 ++++++---------- .../constructors_behavior_test.rs | 29 ++-- .../mcp_handler_test/context_behavior_test.rs | 30 ++-- .../mcp_handler_test/coupling_test.rs | 19 +-- .../mcp_handler_test/dependency_hint_test.rs | 14 +- .../mcp_handler_test/derives_test.rs | 40 ++--- .../mcp_handler_test/diagnose_test.rs | 20 ++- .../mcp_suite/mcp_handler_test/edit_test.rs | 19 ++- .../mcp_handler_test/files_behavior_test.rs | 19 ++- .../find_exact_symbol_test.rs | 33 ++-- .../git_context_request_test.rs | 44 +++--- .../mcp_handler_test/graph_analysis_test.rs | 146 +++++++++++------- .../graph_lookup_typed_request_test.rs | 75 ++++----- .../mcp_handler_test/graph_query_test.rs | 80 ++++++---- .../mcp_handler_test/grep_behavior_test.rs | 68 ++++---- .../mcp_handler_test/health_behavior_test.rs | 60 ++++--- .../hermes_skill_bridge_test.rs | 24 +-- .../mcp_handler_test/impact_behavior_test.rs | 32 ++-- .../info_file_request_test.rs | 81 +++++----- .../inheritance_depth_test.rs | 30 ++-- .../mcp_handler_test/node_behavior_test.rs | 32 ++-- .../mcp_handler_test/port_order_test.rs | 51 +++--- .../mcp_handler_test/port_status_test.rs | 51 +++--- .../mcp_handler_test/rank_behavior_test.rs | 58 +++---- .../mcp_handler_test/redundancy_test.rs | 33 ++-- .../mcp_handler_test/rename_preview_test.rs | 32 ++-- .../retrieve_truncation_test.rs | 17 +- .../mcp_suite/mcp_handler_test/schema_test.rs | 70 ++++++++- .../mcp_handler_test/search_behavior_test.rs | 49 +++--- .../signature_behavior_test.rs | 33 ++-- .../mcp_handler_test/similar_test.rs | 22 +-- .../mcp_handler_test/skill_list_test.rs | 28 ++-- .../skill_view_behavior_test.rs | 43 ++---- .../skills_automation_test.rs | 24 +-- .../mcp_handler_test/test_map_test.rs | 54 +++---- .../mcp_suite/mcp_handler_test/todos_test.rs | 17 +- .../mcp_handler_test/unsafe_patterns_test.rs | 37 +++-- .../mcp_server_test/analytics_test.rs | 9 +- .../automation_run_artifact_view_test.rs | 111 +++++-------- .../automation_run_view_test.rs | 70 +++------ .../mcp_server_test/branch_list_test.rs | 16 +- .../mcp_server_test/protocol_test.rs | 19 +-- .../mcp_server_test/retrieve_behavior_test.rs | 139 ++++++----------- crates/tracedecay/tests/mcp_suite/support.rs | 48 ++++++ 52 files changed, 1192 insertions(+), 1171 deletions(-) diff --git a/crates/tracedecay/tests/mcp_suite/analytics_test.rs b/crates/tracedecay/tests/mcp_suite/analytics_test.rs index 43afcf43d1..410c621bcb 100644 --- a/crates/tracedecay/tests/mcp_suite/analytics_test.rs +++ b/crates/tracedecay/tests/mcp_suite/analytics_test.rs @@ -8,7 +8,7 @@ use serde_json::json; #[cfg(feature = "test-transport")] use crate::support::{ extract_json, extract_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, - production_composition_fixture, + production_composition_fixture, refusal_problem, }; #[cfg(feature = "test-transport")] use serde_json::Value; @@ -18,6 +18,14 @@ use tracedecay_global_db::{AnalyticsEventInsert, RegisteredGlobalDb}; #[cfg(feature = "test-transport")] use tracedecay_runtime_core::tracedecay::current_timestamp; +#[cfg(feature = "test-transport")] +fn assert_invalid_request(response: &Value, message: &str) { + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); +} + #[cfg(feature = "test-transport")] fn tool_call_event( project_id: &str, @@ -105,9 +113,9 @@ async fn analytics_reports_tool_tiers_top_tools_and_zero_call_tools() { .await; } let failed_grep = handle_real_server_tool_call_raw(&server, "tracedecay_grep", json!({})).await; - assert!( - failed_grep["error"].is_object(), - "missing grep pattern must fail over production MCP: {failed_grep}" + assert_invalid_request( + &failed_grep, + "invalid arguments for tracedecay_grep: missing field `pattern`", ); handle_real_server_tool_call(&server, "tracedecay_fact_store_list", json!({})).await; server.ledger_writes_settled().await; @@ -280,10 +288,9 @@ async fn analytics_rejects_unknown_scope_and_section() { json!({"scope": "bogus"}), ) .await; - assert_eq!(response["error"]["code"].as_i64(), Some(-32603)); - assert_eq!( - response["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_analytics: unknown variant `bogus`, expected `project` or `all`" + assert_invalid_request( + &response, + "invalid arguments for tracedecay_analytics: unknown variant `bogus`, expected `project` or `all`", ); let response = handle_real_server_tool_call_raw( @@ -292,10 +299,9 @@ async fn analytics_rejects_unknown_scope_and_section() { json!({"section": "bogus"}), ) .await; - assert_eq!(response["error"]["code"].as_i64(), Some(-32603)); - assert_eq!( - response["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_analytics: unknown variant `bogus`, expected one of `tools`, `hints`, `facts`, `automation`" + assert_invalid_request( + &response, + "invalid arguments for tracedecay_analytics: unknown variant `bogus`, expected one of `tools`, `hints`, `facts`, `automation`", ); drop(server); fixture.harness.shutdown().await; @@ -1080,10 +1086,9 @@ async fn analytics_applies_the_requested_window_and_refuses_one_out_of_range() { json!({"section": "tools", "window_days": window_days}), ) .await; - assert_eq!( - refused["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_analytics: window_days must be between 1 and 365", - "{refused}" + assert_invalid_request( + &refused, + "invalid arguments for tracedecay_analytics: window_days must be between 1 and 365", ); } drop(server); diff --git a/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs index 9ec968de66..71477b61a7 100644 --- a/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs @@ -103,33 +103,19 @@ async fn changelog_rejects_missing_and_non_object_arguments() { .await; let missing_from = call_changelog(&repo, json!({"to_ref": "HEAD", "format": "json"})).await; - let missing_from = missing_from - .error - .expect("missing from_ref is a JSON-RPC error"); - assert_eq!(missing_from.code, -32603); - assert_eq!( - missing_from.message, - "tool execution failed: config error: invalid arguments for tracedecay_changelog: missing field `from_ref`" - ); - assert_eq!( - missing_from.data.as_ref().map(|data| &data["tool"]), - Some(&json!("tracedecay_changelog")) + assert_changelog_refusal( + &missing_from, + "invalid arguments for tracedecay_changelog: missing field `from_ref`", ); let missing_to = call_changelog(&repo, json!({"from_ref": "HEAD", "format": "json"})).await; - let missing_to = missing_to - .error - .expect("missing to_ref is a JSON-RPC error"); - assert_eq!(missing_to.code, -32603); - assert_eq!( - missing_to.message, - "tool execution failed: config error: invalid arguments for tracedecay_changelog: missing field `to_ref`" - ); - assert_eq!( - missing_to.data.as_ref().map(|data| &data["tool"]), - Some(&json!("tracedecay_changelog")) + assert_changelog_refusal( + &missing_to, + "invalid arguments for tracedecay_changelog: missing field `to_ref`", ); + // A non-object argument list never reaches the owner's typed parser: the + // MCP boundary rejects it as a malformed call. let not_object = call_changelog(&repo, json!(["HEAD", "HEAD"])).await; let not_object = not_object .error @@ -148,6 +134,20 @@ async fn changelog_rejects_missing_and_non_object_arguments() { ); } +fn assert_changelog_refusal(response: &JsonRpcResponse, message: &str) { + assert!( + response.error.is_none(), + "changelog refusal is a tool result: {:?}", + response.error.as_ref().map(|error| &error.message) + ); + let problem = crate::support::refusal_problem( + response.result.as_ref().expect("changelog refusal result"), + ); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); +} + #[tokio::test] async fn changelog_unknown_ref_is_a_typed_git_error() { let repo = open_repo(|fixture| { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/analysis_request_contract_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/analysis_request_contract_test.rs index f199a12196..7323fa5b7f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/analysis_request_contract_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/analysis_request_contract_test.rs @@ -14,7 +14,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_json, production_composition_fixture_with_sources, - wait_for_current_graph, + refusal_problem, wait_for_current_graph, }; fn write_sources(project: &Path) { @@ -54,21 +54,27 @@ async fn refusal( fixture: &ProductionCompositionFixture, tool_name: &str, arguments: Value, -) -> String { +) -> Value { let response = fixture .harness .call_tool(&fixture.project_root, tool_name, arguments) .await .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); assert!( - response.result.is_none(), + response.error.is_none(), "{tool_name}: {:?}", - response.result + response.error ); - response - .error - .unwrap_or_else(|| panic!("{tool_name} must refuse the request")) - .message + let result = response + .result + .unwrap_or_else(|| panic!("{tool_name} must refuse the request")); + refusal_problem(&result).clone() +} + +fn assert_invalid_request(problem: &Value, message: &str) { + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } #[tokio::test] @@ -94,14 +100,14 @@ async fn analysis_reports_refuse_arguments_outside_their_typed_request() { "ranking": [{"file": "src/b.rs", "coupled_files": 1}], }) ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_coupling", json!({"direction": "fan_in", "limit": "1"}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_coupling: invalid type: string \"1\", expected u32" + "invalid arguments for tracedecay_coupling: invalid type: string \"1\", expected u32", ); assert_eq!( @@ -124,14 +130,14 @@ async fn analysis_reports_refuse_arguments_outside_their_typed_request() { }], }) ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_unsafe_patterns", json!({"kinds": ["unwraps"]}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_unsafe_patterns: unknown variant `unwraps`, expected one of `unwrap`, `expect`, `panic`, `todo`, `unimplemented`, `unsafe_block`" + "invalid arguments for tracedecay_unsafe_patterns: unknown variant `unwraps`, expected one of `unwrap`, `expect`, `panic`, `todo`, `unimplemented`, `unsafe_block`", ); assert_eq!( @@ -148,14 +154,14 @@ async fn analysis_reports_refuse_arguments_outside_their_typed_request() { "distribution": [{"kind": "function", "count": 1}], }) ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_distribution", json!({"path": "src/b.rs", "summary": "yes"}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_distribution: invalid type: string \"yes\", expected a boolean" + "invalid arguments for tracedecay_distribution: invalid type: string \"yes\", expected a boolean", ); fixture.harness.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_search_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_search_test.rs index f29da266a7..77a236f509 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_search_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_search_test.rs @@ -8,7 +8,9 @@ use std::path::Path; use serde_json::{Value, json}; -use crate::support::{ProductionCompositionFixture, production_composition_fixture_with_sources}; +use crate::support::{ + ProductionCompositionFixture, production_composition_fixture_with_sources, refusal_problem, +}; const RUST_CHECKOUT: &str = "\ fn checkout(qty: u32, sku: &str) { @@ -86,17 +88,15 @@ fn tool_text(response: &Value) -> &str { .unwrap_or_else(|| panic!("tools/call returned no text: {response}")) } -fn assert_config_error(response: &Value, detail: &str) { - assert_eq!(response.get("result"), None); - assert_eq!(response["error"]["code"], -32603); - assert_eq!( - response["error"]["message"], - format!("tool execution failed: config error: {detail}") - ); - assert_eq!( - response["error"]["data"]["tool"], - "tracedecay_ast_grep_search" +fn assert_config_error(response: &Value, message: &str) { + assert!( + response.get("error").is_none() || response["error"].is_null(), + "tools/call returned a JSON-RPC error: {response}" ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } fn rust_hit(line: u64, matched: &str, line_text: &str) -> Value { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_run_list_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_run_list_behavior_test.rs index 1460eacc48..e506a95261 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_run_list_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_run_list_behavior_test.rs @@ -21,7 +21,7 @@ use tracedecay_automation_runtime::automation::run_ledger::{ use crate::mcp_server_test::run_client_connection_with_messages; use crate::mcp_server_test::support::{jsonrpc_request, response_with_id}; use crate::support::{ - TestEnv, TestTraceDecay, close_test_graph, init_test_project, real_mcp_server, + TestEnv, TestTraceDecay, close_test_graph, init_test_project, real_mcp_server, refusal_problem, }; const STARTED_AT: &str = "1782283199"; @@ -195,10 +195,12 @@ _No automation runs recorded._ for limit in [0, 201] { let refused = list_call(&served.server, json!({"format": "json", "limit": limit})).await; + let problem = refusal_problem(&refused["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - refused["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_list: limit must be between 1 and 200", - "{refused}" + problem["message"], + "invalid arguments for tracedecay_automation_run_list: limit must be between 1 and 200" ); } } @@ -561,18 +563,11 @@ async fn automation_run_list_refuses_a_non_directory_dashboard_root() { fs::write(&served.dashboard_root, "not a dashboard directory\n").unwrap(); let response = list_call(&served.server, json!({"format": "json"})).await; - assert!(response["result"].is_null(), "{response}"); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "unavailable"); + assert_eq!(problem["code"], "automation_run_ledger_unavailable"); assert_eq!( - response["error"], - json!({ - "code": -32603, - "message": "tool project route failed: reason_code=automation_run_ledger_unavailable retryable=true: automation run ledger is unavailable during list: config error: automation dashboard root is not a directory", - "data": { - "tool": "tracedecay_automation_run_list", - "reason_code": "automation_run_ledger_unavailable", - "retryable": true, - "detail": "automation run ledger is unavailable during list: config error: automation dashboard root is not a directory" - } - }) + problem["message"], + "automation run ledger is unavailable during list: config error: automation dashboard root is not a directory" ); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_runs_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_runs_test.rs index b7b89215b4..9de9794d31 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_runs_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_runs_test.rs @@ -110,17 +110,17 @@ async fn automation_run_list_and_view_use_the_active_project_ledger() { assert_eq!(view_payload["run"]["run_id"], "run-older"); assert_eq!(view_payload["run"]["reviewed_count"], 3); - let missing = server - .call_tool_for_test( - "tracedecay_automation_run_view", - json!({"run_id": "run-missing"}), - ) - .await - .unwrap_err(); - let message = missing.to_string(); - assert!(message.contains("automation run not found: run-missing")); - assert!(!message.contains("run-older")); - assert!(!message.contains("run-newer")); + let problem = expect_tool_refusal( + server + .call_tool_for_test( + "tracedecay_automation_run_view", + json!({"run_id": "run-missing"}), + ) + .await, + ); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], "automation run not found: run-missing"); drop(server); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_skill_request_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_skill_request_test.rs index 8cc760d87f..3ddef40ca5 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_skill_request_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/automation_skill_request_test.rs @@ -47,21 +47,27 @@ async fn refusal( fixture: &ProductionCompositionFixture, tool_name: &str, arguments: Value, -) -> String { +) -> Value { let response = fixture .harness .call_tool(&fixture.project_root, tool_name, arguments) .await .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); assert!( - response.result.is_none(), - "{tool_name} must refuse the request, answered {:?}", - response.result + response.error.is_none(), + "{tool_name} returned a production MCP error: {:?}", + response.error.as_ref().map(|error| &error.message) ); - response - .error - .unwrap_or_else(|| panic!("{tool_name} must refuse the request")) - .message + let result = response + .result + .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")); + crate::support::refusal_problem(&result).clone() +} + +fn assert_invalid_request(problem: &Value, message: &str) { + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } /// One project whose automation ledger holds exactly one application run: @@ -186,14 +192,14 @@ async fn automation_run_list_refuses_a_mistyped_limit() { ); assert_eq!(listed["scope"], "active_project"); assert_eq!(listed["limit"], 50); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_automation_run_list", - json!({"limit": "5"}) + json!({"limit": "5"}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_list: invalid type: string \"5\", expected u32" + "invalid arguments for tracedecay_automation_run_list: invalid type: string \"5\", expected u32", ); fixture.shutdown().await; @@ -215,14 +221,14 @@ async fn automation_run_view_refuses_an_unknown_field() { .await; assert_eq!(viewed["run"], terminal_row); assert_eq!(viewed["run"]["status"], "skipped"); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_automation_run_view", - json!({"run_id": run_id, "verbose": true}) + json!({"run_id": run_id, "verbose": true}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_view: unknown field `verbose`, expected `run_id`" + "invalid arguments for tracedecay_automation_run_view: unknown field `verbose`, expected `run_id`", ); fixture.shutdown().await; @@ -234,23 +240,23 @@ async fn automation_run_artifact_view_refuses_an_unknown_kind() { fixture, run_id, .. } = curated_project().await; - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_automation_run_artifact_view", - json!({"run_id": run_id, "kind": "trace"}) + json!({"run_id": run_id, "kind": "trace"}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_artifact_view: unknown variant `trace`, expected one of `traces`, `feedback`, `generated_evals`, `validation_gate`, `optimizer_diagnosis`, `codex_handoff`" + "invalid arguments for tracedecay_automation_run_artifact_view: unknown variant `trace`, expected one of `traces`, `feedback`, `generated_evals`, `validation_gate`, `optimizer_diagnosis`, `codex_handoff`", ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_automation_run_artifact_view", - json!({"run_id": run_id, "kind": "traces"}) + json!({"run_id": run_id, "kind": "traces"}), ) .await, - format!("automation run artifact not found: {run_id}/traces") + &format!("automation run artifact not found: {run_id}/traces"), ); fixture.shutdown().await; @@ -270,9 +276,9 @@ async fn analytics_refuses_an_unknown_field() { analytics["automation"]["by_job"], json!([{"job": "memory_curator", "succeeded": 0, "failed": 0, "skipped": 1, "other": 0}]) ); - assert_eq!( - refusal(&fixture, "tracedecay_analytics", json!({"windowdays": 7})).await, - "tool execution failed: config error: invalid arguments for tracedecay_analytics: unknown field `windowdays`, expected one of `scope`, `window_days`, `section`" + assert_invalid_request( + &refusal(&fixture, "tracedecay_analytics", json!({"windowdays": 7})).await, + "invalid arguments for tracedecay_analytics: unknown field `windowdays`, expected one of `scope`, `window_days`, `section`", ); fixture.shutdown().await; @@ -291,14 +297,14 @@ async fn skill_list_refuses_an_unknown_field() { .collect(); assert_eq!(ids, vec![&json!("keeper")]); assert_eq!(listed["count"], 1); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_skill_list", - json!({"include_bodies": true}) + json!({"include_bodies": true}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_skill_list: unknown field `include_bodies`, expected `state` or `include_body`" + "invalid arguments for tracedecay_skill_list: unknown field `include_bodies`, expected `state` or `include_body`", ); fixture.shutdown().await; @@ -318,14 +324,14 @@ async fn skill_view_refuses_an_unknown_field() { assert_eq!(viewed["skill"]["body_markdown"], "Keep the ledger."); assert_eq!(viewed["usage_summary"]["view_count"], 1); assert_eq!(viewed["support_files_included"], false); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_skill_view", - json!({"id": "keeper", "include_support": true}) + json!({"id": "keeper", "include_support": true}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_skill_view: unknown field `include_support`, expected `id` or `include_support_files`" + "invalid arguments for tracedecay_skill_view: unknown field `include_support`, expected `id` or `include_support_files`", ); fixture.shutdown().await; @@ -358,14 +364,14 @@ async fn hermes_skill_bridge_refuses_an_unknown_field() { bridge["bridge"]["skills"][0]["description"], "Keeps the ledger" ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_hermes_skill_bridge", - json!({"include_skill_body": true}) + json!({"include_skill_body": true}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_hermes_skill_bridge: unknown field `include_skill_body`, expected `include_skill_bodies` or `include_pending_payloads`" + "invalid arguments for tracedecay_hermes_skill_bridge: unknown field `include_skill_body`, expected `include_skill_bodies` or `include_pending_payloads`", ); fixture.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_diff_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_diff_behavior_test.rs index 5713f67141..b3d0a3ad56 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_diff_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_diff_behavior_test.rs @@ -13,7 +13,7 @@ use tracedecay::mcp::McpServer; use crate::common::fixture::{git_capture, git_run}; use crate::support::{ - handle_real_server_tool_call, handle_real_server_tool_call_raw, test_temp_dir, + handle_real_server_tool_call, handle_real_server_tool_call_raw, refusal_problem, test_temp_dir, }; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -300,14 +300,18 @@ async fn branch_diff_reports_the_symbols_that_differ_between_master_and_feature( let missing_base = handle_real_server_tool_call_raw(&server, "tracedecay_branch_diff", json!({})).await; - assert_eq!(missing_base["error"]["code"], -32603, "{missing_base}"); + let missing_base_problem = refusal_problem(&missing_base["result"]); assert_eq!( - missing_base["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_branch_diff: missing field `base`", + missing_base_problem["kind"], "invalid_request", "{missing_base}" ); assert_eq!( - missing_base["error"]["data"]["tool"], "tracedecay_branch_diff", + missing_base_problem["code"], "application.surface.invalid_request", + "{missing_base}" + ); + assert_eq!( + missing_base_problem["message"], + "invalid arguments for tracedecay_branch_diff: missing field `base`", "{missing_base}" ); @@ -317,10 +321,17 @@ async fn branch_diff_reports_the_symbols_that_differ_between_master_and_feature( json!({"base": "master", "head": "feature", "limit": 0}), ) .await; - assert_eq!(zero_limit["error"]["code"], -32603, "{zero_limit}"); + let zero_limit_problem = refusal_problem(&zero_limit["result"]); + assert_eq!( + zero_limit_problem["kind"], "invalid_request", + "{zero_limit}" + ); + assert_eq!( + zero_limit_problem["code"], "application.surface.invalid_request", + "{zero_limit}" + ); assert_eq!( - zero_limit["error"]["message"], - "tool execution failed: config error: branch-diff limit must be positive", + zero_limit_problem["message"], "branch-diff limit must be positive", "{zero_limit}" ); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/config_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/config_behavior_test.rs index 3385b0ea02..597d186c2c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/config_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/config_behavior_test.rs @@ -11,7 +11,7 @@ //! omitted, and the scan does not consult gitignore. use crate::support::{ - handle_real_server_tool_call_raw, production_composition_fixture_with_sources, + handle_real_server_tool_call_raw, production_composition_fixture_with_sources, refusal_problem, }; use serde_json::{Value, json}; use std::fs; @@ -20,10 +20,6 @@ use std::path::Path; #[cfg(unix)] use std::os::unix::fs::symlink; -const CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool config ...` \ -(`tracedecay tool config --help` for parameters). If MCP calls keep failing or timing out, fall \ -back to that CLI instead of querying .tracedecay databases directly."; - const APP_TOML: &str = r#"name = "top" ratio = 1.5 enabled = false @@ -178,36 +174,15 @@ fn assert_markdown(response: &Value, text: &str, touched: &[usize]) { assert_eq!(content.len(), 2, "{response}"); } -fn invalid_params(message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": -32602, - "message": message, - "data": { - "tool": "tracedecay_config", - "reason_code": "missing_required_parameter", - "retryable": false, - "detail": message, - } - } - }) -} - -fn execution_failed(message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": -32603, - "message": message, - "data": { - "tool": "tracedecay_config", - "cli_fallback": CLI_FALLBACK, - } - } - }) +fn assert_config_refusal(response: &Value, message: &str) { + assert!( + response.get("error").is_none() || response["error"].is_null(), + "config refusal must be an isError result: {response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message, "{response}"); } fn hit(file: &str, key: &str, value: Value, line: Option) -> Value { @@ -253,11 +228,9 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { ); let missing_key = config(&server, json!({"path": "app.toml", "format": "json"})).await; - assert_eq!( - missing_key, - execution_failed( - "tool execution failed: config error: invalid arguments for tracedecay_config: missing field `key`" - ) + assert_config_refusal( + &missing_key, + "invalid arguments for tracedecay_config: missing field `key`", ); let non_string_key = config( @@ -265,17 +238,15 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { json!({"key": 1, "path": "app.toml", "format": "json"}), ) .await; - assert_eq!( - non_string_key, - execution_failed( - "tool execution failed: config error: invalid arguments for tracedecay_config: invalid type: integer `1`, expected a string" - ) + assert_config_refusal( + &non_string_key, + "invalid arguments for tracedecay_config: invalid type: integer `1`, expected a string", ); let missing_locator = config(&server, json!({"key": "package.name", "format": "json"})).await; - assert_eq!( - missing_locator, - invalid_params("missing required parameter: 'path' or 'glob'") + assert_config_refusal( + &missing_locator, + "missing required parameter: 'path' or 'glob'", ); let non_string_path = config( @@ -283,11 +254,9 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { json!({"key": "package.name", "path": true, "format": "json"}), ) .await; - assert_eq!( - non_string_path, - execution_failed( - "tool execution failed: config error: invalid arguments for tracedecay_config: invalid type: boolean `true`, expected a string" - ) + assert_config_refusal( + &non_string_path, + "invalid arguments for tracedecay_config: invalid type: boolean `true`, expected a string", ); let both_locators = config( @@ -300,11 +269,9 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { }), ) .await; - assert_eq!( - both_locators, - execution_failed( - "tool execution failed: config error: tracedecay_config: 'path' and 'glob' are mutually exclusive" - ) + assert_config_refusal( + &both_locators, + "tracedecay_config: 'path' and 'glob' are mutually exclusive", ); let dotted_path = config( @@ -312,34 +279,21 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { json!({"key": "package.name", "path": "./app.toml", "format": "json"}), ) .await; - assert_eq!( - dotted_path, - execution_failed( - "tool execution failed: config error: path './app.toml' is not normalized" - ) - ); + assert_config_refusal(&dotted_path, "path './app.toml' is not normalized"); let parent_path = config( &server, json!({"key": "token", "path": "../outside.toml", "format": "json"}), ) .await; - assert_eq!( - parent_path, - execution_failed( - "tool execution failed: config error: path '../outside.toml' is not normalized" - ) - ); + assert_config_refusal(&parent_path, "path '../outside.toml' is not normalized"); let bad_glob = config( &server, json!({"key": "package.name", "glob": "[", "format": "json"}), ) .await; - assert_eq!( - bad_glob, - execution_failed(&invalid_glob_message(&project_root)) - ); + assert_config_refusal(&bad_glob, &invalid_glob_message(&project_root)); let missing_file = project_root.join("no-such.toml"); let absent = config( @@ -347,12 +301,12 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { json!({"key": "package.name", "path": "no-such.toml", "format": "json"}), ) .await; - assert_eq!( - absent, - execution_failed(&format!( - "tool execution failed: config error: failed to canonicalize project path '{}': {MISSING_OS_ERROR}", + assert_config_refusal( + &absent, + &format!( + "failed to canonicalize project path '{}': {MISSING_OS_ERROR}", missing_file.display() - )) + ), ); let outside = project_root @@ -366,12 +320,12 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { json!({"key": "token", "path": outside_arg, "format": "json"}), ) .await; - assert_eq!( - absolute_escape, - execution_failed(&format!( - "tool execution failed: config error: path '{outside_arg}' escapes project root '{}'", + assert_config_refusal( + &absolute_escape, + &format!( + "path '{outside_arg}' escapes project root '{}'", project_root.display() - )) + ), ); #[cfg(unix)] @@ -388,12 +342,12 @@ async fn tracedecay_config_reports_literal_values_and_typed_failures() { json!({"key": "token", "path": "escape/secret.toml", "format": "json"}), ) .await; - assert_eq!( - symlink_escape, - execution_failed(&format!( - "tool execution failed: config error: path 'escape/secret.toml' escapes project root '{}'", + assert_config_refusal( + &symlink_escape, + &format!( + "path 'escape/secret.toml' escapes project root '{}'", project_root.display() - )) + ), ); } @@ -778,6 +732,6 @@ fn invalid_glob_message(project_root: &Path) -> String { let pattern = joined.to_string_lossy(); let position = pattern.len() - 1; format!( - "tool execution failed: config error: invalid glob '[': Pattern syntax error near position {position}: invalid range pattern" + "invalid glob '[': Pattern syntax error near position {position}: invalid range pattern" ) } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/constructors_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/constructors_behavior_test.rs index b16b717093..1c21fa7960 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/constructors_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/constructors_behavior_test.rs @@ -14,7 +14,7 @@ use tracedecay_mcp::JsonRpcResponse; use crate::support::{ ProductionCompositionFixture, extract_first_json_content, harness_wait_for_readiness, - production_composition_fixture_with_sources, + production_composition_fixture_with_sources, refusal_problem, }; const FIXTURE_SOURCE: &str = r#"#[derive(Default)] @@ -142,26 +142,15 @@ fn json_payload(response: &JsonRpcResponse) -> Value { } fn assert_struct_argument_refused(response: &JsonRpcResponse, detail: &str) { - let error = response - .error - .as_ref() - .expect("a call without a struct name is a JSON-RPC error"); - assert!(response.result.is_none(), "{response:?}"); - assert_eq!(error.code, -32603); - assert_eq!( - error.message, - format!( - "tool execution failed: config error: invalid arguments for tracedecay_constructors: {detail}" - ) - ); - assert_eq!( - error - .data - .as_ref() - .and_then(|data| data.get("tool")) - .and_then(Value::as_str), - Some("tracedecay_constructors") + assert!( + response.error.is_none(), + "a call without a struct name must be an isError refusal: {response:?}" ); + let problem = refusal_problem(response.result.as_ref().expect("refusal result")); + let message = format!("invalid arguments for tracedecay_constructors: {detail}"); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message.as_str()); } #[tokio::test] diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs index 37c5c2d5c8..05ddf966af 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs @@ -15,7 +15,7 @@ use tracedecay::mcp::McpServer; use crate::support::{ extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, - production_composition_fixture_with_sources, warm_code_index_search, + production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; fn write_billing_sources(project: &Path) { @@ -301,15 +301,16 @@ async fn tracedecay_context_returns_invoice_total_and_tax_policy() { } fn assert_rejected(response: &Value, message: &str) { - assert!( - response.get("result").is_none(), - "rejected call must not return a result: {response}" - ); assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], 1); - assert_eq!(response["error"]["code"], json!(-32603)); - assert_eq!(response["error"]["message"], message); - assert_eq!(response["error"]["data"]["tool"], "tracedecay_context"); + assert!( + response.get("error").is_none() || response["error"].is_null(), + "rejected call returned a JSON-RPC error: {response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message, "{response}"); } #[tokio::test] @@ -323,7 +324,7 @@ async fn tracedecay_context_rejects_malformed_requests() { let missing = handle_real_server_tool_call_raw(&server, "tracedecay_context", json!({})).await; assert_rejected( &missing, - "tool execution failed: config error: invalid arguments for tracedecay_context: missing field `task`", + "invalid arguments for tracedecay_context: missing field `task`", ); let unknown = handle_real_server_tool_call_raw( @@ -334,7 +335,7 @@ async fn tracedecay_context_rejects_malformed_requests() { .await; assert_rejected( &unknown, - "tool execution failed: config error: invalid arguments for tracedecay_context: unknown field `not_a_context_field`, expected one of `task`, `max_nodes`, `include_code`, `max_code_blocks`, `mode`, `include_memory`, `memory_limit`, `memory_min_trust`, `lexical_anchors`, `prefer_symbol`", + "invalid arguments for tracedecay_context: unknown field `not_a_context_field`, expected one of `task`, `max_nodes`, `include_code`, `max_code_blocks`, `mode`, `include_memory`, `memory_limit`, `memory_min_trust`, `lexical_anchors`, `prefer_symbol`", ); let mode = handle_real_server_tool_call_raw( @@ -345,7 +346,7 @@ async fn tracedecay_context_rejects_malformed_requests() { .await; assert_rejected( &mode, - "tool execution failed: config error: invalid arguments for tracedecay_context: unknown variant `nope`, expected `explore` or `plan`", + "invalid arguments for tracedecay_context: unknown variant `nope`, expected `explore` or `plan`", ); let empty_anchor = handle_real_server_tool_call_raw( @@ -354,10 +355,7 @@ async fn tracedecay_context_rejects_malformed_requests() { json!({"task": "invoice_total", "lexical_anchors": [""]}), ) .await; - assert_rejected( - &empty_anchor, - "tool execution failed: config error: lexical anchor 0 is empty", - ); + assert_rejected(&empty_anchor, "lexical anchor 0 is empty"); let spaced_anchor = handle_real_server_tool_call_raw( &server, @@ -367,7 +365,7 @@ async fn tracedecay_context_rejects_malformed_requests() { .await; assert_rejected( &spaced_anchor, - "tool execution failed: config error: lexical anchor 0 must be one identifier or technical term: no surrounding whitespace, inner whitespace, or control characters", + "lexical anchor 0 must be one identifier or technical term: no surrounding whitespace, inner whitespace, or control characters", ); fixture.harness.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/coupling_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/coupling_test.rs index 303c0ce5d0..f7179a0b3b 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/coupling_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/coupling_test.rs @@ -22,7 +22,7 @@ use serde_json::{Value, json}; use tracedecay_mcp::jsonrpc::JsonRpcResponse; use crate::support::{ - ProductionCompositionFixture, production_composition_fixture_with_sources, + ProductionCompositionFixture, production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; @@ -192,15 +192,16 @@ async fn coupling_ranks_distinct_other_files_for_fan_in_and_fan_out() { assert_eq!(tool_text(&markdown), FAN_IN_MARKDOWN); let invalid = call_coupling(&fixture, json!({"direction": "sideways", "format": "json"})).await; - let error = invalid - .error - .as_ref() - .expect("invalid direction must fail the MCP call"); - assert!(invalid.result.is_none(), "{invalid:?}"); - assert_eq!(error.code, -32603); + assert!( + invalid.error.is_none(), + "invalid direction must be an isError refusal: {invalid:?}" + ); + let problem = refusal_problem(invalid.result.as_ref().expect("refusal result")); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - error.message, - "tool execution failed: config error: invalid arguments for tracedecay_coupling: unknown variant `sideways`, expected `fan_in` or `fan_out`" + problem["message"], + "invalid arguments for tracedecay_coupling: unknown variant `sideways`, expected `fan_in` or `fan_out`" ); fixture.harness.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs index 78e33a796f..70d7e262e2 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs @@ -304,16 +304,22 @@ async fn lazy_dependency_fixture( } fn assert_generation_advanced_retry(response: &Value) { - assert!( - response["result"].is_null(), + let problem = refusal_problem(&response["result"]); + assert_eq!( + problem["kind"], "unavailable", "the generation-advancing call must not return a same-call symbol payload: {response}" ); assert_eq!( - response["error"]["data"]["reason_code"].as_str(), + problem["code"].as_str(), Some(GENERATION_ADVANCED_REASON), "lazy admission must expose the canonical usecase retry reason: {response}" ); - assert_eq!(response["error"]["data"]["retryable"], true); + assert_eq!( + problem["message"], + "ignored dependency indexing advanced the graph generation; retry the request", + "{response}" + ); + assert_eq!(problem["retryable"], true, "{response}"); } fn code_generation(payload: &Value) -> &str { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs index f01c291c00..7c5aa8424c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs @@ -7,7 +7,7 @@ use crate::support::{ ProductionCompositionFixture, dispatch_mcp_tool_call, - production_composition_fixture_with_sources, warm_code_index_search, + production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; use serde_json::{Value, json}; use std::fs; @@ -211,33 +211,33 @@ async fn derives_reports_exact_attached_macro_names() { "No matching symbol found." ); + let missing_selector = call_error(&fixture, json!({})).await; + assert_eq!(missing_selector["kind"], "invalid_request"); assert_eq!( - call_error(&fixture, json!({})).await, - json!({ - "code": -32602, - "message": "missing required parameter: qualified_name or node_id", - "data": { - "tool": "tracedecay_derives", - "reason_code": "missing_required_parameter", - "retryable": false, - "detail": "missing required parameter: qualified_name or node_id" - } - }) + missing_selector["code"], + "application.surface.invalid_request" + ); + assert_eq!( + missing_selector["message"], + "missing required parameter: qualified_name or node_id" ); let empty_id = call_error(&fixture, json!({"node_id": ""})).await; - assert_eq!(empty_id["code"], -32603); + assert_eq!(empty_id["kind"], "invalid_request"); + assert_eq!(empty_id["code"], "application.surface.invalid_request"); assert_eq!( empty_id["message"], - "tool execution failed: config error: invalid parameter: node_id must not be empty" + "invalid parameter: node_id must not be empty" ); - assert_eq!(empty_id["data"]["tool"], "tracedecay_derives"); let evidence_anchor = call_error(&fixture, json!({"node_id": "code-file:not-a-symbol"})).await; - assert_eq!(evidence_anchor["code"], -32603); + assert_eq!(evidence_anchor["kind"], "invalid_request"); + assert_eq!( + evidence_anchor["code"], + "application.surface.invalid_request" + ); assert_eq!( evidence_anchor["message"], - "tool execution failed: config error: invalid parameter: node_id `code-file:not-a-symbol` is an evidence anchor, not a graph symbol occurrence" + "invalid parameter: node_id `code-file:not-a-symbol` is an evidence anchor, not a graph symbol occurrence" ); - assert_eq!(evidence_anchor["data"]["tool"], "tracedecay_derives"); fixture.harness.shutdown().await; } @@ -288,8 +288,8 @@ async fn call_text(fixture: &ProductionCompositionFixture, arguments: Value) -> async fn call_error(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { match call_derives(fixture, arguments).await { - Ok(result) => panic!("expected a JSON-RPC error, got: {result}"), - Err(error) => error, + Ok(result) => refusal_problem(&result).clone(), + Err(error) => panic!("expected an isError refusal, got a JSON-RPC error: {error}"), } } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs index 07e25e3dd4..ecbc1d280c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs @@ -14,7 +14,7 @@ use serde_json::{Value, json}; use tracedecay_mcp::JsonRpcResponse; use crate::support::{ - ProductionCompositionFixture, production_composition_fixture_with_sources, + ProductionCompositionFixture, production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; @@ -324,18 +324,16 @@ _No diagnostics._ ); let refused = diagnose_rpc(&fixture, json!({})).await; - let error = refused - .error - .as_ref() - .expect("missing cargo_output is a JSON-RPC error"); - assert_eq!(error.code, -32603); - assert_eq!( - error.message, - "tool execution failed: config error: invalid arguments for tracedecay_diagnose: missing field `cargo_output`" + assert!( + refused.error.is_none(), + "missing cargo_output must be an isError refusal: {refused:?}" ); + let problem = refusal_problem(refused.result.as_ref().expect("refusal result")); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - error.data.as_ref().map(|data| &data["tool"]), - Some(&json!("tracedecay_diagnose")) + problem["message"], + "invalid arguments for tracedecay_diagnose: missing field `cargo_output`" ); fixture.harness.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs index ffd213f88c..2909402ae2 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs @@ -164,9 +164,12 @@ async fn path_containment_config_rejects_parent_traversal_before_serving_config( ) .await; - assert!( - result.is_err(), - "config read should reject parent traversal, got {result:?}" + let problem = expect_tool_refusal(result); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!( + problem["message"], + "path '../outside.toml' is not normalized" ); close_test_graph(cg).await; } @@ -198,10 +201,14 @@ async fn path_containment_config_rejects_symlink_escape_before_serving_config() ) .await; - assert!( - result.is_err(), - "config read should reject symlink escape, got {result:?}" + let problem = expect_tool_refusal(result); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + let message = format!( + "path 'escape/secret.toml' escapes project root '{}'", + project.display() ); + assert_eq!(problem["message"], message.as_str()); } #[tokio::test] diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs index df2d1e41af..08e1367705 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_first_json_content, - production_composition_fixture_with_sources, + production_composition_fixture_with_sources, refusal_problem, }; const TOOL: &str = "tracedecay_files"; @@ -241,9 +241,9 @@ async fn files_lists_the_indexed_census_and_filters() { assert_tool_error( &call(&fixture, json!({"pattern": "["})).await, - "tool execution failed: config error: invalid file glob '[': Pattern syntax error near position 0: invalid range pattern", + "invalid file glob '[': Pattern syntax error near position 0: invalid range pattern", ); - assert_tool_error( + assert_malformed_call( &call(&fixture, json!([])).await, "tool execution failed: config error: invalid arguments: tracedecay_files expects a JSON object", ); @@ -320,6 +320,19 @@ async fn call( } fn assert_tool_error(response: &tracedecay_mcp::JsonRpcResponse, message: &str) { + assert!( + response.error.is_none(), + "{TOOL} refusal must be an isError result: {response:?}" + ); + let problem = refusal_problem(response.result.as_ref().expect("refusal result")); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message, "{response:?}"); +} + +/// A non-object argument list never reaches the owner's typed parser: the +/// MCP boundary rejects the call itself. +fn assert_malformed_call(response: &tracedecay_mcp::JsonRpcResponse, message: &str) { assert!(response.result.is_none(), "{response:?}"); let error = response.error.as_ref().expect("tool error"); assert_eq!(error.code, -32603, "{error:?}"); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs index 5cb660789f..4771adb6e3 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs @@ -7,7 +7,7 @@ use crate::support::{ ProductionCompositionFixture, extract_real_server_text, handle_real_server_tool_call_raw, - production_composition_fixture_with_sources, warm_code_index_search, + production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; use serde_json::{Value, json}; use std::fs; @@ -227,15 +227,15 @@ async fn find_exact_symbol_applies_limit_and_rejects_bad_arguments() { json!({"format": "json"}), ) .await; - assert_eq!(missing["result"], Value::Null); - assert_eq!(missing["error"]["code"], -32603); + let missing_problem = refusal_problem(&missing["result"]); + assert_eq!(missing_problem["kind"], "invalid_request"); assert_eq!( - missing["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_find_exact_symbol: missing field `name`" + missing_problem["code"], + "application.surface.invalid_request" ); assert_eq!( - missing["error"]["data"]["tool"], - json!("tracedecay_find_exact_symbol") + missing_problem["message"], + "invalid arguments for tracedecay_find_exact_symbol: missing field `name`" ); let zero = handle_real_server_tool_call_raw( @@ -244,21 +244,14 @@ async fn find_exact_symbol_applies_limit_and_rejects_bad_arguments() { json!({"name": "shared_token", "limit": 0, "format": "json"}), ) .await; - assert_eq!(zero["result"], Value::Null); - assert_eq!(zero["error"]["code"], -32602); + let zero_problem = refusal_problem(&zero["result"]); + assert_eq!(zero_problem["kind"], "unavailable"); + assert_eq!(zero_problem["code"], "code-graph-invalid-request"); assert_eq!( - zero["error"]["message"], - "tool project route failed: reason_code=code-graph-invalid-request retryable=false: the code-graph read request is invalid: code graph name resolution limit must be positive" - ); - assert_eq!( - zero["error"]["data"], - json!({ - "tool": "tracedecay_find_exact_symbol", - "reason_code": "code-graph-invalid-request", - "retryable": false, - "detail": "the code-graph read request is invalid: code graph name resolution limit must be positive", - }) + zero_problem["message"], + "the code-graph read request is invalid: code graph name resolution limit must be positive" ); + assert_eq!(zero_problem["retryable"], false); fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/git_context_request_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/git_context_request_test.rs index b569ff859d..1990db26ea 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/git_context_request_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/git_context_request_test.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_text, production_composition_fixture_with_sources, - wait_for_current_graph, + refusal_problem, wait_for_current_graph, }; fn write_probe_sources(project: &Path) { @@ -67,21 +67,27 @@ async fn refusal( fixture: &ProductionCompositionFixture, tool_name: &str, arguments: Value, -) -> String { +) -> Value { let response = fixture .harness .call_tool(&fixture.project_root, tool_name, arguments) .await .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); assert!( - response.result.is_none(), - "{tool_name} must refuse the request, answered {:?}", - response.result + response.error.is_none(), + "{tool_name} returned a production MCP error: {:?}", + response.error.as_ref().map(|error| &error.message) ); - response - .error - .unwrap_or_else(|| panic!("{tool_name} must refuse the request")) - .message + let result = response + .result + .unwrap_or_else(|| panic!("{tool_name} must refuse the request")); + refusal_problem(&result).clone() +} + +fn assert_invalid_request(problem: &Value, message: &str) { + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } #[tokio::test] @@ -102,14 +108,14 @@ async fn git_context_tools_refuse_arguments_outside_their_typed_request() { .await, r#"{"changed_files":[],"recent_commits":["production composition fixture"],"suggested_category":null,"summary":"No changes detected.","symbols_by_role":{}}"# ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_commit_context", - json!({"staged_only": "yes"}) + json!({"staged_only": "yes"}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_commit_context: invalid type: string \"yes\", expected a boolean" + "invalid arguments for tracedecay_commit_context: invalid type: string \"yes\", expected a boolean", ); assert_eq!( @@ -121,14 +127,14 @@ async fn git_context_tools_refuse_arguments_outside_their_typed_request() { .await, r#"{"affected_tests":["tests/probe_test.rs"],"changed_files":["tests/probe_test.rs"],"count":1,"ranked_tests":[{"distance":0,"path":"tests/probe_test.rs","proximity":"changed","rank":1}],"ranking_metadata":{"compatibility_field":"affected_tests","distance":"minimum file-dependency hops from the changed files","recommended_proximity":["changed","direct","near"],"strategy":"dependency_distance_then_path"},"recommended_tests":["tests/probe_test.rs"]}"# ); - assert_eq!( - refusal( + assert_invalid_request( + &refusal( &fixture, "tracedecay_affected", json!({"files": ["tests/probe_test.rs"], "depth": "3"}), ) .await, - "tool execution failed: config error: invalid arguments for tracedecay_affected: invalid type: string \"3\", expected u32" + "invalid arguments for tracedecay_affected: invalid type: string \"3\", expected u32", ); let project = fixture.project_root.as_path(); @@ -146,9 +152,9 @@ async fn git_context_tools_refuse_arguments_outside_their_typed_request() { r#"{{"examined":1,"limit":5,"next_after":null,"reason":null,"snapshot_count":1,"snapshots":[{{"branch":"{branch}","source_revision":"{commit}","source_tree":"{tree}"}}],"status":"complete"}}"# ) ); - assert_eq!( - refusal(&fixture, "tracedecay_branch_list", json!({"limt": 5})).await, - "tool execution failed: config error: invalid arguments for tracedecay_branch_list: unknown field `limt`, expected `limit` or `after`" + assert_invalid_request( + &refusal(&fixture, "tracedecay_branch_list", json!({"limt": 5})).await, + "invalid arguments for tracedecay_branch_list: unknown field `limt`, expected `limit` or `after`", ); fixture.harness.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs index 31e899a73d..8de71c78aa 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs @@ -119,6 +119,12 @@ async fn handle_tool_call( .await } +fn assert_refusal_problem(problem: &Value, kind: &str, code: &str, message: &str) { + assert_eq!(problem["kind"], kind, "{problem}"); + assert_eq!(problem["code"], code, "{problem}"); + assert_eq!(problem["message"], message, "{problem}"); +} + async fn close_test_graph(host: impl AnalysisToolHost) { host.close_analysis_host().await; } @@ -1690,11 +1696,12 @@ async fn test_dsm_reports_authored_file_dependencies() { None, None, ) - .await - .expect_err("an unknown DSM shape must be refused"); - assert_eq!( - unknown.to_string(), - "config error: tracedecay_dsm failed over production MCP: tool execution failed: config error: invalid arguments for tracedecay_dsm: unknown variant `layers`, expected one of `stats`, `clusters`, `matrix`" + .await; + assert_refusal_problem( + &expect_tool_refusal(unknown), + "invalid_request", + "application.surface.invalid_request", + "invalid arguments for tracedecay_dsm: unknown variant `layers`, expected one of `stats`, `clusters`, `matrix`", ); let matrix = @@ -3680,11 +3687,12 @@ async fn pr_context_reports_the_pinned_feature_summary() { None, None, ) - .await - .expect_err("a numeric cursor is not a continuation token"); - assert_eq!( - cursor.to_string(), - "config error: tracedecay_pr_context failed over production MCP: tool execution failed: config error: invalid arguments for tracedecay_pr_context: invalid type: integer `1`, expected a string" + .await; + assert_refusal_problem( + &expect_tool_refusal(cursor), + "invalid_request", + "application.surface.invalid_request", + "invalid arguments for tracedecay_pr_context: invalid type: integer `1`, expected a string", ); close_test_graph(host).await; @@ -3937,8 +3945,9 @@ pub fn write_both(target: &mut Target, other: &mut Other) { "payload: {output}" ); - for shadow_source in [ - r#" + for (shadow_source, message) in [ + ( + r#" pub struct Target { pub value: u32 } pub struct Other { pub value: u32 } @@ -3947,7 +3956,10 @@ pub fn closure_then_sibling(target: &Target) -> u32 { read_other(Other { value: 3 }) + target.value } "#, - r#" + "the indexed graph cannot bind field receiver '' at src/lib.rs:6 to exactly one qualified owner", + ), + ( + r#" pub struct Target { pub value: u32 } pub struct Other { pub value: u32 } @@ -3956,7 +3968,10 @@ pub fn if_let_then_sibling(target: &Target, other: Option) -> u32 { read_other + target.value } "#, - r#" + "the indexed graph cannot bind field receiver '' at src/lib.rs:6 to exactly one qualified owner", + ), + ( + r#" pub struct Target { pub value: u32 } pub struct Other { pub value: u32 } @@ -3966,13 +3981,15 @@ pub fn while_let_then_sibling(target: &Target, mut other: Option) -> u32 read_other + target.value } "#, + "the indexed graph cannot bind field receiver '' at src/lib.rs:7 to exactly one qualified owner", + ), ] { let shadow_dir = test_temp_dir(); let shadow_root = shadow_dir.path().join("project"); fs::create_dir_all(shadow_root.join("src")).unwrap(); fs::write(shadow_root.join("src/lib.rs"), shadow_source).unwrap(); let shadow_host = init_test_project(&shadow_root).await; - let error = expect_tool_error( + let problem = expect_tool_refusal( handle_tool_call( &shadow_host, "tracedecay_field_sites", @@ -3982,9 +3999,11 @@ pub fn while_let_then_sibling(target: &Target, mut other: Option) -> u32 ) .await, ); - assert!( - error.contains("verified-field-qualifier-unavailable"), - "shadowed receiver must not be attributed to the parameter owner: {error}" + assert_refusal_problem( + &problem, + "unavailable", + "verified-field-qualifier-unavailable", + message, ); } } @@ -4196,7 +4215,7 @@ async fn field_sites_behavior_reports_literal_read_and_write_sites() { }), ); - let missing_field = expect_tool_error( + let missing_field = expect_tool_refusal( handle_tool_call( &host, "tracedecay_field_sites", @@ -4206,15 +4225,17 @@ async fn field_sites_behavior_reports_literal_read_and_write_sites() { ) .await, ); - assert_eq!( - missing_field, - "config error: tracedecay_field_sites failed over production MCP: tool execution failed: config error: invalid arguments for tracedecay_field_sites: missing field `field`" + assert_refusal_problem( + &missing_field, + "invalid_request", + "application.surface.invalid_request", + "invalid arguments for tracedecay_field_sites: missing field `field`", ); // `take!` is parseable Rust, but its body is a token tree, so the qualifier // path cannot bind `counter.n` to `Counter`. The first unbound site stops // the qualified census. - let unbound_macro = expect_tool_error( + let unbound_macro = expect_tool_refusal( handle_tool_call( &host, "tracedecay_field_sites", @@ -4224,9 +4245,11 @@ async fn field_sites_behavior_reports_literal_read_and_write_sites() { ) .await, ); - assert_eq!( - unbound_macro, - "config error: tracedecay_field_sites failed over production MCP: tool project route failed: reason_code=verified-field-qualifier-unavailable retryable=false: the indexed graph cannot bind field receiver '' at src/lib.rs:31 to exactly one qualified owner" + assert_refusal_problem( + &unbound_macro, + "unavailable", + "verified-field-qualifier-unavailable", + "the indexed graph cannot bind field receiver '' at src/lib.rs:31 to exactly one qualified owner", ); close_test_graph(host).await; @@ -4307,7 +4330,7 @@ pub fn closure_then_sibling(counter: &Counter) -> u32 { .unwrap(); let host = init_test_project(&project_root).await; - let error = expect_tool_error( + let problem = expect_tool_refusal( handle_tool_call( &host, "tracedecay_field_sites", @@ -4317,9 +4340,11 @@ pub fn closure_then_sibling(counter: &Counter) -> u32 { ) .await, ); - assert_eq!( - error, - "config error: tracedecay_field_sites failed over production MCP: tool project route failed: reason_code=verified-field-qualifier-unavailable retryable=false: the indexed graph cannot bind field receiver '' at src/lib.rs:9 to exactly one qualified owner" + assert_refusal_problem( + &problem, + "unavailable", + "verified-field-qualifier-unavailable", + "the indexed graph cannot bind field receiver '' at src/lib.rs:9 to exactly one qualified owner", ); close_test_graph(host).await; @@ -4600,11 +4625,11 @@ async fn diff_context_reports_changed_symbols_callers_and_refuses_invalid_input( None, ) .await; - assert_eq!( - missing_files - .expect_err("missing files must be refused") - .to_string(), - "config error: tracedecay_diff_context failed over production MCP: tool execution failed: config error: invalid arguments for tracedecay_diff_context: missing field `files`" + assert_refusal_problem( + &expect_tool_refusal(missing_files), + "invalid_request", + "application.surface.invalid_request", + "invalid arguments for tracedecay_diff_context: missing field `files`", ); let files_not_array = handle_tool_call( @@ -4615,11 +4640,11 @@ async fn diff_context_reports_changed_symbols_callers_and_refuses_invalid_input( None, ) .await; - assert_eq!( - files_not_array - .expect_err("a string files argument must be refused") - .to_string(), - "config error: tracedecay_diff_context failed over production MCP: tool execution failed: config error: invalid arguments for tracedecay_diff_context: invalid type: string \"src/tier_c.rs\", expected a sequence" + assert_refusal_problem( + &expect_tool_refusal(files_not_array), + "invalid_request", + "application.surface.invalid_request", + "invalid arguments for tracedecay_diff_context: invalid type: string \"src/tier_c.rs\", expected a sequence", ); let not_object = handle_tool_call( @@ -4630,6 +4655,8 @@ async fn diff_context_reports_changed_symbols_callers_and_refuses_invalid_input( None, ) .await; + // A non-object argument list never reaches the owner's typed parser: the + // MCP boundary rejects the call itself. assert_eq!( not_object .expect_err("non-object arguments must be refused") @@ -4645,9 +4672,11 @@ async fn diff_context_reports_changed_symbols_callers_and_refuses_invalid_input( None, ) .await; - assert_eq!( - zero_depth.expect_err("depth 0 must be refused").to_string(), - "config error: tracedecay_diff_context failed over production MCP: tool project route failed: reason_code=code-graph-invalid-request retryable=false: the code-graph read request is invalid: code graph impact depth must be positive" + assert_refusal_problem( + &expect_tool_refusal(zero_depth), + "unavailable", + "code-graph-invalid-request", + "the code-graph read request is invalid: code graph impact depth must be positive", ); close_test_graph(host).await; @@ -5240,18 +5269,17 @@ async fn hotspots_ranks_symbols_by_edge_degree_and_clamps_limit() { ); assert_savings_footer(&chain_markdown, CHAIN_SOURCE.len()); - let rejected = chain_rejected.error.expect("zero limit is a tool error"); - assert_eq!(rejected.code, -32603); - assert_eq!( - rejected.message, - "tool execution failed: config error: invalid parameter: tracedecay_hotspots requires limit to be at least 1" + assert!( + chain_rejected.error.is_none(), + "zero limit is a tool result: {:?}", + chain_rejected.error ); - assert_eq!( - rejected.data, - Some(json!({ - "tool": "tracedecay_hotspots", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool hotspots ...` (`tracedecay tool hotspots --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - })) + let rejected = refusal_problem(chain_rejected.result.as_ref().expect("zero limit refusal")); + assert_refusal_problem( + rejected, + "invalid_request", + "application.surface.invalid_request", + "invalid parameter: tracedecay_hotspots requires limit to be at least 1", ); let fanout_dir = test_temp_dir(); @@ -5524,7 +5552,7 @@ async fn recursion_reports_literal_cycles_and_refuses_non_positive_limit() { "limit 1 keeps the shortest cycle: {limited}" ); - let error = expect_tool_error( + let problem = expect_tool_refusal( handle_tool_call( &graph, "tracedecay_recursion", @@ -5534,9 +5562,11 @@ async fn recursion_reports_literal_cycles_and_refuses_non_positive_limit() { ) .await, ); - assert_eq!( - error, - "config error: tracedecay_recursion failed over production MCP: tool execution failed: config error: invalid parameter: tracedecay_recursion requires limit to be at least 1" + assert_refusal_problem( + &problem, + "invalid_request", + "application.surface.invalid_request", + "invalid parameter: tracedecay_recursion requires limit to be at least 1", ); close_test_graph(graph).await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_lookup_typed_request_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_lookup_typed_request_test.rs index 6c0a4c9171..c8fc0b70d4 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_lookup_typed_request_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_lookup_typed_request_test.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_json, production_composition_fixture_with_sources, - warm_code_index_search, + refusal_problem, warm_code_index_search, }; const SOURCE: &str = "#[derive(Debug, Clone)]\npub struct TypedWidget {\n pub id: u32,\n}\n\npub fn fetch_typed_widget() -> u32 {\n TYPED_REQUEST_MARKER\n}\n"; @@ -39,21 +39,32 @@ async fn call_json( ) } -async fn call_error( +async fn assert_refused( fixture: &ProductionCompositionFixture, tool_name: &str, arguments: Value, -) -> String { + message: &str, +) { let response = fixture .harness .call_tool(&fixture.project_root, tool_name, arguments) .await .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); - assert!(response.result.is_none(), "{:?}", response.result); - response - .error - .unwrap_or_else(|| panic!("{tool_name} must refuse the request")) - .message + assert!( + response.error.is_none(), + "{tool_name} returned a production MCP error: {:?}", + response.error.as_ref().map(|error| &error.message) + ); + let result = response + .result + .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")); + let problem = refusal_problem(&result); + assert_eq!(problem["kind"], "invalid_request", "{tool_name}: {problem}"); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{tool_name}: {problem}" + ); + assert_eq!(problem["message"], message, "{tool_name}: {problem}"); } #[tokio::test] @@ -133,33 +144,27 @@ async fn graph_lookup_tools_refuse_arguments_outside_their_typed_request() { ) ); - assert_eq!( - call_error( - &fixture, - "tracedecay_grep", - json!({"pattern": "TYPED_REQUEST_MARKER", "max_results": "5"}), - ) - .await, - "tool execution failed: config error: invalid arguments for tracedecay_grep: invalid type: string \"5\", expected u32" - ); - assert_eq!( - call_error( - &fixture, - "tracedecay_derives", - json!({"qualified_name": "src/lib.rs::TypedWidget", "include_generated": true}), - ) - .await, - "tool execution failed: config error: invalid arguments for tracedecay_derives: unknown field `include_generated`, expected one of `id`, `node_id`, `qualified_name`" - ); - assert_eq!( - call_error( - &fixture, - "tracedecay_find_exact_symbol", - json!({"name": "fetch_typed_widget", "limit": "3"}), - ) - .await, - "tool execution failed: config error: invalid arguments for tracedecay_find_exact_symbol: invalid type: string \"3\", expected u32" - ); + assert_refused( + &fixture, + "tracedecay_grep", + json!({"pattern": "TYPED_REQUEST_MARKER", "max_results": "5"}), + "invalid arguments for tracedecay_grep: invalid type: string \"5\", expected u32", + ) + .await; + assert_refused( + &fixture, + "tracedecay_derives", + json!({"qualified_name": "src/lib.rs::TypedWidget", "include_generated": true}), + "invalid arguments for tracedecay_derives: unknown field `include_generated`, expected one of `id`, `node_id`, `qualified_name`", + ) + .await; + assert_refused( + &fixture, + "tracedecay_find_exact_symbol", + json!({"name": "fetch_typed_widget", "limit": "3"}), + "invalid arguments for tracedecay_find_exact_symbol: invalid type: string \"3\", expected u32", + ) + .await; fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs index 2bbf7e4759..8ded953037 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs @@ -677,12 +677,21 @@ async fn test_grep_context_lines() { #[tokio::test] async fn test_grep_missing_pattern_errors() { let cg = production_graph_query_fixture().await; - let err = call_production_tool(&cg, "tracedecay_grep", json!({}), None, None) - .await - .unwrap_err(); - assert!( - err.to_string().contains("pattern"), - "missing pattern should be reported: {err}" + let missing_pattern = expect_tool_refusal( + call_production_tool(&cg, "tracedecay_grep", json!({}), None, None).await, + ); + assert_eq!( + missing_pattern["kind"], "invalid_request", + "{missing_pattern}" + ); + assert_eq!( + missing_pattern["code"], "application.surface.invalid_request", + "{missing_pattern}" + ); + assert_eq!( + missing_pattern["message"], + "invalid arguments for tracedecay_grep: missing field `pattern`", + "{missing_pattern}" ); } @@ -1324,12 +1333,16 @@ async fn redundancy_reports_ranked_repository_exact_families_with_bounded_pages( None, None, ) - .await - .expect_err("foreign repository scope must be denied"); - assert!( - unauthorized - .to_string() - .contains("outside the authorized repository scope"), + .await; + let unauthorized = expect_tool_refusal(unauthorized); + assert_eq!(unauthorized["kind"], "unavailable", "{unauthorized}"); + assert_eq!( + unauthorized["code"], "redundancy-repository-not-authorized", + "{unauthorized}" + ); + assert_eq!( + unauthorized["message"], + "the selected repository is outside the authorized repository scope", "{unauthorized}" ); shutdown_graph_fixture(fixture).await; @@ -1641,19 +1654,17 @@ async fn test_rank_invalid_direction() { None, ) .await; - match result { - Err(err) => { - let err_msg = format!("{}", err); - assert!( - err_msg.contains( - "invalid arguments for tracedecay_rank: unknown variant `sideways`, expected `incoming` or `outgoing`" - ), - "error should refuse the direction, got: {}", - err_msg, - ); - } - Ok(_) => panic!("invalid direction should produce an error"), - } + let refused = expect_tool_refusal(result); + assert_eq!(refused["kind"], "invalid_request", "{refused}"); + assert_eq!( + refused["code"], "application.surface.invalid_request", + "{refused}" + ); + assert_eq!( + refused["message"], + "invalid arguments for tracedecay_rank: unknown variant `sideways`, expected `incoming` or `outgoing`", + "{refused}" + ); } #[tokio::test] @@ -2170,14 +2181,19 @@ async fn tracedecay_by_qualified_name_returns_the_symbol_at_that_exact_name() { let response = handle_real_server_tool_call_raw(&server, "tracedecay_by_qualified_name", arguments) .await; + assert!( + response.get("error").is_none() || response["error"].is_null(), + "rejection: {response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request", "rejection: {response}"); assert_eq!( - (&response["error"]["code"], &response["error"]["message"]), - ( - &json!(-32603), - &json!(format!( - "tool execution failed: config error: invalid arguments for tracedecay_by_qualified_name: {refusal}" - )), - ), + problem["code"], "application.surface.invalid_request", + "rejection: {response}" + ); + assert_eq!( + problem["message"], + format!("invalid arguments for tracedecay_by_qualified_name: {refusal}"), "rejection: {response}" ); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs index 2367258507..a6406f11e4 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs @@ -9,7 +9,7 @@ use crate::support::{ extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, - production_composition_fixture_with_sources, warm_code_index_search, + production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; use serde_json::{Value, json}; use std::fs; @@ -31,10 +31,6 @@ const LIB_RS: &str = concat!( ); const NOTE_TXT: &str = "# Notes\nALPHA_NOTE_TOKEN\n"; const CONTEXT_TXT: &str = "w\nx\ny\nz\nCONTEXT_TARGET\nd\ne\nf\ng\n"; -const CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool grep ...` \ -(`tracedecay tool grep --help` for parameters). If MCP calls keep failing or timing out, fall \ -back to that CLI instead of querying .tracedecay databases directly."; - fn write_grep_project(project: &Path) { fs::create_dir_all(project.join("src")).unwrap(); fs::create_dir_all(project.join("docs")).unwrap(); @@ -169,19 +165,18 @@ fn assert_json_payload(response: &Value, expected: Value, touched_bytes: Option< } } -fn execution_failed(message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": -32603, - "message": message, - "data": { - "tool": "tracedecay_grep", - "cli_fallback": CLI_FALLBACK, - } - } - }) +fn assert_grep_refusal(response: &Value, message: &str) { + assert!( + response.get("error").is_none() || response["error"].is_null(), + "{response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request", "{response}"); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{response}" + ); + assert_eq!(problem["message"], message, "{response}"); } fn greeting_markdown(node_id: &str) -> String { @@ -211,18 +206,13 @@ async fn tracedecay_grep_reports_literal_matches_and_typed_failures() { let note_id = symbol_node_id(&server, "note").await; let missing = grep(&server, json!({"format": "json"})).await; - assert_eq!( - missing, - execution_failed( - "tool execution failed: config error: invalid arguments for tracedecay_grep: missing field `pattern`" - ) + assert_grep_refusal( + &missing, + "invalid arguments for tracedecay_grep: missing field `pattern`", ); let empty = grep(&server, json!({"pattern": "", "format": "json"})).await; - assert_eq!( - empty, - execution_failed("tool execution failed: config error: pattern must not be empty") - ); + assert_grep_refusal(&empty, "pattern must not be empty"); let absent = grep( &server, @@ -469,29 +459,23 @@ _Scanned {FILES_SCANNED} files._ assert_eq!(generated_payload["omissions"], json!([])); let invalid_group = grep(&server, json!({"pattern": "(", "format": "json"})).await; - assert_eq!( - invalid_group, - execution_failed( - "tool execution failed: config error: invalid regex pattern '(': regex parse error: ( ^ error: unclosed group" - ) + assert_grep_refusal( + &invalid_group, + "invalid regex pattern '(': regex parse error: ( ^ error: unclosed group", ); let invalid_braces = grep(&server, json!({"pattern": "Hello, {}!", "format": "json"})).await; - assert_eq!( - invalid_braces, - execution_failed( - "tool execution failed: config error: invalid regex pattern 'Hello, {}!': regex parse error: Hello, {}! ^ error: repetition quantifier expects a valid decimal" - ) + assert_grep_refusal( + &invalid_braces, + "invalid regex pattern 'Hello, {}!': regex parse error: Hello, {}! ^ error: repetition quantifier expects a valid decimal", ); let invalid_glob = grep( &server, json!({"pattern": "VISIBLE_TOKEN", "path_glob": "[", "format": "json"}), ) .await; - assert_eq!( - invalid_glob, - execution_failed( - "tool execution failed: config error: invalid path_glob '[': error parsing glob '[': unclosed character class; missing ']'" - ) + assert_grep_refusal( + &invalid_glob, + "invalid path_glob '[': error parsing glob '[': unclosed character class; missing ']'", ); drop(server); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/health_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/health_behavior_test.rs index dab4cc44a8..0a1aa354a1 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/health_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/health_behavior_test.rs @@ -17,7 +17,7 @@ use tracedecay_mcp::ToolResult; use crate::support::{ ProductionCompositionFixture, extract_json, extract_text, - production_composition_fixture_with_sources, wait_for_current_graph, + production_composition_fixture_with_sources, refusal_problem, wait_for_current_graph, }; async fn call_health(fixture: &ProductionCompositionFixture, arguments: Value) -> ToolResult { @@ -183,21 +183,32 @@ async fn health_scores_two_isolated_modules_and_distinguishes_scope() { ); } -async fn health_report_error( +async fn assert_refused( fixture: &ProductionCompositionFixture, tool_name: &str, arguments: Value, -) -> String { + message: &str, +) { let response = fixture .harness .call_tool(&fixture.project_root, tool_name, arguments) .await .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); - assert!(response.result.is_none(), "{:?}", response.result); - response - .error - .unwrap_or_else(|| panic!("{tool_name} must refuse the request")) - .message + assert!( + response.error.is_none(), + "{tool_name} returned a production MCP error: {:?}", + response.error.as_ref().map(|error| &error.message) + ); + let result = response + .result + .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")); + let problem = refusal_problem(&result); + assert_eq!(problem["kind"], "invalid_request", "{tool_name}: {problem}"); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{tool_name}: {problem}" + ); + assert_eq!(problem["message"], message, "{tool_name}: {problem}"); } #[tokio::test] @@ -229,18 +240,27 @@ async fn health_reports_refuse_arguments_outside_their_typed_request() { (&json!("lines"), &json!("file"), &json!(2), &json!(0.0)) ); - assert_eq!( - health_report_error(&fixture, "tracedecay_gini", json!({"metric": "cyclomatic"})).await, - "tool execution failed: config error: invalid arguments for tracedecay_gini: unknown variant `cyclomatic`, expected one of `complexity`, `lines`, `fan_in`, `fan_out`, `members`" - ); - assert_eq!( - health_report_error(&fixture, "tracedecay_dsm", json!({"max_files": "30"})).await, - "tool execution failed: config error: invalid arguments for tracedecay_dsm: invalid type: string \"30\", expected u32" - ); - assert_eq!( - health_report_error(&fixture, "tracedecay_health", json!({"detail": true})).await, - "tool execution failed: config error: invalid arguments for tracedecay_health: unknown field `detail`, expected `path` or `details`" - ); + assert_refused( + &fixture, + "tracedecay_gini", + json!({"metric": "cyclomatic"}), + "invalid arguments for tracedecay_gini: unknown variant `cyclomatic`, expected one of `complexity`, `lines`, `fan_in`, `fan_out`, `members`", + ) + .await; + assert_refused( + &fixture, + "tracedecay_dsm", + json!({"max_files": "30"}), + "invalid arguments for tracedecay_dsm: invalid type: string \"30\", expected u32", + ) + .await; + assert_refused( + &fixture, + "tracedecay_health", + json!({"detail": true}), + "invalid arguments for tracedecay_health: unknown field `detail`, expected `path` or `details`", + ) + .await; fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs index 93ae32a3a4..7c5e89c8d9 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs @@ -19,7 +19,9 @@ use tracedecay::mcp::McpServer; use crate::mcp_server_test::support::{ jsonrpc_request, response_with_id, run_client_connection_with_messages, successful_tool_text, }; -use crate::support::{TestEnv, TestTraceDecay, canonicalize_test_dir, init_test_project}; +use crate::support::{ + TestEnv, TestTraceDecay, canonicalize_test_dir, init_test_project, refusal_problem, +}; const WORKFLOW_BODY: &str = "---\nname: workflow\ndescription: Reusable workflow\n---\n\nDo the work.\n"; @@ -386,9 +388,12 @@ async fn hermes_skill_bridge_mcp_returns_standard_install_inventory() { ); assert_eq!(omitted, populated_inventory(&isolated.home, false, false)); + let problem = refusal_problem(&non_bool["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - non_bool["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_hermes_skill_bridge: invalid type: integer `1`, expected a boolean" + problem["message"], + "invalid arguments for tracedecay_hermes_skill_bridge: invalid type: integer `1`, expected a boolean" ); assert_eq!(included, populated_inventory(&isolated.home, true, true)); assert_eq!( @@ -469,16 +474,13 @@ async fn hermes_skill_bridge_mcp_rejects_invalid_usage_json() { let server = open_server(cg).await; let response = call_bridge(&server, 9, json!({"format": "json"})).await; - assert!(response.get("result").is_none() || response["result"].is_null()); - assert_eq!(response["error"]["code"], -32603); - assert_eq!( - response["error"]["data"]["tool"], - "tracedecay_hermes_skill_bridge" - ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - response["error"]["message"], + problem["message"], format!( - "tool execution failed: config error: Hermes skill usage '{}' is invalid JSON: expected ident at line 1 column 2", + "Hermes skill usage '{}' is invalid JSON: expected ident at line 1 column 2", usage_path.display() ) ); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs index 1f95746a43..ee9f8c3b56 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/impact_behavior_test.rs @@ -20,8 +20,8 @@ //! //! Impact walks incoming dependents. `callee` is therefore reached by //! `local_caller` and `direct` at depth 1, and by `indirect` at depth 2. -//! `untouched` has no dependents. Argument failures stay typed JSON-RPC -//! errors rather than an empty radius. +//! `untouched` has no dependents. Argument failures are typed problem +//! records on an isError tool result rather than an empty radius. use std::collections::HashMap; use std::fs; @@ -30,7 +30,7 @@ use serde_json::{Value, json}; use tracedecay::mcp::McpServer; use crate::support::{ - handle_real_server_tool_call_raw, production_composition_fixture_with_sources, + handle_real_server_tool_call_raw, production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; @@ -166,31 +166,31 @@ async fn impact_reports_callers_by_depth_and_refuses_invalid_requests() { assert_refused( &server, json!({ "node_id": " ", "format": "json" }), - "tool execution failed: config error: invalid parameter: node_id must not be empty", + "invalid parameter: node_id must not be empty", ) .await; assert_refused( &server, json!({ "node_id": ids["callee"], "max_depth": 0, "format": "json" }), - "tool execution failed: config error: invalid parameter: max_depth must be at least 1", + "invalid parameter: max_depth must be at least 1", ) .await; assert_refused( &server, json!({ "format": "json" }), - "tool execution failed: config error: invalid arguments for tracedecay_impact: missing field `node_id`", + "invalid arguments for tracedecay_impact: missing field `node_id`", ) .await; assert_refused( &server, json!({ "node_id": "bad\u{0001}id", "format": "json" }), - "tool execution failed: config error: invalid graph symbol occurrence: SymbolOccurrenceId is not canonical", + "invalid graph symbol occurrence: SymbolOccurrenceId is not canonical", ) .await; assert_refused( &server, json!({ "node_id": "code-chunk:not-a-symbol", "format": "json" }), - "tool execution failed: config error: invalid parameter: node_id `code-chunk:not-a-symbol` is an evidence anchor, not a graph symbol occurrence", + "invalid parameter: node_id `code-chunk:not-a-symbol` is an evidence anchor, not a graph symbol occurrence", ) .await; @@ -280,18 +280,18 @@ async fn symbol_id(server: &McpServer, name: &str) -> String { async fn assert_refused(server: &McpServer, arguments: Value, message: &str) { let response = handle_real_server_tool_call_raw(server, "tracedecay_impact", arguments).await; - assert_eq!( - response["error"]["code"], - json!(-32603), - "typed refusal code: {response}" + assert!( + response.get("error").is_none() || response["error"].is_null(), + "typed refusal is a tool result: {response}" ); + let problem = refusal_problem(&response["result"]); assert_eq!( - response["error"]["message"], message, + problem["kind"], "invalid_request", "typed refusal: {response}" ); assert_eq!( - response["error"]["data"]["tool"], - json!("tracedecay_impact"), - "typed refusal names the tool: {response}" + problem["code"], "application.surface.invalid_request", + "typed refusal: {response}" ); + assert_eq!(problem["message"], message, "typed refusal: {response}"); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_file_request_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_file_request_test.rs index 7ada0836c9..fd286205f9 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_file_request_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/info_file_request_test.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_text, production_composition_fixture_with_sources, - wait_for_current_graph, + refusal_problem, wait_for_current_graph, }; fn write_probe_sources(project: &Path) { @@ -50,25 +50,32 @@ async fn call_json( extract_text(&result).to_owned() } -async fn refusal( +async fn assert_refused( fixture: &ProductionCompositionFixture, tool_name: &str, arguments: Value, -) -> String { + message: &str, +) { let response = fixture .harness .call_tool(&fixture.project_root, tool_name, arguments) .await .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); assert!( - response.result.is_none(), - "{tool_name} must refuse the request, answered {:?}", - response.result + response.error.is_none(), + "{tool_name} returned a production MCP error: {:?}", + response.error.as_ref().map(|error| &error.message) ); - response - .error - .unwrap_or_else(|| panic!("{tool_name} must refuse the request")) - .message + let result = response + .result + .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")); + let problem = refusal_problem(&result); + assert_eq!(problem["kind"], "invalid_request", "{tool_name}: {problem}"); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{tool_name}: {problem}" + ); + assert_eq!(problem["message"], message, "{tool_name}: {problem}"); } #[tokio::test] @@ -89,14 +96,20 @@ async fn file_inspections_refuse_arguments_outside_their_typed_request() { .await, r#"{"count":1,"files":[{"bytes":32,"path":"src/lib.rs","symbols":1}],"layout":"flat"}"# ); - assert_eq!( - refusal(&fixture, "tracedecay_files", json!({"layout": "tree"})).await, - "tool execution failed: config error: invalid arguments for tracedecay_files: unknown variant `tree`, expected `flat` or `grouped`" - ); - assert_eq!( - refusal(&fixture, "tracedecay_files", json!({"patern": "*.rs"})).await, - "tool execution failed: config error: invalid arguments for tracedecay_files: unknown field `patern`, expected one of `path`, `pattern`, `layout`" - ); + assert_refused( + &fixture, + "tracedecay_files", + json!({"layout": "tree"}), + "invalid arguments for tracedecay_files: unknown variant `tree`, expected `flat` or `grouped`", + ) + .await; + assert_refused( + &fixture, + "tracedecay_files", + json!({"patern": "*.rs"}), + "invalid arguments for tracedecay_files: unknown field `patern`, expected one of `path`, `pattern`, `layout`", + ) + .await; assert_eq!( call_json( @@ -107,24 +120,20 @@ async fn file_inspections_refuse_arguments_outside_their_typed_request() { .await, r#"{"match_count":1,"matches":[{"file":"Cargo.toml","key":"package.version","line":3,"value":"0.4.2"}]}"# ); - assert_eq!( - refusal( - &fixture, - "tracedecay_config", - json!({"key": 1, "path": "Cargo.toml"}) - ) - .await, - "tool execution failed: config error: invalid arguments for tracedecay_config: invalid type: integer `1`, expected a string" - ); - assert_eq!( - refusal( - &fixture, - "tracedecay_config", - json!({"key": "package.version", "paths": "Cargo.toml"}) - ) - .await, - "tool execution failed: config error: invalid arguments for tracedecay_config: unknown field `paths`, expected one of `key`, `path`, `glob`" - ); + assert_refused( + &fixture, + "tracedecay_config", + json!({"key": 1, "path": "Cargo.toml"}), + "invalid arguments for tracedecay_config: invalid type: integer `1`, expected a string", + ) + .await; + assert_refused( + &fixture, + "tracedecay_config", + json!({"key": "package.version", "paths": "Cargo.toml"}), + "invalid arguments for tracedecay_config: unknown field `paths`, expected one of `key`, `path`, `glob`", + ) + .await; fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs index d89936718e..f25780404c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs @@ -17,7 +17,7 @@ use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_mcp::JsonRpcResponse; use crate::common::fixture::git_run; -use crate::support::test_temp_dir; +use crate::support::{refusal_problem, test_temp_dir}; const HIERARCHY: &str = "\ pub trait Left {} @@ -432,25 +432,25 @@ async fn inheritance_depth_cycle_is_unavailable() { open_project(&[("src/lib.rs", "pub mod cycle;\n"), ("src/cycle.rs", CYCLE)]).await; let response = call_inheritance_depth(&project, json!({"format": "json"})).await; - let error = response - .error + assert!( + response.error.is_none(), + "cycle is a tool result: {:?}", + response.error + ); + let result = response + .result .as_ref() - .unwrap_or_else(|| panic!("cycle ranked as success: {:?}", response.result)); - assert_eq!(error.code, -32602); + .unwrap_or_else(|| panic!("cycle ranked as success")); + let problem = refusal_problem(result); + assert_eq!(problem["kind"], "unavailable", "{problem}"); assert_eq!( - error.message, - "tool project route failed: reason_code=verified-inheritance-depth-unavailable retryable=false: the admitted extends relation contains a cycle" + problem["code"], "verified-inheritance-depth-unavailable", + "{problem}" ); assert_eq!( - error.data, - Some(json!({ - "tool": "tracedecay_inheritance_depth", - "reason_code": "verified-inheritance-depth-unavailable", - "retryable": false, - "detail": "the admitted extends relation contains a cycle" - })) + problem["message"], "the admitted extends relation contains a cycle", + "{problem}" ); - assert!(response.result.is_none()); project.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs index 199680a9f7..d8edb852aa 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs @@ -15,7 +15,8 @@ use serde_json::{Value, json}; use tracedecay::mcp::McpServer; use crate::support::{ - dispatch_mcp_tool_call, production_composition_fixture_with_sources, wait_for_current_graph, + dispatch_mcp_tool_call, production_composition_fixture_with_sources, refusal_problem, + wait_for_current_graph, }; const SOURCE: &str = r#"/// Loads the current value. @@ -148,32 +149,32 @@ async fn tracedecay_node_reports_declared_symbols_and_typed_refusals() { assert_execution_failed( &node_call(&server, json!({})).await, - "tool execution failed: config error: invalid arguments for tracedecay_node: missing field `node_id`", + "invalid arguments for tracedecay_node: missing field `node_id`", ); assert_execution_failed( &node_call(&server, json!({"node_id": ""})).await, - "tool execution failed: config error: invalid parameter: node_id must not be empty", + "invalid parameter: node_id must not be empty", ); assert_execution_failed( &node_call(&server, json!({"node_id": " "})).await, - "tool execution failed: config error: invalid parameter: node_id must not be empty", + "invalid parameter: node_id must not be empty", ); assert_execution_failed( &node_call(&server, json!({"id": fetch_id})).await, - "tool execution failed: config error: invalid arguments for tracedecay_node: unknown field `id`, expected `node_id`", + "invalid arguments for tracedecay_node: unknown field `id`, expected `node_id`", ); assert_execution_failed( &node_call(&server, json!({"node_id": fetch_id, "limit": 1})).await, - "tool execution failed: config error: invalid arguments for tracedecay_node: unknown field `limit`, expected `node_id`", + "invalid arguments for tracedecay_node: unknown field `limit`, expected `node_id`", ); - assert_execution_failed( + assert_malformed_call( &node_call(&server, json!([fetch_id])).await, "tool execution failed: config error: invalid arguments: tracedecay_node expects a JSON object", ); assert_execution_failed( &node_call(&server, json!({"node_id": EVIDENCE_ANCHOR})).await, &format!( - "tool execution failed: config error: invalid parameter: node_id `{EVIDENCE_ANCHOR}` is an evidence anchor, not a graph symbol occurrence" + "invalid parameter: node_id `{EVIDENCE_ANCHOR}` is an evidence anchor, not a graph symbol occurrence" ), ); @@ -514,7 +515,9 @@ fn parse_json(text: &str) -> Value { .unwrap_or_else(|error| panic!("MCP text was not JSON: {error}\n{text}")) } -fn assert_execution_failed(response: &Value, message: &str) { +/// A non-object argument list never reaches the owner's typed parser: the +/// MCP boundary rejects the call itself. +fn assert_malformed_call(response: &Value, message: &str) { assert_eq!(response["error"]["code"], -32603, "{response}"); assert_eq!(response["error"]["message"], message, "{response}"); assert_eq!( @@ -526,3 +529,14 @@ fn assert_execution_failed(response: &Value, message: &str) { "{response}" ); } + +fn assert_execution_failed(response: &Value, message: &str) { + assert!(response["error"].is_null(), "{response}"); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request", "{response}"); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{response}" + ); + assert_eq!(problem["message"], message, "{response}"); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs index 838d7be3ff..56aef5f293 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_json, production_composition_fixture_with_sources, - wait_for_current_graph, + refusal_problem, wait_for_current_graph, }; /// `order/chain.rs`. Functions only: `leaf` line 1, `zeta` line 5, `alpha` @@ -302,33 +302,33 @@ async fn port_order_ports_leaves_first_and_reports_one_scc() { async fn port_order_rejects_unknown_kinds_and_missing_source_dir() { let fixture = open_port_order_project().await; - let unknown_kind = tool_error( + let unknown_kind = tool_refusal( &fixture, json!({"source_dir": "order", "kinds": ["not_a_kind"]}), ) .await; - assert_eq!(unknown_kind.0, -32603); + assert_eq!(unknown_kind["kind"], "invalid_request"); + assert_eq!(unknown_kind["code"], "application.surface.invalid_request"); assert_eq!( - unknown_kind.1, - "tool execution failed: config error: invalid parameter: kinds must contain at least one supported node kind" + unknown_kind["message"], + "invalid parameter: kinds must contain at least one supported node kind" ); - assert_eq!(unknown_kind.2, "tracedecay_port_order"); - let missing_source_dir = tool_error(&fixture, json!({})).await; - assert_eq!(missing_source_dir.0, -32603); + let missing_source_dir = tool_refusal(&fixture, json!({})).await; + assert_eq!(missing_source_dir["kind"], "invalid_request"); assert_eq!( - missing_source_dir.1, - "tool execution failed: config error: invalid arguments for tracedecay_port_order: missing field `source_dir`" + missing_source_dir["code"], + "application.surface.invalid_request" + ); + assert_eq!( + missing_source_dir["message"], + "invalid arguments for tracedecay_port_order: missing field `source_dir`" ); - assert_eq!(missing_source_dir.2, "tracedecay_port_order"); fixture.harness.shutdown().await; } -async fn tool_error( - fixture: &ProductionCompositionFixture, - mut arguments: Value, -) -> (i32, String, String) { +async fn tool_refusal(fixture: &ProductionCompositionFixture, mut arguments: Value) -> Value { arguments .as_object_mut() .expect("port_order arguments are an object") @@ -339,19 +339,12 @@ async fn tool_error( .await .expect("production MCP tools/call"); assert!( - response.result.is_none(), - "invalid port_order input must not return a result: {:?}", - response.result + response.error.is_none(), + "invalid port_order input must be a tool refusal, not a JSON-RPC error: {:?}", + response.error ); - let error = response - .error - .expect("invalid port_order input must return a JSON-RPC error"); - let tool = error - .data - .as_ref() - .and_then(|data| data.get("tool")) - .and_then(Value::as_str) - .unwrap_or("") - .to_owned(); - (error.code, error.message, tool) + let result = response + .result + .expect("invalid port_order input must return a refusal result"); + refusal_problem(&result).clone() } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_status_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_status_test.rs index e4281f9343..615774fa4d 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_status_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_status_test.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_text, production_composition_fixture_with_sources, - wait_for_current_graph, + refusal_problem, wait_for_current_graph, }; /// `source/biquad.rs`. Default-kind symbols: struct `Biquad` line 1, method @@ -316,33 +316,33 @@ async fn port_status_reports_cross_language_partial_coverage() { async fn port_status_rejects_unknown_kinds_and_missing_source_dir() { let fixture = open_port_project().await; - let unknown_kind = tool_error( + let unknown_kind = tool_refusal( &fixture, json!({"source_dir": "source", "target_dir": "ported", "kinds": ["not_a_kind"]}), ) .await; - assert_eq!(unknown_kind.0, -32603); + assert_eq!(unknown_kind["kind"], "invalid_request"); + assert_eq!(unknown_kind["code"], "application.surface.invalid_request"); assert_eq!( - unknown_kind.1, - "tool execution failed: config error: invalid parameter: kinds must contain at least one supported node kind" + unknown_kind["message"], + "invalid parameter: kinds must contain at least one supported node kind" ); - assert_eq!(unknown_kind.2, "tracedecay_port_status"); - let missing_source_dir = tool_error(&fixture, json!({"target_dir": "ported"})).await; - assert_eq!(missing_source_dir.0, -32603); + let missing_source_dir = tool_refusal(&fixture, json!({"target_dir": "ported"})).await; + assert_eq!(missing_source_dir["kind"], "invalid_request"); assert_eq!( - missing_source_dir.1, - "tool execution failed: config error: invalid arguments for tracedecay_port_status: missing field `source_dir`" + missing_source_dir["code"], + "application.surface.invalid_request" + ); + assert_eq!( + missing_source_dir["message"], + "invalid arguments for tracedecay_port_status: missing field `source_dir`" ); - assert_eq!(missing_source_dir.2, "tracedecay_port_status"); fixture.harness.shutdown().await; } -async fn tool_error( - fixture: &ProductionCompositionFixture, - mut arguments: Value, -) -> (i32, String, String) { +async fn tool_refusal(fixture: &ProductionCompositionFixture, mut arguments: Value) -> Value { arguments .as_object_mut() .expect("port_status arguments are an object") @@ -353,19 +353,12 @@ async fn tool_error( .await .expect("production MCP tools/call"); assert!( - response.result.is_none(), - "invalid port_status input must not return a result: {:?}", - response.result + response.error.is_none(), + "invalid port_status input must be a tool refusal, not a JSON-RPC error: {:?}", + response.error ); - let error = response - .error - .expect("invalid port_status input must return a JSON-RPC error"); - let tool = error - .data - .as_ref() - .and_then(|data| data.get("tool")) - .and_then(Value::as_str) - .unwrap_or("") - .to_owned(); - (error.code, error.message, tool) + let result = response + .result + .expect("invalid port_status input must return a refusal result"); + refusal_problem(&result).clone() } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rank_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rank_behavior_test.rs index a5bf18bd8a..9868ffe2ff 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rank_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rank_behavior_test.rs @@ -15,7 +15,7 @@ use tracedecay::mcp::McpServer; use crate::support::{ ProductionCompositionFixture, handle_real_server_tool_call_raw, - production_composition_fixture_with_sources, wait_for_current_graph, + production_composition_fixture_with_sources, refusal_problem, wait_for_current_graph, }; /// `shared` is called by `left` and `right`. `left` is called only by `right`. @@ -433,62 +433,66 @@ async fn rank_refuses_missing_invalid_and_unpublished_relationships() { let session = open_rank_session().await; let server = &session.server; - let missing = call_rank(server, json!({"format": "json"})).await; - assert_eq!(missing["error"]["code"], -32603, "{missing}"); + let missing_response = call_rank(server, json!({"format": "json"})).await; + let missing = refusal_problem(&missing_response["result"]); + assert_eq!(missing["kind"], "invalid_request", "{missing}"); assert_eq!( - missing["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_rank: missing field `edge_kind`", + missing["code"], "application.surface.invalid_request", "{missing}" ); assert_eq!( - missing["error"]["data"]["tool"], "tracedecay_rank", + missing["message"], "invalid arguments for tracedecay_rank: missing field `edge_kind`", "{missing}" ); - let invalid_kind = call_rank(server, json!({"edge_kind": "inherits", "format": "json"})).await; - assert_eq!(invalid_kind["error"]["code"], -32603, "{invalid_kind}"); + let invalid_kind_response = + call_rank(server, json!({"edge_kind": "inherits", "format": "json"})).await; + let invalid_kind = refusal_problem(&invalid_kind_response["result"]); + assert_eq!(invalid_kind["kind"], "invalid_request", "{invalid_kind}"); assert_eq!( - invalid_kind["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_rank: unknown variant `inherits`, expected one of `implements`, `extends`, `calls`, `uses`, `contains`, `annotates`, `derives_macro`, `type_of`, `returns`, `receives`", + invalid_kind["code"], "application.surface.invalid_request", + "{invalid_kind}" + ); + assert_eq!( + invalid_kind["message"], + "invalid arguments for tracedecay_rank: unknown variant `inherits`, expected one of `implements`, `extends`, `calls`, `uses`, `contains`, `annotates`, `derives_macro`, `type_of`, `returns`, `receives`", "{invalid_kind}" ); - let invalid_direction = call_rank( + let invalid_direction_response = call_rank( server, json!({"edge_kind": "calls", "direction": "sideways", "format": "json"}), ) .await; + let invalid_direction = refusal_problem(&invalid_direction_response["result"]); + assert_eq!( + invalid_direction["kind"], "invalid_request", + "{invalid_direction}" + ); assert_eq!( - invalid_direction["error"]["code"], -32603, + invalid_direction["code"], "application.surface.invalid_request", "{invalid_direction}" ); assert_eq!( - invalid_direction["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_rank: unknown variant `sideways`, expected `incoming` or `outgoing`", + invalid_direction["message"], + "invalid arguments for tracedecay_rank: unknown variant `sideways`, expected `incoming` or `outgoing`", "{invalid_direction}" ); - let derives = call_rank( + let derives_response = call_rank( server, json!({"edge_kind": "derives_macro", "format": "json"}), ) .await; - assert_eq!(derives["error"]["code"], -32602, "{derives}"); - assert_eq!( - derives["error"]["message"], - "tool project route failed: reason_code=verified-rank-unavailable retryable=false: the admitted graph generation does not publish derives_macro relations", - "{derives}" - ); - assert_eq!( - derives["error"]["data"]["reason_code"], "verified-rank-unavailable", - "{derives}" - ); - assert_eq!(derives["error"]["data"]["retryable"], false, "{derives}"); + let derives = refusal_problem(&derives_response["result"]); + assert_eq!(derives["kind"], "unavailable", "{derives}"); + assert_eq!(derives["code"], "verified-rank-unavailable", "{derives}"); assert_eq!( - derives["error"]["data"]["detail"], + derives["message"], "the admitted graph generation does not publish derives_macro relations", "{derives}" ); + assert_eq!(derives["retryable"], false, "{derives}"); shutdown(session).await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/redundancy_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/redundancy_test.rs index 926ffc984d..ed4fceb6d4 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/redundancy_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/redundancy_test.rs @@ -20,7 +20,7 @@ use tracedecay_code_index_runtime::code_index_scheduler::identity::{ use crate::support::{ extract_real_server_text, extract_text, handle_real_server_tool_call, - handle_real_server_tool_call_raw, production_composition_fixture_with_sources, + handle_real_server_tool_call_raw, production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; @@ -850,10 +850,15 @@ async fn redundancy_reports_renames_only_under_the_rename_class_and_refuses_fore ); retired["max_pairs"] = json!(4); let retired = handle_real_server_tool_call_raw(&server, "tracedecay_redundancy", retired).await; - assert_eq!(retired["error"]["code"], -32603, "{retired}"); + let retired = refusal_problem(&retired["result"]); + assert_eq!(retired["kind"], "invalid_request", "{retired}"); assert_eq!( - retired["error"]["message"], - "tool execution failed: config error: invalid arguments for tracedecay_redundancy: unknown field `max_pairs`, expected one of `project_id`, `repository_id`, `match_classes`, `scope`, `include_generated_paths`, `family_limit`, `member_limit`, `work_limit`, `cursor`", + retired["code"], "application.surface.invalid_request", + "{retired}" + ); + assert_eq!( + retired["message"], + "invalid arguments for tracedecay_redundancy: unknown field `max_pairs`, expected one of `project_id`, `repository_id`, `match_classes`, `scope`, `include_generated_paths`, `family_limit`, `member_limit`, `work_limit`, `cursor`", "{retired}" ); @@ -873,20 +878,18 @@ async fn redundancy_reports_renames_only_under_the_rename_class_and_refuses_fore }), ) .await; + let unauthorized = refusal_problem(&unauthorized["result"]); + assert_eq!(unauthorized["kind"], "unavailable", "{unauthorized}"); assert_eq!( - unauthorized["error"], - json!({ - "code": -32602, - "message": "tool project route failed: reason_code=redundancy-repository-not-authorized retryable=false: the selected repository is outside the authorized repository scope", - "data": { - "tool": "tracedecay_redundancy", - "reason_code": "redundancy-repository-not-authorized", - "retryable": false, - "detail": "the selected repository is outside the authorized repository scope", - } - }), + unauthorized["code"], "redundancy-repository-not-authorized", + "{unauthorized}" + ); + assert_eq!( + unauthorized["message"], + "the selected repository is outside the authorized repository scope", "{unauthorized}" ); + assert_eq!(unauthorized["retryable"], false, "{unauthorized}"); fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_preview_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_preview_test.rs index d957242919..d387400676 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_preview_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_preview_test.rs @@ -16,7 +16,7 @@ use tracedecay::mcp::McpServer; use crate::support::{ ProductionCompositionFixture, handle_real_server_tool_call_raw, - production_composition_fixture_with_sources, warm_code_index_search, + production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; const STOCK_RS: &str = "\ @@ -166,20 +166,15 @@ async fn rename_preview_reports_the_declaration_the_caller_and_text_only_names() assert_sources_unchanged(&fixture); } -fn assert_execution_refused(response: &Value, message: &str) { +fn assert_invalid_request_refused(response: &Value, message: &str) { + assert!(response.get("error").is_none(), "{response}"); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request", "{response}"); assert_eq!( - response["error"], - json!({ - "code": -32603, - "message": message, - "data": { - "tool": "tracedecay_rename_preview", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool rename_preview ...` (`tracedecay tool rename_preview --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } - }), + problem["code"], "application.surface.invalid_request", "{response}" ); - assert!(response.get("result").is_none(), "{response}"); + assert_eq!(problem["message"], message, "{response}"); } #[tokio::test] @@ -210,9 +205,9 @@ async fn rename_preview_refuses_unknown_and_unusable_node_identity() { json!({ "format": "json" }), ) .await; - assert_execution_refused( + assert_invalid_request_refused( &omitted, - "tool execution failed: config error: invalid arguments for tracedecay_rename_preview: missing field `node_id`", + "invalid arguments for tracedecay_rename_preview: missing field `node_id`", ); let empty = handle_real_server_tool_call_raw( @@ -221,10 +216,7 @@ async fn rename_preview_refuses_unknown_and_unusable_node_identity() { json!({ "node_id": "", "format": "json" }), ) .await; - assert_execution_refused( - &empty, - "tool execution failed: config error: invalid parameter: node_id must not be empty", - ); + assert_invalid_request_refused(&empty, "invalid parameter: node_id must not be empty"); let apply_shaped = handle_real_server_tool_call_raw( &server, @@ -236,9 +228,9 @@ async fn rename_preview_refuses_unknown_and_unusable_node_identity() { }), ) .await; - assert_execution_refused( + assert_invalid_request_refused( &apply_shaped, - "tool execution failed: config error: invalid arguments for tracedecay_rename_preview: unknown field `dry_run`, expected `node_id` or `new_name`", + "invalid arguments for tracedecay_rename_preview: unknown field `dry_run`, expected `node_id` or `new_name`", ); assert_sources_unchanged(&fixture); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs index 26725c0915..c62620758c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs @@ -128,8 +128,21 @@ async fn retrieve_tool_returns_full_stored_response() { .await .expect("production retrieve invocation"); assert!( - alias.result.is_none() && alias.error.is_some(), - "tracedecay_retrieve must accept only the canonical `handle` field" + alias.error.is_none(), + "tracedecay_retrieve refusal is a tool result: {:?}", + alias.error.as_ref().map(|error| &error.message) + ); + let problem = refusal_problem( + alias + .result + .as_ref() + .expect("tracedecay_retrieve refusal result"), + ); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!( + problem["message"], + "invalid arguments for tracedecay_retrieve: unknown field `retrieve_handle`, expected one of `handle`, `offset`, `max_chars`" ); fixture.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/schema_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/schema_test.rs index 3dd6b26f8c..c9eee6c049 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/schema_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/schema_test.rs @@ -19,6 +19,7 @@ async fn schema_required_arguments_match_representative_handler_parsers() { &server, "tracedecay_search", json!({}), + Refusal::Problem, "invalid arguments for tracedecay_search: missing field `query`", ) .await; @@ -31,6 +32,7 @@ async fn schema_required_arguments_match_representative_handler_parsers() { &server, "tracedecay_callers", json!({}), + Refusal::JsonRpc, "missing field `node_id`", ) .await; @@ -45,6 +47,7 @@ async fn schema_required_arguments_match_representative_handler_parsers() { &server, "tracedecay_insert_at", json!({ "path": "src/lib.rs" }), + Refusal::JsonRpc, "missing required parameter: anchor", ) .await; @@ -55,45 +58,53 @@ async fn schema_required_arguments_match_representative_handler_parsers() { // call with no arguments" is checked together with "the schema told the // caller which arguments were missing", either half alone lets the two // drift apart. - for (tool_name, required_args, expected_message) in [ + for (tool_name, required_args, refusal, expected_message) in [ ( "tracedecay_fact_store_add", &["content"][..], + Refusal::JsonRpc, "missing field `content`", ), ( "tracedecay_fact_store_search", &["query"][..], + Refusal::JsonRpc, "missing field `query`", ), ( "tracedecay_fact_store_probe", &["entity"][..], + Refusal::JsonRpc, "missing field `entity`", ), ( "tracedecay_fact_store_related", &["entity"][..], + Refusal::JsonRpc, "missing field `entity`", ), ( "tracedecay_fact_store_get", &["fact_id"][..], + Refusal::JsonRpc, "missing field `fact_id`", ), ( "tracedecay_fact_store_update", &["fact_id"][..], + Refusal::JsonRpc, "missing field `fact_id`", ), ( "tracedecay_fact_store_remove", &["fact_id"][..], + Refusal::JsonRpc, "missing field `fact_id`", ), ( "tracedecay_fact_store_supersede", &["fact_id", "superseded_by"][..], + Refusal::JsonRpc, "missing field `fact_id`", ), // Generated schemas, decoded straight into a typed request struct, so @@ -101,28 +112,43 @@ async fn schema_required_arguments_match_representative_handler_parsers() { ( "tracedecay_diff_context", &["files"][..], + Refusal::Problem, "missing field `files`", ), ( "tracedecay_changelog", &["from_ref", "to_ref"][..], + Refusal::Problem, "missing field `from_ref`", ), ( "tracedecay_port_status", &["source_dir", "target_dir"][..], + Refusal::Problem, "missing field `source_dir`", ), ( "tracedecay_port_order", &["source_dir"][..], + Refusal::Problem, "missing field `source_dir`", ), - ("tracedecay_context", &["task"][..], "missing field `task`"), + ( + "tracedecay_context", + &["task"][..], + Refusal::Problem, + "missing field `task`", + ), ] { assert_schema_requires(&tools, tool_name, required_args); - expect_real_server_missing_argument_error(&server, tool_name, json!({}), expected_message) - .await; + expect_real_server_missing_argument_error( + &server, + tool_name, + json!({}), + refusal, + expected_message, + ) + .await; } // Nested-object parser style. assert_schema_requires( @@ -153,6 +179,7 @@ async fn schema_required_arguments_match_representative_handler_parsers() { &server, "tracedecay_lcm_expand", json!({ "provider": "cursor", "session_id": "session-1", "target": {} }), + Refusal::JsonRpc, "target: missing field `kind`", ) .await; @@ -633,16 +660,45 @@ pub(crate) fn assert_schema_requires( ); } +/// How a tool's parser rejection reaches the client. +#[derive(Clone, Copy)] +enum Refusal { + /// Graph-tool owner: an `isError` result carrying an `invalid_request` + /// problem record. + Problem, + /// Retained, source-edit, and other application-surface handlers: a + /// JSON-RPC error. + JsonRpc, +} + async fn expect_real_server_missing_argument_error( server: &Arc, tool_name: &str, args: Value, + refusal: Refusal, expected_message: &str, ) { let response = handle_real_server_tool_call_raw(server, tool_name, args).await; - let message = response["error"]["message"] - .as_str() - .unwrap_or_else(|| panic!("{tool_name} should reject missing arguments: {response}")); + let message = match refusal { + Refusal::Problem => { + assert!( + response.get("error").is_none(), + "{tool_name} should refuse as a tool result: {response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!( + problem["kind"], "invalid_request", + "{tool_name}: {response}" + ); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{tool_name}: {response}" + ); + problem["message"].as_str() + } + Refusal::JsonRpc => response["error"]["message"].as_str(), + } + .unwrap_or_else(|| panic!("{tool_name} should reject missing arguments: {response}")); assert!( message.contains(expected_message), "{tool_name} parser error should mention `{expected_message}`, got `{message}`" diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/search_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/search_behavior_test.rs index e4f6232ef7..421b8985c4 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/search_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/search_behavior_test.rs @@ -5,7 +5,7 @@ use crate::support::{ extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, - production_composition_fixture_with_sources, warm_code_index_search, + production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; use serde_json::{Value, json}; use std::fs; @@ -40,22 +40,22 @@ async fn search_returns_the_named_symbol_and_refuses_arguments_outside_its_typed .server(&fixture.project_root) .expect("production search server"); - let refusal = |detail: &str| { - json!({ - "code": -32603, - "message": format!("tool execution failed: config error: invalid arguments for tracedecay_search: {detail}"), - "data": { - "tool": "tracedecay_search", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool search ...` (`tracedecay tool search --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly.", - }, - }) + let assert_refused = |response: &Value, detail: &str| { + assert!(response.get("error").is_none(), "{response}"); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request", "{response}"); + assert_eq!( + problem["code"], "application.surface.invalid_request", + "{response}" + ); + assert_eq!( + problem["message"], + format!("invalid arguments for tracedecay_search: {detail}"), + "{response}" + ); }; let missing = handle_real_server_tool_call_raw(&server, "tracedecay_search", json!({})).await; - assert_eq!( - missing["error"], - refusal("missing field `query`"), - "{missing}" - ); + assert_refused(&missing, "missing field `query`"); // Arguments outside the typed request are refused, not silently ignored. let unknown = handle_real_server_tool_call_raw( &server, @@ -63,14 +63,11 @@ async fn search_returns_the_named_symbol_and_refuses_arguments_outside_its_typed json!({"query": "ledger_post_entry", "semantic_mode": "hybrid"}), ) .await; - assert_eq!( - unknown["error"], - refusal( - "unknown field `semantic_mode`, expected one of `query`, `limit`, `cursor`, \ - `lexical_anchors`, `prefer_symbol`, `lexical_aliases`, `lexical_phrases`, \ - `lexical_proximities`, `lexical_field_filters`, `lazy_index_ignored_dependencies`" - ), - "{unknown}" + assert_refused( + &unknown, + "unknown field `semantic_mode`, expected one of `query`, `limit`, `cursor`, \ + `lexical_anchors`, `prefer_symbol`, `lexical_aliases`, `lexical_phrases`, \ + `lexical_proximities`, `lexical_field_filters`, `lazy_index_ignored_dependencies`", ); let untyped_limit = handle_real_server_tool_call_raw( &server, @@ -78,11 +75,7 @@ async fn search_returns_the_named_symbol_and_refuses_arguments_outside_its_typed json!({"query": "ledger_post_entry", "limit": "5"}), ) .await; - assert_eq!( - untyped_limit["error"], - refusal("invalid type: string \"5\", expected u64"), - "{untyped_limit}" - ); + assert_refused(&untyped_limit, "invalid type: string \"5\", expected u64"); warm_code_index_search(&server, "ledger_post_entry").await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/signature_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/signature_behavior_test.rs index 0b6577a383..653ca242e8 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/signature_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/signature_behavior_test.rs @@ -13,7 +13,8 @@ use serde_json::{Value, json}; use tracedecay::mcp::McpServer; use crate::support::{ - dispatch_mcp_tool_call, production_composition_fixture_with_sources, warm_code_index_search, + dispatch_mcp_tool_call, production_composition_fixture_with_sources, refusal_problem, + warm_code_index_search, }; const SOURCE: &str = r#"/// Loads the current value. @@ -153,28 +154,30 @@ async fn tracedecay_signature_returns_the_declared_signature() { assert_eq!(unknown_node, json!([])); let omitted = signature_call(&server, json!({})).await; - assert_eq!(omitted["error"]["code"], -32602); + assert!(omitted.get("error").is_none(), "{omitted}"); + let omitted = refusal_problem(&omitted["result"]); + assert_eq!(omitted["kind"], "invalid_request", "{omitted}"); assert_eq!( - omitted["error"]["message"], - "missing required parameter: qualified_name or node_id" + omitted["code"], "application.surface.invalid_request", + "{omitted}" ); assert_eq!( - omitted["error"]["data"], - json!({ - "tool": "tracedecay_signature", - "reason_code": "missing_required_parameter", - "retryable": false, - "detail": "missing required parameter: qualified_name or node_id" - }) + omitted["message"], "missing required parameter: qualified_name or node_id", + "{omitted}" ); let blank = signature_call(&server, json!({"node_id": ""})).await; - assert_eq!(blank["error"]["code"], -32603); + assert!(blank.get("error").is_none(), "{blank}"); + let blank = refusal_problem(&blank["result"]); + assert_eq!(blank["kind"], "invalid_request", "{blank}"); + assert_eq!( + blank["code"], "application.surface.invalid_request", + "{blank}" + ); assert_eq!( - blank["error"]["message"], - "tool execution failed: config error: invalid parameter: node_id must not be empty" + blank["message"], "invalid parameter: node_id must not be empty", + "{blank}" ); - assert_eq!(blank["error"]["data"]["tool"], "tracedecay_signature"); fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/similar_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/similar_test.rs index c6cd0cdd6f..7fbd4be687 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/similar_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/similar_test.rs @@ -4,7 +4,7 @@ //! token-verified copy paths and typed denials out. use crate::support::{ - handle_real_server_tool_call_raw, production_composition_fixture_with_sources, + handle_real_server_tool_call_raw, production_composition_fixture_with_sources, refusal_problem, warm_code_index_search, }; use serde_json::{Value, json}; @@ -271,7 +271,6 @@ async fn tracedecay_similar_reports_verified_copy_paths_and_typed_denials() { .await; assert_similar_denial( &unauthorized, - "tool project route failed: reason_code=similar-source-not-found retryable=false: the selected source is outside the authorized repository scope", "the selected source is outside the authorized repository scope", ); @@ -293,7 +292,6 @@ async fn tracedecay_similar_reports_verified_copy_paths_and_typed_denials() { .await; assert_similar_denial( &missing, - "tool project route failed: reason_code=similar-source-not-found retryable=false: the selected source has no body in the verified clone index", "the selected source has no body in the verified clone index", ); @@ -357,15 +355,11 @@ fn family_paths(family: &Value) -> Vec<&str> { paths } -fn assert_similar_denial(response: &Value, message: &str, detail: &str) { - assert_eq!(response["error"]["code"], -32602, "{response}"); - assert_eq!(response["error"]["message"], message, "{response}"); - assert_eq!(response["error"]["data"]["tool"], "tracedecay_similar"); - assert_eq!( - response["error"]["data"]["reason_code"], - "similar-source-not-found" - ); - assert_eq!(response["error"]["data"]["retryable"], false); - assert_eq!(response["error"]["data"]["detail"], detail); - assert!(response["result"].is_null(), "{response}"); +fn assert_similar_denial(response: &Value, message: &str) { + assert!(response["error"].is_null(), "{response}"); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "unavailable", "{response}"); + assert_eq!(problem["code"], "similar-source-not-found", "{response}"); + assert_eq!(problem["message"], message, "{response}"); + assert_eq!(problem["retryable"], false, "{response}"); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_list_test.rs index 115a4f206a..21eec9da41 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_list_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_list_test.rs @@ -13,12 +13,11 @@ use tracedecay_automation_runtime::automation::managed_skills::{ set_managed_skill_state, }; -use crate::support::{ProductionCompositionFixture, production_composition_fixture}; +use crate::support::{ + ProductionCompositionFixture, production_composition_fixture, refusal_problem, +}; const ACTOR: &str = "skill-list-proof"; -const CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool skill_list ...` \ -(`tracedecay tool skill_list --help` for parameters). If MCP calls keep failing or timing out, fall \ -back to that CLI instead of querying .tracedecay databases directly."; #[tokio::test] async fn skill_list_returns_stored_skills_for_the_requested_state() { @@ -114,20 +113,15 @@ async fn skill_list_returns_stored_skills_for_the_requested_state() { ) .await .expect("production MCP call returns a JSON-RPC response"); + assert_eq!(rejected.jsonrpc, "2.0"); + assert_eq!(rejected.id, json!(1)); + assert!(rejected.error.is_none()); + let problem = refusal_problem(rejected.result.as_ref().expect("skill list refusal result")); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - serde_json::to_value(&rejected).expect("JSON-RPC response"), - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": -32603, - "message": "tool execution failed: config error: invalid arguments for tracedecay_skill_list: unknown variant `retired`, expected one of `active`, `disabled`, `archived`", - "data": { - "tool": "tracedecay_skill_list", - "cli_fallback": CLI_FALLBACK, - } - } - }) + problem["message"], + "invalid arguments for tracedecay_skill_list: unknown variant `retired`, expected one of `active`, `disabled`, `archived`" ); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs index f0c15f04ba..7f07b2c255 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skill_view_behavior_test.rs @@ -17,11 +17,8 @@ use crate::fixture; use crate::mcp_server_test::support::{ jsonrpc_request, response_with_id, run_client_connection_with_messages, }; -use crate::support::{TestTraceDecay, open_active_project_scoped_runtime}; +use crate::support::{TestTraceDecay, open_active_project_scoped_runtime, refusal_problem}; -const CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool skill_view ...` \ -(`tracedecay tool skill_view --help` for parameters). If MCP calls keep failing or timing out, fall \ -back to that CLI instead of querying .tracedecay databases directly."; const PROBE_ID: &str = "probe-skill"; const OTHER_ID: &str = "other-skill"; const PROBE_BODY: &str = "Read the checklist, then stop."; @@ -362,34 +359,24 @@ async fn skill_view_denies_missing_id_and_unknown_skill() { let missing = call_skill_view(&fixture.server, 11, json!({})).await; assert_eq!(missing["jsonrpc"], "2.0"); assert_eq!(missing["id"], 11); - assert_eq!( - missing["error"], - json!({ - "code": -32603, - "message": "tool execution failed: config error: invalid arguments for tracedecay_skill_view: missing field `id`", - "data": { - "tool": "tracedecay_skill_view", - "cli_fallback": CLI_FALLBACK, - } - }) + assert_skill_view_refusal( + &missing, + "invalid arguments for tracedecay_skill_view: missing field `id`", ); - assert!(missing.get("result").is_none()); let unknown = call_skill_view(&fixture.server, 12, json!({"id": "no-such-skill"})).await; assert_eq!(unknown["jsonrpc"], "2.0"); assert_eq!(unknown["id"], 12); - assert_eq!( - unknown["error"], - json!({ - "code": -32602, - "message": "managed skill 'no-such-skill' not found", - "data": { - "tool": "tracedecay_skill_view", - "reason_code": "not_found", - "retryable": false, - "detail": "managed skill 'no-such-skill' not found" - } - }) + assert_skill_view_refusal(&unknown, "managed skill 'no-such-skill' not found"); +} + +fn assert_skill_view_refusal(response: &Value, message: &str) { + assert!( + response.get("error").is_none() || response["error"].is_null(), + "skill view refusal is an isError result: {response}" ); - assert!(unknown.get("result").is_none()); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs index f13fe9b5aa..ffa974f82f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs @@ -120,17 +120,19 @@ async fn automation_run_artifact_mcp_tool_reads_verified_payload() { "inspect artifact through MCP" ); - let missing = server - .call_tool_for_test( - "tracedecay_automation_run_artifact_view", - json!({"run_id": run_id, "kind": "generated_evals"}), - ) - .await - .unwrap_err(); - assert!( - missing - .to_string() - .contains("automation run artifact not found") + let problem = expect_tool_refusal( + server + .call_tool_for_test( + "tracedecay_automation_run_artifact_view", + json!({"run_id": run_id, "kind": "generated_evals"}), + ) + .await, + ); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!( + problem["message"], + format!("automation run artifact not found: {run_id}/generated_evals") ); drop(server); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_map_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_map_test.rs index f538ff11ce..2158012b70 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_map_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_map_test.rs @@ -14,7 +14,7 @@ use tracedecay_mcp::jsonrpc::JsonRpcResponse; use crate::support::{ ProductionCompositionFixture, extract_first_json_content, - production_composition_fixture_with_sources, wait_for_current_graph, + production_composition_fixture_with_sources, refusal_problem, wait_for_current_graph, }; const LIB_RS: &str = "\ @@ -119,15 +119,21 @@ fn success_payload(response: &JsonRpcResponse) -> Value { extract_first_json_content(result) } +/// The stable fields of a refusal's problem record; request and trace ids +/// are per-call. fn failure(response: &JsonRpcResponse) -> Value { - let error = response - .error - .as_ref() - .unwrap_or_else(|| panic!("expected a tool error, got {:?}", response.result)); + assert!( + response.error.is_none(), + "expected a tool refusal, got a JSON-RPC error: {:?}", + response.error + ); + let result = response.result.as_ref().expect("refused tools/call result"); + let problem = refusal_problem(result); json!({ - "code": error.code, - "message": error.message, - "data": error.data, + "kind": problem["kind"], + "code": problem["code"], + "message": problem["message"], + "retryable": problem["retryable"], }) } @@ -324,25 +330,19 @@ async fn test_map_reports_literal_coverage_and_typed_refusals() { assert_eq!( failure(&call_test_map(&fixture, json!({})).await), json!({ - "code": -32602, + "kind": "invalid_request", + "code": "application.surface.invalid_request", "message": "missing required parameter: 'file' or 'node_id'", - "data": { - "tool": "tracedecay_test_map", - "reason_code": "missing_required_parameter", - "retryable": false, - "detail": "missing required parameter: 'file' or 'node_id'" - } + "retryable": false }) ); assert_eq!( failure(&call_test_map(&fixture, json!({"node_id": " lead"})).await), json!({ - "code": -32603, - "message": "tool execution failed: config error: invalid test-map symbol occurrence: SymbolOccurrenceId is not canonical", - "data": { - "tool": "tracedecay_test_map", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool test_map ...` (`tracedecay tool test_map --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } + "kind": "invalid_request", + "code": "application.surface.invalid_request", + "message": "invalid test-map symbol occurrence: SymbolOccurrenceId is not canonical", + "retryable": false }) ); @@ -359,14 +359,10 @@ async fn test_map_refuses_when_the_test_is_beyond_depth_three() { assert_eq!( failure(&call_test_map(&fixture, json!({"file": "src/deep.rs"})).await), json!({ - "code": -32602, - "message": "tool project route failed: reason_code=verified-test-evidence-unavailable retryable=false: verified test-map caller expansion exceeded its budget", - "data": { - "tool": "tracedecay_test_map", - "reason_code": "verified-test-evidence-unavailable", - "retryable": false, - "detail": "verified test-map caller expansion exceeded its budget" - } + "kind": "unavailable", + "code": "verified-test-evidence-unavailable", + "message": "verified test-map caller expansion exceeded its budget", + "retryable": false }) ); fixture.harness.shutdown().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/todos_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/todos_test.rs index dcf8b6d4a5..739bbfb7ca 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/todos_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/todos_test.rs @@ -14,7 +14,8 @@ use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_mcp::jsonrpc::JsonRpcResponse; use crate::support::{ - extract_text, production_composition_fixture_with_sources, wait_for_current_graph, + extract_text, production_composition_fixture_with_sources, refusal_problem, + wait_for_current_graph, }; /// `src/lib.rs` lines (1-indexed): @@ -433,12 +434,16 @@ async fn todos_reports_observed_marker_behavior() { json!({"not_a_field": true}), ) .await; - let error = denied.error.expect("unknown field must be an MCP error"); - assert!(denied.result.is_none(), "{:?}", denied.result); - assert_eq!(error.code, -32603); + assert!(denied.error.is_none(), "{:?}", denied.error); + let denied = denied + .result + .expect("unknown field must be a refused tool result"); + let problem = refusal_problem(&denied); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); assert_eq!( - error.message, - "tool execution failed: config error: invalid arguments for tracedecay_todos: unknown field `not_a_field`, expected one of `kinds`, `path`, `limit`" + problem["message"], + "invalid arguments for tracedecay_todos: unknown field `not_a_field`, expected one of `kinds`, `path`, `limit`" ); let after_denial = diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/unsafe_patterns_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/unsafe_patterns_test.rs index b61db46bf2..2b6a1b0501 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/unsafe_patterns_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/unsafe_patterns_test.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use crate::support::{ ProductionCompositionFixture, extract_text, production_composition_fixture_with_sources, - wait_for_current_graph, + refusal_problem, wait_for_current_graph, }; const TODO_MARKDOWN: &str = r#"## Risky Patterns @@ -91,19 +91,24 @@ async fn tool_text(fixture: &ProductionCompositionFixture, args: Value) -> Strin extract_text(&response.result.expect("tools/call result")).to_owned() } -/// The refusal message of a `tools/call` the tool must reject. -async fn tool_error(fixture: &ProductionCompositionFixture, args: Value) -> String { +/// Asserts a `tools/call` is refused for naming a kind the tool does not scan. +async fn assert_unknown_kind_refused(fixture: &ProductionCompositionFixture, args: Value) { let response = fixture .harness .call_tool(&fixture.project_root, "tracedecay_unsafe_patterns", args) .await .unwrap_or_else(|error| panic!("tools/call failed: {error}")); - assert!(response.result.is_none(), "{:?}", response.result); - response.error.expect("tools/call must refuse").message + assert!(response.error.is_none(), "{:?}", response.error); + let result = response.result.expect("tools/call must refuse"); + let problem = refusal_problem(&result); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!( + problem["message"], + "invalid arguments for tracedecay_unsafe_patterns: unknown variant `not_a_kind`, expected one of `unwrap`, `expect`, `panic`, `todo`, `unimplemented`, `unsafe_block`" + ); } -const UNKNOWN_KIND_REFUSAL: &str = "tool execution failed: config error: invalid arguments for tracedecay_unsafe_patterns: unknown variant `not_a_kind`, expected one of `unwrap`, `expect`, `panic`, `todo`, `unimplemented`, `unsafe_block`"; - fn parse_json(text: &str) -> Value { serde_json::from_str(text) .unwrap_or_else(|error| panic!("tool text was not JSON: {error}\n{text}")) @@ -322,10 +327,7 @@ async fn unsafe_patterns_reports_literal_sites_for_each_kind() { assert_eq!(parse_json(&empty_json), empty_report()); // An unknown kind is refused rather than silently matching nothing. - assert_eq!( - tool_error(&fixture, json!({"kinds": ["not_a_kind"], "format": "json"})).await, - UNKNOWN_KIND_REFUSAL - ); + assert_unknown_kind_refused(&fixture, json!({"kinds": ["not_a_kind"], "format": "json"})).await; let mut all_sites = production_sites(); all_sites.push(widget_helper()); @@ -386,14 +388,11 @@ async fn unsafe_patterns_reports_literal_sites_for_each_kind() { report(2, json!({"panic": 2}), vec![fail_closed(), ship_panic()]) ); - assert_eq!( - tool_error( - &fixture, - json!({"kinds": ["unwrap", "not_a_kind"], "format": "json"}), - ) - .await, - UNKNOWN_KIND_REFUSAL - ); + assert_unknown_kind_refused( + &fixture, + json!({"kinds": ["unwrap", "not_a_kind"], "format": "json"}), + ) + .await; let unwraps = tool_text(&fixture, json!({"kinds": ["unwrap"], "format": "json"})).await; assert_eq!( parse_json(&unwraps), diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/analytics_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/analytics_test.rs index 3f9e704603..36fbd71f0e 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/analytics_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/analytics_test.rs @@ -1,5 +1,6 @@ use crate::common::EnvVarGuard; use crate::mcp_server_test::support::*; +use crate::support::refusal_problem; use serde_json::json; #[cfg(feature = "test-transport")] use std::sync::Arc; @@ -310,10 +311,10 @@ async fn skill_view_call_writes_skill_arguments_to_mcp_runtime_analytics() { ) .await; - assert!( - resp["error"].is_object(), - "missing fixture skill should make the tool call fail" - ); + let problem = refusal_problem(&resp["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], "managed skill 'repo-hygiene' not found"); server_handle.ledger_writes_settled().await; let event = expect_mcp_runtime_event( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_artifact_view_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_artifact_view_test.rs index 0f9f7b004f..4a8d14bd85 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_artifact_view_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_artifact_view_test.rs @@ -7,7 +7,7 @@ //! file is rejected instead of returned. use super::support::{jsonrpc_request, response_with_id, run_server_with_messages}; -use crate::support::{init_test_project, real_mcp_server}; +use crate::support::{init_test_project, real_mcp_server, refusal_problem}; use serde_json::{Value, json}; use std::fs; use tempfile::TempDir; @@ -134,36 +134,17 @@ fn assert_tool_text(response: &Value, id: i64, expected: &str) { ); } -fn invalid_params(id: i64, message: &str, reason_code: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32602, - "message": message, - "data": { - "tool": TOOL, - "reason_code": reason_code, - "retryable": false, - "detail": message - } - } - }) -} - -fn internal_error(id: i64, message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32603, - "message": message, - "data": { - "tool": TOOL, - "cli_fallback": "This tool is also available from the shell: `tracedecay tool automation_run_artifact_view ...` (`tracedecay tool automation_run_artifact_view --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } - } - }) +fn assert_invalid_request(response: &Value, id: i64, message: &str) { + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], id); + assert!( + response.get("error").is_none() || response["error"].is_null(), + "tools/call refusal is an isError result: {response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } #[tokio::test] @@ -341,52 +322,40 @@ async fn automation_run_artifact_view_returns_the_exact_hash_checked_payload() { assert_tool_text(&response_with_id(&responses, json!(4)), 4, TRACES_MARKDOWN); assert_tool_text(&response_with_id(&responses, json!(5)), 5, OTHER_JSON); - assert_eq!( - response_with_id(&responses, json!(6)), - invalid_params( - 6, - "automation run artifact not found: run-artifact-exact/generated_evals", - "not_found" - ) + assert_invalid_request( + &response_with_id(&responses, json!(6)), + 6, + "automation run artifact not found: run-artifact-exact/generated_evals", ); - assert_eq!( - response_with_id(&responses, json!(7)), - invalid_params(7, "automation run not found: run-missing", "not_found") + assert_invalid_request( + &response_with_id(&responses, json!(7)), + 7, + "automation run not found: run-missing", ); - assert_eq!( - response_with_id(&responses, json!(8)), - internal_error( - 8, - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_artifact_view: missing field `run_id`" - ) + assert_invalid_request( + &response_with_id(&responses, json!(8)), + 8, + "invalid arguments for tracedecay_automation_run_artifact_view: missing field `run_id`", ); - assert_eq!( - response_with_id(&responses, json!(9)), - internal_error( - 9, - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_artifact_view: missing field `kind`" - ) + assert_invalid_request( + &response_with_id(&responses, json!(9)), + 9, + "invalid arguments for tracedecay_automation_run_artifact_view: missing field `kind`", ); - assert_eq!( - response_with_id(&responses, json!(10)), - internal_error( - 10, - "tool execution failed: config error: automation run_id '' is not safe for artifact paths" - ) + assert_invalid_request( + &response_with_id(&responses, json!(10)), + 10, + "automation run_id '' is not safe for artifact paths", ); - assert_eq!( - response_with_id(&responses, json!(11)), - internal_error( - 11, - "tool execution failed: config error: invalid arguments for tracedecay_automation_run_artifact_view: unknown variant ``, expected one of `traces`, `feedback`, `generated_evals`, `validation_gate`, `optimizer_diagnosis`, `codex_handoff`" - ) + assert_invalid_request( + &response_with_id(&responses, json!(11)), + 11, + "invalid arguments for tracedecay_automation_run_artifact_view: unknown variant ``, expected one of `traces`, `feedback`, `generated_evals`, `validation_gate`, `optimizer_diagnosis`, `codex_handoff`", ); - assert_eq!( - response_with_id(&responses, json!(12)), - internal_error( - 12, - "tool execution failed: config error: automation run artifact 'automation_artifacts/run-artifact-tampered/codex_handoff.json' hash mismatch" - ) + assert_invalid_request( + &response_with_id(&responses, json!(12)), + 12, + "automation run artifact 'automation_artifacts/run-artifact-tampered/codex_handoff.json' hash mismatch", ); assert_eq!( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_view_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_view_test.rs index 20e1186428..c08fc57bc5 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_view_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/automation_run_view_test.rs @@ -6,7 +6,7 @@ //! enumerate the ledger. use super::support::{jsonrpc_request, response_with_id, run_server_with_messages}; -use crate::support::{init_test_project, real_mcp_server}; +use crate::support::{init_test_project, real_mcp_server, refusal_problem}; use serde_json::{Value, json}; use std::fs; use tempfile::TempDir; @@ -116,36 +116,17 @@ fn assert_tool_text(response: &Value, id: i64, expected: &str) { ); } -fn invalid_params(id: i64, message: &str, reason_code: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32602, - "message": message, - "data": { - "tool": "tracedecay_automation_run_view", - "reason_code": reason_code, - "retryable": false, - "detail": message - } - } - }) -} - -fn config_refusal(id: i64, message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32603, - "message": format!("tool execution failed: config error: {message}"), - "data": { - "tool": "tracedecay_automation_run_view", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool automation_run_view ...` (`tracedecay tool automation_run_view --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } - } - }) +fn assert_invalid_request(response: &Value, id: i64, message: &str) { + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], id); + assert!( + response.get("error").is_none() || response["error"].is_null(), + "tools/call refusal is an isError result: {response}" + ); + let problem = refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); } #[tokio::test] @@ -233,23 +214,20 @@ async fn automation_run_view_returns_the_exact_active_project_record() { assert_tool_text(&response_with_id(&responses, json!(2)), 2, EXACT_MARKDOWN); assert_tool_text(&response_with_id(&responses, json!(3)), 3, FAILED_JSON); - assert_eq!( - response_with_id(&responses, json!(4)), - invalid_params(4, "automation run not found: run-missing", "not_found") + assert_invalid_request( + &response_with_id(&responses, json!(4)), + 4, + "automation run not found: run-missing", ); - assert_eq!( - response_with_id(&responses, json!(5)), - config_refusal( - 5, - "invalid arguments for tracedecay_automation_run_view: run_id must not be empty" - ) + assert_invalid_request( + &response_with_id(&responses, json!(5)), + 5, + "invalid arguments for tracedecay_automation_run_view: run_id must not be empty", ); - assert_eq!( - response_with_id(&responses, json!(6)), - config_refusal( - 6, - "invalid arguments for tracedecay_automation_run_view: missing field `run_id`" - ) + assert_invalid_request( + &response_with_id(&responses, json!(6)), + 6, + "invalid arguments for tracedecay_automation_run_view: missing field `run_id`", ); assert_eq!( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/branch_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/branch_list_test.rs index d5aa19bc87..a70f1b8149 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/branch_list_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/branch_list_test.rs @@ -9,7 +9,7 @@ use std::process::Command; use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; -use crate::support::test_temp_dir; +use crate::support::{refusal_problem, test_temp_dir}; const HEAD_COMMIT: &str = "dbc21220c25f50fce6ac93b6e7859062cd3d3ca8"; @@ -219,16 +219,10 @@ async fn branch_list_reports_exact_local_refs_and_typed_rejections() { ); let zero = call(json!({"limit": 0})).await; - assert!(zero["result"].is_null(), "{zero}"); - assert_eq!(zero["error"]["code"], json!(-32603)); - assert_eq!( - zero["error"]["message"], - json!("tool execution failed: config error: branch-list limit must be positive") - ); - assert_eq!( - zero["error"]["data"]["tool"], - json!("tracedecay_branch_list") - ); + let problem = refusal_problem(&zero["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], "branch-list limit must be positive"); std::fs::rename(project_root.join(".git"), project_root.join(".git-hidden")) .expect("hide git dir"); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs index 9d5f2efe31..e5d71ff005 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs @@ -795,19 +795,6 @@ async fn test_tools_call_missing_name() { ); } -fn handle_read_failed_error() -> Value { - json!({ - "code": -32603, - "message": "tool project route failed: reason_code=handle_read_failed retryable=true: response-handle cache is unavailable", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "handle_read_failed", - "retryable": true, - "detail": "response-handle cache is unavailable" - } - }) -} - #[tokio::test] async fn test_unknown_method() { let (server, _dir) = setup_server().await; @@ -1070,11 +1057,13 @@ async fn test_server_stats_include_response_handle_metrics() { json!({ "handle": broken.handle }), ) .await; + let problem = crate::support::refusal_problem(&broken_result["result"]); assert_eq!( - broken_result["error"], - handle_read_failed_error(), + problem["kind"], "unavailable", "broken handle fixture should increment retrieve failure telemetry" ); + assert_eq!(problem["code"], "handle_read_failed"); + assert_eq!(problem["message"], "response-handle cache is unavailable"); fs::remove_dir(&broken_path).unwrap(); store_response_handle( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/retrieve_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/retrieve_behavior_test.rs index c7b10a0f49..05b075466a 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/retrieve_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/retrieve_behavior_test.rs @@ -28,10 +28,6 @@ const CRAB_HANDLE: &str = "rh_85646496e4a65bc20aa95627"; const SHORT: &str = "short"; const SHORT_HANDLE: &str = "rh_f9b0078b5df596d2ea19010c"; -const CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool retrieve ...` \ -(`tracedecay tool retrieve --help` for parameters). If MCP calls keep failing or timing out, \ -fall back to that CLI instead of querying .tracedecay databases directly."; - fn tool_text(response: &Value) -> &str { response["result"]["content"][0]["text"] .as_str() @@ -177,15 +173,18 @@ async fn retrieve_reports_missing_and_expired_handles() { fixture.shutdown().await; } -fn config_refusal(detail: &str) -> Value { - json!({ - "code": -32603, - "message": format!("tool execution failed: config error: invalid arguments for tracedecay_retrieve: {detail}"), - "data": { - "tool": "tracedecay_retrieve", - "cli_fallback": CLI_FALLBACK - } - }) +fn assert_invalid_request(response: &Value, message: &str) { + let problem = crate::support::refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "invalid_request"); + assert_eq!(problem["code"], "application.surface.invalid_request"); + assert_eq!(problem["message"], message); +} + +fn assert_unavailable(response: &Value, code: &str, message: &str) { + let problem = crate::support::refusal_problem(&response["result"]); + assert_eq!(problem["kind"], "unavailable"); + assert_eq!(problem["code"], code); + assert_eq!(problem["message"], message); } #[tokio::test] @@ -194,72 +193,36 @@ async fn retrieve_rejects_arguments_outside_its_typed_request() { let root = response_handle_root(&fixture).await; store_response_handle(&root, SHORT, STORED_AT).unwrap(); - assert_eq!( - retrieve(&fixture, json!({})).await["error"], - json!({ - "code": -32602, - "message": "tracedecay_retrieve requires the `handle` argument copied from a truncated MCP response envelope.", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "missing_handle_argument", - "retryable": false, - "retry_instruction": "Call `tracedecay_retrieve` again with the exact `handle` value emitted by the truncated response envelope." - } - }) + assert_invalid_request( + &retrieve(&fixture, json!({})).await, + "invalid arguments for tracedecay_retrieve: missing field `handle`", ); - assert_eq!( - retrieve(&fixture, json!({"handle": "bogus"})).await["error"], - json!({ - "code": -32602, - "message": "invalid response handle: expected `rh_` followed by 24 hex characters copied from a truncated MCP response envelope", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "invalid_handle", - "retryable": false, - "retry_instruction": "Pass the exact `handle` string from a truncated MCP response envelope; do not shorten or edit it." - } - }) + assert_invalid_request( + &retrieve(&fixture, json!({"handle": "bogus"})).await, + "invalid response handle: expected `rh_` followed by 24 hex characters copied from a truncated MCP response envelope", ); - assert_eq!( - retrieve(&fixture, json!({"retrieve_handle": SHORT_HANDLE})).await["error"], - config_refusal( - "unknown field `retrieve_handle`, expected one of `handle`, `offset`, `max_chars`" - ) + assert_invalid_request( + &retrieve(&fixture, json!({"retrieve_handle": SHORT_HANDLE})).await, + "invalid arguments for tracedecay_retrieve: unknown field `retrieve_handle`, expected one of `handle`, `offset`, `max_chars`", ); // A non-string handle is a type error, not a missing argument. - assert_eq!( - retrieve(&fixture, json!({"handle": 5})).await["error"], - config_refusal("invalid type: integer `5`, expected a string") + assert_invalid_request( + &retrieve(&fixture, json!({"handle": 5})).await, + "invalid arguments for tracedecay_retrieve: invalid type: integer `5`, expected a string", ); - assert_eq!( - retrieve(&fixture, json!({"handle": SHORT_HANDLE, "offset": -1})).await["error"], - config_refusal("invalid value: integer `-1`, expected u64") + assert_invalid_request( + &retrieve(&fixture, json!({"handle": SHORT_HANDLE, "offset": -1})).await, + "invalid arguments for tracedecay_retrieve: invalid value: integer `-1`, expected u64", ); - assert_eq!( - retrieve(&fixture, json!({"handle": SHORT_HANDLE, "max_chars": 0})).await["error"], - json!({ - "code": -32602, - "message": "tool project route failed: reason_code=response_handle_invalid_page_size retryable=false: tracedecay_retrieve max_chars must be at least 1", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "response_handle_invalid_page_size", - "retryable": false, - "detail": "tracedecay_retrieve max_chars must be at least 1" - } - }) + assert_unavailable( + &retrieve(&fixture, json!({"handle": SHORT_HANDLE, "max_chars": 0})).await, + "response_handle_invalid_page_size", + "tracedecay_retrieve max_chars must be at least 1", ); - assert_eq!( - retrieve(&fixture, json!({"handle": SHORT_HANDLE, "offset": 6})).await["error"], - json!({ - "code": -32602, - "message": "tool project route failed: reason_code=response_handle_offset_out_of_range retryable=false: tracedecay_retrieve offset 6 exceeds stored response length 5", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "response_handle_offset_out_of_range", - "retryable": false, - "detail": "tracedecay_retrieve offset 6 exceeds stored response length 5" - } - }) + assert_unavailable( + &retrieve(&fixture, json!({"handle": SHORT_HANDLE, "offset": 6})).await, + "response_handle_offset_out_of_range", + "tracedecay_retrieve offset 6 exceeds stored response length 5", ); fixture.shutdown().await; } @@ -315,31 +278,15 @@ async fn retrieve_reports_unreadable_records_as_typed_route_problems() { fs::remove_file(&unreadable_path).unwrap(); fs::create_dir(&unreadable_path).unwrap(); - assert_eq!( - retrieve(&fixture, json!({"handle": corrupt.handle})).await["error"], - json!({ - "code": -32603, - "message": "tool project route failed: reason_code=corrupt_handle_record retryable=true: corrupt response-handle record: cached payload failed integrity validation", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "corrupt_handle_record", - "retryable": true, - "detail": "corrupt response-handle record: cached payload failed integrity validation" - } - }) + assert_unavailable( + &retrieve(&fixture, json!({"handle": corrupt.handle})).await, + "corrupt_handle_record", + "corrupt response-handle record: cached payload failed integrity validation", ); - assert_eq!( - retrieve(&fixture, json!({"handle": unreadable.handle})).await["error"], - json!({ - "code": -32603, - "message": "tool project route failed: reason_code=handle_read_failed retryable=true: response-handle cache is unavailable", - "data": { - "tool": "tracedecay_retrieve", - "reason_code": "handle_read_failed", - "retryable": true, - "detail": "response-handle cache is unavailable" - } - }) + assert_unavailable( + &retrieve(&fixture, json!({"handle": unreadable.handle})).await, + "handle_read_failed", + "response-handle cache is unavailable", ); fixture.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/support.rs b/crates/tracedecay/tests/mcp_suite/support.rs index 24354029da..94a60a5c39 100644 --- a/crates/tracedecay/tests/mcp_suite/support.rs +++ b/crates/tracedecay/tests/mcp_suite/support.rs @@ -1177,6 +1177,54 @@ pub(crate) fn expect_tool_error(result: tracedecay_domain::errors::Result) } } +/// The owner's typed problem record from a refused `tools/call` result. +/// +/// 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`. +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, + }; + assert!( + problem.is_object(), + "refusal carries no problem record: {result}" + ); + problem +} + +/// [`refusal_problem`] for a [`ToolResult`]: a handler's own result carries +/// the semantic-error mark the transport later renders as `isError`, and a +/// result rebuilt from the transport's reply carries `isError` itself. +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"]; + assert!( + problem.is_object(), + "refusal carries no problem record: {}", + result.value + ); + problem +} + +/// [`tool_result_problem`] for a handler call that must not fail outright. +pub(crate) fn expect_tool_refusal(result: tracedecay_domain::errors::Result) -> Value { + match result { + Ok(result) => tool_result_problem(&result).clone(), + Err(error) => panic!("expected a semantic refusal, got an error: {error}"), + } +} + #[cfg(feature = "test-transport")] pub(crate) async fn seed_project_registry( db_path: &Path,