diff --git a/Cargo.lock b/Cargo.lock index a60d107960..d3b37bf39e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6727,6 +6727,7 @@ dependencies = [ name = "tracedecay-mcp" version = "0.1.0" dependencies = [ + "gix", "glob", "hotpath", "ignore", @@ -6744,17 +6745,22 @@ dependencies = [ "tracedecay-application", "tracedecay-code-extraction", "tracedecay-code-index", + "tracedecay-code-index-runtime", "tracedecay-contracts", "tracedecay-daemon-protocol", "tracedecay-domain", "tracedecay-framing", + "tracedecay-global-db", "tracedecay-graph-query", "tracedecay-hooks", "tracedecay-privacy", + "tracedecay-query", "tracedecay-runtime-core", "tracedecay-session-memory", + "tracedecay-session-temporal-store", "tracedecay-sessions", "tracedecay-store", + "tracedecay-temporal-query", "tracedecay-tool-catalog", "tracing", "tree-sitter", diff --git a/Cargo.toml b/Cargo.toml index c148a913d5..dd25273488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,10 @@ grafeo-core = { version = "=0.5.42", default-features = false, features = ["lpg" grafeo-engine = { version = "=0.5.42", default-features = false, features = ["lpg", "gql", "algos", "wal", "grafeo-file", "vector-index"] } grafeo-storage = { version = "=0.5.42", default-features = false, features = ["wal"] } rusqlite = { version = "0.40.1", default-features = false, features = ["backup", "cache"] } +# Pinned once: the MCP git handlers and the root's edit/lifecycle readers must +# resolve the same object database and status implementation, so a feature +# added for one is never missing for the other. +gix = { version = "=0.86.0", default-features = false, features = ["revision", "blob-diff", "parallel", "sha1", "sha256", "status"] } tracedecay-semantic-contracts = { path = "crates/tracedecay-semantic-contracts", version = "0.1.0" } [profile.bench] diff --git a/crates/tracedecay-mcp/Cargo.toml b/crates/tracedecay-mcp/Cargo.toml index 41ccf6d72f..08c3d5f0c7 100644 --- a/crates/tracedecay-mcp/Cargo.toml +++ b/crates/tracedecay-mcp/Cargo.toml @@ -33,6 +33,7 @@ source-analysis = [ ] [dependencies] +gix.workspace = true glob = "0.3" hotpath.workspace = true ignore = { version = "0.4", optional = true } @@ -50,15 +51,21 @@ tracedecay-application = { path = "../tracedecay-application", version = "0.1.0" tracedecay-contracts = { path = "../tracedecay-contracts", version = "0.1.0" } tracedecay-code-extraction = { path = "../tracedecay-code-extraction", version = "0.1.0", default-features = false } tracedecay-code-index = { path = "../tracedecay-code-index", version = "0.1.0", default-features = false, features = ["lite"] } +tracedecay-code-index-runtime = { path = "../tracedecay-code-index-runtime", version = "0.1.0" } tracedecay-daemon-protocol = { path = "../tracedecay-daemon-protocol", version = "0.1.0" } tracedecay-domain = { path = "../tracedecay-domain", version = "0.1.0" } tracedecay-framing = { path = "../tracedecay-framing", version = "0.1.0" } +tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0" } tracedecay-graph-query = { path = "../tracedecay-graph-query", version = "0.1.0" } tracedecay-hooks = { path = "../tracedecay-hooks", version = "0.1.0" } tracedecay-privacy = { path = "../tracedecay-privacy", version = "0.1.0" } +tracedecay-query = { path = "../tracedecay-query", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } tracedecay-session-memory = { path = "../tracedecay-session-memory", version = "0.1.0" } +tracedecay-session-temporal-store = { path = "../tracedecay-session-temporal-store", version = "0.1.0" } tracedecay-sessions = { path = "../tracedecay-sessions", version = "0.1.0" } +tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } +tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = "0.1.0" } tracedecay-tool-catalog = { path = "../tracedecay-tool-catalog", version = "0.1.0" } tracing = "0.1" tree-sitter = { version = "0.26", optional = true } @@ -70,5 +77,5 @@ tokio = { version = "1", features = ["full", "test-util"] } tracedecay-daemon-protocol = { path = "../tracedecay-daemon-protocol", version = "0.1.0", features = ["test-helpers"] } tracedecay-framing = { path = "../tracedecay-framing", version = "0.1.0", features = ["test-helpers"] } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0", features = ["test-helpers"] } +tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0", features = ["test-helpers"] } tracedecay-sessions = { path = "../tracedecay-sessions", version = "0.1.0", features = ["test-helpers"] } -tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } diff --git a/crates/tracedecay-mcp/src/broker_stream_transport.rs b/crates/tracedecay-mcp/src/broker_stream_transport.rs index 90feedface..1b50ada527 100644 --- a/crates/tracedecay-mcp/src/broker_stream_transport.rs +++ b/crates/tracedecay-mcp/src/broker_stream_transport.rs @@ -16,6 +16,10 @@ use tracedecay_framing::{ }; use tracedecay_session_memory::context::CancellationToken; +use crate::lifecycle::ProjectServerResponseLifecycle; +use crate::server::{RmcpSelectedProjectResponseAuthority, RmcpWorkDeliverySettlement}; +use tracedecay_domain::errors::TraceDecayError; + use crate::{ErrorCode, JsonRpcDecodeError, JsonRpcResponse, McpTransport}; /// Response-revocation authority retained for one selected project server. @@ -51,6 +55,47 @@ pub trait BrokerWorkDeliverySettlement: Send + Sync { ); } +impl BrokerResponseLifecycle for ProjectServerResponseLifecycle { + fn response_revoked(&self) -> &CancellationToken { + ProjectServerResponseLifecycle::response_revoked(self) + } +} + +impl BrokerSelectedResponseAuthority + for RmcpSelectedProjectResponseAuthority +{ + fn take_response( + &self, + id: Option<&serde_json::Value>, + ) -> std::io::Result>> { + self.take(id) + .map(|lease| lease.map(|lease| Box::new(lease) as Box)) + .map_err(selected_response_io_error) + } +} + +fn selected_response_io_error(error: TraceDecayError) -> std::io::Error { + std::io::Error::other(error) +} + +impl BrokerWorkDeliverySettlement for RmcpWorkDeliverySettlement { + fn attempt_for_request( + &self, + request: &serde_json::Value, + ) -> Option { + RmcpWorkDeliverySettlement::attempt_for_request(self, request) + } + + fn settle( + &self, + attempt: tracedecay_domain::DeliverySettlementAttemptV1, + outcome: tracedecay_domain::DeliverySettlementOutcomeV1, + drop_reason: Option, + ) { + RmcpWorkDeliverySettlement::settle(self, attempt, outcome, drop_reason); + } +} + pub struct BrokerStreamTransport { // Every daemon read of this transport races something else in a // `tokio::select!` — draining, an owner open, a completed handler. The @@ -549,3 +594,32 @@ impl rmcp::transport::Transport for BrokerStreamTransport { Ok(()) } } + +#[cfg(test)] +mod selected_response_error_tests { + use super::*; + + #[test] + fn io_boundary_retains_typed_project_route_classification() { + let error = TraceDecayError::project_route( + "project_route_unavailable", + true, + "selected response authority is warming", + ); + + let error = selected_response_io_error(error); + let source = error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .expect("I/O error must retain the typed TraceDecay source"); + + assert_eq!( + source.project_route_context(), + Some(( + "project_route_unavailable", + true, + "selected response authority is warming", + )) + ); + } +} diff --git a/crates/tracedecay/src/mcp/tools/handlers/dependency_hints.rs b/crates/tracedecay-mcp/src/handlers/dependency_hints.rs similarity index 86% rename from crates/tracedecay/src/mcp/tools/handlers/dependency_hints.rs rename to crates/tracedecay-mcp/src/handlers/dependency_hints.rs index 45cf0d84b1..8a3670a6e8 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dependency_hints.rs +++ b/crates/tracedecay-mcp/src/handlers/dependency_hints.rs @@ -10,29 +10,35 @@ use tracedecay_contracts::retrieval::{ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_graph_query::VerifiedGraphQuery; -use tracedecay_mcp::tools::render::{self, Md}; -pub(super) fn should_check_external_import_hint(result_count: usize, limit: usize) -> bool { +use crate::tool_context::McpToolContext; +use crate::tools::render::{self, Md}; + +pub fn should_check_external_import_hint(result_count: usize, limit: usize) -> bool { result_count == 0 || result_count < limit.clamp(1, 20) } -pub(super) fn lazy_indexing_requested(args: &Value) -> bool { +pub fn lazy_indexing_requested(args: &Value) -> bool { args.get("lazy_index_ignored_dependencies") .and_then(Value::as_bool) .unwrap_or(false) } -#[hotpath::measure(future = true, label = "mcp.search.import_hint.total")] -pub(super) async fn external_import_hint( +/// Advisory external-import evidence for a sparse search. +/// +/// The bound context carries the graph's admitted scope plus the caller's +/// deadline and cancellation, so this read cannot outlive the request or reach +/// a graph admitted for another checkout. +#[hotpath::measure(label = "mcp.search.import_hint.total")] +pub fn external_import_hint( + ctx: &McpToolContext<'_>, graph: &VerifiedGraphQuery, query: &str, limit: usize, scope_prefix: Option<&str>, - deadline: Option<&tracedecay_contracts::Deadline>, - cancellation: Option<&tracedecay_contracts::CancellationSignal>, ) -> Result> { let candidates = hotpath::measure_block!("mcp.search.import_hint.scan", { - ignored_dependency_candidates(graph, query, limit, scope_prefix, deadline, cancellation)? + ignored_dependency_candidates(ctx, graph, query, limit, scope_prefix)? }); if candidates.is_empty() { return Ok(None); @@ -51,7 +57,7 @@ pub(super) async fn external_import_hint( }))) } -pub(super) fn unavailable_evidence(error: &TraceDecayError) -> PrimitiveUnavailableEvidenceV1 { +pub fn unavailable_evidence(error: &TraceDecayError) -> PrimitiveUnavailableEvidenceV1 { let (reason_code, retryable, detail) = if let Some((reason_code, retryable, detail)) = error.project_route_context() { (reason_code, retryable, detail.to_owned()) @@ -72,21 +78,19 @@ pub(super) fn unavailable_evidence(error: &TraceDecayError) -> PrimitiveUnavaila } } -pub(super) fn unavailable_hint(error: &TraceDecayError) -> Value { +pub fn unavailable_hint(error: &TraceDecayError) -> Value { json!(unavailable_evidence(error)) } #[hotpath::measure(label = "mcp.search.import_admit.total")] -pub(super) async fn admit_verified_ignored_dependency( +pub async fn admit_verified_ignored_dependency( + ctx: &McpToolContext<'_>, admission: Option<&dyn CodeIndexIgnoredDependencyAdmissionPortV1>, graph: &VerifiedGraphQuery, query: &str, scope_prefix: Option<&str>, - deadline: Option<&tracedecay_contracts::Deadline>, - cancellation: Option<&tracedecay_contracts::CancellationSignal>, ) -> Result<()> { - let candidates = - ignored_dependency_candidates(graph, query, 1, scope_prefix, deadline, cancellation)?; + let candidates = ignored_dependency_candidates(ctx, graph, query, 1, scope_prefix)?; let Some(import) = candidates.first() else { return Ok(()); }; @@ -158,13 +162,15 @@ pub(super) async fn admit_verified_ignored_dependency( } fn ignored_dependency_candidates( + ctx: &McpToolContext<'_>, graph: &VerifiedGraphQuery, query: &str, limit: usize, scope_prefix: Option<&str>, - deadline: Option<&tracedecay_contracts::Deadline>, - cancellation: Option<&tracedecay_contracts::CancellationSignal>, ) -> Result> { + ctx.verify_graph_scope(graph)?; + let cancellation = ctx.cancellation(); + let deadline = ctx.deadline(); if cancellation.is_some_and(tracedecay_contracts::CancellationSignal::is_cancelled) { return Err(TraceDecayError::project_route( "code-graph-cancelled", @@ -190,7 +196,7 @@ fn generation_advanced() -> TraceDecayError { ) } -pub(super) fn append_external_import_hint_md(md: &mut Md, value: &Value) { +pub fn append_external_import_hint_md(md: &mut Md, value: &Value) { let Some(hint) = value.get("external_import_hint") else { return; }; diff --git a/crates/tracedecay/src/mcp/tools/handlers/git/affected.rs b/crates/tracedecay-mcp/src/handlers/git/affected.rs similarity index 97% rename from crates/tracedecay/src/mcp/tools/handlers/git/affected.rs rename to crates/tracedecay-mcp/src/handlers/git/affected.rs index 1f2339faed..351a55b46b 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/git/affected.rs +++ b/crates/tracedecay-mcp/src/handlers/git/affected.rs @@ -105,11 +105,12 @@ pub(crate) async fn collect_affected_test_files, graph: &tracedecay_graph_query::VerifiedGraphQuery, args: Value, ) -> Result { + ctx.verify_graph_scope(graph)?; let files = require_string_array_arg(&args, "files")?; let max_depth = clamped_depth_arg(&args, "depth", 5, 10); @@ -173,7 +174,7 @@ pub(crate) async fn handle_affected( ); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &output, touched_files, @@ -245,7 +246,7 @@ mod tests { let mut visited = HashSet::new(); let mut queue = std::collections::VecDeque::new(); for file in files { - if crate::tracedecay::is_test_file(file) { + if tracedecay_code_index::is_test_file(file) { affected.insert(file.clone()); } if visited.insert(file.clone()) { @@ -260,7 +261,7 @@ mod tests { if !visited.insert(dependent.clone()) { continue; } - if crate::tracedecay::is_test_file(dependent) { + if tracedecay_code_index::is_test_file(dependent) { affected.insert(dependent.clone()); } else { queue.push_back((dependent.clone(), depth + 1)); diff --git a/crates/tracedecay/src/mcp/tools/handlers/git/branch.rs b/crates/tracedecay-mcp/src/handlers/git/branch.rs similarity index 71% rename from crates/tracedecay/src/mcp/tools/handlers/git/branch.rs rename to crates/tracedecay-mcp/src/handlers/git/branch.rs index 4ec003e8de..f3aaa48010 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/git/branch.rs +++ b/crates/tracedecay-mcp/src/handlers/git/branch.rs @@ -77,7 +77,13 @@ fn branch_read_reason(error: &BranchRouteReadErrorV1) -> (&'static str, bool) { use tracedecay_contracts::branch_snapshots::LocalBranchSnapshotErrorV1; match error { - BranchRouteReadErrorV1::Capacity => ("branch_read_capacity_unavailable", true), + // Both ceilings are the same answer to the caller: the local + // admission semaphore refused the read, or the ref walk exceeded its + // own bound. Either way the route is over capacity and retryable. + BranchRouteReadErrorV1::Capacity + | BranchRouteReadErrorV1::Ref(LocalBranchSnapshotErrorV1::CapacityExceeded { .. }) => { + ("branch_read_capacity_unavailable", true) + } BranchRouteReadErrorV1::Task => ("branch_read_failed", true), BranchRouteReadErrorV1::Ref(LocalBranchSnapshotErrorV1::InvalidReference { .. }) => { ("branch_ref_invalid", false) @@ -95,9 +101,6 @@ fn branch_read_reason(error: &BranchRouteReadErrorV1) -> (&'static str, bool) { BranchRouteReadErrorV1::Ref(LocalBranchSnapshotErrorV1::InvalidLimit) => { ("invalid_request", false) } - BranchRouteReadErrorV1::Ref(LocalBranchSnapshotErrorV1::CapacityExceeded { .. }) => { - ("branch_read_capacity_unavailable", true) - } BranchRouteReadErrorV1::Ref(LocalBranchSnapshotErrorV1::Cancelled) => ("cancelled", false), BranchRouteReadErrorV1::Ref(LocalBranchSnapshotErrorV1::TimedOut) => ("timed_out", true), } @@ -105,12 +108,9 @@ fn branch_read_reason(error: &BranchRouteReadErrorV1) -> (&'static str, bool) { /// Lists exact local branch refs. A branch name never selects a branch DB. #[hotpath::measure(future = true, label = "mcp.git.branch_list.total")] -pub(crate) async fn handle_branch_list( - cg: &TraceDecay, - args: Value, - deadline: Option, - cancellation: Option, -) -> Result { +pub async fn handle_branch_list(ctx: &McpToolContext<'_>, args: Value) -> Result { + let deadline = ctx.deadline().cloned(); + let cancellation = ctx.cancellation().cloned(); let limit = args .get("limit") .and_then(Value::as_u64) @@ -129,7 +129,7 @@ pub(crate) async fn handle_branch_list( .map(str::to_owned); match hotpath::future!( run_branch_ref_read( - cg.project_root().to_path_buf(), + ctx.project_root().to_path_buf(), limit, after, deadline, @@ -165,7 +165,7 @@ pub(crate) async fn handle_branch_list( }) ); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &result, vec![], @@ -174,7 +174,7 @@ pub(crate) async fn handle_branch_list( Err(error) => { let (reason, retryable) = branch_read_reason(&error); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &json!({ "status": "unavailable", @@ -190,7 +190,7 @@ pub(crate) async fn handle_branch_list( } fn branch_reference_unavailable( - cg: &TraceDecay, + ctx: &McpToolContext<'_>, args: &Value, field: &str, branch: &str, @@ -198,7 +198,7 @@ fn branch_reference_unavailable( ) -> ToolResult { let (reason, retryable) = branch_read_reason(error); generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), args, &json!({ "status": "unavailable", @@ -215,15 +215,15 @@ fn branch_reference_unavailable( } fn branch_search_unavailable( - cg: &TraceDecay, + ctx: &McpToolContext<'_>, args: &Value, branch: &str, revision: &tracedecay_domain::GitOidV1, - unavailable: &crate::mcp::server::CodeIndexSearchUnavailableV1, + unavailable: &tracedecay_query::code_search::CodeIndexSearchUnavailableV1, ) -> ToolResult { let (reason, retryable) = branch_unavailable_wire(unavailable.reason); generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), args, &json!({ "status": "unavailable", @@ -243,14 +243,14 @@ fn branch_search_unavailable( } fn branch_unavailable_wire( - reason: crate::mcp::server::CodeIndexSearchUnavailableReasonV1, + reason: tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1, ) -> (&'static str, bool) { ( reason.as_str(), matches!( reason, - crate::mcp::server::CodeIndexSearchUnavailableReasonV1::GenerationUnavailable - | crate::mcp::server::CodeIndexSearchUnavailableReasonV1::CapacityUnavailable + tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1::GenerationUnavailable + | tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1::CapacityUnavailable ), ) } @@ -265,14 +265,11 @@ fn branch_search_page_status(has_more: bool) -> (&'static str, Option<&'static s /// Searches the generation sealed for the selected local ref's exact commit. #[hotpath::measure(future = true, label = "mcp.git.branch_search.total")] -pub(crate) async fn handle_branch_search( - cg: &TraceDecay, - args: Value, - executor: Option<&crate::mcp::server::CodeIndexSearchExecutor>, - authority: Option<&crate::mcp::server::CodeIndexSearchAuthorityV1>, - deadline: Option, - cancellation: Option, -) -> Result { +pub async fn handle_branch_search(ctx: &McpToolContext<'_>, args: Value) -> Result { + let executor = ctx.code_index_search_executor(); + let authority = ctx.code_index_search_authority(); + let deadline = ctx.deadline().cloned(); + let cancellation = ctx.cancellation().cloned(); let branch = args .get("branch") .and_then(Value::as_str) @@ -293,11 +290,11 @@ pub(crate) async fn handle_branch_search( .get("limit") .and_then(Value::as_u64) .map_or(10, |value| value.min(500) as usize); - let cursor = super::super::support::retrieval_cursor(&args)?; + let cursor = crate::handlers::support::retrieval_cursor(&args)?; let revision_branch = branch.clone(); let revision = match hotpath::future!( run_branch_ref_read( - cg.project_root().to_path_buf(), + ctx.project_root().to_path_buf(), 1, None, deadline.clone(), @@ -317,24 +314,24 @@ pub(crate) async fn handle_branch_search( Ok(revision) => revision, Err(error) => { return Ok(branch_reference_unavailable( - cg, &args, "branch", &branch, &error, + ctx, &args, "branch", &branch, &error, )); } }; let Some(executor) = executor else { return Ok(branch_search_unavailable( - cg, + ctx, &args, &branch, &revision.commit, - &crate::mcp::server::CodeIndexSearchUnavailableV1 { + &tracedecay_query::code_search::CodeIndexSearchUnavailableV1 { code_generation: None, reason: - crate::mcp::server::CodeIndexSearchUnavailableReasonV1::CapabilityUnavailable, - semantic: crate::mcp::server::CodeIndexSemanticStatusV1::Unavailable { + tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1::CapabilityUnavailable, + semantic: tracedecay_query::code_search::CodeIndexSemanticStatusV1::Unavailable { reason: "code_index_unavailable", }, - coverage: crate::mcp::server::CodeIndexSearchCoverageV1::unavailable( + coverage: tracedecay_query::code_search::CodeIndexSearchCoverageV1::unavailable( "code_index_unavailable", ), }, @@ -347,15 +344,15 @@ pub(crate) async fn handle_branch_search( } })?; match hotpath::future!( - executor(crate::mcp::server::CodeIndexSearchRequestV1 { - project_root: cg.project_root().to_path_buf(), + executor(tracedecay_query::code_search::CodeIndexSearchRequestV1 { + project_root: ctx.project_root().to_path_buf(), query, source_revision: Some(revision.commit.clone()), source_tree: Some(revision.tree.clone()), source_reference: Some(source_reference), limit, cursor, - mode: crate::mcp::server::CodeIndexSearchModeV1::FallbackAllowed, + mode: tracedecay_query::code_search::CodeIndexSearchModeV1::FallbackAllowed, lexical_routing: tracedecay_query::retrieval::lexical::LexicalRoutingV1::query_only(), authority: authority.cloned(), deadline, @@ -365,7 +362,7 @@ pub(crate) async fn handle_branch_search( ) .await { - crate::mcp::server::CodeIndexSearchOutcomeV1::Complete(complete) => { + tracedecay_query::code_search::CodeIndexSearchOutcomeV1::Complete(complete) => { let next_cursor = complete .next_cursor .as_ref() @@ -392,7 +389,7 @@ pub(crate) async fn handle_branch_search( }) .collect::>(); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &hotpath::measure_block!( "mcp.git.branch_search.assemble", @@ -411,22 +408,22 @@ pub(crate) async fn handle_branch_search( vec![], )) } - crate::mcp::server::CodeIndexSearchOutcomeV1::Unavailable(unavailable) => Ok( - branch_search_unavailable(cg, &args, &branch, &revision.commit, &unavailable), + tracedecay_query::code_search::CodeIndexSearchOutcomeV1::Unavailable(unavailable) => Ok( + branch_search_unavailable(ctx, &args, &branch, &revision.commit, &unavailable), ), } } fn branch_diff_unavailable( - cg: &TraceDecay, + ctx: &McpToolContext<'_>, args: &Value, base: (&str, &tracedecay_domain::GitOidV1), head: (&str, &tracedecay_domain::GitOidV1), - unavailable: &crate::mcp::server::CodeIndexBranchDiffUnavailableV1, + unavailable: &tracedecay_query::code_search::CodeIndexBranchDiffUnavailableV1, ) -> ToolResult { let (reason, retryable) = branch_unavailable_wire(unavailable.reason); generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), args, &json!({ "status": "unavailable", @@ -448,7 +445,7 @@ fn branch_diff_unavailable( )) } -fn branch_symbol_json(symbol: &crate::mcp::server::CodeIndexBranchSymbolV1) -> Value { +fn branch_symbol_json(symbol: &tracedecay_query::code_search::CodeIndexBranchSymbolV1) -> Value { json!({ "symbol_identity": symbol.symbol_identity, "symbol_occurrence_id": symbol.symbol_occurrence_id, @@ -462,17 +459,17 @@ fn branch_symbol_json(symbol: &crate::mcp::server::CodeIndexBranchSymbolV1) -> V }) } -fn branch_change_json(change: &crate::mcp::server::CodeIndexBranchChangeV1) -> Value { +fn branch_change_json(change: &tracedecay_query::code_search::CodeIndexBranchChangeV1) -> Value { match change { - crate::mcp::server::CodeIndexBranchChangeV1::Added { symbol } => json!({ + tracedecay_query::code_search::CodeIndexBranchChangeV1::Added { symbol } => json!({ "change": "added", "symbol": branch_symbol_json(symbol), }), - crate::mcp::server::CodeIndexBranchChangeV1::Removed { symbol } => json!({ + tracedecay_query::code_search::CodeIndexBranchChangeV1::Removed { symbol } => json!({ "change": "removed", "symbol": branch_symbol_json(symbol), }), - crate::mcp::server::CodeIndexBranchChangeV1::Changed { base, head } => json!({ + tracedecay_query::code_search::CodeIndexBranchChangeV1::Changed { base, head } => json!({ "change": "changed", "base": branch_symbol_json(base), "head": branch_symbol_json(head), @@ -480,31 +477,33 @@ fn branch_change_json(change: &crate::mcp::server::CodeIndexBranchChangeV1) -> V } } -fn branch_change_files(change: &crate::mcp::server::CodeIndexBranchChangeV1) -> [&str; 2] { +fn branch_change_files( + change: &tracedecay_query::code_search::CodeIndexBranchChangeV1, +) -> [&str; 2] { match change { - crate::mcp::server::CodeIndexBranchChangeV1::Added { symbol } - | crate::mcp::server::CodeIndexBranchChangeV1::Removed { symbol } => { + tracedecay_query::code_search::CodeIndexBranchChangeV1::Added { symbol } + | tracedecay_query::code_search::CodeIndexBranchChangeV1::Removed { symbol } => { [symbol.file.as_str(), symbol.file.as_str()] } - crate::mcp::server::CodeIndexBranchChangeV1::Changed { base, head } => { + tracedecay_query::code_search::CodeIndexBranchChangeV1::Changed { base, head } => { [base.file.as_str(), head.file.as_str()] } } } fn branch_change_counts( - changes: &[crate::mcp::server::CodeIndexBranchChangeV1], + changes: &[tracedecay_query::code_search::CodeIndexBranchChangeV1], ) -> (usize, usize, usize) { changes .iter() .fold((0, 0, 0), |counts, change| match change { - crate::mcp::server::CodeIndexBranchChangeV1::Added { .. } => { + tracedecay_query::code_search::CodeIndexBranchChangeV1::Added { .. } => { (counts.0 + 1, counts.1, counts.2) } - crate::mcp::server::CodeIndexBranchChangeV1::Removed { .. } => { + tracedecay_query::code_search::CodeIndexBranchChangeV1::Removed { .. } => { (counts.0, counts.1 + 1, counts.2) } - crate::mcp::server::CodeIndexBranchChangeV1::Changed { .. } => { + tracedecay_query::code_search::CodeIndexBranchChangeV1::Changed { .. } => { (counts.0, counts.1, counts.2 + 1) } }) @@ -512,14 +511,11 @@ fn branch_change_counts( /// Compares generations sealed for the two selected local refs' exact commits. #[hotpath::measure(future = true, label = "mcp.git.branch_diff.total")] -pub(crate) async fn handle_branch_diff( - cg: &TraceDecay, - args: Value, - executor: Option<&crate::mcp::server::CodeIndexBranchDiffExecutor>, - authority: Option<&crate::mcp::server::CodeIndexSearchAuthorityV1>, - deadline: Option, - cancellation: Option, -) -> Result { +pub async fn handle_branch_diff(ctx: &McpToolContext<'_>, args: Value) -> Result { + let executor = ctx.code_index_branch_diff_executor(); + let authority = ctx.code_index_search_authority(); + let deadline = ctx.deadline().cloned(); + let cancellation = ctx.cancellation().cloned(); let base_name = args .get("base") .and_then(Value::as_str) @@ -531,7 +527,7 @@ pub(crate) async fn handle_branch_diff( let head_name = args .get("head") .and_then(Value::as_str) - .or_else(|| cg.active_branch()) + .or_else(|| ctx.active_branch()) .filter(|head| !head.is_empty()) .map(str::to_owned) .ok_or_else(|| TraceDecayError::Config { @@ -541,7 +537,8 @@ pub(crate) async fn handle_branch_diff( .get("limit") .and_then(Value::as_u64) .map_or(100, |value| { - value.min(crate::mcp::server::CODE_INDEX_BRANCH_DIFF_MAX_RESULTS_V1 as u64) as usize + value.min(tracedecay_query::code_search::CODE_INDEX_BRANCH_DIFF_MAX_RESULTS_V1 as u64) + as usize }); if limit == 0 { return Err(TraceDecayError::Config { @@ -561,7 +558,7 @@ pub(crate) async fn handle_branch_diff( let resolution_head = head_name.clone(); let (base_revision, head_revision) = match hotpath::future!( run_branch_ref_read( - cg.project_root().to_path_buf(), + ctx.project_root().to_path_buf(), 1, None, deadline.clone(), @@ -587,7 +584,7 @@ pub(crate) async fn handle_branch_diff( Ok(revisions) => revisions, Err(error) => { return Ok(branch_reference_unavailable( - cg, + ctx, &args, "base_or_head", &format!("{base_name}..{head_name}"), @@ -597,46 +594,48 @@ pub(crate) async fn handle_branch_diff( }; let Some(executor) = executor else { return Ok(branch_diff_unavailable( - cg, + ctx, &args, (&base_name, &base_revision.commit), (&head_name, &head_revision.commit), - &crate::mcp::server::CodeIndexBranchDiffUnavailableV1 { + &tracedecay_query::code_search::CodeIndexBranchDiffUnavailableV1 { base_generation: None, head_generation: None, reason: - crate::mcp::server::CodeIndexSearchUnavailableReasonV1::CapabilityUnavailable, + tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1::CapabilityUnavailable, }, )); }; match hotpath::future!( - executor(crate::mcp::server::CodeIndexBranchDiffRequestV1 { - project_root: cg.project_root().to_path_buf(), - base_reference: tracedecay_domain::RefId::new(format!("refs/heads/{base_name}")) - .map_err(|error| TraceDecayError::Config { - message: format!("invalid base branch reference: {error}"), - })?, - base_revision: base_revision.commit.clone(), - base_tree: base_revision.tree.clone(), - head_reference: tracedecay_domain::RefId::new(format!("refs/heads/{head_name}")) - .map_err(|error| TraceDecayError::Config { - message: format!("invalid head branch reference: {error}"), - })?, - head_revision: head_revision.commit.clone(), - head_tree: head_revision.tree.clone(), - file_filter: args.get("file").and_then(Value::as_str).map(str::to_owned), - kind_filter: args.get("kind").and_then(Value::as_str).map(str::to_owned), - limit, - cursor, - authority: authority.cloned(), - deadline, - cancellation, - }), + executor( + tracedecay_query::code_search::CodeIndexBranchDiffRequestV1 { + project_root: ctx.project_root().to_path_buf(), + base_reference: tracedecay_domain::RefId::new(format!("refs/heads/{base_name}")) + .map_err(|error| TraceDecayError::Config { + message: format!("invalid base branch reference: {error}"), + })?, + base_revision: base_revision.commit.clone(), + base_tree: base_revision.tree.clone(), + head_reference: tracedecay_domain::RefId::new(format!("refs/heads/{head_name}")) + .map_err(|error| TraceDecayError::Config { + message: format!("invalid head branch reference: {error}"), + })?, + head_revision: head_revision.commit.clone(), + head_tree: head_revision.tree.clone(), + file_filter: args.get("file").and_then(Value::as_str).map(str::to_owned), + kind_filter: args.get("kind").and_then(Value::as_str).map(str::to_owned), + limit, + cursor, + authority: authority.cloned(), + deadline, + cancellation, + } + ), label = "mcp.git.branch_diff.diff" ) .await { - crate::mcp::server::CodeIndexBranchDiffOutcomeV1::Complete(completed) => { + tracedecay_query::code_search::CodeIndexBranchDiffOutcomeV1::Complete(completed) => { let (added, removed, changed) = branch_change_counts(&completed.changes); let changes = completed .changes @@ -645,7 +644,7 @@ pub(crate) async fn handle_branch_diff( .collect::>(); let touched = unique_file_paths(completed.changes.iter().flat_map(branch_change_files)); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &hotpath::measure_block!( "mcp.git.branch_diff.assemble", @@ -671,7 +670,7 @@ pub(crate) async fn handle_branch_diff( touched, )) } - crate::mcp::server::CodeIndexBranchDiffOutcomeV1::Partial(partial) => { + tracedecay_query::code_search::CodeIndexBranchDiffOutcomeV1::Partial(partial) => { let (added, removed, changed) = branch_change_counts(&partial.changes); let changes = partial .changes @@ -680,7 +679,7 @@ pub(crate) async fn handle_branch_diff( .collect::>(); let touched = unique_file_paths(partial.changes.iter().flat_map(branch_change_files)); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &hotpath::measure_block!( "mcp.git.branch_diff.assemble", @@ -708,9 +707,9 @@ pub(crate) async fn handle_branch_diff( touched, )) } - crate::mcp::server::CodeIndexBranchDiffOutcomeV1::Unavailable(unavailable) => { + tracedecay_query::code_search::CodeIndexBranchDiffOutcomeV1::Unavailable(unavailable) => { Ok(branch_diff_unavailable( - cg, + ctx, &args, (&base_name, &base_revision.commit), (&head_name, &head_revision.commit), @@ -722,8 +721,77 @@ pub(crate) async fn handle_branch_diff( #[cfg(test)] mod tests { + use super::super::test_support::{ + branched_repository, ref_read_guard, standalone_context, standalone_context_on_branch, + }; use super::*; + /// A context with no admitted code-index authority must produce the typed + /// capability-unavailable answer, not an empty result set that reads like + /// "this branch contains no matches". + #[tokio::test] + async fn branch_search_without_an_admitted_executor_is_capability_unavailable() { + let _serialized = ref_read_guard().await; + let repo = branched_repository(); + let ctx = standalone_context(repo.path()); + + let result = handle_branch_search(&ctx, json!({ "branch": "feature", "query": "after" })) + .await + .expect("an unadmitted executor returns a typed result, not an error"); + + assert_eq!(result.semantic_error(), Some(true)); + let message = result.failure_message().unwrap_or_default(); + assert!( + message.contains("search is unavailable") && message.contains("code_index_unavailable"), + "absent code-index authority must be named as such, got {message:?}" + ); + } + + /// The same absence on the branch-diff route, which reads a different + /// executor slot off the same context. + #[tokio::test] + async fn branch_diff_without_an_admitted_executor_is_capability_unavailable() { + let _serialized = ref_read_guard().await; + let repo = branched_repository(); + let ctx = standalone_context(repo.path()); + + let result = handle_branch_diff(&ctx, json!({ "base": "main", "head": "feature" })) + .await + .expect("an unadmitted executor returns a typed result, not an error"); + + assert_eq!(result.semantic_error(), Some(true)); + let message = result.failure_message().unwrap_or_default(); + assert!( + message.contains("branch diff main..feature is unavailable") + && message.contains("code_index_unavailable"), + "absent code-index authority must be named as such, got {message:?}" + ); + } + + /// Branch diff takes its head from the context's active branch when the + /// caller names only a base, and reports a typed argument error when + /// neither the arguments nor the context resolve one. + #[tokio::test] + async fn branch_diff_head_comes_from_the_context_active_branch() { + let _serialized = ref_read_guard().await; + let repo = branched_repository(); + + let without_branch = + handle_branch_diff(&standalone_context(repo.path()), json!({ "base": "main" })).await; + assert!(matches!( + without_branch, + Err(TraceDecayError::Config { .. }) + )); + + let with_branch = handle_branch_diff( + &standalone_context_on_branch(repo.path(), "feature"), + json!({ "base": "main" }), + ) + .await + .expect("the context's active branch resolves head"); + assert_eq!(with_branch.semantic_error(), Some(true)); + } + #[test] fn branch_search_continuation_is_reported_as_partial() { assert_eq!( @@ -737,7 +805,7 @@ mod tests { fn corruption_reset_required_has_a_stable_non_retryable_wire_code() { assert_eq!( branch_unavailable_wire( - crate::mcp::server::CodeIndexSearchUnavailableReasonV1::CorruptionResetRequired, + tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1::CorruptionResetRequired, ), ("index_corruption_reset_required", false), ); @@ -745,6 +813,7 @@ mod tests { #[tokio::test] async fn branch_ref_route_reports_capacity_without_queueing() { + let _serialized = ref_read_guard().await; let first = Arc::clone(&BRANCH_REF_READ_ADMISSION) .acquire_owned() .await @@ -769,6 +838,7 @@ mod tests { #[tokio::test] async fn cancelled_branch_ref_read_owns_worker_until_settlement() { + let _serialized = ref_read_guard().await; let cancellation = tracedecay_contracts::CancellationSignal::active("branch-ref-owned-settlement") .expect("cancellation"); diff --git a/crates/tracedecay/src/mcp/tools/handlers/git/context.rs b/crates/tracedecay-mcp/src/handlers/git/context.rs similarity index 93% rename from crates/tracedecay/src/mcp/tools/handlers/git/context.rs rename to crates/tracedecay-mcp/src/handlers/git/context.rs index 5ca0279e8b..7ef857715a 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/git/context.rs +++ b/crates/tracedecay-mcp/src/handlers/git/context.rs @@ -3,8 +3,8 @@ use super::super::dependency_hints; use super::affected::collect_verified_affected_test_files; use super::pr_context_cursor::{ - PrContextCursorBinding, decode_pr_context_cursor, encode_pr_context_cursor, - pr_context_cursor_authority, + PrContextCursorBinding, PrContextCursorComparison, decode_pr_context_cursor, + encode_pr_context_cursor, pr_context_cursor_authority, }; use super::shell::{ classify_file_role, default_pr_base_ref, git_changed_files, git_diff_file_changes, @@ -15,7 +15,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tracedecay_code_index::graph_projection::CodeGraphSymbolSummaryV1; use tracedecay_domain::{RelationEdgeKindV1, SymbolOccurrenceId}; -use tracedecay_global_db::RegisteredGlobalDbLeaseV1; use tracedecay_graph_query::VerifiedGraphQuery; const VERIFIED_GRAPH_MAX_SYMBOLS: usize = 500_000; @@ -223,12 +222,13 @@ where } #[hotpath::measure(future = true, label = "mcp.git.diff_context.total")] -pub(crate) async fn handle_diff_context( - cg: &TraceDecay, +pub async fn handle_diff_context( + ctx: &McpToolContext<'_>, graph: &VerifiedGraphQuery, args: Value, ) -> Result { require_object_args(&args, "tracedecay_diff_context")?; + ctx.verify_graph_scope(graph)?; let files = require_string_array_arg(&args, "files")?; let depth = clamped_depth_arg(&args, "depth", 2, 10); @@ -295,7 +295,7 @@ pub(crate) async fn handle_diff_context( )? ); let has_tests = |path: &str| { - crate::tracedecay::is_test_file(path) || files_with_inline_tests.contains(path) + tracedecay_code_index::is_test_file(path) || files_with_inline_tests.contains(path) }; for impacted_symbol in &impacted.impacted { let impacted_node = &impacted_symbol.summary; @@ -344,7 +344,7 @@ pub(crate) async fn handle_diff_context( ); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &output, touched_files, @@ -352,8 +352,8 @@ pub(crate) async fn handle_diff_context( } #[hotpath::measure(future = true, label = "mcp.git.changelog.total")] -pub(crate) async fn handle_changelog( - cg: &TraceDecay, +pub async fn handle_changelog( + ctx: &McpToolContext<'_>, graph: F, args: Value, ) -> Result @@ -380,7 +380,7 @@ where // that git itself refuses must report its typed git error rather than // whatever state the graph projection mount is in. let changes = { - let project_root = cg.project_root().to_path_buf(); + let project_root = ctx.project_root().to_path_buf(); let from_ref = from_ref.to_owned(); let to_ref = to_ref.to_owned(); match hotpath::future!( @@ -393,11 +393,12 @@ where { Ok(files) => files, Err(e) => { - return Ok(git_error_result(cg, &args, "diff", &e)); + return Ok(git_error_result(ctx, &args, "diff", &e)); } } }; let graph = &hotpath::future!(graph, label = "mcp.git.changelog.graph_admission").await?; + ctx.verify_graph_scope(graph)?; let changed_files: Vec = changes.iter().map(|change| change.path.clone()).collect(); let changed_paths = changed_files.iter().cloned().collect::>(); let graph_symbols = hotpath::measure_block!( @@ -456,7 +457,7 @@ where ); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &result, touched_files, @@ -464,11 +465,12 @@ where } #[hotpath::measure(future = true, label = "mcp.git.commit_context.total")] -pub(crate) async fn handle_commit_context( - cg: &TraceDecay, +pub async fn handle_commit_context( + ctx: &McpToolContext<'_>, graph: &VerifiedGraphQuery, args: Value, ) -> Result { + ctx.verify_graph_scope(graph)?; let staged_only = args .get("staged_only") .and_then(serde_json::Value::as_bool) @@ -477,7 +479,7 @@ pub(crate) async fn handle_commit_context( // gix status classification walks the whole worktree; keep it off the // request runtime's workers so the carried dispatch deadline can preempt it. let changed_files = { - let project_root = cg.project_root().to_path_buf(); + let project_root = ctx.project_root().to_path_buf(); match hotpath::future!( blocking_git_span("status", move || { git_changed_files(&project_root, staged_only) @@ -488,13 +490,13 @@ pub(crate) async fn handle_commit_context( { Ok(files) => files, Err(e) => { - return Ok(git_error_result(cg, &args, "status", &e)); + return Ok(git_error_result(ctx, &args, "status", &e)); } } }; if changed_files.is_empty() { - let project_root = cg.project_root().to_path_buf(); + let project_root = ctx.project_root().to_path_buf(); let recent_commits = match hotpath::future!( blocking_git_span("rev-walk", move || git_recent_commits(&project_root, 5)), label = "mcp.git.commit_context.recent_commits" @@ -503,7 +505,7 @@ pub(crate) async fn handle_commit_context( { Ok(commits) => commits, Err(e) => { - return Ok(git_error_result(cg, &args, "log", &e)); + return Ok(git_error_result(ctx, &args, "log", &e)); } }; let output = hotpath::measure_block!( @@ -517,7 +519,7 @@ pub(crate) async fn handle_commit_context( }) ); return Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &output, vec![], @@ -586,7 +588,7 @@ pub(crate) async fn handle_commit_context( }; let recent_commits = { - let project_root = cg.project_root().to_path_buf(); + let project_root = ctx.project_root().to_path_buf(); match hotpath::future!( blocking_git_span("rev-walk", move || git_recent_commits(&project_root, 5)), label = "mcp.git.commit_context.recent_commits" @@ -595,7 +597,7 @@ pub(crate) async fn handle_commit_context( { Ok(commits) => commits, Err(e) => { - return Ok(git_error_result(cg, &args, "log", &e)); + return Ok(git_error_result(ctx, &args, "log", &e)); } } }; @@ -613,7 +615,7 @@ pub(crate) async fn handle_commit_context( ); Ok(generic_tool_result( - Some(cg.project_root()), + Some(ctx.project_root()), &args, &output, changed_files, @@ -797,21 +799,18 @@ fn graph_enrichment_is_transient(error: &TraceDecayError) -> bool { } #[hotpath::measure(future = true, label = "mcp.pr_context.total")] -pub(crate) async fn handle_pr_context( - cg: &TraceDecay, +pub async fn handle_pr_context( + ctx: &McpToolContext<'_>, graph: F, args: Value, - deadline: Option, - cancellation: Option, - registered_project_session_db: Option, ) -> Result where F: Future>, { require_object_args(&args, "tracedecay_pr_context")?; let controls = PrContextControls { - deadline, - cancellation, + deadline: ctx.deadline().cloned(), + cancellation: ctx.cancellation().cloned(), }; controls.checkpoint()?; let total_started = std::time::Instant::now(); @@ -819,7 +818,7 @@ where let base = args .get("base_ref") .and_then(|v| v.as_str()) - .map_or_else(|| default_pr_base_ref(cg.project_root()), str::to_owned); + .map_or_else(|| default_pr_base_ref(ctx.project_root()), str::to_owned); let head = args .get("head_ref") .and_then(|v| v.as_str()) @@ -827,7 +826,7 @@ where let stage_started = std::time::Instant::now(); let comparison = { - let project_root = cg.project_root().to_path_buf(); + let project_root = ctx.project_root().to_path_buf(); let base_ref = base.clone(); let head_ref = head.to_owned(); match hotpath::future!( @@ -846,7 +845,7 @@ where Ok(comparison) => comparison, Err(e) => { controls.checkpoint()?; - return Ok(git_error_result(cg, &args, "diff", &e)); + return Ok(git_error_result(ctx, &args, "diff", &e)); } } }; @@ -885,7 +884,10 @@ where let stage_started = std::time::Instant::now(); let graph = match hotpath::future!(graph, label = "mcp.pr_context.graph_admission").await { - Ok(graph) => graph, + Ok(graph) => { + ctx.verify_graph_scope(&graph)?; + graph + } Err(error) if encoded_cursor.is_some() || !graph_enrichment_is_transient(&error) => { return Err(error); } @@ -893,7 +895,7 @@ where stage_timings.insert("graph".to_owned(), json!(elapsed_micros(stage_started))); let test_files_changed = changes .iter() - .filter(|change| crate::tracedecay::is_test_file(&change.path)) + .filter(|change| tracedecay_code_index::is_test_file(&change.path)) .map(|change| change.path.clone()) .collect::>(); let output = hotpath::measure_block!( @@ -957,33 +959,41 @@ where timings = %timing_value, "PR context returned Git evidence while graph enrichment was unavailable" ); - return Ok( - generic_tool_result(Some(cg.project_root()), &args, &output, changed_files) - .with_internal_analytics(json!({ - "stage_timings_us": stage_timings, - "symbol_coverage": output["symbol_page"], - })), - ); + return Ok(generic_tool_result( + Some(ctx.project_root()), + &args, + &output, + changed_files, + ) + .with_internal_analytics(json!({ + "stage_timings_us": stage_timings, + "symbol_coverage": output["symbol_page"], + }))); } }; stage_timings.insert("graph".to_owned(), json!(elapsed_micros(stage_started))); let graph_generation = graph.generation().as_str().to_owned(); - let project_root = cg.project_root().to_string_lossy(); - let cursor_binding = PrContextCursorBinding { - protocol: "tracedecay.pr-context.cursor.v1", - project_root: &project_root, - base_oid: &base_oid, - head_oid: &head_oid, - merge_base: &merge_base, - graph_generation: &graph_generation, - maximum_symbols, - changes: &changes, - }; - let cursor_authority = match registered_project_session_db.as_deref() { - Some(session_db) => Some( + // Byte-exact worktree identity: a lossy string would let two distinct + // non-UTF-8 roots mint interchangeable cursors. + let project_root = + tracedecay_runtime_core::os_str_bytes::native_os_str_bytes(ctx.project_root().as_os_str()); + let cursor_binding = PrContextCursorBinding::new( + ctx, + &project_root, + PrContextCursorComparison { + base_oid: &base_oid, + head_oid: &head_oid, + merge_base: &merge_base, + graph_generation: &graph_generation, + maximum_symbols, + changes: &changes, + }, + ); + let cursor_authority = match ctx.authorized_project_session_db() { + Some(_) => Some( hotpath::future!( - pr_context_cursor_authority(session_db, &cursor_binding), + pr_context_cursor_authority(ctx, &cursor_binding), label = "mcp.pr_context.cursor_authority" ) .await?, @@ -1037,7 +1047,7 @@ where .collect(); let added_path_set: HashSet<&str> = added_paths.iter().map(String::as_str).collect(); for change in &changes { - if crate::tracedecay::is_test_file(&change.path) + if tracedecay_code_index::is_test_file(&change.path) || files_with_inline_tests.contains(&change.path) { test_files_changed.push(change.path.clone()); @@ -1144,7 +1154,7 @@ where for impacted in &impact.nodes { let path = symbol_path(impacted)?; if !changed_paths.contains(path) - && (crate::tracedecay::is_test_file(path) || files_with_inline_tests.contains(path)) + && (tracedecay_code_index::is_test_file(path) || files_with_inline_tests.contains(path)) { affected_tests.insert(path.to_owned()); } @@ -1243,7 +1253,7 @@ where ); Ok( - generic_tool_result(Some(cg.project_root()), &args, &output, changed_files) + generic_tool_result(Some(ctx.project_root()), &args, &output, changed_files) .with_internal_analytics(json!({ "stage_timings_us": stage_timings, "symbol_coverage": output["symbol_page"], diff --git a/crates/tracedecay-mcp/src/handlers/git/mod.rs b/crates/tracedecay-mcp/src/handlers/git/mod.rs new file mode 100644 index 0000000000..6ceef98175 --- /dev/null +++ b/crates/tracedecay-mcp/src/handlers/git/mod.rs @@ -0,0 +1,185 @@ +//! Git-backed tool handlers. +//! +//! `shell` owns every `git` subprocess call; the other siblings turn its output +//! into tool payloads. This module holds the shared imports (siblings pick them +//! up through `use super::*`), the two shapes `shell` returns, and the argument +//! helpers used across siblings. +//! +//! Every authority the family reads arrives through [`McpToolContext`]: the +//! admitted project route, the caller's deadline and cancellation, the +//! registered project session store that authenticates PR-context cursors, +//! and the daemon-owned code-index executors. Nothing here opens a store, +//! resolves a project, or mints an authorization for itself. + +mod affected; +mod branch; +mod context; +mod pr_context_cursor; +mod shell; +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod test_support; + +pub use affected::handle_affected; +pub use branch::{handle_branch_diff, handle_branch_list, handle_branch_search}; +pub use context::{ + handle_changelog, handle_commit_context, handle_diff_context, handle_pr_context, +}; + +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::pin::Pin; + +use serde_json::{Value, json}; + +use super::support::{generic_tool_result, require_object_args, unique_file_paths}; +use crate::ToolResult; +use crate::tool_context::McpToolContext; +use tracedecay_domain::errors::{Result, TraceDecayError}; + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +struct GitFileChange { + path: String, + status: &'static str, +} + +struct GitPrComparison { + base_oid: String, + head_oid: String, + merge_base: String, + changes: Vec, + commits: Vec, +} + +fn git_error_result( + ctx: &McpToolContext<'_>, + args: &Value, + operation: &str, + message: &str, +) -> ToolResult { + let output = json!({ + "error": { + "kind": "git", + "operation": operation, + "message": message, + } + }); + generic_tool_result(Some(ctx.project_root()), args, &output, vec![]) + .with_semantic_error(true) + .with_failure_message(message) +} + +/// Typed result returned when a git-dispatched tool exhausts the dispatch +/// deadline the daemon carried into `dispatch_git_tools`. +/// +/// Git tree walks, revwalks, diffs, and the branch-add index build are +/// unbounded on pathological or diverged inputs. When the carried deadline +/// elapses the caller must receive the same shaped, semantic error every other +/// git failure surfaces — never a bare hang or a panic. +pub fn git_dispatch_deadline_result(ctx: &McpToolContext<'_>, tool_name: &str) -> ToolResult { + let message = + format!("git tool '{tool_name}' exceeded its dispatch deadline and was cancelled"); + git_error_result(ctx, &json!({ "tool": tool_name }), "deadline", &message) +} + +fn require_string_array_arg(args: &Value, name: &str) -> Result> { + args.get(name) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) + .collect() + }) + .ok_or_else(|| TraceDecayError::Config { + message: format!("missing required parameter: {name} (array of strings)"), + }) +} + +fn clamped_depth_arg(args: &Value, name: &str, default: usize, max: usize) -> usize { + args.get(name) + .and_then(serde_json::Value::as_u64) + .map_or(default, |v| v.min(max as u64) as usize) +} + +fn matches_test_file( + path: &str, + custom_glob: Option<&glob::Pattern>, + files_with_inline_tests: &HashSet, +) -> bool { + if let Some(glob) = custom_glob { + glob.matches(path) + } else { + tracedecay_code_index::is_test_file(path) || files_with_inline_tests.contains(path) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::test_support::{branched_repository, standalone_context}; + use super::*; + + /// A terminal graph failure must reach the caller as an error. Degrading + /// it into a partial PR context would publish git evidence while claiming + /// the graph enrichment simply found nothing. + #[tokio::test] + async fn pr_context_propagates_terminal_graph_failures() { + let repo = branched_repository(); + let ctx = standalone_context(repo.path()); + let terminal_errors = [ + tracedecay_graph_query::map_code_graph_read_runtime_error( + tracedecay_graph_query::CodeGraphReadError::Cancelled, + ), + tracedecay_graph_query::map_code_graph_read_runtime_error( + tracedecay_graph_query::CodeGraphReadError::Denied, + ), + tracedecay_graph_query::map_code_graph_read_runtime_error( + tracedecay_graph_query::CodeGraphReadError::Corrupt { + detail: "corrupt projection".to_owned(), + }, + ), + tracedecay_graph_query::map_code_graph_read_runtime_error( + tracedecay_graph_query::CodeGraphReadError::ResetRequired { + detail: "generation reset required".to_owned(), + }, + ), + tracedecay_graph_query::map_code_graph_read_runtime_error( + tracedecay_graph_query::CodeGraphReadError::InvalidRequest { + detail: "invalid graph request".to_owned(), + }, + ), + TraceDecayError::Config { + message: "graph configuration is invalid".to_owned(), + }, + ]; + + for error in terminal_errors { + let detail = error.to_string(); + let result = context::handle_pr_context( + &ctx, + async move { Err::(error) }, + json!({"base_ref": "main", "head_ref": "HEAD", "format": "json"}), + ) + .await; + assert!( + result.is_err(), + "terminal graph failure must not become partial success: {detail}" + ); + } + } + + /// An elapsed dispatch deadline surfaces as the same shaped semantic git + /// failure every other git error uses, so a caller never sees a bare hang. + #[test] + fn an_elapsed_dispatch_deadline_is_a_typed_semantic_failure() { + let result = git_dispatch_deadline_result( + &standalone_context(std::path::Path::new("/unread")), + "tracedecay_pr_context", + ); + + assert_eq!(result.semantic_error(), Some(true)); + let message = result.failure_message().unwrap_or_default(); + assert!(message.contains("tracedecay_pr_context"), "got {message:?}"); + assert!(message.contains("dispatch deadline"), "got {message:?}"); + } +} diff --git a/crates/tracedecay-mcp/src/handlers/git/pr_context_cursor.rs b/crates/tracedecay-mcp/src/handlers/git/pr_context_cursor.rs new file mode 100644 index 0000000000..4a692be8f5 --- /dev/null +++ b/crates/tracedecay-mcp/src/handlers/git/pr_context_cursor.rs @@ -0,0 +1,887 @@ +use super::*; +use serde::{Deserialize, Serialize}; +use tracedecay_domain::{ + ManifestDigest, RetrievalGrainV1, SessionId, SymbolOccurrenceId, TemporalModeV1, + canonical_sha256, +}; +use tracedecay_global_db::RegisteredGlobalDb; +use tracedecay_session_temporal_store::GlobalDbCursorKeyProvider; +use tracedecay_temporal_query::cursor::{CursorError, StableSortKey, encode_cursor, verify_cursor}; +use tracedecay_temporal_query::ports::SessionCursorAuthenticator; +use tracedecay_temporal_query::ports::{ + BindingDigest, KernelVersions, TemporalExecutionSnapshot, TemporalSnapshotRequest, + TemporalWatermarks, +}; +use tracedecay_temporal_query::resolution::ValidatedAuthorization; + +const PR_CONTEXT_CURSOR_SESSION: &str = "session.daemon.pr-context"; + +/// Canonical identity of the checkout a cursor was minted for. +/// +/// This is `TraceDecay`'s own resolved project/repository/worktree identity, +/// not a locally derived name: the same authority every other scoped read +/// binds to, carried verbatim so a cursor cannot travel between projects. +/// +/// It is deliberately the three fields +/// [`tracedecay_contracts::ResolvedScope::identifies_same_checkout`] compares, +/// and not the scope digest. The digest also covers the git reference the +/// scope was resolved under, which changes on every ordinary branch switch — +/// binding cursors to it would invalidate in-flight pagination whenever HEAD +/// moved, while proving nothing about which checkout is being read. +#[derive(Clone, Copy, Serialize)] +pub(super) struct PrContextCursorScope<'a> { + pub project_id: &'a str, + pub repository_id: &'a str, + pub worktree_id: &'a str, +} + +impl<'a> PrContextCursorScope<'a> { + fn from_resolved(scope: &'a tracedecay_contracts::ResolvedScope) -> Self { + Self { + project_id: scope.project_id.as_str(), + repository_id: scope.repository_id.as_str(), + worktree_id: scope.worktree_id.as_str(), + } + } +} + +/// The logical shard the store that signs this cursor was opened for. +/// +/// Cursor denial is a store-level outcome: a cursor is "not yours" when it was +/// minted against another store, and that store's own registered shard is what +/// names it. Carrying the shard makes the denial hold even between two stores +/// that happen to serve the same checkout. +/// +/// The shard's logical scope is carried whole through its canonical +/// serialization rather than reduced to the project it mentions. Reducing it +/// would give a project's `Project`, `ProjectSessions`, and `Code` shards one +/// identity, so cursors minted against different stores for one project would +/// verify interchangeably. +#[derive(Clone, Copy, Serialize)] +pub(super) struct PrContextCursorStore<'a> { + pub brain_id: &'a str, + pub profile_id: &'a str, + pub scope: &'a tracedecay_store::StoreShardScopeV1, +} + +#[derive(Serialize)] +pub(super) struct PrContextCursorBinding<'a> { + pub protocol: &'static str, + /// The admitted checkout, when the daemon's route resolved one. A cursor + /// minted under one project's scope cannot verify under another's. + pub scope: Option>, + /// The registered store that signs and verifies this cursor. + pub store: Option>, + /// The worktree root exactly as the filesystem stores it. + /// + /// `Path::to_string_lossy` maps every unpaired byte onto the same + /// replacement character, so two genuinely different non-UTF-8 checkouts + /// would flatten to one identical binding string and mint interchangeable + /// cursors. The native OS bytes are the filesystem's own identity and + /// distinguish those roots. + pub project_root: &'a [u8], + pub base_oid: &'a str, + pub head_oid: &'a str, + pub merge_base: &'a str, + pub graph_generation: &'a str, + pub maximum_symbols: usize, + pub changes: &'a [GitFileChange], +} + +impl<'a> PrContextCursorBinding<'a> { + /// Binds one PR comparison to the checkout this call is admitted for. + pub fn new( + ctx: &'a McpToolContext<'_>, + project_root: &'a [u8], + comparison: PrContextCursorComparison<'a>, + ) -> Self { + Self { + protocol: "tracedecay.pr-context.cursor.v2", + scope: ctx + .admitted_scope() + .map(PrContextCursorScope::from_resolved), + store: ctx + .authorized_project_session_db() + .map(|(lease, _)| PrContextCursorStore::from_shard(&lease.binding().shard_id)), + project_root, + base_oid: comparison.base_oid, + head_oid: comparison.head_oid, + merge_base: comparison.merge_base, + graph_generation: comparison.graph_generation, + maximum_symbols: comparison.maximum_symbols, + changes: comparison.changes, + } + } + + /// Digest of the checkout this cursor belongs to, and nothing else. + /// + /// A cursor whose identity digest differs is another checkout's, however + /// similar its comparison looks. + fn identity_digest(&self) -> Result { + canonical_sha256(&( + "tracedecay.pr-context.cursor.identity.v1", + self.protocol, + &self.scope, + &self.store, + self.project_root, + )) + .map_err(|error| TraceDecayError::Config { + message: format!("failed to bind PR context cursor identity: {error}"), + }) + } + + /// Digest of the comparison this page was frozen against. + fn request_digest(&self) -> Result { + canonical_sha256(self).map_err(|error| TraceDecayError::Config { + message: format!("failed to bind PR context cursor: {error}"), + }) + } +} + +impl<'a> PrContextCursorStore<'a> { + fn from_shard(shard: &'a tracedecay_store::StoreShardIdV1) -> Self { + Self { + brain_id: shard.brain_id.as_str(), + profile_id: shard.profile_id.as_str(), + scope: &shard.scope, + } + } +} + +/// The comparison a page of PR context is frozen against. +#[derive(Clone, Copy)] +pub(super) struct PrContextCursorComparison<'a> { + pub base_oid: &'a str, + pub head_oid: &'a str, + pub merge_base: &'a str, + pub graph_generation: &'a str, + pub maximum_symbols: usize, + pub changes: &'a [GitFileChange], +} + +/// Why an offered PR-context cursor cannot be honored. +/// +/// The distinction is the caller's: a stale cursor means "restart this +/// pagination", while a denied one means "this cursor is not yours". Flattening +/// both into one opaque config error hides an attempted cross-scope read. +fn pr_context_cursor_refusal(error: &CursorError) -> TraceDecayError { + let (reason_code, detail) = match error { + // Authentication and binding failures: the cursor verifies as some + // other request's, or as nobody's. Either way this request may not + // continue from it. + CursorError::Tampered + | CursorError::KeyIdMismatch + | CursorError::KeyVersionMismatch + | CursorError::UnknownOrExpiredKey + | CursorError::InvalidKeyMaterial + | CursorError::WrongAccess + | CursorError::RootMismatch + | CursorError::SessionMismatch => ( + "pr_context_cursor_denied", + "PR context cursor was issued for a different project, store, or worktree root", + ), + // Everything else means the snapshot this cursor froze has moved on, + // so the page set it names no longer exists. + CursorError::WrongRequest + | CursorError::Malformed + | CursorError::Expired + | CursorError::KeyUnavailable + | CursorError::FilterMismatch + | CursorError::TemporalModeMismatch + | CursorError::GrainMismatch + | CursorError::SchemaMismatch + | CursorError::RankingMismatch + | CursorError::ConfigurationMismatch + | CursorError::GenerationMismatch + | CursorError::ParticipantManifestMismatch + | CursorError::EpochMismatch + | CursorError::CandidateCohortMismatch + | CursorError::SourceWatermarkMismatch + | CursorError::ProjectionWatermarkMismatch + | CursorError::IndexWatermarkMismatch + | CursorError::SummaryWatermarkMismatch + | CursorError::SortKeyMismatch => ( + "pr_context_cursor_invalid", + "PR context cursor no longer matches this comparison; restart pagination", + ), + }; + // Neither outcome is retryable with the same cursor: a denied cursor never + // becomes this request's, and a stale one needs a fresh first page. + TraceDecayError::project_route(reason_code, false, format!("{detail}: {error}")) +} + +#[derive(Serialize, Deserialize)] +struct PrContextCursorKey<'a> { + symbol_occurrence_id: &'a str, + impact_nodes_admitted: usize, + direct_call_edges_admitted: usize, + impact_bytes_admitted: usize, +} + +#[derive(Debug)] +pub(super) struct PrContextCursorPosition { + pub after: SymbolOccurrenceId, + pub impact_nodes_admitted: usize, + pub direct_call_edges_admitted: usize, + pub impact_bytes_admitted: usize, +} + +/// Opens the cursor authority for this call's admitted project store. +/// +/// The signing key is the store's own pre-provisioned cursor key, so a cursor +/// minted here can only be verified by the same store — that is what keeps a +/// foreign store's cursor from continuing this pagination. Authorization is +/// read off the admitted binding rather than asserted locally: with no +/// admitted store there is no key and no snapshot. +#[hotpath::measure(label = "mcp.git.cursor.authority")] +pub(super) async fn pr_context_cursor_authority( + ctx: &McpToolContext<'_>, + binding: &PrContextCursorBinding<'_>, +) -> Result<(TemporalExecutionSnapshot, GlobalDbCursorKeyProvider)> { + let Some((session_db, authorization)) = ctx.authorized_project_session_db() else { + return Err(TraceDecayError::project_route( + "pr_context_cursor_authority_unavailable", + true, + "no admitted project session store can authenticate a PR context cursor", + )); + }; + // The root's verdict decides. An unauthorized store is a denial, not a + // missing capability: the store is right there and the caller may not read + // it, and retrying the same request cannot change that. + if !authorization.is_authorized() { + return Err(TraceDecayError::project_route( + "pr_context_cursor_denied", + false, + "this request is not authorized to read the admitted project session store", + )); + } + let session_db: &RegisteredGlobalDb = session_db; + let authenticator = hotpath::future!( + session_db.load_preprovisioned_session_cursor_key_provider_result(), + label = "mcp.git.cursor.key_provider" + ) + .await + .map_err(|error| { + TraceDecayError::project_route( + "pr_context_cursor_authority_unavailable", + true, + format!("pre-provisioned PR context cursor key is unavailable: {error}"), + ) + })?; + let key = authenticator.active_key_ref().clone(); + let snapshot = pr_context_cursor_snapshot(binding, key, authorization)?; + Ok((snapshot, authenticator)) +} + +/// Binds one PR comparison into the snapshot every cursor authenticates +/// against. +/// +/// Identity and comparison are hashed into *separate* bindings on purpose. The +/// root and access digests carry only the admitted scope and the byte-exact +/// worktree root, so `verify_cursor` reports a cursor from another project or +/// checkout as a root/access mismatch; the request digest carries the frozen +/// comparison, so a comparison that moved on reports as a changed request +/// instead. Collapsing both into one digest makes those two outcomes +/// indistinguishable, and the caller cannot tell "restart pagination" from +/// "this cursor is not yours". +fn pr_context_cursor_snapshot( + binding: &PrContextCursorBinding<'_>, + key: tracedecay_domain::SignedCursorKeyRefV1, + authorization: ValidatedAuthorization, +) -> Result { + let (identity_digest, digest) = hotpath::measure_block!( + "mcp.git.cursor.binding_digest", + (binding.identity_digest()?, binding.request_digest()?) + ); + let graph_digest = canonical_sha256(&( + "tracedecay.pr-context.graph-generation.v1", + binding.graph_generation, + )) + .map_err(|error| TraceDecayError::Config { + message: format!("failed to bind PR context graph generation: {error}"), + })?; + let graph_generation_hex = graph_digest + .as_str() + .strip_prefix("sha256:") + .and_then(|hex| hex.get(..16)) + .ok_or_else(|| TraceDecayError::Config { + message: "invalid PR context graph generation digest".to_owned(), + })?; + let graph_generation = u64::from_str_radix(graph_generation_hex, 16) + .map_err(|error| TraceDecayError::Config { + message: format!("invalid PR context graph generation watermark: {error}"), + })? + .max(1); + let request = TemporalSnapshotRequest::new( + SessionId::new(PR_CONTEXT_CURSOR_SESSION).map_err(|error| TraceDecayError::Config { + message: format!("invalid PR context cursor session: {error}"), + })?, + identity_digest.as_str(), + digest.as_str(), + identity_digest.as_str(), + TemporalModeV1::Current, + RetrievalGrainV1::Occurrence, + ) + .map_err(|error| TraceDecayError::Config { + message: format!("invalid PR context cursor binding: {error}"), + })?; + let configuration_digest = BindingDigest::new("configuration_digest", digest.as_str()) + .map_err(|error| TraceDecayError::Config { + message: format!("invalid PR context cursor configuration: {error}"), + })?; + TemporalExecutionSnapshot::new_authorized( + request, + TemporalWatermarks { + generation: graph_generation, + source: 1, + projection: 1, + index: 1, + summary: 1, + }, + KernelVersions { + schema: 1, + ranking: 1, + configuration_digest, + }, + Some(key), + authorization, + ) + .map_err(|error| TraceDecayError::Config { + message: format!("invalid PR context cursor snapshot: {error}"), + }) +} + +#[hotpath::measure(label = "mcp.git.cursor.decode")] +pub(super) fn decode_pr_context_cursor( + encoded: &str, + snapshot: &TemporalExecutionSnapshot, + authenticator: &(impl SessionCursorAuthenticator + ?Sized), +) -> Result { + let sort_key = verify_cursor(encoded, snapshot, authenticator) + .map_err(|error| pr_context_cursor_refusal(&error))?; + let key: PrContextCursorKey<'_> = + serde_json::from_str(&sort_key.stable_id).map_err(|_| TraceDecayError::Config { + message: "invalid PR context cursor key".to_owned(), + })?; + Ok(PrContextCursorPosition { + after: SymbolOccurrenceId::new(key.symbol_occurrence_id.to_owned()).map_err(|error| { + TraceDecayError::Config { + message: format!("invalid PR context symbol cursor: {error}"), + } + })?, + impact_nodes_admitted: key.impact_nodes_admitted, + direct_call_edges_admitted: key.direct_call_edges_admitted, + impact_bytes_admitted: key.impact_bytes_admitted, + }) +} + +#[hotpath::measure(label = "mcp.git.cursor.encode")] +pub(super) fn encode_pr_context_cursor( + after: &SymbolOccurrenceId, + impact_nodes_admitted: usize, + direct_call_edges_admitted: usize, + impact_bytes_admitted: usize, + snapshot: &TemporalExecutionSnapshot, + authenticator: &(impl SessionCursorAuthenticator + ?Sized), +) -> Result { + let stable_id = serde_json::to_string(&PrContextCursorKey { + symbol_occurrence_id: after.as_str(), + impact_nodes_admitted, + direct_call_edges_admitted, + impact_bytes_admitted, + }) + .map_err(|error| TraceDecayError::Config { + message: format!("failed to encode PR context cursor key: {error}"), + })?; + encode_cursor( + snapshot, + &StableSortKey { + normalized_score_micros: 0, + knowledge_at_micros: 0, + stable_id, + }, + authenticator, + ) + .map_err(|error| TraceDecayError::Config { + message: format!("failed to issue PR context cursor: {error}"), + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt as _; + + use super::*; + use crate::tool_context::{AdmittedProjectStore, McpToolBinding, RequestControls}; + use tracedecay_domain::{SessionCursorKeyIdV1, SessionCursorVersionV1, SignedCursorKeyRefV1}; + use tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime; + use tracedecay_temporal_query::ports::InMemoryCursorAuthenticator; + + fn cursor_key() -> SignedCursorKeyRefV1 { + SignedCursorKeyRefV1 { + key_id: SessionCursorKeyIdV1::new("key.pr-context.fixture").expect("key id"), + version: SessionCursorVersionV1::new(1).expect("key version"), + } + } + + fn authenticator() -> InMemoryCursorAuthenticator { + InMemoryCursorAuthenticator::new(cursor_key(), vec![7_u8; 32]).expect("in-memory key") + } + + fn binding_for<'a>( + root: &'a [u8], + scope: Option>, + changes: &'a [GitFileChange], + ) -> PrContextCursorBinding<'a> { + binding_bound_to(root, scope, None, changes) + } + + /// The logical shard a registered project session store reports. + fn session_shard(project: &str) -> tracedecay_store::StoreShardScopeV1 { + tracedecay_store::StoreShardScopeV1::ProjectSessions { + project_id: tracedecay_domain::ProjectId::new(project).expect("project id"), + } + } + + fn binding_bound_to<'a>( + root: &'a [u8], + scope: Option>, + store: Option>, + changes: &'a [GitFileChange], + ) -> PrContextCursorBinding<'a> { + PrContextCursorBinding { + protocol: "tracedecay.pr-context.cursor.v2", + scope, + store, + project_root: root, + base_oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + head_oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + merge_base: "cccccccccccccccccccccccccccccccccccccccc", + graph_generation: "generation.pr-context.fixture", + maximum_symbols: 25, + changes, + } + } + + fn snapshot_for(binding: &PrContextCursorBinding<'_>) -> TemporalExecutionSnapshot { + pr_context_cursor_snapshot(binding, cursor_key(), ValidatedAuthorization::Authorized) + .expect("snapshot binds") + } + + fn position() -> (SymbolOccurrenceId, usize, usize, usize) { + ( + SymbolOccurrenceId::new("occurrence.pr-context.fixture".to_owned()) + .expect("occurrence id"), + 11, + 22, + 33, + ) + } + + /// A real resolved scope, minted through the same contract every admitted + /// read binds to, so these tests exercise canonical identity rather than a + /// hand-rolled stand-in. + fn resolved(project: &str, reference: Option<&str>) -> tracedecay_contracts::ResolvedScope { + tracedecay_contracts::ResolvedScope::new( + tracedecay_domain::ProjectId::new(project.to_owned()).expect("project id"), + tracedecay_domain::RepositoryId::new("repository.pr-context".to_owned()) + .expect("repository id"), + tracedecay_domain::WorktreeId::new("worktree.pr-context".to_owned()) + .expect("worktree id"), + reference.map(|value| tracedecay_domain::RefId::new(value).expect("reference")), + ) + .expect("resolved scope") + } + + /// A cursor issued for one comparison must decode back to the exact page + /// position it froze, or pagination silently restarts or skips symbols. + #[test] + fn a_cursor_round_trips_to_its_own_page_position() { + let changes = Vec::new(); + let scope = resolved("project.a", None); + let binding = binding_for( + b"/projects/round-trip", + Some(PrContextCursorScope::from_resolved(&scope)), + &changes, + ); + let snapshot = snapshot_for(&binding); + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &snapshot, &authenticator) + .expect("cursor issues"); + let decoded = decode_pr_context_cursor(&encoded, &snapshot, &authenticator) + .expect("its own cursor decodes"); + + assert_eq!(decoded.after.as_str(), after.as_str()); + assert_eq!(decoded.impact_nodes_admitted, nodes); + assert_eq!(decoded.direct_call_edges_admitted, edges); + assert_eq!(decoded.impact_bytes_admitted, bytes); + } + + /// Two worktree roots that differ only in bytes no UTF-8 string can + /// represent must not share a cursor identity. + /// + /// `Path::to_string_lossy` maps every unpaired byte onto U+FFFD, so both + /// roots below collapse to the same string; a cursor bound to that string + /// would verify against either checkout. + #[cfg(unix)] + #[test] + fn distinct_non_utf8_roots_cannot_share_a_cursor() { + let left = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/projects/a\xff")); + let right = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/projects/a\xfe")); + assert_eq!( + left.to_string_lossy(), + right.to_string_lossy(), + "fixture must be a pair that a lossy conversion would merge" + ); + + let changes = Vec::new(); + let left_bytes = + tracedecay_runtime_core::os_str_bytes::native_os_str_bytes(left.as_os_str()); + let right_bytes = + tracedecay_runtime_core::os_str_bytes::native_os_str_bytes(right.as_os_str()); + let left_snapshot = snapshot_for(&binding_for(&left_bytes, None, &changes)); + let right_snapshot = snapshot_for(&binding_for(&right_bytes, None, &changes)); + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &left_snapshot, &authenticator) + .expect("cursor issues"); + + let refusal = decode_pr_context_cursor(&encoded, &right_snapshot, &authenticator) + .expect_err("a cursor from a different root must not decode"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_denied"), + "got {refusal}" + ); + } + + /// A cursor minted under one project's admitted scope must be denied under + /// another's, even when the root and the compared commits are identical. + #[test] + fn a_cursor_from_a_foreign_project_scope_is_denied() { + let changes = Vec::new(); + let root = b"/projects/shared"; + let my_scope = resolved("project.mine", None); + let their_scope = resolved("project.theirs", None); + let mine = snapshot_for(&binding_for( + root, + Some(PrContextCursorScope::from_resolved(&my_scope)), + &changes, + )); + let theirs = snapshot_for(&binding_for( + root, + Some(PrContextCursorScope::from_resolved(&their_scope)), + &changes, + )); + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &theirs, &authenticator) + .expect("cursor issues"); + + let refusal = decode_pr_context_cursor(&encoded, &mine, &authenticator) + .expect_err("a foreign project's cursor must not decode"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_denied"), + "got {refusal}" + ); + } + + /// A cursor signed by a different store's key fails authentication, which + /// is a denial rather than a stale page: the store, not the comparison, + /// is what does not match. + #[test] + fn a_cursor_from_a_foreign_store_key_is_denied() { + let changes = Vec::new(); + let scope = resolved("project.a", None); + let binding = binding_for( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&scope)), + &changes, + ); + let snapshot = snapshot_for(&binding); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &snapshot, &authenticator()) + .expect("cursor issues"); + + let foreign_store = + InMemoryCursorAuthenticator::new(cursor_key(), vec![9_u8; 32]).expect("foreign key"); + let refusal = decode_pr_context_cursor(&encoded, &snapshot, &foreign_store) + .expect_err("a foreign store's key must not verify"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_denied"), + "got {refusal}" + ); + } + + /// A comparison that moved on is a stale cursor, not a denied one: the + /// caller should restart pagination rather than be told the cursor is + /// someone else's. + #[test] + fn a_cursor_from_a_moved_comparison_is_invalid() { + let changes = vec![GitFileChange { + path: "src/lib.rs".to_owned(), + status: "modified", + }]; + let empty = Vec::new(); + let scope = resolved("project.a", None); + let before = snapshot_for(&binding_for( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&scope)), + &empty, + )); + let after_change = snapshot_for(&binding_for( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&scope)), + &changes, + )); + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &before, &authenticator) + .expect("cursor issues"); + + let refusal = decode_pr_context_cursor(&encoded, &after_change, &authenticator) + .expect_err("a moved comparison must refuse its old cursor"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_invalid"), + "got {refusal}" + ); + } + + /// Switching branches does not move the checkout, so a page opened on one + /// branch must continue on another. Only the reference differs between the + /// two scopes below, and the reference-sensitive `scope_digest` differs + /// with it — binding cursor identity to that digest would break pagination + /// on every ordinary branch switch. + #[test] + fn a_cursor_survives_a_branch_switch_on_the_same_checkout() { + let changes = Vec::new(); + let registered = resolved("project.a", Some("refs/heads/main")); + let switched = resolved("project.a", Some("refs/heads/feature")); + assert_ne!( + registered.scope_digest, switched.scope_digest, + "fixture must differ in the reference-sensitive digest" + ); + assert!(registered.identifies_same_checkout(&switched)); + + let opened = snapshot_for(&binding_for( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(®istered)), + &changes, + )); + let continued = snapshot_for(&binding_for( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&switched)), + &changes, + )); + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &opened, &authenticator) + .expect("cursor issues"); + + let decoded = decode_pr_context_cursor(&encoded, &continued, &authenticator) + .expect("the same checkout on another branch must continue its own pagination"); + assert_eq!(decoded.after.as_str(), after.as_str()); + } + + /// The root's verdict decides whether this request may read the admitted + /// store, and an unauthorized verdict must deny rather than degrade into a + /// missing capability. The store below is a real registered project store, + /// so the denial comes from the carried authorization and not from an + /// absent authority. + #[tokio::test] + async fn an_unauthorized_store_denies_the_cursor_authority() { + let home = tempfile::tempdir().expect("temp home"); + let project_id = + tracedecay_domain::ProjectId::new("project.pr-context".to_owned()).expect("project id"); + let runtime = RegisteredGlobalDbTestRuntime::project( + home.path().join("profile"), + home.path().join("checkout"), + project_id.clone(), + ) + .await + .expect("registered project store"); + let lease = runtime + .project_database_arc() + .expect("registered project lease"); + // The daemon provisions this store's signing key at project open; the + // authority path below reads it back exactly as production does. + lease + .ensure_active_session_cursor_key_result() + .await + .expect("provision the store's cursor signing key"); + let scope = tracedecay_contracts::ResolvedScope::new( + project_id, + tracedecay_domain::RepositoryId::new("repository.pr-context".to_owned()) + .expect("repository id"), + tracedecay_domain::WorktreeId::new("worktree.pr-context".to_owned()) + .expect("worktree id"), + None, + ) + .expect("resolved scope"); + let changes = Vec::new(); + let comparison = || PrContextCursorComparison { + base_oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + head_oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + merge_base: "cccccccccccccccccccccccccccccccccccccccc", + graph_generation: "generation.pr-context.fixture", + maximum_symbols: 25, + changes: &changes, + }; + let context_for = |authorization| { + McpToolContext::bind(McpToolBinding { + project_root: home.path(), + active_branch: None, + controls: RequestControls::default(), + scope: Some(&scope), + project_session_store: Some(AdmittedProjectStore::new(&lease, authorization)), + code_index: None, + }) + .expect("a real lease for the admitted project binds") + }; + + let denied_context = context_for(ValidatedAuthorization::Unauthorized); + let root = tracedecay_runtime_core::os_str_bytes::native_os_str_bytes( + denied_context.project_root().as_os_str(), + ); + let denied_binding = PrContextCursorBinding::new(&denied_context, &root, comparison()); + let refusal = pr_context_cursor_authority(&denied_context, &denied_binding) + .await + .expect_err("an unauthorized store must not mint a cursor authority"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_denied"), + "got {refusal}" + ); + + let authorized_context = context_for(ValidatedAuthorization::Authorized); + let authorized_binding = + PrContextCursorBinding::new(&authorized_context, &root, comparison()); + pr_context_cursor_authority(&authorized_context, &authorized_binding) + .await + .expect("the same store, authorized, opens its own cursor authority"); + } + + /// Two stores can serve the same checkout — a registered project store and + /// a differently registered one for the same worktree. A cursor minted + /// against one must not verify against the other, so the store's own + /// registered shard is part of cursor identity. + #[test] + fn a_cursor_from_a_foreign_bound_store_is_denied() { + let changes = Vec::new(); + let scope = resolved("project.a", None); + let shard = session_shard("project.a"); + let mine = snapshot_for(&binding_bound_to( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&scope)), + Some(PrContextCursorStore { + brain_id: "brain.mine", + profile_id: "profile.mine", + scope: &shard, + }), + &changes, + )); + let theirs = snapshot_for(&binding_bound_to( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&scope)), + Some(PrContextCursorStore { + brain_id: "brain.theirs", + profile_id: "profile.theirs", + scope: &shard, + }), + &changes, + )); + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + + let encoded = + encode_pr_context_cursor(&after, nodes, edges, bytes, &theirs, &authenticator) + .expect("cursor issues"); + + let refusal = decode_pr_context_cursor(&encoded, &mine, &authenticator) + .expect_err("a foreign store's cursor must not decode"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_denied"), + "got {refusal}" + ); + } + + /// One project has several registered shards — its session store, its + /// project store, and its code stores. They are different stores, so + /// reducing the shard to the project it mentions would let a cursor minted + /// against one verify against another. + #[test] + fn a_cursor_cannot_travel_between_shard_families_of_one_project() { + let changes = Vec::new(); + let scope = resolved("project.a", None); + let sessions = session_shard("project.a"); + let project = tracedecay_store::StoreShardScopeV1::Project { + project_id: tracedecay_domain::ProjectId::new("project.a").expect("project id"), + }; + + let store_for = |shard| { + binding_bound_to( + b"/projects/shared", + Some(PrContextCursorScope::from_resolved(&scope)), + Some(PrContextCursorStore { + brain_id: "brain.local", + profile_id: "profile.local", + scope: shard, + }), + &changes, + ) + }; + // Denial is decided by cursor identity, so the two stores must not + // share one. Without this the cursor merely reads as stale. + assert_ne!( + store_for(&sessions) + .identity_digest() + .expect("session store identity"), + store_for(&project) + .identity_digest() + .expect("project store identity"), + "two shard families of one project must not share a cursor identity" + ); + + let session_binding = snapshot_for(&store_for(&sessions)); + let project_binding = snapshot_for(&store_for(&project)); + + let authenticator = authenticator(); + let (after, nodes, edges, bytes) = position(); + let encoded = encode_pr_context_cursor( + &after, + nodes, + edges, + bytes, + &project_binding, + &authenticator, + ) + .expect("cursor issues"); + + let refusal = decode_pr_context_cursor(&encoded, &session_binding, &authenticator) + .expect_err("the project shard's cursor must not decode against the session shard"); + assert_eq!( + refusal.project_route_context().map(|(reason, _, _)| reason), + Some("pr_context_cursor_denied"), + "got {refusal}" + ); + } +} diff --git a/crates/tracedecay/src/mcp/tools/handlers/git/shell.rs b/crates/tracedecay-mcp/src/handlers/git/shell.rs similarity index 97% rename from crates/tracedecay/src/mcp/tools/handlers/git/shell.rs rename to crates/tracedecay-mcp/src/handlers/git/shell.rs index 5ad06fd30f..2a30609170 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/git/shell.rs +++ b/crates/tracedecay-mcp/src/handlers/git/shell.rs @@ -501,7 +501,7 @@ pub(super) fn classify_file_role( path: &str, _files_with_inline_tests: &HashSet, ) -> &'static str { - if crate::tracedecay::is_test_file(path) { + if tracedecay_code_index::is_test_file(path) { return "test"; } let lower = path.to_lowercase(); @@ -528,26 +528,9 @@ pub(super) fn classify_file_role( #[cfg(test)] mod tests { + use super::super::test_support::test_git; use super::*; - fn test_git(root: &std::path::Path, args: &[&str]) { - let git = tracedecay_runtime_core::git::try_git_program() - .expect("absolute git executable should resolve"); - let output = std::process::Command::new(git) - .args(args) - .current_dir(root) - .env("GIT_AUTHOR_NAME", "TraceDecay Test") - .env("GIT_AUTHOR_EMAIL", "test@tracedecay.invalid") - .env("GIT_COMMITTER_NAME", "TraceDecay Test") - .env("GIT_COMMITTER_EMAIL", "test@tracedecay.invalid") - .output() - .expect("git command should run"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } #[test] fn pr_comparison_anchors_at_merge_base_when_base_advanced() { let temp = tempfile::tempdir().expect("temp repo"); diff --git a/crates/tracedecay-mcp/src/handlers/git/test_support.rs b/crates/tracedecay-mcp/src/handlers/git/test_support.rs new file mode 100644 index 0000000000..ac256f09ac --- /dev/null +++ b/crates/tracedecay-mcp/src/handlers/git/test_support.rs @@ -0,0 +1,88 @@ +//! Repository fixtures, admitted-binding shorthands, and the ref-read +//! serializer shared by this family's tests. + +use std::path::Path; +use std::sync::LazyLock; + +use crate::tool_context::{McpToolBinding, McpToolContext, RequestControls}; + +/// The binding a standalone (non-daemon) server produces: a worktree root and +/// no admitted authority at all. +pub(super) fn standalone_context(project_root: &Path) -> McpToolContext<'_> { + McpToolContext::bind(McpToolBinding { + project_root, + active_branch: None, + controls: RequestControls::default(), + scope: None, + project_session_store: None, + code_index: None, + }) + .expect("an absolute root binds standalone") +} + +/// The same standalone binding with the branch git resolved for the worktree. +pub(super) fn standalone_context_on_branch<'a>( + project_root: &'a Path, + active_branch: &'a str, +) -> McpToolContext<'a> { + McpToolContext::bind(McpToolBinding { + project_root, + active_branch: Some(active_branch), + controls: RequestControls::default(), + scope: None, + project_session_store: None, + code_index: None, + }) + .expect("an absolute root binds standalone") +} + +/// The branch-ref admission semaphore is process-wide, and +/// `branch::tests::branch_ref_route_reports_capacity_without_queueing` +/// deliberately drains it. Every test that performs a real ref read takes +/// this lock first, so a drained semaphore never reaches a sibling test as a +/// spurious capacity failure. +static REF_READ_SERIALIZER: LazyLock> = + LazyLock::new(|| tokio::sync::Mutex::new(())); + +pub(super) async fn ref_read_guard() -> tokio::sync::MutexGuard<'static, ()> { + REF_READ_SERIALIZER.lock().await +} + +pub(super) fn test_git(root: &std::path::Path, args: &[&str]) { + let git = tracedecay_runtime_core::git::try_git_program() + .expect("absolute git executable should resolve"); + let output = std::process::Command::new(git) + .args(args) + .current_dir(root) + .env("GIT_AUTHOR_NAME", "TraceDecay Test") + .env("GIT_AUTHOR_EMAIL", "test@tracedecay.invalid") + .env("GIT_COMMITTER_NAME", "TraceDecay Test") + .env("GIT_COMMITTER_EMAIL", "test@tracedecay.invalid") + .output() + .expect("git command should run"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// A repository whose `feature` branch has one commit past `main`, so a PR +/// comparison and a branch diff both have a real merge base to anchor on. +pub(super) fn branched_repository() -> tempfile::TempDir { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("lib.rs"), "pub fn before() {}\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "initial"]); + test_git(root, &["switch", "-c", "feature"]); + std::fs::write( + root.join("lib.rs"), + "pub fn before() {}\npub fn after() {}\n", + ) + .expect("write change"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "change source"]); + temp +} diff --git a/crates/tracedecay-mcp/src/handlers/mod.rs b/crates/tracedecay-mcp/src/handlers/mod.rs index 594b5022f8..e367338c5c 100644 --- a/crates/tracedecay-mcp/src/handlers/mod.rs +++ b/crates/tracedecay-mcp/src/handlers/mod.rs @@ -1,9 +1,16 @@ -//! Portable MCP handler adapters that depend only on application, protocol, -//! and catalog crates. +//! MCP handler adapters: transport-request decoding, business-owner calls, +//! and response shaping. +//! +//! Handlers depend on application, query, protocol, and catalog crates, and +//! on [`crate::McpToolContext`] for anything the daemon admitted for the +//! call. None of them constructs a project route, opens a store, or mints an +//! authorization. pub mod analysis; pub mod ast_grep; mod bounded_search; +pub mod dependency_hints; +pub mod git; pub mod graph; pub mod grep; pub mod health; diff --git a/crates/tracedecay-mcp/src/handlers/support.rs b/crates/tracedecay-mcp/src/handlers/support.rs index 6c5d0a5d70..9dcff9c761 100644 --- a/crates/tracedecay-mcp/src/handlers/support.rs +++ b/crates/tracedecay-mcp/src/handlers/support.rs @@ -15,6 +15,28 @@ use crate::tools::render; /// back out, so no handler has to remember to strip it. pub const CONTEXT_MEMORY_ANALYTICS_KEY: &str = "context_memory_analytics"; +/// Decodes a paginated read's continuation from the transport arguments. +/// +/// The cursor is caller-supplied, so it is bounded before parsing and then +/// validated: a continuation that does not authenticate is rejected rather +/// than treated as "start from the beginning", which would silently restart +/// a page walk instead of reporting the tampered envelope. +pub fn retrieval_cursor(args: &Value) -> Result> { + let Some(encoded) = args.get("cursor").and_then(Value::as_str) else { + return Ok(None); + }; + if encoded.len() > 4_096 { + return Err(TraceDecayError::Config { + message: "cursor exceeds its bounded authenticated envelope".to_owned(), + }); + } + let cursor: tracedecay_domain::RetrievalCursor = serde_json::from_str(encoded)?; + cursor.validate().map_err(|_| TraceDecayError::Config { + message: "cursor is not a valid authenticated retrieval continuation".to_owned(), + })?; + Ok(Some(cursor)) +} + /// The single wrapper every MCP tool handler returns through. /// /// Lifts internal analytics out of `value` so they travel beside the result diff --git a/crates/tracedecay-mcp/src/lib.rs b/crates/tracedecay-mcp/src/lib.rs index 2c5aa04588..ca10ba2004 100644 --- a/crates/tracedecay-mcp/src/lib.rs +++ b/crates/tracedecay-mcp/src/lib.rs @@ -1,12 +1,16 @@ //! Portable MCP catalog, rendering, JSON-RPC transport, and server-adjacent //! protocol helpers. //! -//! This crate owns daemon-free MCP surface: JSON-RPC contracts, concrete +//! This crate owns the MCP surface itself: JSON-RPC contracts, concrete //! stdio/channel/replay transports, tool definitions, response truncation, //! canonical application-result presentation, request-deadline decoding, //! tool-error classification, hook-event plan decoding, connection scheduling, -//! typed RMCP adaptation, and request lifecycle state. Product dependency -//! construction and handlers that reach daemon internals stay above this crate. +//! typed RMCP adaptation, request lifecycle state, and tool handlers that +//! translate transport requests into business-owner calls. +//! +//! Handlers reach daemon state through [`McpToolContext`], filled by the +//! composition root with the authorities admitted for the call. Product +//! dependency construction and concrete lifecycle adapters stay in that root. #![deny(clippy::all)] #![warn(clippy::pedantic)] @@ -51,6 +55,7 @@ pub mod project_access; pub mod response_handles; pub mod server; pub mod tool_call_deadline; +pub mod tool_context; pub mod tool_errors; pub mod tools; pub mod transport; @@ -88,6 +93,10 @@ pub use tool_call_deadline::{ TOOL_CALL_DEADLINE_META_KEY, caller_tool_call_deadline, caller_tool_call_deadline_from_meta, tool_call_deadline_meta, }; +pub use tool_context::{ + AdmittedCodeIndex, AdmittedProjectStore, McpToolBinding, McpToolBindingError, McpToolContext, + RequestControls, +}; pub use tool_errors::{ mark_semantic_tool_error, semantic_failure_reason, serialize_response_line, structured_hook_error_data, tool_error_response, tool_result_has_semantic_error, diff --git a/crates/tracedecay-mcp/src/tool_context.rs b/crates/tracedecay-mcp/src/tool_context.rs new file mode 100644 index 0000000000..b7111aaba6 --- /dev/null +++ b/crates/tracedecay-mcp/src/tool_context.rs @@ -0,0 +1,798 @@ +//! The single validated admitted binding a moved MCP handler family reads. +//! +//! The composition root resolves project admission, the carried request +//! deadline, cancellation, and every code-index authority *before* handler +//! dispatch, then hands the whole admitted set across this one boundary. +//! +//! Construction is one validated step, not a builder chain, and the binding +//! carries exactly one scope: the checkout the daemon admitted for this +//! request. A scoped authority is admitted *under* that scope rather than +//! arriving with a scope label of its own, so there is no second label a +//! caller could set to make one project's store or executors look like +//! another's. Where an authority knows its own identity, [`McpToolContext::bind`] +//! checks that identity rather than the caller's word: a registered store +//! lease reports the logical shard it was opened for, and a lease that is not +//! this project's session shard is refused however it was presented — a +//! `Project` or `Code` shard for the same project included, since those are +//! different stores and not project-session authority. +//! +//! Authorization is carried, never inferred. The root validated whether this +//! request may read the admitted project store and hands that verdict over +//! with the lease; the context reports it verbatim and cannot upgrade a +//! missing or unauthorized verdict into an apparent capability. +//! +//! Absence stays typed. An authority the daemon never admitted is `None` here +//! and each handler turns that into its own unavailable state. With no +//! admitted scope no scoped authority may be admitted at all, and graph +//! verification refuses rather than waving a query through. + +use std::path::Path; + +use tracedecay_contracts::{CancellationSignal, Deadline, ResolvedScope}; +use tracedecay_domain::errors::{Result, TraceDecayError}; +use tracedecay_global_db::RegisteredGlobalDbLeaseV1; +use tracedecay_graph_query::VerifiedGraphQuery; +use tracedecay_query::code_search::{ + CodeIndexBranchDiffExecutor, CodeIndexSearchAuthorityV1, CodeIndexSearchExecutor, +}; +use tracedecay_store::StoreShardScopeV1; +use tracedecay_temporal_query::resolution::ValidatedAuthorization; + +/// Why a proposed binding is not one coherent admitted request scope. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum McpToolBindingError { + #[error("admitted project root '{root}' is not absolute")] + RelativeProjectRoot { root: String }, + #[error("admitted request scope is not self-consistent: {detail}")] + ScopeInvalid { detail: String }, + #[error("{authority} cannot be admitted without a resolved request scope")] + UnscopedAuthority { authority: &'static str }, + #[error( + "admitted project session store must be a project-sessions shard, but its lease was opened for {shard} while this request resolved {request}" + )] + ProjectStoreNotSessionScoped { request: String, shard: String }, + #[error( + "project session store lease was opened for project {lease} but this request resolved {request}" + )] + ProjectStoreProjectMismatch { request: String, lease: String }, + #[error("code index admission carries no executor to authorize")] + CodeIndexWithoutExecutor, +} + +impl McpToolBindingError { + /// The stable reason code an operator sees for a refused binding. + #[must_use] + pub fn reason_code(&self) -> &'static str { + match self { + Self::RelativeProjectRoot { .. } => "mcp_tool_binding_root_not_absolute", + Self::ScopeInvalid { .. } => "mcp_tool_binding_scope_invalid", + Self::UnscopedAuthority { .. } => "mcp_tool_binding_scope_unresolved", + Self::ProjectStoreNotSessionScoped { .. } => "mcp_tool_binding_store_not_session_shard", + Self::ProjectStoreProjectMismatch { .. } => "mcp_tool_binding_store_project_mismatch", + Self::CodeIndexWithoutExecutor => "mcp_tool_binding_code_index_without_executor", + } + } +} + +impl From for TraceDecayError { + fn from(error: McpToolBindingError) -> Self { + // A refused binding is a daemon wiring fault, not a transient + // condition: retrying the same admission reproduces it exactly. + Self::project_route(error.reason_code(), false, error.to_string()) + } +} + +/// The caller's carried deadline and cancellation. +/// +/// Handlers propagate both into bounded walks so a cancelled call stops at its +/// next checkpoint instead of running to completion. +#[derive(Clone, Copy, Default)] +pub struct RequestControls<'a> { + pub deadline: Option<&'a Deadline>, + pub cancellation: Option<&'a CancellationSignal>, +} + +/// The registered project session store the daemon opened for this request, +/// with the authorization the daemon validated for reading it. +/// +/// Both halves come from the root. The lease knows the logical shard it was +/// opened for, so [`McpToolContext::bind`] can check it against the admitted +/// checkout instead of trusting how it was presented; the authorization is the +/// root's own verdict and is carried through untouched. +#[derive(Clone, Copy)] +pub struct AdmittedProjectStore<'a> { + lease: &'a RegisteredGlobalDbLeaseV1, + authorization: ValidatedAuthorization, +} + +impl<'a> AdmittedProjectStore<'a> { + /// Pairs the lease the root opened with the verdict the root reached. + /// + /// `authorization` must be the authorization the daemon validated for this + /// request. Passing [`ValidatedAuthorization::Unauthorized`] keeps every + /// store-backed handler denied; there is no value that means "decide later". + #[must_use] + pub fn new( + lease: &'a RegisteredGlobalDbLeaseV1, + authorization: ValidatedAuthorization, + ) -> Self { + Self { + lease, + authorization, + } + } +} + +/// Daemon-owned code-index executors with the authorization proved for them. +/// +/// The authority is required, not optional: an executor admitted without the +/// admission envelope it authenticates is an empty capability claim that would +/// report the index as mounted while nothing can answer. The executors carry +/// no scope of their own — they are admitted under the request's one scope, +/// and each one re-authorizes its embedded route admission against the request +/// root when it runs. +#[derive(Clone, Copy)] +pub struct AdmittedCodeIndex<'a> { + authority: &'a CodeIndexSearchAuthorityV1, + search: Option<&'a CodeIndexSearchExecutor>, + branch_diff: Option<&'a CodeIndexBranchDiffExecutor>, +} + +impl<'a> AdmittedCodeIndex<'a> { + /// Admits at least one executor together with the authority it presents. + pub fn new( + authority: &'a CodeIndexSearchAuthorityV1, + search: Option<&'a CodeIndexSearchExecutor>, + branch_diff: Option<&'a CodeIndexBranchDiffExecutor>, + ) -> std::result::Result { + if search.is_none() && branch_diff.is_none() { + return Err(McpToolBindingError::CodeIndexWithoutExecutor); + } + Ok(Self { + authority, + search, + branch_diff, + }) + } +} + +/// Everything the composition root admits for one MCP tool call. +#[derive(Clone, Copy)] +pub struct McpToolBinding<'a> { + /// The admitted worktree root every handler resolves paths against. + pub project_root: &'a Path, + /// The branch git resolved for that worktree, when it has one. + pub active_branch: Option<&'a str>, + pub controls: RequestControls<'a>, + /// The one checkout the daemon admitted for this request. Absent on a + /// standalone server and on the core server that answers before + /// project-open publication resolves a route. + pub scope: Option<&'a ResolvedScope>, + pub project_session_store: Option>, + pub code_index: Option>, +} + +/// Admitted daemon authorities for one MCP tool call. +/// +/// Borrowed for the duration of the call: the root owns every authority and +/// the handler family only reads them, so no handler can outlive the +/// admission that produced them. +pub struct McpToolContext<'a> { + project_root: &'a Path, + active_branch: Option<&'a str>, + deadline: Option<&'a Deadline>, + cancellation: Option<&'a CancellationSignal>, + /// The one checkout every admitted authority in this binding belongs to. + admitted_scope: Option<&'a ResolvedScope>, + project_session_db: Option<&'a RegisteredGlobalDbLeaseV1>, + /// The root's verdict for reading `project_session_db`, carried verbatim. + project_session_authorization: Option, + code_index_search_executor: Option<&'a CodeIndexSearchExecutor>, + code_index_branch_diff_executor: Option<&'a CodeIndexBranchDiffExecutor>, + code_index_search_authority: Option<&'a CodeIndexSearchAuthorityV1>, +} + +impl<'a> McpToolContext<'a> { + /// Validates one admitted binding and freezes it for the call. + /// + /// The root must be absolute, the admitted scope self-consistent, and every + /// scoped authority must actually have that scope to be admitted under. A + /// store lease is checked against its own logical shard identity, so a + /// lease opened for another project is refused whatever scope accompanied + /// it. Nothing is defaulted or repaired: a binding that does not prove one + /// coherent request scope is refused whole. + pub fn bind(binding: McpToolBinding<'a>) -> std::result::Result { + if !binding.project_root.is_absolute() { + return Err(McpToolBindingError::RelativeProjectRoot { + root: binding.project_root.display().to_string(), + }); + } + if let Some(scope) = binding.scope + && let Err(error) = scope.validate() + { + return Err(McpToolBindingError::ScopeInvalid { + detail: error.to_string(), + }); + } + if let Some(store) = binding.project_session_store { + verify_store_lease( + require_scope(binding.scope, "project session store")?, + store, + )?; + } + if binding.code_index.is_some() { + require_scope(binding.scope, "code index")?; + } + + Ok(Self { + project_root: binding.project_root, + active_branch: binding.active_branch, + deadline: binding.controls.deadline, + cancellation: binding.controls.cancellation, + admitted_scope: binding.scope, + project_session_db: binding.project_session_store.map(|store| store.lease), + project_session_authorization: binding + .project_session_store + .map(|store| store.authorization), + code_index_search_executor: binding.code_index.and_then(|code_index| code_index.search), + code_index_branch_diff_executor: binding + .code_index + .and_then(|code_index| code_index.branch_diff), + code_index_search_authority: binding.code_index.map(|code_index| code_index.authority), + }) + } + + #[must_use] + pub fn project_root(&self) -> &'a Path { + self.project_root + } + + #[must_use] + pub fn active_branch(&self) -> Option<&'a str> { + self.active_branch + } + + #[must_use] + pub fn deadline(&self) -> Option<&'a Deadline> { + self.deadline + } + + #[must_use] + pub fn cancellation(&self) -> Option<&'a CancellationSignal> { + self.cancellation + } + + /// The one checkout this call is admitted for, when the daemon resolved one. + #[must_use] + pub fn admitted_scope(&self) -> Option<&'a ResolvedScope> { + self.admitted_scope + } + + /// The admitted project store together with the root's authorization. + /// + /// The verdict is the root's, carried through [`Self::bind`] unchanged: a + /// handler cannot decide for itself that a store read is authorized, and + /// this accessor never supplies a verdict of its own. With no admitted + /// store the caller receives the typed absence instead. + #[must_use] + pub fn authorized_project_session_db( + &self, + ) -> Option<(&'a RegisteredGlobalDbLeaseV1, ValidatedAuthorization)> { + self.project_session_db + .zip(self.project_session_authorization) + } + + #[must_use] + pub fn code_index_search_executor(&self) -> Option<&'a CodeIndexSearchExecutor> { + self.code_index_search_executor + } + + #[must_use] + pub fn code_index_branch_diff_executor(&self) -> Option<&'a CodeIndexBranchDiffExecutor> { + self.code_index_branch_diff_executor + } + + #[must_use] + pub fn code_index_search_authority(&self) -> Option<&'a CodeIndexSearchAuthorityV1> { + self.code_index_search_authority + } + + /// Admits a resolved graph query into this call's scope. + /// + /// The graph authority only exists once its admission future resolves, so + /// it cannot be cross-checked at bind time; every handler that awaits one + /// passes it through here first. The graph carries its own resolved scope, + /// which must name the checkout this call was admitted for. With no + /// admitted scope there is nothing to isolate against and the query is + /// refused rather than trusted. + pub fn verify_graph_scope(&self, graph: &VerifiedGraphQuery) -> Result<()> { + verify_scope_isolation(self.admitted_scope, graph.request_context().scope()) + } +} + +impl std::fmt::Debug for McpToolContext<'_> { + /// Names the admitted set without reaching into any authority: the + /// executors are opaque closures and a store lease has no printable form, + /// so presence is the diagnostic. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("McpToolContext") + .field("project_root", &self.project_root) + .field("active_branch", &self.active_branch) + .field("admitted_scope", &self.admitted_scope) + .field("has_deadline", &self.deadline.is_some()) + .field("has_cancellation", &self.cancellation.is_some()) + .field("has_project_session_db", &self.project_session_db.is_some()) + .field( + "project_session_authorization", + &self.project_session_authorization, + ) + .field( + "has_code_index_search_executor", + &self.code_index_search_executor.is_some(), + ) + .field( + "has_code_index_branch_diff_executor", + &self.code_index_branch_diff_executor.is_some(), + ) + .field( + "has_code_index_search_authority", + &self.code_index_search_authority.is_some(), + ) + .finish() + } +} + +/// The admitted scope a scoped authority needs, or a typed refusal. +/// +/// An authority the daemon scoped cannot be admitted into a request that never +/// resolved a checkout: there would be nothing to isolate it against, and a +/// handler reading it would answer from whatever project the authority happens +/// to hold. +fn require_scope<'a>( + scope: Option<&'a ResolvedScope>, + authority: &'static str, +) -> std::result::Result<&'a ResolvedScope, McpToolBindingError> { + scope.ok_or(McpToolBindingError::UnscopedAuthority { authority }) +} + +/// The project a shard is the *session* store for, if it is one at all. +/// +/// The family is matched exactly, and exhaustively so a shard family added +/// later must be classified here rather than silently inheriting an answer. +/// [`StoreShardScopeV1::project_id`] cannot stand in: it reports `Project`, +/// `ProjectSessions`, and `Code` shards alike, so a project-only comparison +/// would accept a lease on this project's *project* store or one of its code +/// stores as project-session authority. Those are separate stores with their +/// own tables and retention. +fn session_shard_project(shard: &StoreShardScopeV1) -> Option<&tracedecay_domain::ProjectId> { + match shard { + StoreShardScopeV1::ProjectSessions { project_id } => Some(project_id), + StoreShardScopeV1::Profile + | StoreShardScopeV1::ProfileMemory + | StoreShardScopeV1::ProfileSessions + | StoreShardScopeV1::RemoteNode { .. } + | StoreShardScopeV1::Project { .. } + | StoreShardScopeV1::Code { .. } => None, + } +} + +/// Refuses a store lease that is not this project's session store. +/// +/// The lease reports the logical shard the registry opened it for, which is +/// the store's own identity rather than a label travelling beside it. +fn verify_store_lease( + scope: &ResolvedScope, + store: AdmittedProjectStore<'_>, +) -> std::result::Result<(), McpToolBindingError> { + let shard = &store.lease.binding().shard_id; + let Some(lease_project) = session_shard_project(&shard.scope) else { + return Err(McpToolBindingError::ProjectStoreNotSessionScoped { + request: checkout_label(scope), + shard: format!("{:?}", shard.scope), + }); + }; + if lease_project != &scope.project_id { + return Err(McpToolBindingError::ProjectStoreProjectMismatch { + request: checkout_label(scope), + lease: lease_project.as_str().to_owned(), + }); + } + Ok(()) +} + +/// Refuses a graph admitted for a different checkout than this call. +fn verify_scope_isolation(admitted: Option<&ResolvedScope>, graph: &ResolvedScope) -> Result<()> { + let Some(admitted) = admitted else { + return Err(TraceDecayError::project_route( + "mcp_tool_graph_scope_unresolved", + false, + format!( + "verified graph answers for checkout {} but this request resolved no admitted scope", + checkout_label(graph) + ), + )); + }; + if admitted.identifies_same_checkout(graph) { + return Ok(()); + } + Err(TraceDecayError::project_route( + "mcp_tool_graph_scope_mismatch", + false, + format!( + "verified graph answers for checkout {} but this request is admitted for {}", + checkout_label(graph), + checkout_label(admitted) + ), + )) +} + +/// Names the physical checkout a scope identifies, for operator-facing refusals. +fn checkout_label(scope: &ResolvedScope) -> String { + format!( + "{}/{}/{}", + scope.project_id.as_str(), + scope.repository_id.as_str(), + scope.worktree_id.as_str() + ) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use tracedecay_domain::{ProjectId, RepositoryId, WorktreeId}; + use tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime; + + /// A registered project session store opened by the production + /// registration path, so a binding is checked against a lease's own + /// logical shard rather than a stand-in that repeats whatever the caller + /// claimed. + async fn registered_project_store( + home: &Path, + project: &str, + ) -> (RegisteredGlobalDbTestRuntime, RegisteredGlobalDbLeaseV1) { + let project_id = ProjectId::new(format!("project.{project}")).expect("project id"); + let runtime = RegisteredGlobalDbTestRuntime::project( + home.join(format!("profile-{project}")), + home.join(format!("checkout-{project}")), + project_id, + ) + .await + .expect("registered project store"); + let lease = runtime + .project_database_arc() + .expect("registered project lease"); + (runtime, lease) + } + + /// A registered store opened for one exact shard family through the same + /// publication, schema installation, and client issuance route production + /// admission uses. The owner is returned so the caller keeps it alive. + async fn registered_store_for_shard( + home: &Path, + label: &str, + scope: tracedecay_runtime_core::db::TestDatabaseRuntimeScope, + ) -> ( + RegisteredGlobalDbLeaseV1, + tracedecay_global_db::RegisteredGlobalDbOwnerV1, + ) { + let path = home.join(label).join("store.sqlite3"); + tracedecay_global_db::tests::harness::open_registered_test_database_fixture(&path, scope) + .await + .expect("registered store fixture") + } + + fn scope(project: &str) -> ResolvedScope { + ResolvedScope::new( + ProjectId::new(format!("project.{project}")).expect("project id"), + RepositoryId::new(format!("repository.{project}")).expect("repository id"), + WorktreeId::new(format!("worktree.{project}")).expect("worktree id"), + None, + ) + .expect("scope") + } + + fn binding<'a>(root: &'a Path, scope: Option<&'a ResolvedScope>) -> McpToolBinding<'a> { + McpToolBinding { + project_root: root, + active_branch: None, + controls: RequestControls::default(), + scope, + project_session_store: None, + code_index: None, + } + } + + fn authority() -> CodeIndexSearchAuthorityV1 { + CodeIndexSearchAuthorityV1 { + principal: tracedecay_domain::PrincipalId::new("principal.mcp-binding.fixture") + .expect("principal"), + authorization_revision: tracedecay_domain::AuthorizationRevision::new( + "revision.mcp-binding.fixture", + ) + .expect("revision"), + } + } + + /// A relative root cannot anchor path resolution or identity, so it is + /// refused instead of silently joined against the process directory. + #[test] + fn a_relative_project_root_is_refused() { + let error = McpToolContext::bind(binding(Path::new("relative/root"), None)) + .expect_err("relative root must be refused"); + assert_eq!(error.reason_code(), "mcp_tool_binding_root_not_absolute"); + } + + /// A code-index admission with no executor is an empty capability claim: + /// handlers would report the index as mounted while nothing can answer. + #[test] + fn a_code_index_admission_without_an_executor_is_refused() { + let authority = authority(); + + let Err(error) = AdmittedCodeIndex::new(&authority, None, None) else { + panic!("an executorless code index admission must be refused"); + }; + + assert_eq!( + error.reason_code(), + "mcp_tool_binding_code_index_without_executor" + ); + } + + /// A request that resolved no checkout has nothing to isolate a scoped + /// authority against, so admitting one fails closed rather than reading + /// whatever project the authority happens to hold. + #[test] + fn a_code_index_cannot_be_admitted_without_a_resolved_scope() { + let temp = tempfile::tempdir().expect("temp root"); + let authority = authority(); + let search: CodeIndexSearchExecutor = + std::sync::Arc::new(|_| unreachable!("binding must be refused before any search runs")); + + let error = McpToolContext::bind(McpToolBinding { + code_index: Some( + AdmittedCodeIndex::new(&authority, Some(&search), None).expect("admission"), + ), + ..binding(temp.path(), None) + }) + .expect_err("an unscoped code index admission must be refused"); + + assert_eq!(error.reason_code(), "mcp_tool_binding_scope_unresolved"); + } + + /// A graph admitted for another checkout is refused before a handler reads + /// it, so cross-project graph evidence cannot reach a scoped response. + #[test] + fn a_graph_from_another_checkout_is_refused() { + let admitted = scope("admitted"); + let foreign = scope("foreign"); + + let error = verify_scope_isolation(Some(&admitted), &foreign) + .expect_err("a foreign graph scope must be refused"); + assert_eq!( + error.project_route_context().map(|(reason, _, _)| reason), + Some("mcp_tool_graph_scope_mismatch") + ); + + verify_scope_isolation(Some(&admitted), &admitted) + .expect("the admitted checkout's own graph must pass"); + } + + /// With no admitted scope the graph's own admission is the only identity + /// in play, and trusting it would let any checkout's graph answer. The + /// query is refused instead. + #[test] + fn a_graph_without_an_admitted_scope_is_refused() { + let graph = scope("graph"); + + let error = verify_scope_isolation(None, &graph) + .expect_err("an unscoped request must not read a verified graph"); + assert_eq!( + error.project_route_context().map(|(reason, _, _)| reason), + Some("mcp_tool_graph_scope_unresolved") + ); + } + + /// A store lease opened for one project must never be readable through a + /// context admitted for another. The lease below is a real registered + /// project store, and the refusal comes from its own logical shard rather + /// than from any label presented alongside it. + #[tokio::test] + async fn a_real_lease_from_another_project_is_refused() { + let home = tempfile::tempdir().expect("temp home"); + let (_admitted_runtime, admitted_lease) = + registered_project_store(home.path(), "admitted").await; + let (_foreign_runtime, foreign_lease) = + registered_project_store(home.path(), "foreign").await; + let admitted = scope("admitted"); + + let error = McpToolContext::bind(McpToolBinding { + project_session_store: Some(AdmittedProjectStore::new( + &foreign_lease, + ValidatedAuthorization::Authorized, + )), + ..binding(home.path(), Some(&admitted)) + }) + .map(|_| ()) + .expect_err("another project's real lease must be refused"); + assert_eq!( + error.reason_code(), + "mcp_tool_binding_store_project_mismatch" + ); + assert!(error.to_string().contains("project.foreign"), "got {error}"); + + let bound = McpToolContext::bind(McpToolBinding { + project_session_store: Some(AdmittedProjectStore::new( + &admitted_lease, + ValidatedAuthorization::Authorized, + )), + ..binding(home.path(), Some(&admitted)) + }) + .expect("the admitted project's own lease must bind"); + let (bound_lease, authorization) = bound + .authorized_project_session_db() + .expect("the bound store is reported"); + assert!( + bound_lease.shares_client_with(&admitted_lease), + "the bound context must report the very lease it was admitted with" + ); + assert_eq!(authorization, ValidatedAuthorization::Authorized); + } + + /// The family gate fires on a real registered lease, not just on a shard + /// identity in isolation. The lease below is genuinely published through + /// the production registration route and is a real store this daemon + /// opens — it is simply the profile's session store rather than the + /// admitted project's, so it carries no project-session authority here. + #[tokio::test] + async fn a_real_non_session_shard_lease_is_refused_at_the_binding() { + let home = tempfile::tempdir().expect("temp home"); + let admitted = scope("admitted"); + let (lease, _owner) = registered_store_for_shard( + home.path(), + "profile-sessions", + tracedecay_runtime_core::db::TestDatabaseRuntimeScope::ProfileSessions, + ) + .await; + + let error = McpToolContext::bind(McpToolBinding { + project_session_store: Some(AdmittedProjectStore::new( + &lease, + ValidatedAuthorization::Authorized, + )), + ..binding(home.path(), Some(&admitted)) + }) + .map(|_| ()) + .expect_err("a non-session-family lease must be refused"); + assert_eq!( + error.reason_code(), + "mcp_tool_binding_store_not_session_shard" + ); + } + + /// Only `ProjectSessions` carries project-session authority, and the two + /// families a project-only comparison would have waved through are the + /// dangerous ones: this project's own `Project` and `Code` shards name the + /// admitted project exactly, so nothing but the family distinguishes them. + /// + /// Both are asserted against canonical production shard identities rather + /// than through a registered lease because neither family can hold one: + /// the registered global-db schema is the session store's, so the + /// publication route refuses a `Project` shard outright and a `Code` shard + /// is a graph store that never becomes a global-db lease. The lease route + /// itself is covered by the tests above. + #[test] + fn only_the_project_sessions_family_carries_session_authority() { + let project = ProjectId::new("project.admitted").expect("project id"); + let repository = RepositoryId::new("repository.admitted").expect("repository id"); + let worktree = WorktreeId::new("worktree.admitted").expect("worktree id"); + + let code = tracedecay_store::StoreShardIdV1::code( + tracedecay_domain::BrainId::new("brain.admitted").expect("brain id"), + tracedecay_domain::UserProfileId::new("profile.admitted").expect("profile id"), + project.clone(), + repository, + tracedecay_store::CodeShardScopeV1::Worktree { + worktree_id: worktree, + }, + ); + assert_eq!( + code.scope.project_id(), + Some(&project), + "the code shard names the admitted project, so a project-only \ + comparison would have accepted it" + ); + assert_eq!( + session_shard_project(&code.scope), + None, + "a code shard is not project-session authority" + ); + + assert_eq!( + session_shard_project(&StoreShardScopeV1::Project { + project_id: project.clone() + }), + None, + "a project shard is not project-session authority" + ); + assert_eq!( + session_shard_project(&StoreShardScopeV1::Profile), + None, + "a profile shard has no project at all" + ); + assert_eq!( + session_shard_project(&StoreShardScopeV1::ProjectSessions { + project_id: project.clone() + }), + Some(&project), + "the project's session shard is the one family that carries it" + ); + } + + /// A request that resolved no checkout cannot admit a store either: there + /// would be no identity to check the lease's shard against. + #[tokio::test] + async fn a_real_lease_cannot_be_admitted_without_a_resolved_scope() { + let home = tempfile::tempdir().expect("temp home"); + let (_runtime, lease) = registered_project_store(home.path(), "admitted").await; + + let error = McpToolContext::bind(McpToolBinding { + project_session_store: Some(AdmittedProjectStore::new( + &lease, + ValidatedAuthorization::Authorized, + )), + ..binding(home.path(), None) + }) + .map(|_| ()) + .expect_err("an unscoped store admission must be refused"); + + assert_eq!(error.reason_code(), "mcp_tool_binding_scope_unresolved"); + } + + /// The root's verdict is carried, not re-derived: a context bound with an + /// unauthorized store reports exactly that, so every store-backed handler + /// denies instead of reading it. + #[tokio::test] + async fn an_unauthorized_verdict_survives_binding() { + let home = tempfile::tempdir().expect("temp home"); + let (_runtime, lease) = registered_project_store(home.path(), "admitted").await; + let admitted = scope("admitted"); + + let bound = McpToolContext::bind(McpToolBinding { + project_session_store: Some(AdmittedProjectStore::new( + &lease, + ValidatedAuthorization::Unauthorized, + )), + ..binding(home.path(), Some(&admitted)) + }) + .expect("an unauthorized store is still a coherent binding"); + + let (_, authorization) = bound + .authorized_project_session_db() + .expect("the store is reported with its verdict"); + assert_eq!(authorization, ValidatedAuthorization::Unauthorized); + } + + /// A checkout differs from another by project, repository, or worktree — + /// never by the branch reference HEAD happens to carry. Two scopes for the + /// same checkout on different branches must isolate identically. + #[test] + fn a_branch_switch_does_not_change_the_admitted_checkout() { + let registered = scope("admitted"); + let switched = ResolvedScope::new( + registered.project_id.clone(), + registered.repository_id.clone(), + registered.worktree_id.clone(), + Some(tracedecay_domain::RefId::new("refs/heads/feature").expect("reference")), + ) + .expect("scope on another branch"); + assert_ne!( + registered.scope_digest, switched.scope_digest, + "fixture must differ in the reference-sensitive digest" + ); + + verify_scope_isolation(Some(®istered), &switched) + .expect("the same checkout on another branch must still bind"); + } +} diff --git a/crates/tracedecay/Cargo.toml b/crates/tracedecay/Cargo.toml index f1524db02a..eb7eb9bfb8 100644 --- a/crates/tracedecay/Cargo.toml +++ b/crates/tracedecay/Cargo.toml @@ -375,7 +375,7 @@ sha2 = "0.11" glob = "0.3" walkdir = "2" ignore = "0.4" -gix = { version = "=0.86.0", default-features = false, features = ["revision", "blob-diff", "parallel", "sha1", "sha256", "status"] } +gix.workspace = true dirs = "6" hex = "0.4" fs2 = "0.4" diff --git a/crates/tracedecay/src/daemon/connection_serving.rs b/crates/tracedecay/src/daemon/connection_serving.rs index 1030783b49..679b7aa3c3 100644 --- a/crates/tracedecay/src/daemon/connection_serving.rs +++ b/crates/tracedecay/src/daemon/connection_serving.rs @@ -8,10 +8,7 @@ use super::profile_host_admission_replay::ProfileHostAdmissionBootstrapStatus; use super::*; use tracedecay_daemon_protocol::DaemonInvocationPayload; use tracedecay_daemon_service::{DaemonInvocationService, Lease}; -use tracedecay_mcp::{ - BrokerResponseLifecycle, BrokerSelectedResponseAuthority, BrokerSelectedResponseLease, - BrokerWorkDeliverySettlement, -}; +use tracedecay_mcp::BrokerSelectedResponseLease; use tracedecay_session_memory::context::CancellationToken; /// Hermetic production-route benchmark support for the typed RMCP transport. @@ -22,84 +19,12 @@ use tracedecay_session_memory::context::CancellationToken; #[cfg(feature = "rmcp-benchmark")] pub mod rmcp_benchmark; -impl BrokerResponseLifecycle for crate::mcp::server::ProjectServerResponseLifecycle { - fn response_revoked(&self) -> &CancellationToken { - crate::mcp::server::ProjectServerResponseLifecycle::response_revoked(self) - } -} - impl BrokerSelectedResponseLease for crate::mcp::server::SelectedProjectResponseLease { fn response_revoked(&self) -> &CancellationToken { self.revoked() } } -impl BrokerSelectedResponseAuthority for crate::mcp::server::RmcpSelectedProjectResponseAuthority { - fn take_response( - &self, - id: Option<&serde_json::Value>, - ) -> std::io::Result>> { - self.take(id) - .map(|lease| { - lease.map(|lease| { - Box::new(lease) as Box - }) - }) - .map_err(selected_response_io_error) - } -} - -fn selected_response_io_error(error: TraceDecayError) -> std::io::Error { - std::io::Error::other(error) -} - -#[cfg(test)] -mod selected_response_error_tests { - use super::*; - - #[test] - fn io_boundary_retains_typed_project_route_classification() { - let error = TraceDecayError::project_route( - "project_route_unavailable", - true, - "selected response authority is warming", - ); - - let error = selected_response_io_error(error); - let source = error - .get_ref() - .and_then(|source| source.downcast_ref::()) - .expect("I/O error must retain the typed TraceDecay source"); - - assert_eq!( - source.project_route_context(), - Some(( - "project_route_unavailable", - true, - "selected response authority is warming", - )) - ); - } -} - -impl BrokerWorkDeliverySettlement for crate::mcp::server::RmcpWorkDeliverySettlement { - fn attempt_for_request( - &self, - request: &serde_json::Value, - ) -> Option { - crate::mcp::server::RmcpWorkDeliverySettlement::attempt_for_request(self, request) - } - - fn settle( - &self, - attempt: tracedecay_domain::DeliverySettlementAttemptV1, - outcome: tracedecay_domain::DeliverySettlementOutcomeV1, - drop_reason: Option, - ) { - crate::mcp::server::RmcpWorkDeliverySettlement::settle(self, attempt, outcome, drop_reason); - } -} - type ProjectOwnerAwaitFutureV1<'a, T> = std::pin::Pin< Box)>>> + Send + 'a>, >; diff --git a/crates/tracedecay/src/daemon/project_composition.rs b/crates/tracedecay/src/daemon/project_composition.rs index 632a0a36d6..5fd9c6ca51 100644 --- a/crates/tracedecay/src/daemon/project_composition.rs +++ b/crates/tracedecay/src/daemon/project_composition.rs @@ -519,6 +519,7 @@ impl ComposedCoreServer { ), ) .with_code_index_search_authority(code_index.search_authority.clone()) + .with_admitted_project_scope(code_index.scope.clone()) .with_project_server_live(Arc::clone(&self.route_registered)) .with_application_invocation_executor(Arc::clone( &ports.application_invocation_executor, diff --git a/crates/tracedecay/src/mcp/server.rs b/crates/tracedecay/src/mcp/server.rs index d21f5957e4..cd173fa82d 100644 --- a/crates/tracedecay/src/mcp/server.rs +++ b/crates/tracedecay/src/mcp/server.rs @@ -63,10 +63,9 @@ pub(crate) use construction::*; pub(crate) use hook_writes::*; pub(crate) use ledger::McpToolErrorAnalyticsRequest; pub(crate) use lifecycle::VersionCheckState; -pub(crate) use rmcp::{ - RmcpConnectionAdapter, RmcpInitializeResponseDecorator, RmcpSelectedProjectResponseAuthority, - RmcpWorkDeliverySettlement, -}; +pub(crate) use rmcp::{RmcpConnectionAdapter, RmcpInitializeResponseDecorator}; +#[cfg(test)] +pub(crate) use rmcp::{RmcpSelectedProjectResponseAuthority, RmcpWorkDeliverySettlement}; pub(crate) use routing::*; pub(crate) use session_refresh::*; use tracedecay_daemon_service::{DaemonProjectRegistryReadService, DaemonWorkflowIndexReadService}; @@ -432,6 +431,10 @@ pub struct McpServer { /// Admission supplied by an authenticated daemon application route. It is /// deliberately absent until such a route/grant is available. code_index_search_authority: Option, + /// The checkout project open resolved for this route. Handler dispatch + /// binds every scoped authority against it, so a store lease or code-index + /// executor admitted for another project cannot be presented here. + admitted_project_scope: Option, retained_project_server_resolver: Option, #[cfg(any(test, feature = "test-transport"))] _host_admission_test_runtime: Option>, @@ -910,6 +913,7 @@ impl McpServer { verified_graph_query_port, code_index_ignored_dependency_admission, code_index_search_authority, + admitted_project_scope, retained_project_server_resolver, project_routes, application_invocation_executor, @@ -1176,6 +1180,7 @@ impl McpServer { source_edit_reconciliation_executor: tokio::sync::OnceCell::new(), source_edit_rollback_executor: tokio::sync::OnceCell::new(), code_index_search_authority, + admitted_project_scope, retained_project_server_resolver, #[cfg(any(test, feature = "test-transport"))] _host_admission_test_runtime: host_admission_test_runtime, diff --git a/crates/tracedecay/src/mcp/server/construction.rs b/crates/tracedecay/src/mcp/server/construction.rs index 91e85038ad..8ae49b9f3f 100644 --- a/crates/tracedecay/src/mcp/server/construction.rs +++ b/crates/tracedecay/src/mcp/server/construction.rs @@ -170,6 +170,10 @@ pub(crate) struct McpServerConstructionContext { pub(crate) code_index_ignored_dependency_admission: Option, pub(crate) code_index_search_authority: Option, + /// The one checkout this server answers for, resolved once by project open + /// through the daemon code-index authority. `None` on a direct server and + /// on the core server that answers before project-open publication. + pub(crate) admitted_project_scope: Option, pub(crate) retained_project_server_resolver: Option, pub(crate) project_routes: crate::mcp::project_route::SharedHookProjectRouteCache, pub(crate) application_invocation_executor: @@ -285,6 +289,7 @@ impl McpServerConstructionContext { verified_graph_query_port: None, code_index_ignored_dependency_admission: None, code_index_search_authority: None, + admitted_project_scope: None, retained_project_server_resolver: None, project_routes: crate::mcp::project_route::SharedHookProjectRouteCache::default(), application_invocation_executor: None, @@ -391,6 +396,7 @@ impl McpServerConstructionContext { verified_graph_query_port: None, code_index_ignored_dependency_admission: None, code_index_search_authority: None, + admitted_project_scope: None, retained_project_server_resolver: None, project_routes, application_invocation_executor: None, @@ -458,6 +464,7 @@ impl McpServerConstructionContext { verified_graph_query_port: None, code_index_ignored_dependency_admission: None, code_index_search_authority: None, + admitted_project_scope: None, retained_project_server_resolver: None, project_routes, application_invocation_executor: None, @@ -557,6 +564,17 @@ impl McpServerConstructionContext { self } + /// Records the checkout project open resolved for this route, so handler + /// dispatch can bind every scoped authority to one admitted scope instead + /// of re-deriving identity from the request path. + pub(crate) fn with_admitted_project_scope( + mut self, + scope: tracedecay_contracts::ResolvedScope, + ) -> Self { + self.admitted_project_scope = Some(scope); + self + } + pub(crate) fn with_application_invocation_executor( mut self, executor: Arc, diff --git a/crates/tracedecay/src/mcp/server/requests/tool_dispatch.rs b/crates/tracedecay/src/mcp/server/requests/tool_dispatch.rs index aa3a9cded8..269e9721c8 100644 --- a/crates/tracedecay/src/mcp/server/requests/tool_dispatch.rs +++ b/crates/tracedecay/src/mcp/server/requests/tool_dispatch.rs @@ -333,6 +333,7 @@ impl McpServer { .cloned(), source_edit_rollback_executor: self.source_edit_rollback_executor.get().cloned(), code_index_search_authority: self.code_index_search_authority.clone(), + admitted_project_scope: self.admitted_project_scope.clone(), code_graph_projection_read_port: self.code_graph_projection_read_port.clone(), code_graph_read_admission_port: self.code_graph_read_admission_port.clone(), verified_graph_query_port: self.verified_graph_query_port.clone(), diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs index 1ef0ae01a6..3c524787ca 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs @@ -14,12 +14,17 @@ 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; use tracedecay_mcp::handlers::analysis as portable_analysis; use tracedecay_mcp::handlers::ast_grep as portable_ast_grep; +use tracedecay_mcp::handlers::git; use tracedecay_mcp::handlers::graph as portable_graph; use tracedecay_mcp::handlers::grep as portable_grep; use tracedecay_mcp::handlers::info as portable_info; +use tracedecay_mcp::{ + AdmittedCodeIndex, AdmittedProjectStore, McpToolBinding, McpToolContext, RequestControls, + ToolResult, +}; +use tracedecay_temporal_query::resolution::ValidatedAuthorization; use super::ToolCallRegistryOptions; use super::support::{effective_path, generic_tool_result, unique_file_paths}; @@ -27,7 +32,7 @@ use super::tool_call_support::handle_retrieve; use super::unknown_tool_error; use super::{ admin_cli, admin_project, application_surface, automation_runs, dashboard, dispatch_controls, - edit, git, graph, hook_runtime, info, skills, workflow, + edit, graph, hook_runtime, info, skills, workflow, }; mod health_dispatch; @@ -504,8 +509,7 @@ fn dispatch_graph_tools_inner<'a>( options.code_index_search_authority.as_ref(), options.code_index_ignored_dependency_admission.as_deref(), options.code_index_freshness_reader.as_ref(), - options.application_deadline.clone(), - options.application_cancellation.clone(), + &admitted_tool_context(cg, &options)?, ) .await } @@ -541,8 +545,7 @@ fn dispatch_graph_tools_inner<'a>( options.code_index_search_executor.as_ref(), options.code_index_search_authority.as_ref(), options.code_index_freshness_reader.as_ref(), - options.application_deadline.clone(), - options.application_cancellation.clone(), + &admitted_tool_context(cg, &options)?, ) .await } @@ -597,8 +600,7 @@ fn dispatch_graph_tools_inner<'a>( args, selected_scope_prefix, options.code_index_ignored_dependency_admission.as_deref(), - options.application_deadline.as_ref(), - options.application_cancellation.as_ref(), + &admitted_tool_context(cg, &options)?, ) .await } @@ -1027,20 +1029,21 @@ fn dispatch_git_tools_inner<'a>( // also tells the underlying operation to stop at its next checkpoint. let carried_deadline = options.application_deadline.as_ref(); let remaining = carried_deadline.and_then(tracedecay_daemon_protocol::deadline_remaining); + let ctx = admitted_tool_context(cg, &options)?; let handler = async { match tool_name { "tracedecay_affected" => { let graph = admitted_graph_query(cg, &options, "file_dependents").await?; - git::handle_affected(cg, &graph, args).await + git::handle_affected(&ctx, &graph, args).await } "tracedecay_diff_context" => { let graph = admitted_graph_query(cg, &options, "file_dependents").await?; - git::handle_diff_context(cg, &graph, args).await + git::handle_diff_context(&ctx, &graph, args).await } "tracedecay_changelog" => { git::handle_changelog( - cg, + &ctx, admitted_graph_query(cg, &options, "file_dependents"), args, ) @@ -1048,54 +1051,19 @@ fn dispatch_git_tools_inner<'a>( } "tracedecay_commit_context" => { let graph = admitted_graph_query(cg, &options, "file_dependents").await?; - git::handle_commit_context(cg, &graph, args).await + git::handle_commit_context(&ctx, &graph, args).await } "tracedecay_pr_context" => { - let deadline = options.application_deadline.clone(); - let cancellation = options.application_cancellation.clone(); - let registered_project_session_db = - options.registered_project_session_db.clone(); git::handle_pr_context( - cg, + &ctx, admitted_graph_query(cg, &options, "file_dependents"), args, - deadline, - cancellation, - registered_project_session_db, - ) - .await - } - "tracedecay_branch_search" => { - git::handle_branch_search( - cg, - args, - options.code_index_search_executor.as_ref(), - options.code_index_search_authority.as_ref(), - options.application_deadline.clone(), - options.application_cancellation.clone(), - ) - .await - } - "tracedecay_branch_diff" => { - git::handle_branch_diff( - cg, - args, - options.code_index_branch_diff_executor.as_ref(), - options.code_index_search_authority.as_ref(), - options.application_deadline.clone(), - options.application_cancellation.clone(), - ) - .await - } - "tracedecay_branch_list" => { - git::handle_branch_list( - cg, - args, - options.application_deadline.clone(), - options.application_cancellation.clone(), ) .await } + "tracedecay_branch_search" => git::handle_branch_search(&ctx, args).await, + "tracedecay_branch_diff" => git::handle_branch_diff(&ctx, args).await, + "tracedecay_branch_list" => git::handle_branch_list(&ctx, args).await, _ => Err(unknown_tool_error(tool_name)), } }; @@ -1103,12 +1071,12 @@ fn dispatch_git_tools_inner<'a>( match (carried_deadline.is_some(), remaining) { (_, Some(remaining)) => match tokio::time::timeout(remaining, handler).await { Ok(result) => result, - Err(_elapsed) => Ok(git::git_dispatch_deadline_result(cg, tool_name)), + Err(_elapsed) => Ok(git::git_dispatch_deadline_result(&ctx, tool_name)), }, // `deadline_remaining` yields `None` for a non-positive budget, so a // carried deadline that already elapsed must be rejected rather than // dispatched unbounded. - (true, None) => Ok(git::git_dispatch_deadline_result(cg, tool_name)), + (true, None) => Ok(git::git_dispatch_deadline_result(&ctx, tool_name)), // Standalone / non-admission callers carry no deadline and stay // unbounded. (false, None) => handler.await, @@ -1116,6 +1084,65 @@ fn dispatch_git_tools_inner<'a>( }) } +/// Binds the admitted authorities a moved handler family reads. +/// +/// Everything the family may touch — the resolved project scope, the caller's +/// deadline and cancellation, the registered project session store that +/// authenticates PR-context cursors, and the daemon-owned code-index executors +/// with the authorization proved for them — crosses into `tracedecay-mcp` as +/// one validated binding, under the single checkout the serving route was +/// admitted for. An authority the daemon did not admit stays absent and the +/// handler reports its own typed unavailable state; an authority that +/// contradicts the admitted checkout refuses the whole call. +fn admitted_tool_context<'a>( + cg: &'a TraceDecay, + options: &'a ToolCallRegistryOptions<'a>, +) -> Result> { + // Project open resolves one checkout per served route and publishes it + // alongside the authorities that mount behind it, so this is the checkout + // every scoped authority below belongs to. Absent, the request never + // resolved a project: a scoped authority offered without it is dropped + // here rather than read against whatever project it happens to hold. + let scope = options.admitted_project_scope.as_ref(); + // Executors and their admission envelope are published together by the + // route. Presenting executors without the envelope is a wiring fault, not + // a capability to report: they would authenticate nothing. + let code_index = match ( + scope.and(options.code_index_search_authority.as_ref()), + options.code_index_search_executor.as_ref(), + options.code_index_branch_diff_executor.as_ref(), + ) { + (Some(authority), search, branch_diff) => { + Some(AdmittedCodeIndex::new(authority, search, branch_diff)?) + } + (None, None, None) => None, + (None, _, _) => { + return Err(TraceDecayError::project_route( + "mcp_tool_binding_code_index_without_authority", + false, + "code-index executors were admitted without the admitted scope and read admission envelope they authenticate against", + )); + } + }; + // The daemon opened this store for the route it admitted, which is the + // authorization this request carries. `bind` proves independently that the + // lease's own logical shard names that project before any handler reads it. + let project_session_store = scope + .and(options.registered_project_session_db.as_ref()) + .map(|lease| AdmittedProjectStore::new(lease, ValidatedAuthorization::Authorized)); + Ok(McpToolContext::bind(McpToolBinding { + project_root: cg.project_root(), + active_branch: cg.active_branch(), + controls: RequestControls { + deadline: options.application_deadline.as_ref(), + cancellation: options.application_cancellation.as_ref(), + }, + scope, + project_session_store, + code_index, + })?) +} + /// Dispatch source-editing tools (`tracedecay_str_replace`, /// `tracedecay_move_symbol`, ...). #[hotpath::measure(future = true, label = "mcp.dispatch.edit")] diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs index 2f4c4df89e..281df333f3 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs @@ -225,6 +225,7 @@ fn verified_graph_options_with_freshness<'a>( store, freshness, })); + options.admitted_project_scope = Some(scope.clone()); options.code_graph_read_admission_port = Some(Arc::new(FixtureCodeGraphAdmission { scope })); options.verified_graph_query_port = Some( crate::tracedecay::queries::graph::admitted_verified_graph_query_port_with_source( diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs index 8814555414..53fa12b53b 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs @@ -1338,79 +1338,6 @@ async fn pr_context_returns_git_evidence_while_verified_graph_is_unavailable() { cg.close(); } -#[tokio::test] -async fn pr_context_propagates_terminal_graph_failures_without_a_cursor() { - let _env_lock = lock_user_data_dir_test_env(); - let dir = TempDir::new().unwrap(); - let _env = SelectorEnv::new(dir.path()); - let project = dir.path().join("git-pr-context-terminal-graph"); - fs::create_dir_all(project.join("src")).unwrap(); - run_git_in(&project, &["init", "-b", "main"]); - fs::write(project.join("src/lib.rs"), "pub fn before() {}\n").unwrap(); - run_git_in(&project, &["add", "."]); - run_git_in(&project, &["commit", "-m", "initial"]); - run_git_in(&project, &["switch", "-c", "feature"]); - fs::write( - project.join("src/lib.rs"), - "pub fn before() {}\npub fn after() {}\n", - ) - .unwrap(); - run_git_in(&project, &["add", "."]); - run_git_in(&project, &["commit", "-m", "change source"]); - - let (cg, _runtime) = TraceDecay::init_test_fixture_with_registered_runtime( - &project, - "project.mcp-git-pr-context-terminal-graph", - ) - .await - .unwrap(); - let terminal_errors = [ - tracedecay_graph_query::map_code_graph_read_runtime_error( - tracedecay_graph_query::CodeGraphReadError::Cancelled, - ), - tracedecay_graph_query::map_code_graph_read_runtime_error( - tracedecay_graph_query::CodeGraphReadError::Denied, - ), - tracedecay_graph_query::map_code_graph_read_runtime_error( - tracedecay_graph_query::CodeGraphReadError::Corrupt { - detail: "corrupt projection".to_owned(), - }, - ), - tracedecay_graph_query::map_code_graph_read_runtime_error( - tracedecay_graph_query::CodeGraphReadError::ResetRequired { - detail: "generation reset required".to_owned(), - }, - ), - tracedecay_graph_query::map_code_graph_read_runtime_error( - tracedecay_graph_query::CodeGraphReadError::InvalidRequest { - detail: "invalid graph request".to_owned(), - }, - ), - TraceDecayError::Config { - message: "graph configuration is invalid".to_owned(), - }, - ]; - - for error in terminal_errors { - let detail = error.to_string(); - let result = git::handle_pr_context( - &cg, - async move { Err::(error) }, - json!({"base_ref": "main", "head_ref": "HEAD", "format": "json"}), - None, - None, - None, - ) - .await; - assert!( - result.is_err(), - "terminal graph failure must not become partial success: {detail}" - ); - } - - cg.close(); -} - #[tokio::test] async fn graph_tools_reject_blank_node_ids_and_zero_depth_with_typed_errors() { let _env_lock = lock_user_data_dir_test_env(); diff --git a/crates/tracedecay/src/mcp/tools/handlers/git/mod.rs b/crates/tracedecay/src/mcp/tools/handlers/git/mod.rs deleted file mode 100644 index aadc5ec1f0..0000000000 --- a/crates/tracedecay/src/mcp/tools/handlers/git/mod.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Git-backed tool handlers. -//! -//! `shell` owns every `git` subprocess call; the other siblings turn its output -//! into tool payloads. This module holds the shared imports (siblings pick them -//! up through `use super::*`), the two shapes `shell` returns, and the argument -//! helpers used across siblings. - -mod affected; -mod branch; -mod context; -mod pr_context_cursor; -mod shell; - -pub(super) use affected::handle_affected; -pub(super) use branch::{handle_branch_diff, handle_branch_list, handle_branch_search}; -pub(super) use context::{ - handle_changelog, handle_commit_context, handle_diff_context, handle_pr_context, -}; - -use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::pin::Pin; - -use serde_json::{Value, json}; - -use super::support::{generic_tool_result, require_object_args, unique_file_paths}; -use crate::tracedecay::TraceDecay; -use tracedecay_domain::errors::{Result, TraceDecayError}; -use tracedecay_mcp::ToolResult; - -#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] -struct GitFileChange { - path: String, - status: &'static str, -} - -struct GitPrComparison { - base_oid: String, - head_oid: String, - merge_base: String, - changes: Vec, - commits: Vec, -} - -fn git_error_result(cg: &TraceDecay, args: &Value, operation: &str, message: &str) -> ToolResult { - let output = json!({ - "error": { - "kind": "git", - "operation": operation, - "message": message, - } - }); - generic_tool_result(Some(cg.project_root()), args, &output, vec![]) - .with_semantic_error(true) - .with_failure_message(message) -} - -/// Typed result returned when a git-dispatched tool exhausts the dispatch -/// deadline the daemon carried into `dispatch_git_tools`. -/// -/// Git tree walks, revwalks, diffs, and the branch-add index build are -/// unbounded on pathological or diverged inputs. When the carried deadline -/// elapses the caller must receive the same shaped, semantic error every other -/// git failure surfaces — never a bare hang or a panic. -pub(crate) fn git_dispatch_deadline_result(cg: &TraceDecay, tool_name: &str) -> ToolResult { - let message = - format!("git tool '{tool_name}' exceeded its dispatch deadline and was cancelled"); - git_error_result(cg, &json!({ "tool": tool_name }), "deadline", &message) -} - -fn require_string_array_arg(args: &Value, name: &str) -> Result> { - args.get(name) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) - .collect() - }) - .ok_or_else(|| TraceDecayError::Config { - message: format!("missing required parameter: {name} (array of strings)"), - }) -} - -fn clamped_depth_arg(args: &Value, name: &str, default: usize, max: usize) -> usize { - args.get(name) - .and_then(serde_json::Value::as_u64) - .map_or(default, |v| v.min(max as u64) as usize) -} - -fn matches_test_file( - path: &str, - custom_glob: Option<&glob::Pattern>, - files_with_inline_tests: &HashSet, -) -> bool { - if let Some(glob) = custom_glob { - glob.matches(path) - } else { - crate::tracedecay::is_test_file(path) || files_with_inline_tests.contains(path) - } -} diff --git a/crates/tracedecay/src/mcp/tools/handlers/git/pr_context_cursor.rs b/crates/tracedecay/src/mcp/tools/handlers/git/pr_context_cursor.rs deleted file mode 100644 index 3526cd8a20..0000000000 --- a/crates/tracedecay/src/mcp/tools/handlers/git/pr_context_cursor.rs +++ /dev/null @@ -1,184 +0,0 @@ -use super::*; -use serde::{Deserialize, Serialize}; -use tracedecay_domain::{ - RetrievalGrainV1, SessionId, SymbolOccurrenceId, TemporalModeV1, canonical_sha256, -}; -use tracedecay_global_db::RegisteredGlobalDb; -use tracedecay_session_temporal_store::GlobalDbCursorKeyProvider; -use tracedecay_temporal_query::cursor::{StableSortKey, encode_cursor, verify_cursor}; -use tracedecay_temporal_query::ports::{ - BindingDigest, KernelVersions, TemporalExecutionSnapshot, TemporalSnapshotRequest, - TemporalWatermarks, -}; -use tracedecay_temporal_query::resolution::ValidatedAuthorization; - -const PR_CONTEXT_CURSOR_SESSION: &str = "session.daemon.pr-context"; - -#[derive(Serialize)] -pub(super) struct PrContextCursorBinding<'a> { - pub protocol: &'static str, - pub project_root: &'a str, - pub base_oid: &'a str, - pub head_oid: &'a str, - pub merge_base: &'a str, - pub graph_generation: &'a str, - pub maximum_symbols: usize, - pub changes: &'a [GitFileChange], -} - -#[derive(Serialize, Deserialize)] -struct PrContextCursorKey<'a> { - symbol_occurrence_id: &'a str, - impact_nodes_admitted: usize, - direct_call_edges_admitted: usize, - impact_bytes_admitted: usize, -} - -pub(super) struct PrContextCursorPosition { - pub after: SymbolOccurrenceId, - pub impact_nodes_admitted: usize, - pub direct_call_edges_admitted: usize, - pub impact_bytes_admitted: usize, -} - -#[hotpath::measure(label = "mcp.git.cursor.authority")] -pub(super) async fn pr_context_cursor_authority( - session_db: &RegisteredGlobalDb, - binding: &PrContextCursorBinding<'_>, -) -> Result<(TemporalExecutionSnapshot, GlobalDbCursorKeyProvider)> { - let digest = hotpath::measure_block!( - "mcp.git.cursor.binding_digest", - canonical_sha256(binding).map_err(|error| TraceDecayError::Config { - message: format!("failed to bind PR context cursor: {error}"), - })? - ); - let graph_digest = canonical_sha256(&( - "tracedecay.pr-context.graph-generation.v1", - binding.graph_generation, - )) - .map_err(|error| TraceDecayError::Config { - message: format!("failed to bind PR context graph generation: {error}"), - })?; - let graph_generation_hex = graph_digest - .as_str() - .strip_prefix("sha256:") - .and_then(|hex| hex.get(..16)) - .ok_or_else(|| TraceDecayError::Config { - message: "invalid PR context graph generation digest".to_owned(), - })?; - let graph_generation = u64::from_str_radix(graph_generation_hex, 16) - .map_err(|error| TraceDecayError::Config { - message: format!("invalid PR context graph generation watermark: {error}"), - })? - .max(1); - let authenticator = hotpath::future!( - session_db.load_preprovisioned_session_cursor_key_provider_result(), - label = "mcp.git.cursor.key_provider" - ) - .await - .map_err(|error| { - TraceDecayError::project_route( - "pr_context_cursor_authority_unavailable", - true, - format!("pre-provisioned PR context cursor key is unavailable: {error}"), - ) - })?; - let key = authenticator.active_key_ref().clone(); - let request = TemporalSnapshotRequest::new( - SessionId::new(PR_CONTEXT_CURSOR_SESSION).map_err(|error| TraceDecayError::Config { - message: format!("invalid PR context cursor session: {error}"), - })?, - digest.as_str(), - digest.as_str(), - digest.as_str(), - TemporalModeV1::Current, - RetrievalGrainV1::Occurrence, - ) - .map_err(|error| TraceDecayError::Config { - message: format!("invalid PR context cursor binding: {error}"), - })?; - let configuration_digest = BindingDigest::new("configuration_digest", digest.as_str()) - .map_err(|error| TraceDecayError::Config { - message: format!("invalid PR context cursor configuration: {error}"), - })?; - let snapshot = TemporalExecutionSnapshot::new_authorized( - request, - TemporalWatermarks { - generation: graph_generation, - source: 1, - projection: 1, - index: 1, - summary: 1, - }, - KernelVersions { - schema: 1, - ranking: 1, - configuration_digest, - }, - Some(key), - ValidatedAuthorization::Authorized, - ) - .map_err(|error| TraceDecayError::Config { - message: format!("invalid PR context cursor snapshot: {error}"), - })?; - Ok((snapshot, authenticator)) -} - -#[hotpath::measure(label = "mcp.git.cursor.decode")] -pub(super) fn decode_pr_context_cursor( - encoded: &str, - snapshot: &TemporalExecutionSnapshot, - authenticator: &GlobalDbCursorKeyProvider, -) -> Result { - let sort_key = verify_cursor(encoded, snapshot, authenticator).map_err(|error| { - TraceDecayError::Config { - message: format!("invalid or stale PR context cursor: {error}"), - } - })?; - let key: PrContextCursorKey<'_> = - serde_json::from_str(&sort_key.stable_id).map_err(|_| TraceDecayError::Config { - message: "invalid PR context cursor key".to_owned(), - })?; - Ok(PrContextCursorPosition { - after: SymbolOccurrenceId::new(key.symbol_occurrence_id.to_owned()).map_err(|error| { - TraceDecayError::Config { - message: format!("invalid PR context symbol cursor: {error}"), - } - })?, - impact_nodes_admitted: key.impact_nodes_admitted, - direct_call_edges_admitted: key.direct_call_edges_admitted, - impact_bytes_admitted: key.impact_bytes_admitted, - }) -} - -#[hotpath::measure(label = "mcp.git.cursor.encode")] -pub(super) fn encode_pr_context_cursor( - after: &SymbolOccurrenceId, - impact_nodes_admitted: usize, - direct_call_edges_admitted: usize, - impact_bytes_admitted: usize, - snapshot: &TemporalExecutionSnapshot, - authenticator: &GlobalDbCursorKeyProvider, -) -> Result { - let stable_id = serde_json::to_string(&PrContextCursorKey { - symbol_occurrence_id: after.as_str(), - impact_nodes_admitted, - direct_call_edges_admitted, - impact_bytes_admitted, - }) - .map_err(|error| TraceDecayError::Config { - message: format!("failed to encode PR context cursor key: {error}"), - })?; - encode_cursor( - snapshot, - &StableSortKey { - normalized_score_micros: 0, - knowledge_at_micros: 0, - stable_id, - }, - authenticator, - ) - .map_err(|error| TraceDecayError::Config { - message: format!("failed to issue PR context cursor: {error}"), - }) -} diff --git a/crates/tracedecay/src/mcp/tools/handlers/graph.rs b/crates/tracedecay/src/mcp/tools/handlers/graph.rs index a6890141c3..e8ebf6983c 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/graph.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/graph.rs @@ -21,13 +21,14 @@ use crate::tracedecay::TraceDecay; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_mcp::context_headings::CONTEXT_SEEN_NODE_IDS_LABEL; -use super::dependency_hints; use super::support::{ self, CONTEXT_MEMORY_ANALYTICS_KEY, decode_primitive_request, take_internal_context_memory_analytics, text_tool_result, unique_file_paths, }; -use tracedecay_mcp::ToolResult; +use tracedecay_mcp::handlers::dependency_hints; +use tracedecay_mcp::handlers::support::retrieval_cursor; use tracedecay_mcp::tools::render::{self, Md}; +use tracedecay_mcp::{McpToolContext, ToolResult}; mod context_support; mod lexical_routing; @@ -234,12 +235,13 @@ pub(super) async fn handle_search( &dyn tracedecay_application::code_index::CodeIndexIgnoredDependencyAdmissionPortV1, >, freshness_reader: Option<&CodeIndexFreshnessReader>, - deadline: Option, - cancellation: Option, + ctx: &McpToolContext<'_>, ) -> Result where F: Future>, { + let deadline = ctx.deadline().cloned(); + let cancellation = ctx.cancellation().cloned(); let query = args.get("query") .and_then(|v| v.as_str()) @@ -250,7 +252,7 @@ where let semantic_mode = semantic_search_mode(&args)?; let lexical_routing = lexical_routing::routing_from_args(&args)?; let lazy_indexing_requested = dependency_hints::lazy_indexing_requested(&args); - let cursor = support::retrieval_cursor(&args)?; + let cursor = retrieval_cursor(&args)?; let include_graph_node_ids = render::wants_json(&args); let limit = args .get("limit") @@ -322,12 +324,11 @@ where preserve_complete_search_after_lazy_admission( hotpath::future!( dependency_hints::admit_verified_ignored_dependency( + ctx, ignored_dependency_admission, &graph, query, - scope_prefix, - deadline.as_ref(), - cancellation.as_ref() + scope_prefix ), label = "mcp.graph.search.admit" ) @@ -406,13 +407,7 @@ where if (scope_prefix.is_some() || dependency_hints::should_check_external_import_hint(result_count, limit)) && let Some(hint) = graph_evidence - .external_import_hint( - query, - limit, - scope_prefix, - deadline.as_ref(), - cancellation.as_ref(), - ) + .external_import_hint(ctx, query, limit, scope_prefix) .await { output["external_import_hint"] = hint; @@ -797,12 +792,13 @@ pub(super) async fn handle_context( search_executor: Option<&crate::mcp::server::CodeIndexSearchExecutor>, search_authority: Option<&crate::mcp::server::CodeIndexSearchAuthorityV1>, freshness_reader: Option<&CodeIndexFreshnessReader>, - deadline: Option, - cancellation: Option, + ctx: &McpToolContext<'_>, ) -> Result where F: Future>, { + let deadline = ctx.deadline().cloned(); + let cancellation = ctx.cancellation().cloned(); let request: ContextSurfaceRequestV1 = decode_primitive_request(&args, "tracedecay_context")?; let task = request.task.as_str(); let mode = request.mode.unwrap_or(ContextModeV1::Explore); @@ -1046,8 +1042,7 @@ pub(super) async fn handle_find_exact_symbol( ignored_dependency_admission: Option< &dyn tracedecay_application::code_index::CodeIndexIgnoredDependencyAdmissionPortV1, >, - deadline: Option<&tracedecay_contracts::Deadline>, - cancellation: Option<&tracedecay_contracts::CancellationSignal>, + ctx: &McpToolContext<'_>, ) -> Result { let name = args.get("name") @@ -1067,12 +1062,11 @@ pub(super) async fn handle_find_exact_symbol( if nodes.is_empty() && dependency_hints::lazy_indexing_requested(&args) { hotpath::future!( dependency_hints::admit_verified_ignored_dependency( + ctx, ignored_dependency_admission, graph, name, scope_prefix, - deadline, - cancellation, ), label = "mcp.graph.find_exact_symbol.admit" ) diff --git a/crates/tracedecay/src/mcp/tools/handlers/graph/search_evidence.rs b/crates/tracedecay/src/mcp/tools/handlers/graph/search_evidence.rs index 148f636ccf..6bc0bd219f 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/graph/search_evidence.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/graph/search_evidence.rs @@ -7,7 +7,8 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_graph_query::VerifiedGraphQuery; use tracedecay_mcp::tools::render::Md; -use super::super::dependency_hints; +use tracedecay_mcp::McpToolContext; +use tracedecay_mcp::handlers::dependency_hints; #[hotpath::measure(future = true, label = "mcp.graph.search_race")] pub(super) async fn race_primary_search_with_graph( @@ -131,26 +132,19 @@ impl<'a> SearchGraphEvidence<'a> { #[hotpath::measure(future = true, label = "mcp.graph.import_hint")] pub(super) async fn external_import_hint( &self, + ctx: &McpToolContext<'_>, query: &str, limit: usize, scope_prefix: Option<&str>, - deadline: Option<&tracedecay_contracts::Deadline>, - cancellation: Option<&tracedecay_contracts::CancellationSignal>, ) -> Option { match self.graph { - Ok(graph) => match dependency_hints::external_import_hint( - graph, - query, - limit, - scope_prefix, - deadline, - cancellation, - ) - .await - { - Ok(hint) => hint, - Err(error) => Some(dependency_hints::unavailable_hint(&error)), - }, + Ok(graph) => { + match dependency_hints::external_import_hint(ctx, graph, query, limit, scope_prefix) + { + Ok(hint) => hint, + Err(error) => Some(dependency_hints::unavailable_hint(&error)), + } + } Err(error) => Some(dependency_hints::unavailable_hint(error)), } } diff --git a/crates/tracedecay/src/mcp/tools/handlers/mod.rs b/crates/tracedecay/src/mcp/tools/handlers/mod.rs index 89d3a72b37..cd51963d52 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/mod.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/mod.rs @@ -46,7 +46,6 @@ mod configuration_dispatch_tests; clippy::uninlined_format_args )] mod context_scout_control_dispatch_tests; -mod dependency_hints; mod dispatch_controls; mod dispatch_groups; #[cfg(test)] @@ -68,7 +67,6 @@ mod dispatch_test_support; )] mod dispatch_tests; pub mod edit; -pub mod git; pub mod graph; pub mod health; pub mod hook_runtime; @@ -300,6 +298,10 @@ pub struct ToolCallRegistryOptions<'a> { pub(crate) source_edit_rollback_executor: Option, pub(crate) code_index_search_authority: Option, + /// The checkout the serving route was admitted for. Every scoped authority + /// a moved handler family reads binds against this one scope; absent, no + /// scoped authority may be admitted at all. + pub(crate) admitted_project_scope: Option, pub(crate) code_graph_projection_read_port: Option, pub(crate) code_graph_read_admission_port: @@ -369,6 +371,7 @@ impl Default for ToolCallRegistryOptions<'_> { source_edit_reconciliation_executor: None, source_edit_rollback_executor: None, code_index_search_authority: None, + admitted_project_scope: None, code_graph_projection_read_port: None, code_graph_read_admission_port: None, verified_graph_query_port: None, diff --git a/crates/tracedecay/src/mcp/tools/handlers/support.rs b/crates/tracedecay/src/mcp/tools/handlers/support.rs index 3d85f3c0f5..50d87880b1 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/support.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/support.rs @@ -14,29 +14,6 @@ use tracedecay_global_db::{ProjectRegistryContext, RegisteredGlobalDb}; use tracedecay_mcp::ToolResult; use tracedecay_mcp::tools::render; -/// Builds a `Config` error from a message, for argument-validation failures. -pub(super) fn argument_error(message: impl Into) -> TraceDecayError { - TraceDecayError::Config { - message: message.into(), - } -} - -pub(super) fn retrieval_cursor(args: &Value) -> Result> { - let Some(encoded) = args.get("cursor").and_then(Value::as_str) else { - return Ok(None); - }; - if encoded.len() > 4_096 { - return Err(argument_error( - "cursor exceeds its bounded authenticated envelope", - )); - } - let cursor: tracedecay_domain::RetrievalCursor = serde_json::from_str(encoded)?; - cursor.validate().map_err(|_| { - argument_error("cursor is not a valid authenticated retrieval continuation") - })?; - Ok(Some(cursor)) -} - /// Key under which context handlers stash analytics that must reach the server /// but never the client. [`rendered_tool_result`] is the one place it is lifted /// back out, so no handler has to remember to strip it.