Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions crates/tracedecay/tests/mcp_suite/analytics_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
44 changes: 22 additions & 22 deletions crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]
Expand All @@ -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!(
Expand All @@ -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!(
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
);
}
}
Expand Down Expand Up @@ -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"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading