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
6 changes: 2 additions & 4 deletions crates/tracedecay-cli/src/tool_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ use tracedecay_daemon_protocol::{
use tracedecay_daemon_service::application_surface::observe_surface_argument_rejection;
use tracedecay_domain::UtcMicros;
use tracedecay_domain::errors::{Result, TraceDecayError};
use tracedecay_mcp::tool_errors::project_route_problem;
use tracedecay_mcp::tool_errors::{project_route_problem, tool_result_problem};
use tracedecay_mcp::tools::binding::tool_dispatches_registered_project_reader;
use tracedecay_mcp::tools::response_trailers::{
CODE_GRAPH_FRESHNESS_TRAILER_PREFIX, REQUEST_COST_TRAILER_PREFIX,
Expand Down Expand Up @@ -1198,9 +1198,7 @@ fn tool_result_process_outcome(result_value: &Value, tool_name: &str) -> Result<
// returning the status-only error so the process boundary can drop its
// profiling guard and then return the nonzero `ExitCode`.
std::io::stdout().flush()?;
let problem = result_value
.get("problem")
.or_else(|| result_value.pointer("/structuredContent/problem"));
let problem = tool_result_problem(result_value);
let problem_text = |key: &str| {
problem
.and_then(|problem| problem.get(key))
Expand Down
13 changes: 9 additions & 4 deletions crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3028,7 +3028,8 @@ fn tool_waits_through_an_after_delay_unavailable_within_its_deadline() {
)
});
assert_eq!(
printed["problem"]["code"], "application.retained.authority-unavailable",
printed["structuredContent"]["problem"]["code"],
"application.retained.authority-unavailable",
"stdout must carry the mounted owner's answer, got:\n{stdout}"
);
assert!(
Expand All @@ -3047,7 +3048,7 @@ fn tool_waits_through_an_after_delay_unavailable_within_its_deadline() {
// The mounting refusal and the final answer are both `after_delay`
// problems built by the one problem-record authority, so the printed
// delay is the delay each ridden-out refusal waited.
let retry_after_millis = printed["problem"]["retry_after_millis"]
let retry_after_millis = printed["structuredContent"]["problem"]["retry_after_millis"]
.as_u64()
.expect("an after_delay problem names its delay");
assert!(
Expand Down Expand Up @@ -3090,10 +3091,14 @@ fn tool_returns_a_completed_authority_result_without_resending() {
});
assert_eq!(printed["isError"], true);
assert_eq!(
printed["problem"]["code"], "application.retained.authority-unavailable",
printed["structuredContent"]["problem"]["code"],
"application.retained.authority-unavailable",
"the daemon's completed answer must be surfaced, got:\n{stdout}"
);
assert_eq!(printed["problem"]["retry"], "after_delay");
assert_eq!(
printed["structuredContent"]["problem"]["retry"],
"after_delay"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
stderr.lines().last(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ fn code_graph_tools_do_not_first_touch_project_store() {
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|error| panic!("status --json prints its refusal: {error}\n{stdout}"));
let problem = &result["problem"];
let problem = &result["structuredContent"]["problem"];
assert_eq!(
(&problem["kind"], &problem["code"], &problem["message"]),
(
Expand Down
9 changes: 5 additions & 4 deletions crates/tracedecay-mcp/src/application_output/tool_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use super::markdown;
use super::view::CanonicalHumanView;
use crate::ToolResult;
use crate::handlers::support::text_tool_result;
use crate::tool_errors::problem_structured_content;
use crate::tools::render::finalize_with_format;
use crate::tools::response_trailers::ResponseTrailer;

Expand Down Expand Up @@ -62,8 +63,8 @@ pub fn problem_error(problem: ApplicationProblemEnvelope) -> TraceDecayError {
}

/// Renders one settled application call. A problem is a semantic failure
/// whose whole record rides beside the text as `problem`, which MCP carries
/// as structured content and `--json` prints with the result.
/// whose whole record rides beside the text as MCP
/// `structuredContent.problem`, which `--json` prints with the result.
pub fn render_application_result(
response_handle_root: Option<&Path>,
operation: &str,
Expand Down Expand Up @@ -108,8 +109,8 @@ pub fn render_application_result(
Err(problem) => {
if let Some(object) = rendered.value.as_object_mut() {
object.insert(
"problem".to_string(),
serde_json::to_value(problem.problem.as_ref())?,
"structuredContent".to_string(),
problem_structured_content(&problem.problem)?,
Comment on lines +112 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Migrate the remaining raw CLI refusal readers

When an application tool such as diagnostics returns a semantic refusal under --json, this writer now exposes the record only at structuredContent.problem, but the Unix core_cli_suite still has SurfaceOutcome::problem_code reading payload.problem and the tests at tool_surface_transport_test.rs:584 and :642 doing the same. Those deterministic refusal journeys consequently receive null instead of the expected code/detail, leaving the advertised full core CLI suite red; update these consumers to read the new location as part of this cutover.

AGENTS.md reference: AGENTS.md:L178-L180

Useful? React with 👍 / 👎.

);
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-mcp/src/handlers/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ mod tests {
.await
.expect("a refused source edit renders as a tool result");
assert_eq!(result.semantic_error(), Some(true), "{}", result.value);
result.value["problem"].clone()
result.value["structuredContent"]["problem"].clone()
}

fn problem_summary(problem: &Value) -> Value {
Expand Down
6 changes: 1 addition & 5 deletions crates/tracedecay-mcp/src/server/rmcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use tokio::sync::{RwLock, Semaphore};

use crate::tool_errors::structure_tool_problem;
use crate::transport::{JsonRpcError, JsonRpcRequest, JsonRpcResponse};

use super::{
Expand Down Expand Up @@ -787,12 +786,9 @@ where
}
let started =
(self.timings_enabled || self.context.timings_enabled()).then(std::time::Instant::now);
let mut response = self
let response = self
.dispatch(context, "tools/call", McpDispatchParams::ToolsCall(request))
.await?;
if let Some(result) = response.result.as_mut() {
structure_tool_problem(result);
}
let mut result = rmcp_response_result::<CallToolResult>(response)?;
if let Some(started) = started {
result
Expand Down
41 changes: 15 additions & 26 deletions crates/tracedecay-mcp/src/tool_errors.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Semantic tool-failure classification and JSON-RPC error-response mapping.

use serde_json::{Value, json};
use tracedecay_contracts::ApplicationProblem;
use tracedecay_contracts::{ApplicationProblem, ApplicationProblemRecord};
use tracedecay_domain::errors::{
PROFILE_RESET_COMMAND, STALE_STORE_RESET_COMMAND, TraceDecayError,
};
Expand Down Expand Up @@ -119,32 +119,21 @@ pub fn semantic_failure_reason(result: &ToolResult) -> Option<String> {
.map(|text| text.trim_start().to_string())
}

/// Moves a tool result's typed `problem` into its MCP structured content.
/// The MCP `structuredContent` member of a tool result that refuses with
/// `problem`.
///
/// `CallToolResult` has no extension members, so a problem left beside
/// `content` reaches MCP clients only as prose. Every route that answers
/// `tools/call` to a host renders the refusal record here.
pub fn structure_tool_problem(result: &mut Value) {
let Some(object) = result.as_object_mut() else {
return;
};
let Some(problem) = object.remove("problem") else {
return;
};
match object
.get_mut("structuredContent")
.and_then(Value::as_object_mut)
{
Some(structured) => {
structured.insert("problem".to_owned(), problem);
}
None => {
object.insert(
"structuredContent".to_owned(),
json!({ "problem": problem }),
);
}
}
/// `CallToolResult` has no extension members, so structured content is the
/// only place a typed record reaches MCP clients. Every route, and the
/// `tracedecay tool --json` output, carries the refusal record there and
/// nowhere else; [`tool_result_problem`] is its one reader.
pub fn problem_structured_content(problem: &ApplicationProblemRecord) -> serde_json::Result<Value> {
Ok(json!({ "problem": serde_json::to_value(problem)? }))
}

/// The typed problem record a tool result refuses with, if any.
#[must_use]
pub fn tool_result_problem(result: &Value) -> Option<&Value> {
result.get("structuredContent")?.get("problem")
}

pub fn mark_semantic_tool_error(result: &mut ToolResult) {
Expand Down
78 changes: 46 additions & 32 deletions crates/tracedecay/src/daemon/core_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub(crate) use tracedecay_daemon_protocol::connection::{
pub use tracedecay_daemon_protocol::daemon_tool_response_bound;
use tracedecay_daemon_protocol::tool_request_deadline;
use tracedecay_mcp::server::attach_stateless_request_context;
use tracedecay_mcp::tool_errors::tool_result_problem;
use tracedecay_runtime_core::config::ProfileRoot;

use super::{
Expand Down Expand Up @@ -539,19 +540,14 @@ fn daemon_tool_call_error(error: JsonRpcError) -> TraceDecayError {
/// the publication-window mounting refusal.
///
/// A project-scoped owner that registers behind the core publication answers
/// `application.runtime.mounting` while it is still mounting. A project
/// connection renders that record under the tool result's `problem` member;
/// the projectless route, which `tracedecay serve` relays to hosts verbatim,
/// renders it as MCP `structuredContent.problem`. An admitted terminal, and
/// every other completed problem (a retained authority that is unavailable, a
/// saturated owner, an observed diagnostic), is the answer: its `after_delay`
/// directive is for the caller, not a transport loop.
/// `application.runtime.mounting` while it is still mounting, on every route.
/// An admitted terminal, and every other completed problem (a retained
/// authority that is unavailable, a saturated owner, an observed diagnostic),
/// is the answer: its `after_delay` directive is for the caller, not a
/// transport loop.
fn tool_result_retry_after_delay(result: &serde_json::Value) -> Option<Duration> {
let problem = result
.get("problem")
.or_else(|| result.pointer("/structuredContent/problem"))?;
let record: tracedecay_contracts::ApplicationProblemRecord =
serde_json::from_value(problem.clone()).ok()?;
serde_json::from_value(tool_result_problem(result)?.clone()).ok()?;
record.owner_mount_resend_delay()
}

Expand Down Expand Up @@ -822,8 +818,9 @@ mod tests {
ApplicationProblem, ApplicationProblemEnvelope, RequestId, ResultContractRef,
SafeDiagnostic,
};
use tracedecay_mcp::tool_errors::structure_tool_problem;
use tracedecay_tool_catalog::SchemaId;
use tracedecay_mcp::application_output::tool_result::ApplicationRefusal;
use tracedecay_mcp::tool_errors::mark_semantic_tool_error;
use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingId, SchemaId};

use super::super::{
JsonRpcError, PROJECT_SERVER_CAPACITY_REASON_CODE,
Expand Down Expand Up @@ -886,34 +883,51 @@ mod tests {
);
}

/// A problem tool result as the daemon's projectless `tools/call` route
/// answers it: the canonical record, moved into MCP structured content.
/// A problem tool result as the daemon's projectless retained route
/// renders it.
fn projectless_problem_result(problem: ApplicationProblem) -> serde_json::Value {
let envelope = ApplicationProblemEnvelope::new(
ResultContractRef::new(
SchemaId::new("schema.application.retained.message-search.result")
.expect("schema id"),
1,
let refusal = ApplicationRefusal {
operation: ApplicationSurfaceOperation::MessageSearch,
binding_id: BindingId::new("binding.mcp.message_search.v1").expect("binding id"),
problem: ApplicationProblemEnvelope::new(
ResultContractRef::new(
SchemaId::new("schema.application.retained.message-search.result")
.expect("schema id"),
1,
)
.expect("result contract"),
RequestId::new("request.mcp.projectless-mounting").expect("request id"),
problem,
)
.expect("result contract"),
RequestId::new("request.mcp.projectless-mounting").expect("request id"),
problem,
)
.expect("problem envelope");
let mut result = json!({
"content": [{ "type": "text", "text": "problem" }],
"isError": true,
"problem": serde_json::to_value(envelope.problem.as_ref()).expect("problem record"),
});
structure_tool_problem(&mut result);
result
.expect("problem envelope"),
};
let mut result = refusal
.render(None, &json!({ "format": "json" }))
.expect("rendered refusal");
mark_semantic_tool_error(&mut result);
result.value
}

#[test]
fn projectless_mounting_refusal_is_re_sent_after_its_delay() {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
let mounting = projectless_problem_result(ApplicationProblem::runtime_mounting());
assert_eq!(mounting.get("problem"), None);
assert_eq!(
(
&mounting["isError"],
&mounting["structuredContent"]["problem"]["diagnostic"]["code"],
&mounting["structuredContent"]["problem"]["retry"],
&mounting["structuredContent"]["problem"]["retry_after_millis"],
),
(
&json!(true),
&json!("application.runtime.mounting"),
&json!("after_delay"),
&json!(250),
),
"{mounting}"
);
assert_eq!(
super::project_open_retry_wait(&Ok(mounting), deadline),
Some(std::time::Duration::from_millis(250))
Expand Down
Loading
Loading