From b0753df61698d97332d635d1c92c08e78f762c7a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 17:04:37 +0000 Subject: [PATCH 1/5] test: resolve catalog bindings through the composed snapshot --- .../tracedecay-cli/src/tool_command/tests.rs | 35 ++++++++++++++---- .../native_integration_surface_mount.rs | 36 +++++++++++++------ .../v2_surface_mount_conformance.rs | 16 ++------- 3 files changed, 57 insertions(+), 30 deletions(-) diff --git a/crates/tracedecay-cli/src/tool_command/tests.rs b/crates/tracedecay-cli/src/tool_command/tests.rs index 6bcefc7fe8..eeb8b936ed 100644 --- a/crates/tracedecay-cli/src/tool_command/tests.rs +++ b/crates/tracedecay-cli/src/tool_command/tests.rs @@ -4,12 +4,17 @@ use tracedecay_contracts::{ ApplicationProblem, ApplicationProblemEnvelope, OpaqueCursor, PageRequest, RequestId, ResultContractRef, SafeDiagnostic, }; -use tracedecay_daemon_protocol::decode_retained_request; +use tracedecay_daemon_protocol::{ + BindingResolution, BindingResolver, CatalogBindingResolver, ResolvedBinding, + decode_retained_request, +}; use tracedecay_daemon_service::application_surface::{ - parse_http_application_surface_request, resolve_application_surface_dispatch_with_controls, - resolve_catalog_tool_binding, + application_surface_catalog_ref, parse_http_application_surface_request, + resolve_application_surface_dispatch_with_controls, +}; +use tracedecay_tool_catalog::{ + BindingId, BindingSurface, ProfileId, SchemaId, SurfaceOperationName, }; -use tracedecay_tool_catalog::{BindingId, BindingSurface, SchemaId}; fn defs() -> Vec { get_tool_definitions().expect("tool definitions") @@ -1368,6 +1373,25 @@ fn session_refresh_equivalent_requests() -> Vec { requests } +/// The binding the daemon's composed catalog resolves for `tool_name` on +/// `surface`, for the default profile at the production protocol revision. +fn catalog_binding(surface: BindingSurface, tool_name: &str) -> Option { + let operation = ApplicationSurfaceOperation::from_tool_name(tool_name) + .unwrap_or_else(|| panic!("{tool_name} names no application operation")); + CatalogBindingResolver::new(application_surface_catalog_ref().expect("application catalog")) + .resolve_binding( + surface, + &BindingResolution { + profile_id: ProfileId::new(tracedecay_contracts::APPLICATION_DEFAULT_PROFILE_ID) + .expect("default profile"), + operation: SurfaceOperationName::new(operation.name_for_surface(surface)) + .expect("operation name"), + protocol_revision: 1, + negotiated_features: std::collections::BTreeSet::new(), + }, + ) +} + /// The retained half of the transport equivalence: CLI and MCP normalize /// through the shared argument adapter and, like the HTTP body, land on /// `decode_request` for the exact operation. All three must decode the same @@ -1404,8 +1428,7 @@ fn assert_retained_transports_decode_one_canonical_request( let request = decode_retained_request(operation, body) .unwrap_or_else(|error| panic!("{tool_name} {surface:?} request: {error}")); assert_eq!(request.operation(), operation, "{tool_name} {surface:?}"); - let binding = resolve_catalog_tool_binding(surface, tool_name) - .unwrap_or_else(|error| panic!("{tool_name} {surface:?} binding: {error}")) + let binding = catalog_binding(surface, tool_name) .unwrap_or_else(|| panic!("{tool_name} has no {surface:?} catalog binding")); ( surface, diff --git a/crates/tracedecay/tests/product_surface_suite/native_integration_surface_mount.rs b/crates/tracedecay/tests/product_surface_suite/native_integration_surface_mount.rs index 6ae28296c1..8824ca3546 100644 --- a/crates/tracedecay/tests/product_surface_suite/native_integration_surface_mount.rs +++ b/crates/tracedecay/tests/product_surface_suite/native_integration_surface_mount.rs @@ -1,5 +1,8 @@ +use std::collections::BTreeSet; + use serde_json::json; use tracedecay_api::is_http_application_operation_exposed; +use tracedecay_contracts::APPLICATION_DEFAULT_PROFILE_ID; use tracedecay_contracts::{ NativeIntegrationSurfaceResultV1, NativeIntegrationSurfaceUnavailableV1, }; @@ -10,9 +13,12 @@ use tracedecay_contracts::{ use tracedecay_daemon_protocol::RequestedOutputFormat; use tracedecay_daemon_protocol::{ApplicationSurfaceRequest, parse_application_surface_request}; use tracedecay_daemon_service::application_surface::{ - resolve_application_surface_dispatch, resolve_catalog_tool_binding, + application_surface_catalog_ref, resolve_application_surface_dispatch, +}; +use tracedecay_tool_catalog::{ + ApplicationSurfaceOperation, BindingSurface, CatalogContributionV1, ProfileId, + SurfaceOperationName, }; -use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingSurface, CatalogContributionV1}; /// The transaction journey, restated here as the reverse authority. Deriving /// it from the module under test would let a dropped operation pass vacuously. @@ -132,17 +138,27 @@ fn only_the_status_read_carries_a_dashboard_binding() { "{name} dashboard exposure must match the read-only status contract" ); } - let resolved = resolve_catalog_tool_binding( - BindingSurface::Dashboard, - "tracedecay_native_integration_status", - ) - .expect("dashboard binding resolution"); assert!( - resolved.is_some(), + production_catalog_resolves(BindingSurface::Dashboard, "native_integration_status"), "the status dashboard binding is declared but the production resolver answers nothing" ); } +/// Whether the daemon's composed catalog resolves `operation` on `surface` +/// for the default profile at the production protocol revision. +fn production_catalog_resolves(surface: BindingSurface, operation: &str) -> bool { + application_surface_catalog_ref() + .expect("application catalog") + .resolve_binding( + &ProfileId::new(APPLICATION_DEFAULT_PROFILE_ID).expect("default profile"), + surface, + &SurfaceOperationName::new(operation).expect("operation name"), + 1, + &BTreeSet::new(), + ) + .is_some() +} + fn assert_cli_and_mcp_bindings(contribution: &CatalogContributionV1, name: &str) { for surface in [BindingSurface::Cli, BindingSurface::Mcp] { assert!( @@ -151,10 +167,8 @@ fn assert_cli_and_mcp_bindings(contribution: &CatalogContributionV1, name: &str) }), "{name} declares no {surface:?} binding" ); - let resolved = resolve_catalog_tool_binding(surface, &format!("tracedecay_{name}")) - .expect("binding resolution"); assert!( - resolved.is_some(), + production_catalog_resolves(surface, name), "{name} is declared for {surface:?} but the production resolver answers nothing" ); } diff --git a/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs b/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs index 0b4697a8d4..db8b62d350 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs @@ -55,7 +55,6 @@ use tracedecay_api::{ is_http_application_operation_exposed, }; use tracedecay_contracts::catalog_composition::build_application_catalog_snapshot; -use tracedecay_daemon_service::application_surface::resolve_catalog_tool_binding; use tracedecay_session_memory::event_lane::ActivityFamilyV1; use tracedecay_tool_catalog::{ ApplicationSurfaceOperation, BindingSurface, CapabilityManifestV1, CatalogSnapshotV1, @@ -465,10 +464,9 @@ const NEGOTIATED_PROTOCOL_REVISION: u32 = 1; /// Whether some sanctioned production negotiation resolves this spelling. /// -/// Default-profile surfaces are probed exactly as their adapters probe them. -/// A binding the default probe cannot see is then probed under each profile -/// that declares it with exactly its declared required features, the shape -/// of an initialize-time negotiation (today: the LSP context family). A +/// The binding is probed under each profile with exactly its declared +/// required features, the shape of an initialize-time negotiation (none for +/// most surfaces; today the LSP context family declares some). A /// catalog entry that no profile includes, whose features can never be /// negotiated, or whose revision range excludes the production protocol still /// resolves to `None`, which is exactly the "declared but not reachable" @@ -478,14 +476,6 @@ fn binding_resolves( surface: BindingSurface, operation: &str, ) -> bool { - if resolve_catalog_tool_binding(surface, operation) - .unwrap_or_else(|error| { - panic!("the application catalog could not resolve {operation} on {surface:?}: {error}") - }) - .is_some() - { - return true; - } let Ok(operation_name) = SurfaceOperationName::new(operation) else { return false; }; From c3fd2a1bb2ee96aa1885650fd051443e89dbc018 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 23:03:15 +0000 Subject: [PATCH 2/5] simplify(mcp): delete the tool compatibility dispatch layer --- crates/tracedecay-cli/src/commands.rs | 4 +- crates/tracedecay-cli/src/commands/daemon.rs | 48 ------- crates/tracedecay-cli/src/tool_command.rs | 135 +----------------- .../src/tool_command/application_family.rs | 26 +++- .../tracedecay-cli/src/tool_command/tests.rs | 33 ----- .../src/application_surface.rs | 4 +- .../src/application_surface/catalog.rs | 26 +--- .../src/mcp/tools/handlers/dispatch_tests.rs | 2 +- .../tracedecay/src/mcp/tools/handlers/mod.rs | 93 +----------- .../tools/handlers/tool_definition_tests.rs | 2 +- crates/tracedecay/src/mcp/tools/mod.rs | 35 ----- 11 files changed, 38 insertions(+), 370 deletions(-) diff --git a/crates/tracedecay-cli/src/commands.rs b/crates/tracedecay-cli/src/commands.rs index 81433e504b..9e91e55f0f 100644 --- a/crates/tracedecay-cli/src/commands.rs +++ b/crates/tracedecay-cli/src/commands.rs @@ -16,8 +16,8 @@ pub(crate) use bench::handle_bench; pub(crate) use branch::handle_branch_action; pub(crate) use daemon::{ admin_cli_result, admin_cli_result_mismatch, admin_cli_scope, client_handshake, - daemon_tool_json, daemon_tool_json_until, recover_truncated_mcp_result, - reject_truncation_envelope, retained_effect_payload, retained_tool_payload, + daemon_tool_json, daemon_tool_json_until, reject_truncation_envelope, retained_effect_payload, + retained_tool_payload, }; pub use gain::handle_gain; pub(crate) use index::{handle_init, handle_no_command, handle_sync}; diff --git a/crates/tracedecay-cli/src/commands/daemon.rs b/crates/tracedecay-cli/src/commands/daemon.rs index e35f2ac306..96443ae0bb 100644 --- a/crates/tracedecay-cli/src/commands/daemon.rs +++ b/crates/tracedecay-cli/src/commands/daemon.rs @@ -296,54 +296,6 @@ async fn recover_truncated_payload( serde_json::from_str(&content).map_err(Into::into) } -/// Recover a truncated MCP tool result while keeping the MCP envelope shape -/// `tracedecay tool` prints. Status unwraps to the inner JSON; this path must -/// leave `content[*].text` as the recovered payload so `--format json` and -/// `--json` callers still parse the tool schema rather than a handle envelope. -pub(crate) async fn recover_truncated_mcp_result( - handshake: &tracedecay_daemon_protocol::DaemonHandshake, - tool_name: &str, - result: serde_json::Value, - deadline: Option, -) -> tracedecay_domain::errors::Result { - let Ok(payload) = tracedecay::daemon::tool_json_payload(&result, tool_name) else { - return Ok(result); - }; - if !is_truncation_envelope(&payload) { - return Ok(result); - } - let recovered = - recover_truncated_payload(handshake, tool_name, result.clone(), deadline).await?; - let text = serde_json::to_string(&recovered)?; - let mut recovered_result = result; - let blocks = recovered_result - .get_mut("content") - .and_then(serde_json::Value::as_array_mut) - .ok_or_else(|| tracedecay_domain::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} returned no content blocks"), - })?; - let mut replaced = false; - for block in blocks { - let Some(block_text) = block.get("text").and_then(serde_json::Value::as_str) else { - continue; - }; - let Ok(block_payload) = serde_json::from_str::(block_text) else { - continue; - }; - if is_truncation_envelope(&block_payload) { - block["text"] = serde_json::Value::String(text.clone()); - replaced = true; - break; - } - } - if !replaced { - return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: format!("daemon tool {tool_name} omitted its truncation payload"), - }); - } - Ok(recovered_result) -} - pub(crate) fn is_truncation_envelope(value: &Value) -> bool { value.get("truncated").and_then(Value::as_bool) == Some(true) && value diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index 7d65020918..4c27f79b00 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -46,9 +46,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracedecay_runtime_core::config::ProfileRoot; use serde_json::Value; -use tokio::time::{Instant, timeout_at}; +use tokio::time::Instant; -use tracedecay::daemon::call_default_tool_awaiting_project_open; use tracedecay_contracts::code_index_freshness::{ CODE_INDEX_READINESS_WAIT_TIMED_OUT, CODE_INDEX_READINESS_WAIT_UNAVAILABLE, CodeIndexReadinessWaitOutcomeV1, @@ -76,7 +75,6 @@ use tracedecay_mcp::{ use tracedecay_tool_catalog::ApplicationSurfaceOperation; use crate::cli::dispatch::resolve_cli_application_surface; -use crate::commands::{recover_truncated_mcp_result, reject_truncation_envelope}; mod application_family; mod args; @@ -133,30 +131,6 @@ pub(crate) fn tool_command_deadline() -> Result { tool_request_deadline() } -fn tool_timeout_error(tool_name: &str) -> TraceDecayError { - TraceDecayError::Config { - message: format!( - "tool request timed out before deadline: {tool_name}; request outcome may be unknown" - ), - } -} - -fn reject_tool_result_truncation(result_value: &Value, tool_name: &str) -> Result<()> { - reject_truncation_envelope(result_value, tool_name)?; - let Some(blocks) = result_value.get("content").and_then(Value::as_array) else { - return Ok(()); - }; - for block in blocks { - let Some(text) = block.get("text").and_then(Value::as_str) else { - continue; - }; - if let Ok(payload) = serde_json::from_str::(text) { - reject_truncation_envelope(&payload, tool_name)?; - } - } - Ok(()) -} - /// Entry point for `tracedecay tool ...`. #[hotpath::measure(label = "cli.tool.dispatch", future = true)] pub(crate) async fn run( @@ -421,16 +395,9 @@ fn run_inner( ) .await; } - // Finding `def` in the host-filtered MCP definitions is the retained - // compatibility owner's admission authority. This point is reachable - // only after every typed branch above rejected the name, so composing - // the application catalog again can only return `None`; rebuilding a - // second advertised-name set likewise repeats the exact membership - // check that selected `def`. - let dispatch = - DaemonToolDispatch::for_tool(profile, explicit_project, &def.name, &mut tool_args); - dispatch_compatibility_tool(profile, dispatch, &def.name, tool_args, raw_json, deadline) - .await + Err(TraceDecayError::Config { + message: format!("{} has no typed CLI route", def.name), + }) }) } @@ -1090,26 +1057,6 @@ impl DaemonToolDispatch { self.allow_init, ) } - - /// `deadline` is the caller's request deadline. It is sent to the daemon and - /// enforced there; the transport reads for a bounded grace beyond it. - #[hotpath::skip] - async fn call( - &self, - profile: &ProfileRoot, - tool_name: &str, - tool_args: Value, - deadline: Instant, - ) -> Result { - let handshake = self.handshake(profile)?; - // The interactive CLI wants the tool's answer, not the daemon's typed - // warming state: ride out a cold project open until the CLI deadline, - // the same transport behavior as the typed application-surface path. - let result = - call_default_tool_awaiting_project_open(&handshake, tool_name, tool_args, deadline) - .await?; - recover_truncated_mcp_result(&handshake, tool_name, result, Some(deadline)).await - } } /// Whether a retained call addresses the authenticated profile's own stores. @@ -1143,80 +1090,6 @@ fn seed_registry_context_path(tool_args: &mut Value, explicit_project: &Path) { } } -fn map_tool_deadline_error(tool_name: &str, error: TraceDecayError) -> TraceDecayError { - if tracedecay::daemon::error_is_read_deadline(&error) { - tool_timeout_error(tool_name) - } else { - error - } -} - -/// Compatibility owner for advertised tools that do not yet have a typed -/// `ApplicationSurfaceRequest`. -/// -/// Owner: root MCP tool-dispatch migration. The operation has already passed -/// definition admission and, when declared, catalog binding resolution. -#[hotpath::measure(label = "cli.tool.compatibility", future = true)] -async fn dispatch_compatibility_tool( - profile: &ProfileRoot, - dispatch: DaemonToolDispatch, - tool_name: &str, - tool_args: Value, - raw_json: bool, - deadline: Instant, -) -> Result<()> { - #[cfg(feature = "hotpath")] - hotpath::val!("cli.compatibility_tool.name").set(&tool_name); - // `deadline` is the caller's *request* deadline: it now travels to the - // daemon, which enforces it. The local wait exists only to bound a dead or - // wedged daemon, so it runs on the transport's response bound, that same - // deadline plus a bounded grace. Waiting strictly to the request deadline - // made every deadline-elapsed typed terminal unobservable through this - // transport: the daemon's PartialEffect (committed receipt, Reconcile-only - // legal action) or typed timeout envelope arrived moments after the local - // abort had already printed "outcome may be unknown", untruthful, since - // the outcome was in flight. Never discard an envelope that was received. - let response_bound = tracedecay::daemon::daemon_tool_response_bound(deadline)?; - let json_output = raw_json || tool_args.get("format").and_then(Value::as_str) == Some("json"); - let result_value = match timeout_at( - response_bound, - dispatch.call(profile, tool_name, tool_args, deadline), - ) - .await - { - Ok(Ok(value)) => value, - Ok(Err(error)) => { - let error = map_tool_deadline_error(tool_name, error); - if json_output { - print_project_route_problem(tool_name, &error)?; - } - return Err(error); - } - Err(_) => return Err(tool_timeout_error(tool_name)), - }; - reject_tool_result_truncation(&result_value, tool_name)?; - print_tool_output(&result_value, raw_json); - // The payload above is the tool's answer and callers parse it, so it is - // printed byte-for-byte either way; only the process status changes here. - // A tool result the daemon classified as an application failure must not - // exit 0, that made every script and CI gate shelling out to - // `tracedecay tool` silently blind to a failing tool. - tool_result_process_outcome(&result_value, tool_name) -} - -/// A JSON request answered by a typed daemon refusal still gets a JSON -/// document on stdout: `{"problem": …}`, the same problem the MCP error -/// carries. The error itself goes to stderr and sets the exit status. -fn print_project_route_problem(tool_name: &str, error: &TraceDecayError) -> Result<()> { - let Some(problem) = tracedecay_mcp::tool_errors::project_route_problem(tool_name, error) else { - return Ok(()); - }; - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "{}", serde_json::json!({ "problem": problem }))?; - stdout.flush()?; - Ok(()) -} - /// The process outcome for a completed MCP tool result: `Ok` (exit 0) for a /// successful call, `Err` (nonzero exit) for one the daemon classified as an /// application failure. diff --git a/crates/tracedecay-cli/src/tool_command/application_family.rs b/crates/tracedecay-cli/src/tool_command/application_family.rs index 9b5c942aab..2a875aabb7 100644 --- a/crates/tracedecay-cli/src/tool_command/application_family.rs +++ b/crates/tracedecay-cli/src/tool_command/application_family.rs @@ -1,4 +1,4 @@ -//! `tracedecay tool` for the closed Work and Workflow families. +//! `tracedecay tool` for the closed Work, Workflow, and multi-root families. //! //! These tools run through the canonical owner their MCP calls reach, invoked //! over the daemon socket instead of through a daemon MCP tool call, so the CLI @@ -27,7 +27,10 @@ use tracedecay_daemon_protocol::{ }; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_domain::{ManifestDigest, UtcMicros}; -use tracedecay_mcp::tools::binding::{work_operation_for_tool, workflow_operation_for_tool}; +use tracedecay_mcp::tools::binding::{ + McpToolDispatchGroup, dispatch_group_for_tool, work_operation_for_tool, + workflow_operation_for_tool, +}; use super::{ OWNER_MOUNT_RESEND_DELAY, cli_request_controls, rendered_tool_output, @@ -39,6 +42,7 @@ use crate::work_cli::{WorkCliDelivery, work_delivery_is_eligible}; pub(super) enum FamilyTool { Work(WorkOperation), Workflow, + MultiRoot, } impl FamilyTool { @@ -46,10 +50,15 @@ impl FamilyTool { work_operation_for_tool(tool_name) .map(Self::Work) .or_else(|| workflow_operation_for_tool(tool_name).map(|_| Self::Workflow)) + .or_else(|| { + (dispatch_group_for_tool(tool_name) == Some(McpToolDispatchGroup::MultiRoot)) + .then_some(Self::MultiRoot) + }) } } -/// Run one Work or Workflow tool and print the tool result its MCP call returns. +/// Run one Work, Workflow, or multi-root tool and print the tool result its +/// MCP call returns. #[hotpath::measure(label = "cli.tool.application_family", future = true)] pub(super) async fn dispatch_cli_family_tool( profile: &ProfileRoot, @@ -100,6 +109,17 @@ pub(super) async fn dispatch_cli_family_tool( ) .await? } + FamilyTool::MultiRoot => { + tracedecay_mcp::handle_multi_root( + tool_name, + tool_args.clone(), + Some(&executor), + Some(request_id.clone()), + Some(request_deadline), + Some(cancellation), + ) + .await? + } }; if !executor.take_mounting() || deadline.saturating_duration_since(Instant::now()) <= OWNER_MOUNT_RESEND_DELAY diff --git a/crates/tracedecay-cli/src/tool_command/tests.rs b/crates/tracedecay-cli/src/tool_command/tests.rs index eeb8b936ed..8f1ee363df 100644 --- a/crates/tracedecay-cli/src/tool_command/tests.rs +++ b/crates/tracedecay-cli/src/tool_command/tests.rs @@ -982,39 +982,6 @@ fn join_content_text_empty_when_no_content() { assert_eq!(join_content_text(&json!({ "content": [] })), ""); } -#[test] -fn reject_tool_result_truncation_detects_content_envelope() { - let value = json!({ - "content": [{ - "type": "text", - "text": "{\"truncated\":true,\"original_chars\":16000,\"preview\":\"{}\",\"handle\":\"h1\"}" - }] - }); - let err = reject_tool_result_truncation(&value, "tracedecay_search").unwrap_err(); - let message = err.to_string(); - assert!(message.contains("truncated JSON"), "{message}"); - assert!(message.contains("tracedecay_retrieve"), "{message}"); - assert!( - reject_tool_result_truncation( - &json!({ "content": [{ "type": "text", "text": "{\"ok\":true}" }] }), - "tracedecay_search" - ) - .is_ok() - ); - assert!( - reject_tool_result_truncation( - &json!({ - "content": [{ - "type": "text", - "text": "{\"truncated\":true,\"matches\":[]}" - }] - }), - "tracedecay_grep" - ) - .is_ok() - ); -} - /// `main` maps `Ok` to exit 0 and any `Err` to a failing `ExitCode`, so the /// outcome these assertions inspect *is* the process exit status. #[test] diff --git a/crates/tracedecay-daemon-service/src/application_surface.rs b/crates/tracedecay-daemon-service/src/application_surface.rs index 4bc3d08e4d..6716f3b5c1 100644 --- a/crates/tracedecay-daemon-service/src/application_surface.rs +++ b/crates/tracedecay-daemon-service/src/application_surface.rs @@ -55,9 +55,7 @@ mod work; mod workflow; use catalog::resolve_application_binding; -pub use catalog::{ - application_surface_catalog, application_surface_catalog_ref, resolve_catalog_tool_binding, -}; +pub use catalog::{application_surface_catalog, application_surface_catalog_ref}; use configuration_wire::{ CONFIGURATION_WIRE_OPERATIONS, configuration_binding_has_schema, is_configuration_operation, }; diff --git a/crates/tracedecay-daemon-service/src/application_surface/catalog.rs b/crates/tracedecay-daemon-service/src/application_surface/catalog.rs index 31f1c7ffda..6a7fd128ad 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/catalog.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/catalog.rs @@ -50,17 +50,9 @@ pub(super) fn resolve_application_binding( resolver: &impl BindingResolver, surface: BindingSurface, operation: ApplicationSurfaceOperation, -) -> Option { - resolve_named_binding(resolver, surface, operation.name_for_surface(surface)) -} - -pub(super) fn resolve_named_binding( - resolver: &impl BindingResolver, - surface: BindingSurface, - operation: &str, ) -> Option { let profile_id = ProfileId::new(APPLICATION_DEFAULT_PROFILE_ID).ok()?; - let operation = SurfaceOperationName::new(operation).ok()?; + let operation = SurfaceOperationName::new(operation.name_for_surface(surface)).ok()?; resolver.resolve_binding( surface, &BindingResolution { @@ -72,22 +64,6 @@ pub(super) fn resolve_named_binding( ) } -/// Resolves a public tool name through the application catalog for one host surface. -/// -/// Typed application surfaces continue through [`ApplicationSurfaceOperation`]; -/// compatibility-owned tools use this boundary before entering their retained -/// execution adapter, so catalog metadata remains the single binding authority. -#[hotpath::measure(label = "application_surface.catalog_binding")] -pub fn resolve_catalog_tool_binding( - surface: BindingSurface, - tool_name: &str, -) -> Result, ApplicationSurfaceAdapterError> { - let operation = tool_name.strip_prefix("tracedecay_").unwrap_or(tool_name); - let catalog = application_surface_catalog_ref()?; - let resolver = CatalogBindingResolver::new(catalog); - Ok(resolve_named_binding(&resolver, surface, operation)) -} - pub(super) fn application_negotiated_features() -> BTreeSet { BTreeSet::new() } diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs index be26b4f758..a796ecc642 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs @@ -6,9 +6,9 @@ use std::sync::Mutex; use serde_json::{Value, json}; use tempfile::TempDir; -use super::super::get_tool_definitions; use super::dispatch_test_support::*; use super::*; +use tracedecay_mcp::get_tool_definitions; /// Records the daemon operation every multi-root tool routes to, then refuses /// it. The refusal is the point: it proves the MCP name reached the closed diff --git a/crates/tracedecay/src/mcp/tools/handlers/mod.rs b/crates/tracedecay/src/mcp/tools/handlers/mod.rs index 19ee3cea65..fa95833907 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/mod.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/mod.rs @@ -163,14 +163,12 @@ pub(crate) use tool_call_support::resolve_registered_project_route_for_tool; use serde_json::Value; use tracedecay_contracts::retrieval::ServedCodeGraphGenerationV1; use tracedecay_contracts::{InvocationTarget, RetainedSurfaceOperation}; -use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingSurface}; +use tracedecay_tool_catalog::ApplicationSurfaceOperation; -use super::LegacyToolCompatibilityOwner; use dispatch_groups::dispatch_application_surface_tools; use tool_call_support::{boxed_send, rejected_tool_project_selector_present}; use tracedecay_api::{WorkHttpRequest, WorkflowHttpRequest}; use tracedecay_daemon_protocol::DaemonInvocationExecutor; -use tracedecay_daemon_service::application_surface::resolve_catalog_tool_binding; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::RegisteredGlobalDbLeaseV1; use tracedecay_mcp::ToolResult; @@ -178,10 +176,8 @@ use tracedecay_mcp::handlers::{SessionAuthorities, unknown_tool_error}; use tracedecay_mcp::tools::binding::{ INTERNAL_DAEMON_TOOL_NAMES, McpToolDispatchGroup, dispatch_group_for_tool, mcp_dispatch_contract, tool_accepts_registered_project_selector, - tool_dispatches_registered_project_reader, tool_requires_canonical_effect_settlement, + tool_dispatches_registered_project_reader, }; -use tracedecay_mcp::tools::dispatch_ceiling::{tool_dispatch_budget, tool_dispatch_deadline_error}; -use tracedecay_mcp::tools::response_trailers::append_code_graph_freshness; use tracedecay_mcp::{handle_multi_root, handle_work, handle_workflow}; use tracedecay_project::project::TraceDecay; use tracedecay_runtime_core::storage::registered_project_id; @@ -442,10 +438,6 @@ impl<'a> ToolCallRegistryOptions<'a> { } } -#[expect( - clippy::too_many_lines, - reason = "Tool-call handling is one registry dispatch match onto the owning handler." -)] pub fn handle_tool_call_with_registry_options<'a>( cg: &'a TraceDecay, tool_name: &'a str, @@ -556,84 +548,9 @@ pub fn handle_tool_call_with_registry_options<'a>( )) .await; } - // Catalog-declared compatibility operations must resolve the MCP binding - // before reaching their retained typed handler. Operations without an - // application-catalog contract remain under the explicit root MCP - // migration owner until their family receives one. - if let Err(error) = resolve_catalog_tool_binding(BindingSurface::Mcp, tool_name) { - return Err(TraceDecayError::Config { - message: error.to_string(), - }); - } - let compatibility_owned = - LegacyToolCompatibilityOwner::admits(tool_name).map_err(|error| { - TraceDecayError::project_route( - "mcp.catalog_discovery_unavailable", - false, - format!("MCP tool discovery is unavailable: {error}"), - ) - })?; - if !compatibility_owned && !INTERNAL_DAEMON_TOOL_NAMES.contains(&tool_name) { - return Err(unknown_tool_error(tool_name)); - } - ensure_mcp_dispatch_available(tool_name)?; - // The universal ceiling. Every dispatch group below runs inside this one - // bound, so a group added later inherits it without opting in and no - // handler can be reached unbounded. Per-group wraps (git, memory) stay: - // they report a nicer domain-shaped result and a shorter bound, and this - // is only the backstop beneath them. - let dispatch_budget = - tool_dispatch_budget(tool_name, options.application_deadline.as_ref()); - let Some(dispatch_budget) = dispatch_budget else { - // `deadline_remaining` yields `None` only for an already-elapsed - // carried deadline, which must be rejected rather than dispatched. - return Err(tool_dispatch_deadline_error( - tool_name, - std::time::Duration::ZERO, - )); - }; - let served_code_graph = options.served_code_graph.clone(); - let dispatched = async { - match dispatch_group { - // Typed daemon surface tools already returned above, and the daemon - // serves the internal branch-add tool before MCP dispatch; reaching - // here means the name resolves to no reachable dispatch entry. - Some( - McpToolDispatchGroup::ApplicationSurface - | McpToolDispatchGroup::Git - | McpToolDispatchGroup::MultiRoot - | McpToolDispatchGroup::Work - | McpToolDispatchGroup::Workflow, - ) - | None => Err(unknown_tool_error(tool_name)), - } - }; - let result = if tool_requires_canonical_effect_settlement(tool_name) { - // Canonically settled effects complete their own deadline and - // cancellation protocol before this adapter receives a terminal. - // Dropping that terminal in the generic transport timeout would - // erase an admitted Effect or PartialEffect receipt. - dispatched.await - } else { - match tokio::time::timeout(dispatch_budget, dispatched).await { - Ok(result) => result, - Err(_elapsed) => Err(tool_dispatch_deadline_error(tool_name, dispatch_budget)), - } - }; - match result { - Ok(mut result) => { - // The verified-graph open funnel reports a stale serving seat - // through the one-shot options slot. The answer is sound for - // that generation but may trail the live worktree, so name - // whether source movement proved a rebuild or source currency - // remains unverified. - if let Some(served) = served_code_graph.served() { - append_code_graph_freshness(&mut result, &served); - } - Ok(result) - } - Err(error) => Err(error), - } + // The daemon serves its internal branch-add tool before MCP dispatch; + // every other name has returned through its typed owner above. + Err(unknown_tool_error(tool_name)) }; Box::pin(hotpath::future!(dispatch, label = "mcp.tool_call")) } diff --git a/crates/tracedecay/src/mcp/tools/handlers/tool_definition_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/tool_definition_tests.rs index 2d42f73db0..df07642cd8 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/tool_definition_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/tool_definition_tests.rs @@ -1,8 +1,8 @@ use serde_json::json; use tracedecay_tool_catalog::OperationId; -use super::super::get_tool_definitions; use super::*; +use tracedecay_mcp::get_tool_definitions; #[test] fn retired_simplify_scan_is_absent_from_the_public_catalog() { diff --git a/crates/tracedecay/src/mcp/tools/mod.rs b/crates/tracedecay/src/mcp/tools/mod.rs index b7a6d250b1..38532d722a 100644 --- a/crates/tracedecay/src/mcp/tools/mod.rs +++ b/crates/tracedecay/src/mcp/tools/mod.rs @@ -6,12 +6,6 @@ pub(crate) mod handlers; -use std::collections::HashSet; -use std::sync::LazyLock; - -use tracedecay_mcp::get_tool_definitions; -use tracedecay_mcp::tools::dispatch::McpDispatchMetadataError; - pub use handlers::{ GraphToolOutcome, RetainedSurfaceExecution, ToolCallRegistryOptions, execute_graph_tool_surface, execute_retained_surface_tool, execute_work_tool_surface, @@ -20,32 +14,3 @@ pub use handlers::{ run_retained_surface_tool, }; pub(crate) use handlers::{compute_graph_tool_for_owner, graph_tool_error_problem}; - -/// Explicit owner for advertised tools awaiting typed application contracts. -/// -/// These tools retain their existing root handlers, but they are no longer an -/// unclassified dispatch fallback: definition admission is mandatory, and any -/// application-catalog binding is resolved before this owner is entered. -pub struct LegacyToolCompatibilityOwner; - -impl LegacyToolCompatibilityOwner { - pub fn admits(tool_name: &str) -> std::result::Result { - // Every dispatched compatibility tool call asks this, and rebuilding - // the full schema catalog per call was the dominant per-dispatch cost. - // The advertised name set is process-stable: the definitions are - // static and the only host gate (`ast_grep_available`) is resolved - // once per process, so membership is answered from a cached set. - static ADVERTISED_TOOL_NAMES: LazyLock, String>> = - LazyLock::new(|| { - Ok(get_tool_definitions() - .map_err(|error| error.to_string())? - .into_iter() - .map(|definition| definition.name) - .collect()) - }); - match &*ADVERTISED_TOOL_NAMES { - Ok(names) => Ok(names.contains(tool_name)), - Err(error) => Err(McpDispatchMetadataError::Initialization(error.clone())), - } - } -} From 493fb0db2080c0b8aea09401480ff0d6f910dcfd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 23:35:59 +0000 Subject: [PATCH 3/5] simplify(cli): route tool calls to the client's daemon socket --- crates/tracedecay-cli/src/tool_command.rs | 12 +- .../src/tool_command/application_family.rs | 2 +- .../tests/core_cli_suite/tool_daemon_test.rs | 234 ++++-------------- crates/tracedecay/src/daemon/core_client.rs | 15 ++ .../v2_surface_mount_conformance.rs | 17 ++ 5 files changed, 86 insertions(+), 194 deletions(-) diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index 4c27f79b00..b6d649e38c 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -504,7 +504,7 @@ fn dispatch_cli_application_surface_inner( if let Ok(handshake) = crate::commands::client_handshake(profile, project.as_deref()) && let Ok(client) = - tracedecay_daemon_identity::invocation_client_for_current(handshake) + tracedecay::daemon::invocation_client_for_current_client(handshake) { observe_surface_argument_rejection( Some(&client), @@ -527,7 +527,7 @@ fn dispatch_cli_application_surface_inner( false, false, )?; - let client = tracedecay_daemon_identity::invocation_client_for_current(handshake)?; + let client = tracedecay::daemon::invocation_client_for_current_client(handshake)?; // A cold daemon answers the mounting refusal while the project open // still warms in the background. The compatibility tool path rides // that state out through its project-open retry loop; the typed @@ -640,7 +640,7 @@ async fn dispatch_cli_retained( message: "could not allocate an application surface request id".to_owned(), })?; let client = - tracedecay_daemon_identity::invocation_client_for_current(dispatch.handshake(profile)?)?; + tracedecay::daemon::invocation_client_for_current_client(dispatch.handshake(profile)?)?; // The mounting refusal precedes admission; re-send it until the deadline. let execution = loop { let (request_deadline, cancellation) = cli_request_controls(&request_id, deadline)?; @@ -701,7 +701,7 @@ async fn dispatch_cli_source_edit( false, false, )?; - let client = tracedecay_daemon_identity::invocation_client_for_current(handshake)?; + let client = tracedecay::daemon::invocation_client_for_current_client(handshake)?; // A cold daemon refuses with the mounting problem while the project open // warms; that refusal precedes admission, so it is re-sent until the CLI // deadline like every other surface. @@ -805,7 +805,7 @@ async fn invoke_cli_graph_tool( mint_global_request_id(GlobalRequestSurface::Cli).map_err(|_| TraceDecayError::Config { message: "could not allocate an application surface request id".to_owned(), })?; - let client = tracedecay_daemon_identity::invocation_client_for_current(handshake)?; + let client = tracedecay::daemon::invocation_client_for_current_client(handshake)?; // A cold daemon refuses with the mounting problem while the project open // warms; that refusal precedes admission, so it is re-sent until the CLI // deadline like every other surface. @@ -867,7 +867,7 @@ async fn dispatch_cli_profile_registry( message: "could not allocate an application surface request id".to_owned(), })?; let client = - tracedecay_daemon_identity::invocation_client_for_current(dispatch.handshake(profile)?)?; + tracedecay::daemon::invocation_client_for_current_client(dispatch.handshake(profile)?)?; let (request_deadline, cancellation) = cli_request_controls(&request_id, deadline)?; let outcome = tracedecay::mcp::tools::execute_graph_tool_surface( tracedecay_tool_catalog::BindingSurface::Cli, diff --git a/crates/tracedecay-cli/src/tool_command/application_family.rs b/crates/tracedecay-cli/src/tool_command/application_family.rs index 2a875aabb7..011f33b6be 100644 --- a/crates/tracedecay-cli/src/tool_command/application_family.rs +++ b/crates/tracedecay-cli/src/tool_command/application_family.rs @@ -76,7 +76,7 @@ pub(super) async fn dispatch_cli_family_tool( let handshake = tracedecay::daemon::handshake_for_current_client(profile, project, None, false, false)?; let executor = FamilyToolExecutor { - client: tracedecay_daemon_identity::invocation_client_for_current(handshake)?, + client: tracedecay::daemon::invocation_client_for_current_client(handshake)?, tool, delivery: Mutex::new(None), mounting: Mutex::new(false), diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index eae73e7a1e..358fcd4ef5 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -442,10 +442,10 @@ fn daemon_first_init_enrolls_a_clean_profile_from_a_linked_worktree() { ); } -/// Arguments for a tool the CLI still sends to the daemon as an MCP -/// `tools/call`. The scope set is absent, so the admitted project's -/// multi-root owner answers its concealed not-found problem. -const COMPAT_TOOL_PROBE: [&str; 4] = [ +/// Arguments for a daemon-owned multi-root read. The scope set is absent, so +/// the admitted project's multi-root owner answers its concealed not-found +/// problem. +const SCOPE_SET_PROBE: [&str; 4] = [ "multi_root_scope_set_read", "--scope-set-id", "scope-set.absent", @@ -453,8 +453,8 @@ const COMPAT_TOOL_PROBE: [&str; 4] = [ ]; /// Asserts `output` is the multi-root owner's concealed not-found answer to -/// [`COMPAT_TOOL_PROBE`], which only an admitted project server gives. -fn assert_compat_probe_answered(output: &Output, context: &str) { +/// [`SCOPE_SET_PROBE`], which only an admitted project server gives. +fn assert_scope_set_probe_answered(output: &Output, context: &str) { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let result: Value = serde_json::from_str(stdout.trim()) @@ -473,7 +473,8 @@ fn assert_compat_probe_answered(output: &Output, context: &str) { ); } -/// Sends one MCP tool call through the daemon's project server, then reads +/// Sends one MCP `tools/call` (`tracedecay status`) through the daemon's +/// project server, then reads /// that server's tool-call counter from its typed status. Until the full /// project server takes over from the core, the call and the status can land /// on different servers, so the pair is re-sent until the status server has @@ -484,11 +485,15 @@ fn wait_for_tool_status_server_tool_calls(home: &Path, project: &Path) -> u64 { loop { let counted = tracedecay_command_with_home(home) .current_dir(project) - .args(["tool", "--project", &project_arg]) - .args(COMPAT_TOOL_PROBE) + .args(["status", "--json"]) .output() - .expect("tracedecay tool multi_root_scope_set_read should run"); - assert_compat_probe_answered(&counted, "the probe must reach the project server"); + .expect("tracedecay status should run"); + assert!( + counted.status.success(), + "the status call must reach the project server\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&counted.stdout), + String::from_utf8_lossy(&counted.stderr) + ); let output = tracedecay_command_with_home(home) .current_dir(project) .args([ @@ -590,119 +595,6 @@ fn wait_for_daemon_socket(socket_path: &Path) { ); } -fn spawn_sentinel_daemon_with_notification( - socket_path: PathBuf, - expected_tool_name: &'static str, - expect_project_path: bool, - expect_allow_init: bool, - sentinel: &'static str, - emit_notification: bool, -) -> mpsc::Receiver { - let (ready_tx, ready_rx) = mpsc::channel(); - let (request_tx, request_rx) = mpsc::channel(); - - std::thread::spawn(move || { - let _ = std::fs::remove_file(&socket_path); - let authority = seed_fake_daemon_authority(&socket_path); - let listener = UnixListener::bind(&socket_path).expect("bind fake daemon socket"); - listener - .set_nonblocking(true) - .expect("set listener nonblocking"); - ready_tx.send(()).expect("notify fake daemon readiness"); - - let deadline = Instant::now() + CLI_ROUNDTRIP_TIMEOUT; - let (stream, _) = common::poll_until( - deadline, - Duration::from_millis(10), - || match listener.accept() { - Ok(accepted) => Some(accepted), - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => None, - Err(e) => panic!("accept fake daemon client: {e}"), - }, - || "timed out waiting for tool CLI to connect to fake daemon".to_string(), - ); - stream - .set_nonblocking(false) - .expect("set accepted stream blocking"); - stream - .set_write_timeout(Some(CLI_ROUNDTRIP_TIMEOUT)) - .expect("write timeout"); - - let mut reader = BufReader::new(stream.try_clone().expect("clone fake daemon stream")); - let mut preface = String::new(); - reader.read_line(&mut preface).expect("read auth preface"); - assert!( - DaemonAuthPreface::from_line(preface.trim()) - .expect("fake daemon auth preface") - .authenticate(authority.auth_token()), - "the CLI must present the daemon token" - ); - let mut handshake = String::new(); - reader - .read_line(&mut handshake) - .expect("read daemon handshake"); - let handshake: Value = serde_json::from_str(handshake.trim()).expect("handshake JSON"); - assert_eq!(handshake["project_path"].is_string(), expect_project_path); - assert_eq!( - handshake - .get("allow_init") - .and_then(Value::as_bool) - .unwrap_or(false), - expect_allow_init - ); - - let mut request = String::new(); - reader - .read_line(&mut request) - .expect("read JSON-RPC request"); - let request: Value = serde_json::from_str(request.trim()).expect("request JSON"); - assert_eq!(request["method"], "tools/call"); - assert_eq!(request["params"]["name"], expected_tool_name); - // The one-shot call has no `initialize` session; the daemon serves it - // over rmcp only because it carries SEP-2575 per-request context. - let meta = &request["params"]["_meta"]; - assert!( - meta["io.modelcontextprotocol/protocolVersion"].is_string() - && meta["io.modelcontextprotocol/clientCapabilities"].is_object(), - "one-shot tools/call omitted its per-request MCP context: {request}" - ); - request_tx - .send(request.clone()) - .expect("send observed JSON-RPC request"); - - let response = json!({ - "jsonrpc": "2.0", - "id": request["id"].clone(), - "result": { - "content": [{ - "type": "text", - "text": sentinel - }] - } - }); - let mut writer = stream; - if emit_notification { - let notification = json!({ - "jsonrpc": "2.0", - "method": "notifications/message", - "params": { - "level": "warning", - "data": "daemon notice before response" - } - }); - writeln!(writer, "{}", serde_json::to_string(¬ification).unwrap()) - .expect("write fake daemon notification"); - } - writeln!(writer, "{}", serde_json::to_string(&response).unwrap()) - .expect("write fake daemon response"); - }); - - ready_rx - .recv_timeout(LOCAL_READY_TIMEOUT) - .expect("fake daemon should become ready"); - request_rx -} - /// Enroll a project for the native hook capture contract: an enrollment /// marker binds the project root to a profile shard, and a daemon-issued /// hook configuration binding is published under that shard's data root so @@ -1200,50 +1092,6 @@ fn daemon_socket_is_owner_only() { ); } -#[test] -fn tool_cli_skips_daemon_notifications_until_matching_response() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - let socket_dir = TempDir::new().unwrap(); - let home_path = canonical_existing_path(home.path()); - let project_path = canonical_existing_path(project.path()); - init_project_with_cli(&home_path, &project_path); - - let sentinel = "daemon response after notification"; - let socket_path = socket_dir.path().join("tracedecay.sock"); - let observed_request = spawn_sentinel_daemon_with_notification( - socket_path.clone(), - "tracedecay_multi_root_scope_set_read", - true, - false, - sentinel, - true, - ); - let project_arg = project_path.to_string_lossy().to_string(); - let output = tracedecay_command_with_home(&home_path) - .current_dir(&project_path) - .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) - .args(["tool", "--project", &project_arg]) - .args(COMPAT_TOOL_PROBE) - .output() - .expect("tracedecay tool should run"); - - assert!( - output.status.success(), - "tool CLI should skip daemon notifications before the response\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(sentinel), - "tool CLI should print daemon response after notification, got:\n{stdout}" - ); - observed_request - .recv_timeout(CLI_ROUNDTRIP_TIMEOUT) - .expect("fake daemon should receive tools/call request"); -} - /// A retained store tool travels to the profile's daemon as one typed /// invocation: the explicit `--project` rides the handshake with first-touch /// init allowed, and the decoded request carries the caller's exact fields. @@ -1692,11 +1540,11 @@ fn daemon_project_handshake_uses_client_profile_identity() { common::daemon_socket_path(&daemon_home_path), ) .args(["tool", "--project", &project_arg]) - .args(COMPAT_TOOL_PROBE) + .args(SCOPE_SET_PROBE) .output() .expect("tracedecay tool multi_root_scope_set_read should run"); - assert_compat_probe_answered( + assert_scope_set_probe_answered( &output, "daemon should open the client's profile-sharded project", ); @@ -1878,10 +1726,10 @@ fn daemon_project_cache_is_scoped_by_client_identity() { .current_dir(&project_path) .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) .args(["tool", "--project", &project_arg]) - .args(COMPAT_TOOL_PROBE) + .args(SCOPE_SET_PROBE) .output() .expect("client A tool multi_root_scope_set_read should run"); - assert_compat_probe_answered( + assert_scope_set_probe_answered( &client_a_output, "client A should open its initialized project through the shared daemon", ); @@ -1890,7 +1738,7 @@ fn daemon_project_cache_is_scoped_by_client_identity() { .current_dir(&project_path) .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) .args(["tool", "--project", &project_arg]) - .args(COMPAT_TOOL_PROBE) + .args(SCOPE_SET_PROBE) .output() .expect("client B tool multi_root_scope_set_read should run"); assert!( @@ -1925,7 +1773,7 @@ fn tool_cli_without_daemon_socket_reports_daemon_unavailable() { .current_dir(&project_path) .env("TRACEDECAY_DAEMON_SOCKET", &missing_socket) .args(["tool", "--project", &project_arg]) - .args(COMPAT_TOOL_PROBE) + .args(SCOPE_SET_PROBE) .output() .expect("tracedecay tool should run"); @@ -2688,8 +2536,7 @@ fn daemon_status_headline_is_the_daemon_when_the_service_manager_is_unreachable( /// A JSON request the daemon refuses because no project is in reach still /// prints the typed problem on stdout, and the process exits non-zero, on -/// the owner-answered route (`search`) and the compatibility route -/// (`multi_root_scope_set_read`) alike. +/// the owner-answered `search` and the daemon-owned multi-root read alike. #[test] fn projectless_json_tool_call_prints_the_typed_refusal() { let home = TempDir::new().unwrap(); @@ -2739,22 +2586,35 @@ fn projectless_json_tool_call_prints_the_typed_refusal() { inside an initialized project or pass --project " ); - let compat = run(&[ + let multi_root = run(&[ "multi_root_scope_set_read", "--scope-set-id", "scope-set.absent", "--json", ]); + let payload: Value = serde_json::from_str( + multi_root["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("multi-root refusal carries no text: {multi_root}")), + ) + .expect("multi-root refusal payload"); + let problem = &payload["application"]["problem"]; assert_eq!( - compat, - json!({"problem": { - "tool": "tracedecay_multi_root_scope_set_read", - "kind": "invalid_request", - "code": "project_required", - "reason_code": "project_required", - "retryable": false, - "detail": "tracedecay_multi_root_scope_set_read requires an initialized code \ - project; run it inside an initialized project or pass --project ", - }}) + ( + &multi_root["isError"], + &problem["kind"], + &problem["code"], + &problem["message"], + ), + ( + &json!(true), + &json!("invalid_request"), + &json!("project_required"), + &json!( + "this operation needs a TraceDecay project, and the request named none; run \ + it inside an initialized project or pass --project " + ), + ), + "{multi_root}" ); } diff --git a/crates/tracedecay/src/daemon/core_client.rs b/crates/tracedecay/src/daemon/core_client.rs index 32a124775d..6607624040 100644 --- a/crates/tracedecay/src/daemon/core_client.rs +++ b/crates/tracedecay/src/daemon/core_client.rs @@ -150,6 +150,21 @@ pub(crate) fn default_available_socket_path(handshake: &DaemonHandshake) -> Resu } } +/// Authenticated invocation client for the daemon serving this client: the +/// socket `TRACEDECAY_DAEMON_SOCKET` names when set, else the profile's own. +/// An absent socket is the typed daemon-unreachable refusal, as on the +/// `tools/call` transport. +pub fn invocation_client_for_current_client( + handshake: DaemonHandshake, +) -> Result { + let socket_path = default_available_socket_path(&handshake)?; + let connection = client_connection(&handshake.client_identity.profile_root, &socket_path)?; + Ok(tracedecay_daemon_protocol::DaemonInvocationClient::new( + connection.into_protocol(), + handshake, + )) +} + pub(crate) async fn connect_to_current_daemon_within( profile_root: &Path, socket_path: &Path, diff --git a/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs b/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs index db8b62d350..08479df929 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs @@ -570,6 +570,23 @@ fn every_catalog_binding_is_mounted_on_its_declared_surface() { Some(_) | None => false, } } + // An internal owner operation is served by name for first-party + // CLI commands and host hooks; being listed is the defect. + BindingSurface::Mcp | BindingSurface::Cli + if ApplicationSurfaceOperation::from_surface_name(*surface, operation) + .is_some_and(ApplicationSurfaceOperation::is_internal) => + { + let listed = match surface { + BindingSurface::Mcp => mcp_tools.contains(&format!("tracedecay_{operation}")), + _ => cli_tools.contains(operation.as_str()), + }; + if listed { + failures.push(format!( + "{note}: an internal owner operation is advertised in the {surface:?} listing" + )); + } + continue; + } BindingSurface::Mcp => mcp_tools.contains(&format!("tracedecay_{operation}")), BindingSurface::Cli => cli_tools.contains(operation.as_str()), // The LSP and dashboard adapters have no listing endpoint of their From 2e31b550e7360e0c596d9776ec46b186fa08eb11 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 23:44:37 +0000 Subject: [PATCH 4/5] test(cli): assert the typed refusal for an unenrolled client --- .../tests/core_cli_suite/tool_daemon_test.rs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index 358fcd4ef5..899028c588 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -1747,14 +1747,27 @@ fn daemon_project_cache_is_scoped_by_client_identity() { String::from_utf8_lossy(&client_b_output.stdout), String::from_utf8_lossy(&client_b_output.stderr) ); - let stderr = String::from_utf8_lossy(&client_b_output.stderr); - let expected_project_path = project_path.to_string_lossy(); - let stderr_lower = stderr.to_lowercase(); + let stdout = String::from_utf8_lossy(&client_b_output.stdout); + let result: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|error| { + panic!("client B refusal is not one JSON result ({error}): {stdout}") + }); + let payload: Value = serde_json::from_str( + result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("client B refusal carries no text: {result}")), + ) + .expect("client B refusal payload"); + let problem = &payload["application"]["problem"]; + assert_eq!( + (&result["isError"], &problem["code"]), + (&json!(true), &json!("project_not_enrolled")), + "client B's profile has not initialized the project, so the shared daemon must refuse it: {result}" + ); assert!( - stderr.contains("project route error (project_not_enrolled)") - && stderr_lower.contains("no tracedecay index found") - && stderr.contains(expected_project_path.as_ref()), - "expected client B to fail because its profile has not initialized the project, got:\n{stderr}" + problem["message"] + .as_str() + .is_some_and(|message| message.contains(project_path.to_string_lossy().as_ref())), + "the refusal must name the project client B asked for: {result}" ); } From b20c6b3fe304b433a43f128d276a17cb26c0f387 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 28 Sep 2026 00:09:34 +0000 Subject: [PATCH 5/5] test(cli): pin the daemon socket for every typed tool route --- .../tests/core_cli_suite/tool_daemon_test.rs | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index 899028c588..d817dcf26f 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -1782,27 +1782,33 @@ fn tool_cli_without_daemon_socket_reports_daemon_unavailable() { let missing_socket = socket_dir.path().join("missing.sock"); let project_arg = project_path.to_string_lossy().to_string(); - let output = tracedecay_command_with_home(&home_path) - .current_dir(&project_path) - .env("TRACEDECAY_DAEMON_SOCKET", &missing_socket) - .args(["tool", "--project", &project_arg]) - .args(SCOPE_SET_PROBE) - .output() - .expect("tracedecay tool should run"); + // Every typed `tracedecay tool` route resolves the same socket: a + // daemon-owned multi-root read and an owner-served graph tool alike. + let status_probe: [&str; 2] = ["status", "--json"]; + for probe in [&SCOPE_SET_PROBE[..], &status_probe[..]] { + let output = tracedecay_command_with_home(&home_path) + .current_dir(&project_path) + .env("TRACEDECAY_DAEMON_SOCKET", &missing_socket) + .args(["tool", "--project", &project_arg]) + .args(probe) + .output() + .expect("tracedecay tool should run"); - // Scripted callers (the Pi extension) branch on this typed status rather - // than on the error text. - assert_eq!( - output.status.code(), - Some(i32::from( - tracedecay_daemon_identity::DAEMON_UNREACHABLE_EXIT_CODE - )) - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), - "expected explicit daemon-unavailable error, got:\n{stderr}" - ); + // Scripted callers (the Pi extension) branch on this typed status + // rather than on the error text. + assert_eq!( + output.status.code(), + Some(i32::from( + tracedecay_daemon_identity::DAEMON_UNREACHABLE_EXIT_CODE + )), + "{probe:?}: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("TraceDecay daemon socket") && stderr.contains("is not available"), + "{probe:?}: expected explicit daemon-unavailable error, got:\n{stderr}" + ); + } } #[test]