From 523b563057c7cf106f68e1a9021f9a9164a6b3f1 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 2 Aug 2026 16:50:40 +0100 Subject: [PATCH] (mcp) Enforce hard response budgets --- .../adr-128-hard-mcp-response-budgets.md | 139 +++++++ docs/mcp.md | 21 +- rust/PORT-CONTRACT.d/10-mcp-surface.md | 57 ++- rust/decided-mcp/src/http.rs | 20 +- rust/decided-mcp/src/main.rs | 95 ++++- rust/decided-mcp/tests/common/mod.rs | 37 +- rust/decided-mcp/tests/http_transport.rs | 46 +++ rust/decided-mcp/tests/response_budget.rs | 76 ++++ rust/rac-engine/src/budget.rs | 346 ++++++++++++++++-- 9 files changed, 759 insertions(+), 78 deletions(-) create mode 100644 decisions/decisions/adr-128-hard-mcp-response-budgets.md create mode 100644 rust/decided-mcp/tests/response_budget.rs diff --git a/decisions/decisions/adr-128-hard-mcp-response-budgets.md b/decisions/decisions/adr-128-hard-mcp-response-budgets.md new file mode 100644 index 00000000..bbdb2142 --- /dev/null +++ b/decisions/decisions/adr-128-hard-mcp-response-budgets.md @@ -0,0 +1,139 @@ +--- +schema_version: 1 +id: RAC-01K8Q7MCP411 +type: decision +--- +# ADR-128: Hard MCP Response Budgets + +## Context + +ADR-033 established a deterministic character budget for MCP tool payloads, +but the first implementation retained two overrun cases: summaries were only +marked, and deep relationship neighborhoods could remain far above the +configured limit. The stdio binary also had no way to choose a startup budget, +so the documented configurability existed only in an embedding API. + +Those exceptions make the safety property illusory. An agent receiving a +24,000-character summary or a 60,000-character graph response has still had +its context flooded, even if the payload says `truncated: true`. The native +AsDecided server must enforce the boundary at the point where every transport +serializes a tool result. + +## Decision + +Every successful MCP tool payload string is at or below its effective +character budget. + +- The default is 10,000 characters. `decided-mcp --budget N` configures the + server-wide budget for both stdio and HTTP, and `N` must be at least 128. +- The `get_artifact` and `retrieve_grounding` per-call `budget` arguments may + lower the startup value. A positive value below 128 is rejected as a tool + error; a caller cannot use it to produce an invalid oversized response. +- Repeated collections (`matches`, `items`, `incoming`, `neighborhood`, + `decisions`, `attention`, and outgoing relationship targets) are reduced + deterministically from the tail at whole-item boundaries. Retrieval + excerpts and artifact content may use a deterministic fitting prefix before + whole items are removed. +- `truncated`, `omitted`, and `hint` remain the only response markers. Omitted + counts are truthful: they include source overflow and entries removed by the + budget pass, while character-prefix reductions report dropped characters + where the source shape supports that count. +- If fixed fields alone cannot fit, the serializer returns a small structured + `response_budget_exceeded` error instead of an oversized successful payload. +- These rules replace the historical summary and deep-neighborhood overrun + exceptions. Port parity does not preserve those context-flooding bugs. + +ADR-033 remains the governing rationale for deterministic character budgets; +this decision hardens its implementation contract for the native server. + +## Status + +Accepted + +## Category + +Technical + +## Consequences + +Agents receive a predictable upper bound on every successful result, across +both transports and every response shape. Broad summaries and graph walks may +return less context than before, but the marker and hint make the omission +visible and actionable. Operators can choose a larger bound deliberately at +startup without changing the default safety posture. + +There is no cursor or session state: callers narrow the query or make another +stateless request when they need omitted context. A 128-character minimum +leaves room for the structured budget error and avoids accepting unusably tiny +server configurations. + +## Alternatives Considered + +### Preserve the oracle's overrun exceptions + +Rejected. Marking an oversized success does not protect an agent context, and +the Rust cutover is the opportunity to retire the known bug-for-bug behavior. + +### Remove the configurable startup budget + +Rejected. Different clients and deployment contexts need different context +windows; a deterministic operator-controlled character limit is cheap to +support and remains transport-neutral. + +### Use token counts or an LLM judge + +Rejected. Tokenizers vary by model and version, and model judgement would +break deterministic local enforcement. Character counts and fixed truncation +rules preserve ADR-032 and ADR-066. + +## Code Constraints + +```yaml +version: 1 +eligibility: eligible +reason: "The native budget boundary and explicit overflow response are stable source contracts." +rules: + - id: mcp-budget-keeps-default + kind: require_pattern + path_glob: "rust/rac-engine/src/budget.rs" + pattern: 'pub const DEFAULT_BUDGET: i64 = 10_000;' + message: "The native MCP response budget must retain its 10,000-character default." + - id: mcp-budget-keeps-minimum + kind: require_pattern + path_glob: "rust/rac-engine/src/budget.rs" + pattern: 'pub const MIN_BUDGET: i64 = 128;' + message: "Configured response budgets must retain the explicit 128-character minimum." + - id: mcp-budget-has-explicit-overflow + kind: require_pattern + path_glob: "rust/rac-engine/src/budget.rs" + pattern: 'pub const BUDGET_ERROR: &str = "response_budget_exceeded";' + message: "Fixed-field budget failures must return an explicit structured error." + - id: mcp-budget-is-configurable-at-startup + kind: require_pattern + path_glob: "rust/decided-mcp/src/main.rs" + pattern: '"--budget"' + message: "The native MCP server must expose the response budget at startup." +``` + +## Related Decisions + +- adr-007 +- adr-032 +- adr-033 +- adr-066 +- adr-121 + +## Related Requirements + +- rac-agent-context-guide + +## Applies To + +- rust/rac-engine/src/budget.rs +- rust/decided-mcp/src/main.rs +- rust/decided-mcp/src/http.rs +- rust/decided-mcp/src/tools.rs +- rust/decided-mcp/tests/response_budget.rs +- rust/decided-mcp/tests/http_transport.rs +- docs/mcp.md +- rust/PORT-CONTRACT.d/10-mcp-surface.md diff --git a/docs/mcp.md b/docs/mcp.md index 78224a2f..9490f640 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -148,6 +148,25 @@ The `examples/guide/` corpus contains one requirement, decision, design, and roadmap for a fictional user management service — enough to explore all four tools. +### Response budgets + +The native server caps each successful tool payload at 10,000 characters by +default. Set a different startup budget for either transport with +`--budget N` (the minimum is 128 characters): + +```bash +decided-mcp --root /path/to/your/repo --budget 20000 +``` + +The limit is measured on the JSON payload in `content[0].text`, not the outer +JSON-RPC frame. Collection results are truncated deterministically at whole +items and carry `truncated`, `omitted`, and `hint` fields. Artifact content +and retrieval excerpts may be shortened by character prefix. If fixed fields +alone cannot fit, the tool returns a small `response_budget_exceeded` error +instead of an oversized success. `get_artifact` and `retrieve_grounding` also +accept a positive per-call `budget` that can lower the startup value; values +below 128 are rejected as a tool error. + ## 4. Your first grounded question Once the server is connected, ask your agent: @@ -412,7 +431,7 @@ server gains a streamable **HTTP transport** for exactly this ([ADR-098](https://github.com/asdecided/core/blob/main/decisions/decisions/adr-098-shared-http-mcp-serving.md)): ```bash -decided-mcp --root /path/to/your/repo --transport http --host 127.0.0.1 --port 8000 --path /mcp +decided-mcp --root /path/to/your/repo --transport http --host 127.0.0.1 --port 8000 --path /mcp --budget 10000 ``` - **`--transport`** — `stdio` (default) or `http`. Bare `decided-mcp` is unchanged, diff --git a/rust/PORT-CONTRACT.d/10-mcp-surface.md b/rust/PORT-CONTRACT.d/10-mcp-surface.md index e655f400..5556585d 100644 --- a/rust/PORT-CONTRACT.d/10-mcp-surface.md +++ b/rust/PORT-CONTRACT.d/10-mcp-surface.md @@ -324,36 +324,33 @@ bytes: - Default budget **10,000 characters**, measured over the serialized **payload string** (`content[0].text`), *not* the wire frame (which is - ~2× + escaping). Configured only at server construction - (`build_server(budget=…)`); the stdio CLI has **no flag** — it is always - 10,000. ORACLE-NEXT adds per-call `budget` args that may only *lower* - it: `effective = server if arg<=0 else min(server, arg)`. -- Truncation is whole-item, from the tail, deterministic; marker fields are - appended (insertion order puts them last): `"truncated": true`, - `"omitted": `, `"hint": ""`. `truncated` is **absent** - (never `false`) on complete responses. Pinned hints: `HINT_SEARCH`, - `HINT_RELATED`, `HINT_CONTENT`, `HINT_SUMMARY` in `budget.py` - (+ `HINT_RETRIEVE` = `"Lower top_k, raise the budget, or narrow the - task."` on NEXT). -- Per-shape rule (first matching key wins — order matters): - `matches` → drop whole matches; `incoming` → drop whole incoming entries; - (NEXT: `items` → binary-search-trim the **last kept item's `excerpt`** - first, drop whole items only if an empty excerpt still doesn't fit; - `omitted` counts dropped whole items, a pure excerpt trim is - `truncated:true, omitted:0`); `content` → binary-search the largest - fitting prefix, `omitted` = characters dropped; anything else (summary) → - marker added, **nothing dropped**. -- Verified wire consequences (live corpus, defaults): - - `get_artifact` on a >10k artifact → payload **exactly 10,000 chars**, - `omitted:14350`, `HINT_CONTENT`. - - `search_artifacts "telemetry"` → 9,950 chars, `omitted:15`. - - **`get_summary` CAN exceed the budget**: live-corpus payload is 24,346 - chars with `truncated:true, omitted:0, HINT_SUMMARY` — marked, not cut. - - **`get_related` with `depth>1` CAN massively exceed the budget - (landmine):** the truncator only shrinks `incoming`; `neighborhood` is - not truncatable, so `depth:3` on ADR-001 served a 62,609-char payload - with `truncated:true, omitted:10, HINT_RELATED`. This is the oracle's - real behavior — port it bug-for-bug; do not "fix" it in the port. + ~2× + escaping). The native server accepts `--budget N` on both stdio and + HTTP; `N` must be at least **128**. The default remains 10,000 when the + flag is omitted. +- ORACLE-NEXT per-call `budget` arguments may only lower the configured + server budget: `effective = server if arg<=0 else min(server, arg)`. A + positive per-call value below 128 is a tool error, never an oversized + successful response. +- Truncation is deterministic and whole-item wherever a repeated collection + is involved. Marker fields are appended (insertion order puts them last): + `"truncated": true`, `"omitted": `, `"hint": ""`. + `truncated` is **absent** (never `false`) on complete responses. Pinned + hints: `HINT_SEARCH`, `HINT_RELATED`, `HINT_CONTENT`, `HINT_SUMMARY`, and + `HINT_RETRIEVE`. +- The serializer applies stable shape rules: `matches`, `incoming`, + `neighborhood`, `items`, `decisions`, `attention`, and outgoing relationship + targets are reduced from the tail as whole entries. Retrieve `items` may + first shorten the last kept `excerpt`; artifact `content` may be shortened + to the largest fitting prefix. Omitted counts include source overflow and + every dropped item; a pure excerpt/content reduction reports dropped + characters where the source shape supports it. +- **Every successful payload string is at or below the configured budget.** + If fixed fields alone cannot fit, the server returns the small structured + error `{"error":"response_budget_exceeded", "hint":"..."}` (or its + shorter form at unusually small direct serializer limits), rather than an + oversized success. This replaces the historical summary and deep- + neighborhood overrun exceptions; the native engine does not preserve those + bugs for compatibility. - The budget serializer re-measures with `_dumps` (spaces included) — the 10,000 counts those spaces. diff --git a/rust/decided-mcp/src/http.rs b/rust/decided-mcp/src/http.rs index 395fdf1d..a75892bd 100644 --- a/rust/decided-mcp/src/http.rs +++ b/rust/decided-mcp/src/http.rs @@ -77,6 +77,7 @@ HTTP (ADR-084).", /// model and audit recorder remain serialised behind mutexes, while request /// parsing happens outside those locks so a slow client cannot block the next /// client from being accepted. +#[allow(clippy::too_many_arguments)] pub fn serve_http( root: &str, state: ServerState, @@ -85,6 +86,7 @@ pub fn serve_http( port: u16, path: &str, allowed_origins: &[String], + server_budget: i64, ) -> ! { let listener = match TcpListener::bind((host, port)) { Ok(l) => l, @@ -129,7 +131,15 @@ stateless per call; authentication belongs to the deployment proxy, ADR-085)." let allowed_origins = Arc::clone(&allowed_origins); std::thread::spawn(move || { let _permit = ConnectionPermit { active }; - handle_connection(&root, &state, &recorder, &path, &allowed_origins, s); + handle_connection( + &root, + &state, + &recorder, + &path, + &allowed_origins, + server_budget, + s, + ); }); } Err(_) => continue, @@ -174,6 +184,7 @@ fn handle_connection( recorder: &Mutex>, path: &str, allowed_origins: &[String], + server_budget: i64, mut stream: TcpStream, ) { if stream.set_read_timeout(Some(HTTP_IO_TIMEOUT)).is_err() @@ -199,7 +210,7 @@ fn handle_connection( let response = { let mut state = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut recorder = recorder.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - route(root, &mut state, &mut recorder, path, &req) + route(root, &mut state, &mut recorder, path, server_budget, &req) }; respond(&mut stream, &response); } @@ -355,6 +366,7 @@ fn route( state: &mut ServerState, recorder: &mut Option, path: &str, + server_budget: i64, req: &Request, ) -> Response { if req.path() != path { @@ -367,7 +379,7 @@ fn route( // SDK, which opens an idle stream). "GET" => Response { status: "405 Method Not Allowed", body: None }, "DELETE" => Response { status: "405 Method Not Allowed", body: None }, - "POST" => route_post(root, state, recorder, req), + "POST" => route_post(root, state, recorder, server_budget, req), _ => Response { status: "405 Method Not Allowed", body: None }, } } @@ -376,6 +388,7 @@ fn route_post( root: &str, state: &mut ServerState, recorder: &mut Option, + server_budget: i64, req: &Request, ) -> Response { // Accept must be present and admit JSON (json_response mode): absent -> 406. @@ -448,6 +461,7 @@ fn route_post( &message, recorder.as_mut(), principal.as_deref(), + server_budget, ); let status = if era == protocol::Era::Current && !protocol::current_method_supported(method) diff --git a/rust/decided-mcp/src/main.rs b/rust/decided-mcp/src/main.rs index 60ab330b..037a0f4b 100644 --- a/rust/decided-mcp/src/main.rs +++ b/rust/decided-mcp/src/main.rs @@ -51,6 +51,7 @@ fn main() { let mut host = "127.0.0.1".to_string(); let mut port: u16 = 8000; let mut path = "/mcp".to_string(); + let mut server_budget = budget::DEFAULT_BUDGET; let mut allowed_origins = Vec::new(); while let Some(a) = argv.next() { match a.as_str() { @@ -80,6 +81,17 @@ fn main() { Some(v) => path = v, None => usage_error("--path requires a value"), }, + "--budget" => match argv.next() { + Some(v) => match v.parse::() { + Ok(value) if budget::valid_configured_budget(value) => server_budget = value, + Ok(value) => usage_error(&format!( + "argument --budget: value must be at least {} (got {value})", + budget::MIN_BUDGET + )), + Err(_) => usage_error(&format!("argument --budget: invalid int value: '{v}'")), + }, + None => usage_error("--budget requires a value"), + }, "--allowed-origin" => match argv.next() { Some(v) if !v.trim().is_empty() => allowed_origins.push(v), Some(_) => usage_error("--allowed-origin requires a non-empty origin"), @@ -137,13 +149,14 @@ fn main() { port, &path, &allowed_origins, + server_budget, ); } let mut recorder = audit::build(&root, "stdio", &audit_config); if let Some(recorder) = recorder.as_ref() { audit::announce(recorder); } - serve(&root, &mut state, &mut recorder); + serve(&root, &mut state, &mut recorder, server_budget); } /// Startup diagnostic (stderr only; declared-normalized in parity, §0). @@ -162,6 +175,7 @@ fn serve( root: &str, state: &mut ServerState, recorder: &mut Option, + server_budget: i64, ) { let stdin = std::io::stdin(); let stdout = std::io::stdout(); @@ -210,8 +224,16 @@ fn serve( continue; } } - let frame = - process_request(root, state, era, &id_json, &message, recorder.as_mut(), None); + let frame = process_request( + root, + state, + era, + &id_json, + &message, + recorder.as_mut(), + None, + server_budget, + ); writeln!(out, "{frame}").ok(); out.flush().ok(); } @@ -221,6 +243,7 @@ fn serve( /// agnostic, so stdio and HTTP share exactly one code path — the byte-parity /// surface, PORT-CONTRACT.d/10 §2/§4/§5). Callers extract `method`/`id` and the /// per-transport envelope; this owns only the payload. +#[allow(clippy::too_many_arguments)] pub(crate) fn process_request( root: &str, state: &mut ServerState, @@ -229,6 +252,7 @@ pub(crate) fn process_request( message: &Value, recorder: Option<&mut audit::Recorder>, principal: Option<&str>, + server_budget: i64, ) -> String { let method = message .get("method") @@ -263,9 +287,16 @@ pub(crate) fn process_request( (protocol::Era::Legacy, "resources/list") => { format!("{{\"jsonrpc\":\"2.0\",\"id\":{id_json},\"result\":{{\"resources\":[]}}}}") } - (_, "tools/call") => { - tools_call_frame(root, state, era, id_json, message, recorder, principal) - } + (_, "tools/call") => tools_call_frame( + root, + state, + era, + id_json, + message, + recorder, + principal, + server_budget, + ), (protocol::Era::Current, _) => protocol::method_not_found_frame(id_json, method), (protocol::Era::Legacy, _) => format!( "{{\"jsonrpc\":\"2.0\",\"id\":{id_json},\"error\":{{\"code\":-32602,\"message\":\"Invalid request parameters\",\"data\":\"\"}}}}" @@ -304,6 +335,7 @@ fn call_result_frame( format!("{{\"jsonrpc\":\"2.0\",\"id\":{id_json},\"result\":{result_json}}}") } +#[allow(clippy::too_many_arguments)] fn tools_call_frame( root: &str, state: &mut ServerState, @@ -312,6 +344,7 @@ fn tools_call_frame( message: &Value, recorder: Option<&mut audit::Recorder>, principal: Option<&str>, + server_budget: i64, ) -> String { let name = message .pointer("/params/name") @@ -320,7 +353,15 @@ fn tools_call_frame( let empty = json!({}); let arguments = message.pointer("/params/arguments").unwrap_or(&empty); let dispatch_started = rac_engine::timing::start(); - let dispatched = dispatch(root, state, name, arguments, recorder, principal); + let dispatched = dispatch( + root, + state, + name, + arguments, + recorder, + principal, + server_budget, + ); rac_engine::timing::emit_since( "mcp.dispatch", dispatch_started, @@ -379,6 +420,7 @@ fn dispatch( arguments: &Value, recorder: Option<&mut audit::Recorder>, principal: Option<&str>, + server_budget: i64, ) -> Result { if !matches!( name, @@ -391,9 +433,6 @@ fn dispatch( ) { return Err(format!("Unknown tool: {name}")); } - // ADR-033: the server budget is fixed at construction; the stdio CLI has - // no flag, so it is always the default. - let server_budget = budget::DEFAULT_BUDGET; // Freshen the read-model once per call (the corpus-change check every // tool answer rides, ADR-105); without the tracker every arm re-walks. let (generation, model) = match state.tracker.as_mut() { @@ -416,6 +455,7 @@ fn dispatch( ]; let a = args::validate(name, "get_artifactArguments", ¶ms, arguments)?; let effective = tools::effective_budget(server_budget, a_int(&a, 1, 0)); + budget::validate_call_budget(effective)?; let audit_args = json!({ "id": a_str(&a, 0, "") }); Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { @@ -474,6 +514,7 @@ fn dispatch( let raw_budget = a_int(&a, 3, 0); let live_only = a_bool(&a, 4, true); let effective = tools::effective_budget(server_budget, raw_budget); + budget::validate_call_budget(effective)?; let mut m = Map::new(); m.insert("task".into(), Value::String(task.clone())); if !scope.is_empty() { @@ -593,6 +634,7 @@ mod tests { &json!({}), None, None, + budget::DEFAULT_BUDGET, ); assert_eq!(result, Err("Unknown tool: not_a_tool".to_string())); assert_eq!(state.tracker.as_ref().and_then(|t| t.corpus_hash()), None); @@ -615,12 +657,30 @@ mod tests { }; let arguments = json!({"id": "FIX-0DEC1GRAPH00", "depth": 2}); - let first = dispatch(&root, &mut state, "get_related", &arguments, None, None).unwrap(); + let first = dispatch( + &root, + &mut state, + "get_related", + &arguments, + None, + None, + budget::DEFAULT_BUDGET, + ) + .unwrap(); assert!(first.contains("FIX-0REQ1GRAPH00"), "{first}"); assert_eq!(state.graph_cache.builds(), 1); let first_generation = state.tracker.as_ref().unwrap().serving_generation(); - let second = dispatch(&root, &mut state, "get_related", &arguments, None, None).unwrap(); + let second = dispatch( + &root, + &mut state, + "get_related", + &arguments, + None, + None, + budget::DEFAULT_BUDGET, + ) + .unwrap(); assert_eq!(second, first); assert_eq!(state.graph_cache.builds(), 1); assert_eq!( @@ -629,7 +689,16 @@ mod tests { ); std::fs::write(corpus.join("requirement-2.md"), requirement("FIX-0REQ2GRAPH00")).unwrap(); - let changed = dispatch(&root, &mut state, "get_related", &arguments, None, None).unwrap(); + let changed = dispatch( + &root, + &mut state, + "get_related", + &arguments, + None, + None, + budget::DEFAULT_BUDGET, + ) + .unwrap(); assert!(changed.contains("FIX-0REQ1GRAPH00")); assert!(changed.contains("FIX-0REQ2GRAPH00")); assert_eq!(state.graph_cache.builds(), 2); diff --git a/rust/decided-mcp/tests/common/mod.rs b/rust/decided-mcp/tests/common/mod.rs index 559a117f..ac0a0311 100644 --- a/rust/decided-mcp/tests/common/mod.rs +++ b/rust/decided-mcp/tests/common/mod.rs @@ -27,10 +27,41 @@ pub fn scratch(tag: &str) -> PathBuf { } pub fn run_stdio(tag: &str, requests: &[String]) -> Vec { + run_stdio_with_args_and_files(tag, &[], &[], requests) +} + +pub fn run_stdio_with_budget( + tag: &str, + budget: i64, + files: &[(&str, &str)], + requests: &[String], +) -> Vec { + let args = [ + "--budget".to_string(), + budget.to_string(), + "--no-cache".to_string(), + ]; + run_stdio_with_args_and_files(tag, &args, files, requests) +} + +fn run_stdio_with_args_and_files( + tag: &str, + args: &[String], + files: &[(&str, &str)], + requests: &[String], +) -> Vec { let corpus = scratch(tag); - let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) - .arg("--root") - .arg(&corpus) + for (name, contents) in files { + let path = corpus.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create fixture parent"); + } + std::fs::write(path, contents).expect("write fixture file"); + } + let mut command = Command::new(env!("CARGO_BIN_EXE_decided-mcp")); + command.arg("--root").arg(&corpus); + command.args(args); + let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/rust/decided-mcp/tests/http_transport.rs b/rust/decided-mcp/tests/http_transport.rs index 492d0562..3b7764f5 100644 --- a/rust/decided-mcp/tests/http_transport.rs +++ b/rust/decided-mcp/tests/http_transport.rs @@ -39,6 +39,15 @@ impl Server { tag: &str, allowed_origins: &[&str], files: &[(&str, &str)], + ) -> Self { + Self::start_with_origins_and_files_and_budget(tag, allowed_origins, files, 10_000) + } + + fn start_with_origins_and_files_and_budget( + tag: &str, + allowed_origins: &[&str], + files: &[(&str, &str)], + budget: i64, ) -> Self { let corpus = scratch(tag); let audit_path = corpus.join("audit.jsonl"); @@ -73,6 +82,8 @@ impl Server { "127.0.0.1".to_string(), "--port".to_string(), port.to_string(), + "--budget".to_string(), + budget.to_string(), ]; for origin in allowed_origins { args.push("--allowed-origin".to_string()); @@ -336,6 +347,41 @@ fn current_http_tool_call_requires_name_and_returns_current_result() { ); } +#[test] +fn http_caps_large_artifact_payload_to_configured_budget() { + let _guard = serial_http_test(); + let artifact = format!( + "---\nschema_version: 1\nid: RAC-111111111111\ntype: decision\n---\n# HTTP budget fixture\n\n## Status\n\nAccepted\n\n## Context\n\n{}\n\n## Decision\n\nKeep the response bounded.\n\n## Consequences\n\nThe caller can request the source file for the remainder.\n", + "large-context ".repeat(2_000) + ); + let server = Server::start_with_origins_and_files_and_budget( + "http-budget", + &[], + &[("large.md", artifact.as_str())], + 512, + ); + let request = json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "tools/call", + "params": { + "name": "get_artifact", + "arguments": {"id": "RAC-111111111111"}, + "_meta": current_meta() + } + }); + let (status, response) = server.post(&request, "tools/call", Some("get_artifact")); + assert_eq!(status, "HTTP/1.1 200 OK"); + let text = response + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .expect("tool text"); + assert!(text.chars().count() <= 512, "{} characters", text.chars().count()); + let payload: Value = serde_json::from_str(text).expect("serialized payload"); + assert_eq!(payload["truncated"], json!(true)); + assert!(payload["omitted"].as_i64().unwrap_or(0) > 0); +} + #[test] fn stalled_client_does_not_block_a_later_request() { let _guard = serial_http_test(); diff --git a/rust/decided-mcp/tests/response_budget.rs b/rust/decided-mcp/tests/response_budget.rs new file mode 100644 index 00000000..76d63fae --- /dev/null +++ b/rust/decided-mcp/tests/response_budget.rs @@ -0,0 +1,76 @@ +mod common; + +use common::{current_meta, parse, run_stdio_with_budget, scratch}; +use serde_json::{json, Value}; +use std::process::Command; + +#[test] +fn stdio_caps_large_artifact_payload() { + let artifact = format!( + "---\nschema_version: 1\nid: RAC-111111111111\ntype: decision\n---\n# Budget fixture\n\n## Status\n\nAccepted\n\n## Context\n\n{}\n\n## Decision\n\nKeep the response bounded.\n\n## Consequences\n\nThe caller can request the source file for the remainder.\n", + "large-context ".repeat(2_000) + ); + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_artifact", + "arguments": {"id": "RAC-111111111111"}, + "_meta": current_meta() + } + }); + let frames = run_stdio_with_budget( + "stdio-budget", + 512, + &[("large.md", artifact.as_str())], + &[request.to_string()], + ); + let response = parse(&frames[0]); + let text = response + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .expect("tool text"); + assert!(text.chars().count() <= 512, "{} characters", text.chars().count()); + let payload: Value = serde_json::from_str(text).expect("serialized payload"); + assert_eq!(payload["truncated"], json!(true)); + assert!(payload["omitted"].as_i64().unwrap_or(0) > 0); +} + +#[test] +fn per_call_budget_below_minimum_is_a_tool_error() { + let request = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_artifact", + "arguments": {"id": "FIX-MCP20260728", "budget": 64}, + "_meta": current_meta() + } + }); + let frames = run_stdio_with_budget("stdio-minimum", 512, &[], &[request.to_string()]); + let response = parse(&frames[0]); + assert_eq!(response.pointer("/result/isError"), Some(&json!(true))); + assert!(response + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .is_some_and(|text| text.contains("minimum supported budget"))); +} + +#[test] +fn startup_budget_below_minimum_fails_before_serving() { + let corpus = scratch("startup-minimum"); + let output = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .args([ + "--root", + corpus.to_str().expect("UTF-8 corpus path"), + "--budget", + "127", + ]) + .output() + .expect("run invalid startup budget"); + let _ = std::fs::remove_dir_all(corpus); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("must be at least 128")); +} diff --git a/rust/rac-engine/src/budget.rs b/rust/rac-engine/src/budget.rs index 44880b27..c54f101b 100644 --- a/rust/rac-engine/src/budget.rs +++ b/rust/rac-engine/src/budget.rs @@ -10,20 +10,18 @@ //! truth is *with* spaces (PORT-CONTRACT.d/10 §2) — port the code, not the //! comment. //! -//! Rule order (first matching key wins): `matches` → `incoming` → `items` → -//! `content` → mark-only (summary). Two overrun behaviors are ported -//! bug-for-bug (PORT-CONTRACT.d/10 §6): -//! - `get_summary` has no truncatable field: an over-budget summary is -//! marked (`truncated:true, omitted:0, HINT_SUMMARY`) but nothing is -//! dropped — the payload stays over budget. -//! - `get_related` with `depth>1`: only `incoming` shrinks; `neighborhood` -//! is not truncatable, so the response can massively exceed the budget -//! while carrying the marker. +//! Truncation is deterministic and whole-item wherever a repeated collection is +//! involved. Every successful payload is either reduced to the configured +//! character budget or replaced with a small explicit budget error; an +//! over-budget success is never returned. use crate::pyjson::dumps_compact; use serde_json::{json, Map, Value}; pub const DEFAULT_BUDGET: i64 = 10_000; +/// Smallest supported configured budget. This leaves room for a structured +/// budget error when fixed response fields alone cannot fit. +pub const MIN_BUDGET: i64 = 128; pub const MARKER_TRUNCATED: &str = "truncated"; pub const MARKER_OMITTED: &str = "omitted"; @@ -36,6 +34,23 @@ pub const HINT_CONTENT: &str = pub const HINT_SUMMARY: &str = "The repository summary exceeds the response budget; raise the \ server budget to see the full overview."; pub const HINT_RETRIEVE: &str = "Lower top_k, raise the budget, or narrow the task."; +pub const BUDGET_ERROR: &str = "response_budget_exceeded"; +pub const BUDGET_ERROR_HINT: &str = "Raise the response budget or narrow the request."; + +type TruncationStrategy = fn(&Value, i64) -> (Value, bool); + +pub fn valid_configured_budget(budget: i64) -> bool { + budget >= MIN_BUDGET +} + +pub fn validate_call_budget(budget: i64) -> Result<(), String> { + if budget > 0 && budget < MIN_BUDGET { + return Err(format!( + "Requested response budget {budget} is below the minimum supported budget of {MIN_BUDGET} characters." + )); + } + Ok(()) +} /// `len(text)` in Python — code points, not bytes. pub fn char_len(s: &str) -> i64 { @@ -61,29 +76,67 @@ pub fn serialize(payload: &Value, budget: i64) -> String { if char_len(&text) <= budget { return text; } - dumps_compact(&truncate(payload, budget)) + let truncated = truncate(payload, budget); + let text = dumps_compact(&truncated); + if char_len(&text) <= budget { + return text; + } + budget_error(budget) } fn truncate(payload: &Value, budget: i64) -> Value { - let obj = payload.as_object().expect("payload is an object"); - if obj.contains_key("matches") { - return truncate_list(payload, "matches", budget, HINT_SEARCH); + let Some(obj) = payload.as_object() else { + return json!({"error": BUDGET_ERROR, "hint": BUDGET_ERROR_HINT}); + }; + let mut candidate = Value::Object(obj.clone()); + + // A shape can require more than one reduction (for example, a deep + // relationship response has both incoming and neighborhood collections). + // Apply strategies in a stable order, then remove optional fixed fields as + // a last resort before returning the explicit error from `serialize`. + let strategies: [TruncationStrategy; 9] = [ + |value, limit| truncate_list_strategy(value, "matches", limit, HINT_SEARCH), + |value, limit| truncate_items_strategy(value, limit), + |value, limit| truncate_content_strategy(value, limit), + |value, limit| truncate_related_strategy(value, limit), + |value, limit| truncate_list_strategy(value, "decisions", limit, HINT_SEARCH), + |value, limit| truncate_list_strategy(value, "attention", limit, HINT_SUMMARY), + |value, limit| truncate_list_strategy(value, "neighborhood", limit, HINT_RELATED), + |value, limit| truncate_outgoing_strategy(value, limit), + truncate_optional_strategy, + ]; + for strategy in strategies { + if char_len(&dumps_compact(&candidate)) <= budget { + break; + } + let (next, changed) = strategy(&candidate, budget); + candidate = next; + if !changed { + continue; + } } - if obj.contains_key("incoming") { - return truncate_list(payload, "incoming", budget, HINT_RELATED); + candidate +} + +fn budget_error(budget: i64) -> String { + let full = dumps_compact(&json!({ + "error": BUDGET_ERROR, + "hint": BUDGET_ERROR_HINT, + })); + if char_len(&full) <= budget { + return full; } - if obj.contains_key("items") { - return truncate_items(payload, budget); + let short = dumps_compact(&json!({"error": BUDGET_ERROR})); + if char_len(&short) <= budget { + return short; } - if obj.contains_key("content") { - return truncate_content(payload, budget); + if budget >= 2 { + return "{}".to_string(); } - // No truncatable field (get_summary): mark, drop nothing (overrun #1). - let mut marked = obj.clone(); - marked.insert(MARKER_TRUNCATED.to_string(), json!(true)); - marked.insert(MARKER_OMITTED.to_string(), json!(0)); - marked.insert(MARKER_HINT.to_string(), json!(HINT_SUMMARY)); - Value::Object(marked) + if budget == 1 { + return "0".to_string(); + } + String::new() } /// A copy of `payload` with `key` replaced by `kept` and the marker added. @@ -99,18 +152,57 @@ fn with_marker(payload: &Value, key: &str, kept: Vec, omitted: i64, hint: Value::Object(marked) } -fn truncate_list(payload: &Value, key: &str, budget: i64, hint: &str) -> Value { +fn existing_omitted(payload: &Value) -> i64 { + payload + .get(MARKER_OMITTED) + .and_then(Value::as_i64) + .unwrap_or(0) +} + +fn truncate_list_strategy( + payload: &Value, + key: &str, + budget: i64, + hint: &str, +) -> (Value, bool) { + let Some(items) = payload.get(key).and_then(Value::as_array) else { + return (payload.clone(), false); + }; + if items.is_empty() { + return (payload.clone(), false); + } let items: Vec = payload[key].as_array().cloned().unwrap_or_default(); let total = items.len() as i64; let mut kept = items; while !kept.is_empty() { let candidate = with_marker(payload, key, kept.clone(), total - kept.len() as i64, hint); if length(&candidate) <= budget { - return candidate; + return (candidate, true); } kept.pop(); } - with_marker(payload, key, Vec::new(), total, hint) + (with_marker(payload, key, Vec::new(), total, hint), true) +} + +fn truncate_items_strategy(payload: &Value, budget: i64) -> (Value, bool) { + if payload + .get("items") + .and_then(Value::as_array) + .is_none_or(|items| items.is_empty()) + { + return (payload.clone(), false); + } + (truncate_items(payload, budget), true) +} + +fn truncate_content_strategy(payload: &Value, budget: i64) -> (Value, bool) { + let Some(content) = payload.get("content").and_then(Value::as_str) else { + return (payload.clone(), false); + }; + if content.is_empty() { + return (payload.clone(), false); + } + (truncate_content(payload, budget), true) } fn truncate_content(payload: &Value, budget: i64) -> Value { @@ -186,3 +278,201 @@ fn truncate_items(payload: &Value, budget: i64) -> Value { } with_marker(payload, "items", Vec::new(), total, HINT_RETRIEVE) } + +fn truncate_related_strategy(payload: &Value, budget: i64) -> (Value, bool) { + let has_incoming = payload.get("incoming").is_some_and(Value::is_array); + let has_neighborhood = payload.get("neighborhood").is_some_and(Value::is_array); + if !has_incoming && !has_neighborhood { + return (payload.clone(), false); + } + let mut candidate = payload.clone(); + let mut omitted = existing_omitted(payload); + let mut changed = false; + for key in ["incoming", "neighborhood"] { + let Some(items) = candidate.get(key).and_then(Value::as_array).cloned() else { + continue; + }; + let mut kept = items; + while length(&candidate) > budget && !kept.is_empty() { + kept.pop(); + omitted += 1; + changed = true; + let mut marked = candidate.as_object().expect("object").clone(); + marked.insert(key.to_string(), Value::Array(kept.clone())); + marked.insert(MARKER_TRUNCATED.to_string(), json!(true)); + marked.insert(MARKER_OMITTED.to_string(), json!(omitted)); + marked.insert(MARKER_HINT.to_string(), json!(HINT_RELATED)); + candidate = Value::Object(marked); + } + if length(&candidate) <= budget { + return (candidate, changed); + } + } + (candidate, changed) +} + +fn truncate_outgoing_strategy(payload: &Value, budget: i64) -> (Value, bool) { + let Some(outgoing) = payload.get("outgoing").and_then(Value::as_object) else { + return (payload.clone(), false); + }; + if !outgoing.values().any(Value::is_array) { + return (payload.clone(), false); + } + let mut candidate = payload.clone(); + let mut omitted = existing_omitted(payload); + let mut changed = false; + while length(&candidate) > budget { + let Some((section, targets)) = candidate + .get("outgoing") + .and_then(Value::as_object) + .and_then(|map| { + map.iter() + .rev() + .find(|(_, value)| value.as_array().is_some_and(|items| !items.is_empty())) + }) + else { + break; + }; + let mut updated = candidate.as_object().expect("object").clone(); + let mut outgoing = updated + .get("outgoing") + .and_then(Value::as_object) + .cloned() + .expect("outgoing object"); + let mut kept = targets.as_array().cloned().expect("outgoing targets"); + kept.pop(); + if kept.is_empty() { + outgoing.remove(section); + } else { + outgoing.insert(section.clone(), Value::Array(kept)); + } + omitted += 1; + changed = true; + updated.insert("outgoing".to_string(), Value::Object(outgoing)); + updated.insert(MARKER_TRUNCATED.to_string(), json!(true)); + updated.insert(MARKER_OMITTED.to_string(), json!(omitted)); + updated.insert(MARKER_HINT.to_string(), json!(HINT_RELATED)); + candidate = Value::Object(updated); + } + (candidate, changed) +} + +fn truncate_optional_strategy(payload: &Value, budget: i64) -> (Value, bool) { + let Some(object) = payload.as_object() else { + return (payload.clone(), false); + }; + // These fields are derived context, not the artifact identity itself. Drop + // them in a fixed order only after whole-item collection truncation has + // been exhausted. The marker tells the caller that context was omitted. + const OPTIONAL: [&str; 10] = [ + "provenance", + "evidence", + "outgoing", + "incoming", + "neighborhood", + "attention", + "completeness", + "relationships", + "health", + "validation_status", + ]; + let mut candidate = object.clone(); + let mut changed = false; + for key in OPTIONAL { + if candidate.contains_key(key) && length(&Value::Object(candidate.clone())) > budget { + candidate.remove(key); + candidate.insert(MARKER_TRUNCATED.to_string(), json!(true)); + candidate.insert(MARKER_OMITTED.to_string(), json!(existing_omitted(payload))); + candidate.insert(MARKER_HINT.to_string(), json!(HINT_RELATED)); + changed = true; + } + if length(&Value::Object(candidate.clone())) <= budget { + break; + } + } + (Value::Object(candidate), changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repeated(prefix: &str, count: usize) -> Vec { + (0..count) + .map(|index| json!({"id": format!("{prefix}-{index:04}"), "path": format!("{prefix}/{index}.md")})) + .collect() + } + + #[test] + fn summary_attention_is_truncated_to_the_budget() { + let payload = json!({ + "schema_version": "1", + "directory": "decisions", + "recursive": true, + "attention": repeated("attention", 256), + "health": {"score": 1.0} + }); + let text = serialize(&payload, 512); + assert!(char_len(&text) <= 512, "{} characters", char_len(&text)); + let value: Value = serde_json::from_str(&text).expect("budget result is JSON"); + assert_eq!(value[MARKER_TRUNCATED], json!(true)); + assert!(value[MARKER_OMITTED].as_i64().unwrap_or(0) > 0); + assert!(value["attention"].as_array().unwrap().len() < 256); + } + + #[test] + fn related_incoming_and_neighborhood_are_truncated_deterministically() { + let payload = json!({ + "schema_version": "1", + "id": "ADR-001", + "depth": 3, + "incoming": repeated("incoming", 128), + "neighborhood": repeated("neighbor", 128) + }); + let first = serialize(&payload, 512); + let second = serialize(&payload, 512); + assert_eq!(first, second); + assert!(char_len(&first) <= 512, "{} characters", char_len(&first)); + let value: Value = serde_json::from_str(&first).expect("budget result is JSON"); + assert_eq!(value[MARKER_TRUNCATED], json!(true)); + assert!(value[MARKER_OMITTED].as_i64().unwrap_or(0) > 0); + } + + #[test] + fn outgoing_relationship_targets_are_truncated_deterministically() { + let payload = json!({ + "schema_version": "1", + "id": "ADR-001", + "depth": 3, + "outgoing": { + "related decisions": repeated("decision", 96), + "related requirements": repeated("requirement", 96) + } + }); + let first = serialize(&payload, 512); + let second = serialize(&payload, 512); + assert_eq!(first, second); + assert!(char_len(&first) <= 512, "{} characters", char_len(&first)); + let value: Value = serde_json::from_str(&first).expect("budget result is JSON"); + assert_eq!(value[MARKER_TRUNCATED], json!(true)); + assert!(value[MARKER_OMITTED].as_i64().unwrap_or(0) > 0); + } + + #[test] + fn fixed_fields_return_an_explicit_error_instead_of_an_oversized_success() { + let payload = json!({"query": "x".repeat(20_000)}); + let text = serialize(&payload, MIN_BUDGET); + assert!(char_len(&text) <= MIN_BUDGET); + let value: Value = serde_json::from_str(&text).expect("budget error is JSON"); + assert_eq!(value["error"], json!(BUDGET_ERROR)); + } + + #[test] + fn configured_and_per_call_minimums_are_explicit() { + assert!(valid_configured_budget(MIN_BUDGET)); + assert!(!valid_configured_budget(MIN_BUDGET - 1)); + assert!(validate_call_budget(0).is_ok()); + assert!(validate_call_budget(MIN_BUDGET).is_ok()); + assert!(validate_call_budget(MIN_BUDGET - 1).is_err()); + } +}