diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e6d95a..1484ff3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `burn mcp-server` now exposes summary, hotspots, overhead attribution, + overhead trimming, and model comparison through validated read-only tools. - Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers. - `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00. diff --git a/README.md b/README.md index a8adab43..16cdfd1e 100644 --- a/README.md +++ b/README.md @@ -170,16 +170,22 @@ MCP. The server is stdio-only and read-only. | Option | What it does | |---|---| -| `--session-id ` | Default session ID used by MCP tools when the caller omits one. | +| `--session-id ` | Default session ID used by session cost, summary, and hotspots when the caller omits one. | | Tool | What it returns | |---|---| | `burn__sessionCost` | Total USD, tokens, turns, and models for a session. | +| `burn__fingerprint` | Cheap change-detection fingerprint for the ledger or a session/project scope. | +| `burn__summary` | Token use and cost by tool and model, with optional session, project, time, and enrichment filters. | +| `burn__hotspots` | Attribution, grouped hotspots, or findings for expensive and repeated activity. | +| `burn__overhead` | Instruction-file token overhead and cost by file and section. | +| `burn__overheadTrim` | Ranked instruction-file trimming recommendations and projected savings. | +| `burn__compare` | Per-model, per-activity cost and outcome comparison. | | Example | Result | |---|---| -| `burn mcp-server --session-id ` | Start a session-scoped stdio MCP server. | -| `burn mcp-server` | Start a server where tools require explicit session IDs. | +| `burn mcp-server --session-id ` | Start a stdio server whose session cost, summary, and hotspots tools default to that session. | +| `burn mcp-server` | Start an unscoped stdio server; callers can pass filters explicitly, while overhead and compare remain cross-session by design. | ## `burn state` diff --git a/crates/relayburn-cli/src/cli.rs b/crates/relayburn-cli/src/cli.rs index 31275a63..b95082b6 100644 --- a/crates/relayburn-cli/src/cli.rs +++ b/crates/relayburn-cli/src/cli.rs @@ -221,18 +221,17 @@ pub struct IngestArgs { /// Per-command flags for `burn mcp-server`. The stdio MCP server speaks /// JSON-RPC 2.0 line-delimited frames over stdin/stdout and exposes the -/// `burn__sessionCost` read-only tool. Closes #210. +/// read-only burn MCP tool catalog. /// /// Global `--ledger-path` (on [`Args`]) is consulted as the SDK ledger -/// home. `--session-id` registers a default session id so MCP clients -/// that omit `sessionId` in `tools/call` get a useful answer (the -/// running agent's own session). +/// home. `--session-id` registers a default session id so MCP clients that +/// omit a session selector get a useful answer (the running agent's own +/// session). #[derive(Debug, Clone, ClapArgs)] pub struct McpServerArgs { - /// Default sessionId to use when `tools/call burn__sessionCost` - /// omits the argument. Lets the host wrap the server with the - /// running agent's own session id so the agent can self-query - /// without knowing it. + /// Default sessionId used by sessionCost, summary, and hotspots when + /// their session argument is omitted. Lets the host register the running + /// agent's own session so it can self-query without knowing the id. #[arg(long = "session-id", value_name = "ID")] pub session_id: Option, diff --git a/crates/relayburn-cli/src/commands/mcp_server.rs b/crates/relayburn-cli/src/commands/mcp_server.rs index 6332a8fd..09af2cb4 100644 --- a/crates/relayburn-cli/src/commands/mcp_server.rs +++ b/crates/relayburn-cli/src/commands/mcp_server.rs @@ -10,21 +10,23 @@ //! protocol evolves, this module is localized enough to update in one //! place — same trade-off the TS sibling makes. //! -//! Tool surface today: `burn__sessionCost` (compact session cost shape) -//! and `burn__fingerprint` (cheap polling primitive — see #440). Both -//! are thin SDK wrappers, mirroring `packages/mcp/src/tools/*.ts` 1:1. -//! Other tools (`summary`, `hotspots`, …) are tracked as follow-ups so -//! the scope of D8 stays tight. +//! The seven read-only tools mirror `packages/mcp/src/tools/*.ts`: session +//! cost, fingerprint, summary, hotspots, overhead attribution, overhead +//! trimming, and model comparison. Every tool is a thin wrapper around a +//! [`LedgerHandle`] verb; this presenter owns only MCP schema validation and +//! result framing. use std::io::{BufRead, Write}; use std::sync::Arc; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use relayburn_sdk::{ - FingerprintScope, Ledger, LedgerHandle, LedgerOpenOptions, SessionCostOptions, - SessionCostResult, + CompareOptions, Enrichment, FingerprintScope, HotspotsOptions, Ledger, LedgerHandle, + LedgerOpenOptions, OverheadOptions, OverheadTrimOptions, SessionCostOptions, SessionCostResult, + SummaryOptions, }; use crate::cli::{GlobalArgs, McpServerArgs}; @@ -241,56 +243,7 @@ impl Server { } fn handle_tools_list(&self, id: &Value) { - let tools = json!([ - { - "name": "burn__sessionCost", - "description": - "Return the total cost (USD), token count, and turn count for a session. \ - Defaults to the server's registered sessionId (the running agent's own \ - session). Read-only.", - "inputSchema": { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": - "Override the registered session id. Omit to query the running \ - agent's own session.", - }, - }, - "required": [], - "additionalProperties": false, - }, - }, - { - "name": "burn__fingerprint", - "description": - "Cheap polling primitive over the burn ledger. Returns \ - `{count}:{maxMtimeUnix}:{totalBytes}` — three integers \ - joined by colons. Clients keep the last-seen value and \ - skip re-querying when it's unchanged. Optionally scoped \ - to a session id or a project path. Read-only.", - "inputSchema": { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": - "Restrict to a single session_id. Mutually exclusive with project.", - }, - "project": { - "type": "string", - "description": - "Restrict to rows whose project path matches. Mutually exclusive \ - with sessionId.", - }, - }, - "required": [], - "additionalProperties": false, - }, - } - ]); - write_success(id, json!({ "tools": tools })); + write_success(id, json!({ "tools": tool_catalog() })); } async fn handle_tools_call(&self, id: &Value, params: &Value) { @@ -305,21 +258,31 @@ impl Server { return; }; let args = params.get("arguments").cloned().unwrap_or(json!({})); - match name { - "burn__sessionCost" => self.tool_session_cost(id, &args).await, - "burn__fingerprint" => self.tool_fingerprint(id, &args).await, - other => { - write_response(&error_envelope( - id, - -32601, - &format!("unknown tool: {other}"), - None, - )); - } + match self.call_tool(name, &args).await { + Some(result) => write_success(id, result), + None => write_response(&error_envelope( + id, + -32601, + &format!("unknown tool: {name}"), + None, + )), } } - async fn tool_fingerprint(&self, id: &Value, args: &Value) { + async fn call_tool(&self, name: &str, args: &Value) -> Option { + Some(match name { + "burn__sessionCost" => self.tool_session_cost(args).await, + "burn__fingerprint" => self.tool_fingerprint(args).await, + "burn__summary" => self.tool_summary(args).await, + "burn__hotspots" => self.tool_hotspots(args).await, + "burn__overhead" => self.tool_overhead(args).await, + "burn__overheadTrim" => self.tool_overhead_trim(args).await, + "burn__compare" => self.tool_compare(args).await, + _ => return None, + }) + } + + async fn tool_fingerprint(&self, args: &Value) -> Value { // Empty / missing args → AllSessions. `sessionId` and `project` // are mutually exclusive; if both are present, fail loud at // tool-error level rather than silently picking one. @@ -333,17 +296,7 @@ impl Server { .map(std::path::PathBuf::from); let scope = match (session, project) { (Some(_), Some(_)) => { - write_success( - id, - json!({ - "content": [{ - "type": "text", - "text": "fingerprint: pass at most one of sessionId / project", - }], - "isError": true, - }), - ); - return; + return tool_error("fingerprint: pass at most one of sessionId / project"); } (Some(s), None) => FingerprintScope::Session(s), (None, Some(p)) => FingerprintScope::Project(p), @@ -354,32 +307,13 @@ impl Server { let result = handle_guard.fingerprint(scope); drop(handle_guard); - let fp = match result { - Ok(fp) => fp, - Err(err) => { - write_success( - id, - json!({ - "content": [{ "type": "text", "text": err.to_string() }], - "isError": true, - }), - ); - return; - } - }; - - let payload = json!({ "fingerprint": fp.as_str() }); - let text = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()); - write_success( - id, - json!({ - "content": [{ "type": "text", "text": text }], - "structuredContent": payload, - }), - ); + match result { + Ok(fp) => tool_output(&json!({ "fingerprint": fp.as_str() })), + Err(err) => tool_error(err), + } } - async fn tool_session_cost(&self, id: &Value, args: &Value) { + async fn tool_session_cost(&self, args: &Value) -> Value { let override_id = args .get("sessionId") .and_then(|v| v.as_str()) @@ -388,16 +322,6 @@ impl Server { .clone() .or_else(|| self.default_session_id.clone()); - // Run the SDK call. `session_cost` is sync but we already hold a - // ledger handle — call directly on it via - // `LedgerHandle::session_cost` so we don't re-open the ledger - // every call. The free `relayburn_sdk::session_cost` would - // open + close per call, which is wasteful for a long-lived - // server. - // - // This branch is intentionally cheap: the entire body is CPU / - // SQLite work, so the `await` below only yields if the global - // ledger lock is contended (it isn't — we're the only user). let opts = SessionCostOptions { session: session.clone(), ledger_home: None, @@ -408,20 +332,7 @@ impl Server { let mut payload: SessionCostResult = match result { Ok(r) => r, - Err(err) => { - let msg = err.to_string(); - // Per MCP convention: tool errors are non-throwing - // results with `isError: true`. Reserve JSON-RPC errors - // for protocol problems (parse / method-not-found). - write_success( - id, - json!({ - "content": [{ "type": "text", "text": msg }], - "isError": true, - }), - ); - return; - } + Err(err) => return tool_error(err), }; // Mirror TS: when no override and no registered default, surface @@ -433,16 +344,500 @@ impl Server { payload.note = Some("no session id provided and server was not registered with one".to_string()); } + tool_output(&payload) + } + + async fn tool_summary(&self, args: &Value) -> Value { + let input = match object_input( + args, + "summary", + &["session", "project", "since", "tags", "groupByTag"], + ) { + Ok(input) => input, + Err(err) => return tool_error(err), + }; + let opts = match (|| -> Result { + Ok(SummaryOptions { + session: optional_string(input, "session", "summary")? + .or_else(|| self.default_session_id.clone()), + project: optional_string(input, "project", "summary")?, + since: optional_string(input, "since", "summary")?, + tags: optional_string_record(input, "tags", "summary")?, + group_by_tag: optional_string(input, "groupByTag", "summary")?, + // Ledger selection belongs to the server's pre-opened handle. + ledger_home: None, + }) + })() { + Ok(opts) => opts, + Err(err) => return tool_error(err), + }; + let handle = self.handle.lock().await; + match handle.summary(opts) { + Ok(result) => tool_output(&result), + Err(err) => tool_error(err), + } + } + + async fn tool_hotspots(&self, args: &Value) -> Value { + let input = match object_input( + args, + "hotspots", + &[ + "session", "project", "since", "groupBy", "patterns", "workflow", "provider", + ], + ) { + Ok(input) => input, + Err(err) => return tool_error(err), + }; + let opts = match (|| -> Result { + Ok(HotspotsOptions { + session: optional_string(input, "session", "hotspots")? + .or_else(|| self.default_session_id.clone()), + project: optional_string(input, "project", "hotspots")?, + since: optional_string(input, "since", "hotspots")?, + group_by: optional_enum( + input, + "groupBy", + "hotspots", + &[ + "attribution", + "bash", + "bash-verb", + "file", + "subagent", + "findings", + ], + )?, + patterns: optional_string_array(input, "patterns", "hotspots")?, + workflow: optional_string(input, "workflow", "hotspots")?, + provider: optional_string_array(input, "provider", "hotspots")?, + ledger_home: None, + }) + })() { + Ok(opts) => opts, + Err(err) => return tool_error(err), + }; + let handle = self.handle.lock().await; + match handle.hotspots(opts) { + Ok(result) => tool_output(&result), + Err(err) => tool_error(err), + } + } + + async fn tool_overhead(&self, args: &Value) -> Value { + let input = match object_input(args, "overhead", &["project", "since", "kind"]) { + Ok(input) => input, + Err(err) => return tool_error(err), + }; + let opts = match (|| -> Result { + Ok(OverheadOptions { + project: optional_string(input, "project", "overhead")?.map(Into::into), + since: optional_string(input, "since", "overhead")?, + kind: optional_enum(input, "kind", "overhead", &["claude-md", "agents-md"])?, + ledger_home: None, + }) + })() { + Ok(opts) => opts, + Err(err) => return tool_error(err), + }; + let handle = self.handle.lock().await; + match handle.overhead(opts) { + Ok(result) => tool_output(&result), + Err(err) => tool_error(err), + } + } + + async fn tool_overhead_trim(&self, args: &Value) -> Value { + let input = match object_input( + args, + "overhead trim", + &["project", "since", "kind", "top", "includeDiff"], + ) { + Ok(input) => input, + Err(err) => return tool_error(err), + }; + let opts = match (|| -> Result { + let top = optional_u32(input, "top", "overhead trim")?; + if top == Some(0) { + return Err("overhead trim: top must be a positive safe integer".to_string()); + } + Ok(OverheadTrimOptions { + project: optional_string(input, "project", "overhead trim")?.map(Into::into), + since: optional_string(input, "since", "overhead trim")?, + kind: optional_enum(input, "kind", "overhead trim", &["claude-md", "agents-md"])?, + ledger_home: None, + top: top.map(u64::from), + include_diff: optional_boolean(input, "includeDiff", "overhead trim")?, + }) + })() { + Ok(opts) => opts, + Err(err) => return tool_error(err), + }; + let handle = self.handle.lock().await; + match handle.overhead_trim(opts) { + Ok(result) => tool_output(&result), + Err(err) => tool_error(err), + } + } + + async fn tool_compare(&self, args: &Value) -> Value { + let input = match object_input( + args, + "compare", + &[ + "models", + "session", + "project", + "since", + "workflow", + "agent", + "provider", + "minSample", + "minFidelity", + ], + ) { + Ok(input) => input, + Err(err) => return tool_error(err), + }; + let opts = match (|| -> Result { + let models = required_string_array(input, "models", "compare", 2)?; + Ok(CompareOptions { + models, + session: optional_string(input, "session", "compare")?, + project: optional_string(input, "project", "compare")?, + since: optional_string(input, "since", "compare")?, + workflow: optional_string(input, "workflow", "compare")?, + agent: optional_string(input, "agent", "compare")?, + provider: optional_string_array(input, "provider", "compare")?, + min_sample: optional_u32(input, "minSample", "compare")?.map(u64::from), + min_fidelity: optional_enum( + input, + "minFidelity", + "compare", + &[ + "full", + "usage-only", + "aggregate-only", + "cost-only", + "partial", + ], + )?, + ledger_home: None, + }) + })() { + Ok(opts) => opts, + Err(err) => return tool_error(err), + }; + let handle = self.handle.lock().await; + match handle.compare(opts) { + Ok(result) => tool_output(&result), + Err(err) => tool_error(err), + } + } +} + +fn tool_catalog() -> Value { + json!([ + { + "name": "burn__sessionCost", + "description": + "Return the total cost (USD), token count, and turn count for a session. \ + Defaults to the server's registered sessionId (the running agent's own \ + session). Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": + "Override the registered session id. Omit to query the running \ + agent's own session.", + }, + }, + "required": [], + "additionalProperties": false, + }, + }, + { + "name": "burn__fingerprint", + "description": + "Cheap polling primitive over the burn ledger. Returns \ + `{count}:{maxMtimeUnix}:{totalBytes}` — three integers \ + joined by colons. Clients keep the last-seen value and \ + skip re-querying when it's unchanged. Optionally scoped \ + to a session id or a project path. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": + "Restrict to a single session_id. Mutually exclusive with project.", + }, + "project": { + "type": "string", + "description": + "Restrict to rows whose project path matches. Mutually exclusive \ + with sessionId.", + }, + }, + "required": [], + "additionalProperties": false, + }, + }, + { + "name": "burn__summary", + "description": "Summarize token use and cost by tool and model, optionally filtered by session, project, time window, or enrichment tags. When the server has a registered default session, omitting session restricts the query to it. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "session": { "type": "string", "description": "Restrict to one session id. Omit to use the server registered session when present." }, + "project": { "type": "string", "description": "Restrict to one project path or key." }, + "since": { "type": "string", "description": "ISO timestamp or relative range such as 24h or 7d." }, + "tags": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Folded enrichment tags; every key/value pair must match." }, + "groupByTag": { "type": "string", "description": "Group totals by this folded enrichment tag key." } + }, + "required": [], "additionalProperties": false + } + }, + { + "name": "burn__hotspots", + "description": "Find expensive tool-output persistence and repeated workflow patterns, with attribution or grouped findings views. When the server has a registered default session, omitting session restricts the query to it. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "session": { "type": "string", "description": "Restrict to one session id. Omit to use the server registered session when present." }, + "project": { "type": "string", "description": "Restrict to one project path or key." }, + "since": { "type": "string", "description": "ISO timestamp or relative range such as 24h or 7d." }, + "groupBy": { "type": "string", "enum": ["attribution", "bash", "bash-verb", "file", "subagent", "findings"], "description": "Select the hotspot result view." }, + "patterns": { "type": "array", "items": { "type": "string" }, "description": "Only include matching finding patterns. A non-empty list selects findings mode." }, + "workflow": { "type": "string", "description": "Restrict to a folded workflowId enrichment stamp." }, + "provider": { "type": "array", "items": { "type": "string" }, "description": "Case-insensitive provider allow-list." } + }, + "required": [], "additionalProperties": false + } + }, + { + "name": "burn__overhead", + "description": "Attribute CLAUDE.md and AGENTS.md instruction-file token overhead and cost by file and section. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "project": { "type": "string", "description": "Project filesystem path. The Rust SDK defaults to the current directory; project keys are not accepted." }, + "since": { "type": "string", "description": "ISO timestamp or relative range such as 24h or 7d." }, + "kind": { "type": "string", "enum": ["claude-md", "agents-md"], "description": "Restrict to one instruction-file kind." } + }, + "required": [], "additionalProperties": false + } + }, + { + "name": "burn__overheadTrim", + "description": "Recommend high-cost instruction-file sections to trim and estimate their savings, optionally with suggested diffs. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "project": { "type": "string", "description": "Project filesystem path. The Rust SDK defaults to the current directory; project keys are not accepted." }, + "since": { "type": "string", "description": "ISO timestamp or relative range such as 24h or 7d." }, + "kind": { "type": "string", "enum": ["claude-md", "agents-md"], "description": "Restrict to one instruction-file kind." }, + "top": { "type": "integer", "minimum": 1, "maximum": 4294967295_u64, "description": "Maximum number of recommendations." }, + "includeDiff": { "type": "boolean", "description": "Include a suggested edit diff for each recommendation." } + }, + "required": [], "additionalProperties": false + } + }, + { + "name": "burn__compare", + "description": "Compare cost and outcome metrics across at least two models, grouped by activity category. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "models": { "type": "array", "items": { "type": "string" }, "minItems": 2, "description": "Model names to compare." }, + "session": { "type": "string", "description": "Restrict to one session id." }, + "project": { "type": "string", "description": "Restrict to one project path or key." }, + "since": { "type": "string", "description": "ISO timestamp or relative range such as 24h or 7d." }, + "workflow": { "type": "string", "description": "Restrict to a folded workflowId enrichment stamp." }, + "agent": { "type": "string", "description": "Restrict to a folded agentId enrichment stamp." }, + "provider": { "type": "array", "items": { "type": "string" }, "description": "Case-insensitive provider allow-list." }, + "minSample": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "description": "Minimum observations before a comparison cell is sufficient." }, + "minFidelity": { "type": "string", "enum": ["full", "usage-only", "aggregate-only", "cost-only", "partial"], "description": "Minimum accepted telemetry fidelity." } + }, + "required": ["models"], "additionalProperties": false + } + } + ]) +} + +// --------------------------------------------------------------------------- +// Tool input validation + result framing +// --------------------------------------------------------------------------- + +fn object_input<'a>( + raw: &'a Value, + tool: &str, + allowed: &[&str], +) -> Result<&'a Map, String> { + let Some(input) = raw.as_object() else { + return Err(format!("{tool}: input must be an object")); + }; + if let Some(key) = input.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!("{tool}: unknown property {key}")); + } + Ok(input) +} + +fn optional_string( + input: &Map, + key: &str, + tool: &str, +) -> Result, String> { + let Some(value) = input.get(key) else { + return Ok(None); + }; + value + .as_str() + .map(|value| Some(value.to_string())) + .ok_or_else(|| format!("{tool}: {key} must be a string")) +} + +fn optional_boolean( + input: &Map, + key: &str, + tool: &str, +) -> Result, String> { + let Some(value) = input.get(key) else { + return Ok(None); + }; + value + .as_bool() + .map(Some) + .ok_or_else(|| format!("{tool}: {key} must be a boolean")) +} + +fn optional_u32(input: &Map, key: &str, tool: &str) -> Result, String> { + let Some(value) = input.get(key) else { + return Ok(None); + }; + let value = value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| { + value.as_f64().and_then(|value| { + (value.is_finite() + && value.fract() == 0.0 + && value >= 0.0 + && value <= f64::from(u32::MAX)) + .then_some(value as u32) + }) + }); + let Some(value) = value else { + return Err(format!("{tool}: {key} must be a 32-bit unsigned integer")); + }; + Ok(Some(value)) +} + +fn optional_string_array( + input: &Map, + key: &str, + tool: &str, +) -> Result>, String> { + let Some(value) = input.get(key) else { + return Ok(None); + }; + let Some(items) = value.as_array() else { + return Err(format!("{tool}: {key} must be an array of strings")); + }; + let values: Option> = items + .iter() + .map(|item| item.as_str().map(str::to_string)) + .collect(); + values + .map(Some) + .ok_or_else(|| format!("{tool}: {key} must be an array of strings")) +} - let value = serde_json::to_value(&payload).unwrap_or(Value::Null); - let text = serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string()); - write_success( - id, - json!({ +fn required_string_array( + input: &Map, + key: &str, + tool: &str, + minimum: usize, +) -> Result, String> { + let value = optional_string_array(input, key, tool)?; + match value { + Some(items) if items.len() >= minimum => Ok(items), + _ => Err(format!( + "{tool}: {key} must contain at least {minimum} strings" + )), + } +} + +fn optional_string_record( + input: &Map, + key: &str, + tool: &str, +) -> Result, String> { + let Some(value) = input.get(key) else { + return Ok(None); + }; + let Some(record) = value.as_object() else { + return Err(format!( + "{tool}: {key} must be an object with string values" + )); + }; + let mut result = Enrichment::new(); + for (record_key, value) in record { + let Some(value) = value.as_str() else { + return Err(format!( + "{tool}: {key} must be an object with string values" + )); + }; + result.insert(record_key.clone(), value.to_string()); + } + Ok(Some(result)) +} + +fn optional_enum( + input: &Map, + key: &str, + tool: &str, + allowed: &[&str], +) -> Result, String> +where + T: DeserializeOwned, +{ + let Some(value) = input.get(key) else { + return Ok(None); + }; + let Some(raw) = value.as_str() else { + return Err(format!("{tool}: {key} must be a string")); + }; + if !allowed.contains(&raw) { + return Err(format!( + "{tool}: {key} must be one of {}", + allowed.join(", ") + )); + } + serde_json::from_value(value.clone()) + .map(Some) + .map_err(|err| format!("{tool}: invalid {key}: {err}")) +} + +fn tool_error(err: impl std::fmt::Display) -> Value { + json!({ + "content": [{ "type": "text", "text": err.to_string() }], + "isError": true, + }) +} + +fn tool_output(payload: &impl Serialize) -> Value { + match serde_json::to_value(payload) { + Ok(value) => match serde_json::to_string(&value) { + Ok(text) => json!({ "content": [{ "type": "text", "text": text }], "structuredContent": value, }), - ); + Err(err) => tool_error(format!("failed to encode tool result: {err}")), + }, + Err(err) => tool_error(format!("failed to encode tool result: {err}")), } } @@ -485,6 +880,93 @@ fn write_response(value: &Value) { #[cfg(test)] mod tests { use super::*; + use relayburn_sdk::{SourceKind, ToolCall, TurnRecord, Usage}; + + fn fixture_turn(index: u64, session: &str, model: &str, project: &str) -> TurnRecord { + TurnRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session.to_string(), + session_path: None, + message_id: format!("message-{index}"), + turn_index: index, + ts: format!("2026-08-03T12:00:0{index}.000Z"), + model: model.to_string(), + project: Some(project.to_string()), + project_key: None, + usage: Usage { + input: 100, + output: 20, + ..Default::default() + }, + tool_calls: vec![ToolCall { + id: format!("tool-{index}"), + name: "Read".to_string(), + target: Some("AGENTS.md".to_string()), + args_hash: "fixture".to_string(), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, + }], + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } + } + + fn fixture_server() -> (Server, tempfile::TempDir, std::path::PathBuf) { + let home = tempfile::tempdir().expect("temp ledger home"); + let project = home.path().join("project"); + std::fs::create_dir(&project).expect("create fixture project"); + std::fs::write( + project.join("AGENTS.md"), + "# Fixture instructions\n\nKeep tests deterministic.\n", + ) + .expect("write fixture instruction file"); + let mut handle = + Ledger::open(LedgerOpenOptions::with_home(home.path())).expect("open fixture ledger"); + let project_string = project.to_string_lossy().into_owned(); + handle + .raw_mut() + .append_turns(&[ + fixture_turn(1, "fixture-session", "claude-sonnet-4-6", &project_string), + fixture_turn(2, "fixture-session", "gpt-5.4", &project_string), + fixture_turn(3, "other-session", "gpt-5.4", &project_string), + ]) + .expect("append fixture turns"); + ( + Server { + handle: Arc::new(tokio::sync::Mutex::new(handle)), + default_session_id: Some("fixture-session".to_string()), + debug: false, + }, + home, + project, + ) + } + + fn assert_tool_success(result: &Value) -> &Value { + assert_ne!(result.get("isError"), Some(&Value::Bool(true)), "{result}"); + let structured = result + .get("structuredContent") + .unwrap_or_else(|| panic!("missing structuredContent: {result}")); + let text = result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("missing text content: {result}")); + let text_value: Value = serde_json::from_str(text).expect("tool text is JSON"); + assert_eq!( + &text_value, structured, + "text and structured result diverged" + ); + structured + } /// The wire protocol is small enough to unit-test the framing /// helpers without spinning up a full server. @@ -500,4 +982,207 @@ mod tests { Some(&Value::String("method not found: foo".into())), ); } + + #[test] + fn tools_list_catalog_contains_all_seven_tools_and_mirrors_numeric_caps() { + let tools = tool_catalog(); + let names: Vec<&str> = tools + .as_array() + .expect("catalog array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect(); + assert_eq!( + names, + [ + "burn__sessionCost", + "burn__fingerprint", + "burn__summary", + "burn__hotspots", + "burn__overhead", + "burn__overheadTrim", + "burn__compare", + ] + ); + assert_eq!( + tools[5]["inputSchema"]["properties"]["top"]["maximum"], + json!(u32::MAX) + ); + assert_eq!( + tools[6]["inputSchema"]["properties"]["minSample"]["maximum"], + json!(u32::MAX) + ); + } + + #[tokio::test] + async fn new_tools_invoke_sdk_verbs_against_fixture_ledger() { + let (server, _home, project) = fixture_server(); + let project = project.to_string_lossy(); + + let summary = server + .call_tool("burn__summary", &json!({})) + .await + .expect("known summary tool"); + assert_eq!(assert_tool_success(&summary)["turnCount"], json!(2)); + let other_summary = server + .call_tool("burn__summary", &json!({ "session": "other-session" })) + .await + .expect("known summary tool"); + assert_eq!(assert_tool_success(&other_summary)["turnCount"], json!(1)); + + let scoped_hotspots = server + .call_tool("burn__hotspots", &json!({})) + .await + .expect("known hotspots tool"); + assert_eq!( + assert_tool_success(&scoped_hotspots)["turnsAnalyzed"], + json!(2) + ); + + let hotspots = server + .call_tool( + "burn__hotspots", + &json!({ "groupBy": "findings", "patterns": ["unpriced-usage"] }), + ) + .await + .expect("known hotspots tool"); + assert_eq!(assert_tool_success(&hotspots)["kind"], json!("findings")); + + let overhead = server + .call_tool( + "burn__overhead", + &json!({ "project": project, "kind": "agents-md" }), + ) + .await + .expect("known overhead tool"); + assert_eq!( + assert_tool_success(&overhead)["files"] + .as_array() + .map(Vec::len), + Some(1) + ); + + let trim = server + .call_tool( + "burn__overheadTrim", + &json!({ "project": project, "kind": "agents-md", "top": 1, "includeDiff": false }), + ) + .await + .expect("known overhead trim tool"); + assert!(assert_tool_success(&trim)["summary"].is_object()); + + let compare = server + .call_tool( + "burn__compare", + &json!({ + "models": ["claude-sonnet-4-6", "gpt-5.4"], + "minFidelity": "partial" + }), + ) + .await + .expect("known compare tool"); + assert_eq!(assert_tool_success(&compare)["analyzedTurns"], json!(3)); + } + + #[tokio::test] + async fn invalid_inputs_are_tool_errors_without_structured_content_and_server_continues() { + let (server, _home, _project) = fixture_server(); + let cases = [ + ("burn__summary", json!([])), + ("burn__summary", json!("x")), + ("burn__summary", Value::Null), + ("burn__summary", json!({ "unknown": true })), + ("burn__summary", json!({ "tags": { "bad": 1 } })), + ("burn__hotspots", json!({ "groupBy": "unknown" })), + ("burn__overhead", json!({ "kind": "readme" })), + ("burn__overheadTrim", json!({ "top": 0 })), + ("burn__overheadTrim", json!({ "top": 1.5 })), + ("burn__overheadTrim", json!({ "top": 4294967296_u64 })), + ("burn__compare", json!({ "models": ["one"] })), + ( + "burn__compare", + json!({ "models": ["one", "two"], "minSample": -1 }), + ), + ]; + + for (name, args) in cases { + let result = server.call_tool(name, &args).await.expect("known tool"); + assert_eq!(result.get("isError"), Some(&Value::Bool(true)), "{result}"); + assert!(result.get("structuredContent").is_none(), "{result}"); + } + + let enum_error = server + .call_tool("burn__hotspots", &json!({ "groupBy": "bogus" })) + .await + .expect("known tool"); + assert_eq!( + enum_error["content"][0]["text"], + json!("hotspots: groupBy must be one of attribution, bash, bash-verb, file, subagent, findings") + ); + + assert!(server + .call_tool("burn__doesNotExist", &json!({})) + .await + .is_none()); + let recovered = server + .call_tool("burn__summary", &json!({})) + .await + .expect("known tool after errors"); + assert_eq!(assert_tool_success(&recovered)["turnCount"], json!(2)); + } + + #[tokio::test] + async fn integral_json_floats_match_integer_schema_and_npm_validation() { + let (server, _home, project) = fixture_server(); + let trim = server + .call_tool( + "burn__overheadTrim", + &json!({ + "project": project.to_string_lossy(), + "top": 2.0, + "includeDiff": false + }), + ) + .await + .expect("known overhead trim tool"); + assert_tool_success(&trim); + + let compare = server + .call_tool( + "burn__compare", + &json!({ "models": ["one", "two"], "minSample": 3.0 }), + ) + .await + .expect("known compare tool"); + assert_eq!(assert_tool_success(&compare)["minSample"], json!(3)); + } + + #[tokio::test] + async fn new_tools_return_valid_shapes_on_an_empty_ledger() { + let home = tempfile::tempdir().expect("temp ledger home"); + let handle = Ledger::open(LedgerOpenOptions::with_home(home.path())) + .expect("open empty fixture ledger"); + let server = Server { + handle: Arc::new(tokio::sync::Mutex::new(handle)), + default_session_id: None, + debug: false, + }; + let project = home.path().join("empty-project"); + std::fs::create_dir(&project).expect("create empty project"); + let project = project.to_string_lossy(); + let calls = [ + ("burn__summary", json!({})), + ("burn__hotspots", json!({})), + ("burn__overhead", json!({ "project": project })), + ( + "burn__overheadTrim", + json!({ "project": project, "includeDiff": false }), + ), + ("burn__compare", json!({ "models": ["one", "two"] })), + ]; + for (name, args) in calls { + let result = server.call_tool(name, &args).await.expect("known tool"); + assert_tool_success(&result); + } + } } diff --git a/crates/relayburn-cli/tests/mcp_server.rs b/crates/relayburn-cli/tests/mcp_server.rs new file mode 100644 index 00000000..849241dd --- /dev/null +++ b/crates/relayburn-cli/tests/mcp_server.rs @@ -0,0 +1,118 @@ +use std::time::Duration; + +use serde_json::{json, Value}; + +#[test] +fn stdio_catalog_rejects_bad_input_and_keeps_serving() { + let home = tempfile::tempdir().expect("temp ledger home"); + let requests = [ + json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }), + json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "burn__summary", "arguments": [] } + }), + json!({ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": { "name": "burn__summary", "arguments": {} } + }), + json!({ + "jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": { "name": "burn__unknown", "arguments": {} } + }), + ]; + let input = requests + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + + "\n"; + let output = assert_cmd::Command::new(env!("CARGO_BIN_EXE_burn")) + .args([ + "--ledger-path", + home.path().to_str().expect("temp path is utf-8"), + "mcp-server", + ]) + .write_stdin(input) + .timeout(Duration::from_secs(10)) + .assert() + .success() + .get_output() + .clone(); + let responses: Vec = String::from_utf8(output.stdout) + .expect("stdout is utf-8") + .lines() + .map(|line| serde_json::from_str(line).expect("response is JSON")) + .collect(); + assert_eq!(responses.len(), 4, "one response per request"); + for (index, response) in responses.iter().enumerate() { + assert_eq!(response["id"], json!(index + 1), "response {index}"); + } + + let tools = responses[0]["result"]["tools"] + .as_array() + .expect("tools array"); + let names: Vec<&str> = tools + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect(); + assert_eq!( + names, + [ + "burn__sessionCost", + "burn__fingerprint", + "burn__summary", + "burn__hotspots", + "burn__overhead", + "burn__overheadTrim", + "burn__compare", + ] + ); + let property_names = |tool: &Value| { + tool["inputSchema"]["properties"] + .as_object() + .expect("schema properties") + .keys() + .cloned() + .collect::>() + }; + assert_eq!( + property_names(&tools[2]), + ["session", "project", "since", "tags", "groupByTag"] + ); + assert_eq!( + tools[3]["inputSchema"]["properties"]["groupBy"]["enum"], + json!([ + "attribution", + "bash", + "bash-verb", + "file", + "subagent", + "findings" + ]) + ); + assert_eq!( + tools[4]["inputSchema"]["properties"]["kind"]["enum"], + json!(["claude-md", "agents-md"]) + ); + assert_eq!( + tools[5]["inputSchema"]["properties"]["top"], + json!({ + "type": "integer", + "minimum": 1, + "maximum": u32::MAX, + "description": "Maximum number of recommendations." + }) + ); + assert_eq!(tools[6]["inputSchema"]["required"], json!(["models"])); + assert_eq!( + tools[6]["inputSchema"]["properties"]["models"]["minItems"], + json!(2) + ); + for tool in tools { + assert_eq!(tool["inputSchema"]["additionalProperties"], json!(false)); + } + assert_eq!(responses[1]["result"]["isError"], json!(true)); + assert!(responses[1]["result"].get("structuredContent").is_none()); + assert!(responses[2]["result"]["structuredContent"].is_object()); + assert_eq!(responses[3]["error"]["code"], json!(-32601)); +}