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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions crates/tracedecay-cli/tests/core_cli_suite/cli_boundary.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::path::Path;
use std::process::Command;

use tracedecay_mcp::get_tool_definitions_with_budget;
use tracedecay_mcp::get_tool_definitions;

use crate::common::apply_isolated_profile_env;

Expand Down Expand Up @@ -30,10 +30,10 @@ fn shipped_binary_stops_quietly_when_a_pipeline_reader_exits() {
);
}

/// Help runs without a project graph, so it describes the tool without a
/// node count or call budget it cannot compute.
/// Help and `tools/list` share one static description, so help cannot drift
/// from what hosts see.
#[test]
fn context_help_describes_the_tool_without_a_project_size() {
fn context_help_describes_the_tool_with_the_advertised_description() {
let home = tempfile::tempdir().expect("isolated home");
let output = sandboxed_command(crate::tracedecay_exe(), home.path())
.args(["tool", "context", "--help"])
Expand All @@ -43,16 +43,12 @@ fn context_help_describes_the_tool_without_a_project_size() {
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8_lossy(&output.stdout);
let description = stdout.lines().nth(2).expect("description line");
let sized = get_tool_definitions_with_budget(6_000, 5)
.expect("budgeted definitions")
let advertised = get_tool_definitions()
.expect("definitions")
.into_iter()
.find(|definition| definition.name == "tracedecay_context")
.expect("context definition")
.description;
assert_eq!(
sized,
format!("{description} This project (6000 nodes) allows 5 broad context calls."),
"{stdout}"
);
assert_eq!(advertised, description, "{stdout}");
assert!(!description.contains("nodes"), "{stdout}");
}
59 changes: 6 additions & 53 deletions crates/tracedecay-mcp-catalog/src/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,51 +169,14 @@ fn project_selector_object(description: &str) -> Value {
})
}

/// Computes the call budget based on project size.
pub fn explore_call_budget(total_nodes: u64) -> u8 {
match total_nodes {
0..=5_000 => 3,
5_001..=20_000 => 4,
20_001..=80_000 => 5,
80_001..=250_000 => 7,
_ => 10,
}
}

/// The `tracedecay_context` description for surfaces that have no project
/// graph to size, such as `tracedecay tool context --help`.
/// The static `tracedecay_context` description. Tool descriptions are cached by
/// hosts for a whole session, so they carry no runtime state; live state
/// belongs in each response's `freshness:` and `indexing:` lines.
const CONTEXT_DESCRIPTION: &str = "Build an AI-ready context for a task description. Returns \
relevant symbols, relationships, up to three untracked project memory matches when \
available, and optionally code snippets. Use it for broad questions that need \
relationship synthesis across the code graph.";

/// Generates the `tracedecay_context` description with a dynamic call budget.
pub fn context_description(node_count: u64, budget: u8) -> String {
format!(
"{CONTEXT_DESCRIPTION} This project ({node_count} nodes) allows {budget} broad \
context calls."
)
}

/// Returns tool definitions with a dynamic call budget for `tracedecay_context`.
pub fn get_tool_definitions_with_budget(
node_count: u64,
budget: u8,
) -> Result<Vec<ToolDefinition>, McpCatalogError> {
let mut defs = get_tool_definitions()?;
apply_context_budget(&mut defs, node_count, budget);
Ok(defs)
}

fn apply_context_budget(defs: &mut [ToolDefinition], node_count: u64, budget: u8) {
// Replace the context tool's description with the budgeted version
for def in defs {
if def.name == "tracedecay_context" {
def.description = context_description(node_count, budget);
}
}
}

pub fn project_catalog_discovery_scope() -> BTreeSet<ScopeDimension> {
[
ScopeDimension::Project,
Expand All @@ -232,14 +195,6 @@ pub fn project_catalog_discovery_scope() -> BTreeSet<ScopeDimension> {
.collect()
}

/// The `tracedecay_context` description while the project graph is still warming.
pub fn context_warming_description(budget: u8) -> String {
format!(
"{CONTEXT_DESCRIPTION} The graph is still warming, and {budget} broad context calls \
are available."
)
}

/// Returns the list of all tool definitions exposed by this MCP server.
pub fn get_tool_definitions() -> Result<Vec<ToolDefinition>, McpCatalogError> {
Ok(get_tool_definitions_ref()?.to_vec())
Expand Down Expand Up @@ -293,11 +248,9 @@ fn build_search_cli_definition() -> Result<ToolDefinition, McpCatalogError> {
/// Borrow the registry, assembled once per process.
///
/// Every input is static for the life of the process: the application catalog
/// is a `LazyLock` snapshot. Nothing session-scoped is frozen here, the
/// per-session passes (the context description budget and the
/// profile/capability filtering in
/// `get_catalog_filtered_tool_definitions_with_budget`) all run on a clone,
/// after the cache.
/// is a `LazyLock` snapshot. Descriptions carry no runtime state, and the
/// per-session profile/capability filtering in
/// `get_catalog_filtered_tool_definitions` runs on a clone, after the cache.
pub fn get_tool_definitions_ref() -> Result<&'static [ToolDefinition], McpCatalogError> {
// The error type is not `Clone`, and a failure here is a deterministic
// catalog/schema defect rather than a transient condition, so the cache
Expand Down
58 changes: 14 additions & 44 deletions crates/tracedecay-mcp-catalog/src/definitions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,52 +292,22 @@ fn search_cli_definition_matches_the_advertised_contract() {
);
}

/// Caching the registry must not freeze anything session-scoped into it.
///
/// The per-session passes mutate the vector they are handed, so every caller
/// has to receive an independent clone. If the cache handed out shared state,
/// one session's context budget would be visible to the next.
/// Hosts cache `tools/list` for a session, so no description may embed state
/// that changes while it runs; that state belongs in response headers.
#[test]
fn per_session_budget_does_not_leak_through_the_cached_registry() {
fn context_description(definitions: &[ToolDefinition]) -> String {
definitions
.iter()
.find(|definition| definition.name == "tracedecay_context")
.map(|definition| definition.description.clone())
.expect("tracedecay_context is advertised")
fn context_description_carries_no_runtime_state() {
let definitions = get_tool_definitions().expect("tool definitions");
let description = &definitions
.iter()
.find(|definition| definition.name == "tracedecay_context")
.expect("tracedecay_context is advertised")
.description;
for stale in ["warming", "broad context calls", "nodes"] {
assert!(
!description.contains(stale),
"context description must be static, found {stale:?}: {description}"
);
}

let small = get_tool_definitions_with_budget(11, 2).expect("tool definitions");
let small_description = context_description(&small);
assert!(
small_description.contains("2 broad context calls"),
"budget must reach the context description: {small_description}"
);

let large = get_tool_definitions_with_budget(999_999, 9).expect("tool definitions");
let large_description = context_description(&large);
assert!(
large_description.contains("9 broad context calls"),
"budget must reach the context description: {large_description}"
);

assert_ne!(
small_description, large_description,
"two sessions with different budgets must not share one description"
);
assert_eq!(
context_description(&small),
small_description,
"the earlier session's definitions must not be rewritten by a later one"
);

// A third, unbudgeted read must still see the neutral registry.
let neutral = get_tool_definitions().expect("tool definitions");
let neutral_description = context_description(&neutral);
assert!(
!neutral_description.contains("9 broad context calls"),
"an unbudgeted caller inherited another session's budget: {neutral_description}"
);
}

#[test]
Expand Down
8 changes: 3 additions & 5 deletions crates/tracedecay-mcp-catalog/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,9 @@ mod project_access;
pub use catalog_error::McpCatalogError;
pub use category::ToolCategory;
pub use definitions::{
ALWAYS_LOAD_META, RESPONSE_RETRIEVE_TOOL, cli_tool_definition, context_description,
context_warming_description, explore_call_budget, format_capable_tool_names,
get_tool_definitions, get_tool_definitions_ref, get_tool_definitions_with_budget,
mcp_input_schema, project_catalog_discovery_scope, tool_defaults_to_markdown, work_tool_name,
workflow_tool_name,
ALWAYS_LOAD_META, RESPONSE_RETRIEVE_TOOL, cli_tool_definition, format_capable_tool_names,
get_tool_definitions, get_tool_definitions_ref, mcp_input_schema,
project_catalog_discovery_scope, tool_defaults_to_markdown, work_tool_name, workflow_tool_name,
};
pub use project_access::registered_project_reader_tool_names;

Expand Down
7 changes: 3 additions & 4 deletions crates/tracedecay-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,9 @@ pub use tools::{
};
pub use tracedecay_mcp_catalog::{
ALWAYS_LOAD_META, MAX_RESPONSE_CHARS, McpCatalogError, ToolCategory, ToolDefinition,
cli_tool_definition, context_description, context_warming_description, explore_call_budget,
format_capable_tool_names, get_tool_definitions, get_tool_definitions_ref,
get_tool_definitions_with_budget, mcp_input_schema, project_catalog_discovery_scope,
registered_project_reader_tool_names, tool_defaults_to_markdown,
cli_tool_definition, format_capable_tool_names, get_tool_definitions, get_tool_definitions_ref,
mcp_input_schema, project_catalog_discovery_scope, registered_project_reader_tool_names,
tool_defaults_to_markdown,
};
pub use workflow::{
LibtestReport, MAX_TEST_TIMEOUT_SECS, RunAffectedArgs, TestProfile, TestRunControl,
Expand Down
8 changes: 3 additions & 5 deletions crates/tracedecay-mcp/src/tools/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1295,16 +1295,14 @@ mod tests {
];

fn advertised_tool_names() -> Vec<String> {
use crate::project_catalog_discovery_scope;
use crate::tools::catalog_discovery::{
default_catalog_discovery_authority, get_catalog_filtered_tool_definitions_with_budget,
default_catalog_discovery_authority, get_catalog_filtered_tool_definitions,
};
use crate::{explore_call_budget, project_catalog_discovery_scope};

let profile_id = ProfileId::new(tracedecay_contracts::APPLICATION_DEFAULT_PROFILE_ID)
.expect("default profile");
get_catalog_filtered_tool_definitions_with_budget(
0,
explore_call_budget(0),
get_catalog_filtered_tool_definitions(
&profile_id,
&default_catalog_discovery_authority().expect("default discovery authority"),
&project_catalog_discovery_scope(),
Expand Down
Loading
Loading