From 62d7110d0324a2d5c607c4086c6cf93a59aedc68 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Sun, 1 Nov 2026 22:17:43 +0100 Subject: [PATCH] build(deps): adopt rmcp 3.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two minor releases, read as one src diff of the vendored crates: 3.6.0 carries every server-side change; 3.7.0 touches only the OAuth client (the GHSA-5p52-q7pq-9fq2 SSRF fix, #1385, #1302 — the `auth` feature this crate never enables) and the reqwest client's 401/403 handling (#1341, the test client only). rmcp-macros 3.7.0 is a version bump: its src is byte-identical to 3.5.1. What matters, and why each bugwarden change was made: DEFAULT_MAX_LINE_LENGTH of 16 MiB and DISCARDS a longer one, logging ERROR and reading on. That is the skip-and-resume DESIGN.md rejects for stdio (the request id is inside the unparsed frame, rmcp clients set no timeout, so a peer waits forever), and it sits below this build's own policy-derived cap, which clamps to 64 MiB. Measured: under a 16 MiB attachment cap the 3.5.1 build served a 17 MiB tools/call over stdio and the bare 3.7.0 transport dropped it unanswered, logged the ERROR, and read on. DiscoverAnswering::framed now builds the transport with with_max_line_length(usize::MAX), rmcp's documented off switch, so BoundedLines stays the one bound and closes as before. Two tests pin it: a_frame_of_exactly_this_builds_cap_is_served_past_rmcps_line_bound feeds a frame of exactly the cap a 16 MiB policy derives (~22 MiB) through the real framed constructor and requires it to reach rmcp, so any rmcp bound below the derived cap fails it (without the override it reports `None`); stdio_a_frame_over_rmcps_line_bound_is_served_when_the _policy_admits_it drives a 17 MiB tools/call through the shipped binary under that policy and requires the reply, which fails the moment main frames stdin through anything but `framed`. The 3.5.1 "unbounded Vec" wording in stdio.rs, main.rs, server.rs, binary_shutdown.rs and DESIGN.md is rewritten to say what each rmcp did, and DESIGN.md's stdio serving line now quotes the real call and names the tuple form as the trap. with_max_line_length is the only 3.6-only API the crate's non-test code relies on (the test also reads DEFAULT_MAX_LINE_LENGTH), so the manifest requirement moves from "3.5" to "3.6", not "3.7": nothing 3.7 added over 3.6 is compiled in, and DESIGN.md quotes and justifies the new requirement. `Bad Request: Session ID is required` where 3.5.1 answered 422 `Unexpected message, expect initialize request`, and the stateless branch now refuses a posted JSON-RPC response or error with 400/-32600 where 3.5.1 took it with a 202. Both are the transport's own answers before any handler, so unrecorded either way. No test pinned the old shapes; two raw-POST wiremock tests now pin the new ones (a_session_less_non_initialize_post_is_refused_400_before_any_handler, a_posted_json_rpc_response_on_the_stateless_path_is_refused_400), both failing on the 3.5.1 lock, and DESIGN.md's mcp-session-id trap records them — including that over http the #1283/#1346 shapes below never reach either branch, because expect_json refuses them 415 with the `unrecognized JSON-RPC message` text that any non-JSON-RPC JSON object now draws. beside `result`/`error`, both of those, or `method` with a null id is refused -32600 in-band with no id, and an integral float id (1.0) is read as that integer. 3.5.1 read a request with a float or null id as a NOTIFICATION, which before initialize ended the stdio process through the #261 path and in-session was dropped; 3.7.0 refuses it in-band and keeps waiting. Two binary tests pin the stdio shapes, both failing on the 3.5.1 lock (stdio_a_pre_initialize_float_id_request_is_refused_in _band_and_the_session_survives; stdio_an_in_session_frame_with_method _and_result_is_refused_not_served), and DESIGN.md's stdio trap records them. stateless path reads get_tool off the service built for that request, the session path builds one per lookup, and the factory now runs before the SEP-2243 check. For this build the factory is server.clone(), side- effect free, and get_tool still takes no RequestContext, so the #116 record stays closed as a per-deployment, validate-vs-skip oracle; the DESIGN.md text that described the cache is rewritten. the serve loop's drain ends. The handler future is not dropped, this build's handlers read no ct, and the record is written after dispatch, so the #319 gap is unchanged; DESIGN.md says so beside it. client-side validator now rejects the key on the root and off such a chain. bugwarden authors none; the tripwire already bans the key at any depth and any value type, so only its rationale and the comment naming rmcp's functions move (reject_nested_annotations is gone). Not relevant, with evidence: #1326 refuses server-to-client requests at 2026-07-28 (this server issues none; the only send_request sites are test clients); #1304 stdio_pipes and the new Linux libc dependency (this build wraps tokio::io::stdin itself and never calls rmcp's stdio()); #1323 releases the session-map lock before awaiting a session (liveness only, same API); #1329/#1314 feature gating; #1344 the rmcp client's cancel-on-drop, #1366 pre-init ping answering, #1367 same- origin 307/308 redirects, #1361 re-initialization errors, #1352/#1385/ serves a redirect, and the bearer-gate tests use raw reqwest). Re-verified on 3.7.0 sources: parse_allowed_authority (I9), is_legacy_request, strip_result_type_for_legacy_peer, fill_missing_cache_hints, DiscoverResult, get_tool's signature, handler/server.rs, router/tool.rs, model/meta.rs and model/serde_impl.rs are byte-identical to 3.5.1; LATEST is still 2026-07-28, LATEST_WITH_INITIALIZE 2025-11-25, KNOWN_VERSIONS the same five; StreamableHttpServerConfig still has the ten public fields the DESIGN.md table lists plus the private origin flag; serve_server_with_ct_inner's pre-init paths are unchanged and the serve loop's drain differs only by the #1333 cancel; no Span::record; the one new tracing::error! on a per-request path is #1049's, carries only a numeric field, and is unreachable with the bound off; no new bare % field fed from a client (%client_requested and %id stay as the tracing-caps probe expects); the mutants exclude_re premise holds (the single ExpectedInitializeRequest site passes Some at service/server.rs:667, UnexpectedInitializeResponse only on the handler-result path, Cancelled only under a token ServiceExt::serve makes itself). Every line citation was re-read at both ends, not shifted: service.rs -14, service/server.rs -15 with the pre-init loop now :544-672, model.rs +163; the negotiate re-run citation (:658) and set_peer_info (:323) were stale already and now name their lines (:692, :322). The 3.5.x stamps in DESIGN.md, ci.yml and the crate comments move to 3.7.0. Served bytes, dumped from a 3.5.1 build of origin/main and this one over stdio (initialize at each served revision and the fallbacks, server/discover in five _meta shapes, handshake-free tools/list and tools/call, the bare pre-initialize refusal, an over-cap frame at the default policy) and over http (initialize, in-session and session-less POSTs, header-only and per-request 2026-07-28, GET and DELETE without a session): content-identical except the #1049, #1377 and #1283/#1346 shapes explained above, each of which this message names. Cargo.lock is Dependabot's: rmcp and rmcp-macros 3.7.0, the matched pair, tokio 1.54.1, schemars 1.2.3, serde_json 1.0.154, sha2 0.11.1, plus libc as rmcp's new Linux dependency. Supersedes Dependabot #409. --- .github/workflows/ci.yml | 6 +- Cargo.lock | 41 ++-- crates/bugwarden/Cargo.toml | 4 +- crates/bugwarden/src/config.rs | 6 +- crates/bugwarden/src/main.rs | 18 +- crates/bugwarden/src/server.rs | 44 ++-- crates/bugwarden/src/stdio.rs | 99 +++++++-- .../tests/binary_discover_lifecycle.rs | 4 +- crates/bugwarden/tests/binary_shutdown.rs | 204 +++++++++++++++++- crates/bugwarden/tests/common/deadline.rs | 7 +- .../tests/http_transport_wiremock.rs | 92 +++++++- docs/DESIGN.md | 179 +++++++++------ 12 files changed, 553 insertions(+), 151 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52fccc1..686051e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,7 +356,7 @@ jobs: exit 1 fi # Every three-or-more-part version token in the file, not just the - # ones on a line mentioning rmcp: one stamp says only "in 3.5.0". The + # ones on a line mentioning rmcp: one stamp says only "in 3.7.0". The # loopback addresses are the sole other such token today and this # check keeps it that way -- a new non-rmcp X.Y.Z fails here and has # to be decided, rather than being silently skipped by a narrower @@ -380,8 +380,8 @@ jobs: # requirement, so it is compared with that manifest and not with the # lock: a lock-only patch bump does not move the requirement, and # comparing it there would teach the doc to misquote Cargo.toml. - # DESIGN.md's other two-part mentions ("rmcp 3.5 usage notes", "rmcp - # 3.5 default") stay unchecked -- the file also names rmcp 2.2 and + # DESIGN.md's other two-part mentions ("rmcp 3.7 usage notes", "rmcp + # 3.7 default") stay unchecked -- the file also names rmcp 2.2 and # axum 0.8 in that shape, so no line rule tells a stamp from a # deliberate reference. They can only go stale alongside a three-part # stamp, which already fails above. diff --git a/Cargo.lock b/Cargo.lock index 3e1dd23..e21aa60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,7 +585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1428,7 +1428,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1578,9 +1578,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e" dependencies = [ "async-trait", "base64 0.23.1", @@ -1591,6 +1591,7 @@ dependencies = [ "http-body", "http-body-util", "indexmap", + "libc", "pastey", "pin-project-lite", "rand", @@ -1611,9 +1612,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" dependencies = [ "darling", "proc-macro2", @@ -1653,7 +1654,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1710,7 +1711,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1763,9 +1764,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", @@ -1777,9 +1778,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", @@ -1859,9 +1860,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -1893,9 +1894,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -2069,7 +2070,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2128,9 +2129,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2526,7 +2527,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/bugwarden/Cargo.toml b/crates/bugwarden/Cargo.toml index f7a1378..aacd99e 100644 --- a/crates/bugwarden/Cargo.toml +++ b/crates/bugwarden/Cargo.toml @@ -36,7 +36,7 @@ workspace = true [dependencies] bugwarden-core = { path = "../bugwarden-core", version = "0.7.0" } -rmcp = { version = "3.5", features = [ +rmcp = { version = "3.6", features = [ "server", "macros", "transport-io", @@ -91,7 +91,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # in-memory duplex transport (tools_wiremock) and over real streamable HTTP # (http_transport_wiremock) — so the client half of rmcp is needed here. The # rmcp `reqwest` feature selects rustls, matching the rustls-only workspace. -rmcp = { version = "3.5", features = [ +rmcp = { version = "3.6", features = [ "client", "transport-streamable-http-client-reqwest", "reqwest", diff --git a/crates/bugwarden/src/config.rs b/crates/bugwarden/src/config.rs index f54d466..8f4b8ef 100644 --- a/crates/bugwarden/src/config.rs +++ b/crates/bugwarden/src/config.rs @@ -204,7 +204,7 @@ impl Cli { /// authority (`*`, a scheme-carrying URL, `a;b`, a percent-encoded /// comma, zero-width unicode, a space-containing typo) is a startup /// error. Bare IPv6 (`::1`) is matchable: it is rmcp's default - /// loopback spelling. rmcp 3.5.0's `parse_allowed_authority` would + /// loopback spelling. rmcp 3.7.0's `parse_allowed_authority` would /// otherwise keep an unparsable list (validation on) and store a host /// no inbound `Host` header will ever equal — a silent deny-all. pub fn checked_allowed_hosts(&self) -> anyhow::Result>> { @@ -298,7 +298,7 @@ impl Cli { } } -/// True when `entry` is a Host authority rmcp 3.5.0 would compare to a +/// True when `entry` is a Host authority rmcp 3.7.0 would compare to a /// successfully parsed inbound `Host`. /// /// `http::uri::Authority` is the same parser rmcp uses. A bare IPv6 @@ -726,7 +726,7 @@ mod tests { #[test] fn unparsable_allowed_hosts_are_a_startup_error_named_in_the_message() { - // Each of these is what rmcp 3.5.0 would store (or skip) without + // Each of these is what rmcp 3.7.0 would store (or skip) without // matching any inbound Host — validation on, deny-all. Refusing // them here is what makes that fail at startup instead of one 403 // at a time. Deleting the refusal, or warning and continuing, must diff --git a/crates/bugwarden/src/main.rs b/crates/bugwarden/src/main.rs index a46a686..82e3ce5 100644 --- a/crates/bugwarden/src/main.rs +++ b/crates/bugwarden/src/main.rs @@ -259,11 +259,12 @@ async fn main() -> anyhow::Result<()> { // anything that then races an unhandled signal (#173). let shutdown = shutdown_signal(); tokio::pin!(shutdown); - // Bounded stdin, never rmcp's bare `stdio()`: it reads a - // frame into an unbounded `Vec` (#234). Built here for the - // same reason `http_server_config()` is built in the other - // arm — the cap comes from `server`'s own policy, and - // `serve` takes it by value. + // Bounded stdin, never rmcp's bare `stdio()`: 3.5 read a + // frame into an unbounded `Vec` (#234), 3.6+ drops one over + // its own 16 MiB. Built here for the same reason + // `http_server_config()` is built in the other arm — the + // cap comes from `server`'s own policy, and `serve` takes + // it by value. let (stdin, stdout) = server.stdio_transport(); // rmcp maps every read error to `receive() -> None`, which // the service reports as an ordinary close. Two flags tell @@ -473,8 +474,8 @@ const INPUT_STREAM_FAILED: &str = "the input stream failed before initialize"; /// A fixed, server-authored classification of a failed stdio handshake — /// the only thing either exit path may say about one (#261). /// -/// rmcp 3.5.0's `ServerInitializeError` -/// (`service/server.rs:83-105`, `#[non_exhaustive]`) puts the client's +/// rmcp 3.7.0's `ServerInitializeError` +/// (`service/server.rs:70-91`, `#[non_exhaustive]`) puts the client's /// whole first frame into `ExpectedInitializeRequest`, and its /// `#[error("expect initialized request, but received: {0:?}")]` puts it /// into DISPLAY as well as `Debug` — so neither the error nor its Display @@ -521,7 +522,8 @@ fn serve_failure(error: &rmcp::service::ServerInitializeError) -> &'static str { Frame::Response(_) => "the first frame was a response, not initialize", Frame::Error(_) => "the first frame was an error reply, not initialize", }, - // Unreachable on rmcp 3.5.0: both construction sites pass `Some`. + // Unreachable on rmcp 3.7.0: the one construction site + // (`service/server.rs:667`) passes `Some`. Failure::ExpectedInitializeRequest(None) => "no first frame was received", // EOF before initialize. A read error and this build's own // frame-cap refusal reach the same variant; the caller names diff --git a/crates/bugwarden/src/server.rs b/crates/bugwarden/src/server.rs index 9c9539b..7ae4dc3 100644 --- a/crates/bugwarden/src/server.rs +++ b/crates/bugwarden/src/server.rs @@ -841,7 +841,7 @@ fn allowlisted(params: Option<&JsonObject>) -> BTreeMap { /// * [`Lifecycle::PerRequest`] — the request's own `_meta`. **Never /// `ctx.client_info()`**: that convenience accessor falls back to /// `peer_info().client_info` unless `request_metadata_required()`, which -/// rmcp 3.5.0 sets only on the stdio handshake-free path +/// rmcp 3.7.0 sets only on the stdio handshake-free path /// (`service/server.rs`), so over streamable http it IS the /// `{"name":"rmcp",…}` placeholder rmcp synthesises for a peer that /// never handshook (`peer_info_for_stateless_request`, #34 §3c) — and @@ -973,7 +973,7 @@ enum Lifecycle { /// what gets served and who gets recorded cannot diverge by call-site /// ordering. /// -/// rmcp 3.5.0 routes on `_meta` shape, not on the negotiated revision and +/// rmcp 3.7.0 routes on `_meta` shape, not on the negotiated revision and /// not on [`SUPPORTED_PROTOCOL_VERSIONS`]: `is_legacy_request` /// (`transport/streamable_http_server/tower.rs`) returns true — the session /// path — for every `initialize` (rmcp #1228), and otherwise sends a POST @@ -1048,7 +1048,7 @@ fn read_scope_serves(tool: &str) -> bool { /// The refusal for a tool this request's credential does not reach. /// /// Byte-identical to what `ToolRouter` answers for a name it does not route -/// (rmcp 3.5 `handler/server/router/tool.rs`), so a scope-hidden tool is +/// (rmcp 3.7 `handler/server/router/tool.rs`), so a scope-hidden tool is /// indistinguishable from one that does not exist — the same /// indistinguishability read-only mode already gives every caller (I13), and /// no oracle about which tools the deployment serves (I2). @@ -3365,7 +3365,7 @@ impl BugWarden { /// there is one place where the policy field is read. Call it before /// the server moves into the service closure. /// - /// These rmcp 3.5 defaults are set by name rather than inherited, + /// These rmcp 3.7 defaults are set by name rather than inherited, /// because inheriting them changes how a deployment behaves without /// anyone choosing it: /// @@ -3409,7 +3409,7 @@ impl BugWarden { /// have recorded the attempt — though rmcp's own refusal body prints /// the number outright. That the boundary is observable to an /// unauthenticated client, and what it discloses, is recorded in - /// DESIGN.md under "rmcp 3.5 usage notes". + /// DESIGN.md under "rmcp 3.7 usage notes". /// /// `allowed_origins` is the browser-facing sibling of `allowed_hosts` /// and the same reasoning covers it, but it is left inherited: its @@ -3417,7 +3417,7 @@ impl BugWarden { /// nothing. Anything that changes the `allowed_hosts` call below should /// decide this one too rather than leave it behind. The remaining /// fields, and why each stays inherited, are inventoried in DESIGN.md - /// under "rmcp 3.5 usage notes"; the caller in `main` adds + /// under "rmcp 3.7 usage notes"; the caller in `main` adds /// `cancellation_token` so a SIGINT or SIGTERM reaches the live /// transport (issue #114). /// @@ -3460,9 +3460,12 @@ impl BugWarden { /// The stdio transport pair to serve, in place of rmcp's bare /// `stdio()`. /// - /// Stdin is wrapped in [`BoundedLines`] at `request_body_cap`: - /// rmcp reads a frame into an unbounded `Vec` (#234), so without this - /// the transport buffers whatever a peer writes before a newline. + /// Stdin is wrapped in [`BoundedLines`] at `request_body_cap`: rmcp + /// 3.5 read a frame into an unbounded `Vec` (#234), and rmcp 3.6+ + /// bounds one at its own fixed 16 MiB and skips it, which + /// `DiscoverAnswering::framed` switches off — so without this the + /// transport either buffers whatever a peer writes before a newline or + /// answers a policy-admitted frame with silence. /// Stdout is untouched — it carries this server's own output, which the /// `FramedWrite` half already frames. /// @@ -6114,7 +6117,7 @@ impl ServerHandler for BugWarden { Reach::ReadOnly => tools.retain(|tool| read_scope_serves(&tool.name)), Reach::Nothing => tools.clear(), } - // The SEP-2549 hints are ours to gate: rmcp 3.5.0 strips + // The SEP-2549 hints are ours to gate: rmcp 3.7.0 strips // `resultType` for a legacy peer and leaves these two alone. The // predicate is HAND-COPIED from its `sep_2322_supported` — no shared // constant, no test pinning the agreement — so re-read both on an @@ -7718,12 +7721,12 @@ mod tests { } } - /// SEP-2243's schema annotation, byte-exact as rmcp 3.5.0 reads it — + /// SEP-2243's schema annotation, byte-exact as rmcp 3.7.0 reads it — /// `schema.get("x-mcp-header")` in `transport/common/mcp_headers.rs` - /// (`param_header_annotations`, `validate_param_header_annotations`, - /// `reject_nested_annotations`). Upstream exports no constant for the - /// spelling, so this is our own copy: re-grep the vendored tree on every - /// rmcp bump. + /// (`collect_property_annotations`, behind `param_header_annotations` + /// and `validate_param_header_annotations`). Upstream exports no + /// constant for the spelling, so this is our own copy: re-grep the + /// vendored tree on every rmcp bump. const X_MCP_HEADER: &str = "x-mcp-header"; /// The forbidden annotation anywhere in a serialized tool definition, as @@ -7736,12 +7739,13 @@ mod tests { /// with no recursion arms to keep in lockstep, which is the maintenance /// surface `schema_portability_error` above has to carry. /// - /// Deliberately broader than rmcp's functional read, which in 3.5.0 is - /// top-level `properties` and string-valued only: the key is banned + /// Deliberately broader than rmcp's functional read, which in 3.7.0 is + /// every `properties` chain from the root (nested since 3.6, #1359; + /// top-level only before) and string-valued only: the key is banned /// everywhere, at any value type, because upstream's depth rules are - /// version-specific. It will also trip on the substring appearing in a - /// description or a property name — accepted; a tripwire fails loud and - /// a human looks. + /// version-specific and have moved once. It will also trip on the + /// substring appearing in a description or a property name — accepted; + /// a tripwire fails loud and a human looks. fn x_mcp_header_smell(name: &str, tool_json: &Value) -> Option { let serialized = tool_json.to_string(); serialized.contains(X_MCP_HEADER).then(|| { diff --git a/crates/bugwarden/src/stdio.rs b/crates/bugwarden/src/stdio.rs index bb97d6c..07e7c61 100644 --- a/crates/bugwarden/src/stdio.rs +++ b/crates/bugwarden/src/stdio.rs @@ -1,11 +1,15 @@ //! What wraps rmcp's stdio transport: a frame bound ([`BoundedLines`]) //! and a `server/discover` answer ([`DiscoverAnswering`]). //! -//! rmcp's `AsyncRwTransport` reads a newline-delimited frame with +//! rmcp 3.5's `AsyncRwTransport` read a newline-delimited frame with //! `read_until(b'\n', &mut self.line_buf)` into a `Vec` it never -//! bounds, so a peer that writes bytes and no `\n` grows that buffer until -//! the allocator gives up (#234). HTTP has the derived POST cap and a -//! `413`; stdio had nothing. +//! bounded, so a peer that wrote bytes and no `\n` grew that buffer until +//! the allocator gave up (#234). HTTP has the derived POST cap and a +//! `413`; stdio had nothing. rmcp 3.6 (#1049) added a bound of its own, a +//! fixed 16 MiB that DISCARDS the frame and reads on; +//! [`DiscoverAnswering::framed`] switches it off, because the cap here is +//! the operator's and may exceed it, and because skipping is what the next +//! paragraph rejects. //! //! [`BoundedLines`] is that missing half: an [`AsyncRead`] wrapper that //! counts the bytes since the last `\n` and fails the read past the cap. @@ -208,9 +212,9 @@ type QueuedSend = Pin> + Send>>; /// /// `server/discover` is a probe, but over stdio rmcp lets it CHOOSE the /// session lifecycle before anyone answers it: `serve_server_with_ct_inner` -/// (rmcp 3.5.0 `service/server.rs:559-636`) takes any first non-`ping` +/// (rmcp 3.7.0 `service/server.rs:544-672`) takes any first non-`ping` /// frame that is not `initialize` as a commitment to the handshake-free -/// lifecycle and calls `Peer::require_request_metadata()` (`:625`) — a +/// lifecycle and calls `Peer::require_request_metadata()` (`:653`) — a /// sticky `AtomicBool` that no later `initialize` clears and no public API /// can reset, both methods being `pub(crate)`. Every subsequent request /// but `initialize` that carries no `_meta` is then refused -32602 @@ -236,11 +240,11 @@ pub struct DiscoverAnswering> { server: BugWarden, /// A discover reply whose write has not finished, parked across /// `receive` calls because `receive` is one arm of rmcp's `select!` - /// (`service.rs:1412`) and is dropped whenever another arm wins. A + /// (`service.rs:1398`) and is dropped whenever another arm wins. A /// reply awaited on that stack dies with the frame that asked for it — /// consumed, never answered, the client hung on that id — while the /// inner transport survives the same cancellation by keeping its - /// partial line in `line_buf` (`transport/async_rw.rs:126-131`). + /// partial line in `line_buf` (`transport/async_rw.rs:247-256`). pending: Option>, /// Set once a probe's reply has reached the peer, served or refused. /// See [`DiscoverAnswering::answered`]. @@ -270,9 +274,9 @@ impl> DiscoverAnswering { } /// rmcp's own answer for one discover request, in rmcp's own order: - /// the pre-`initialize` metadata check (`service/server.rs:604-619`, - /// its text at `:528`), then the declared-revision check - /// (`handler/server.rs:64-72`), then that file's default result (`:397`). + /// the pre-`initialize` metadata check (`service/server.rs:588-604`, + /// its text at `:516`), then the declared-revision check + /// (`handler/server.rs:64-72`), then that file's default result (`:396`). /// /// That order is the pre-`initialize` path's, which is the one this /// replaces; rmcp's in-session handler runs the two the other way @@ -282,7 +286,7 @@ impl> DiscoverAnswering { /// required key declares no lifecycle worth reading a version out of. /// /// Not stripped for a legacy peer: `strip_result_type_for_legacy_peer` - /// (`model.rs:4789`) has no `DiscoverResult` arm, so `resultType` + /// (`model.rs:4951`) has no `DiscoverResult` arm, so `resultType` /// stays on the wire whatever revision the probe declares. fn discover_reply(&self, request: &ClientRequest, id: RequestId) -> ServerJsonRpcMessage { let meta = request.get_meta(); @@ -325,8 +329,16 @@ where { /// The same wrapper over a newline-framed pair, so `main` frames stdio /// through rmcp rather than naming rmcp's transport itself. + /// + /// rmcp's own line bound is switched off: since 3.6 it drops a frame + /// over a fixed 16 MiB and reads on — the skip-and-resume this module + /// rejects, at a number the operator's cap may exceed. [`BoundedLines`] + /// is the one bound, and it closes. pub fn framed(read: R, write: W, server: BugWarden) -> Self { - Self::new(AsyncRwTransport::new_server(read, write), server) + Self::new( + AsyncRwTransport::new_server(read, write).with_max_line_length(usize::MAX), + server, + ) } } @@ -639,6 +651,7 @@ mod discover { use rmcp::model::{ ClientJsonRpcMessage, ClientRequest, DiscoverResult, ProtocolVersion, ServerJsonRpcMessage, }; + use rmcp::transport::async_rw::DEFAULT_MAX_LINE_LENGTH; use rmcp::transport::Transport as _; use rmcp::{RoleServer, ServerHandler as _, ServiceExt as _}; use serde_json::{json, Value}; @@ -646,7 +659,7 @@ mod discover { AsyncBufReadExt as _, AsyncWriteExt as _, BufReader, DuplexStream, ReadHalf, WriteHalf, }; - use super::DiscoverAnswering; + use super::{BoundedLines, DiscoverAnswering}; use crate::pinned_cli::pinned; use crate::server::{bugzilla_client, BugWarden, SUPPORTED_PROTOCOL_VERSIONS}; @@ -666,6 +679,10 @@ mod discover { const PIPE: usize = 8 * 1024; fn test_server() -> BugWarden { + test_server_with_policy("") + } + + fn test_server_with_policy(policy: &str) -> BugWarden { let cfg = Arc::new(pinned(&[ "bugwarden", "--bugzilla-server", @@ -676,7 +693,7 @@ mod discover { "test-key", ])); let guard = Arc::new(Guard { - policy: Policy::from_toml_str("").expect("the empty policy must parse"), + policy: Policy::from_toml_str(policy).expect("the test policy must parse"), }); let bz = Arc::new(bugzilla_client(&cfg).expect("client must build")); BugWarden::new(cfg, guard, bz).expect("server must build") @@ -906,7 +923,7 @@ mod discover { async fn a_pipelined_probe_is_answered_under_write_contention() { // The one thing this wrapper does that the inner transport does // not: hold a reply. rmcp polls `receive` as one arm of a - // `select!` (rmcp 3.5.0 `service.rs:1412`) and drops the future + // `select!` (rmcp 3.7.0 `service.rs:1398`) and drops the future // whenever another arm wins, so a reply awaited on `receive`'s own // stack dies with the frame that asked for it — consumed, never // answered, the client hung on that id while every other reply @@ -1061,7 +1078,7 @@ mod discover { "the probe must name this build, and nothing else: {probe}" ); // Present whatever revision the probe declares: - // `strip_result_type_for_legacy_peer` (rmcp 3.5.0 model.rs) has no + // `strip_result_type_for_legacy_peer` (rmcp 3.7.0 model.rs) has no // `DiscoverResult` arm, so rmcp never stripped it here either. assert_eq!(probe["result"]["resultType"], "complete", "{probe}"); } @@ -1382,4 +1399,52 @@ mod discover { .iter() .any(|version| version.as_str() == UNSERVED)); } + + #[tokio::test] + async fn a_frame_of_exactly_this_builds_cap_is_served_past_rmcps_line_bound() { + // `framed` switches off rmcp's fixed 16 MiB line bound, so a frame + // the policy's cap admits reaches rmcp whatever its size. Any rmcp + // bound below the derived cap fails this: without the override + // `receive` discards the frame and reports the EOF behind it. + let server = test_server_with_policy("[global]\nmax_attachment_bytes = 16777216\n"); + let cap = server.stdio_transport().0.cap(); + assert!( + cap > DEFAULT_MAX_LINE_LENGTH, + "the premise: a cap above rmcp's own" + ); + // Exactly the cap before the delimiter: the largest frame accepted. + let frame = { + let head = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mcp_server_info","arguments":{"pad":""#; + let tail = "\"}}}"; + format!( + "{head}{}{tail}\n", + "x".repeat(cap - head.len() - tail.len()) + ) + }; + assert_eq!(frame.len() - 1, cap); + let (mut peer, ours) = tokio::io::duplex(64 * 1024); + let mut transport = + DiscoverAnswering::framed(BoundedLines::new(ours, cap), tokio::io::sink(), server); + // A 22 MiB write through a 64 KiB duplex completes only as the + // transport reads it, so the writer runs beside the read. + let writer = tokio::spawn(async move { + peer.write_all(frame.as_bytes()) + .await + .expect("the duplex must take the frame"); + }); + let received = tokio::time::timeout(REPLY_BUDGET, transport.receive()) + .await + .expect("the transport must answer within the budget"); + match received { + Some(ClientJsonRpcMessage::Request(request)) => assert!( + matches!(request.request, ClientRequest::CallToolRequest(_)), + "the frame must come through as the call it is" + ), + other => panic!("a frame the policy admits must reach rmcp, got {other:?}"), + } + tokio::time::timeout(REPLY_BUDGET, writer) + .await + .expect("the writer must finish within the budget") + .expect("the writer must not panic"); + } } diff --git a/crates/bugwarden/tests/binary_discover_lifecycle.rs b/crates/bugwarden/tests/binary_discover_lifecycle.rs index 1e6021a..290d97c 100644 --- a/crates/bugwarden/tests/binary_discover_lifecycle.rs +++ b/crates/bugwarden/tests/binary_discover_lifecycle.rs @@ -351,8 +351,8 @@ async fn a_probe_only_client_hangs_up_cleanly() { #[tokio::test] async fn a_pipelined_probe_is_answered_over_real_pipes() { // A probe behind other traffic, which no lock-step row here reaches: - // rmcp polls `receive` as one arm of a `select!` (rmcp 3.5.0 - // `service.rs:1412`) and drops that future whenever another arm wins, + // rmcp polls `receive` as one arm of a `select!` (rmcp 3.7.0 + // `service.rs:1398`) and drops that future whenever another arm wins, // so a reply the wrapper awaited on `receive`'s own stack would be // dropped with it — the frame consumed, the id never answered, the // client hung on it while every other reply arrives. The listings are diff --git a/crates/bugwarden/tests/binary_shutdown.rs b/crates/bugwarden/tests/binary_shutdown.rs index dda6382..e8a8142 100644 --- a/crates/bugwarden/tests/binary_shutdown.rs +++ b/crates/bugwarden/tests/binary_shutdown.rs @@ -50,7 +50,7 @@ //! - any `serve_failure` arm deleted so its failure falls to the wildcard, //! or any two of its literals exchanged. Three arms have no row, and //! cannot: `ExpectedInitializeRequest(None)` is constructed nowhere in -//! rmcp 3.5.0, `UnexpectedInitializeResponse` cannot happen while rmcp +//! rmcp 3.7.0, `UnexpectedInitializeResponse` cannot happen while rmcp //! maps this handler's own `InitializeResult` into the result it then //! checks, and `Cancelled` needs a cancellation token `ServiceExt::serve` //! creates and never cancels. cargo-mutants reaches only the OUTER @@ -118,8 +118,8 @@ fn the_scrub_list_covers_every_environment_fallback() { const STDIO_READY: &str = "Starting Bugzilla MCP server on stdio"; /// Past the 4 MiB cap the default policy derives, and with no delimiter -/// anywhere in it: an unbounded read is the defect, so the frame must be -/// one the server can never complete. +/// anywhere in it: the server must refuse it at the cap rather than wait +/// for a delimiter that never comes. const OVER_CAP_FRAME: usize = 5 * 1024 * 1024; /// bugwarden's own refusal line, in full. @@ -860,9 +860,9 @@ async fn stdio_sigterm_during_the_handshake_wait_exits_zero() { #[tokio::test] async fn stdio_an_over_cap_frame_before_the_handshake_exits_one() { - // Served on a bare `stdio()` this never returns: rmcp's `read_until` - // takes all 5 MiB into an unbounded `Vec` and waits for the delimiter, - // so `wait` times out instead of the process exiting (#234). + // Served on a bare `stdio()` this never returns: 5 MiB is under rmcp's + // own 16 MiB line bound, so it buffers the whole frame and waits for + // the delimiter; `wait` times out instead of the process exiting (#234). let mut server = spawn_stdio(); server.wait_for_stderr(STDIO_READY).await; write_an_over_cap_frame(&mut server).await; @@ -914,7 +914,7 @@ async fn stdio_an_over_cap_frame_after_initialize_exits_one() { #[tokio::test] async fn stdio_a_pre_initialize_tool_call_never_logs_the_frame() { - // rmcp 3.5.1 answers this frame in-band: one stdout -32602 + // rmcp 3.7.0 answers this frame in-band: one stdout -32602 // with a fixed `_meta` message, no client bytes, and the // session keeps waiting for `initialize`. The handler never // runs, stderr stays free of the frame, and the process only @@ -1530,3 +1530,193 @@ async fn stdio_sigterm_mid_call_still_records_the_call() { ); assert_one_error_record(&audit_path, "stdio SIGTERM mid-call"); } + +/// The legacy handshake, as one frame. +const INITIALIZE_FRAME: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"binary-shutdown-test","version":"0"}}}"#; + +/// A frame over rmcp's own 16 MiB line bound and under the cap +/// [`wide_cap_policy`] derives. +const WIDE_FRAME: usize = 17 * 1024 * 1024; + +/// Write a policy whose derived frame cap sits above rmcp's own line +/// bound: a 16 MiB attachment cap derives one of ~22 MiB. +fn wide_cap_policy(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("policy.toml"); + std::fs::write(&path, "[global]\nmax_attachment_bytes = 16777216\n") + .expect("the policy must be writable"); + path +} + +/// The next stdout line, under the shared call deadline. +async fn next_reply( + lines: &mut tokio::io::Lines>, + what: &str, +) -> String { + tokio::time::timeout(deadline::CALL_DEADLINE, lines.next_line()) + .await + .unwrap_or_else(|_| { + panic!( + "{what}: the server must answer within {:?}", + deadline::CALL_DEADLINE + ) + }) + .expect("stdout must be readable") + .unwrap_or_else(|| panic!("{what}: the server must answer, not exit")) +} + +/// Handshake the child and keep its stdout reader, for the rows that read +/// replies after the handshake instead of draining them. +async fn handshake_keeping_stdout( + server: &mut Server, +) -> tokio::io::Lines> { + let stdout = server.child.stdout.take().expect("stdout is piped"); + let mut lines = BufReader::new(stdout).lines(); + server.write_frame(INITIALIZE_FRAME).await; + let reply = next_reply(&mut lines, "the handshake").await; + assert!( + reply.contains("bugwarden"), + "initialize must complete before the test acts: {reply}" + ); + server + .write_frame(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .await; + lines +} + +/// Close stdin and require the clean exit a peer hangup after the +/// handshake gets. +async fn assert_hangup_exit(mut server: Server, what: &str) { + server.close_stdin(); + let status = tokio::time::timeout(EXIT_TIMEOUT, server.child.wait()) + .await + .unwrap_or_else(|_| { + panic!("{what}: the process must exit within {EXIT_TIMEOUT:?} of stdin closing") + }) + .expect("the child must be waitable"); + assert_eq!( + status.code(), + Some(0), + "{what}: a peer hangup after the handshake is a clean exit: status={status:?} stderr={log}", + log = server.log + ); +} + +#[tokio::test] +async fn stdio_a_frame_over_rmcps_line_bound_is_served_when_the_policy_admits_it() { + // Pins main's wiring: stdin is framed through `DiscoverAnswering::framed`, + // which switches off rmcp's fixed 16 MiB line bound, so the policy's + // cap is the only one. Served on the bare transport this call is + // discarded with an rmcp ERROR and the reply never comes. + let dir = tempfile::tempdir().expect("a temp dir"); + let policy = wide_cap_policy(&dir); + let mut server = Server::spawn(&[ + "--transport", + "stdio", + "--bugzilla-server", + "https://bugzilla.example.invalid", + "--api-key", + "test-key", + "--policy", + policy.to_str().expect("a utf-8 temp path"), + ]); + server.wait_for_stderr(STDIO_READY).await; + let mut lines = handshake_keeping_stdout(&mut server).await; + let frame = { + let head = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mcp_server_info","arguments":{"pad":""#; + let tail = "\"}}}\n"; + format!( + "{head}{}{tail}", + run_of('x', WIDE_FRAME - head.len() - tail.len()) + ) + }; + let stdin = server.stdin.as_mut().expect("stdin is piped"); + tokio::time::timeout(deadline::CALL_DEADLINE, stdin.write_all(frame.as_bytes())) + .await + .expect("the child must keep reading the frame") + .expect("the child must take the whole frame"); + let reply = next_reply(&mut lines, "a 17 MiB call under a wider cap").await; + let json: serde_json::Value = serde_json::from_str(&reply).expect("the reply must be JSON"); + assert_eq!( + json["id"], + serde_json::json!(2), + "the reply must answer the call: {reply}" + ); + assert_eq!( + json["result"]["isError"], + serde_json::json!(false), + "the call must be served, not refused: {reply}" + ); + assert_hangup_exit(server, "stdio wide-cap frame").await; +} + +#[tokio::test] +async fn stdio_a_pre_initialize_float_id_request_is_refused_in_band_and_the_session_survives() { + // rmcp 3.6 (#1283) classifies a frame by its fields before reading the + // id, so a request whose id is `1.5` is refused -32600 with no id and + // the process keeps waiting for `initialize`; 3.5 read it as a + // notification and ended the process with no reply (#261's path). + let mut server = spawn_stdio(); + server.wait_for_stderr(STDIO_READY).await; + let stdout = server.child.stdout.take().expect("stdout is piped"); + let mut lines = BufReader::new(stdout).lines(); + server + .write_frame(r#"{"jsonrpc":"2.0","id":1.5,"method":"tools/list","params":{}}"#) + .await; + let reply = next_reply(&mut lines, "a float-id request before initialize").await; + let json: serde_json::Value = serde_json::from_str(&reply).expect("the reply must be JSON"); + assert_eq!( + json["error"]["code"], + serde_json::json!(-32600), + "refused as no JSON-RPC shape: {reply}" + ); + assert!( + json.get("id").is_none(), + "an id that is neither integer nor string cannot be echoed: {reply}" + ); + server.write_frame(INITIALIZE_FRAME).await; + let reply = next_reply(&mut lines, "initialize after a refused frame").await; + assert!( + reply.contains(r#""protocolVersion":"2025-11-25""#), + "the session must still open: {reply}" + ); + server + .write_frame(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .await; + assert_hangup_exit(server, "stdio float-id then handshake").await; +} + +#[tokio::test] +async fn stdio_an_in_session_frame_with_method_and_result_is_refused_not_served() { + // rmcp 3.6 (#1283): a frame carrying `method` beside `result` is no + // JSON-RPC shape at all, so it is refused -32600 in-band with no id, + // where 3.5 took it for the request its `method` named and served it. + let mut server = spawn_stdio(); + server.wait_for_stderr(STDIO_READY).await; + let mut lines = handshake_keeping_stdout(&mut server).await; + server + .write_frame(r#"{"jsonrpc":"2.0","id":2,"method":"ping","result":{}}"#) + .await; + let reply = next_reply(&mut lines, "a method-and-result frame").await; + let json: serde_json::Value = serde_json::from_str(&reply).expect("the reply must be JSON"); + assert_eq!( + json["error"]["code"], + serde_json::json!(-32600), + "refused, not served as the ping it names: {reply}" + ); + assert!( + json.get("id").is_none() && json.get("result").is_none(), + "a refusal with nothing of the frame in it: {reply}" + ); + server + .write_frame(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#) + .await; + let reply = next_reply(&mut lines, "a ping after the refusal").await; + let json: serde_json::Value = serde_json::from_str(&reply).expect("the reply must be JSON"); + assert_eq!(json["id"], serde_json::json!(3), "{reply}"); + assert_eq!( + json["result"], + serde_json::json!({}), + "the session survives the refused frame: {reply}" + ); + assert_hangup_exit(server, "stdio method-and-result frame").await; +} diff --git a/crates/bugwarden/tests/common/deadline.rs b/crates/bugwarden/tests/common/deadline.rs index 84e3efb..093ee75 100644 --- a/crates/bugwarden/tests/common/deadline.rs +++ b/crates/bugwarden/tests/common/deadline.rs @@ -18,9 +18,10 @@ use std::time::Duration; /// Bound on every request one of these harnesses awaits an answer to. /// -/// rmcp 3.5.0 sends with `PeerRequestOptions::default()`, whose `timeout` -/// is `None` (`service.rs`), and the `initialize` handshake reads its -/// response off the transport with no deadline either. So a request the +/// rmcp 3.7.0's typed client calls send with +/// `PeerRequestOptions::no_options()` — `Self::default()`, `timeout: None` +/// (`service.rs`) — and the `initialize` handshake reads its response off +/// the transport with no deadline either. So a request the /// server never answers waits forever: it hangs its test, its whole test /// binary, and — cargo runs the `bugwarden` crate's binaries before /// `bugwarden-core`'s — every binary queued behind it. A defect must fail diff --git a/crates/bugwarden/tests/http_transport_wiremock.rs b/crates/bugwarden/tests/http_transport_wiremock.rs index e15cb25..f818c5c 100644 --- a/crates/bugwarden/tests/http_transport_wiremock.rs +++ b/crates/bugwarden/tests/http_transport_wiremock.rs @@ -454,7 +454,7 @@ async fn guard_denies_uniformly_over_http() { #[tokio::test] async fn a_client_addressing_the_server_by_name_is_served() { - // rmcp 3.5's `allowed_hosts` default is loopback only, so inheriting it + // rmcp 3.7's `allowed_hosts` default is loopback only, so inheriting it // would answer every request whose `Host` is the name the operator // actually deployed under — every containerised one — with a rejection. // main.rs disables that validation by name; this pins the decision, @@ -576,7 +576,7 @@ async fn an_allowed_hosts_entry_naming_no_host_leaves_validation_off() { #[tokio::test] async fn an_unparsable_allowed_hosts_entry_is_a_startup_error() { - // The same path `serve_http` and `main` take: rmcp 3.5.0 would store + // The same path `serve_http` and `main` take: rmcp 3.7.0 would store // `*` as a host that matches only `Host: *`, turning validation on as // a silent deny-all. Refusing here is what the operator sees instead // of one 403 at a time. @@ -599,7 +599,7 @@ async fn an_unparsable_allowed_hosts_entry_is_a_startup_error() { #[tokio::test] async fn a_handshake_free_call_is_refused_and_never_names_a_client() { - // rmcp 3.5.0 routes to its handshake-free lifecycle when the request's + // rmcp 3.7.0 routes to its handshake-free lifecycle when the request's // revision is 2026-07-28 or newer, or `_meta` carries BOTH // `protocolVersion` and `clientCapabilities` — not for `initialize`, // which always takes the session path (#1228). The body below sends @@ -892,7 +892,7 @@ async fn traceparent_over_http_lands_in_the_audit_record() { // `CallToolRequestParams.meta` arrives `None` here — and delivers it // to the handler as the extensions-backed `RequestContext.meta`, so // this test pins the `context.meta` fallback that every serialized - // transport depends on (see the rmcp 3.5 usage notes in DESIGN.md). + // transport depends on (see the rmcp 3.7 usage notes in DESIGN.md). let mock = MockServer::start().await; mount_bug_for_key(&mock, world_readable_bug(7), "srv-key").await; @@ -1525,7 +1525,7 @@ async fn a_per_request_call_naming_no_client_is_served_and_names_no_placeholder( // The mutation this kills is `client_of` reading `ctx.client_info()` or // `peer_info()` on this path: rmcp synthesises the stateless peer with // `Implementation::default()`, so either would put - // `{"name":"rmcp","version":"3.5.0"}` into the record — not a missing + // `{"name":"rmcp","version":"3.7.0"}` into the record — not a missing // field but a plausible wrong one. The whole file is checked for the // string, not just this record's field, because a placeholder that // leaked into any other record would be the same defect. @@ -2412,3 +2412,85 @@ async fn a_stray_mcp_param_header_is_inert_on_the_per_request_path() { "a refused call contacts no upstream, canary header or not" ); } + +#[tokio::test] +async fn a_session_less_non_initialize_post_is_refused_400_before_any_handler() { + // rmcp 3.6 (#1377): the session branch answers a POST that carries no + // `mcp-session-id` and is not `initialize` with 400 and a fixed body, + // where 3.5 said 422 `Unexpected message, expect initialize request`. + // The transport's own answer: no handler, no record, no upstream. + let mock = MockServer::start().await; + let dir = tempfile::tempdir().expect("audit temp dir"); + let file = key_file("srv-key\n"); + let (addr, audit_path) = served_with_audit(&mock, &dir, &file).await; + let before = mock.received_requests().await.unwrap_or_default().len(); + let response = raw_client() + .post(format!("http://{addr}/mcp")) + .header("Accept", "application/json, text/event-stream") + .header("Content-Type", "application/json") + .json(&json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {} })) + .send() + .await + .expect("the session-less POST must reach the server"); + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "refused before any handler" + ); + assert_eq!( + response.text().await.expect("a body"), + "Bad Request: Session ID is required" + ); + assert!( + !audit_path.exists() || audit_events(&audit_path).is_empty(), + "a request the handler never saw records nothing" + ); + assert_eq!( + mock.received_requests().await.unwrap_or_default().len(), + before, + "and contacts no upstream" + ); +} + +#[tokio::test] +async fn a_posted_json_rpc_response_on_the_stateless_path_is_refused_400() { + // #1377's other half: a JSON-RPC response POSTed at 2026-07-28 answers + // no request this server made, so the stateless branch refuses it + // -32600 where 3.5 took it with a 202. Transport-level again: no + // handler, no record, no upstream. + let mock = MockServer::start().await; + let dir = tempfile::tempdir().expect("audit temp dir"); + let file = key_file("srv-key\n"); + let (addr, audit_path) = served_with_audit(&mock, &dir, &file).await; + let before = mock.received_requests().await.unwrap_or_default().len(); + let response = raw_client() + .post(format!("http://{addr}/mcp")) + .header("Accept", "application/json, text/event-stream") + .header("Content-Type", "application/json") + .header("MCP-Protocol-Version", PER_REQUEST_REVISION) + .json(&json!({ "jsonrpc": "2.0", "id": 9, "result": {} })) + .send() + .await + .expect("the posted response must reach the server"); + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "refused before any handler" + ); + let body: Value = response.json().await.expect("a JSON-RPC error body"); + assert_eq!(body["error"]["code"], json!(-32600), "{body}"); + assert_eq!( + body["error"]["message"], + json!("stateless server does not accept JSON-RPC responses"), + "{body}" + ); + assert!( + !audit_path.exists() || audit_events(&audit_path).is_empty(), + "a request the handler never saw records nothing" + ); + assert_eq!( + mock.received_requests().await.unwrap_or_default().len(), + before, + "and contacts no upstream" + ); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 642de8b..f692957 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -11,7 +11,7 @@ Cargo workspace, two crates: - `crates/bugwarden-core` — guard policy engine + async Bugzilla REST client. MUST NOT depend on rmcp, axum, clap, or any MCP/transport crate. -- `crates/bugwarden` — the binary: clap CLI, rmcp 3.5 MCP server, stdio and +- `crates/bugwarden` — the binary: clap CLI, rmcp 3.7 MCP server, stdio and streamable-HTTP transports. Depends on `bugwarden-core`. The crate also has a lib target (`lib.rs` re-exporting `config` and `server`) consumed by `main.rs` and by the integration tests under `crates/bugwarden/tests/`, @@ -1333,7 +1333,7 @@ constraints the model must know. | bug_comments | id, include_private: bool = false, new_since?, head?: u32, tail?: u32, max_comment_chars?: u32 | comments | filter_comments applied (I5). Optional windowing: with ANY of head/tail/max_comment_chars set the response is an envelope `{"comments":[..], "truncation":{"omitted_comments":N,"shown_comments":M}}`, else the bare array it always was. Windowing runs LAST, on the post-filter (I5), post-duplicate-marker-scrub (I14) list — `omitted_comments` counts only comments the client may see, so private-comment existence cannot leak by arithmetic. head keeps the first N (comment 0 is the report), tail the last N; overlap keeps everything and omits nothing. A comment capped by max_comment_chars carries a sibling `text_truncated: {"shown_chars","total_chars"}` — never an in-text marker, which free-form comment text would absorb. The name is max_comment_chars, not download_attachment's max_chars, on purpose: same unit, different scope — this caps EACH comment, that caps the WHOLE response — so do not converge the two into one name. summarize_bug is deliberately NOT windowed (its prompt needs every comment). Audit: `note_redacted_client_window("comments_window")` when omission or a cap actually fired — that note PRESERVES the granting rule, unlike the I5 suppression noted beside it (see "The `guard.rule` encoding") | | bugs_quicksearch | query, status: String = "ALL", include_fields: String = "id,product,component,assigned_to,status,resolution,summary,last_change_time", limit: u32 = 50, offset: u32 = 0, changed_since?: DateTime, group_by?: String | post-filter | fetch include_fields = requested ∪ CLASSIFY_FIELDS; optional `changed_since` narrows upstream rows to bugs changed since then (Bugzilla `last_change_time`), so an unchanged watch costs exactly one scan request and classifies zero rows while the guard still classifies every returned row with no counts leaving the server (#315); after filter, project kept bugs to requested fields (keep `_redacted` marker; `comment_count` always dropped, I5); envelope `{"bugs":[..]}` — or `{"groups":[..]}` under `group_by`, see below — only (I3), except an advisory `note` when the query is nothing but bug ids (comma/whitespace-separated, optional `#` per id) steering exact id sets to bug_info — the note is a pure function of the CLIENT'S REQUEST (the query and status strings), never of results, verdicts, or anything upstream said (no new oracle), and the `bugs` array is byte-identical with or without it (the query is still searched, never rerouted); its wording tracks the request: a non-empty status is prefixed to the query so upstream content-matches the whole expression, while an empty status sends the query bare and Bugzilla routes a bare all-number query to an exact id lookup (bug_id + anyexact) — on that path the note drops the content-matching claim — and a query naming more distinct ids than MAX_ASSESS_IDS steers to batched bug_info calls (the cap is already public in the too_many_ids refusal text) instead of straight into that refusal | **limit/offset address the bugs the client may SEE, not upstream rows** (Guard::quicksearch_window): filtering an already-paginated page left a hole exactly where a hidden bug sat — a short page the next offset contradicted — and since quicksearch matches summary text that hole was a probe for the hidden title, one word at a time. The guard now scans upstream from row 0 in 200-row chunks, classifies each, and fills the window from the survivors; rows are deduped on the server-reported id (relevance order is not stable between calls) and an id-less row is dropped (I4). A short page is NOT read as end-of-results — Bugzilla is free to cap a page below the requested chunk size (an admin-configured `max_search_results`, for instance), and that cap looks identical to a short page at the genuine end of a result set; only an empty page ends the scan early. Bounds, independent of each other: MAX_SEARCH_WINDOW=1000 addressable, 2000 rows scanned, and 10 sequential requests; hitting any of the three truncates, which looks exactly like the end of results. `limit` is honoured up to MAX_SEARCH_WINDOW and silently clamped above; prefer one call with a large `limit` and a lean `include_fields` over an offset walk — the walk re-scans from row zero, so one large call costs the server no more than the last page of the walk and the client one turn instead of N+1; a page short of `limit` is not the end of results. The objects returned are the ones classified. The scan target is quantised to whole chunks so the stopping point does not track the client's `limit`; without that, `limit` could be binary-searched against the clock to recover each block's exact hidden count. Residual, accepted: filling a window of VISIBLE bugs needs more rows when bugs are hidden, so a stopwatch still learns one bit per scanned block ("not entirely visible"). Removing that would mean scanning the worst case on every search, or letting pages go short again. Search failure returns a bare "Search failed"; the upstream text is logged server-side only (it can name a bug and say whether it exists). The scan's accounting — rows examined, verdict-dropped ids — goes to the audit record only (`guard.scan` plus the suppressed-ids machinery, issue #29); the response is byte-identical with or without drops. Optional `group_by` (issue #143): a comma-separated subset of GROUP_BY_FIELDS = product, component, status, resolution, severity, priority, deduped, unknown name refused BEFORE the upstream call (a silently ungrouped typo looks like a broken feature; the text quotes only what the client sent, so it is no oracle) while a spec naming NOTHING (`""`, bare commas) means "no grouping" rather than an error — that is what a client filling in every declared param sends, and the sibling `include_fields` reads an empty segment the same way. The response becomes `{"groups":[{"bugs":[..], :, ..}]}` and each grouped field is reported once per group instead of once per bug. Header key order is NOT caller order and is not promised anywhere client-facing: serde_json is built without `preserve_order`, so its Map is a BTreeMap and a header serializes with keys sorted, `bugs` first — `group_by=status,product` and `product,status` return identical bytes. GROUP_BY_FIELDS is a HAND-PICKED subset of the CLASSIFY_FIELDS ∩ SUMMARY_FIELDS intersection (which is all ten SUMMARY_FIELDS), restricted to low-cardinality enum-ish values; that membership is what makes grouping free of policy consequences — no extra upstream field is pulled, and a summary-redacted row carries all six so it buckets like any other row instead of needing one of its own. Membership is NOT the I14 argument: the same intersection holds `summary`, which routinely reads "regression from bug 12345". What keeps a header clean is ORDERING — a grouped field is FORCED into `requested` the way `id` is, so grouping runs on the served, link-scrubbed projection, a value can only reach a header by the same path that would have put it on the bug, and grouping never surfaces a field the projection dropped. Group order and within-group order are first appearance in the served window, so grouping cannot reorder by verdict, and a group exists only where a served bug put it: no empty buckets, no header naming a hidden bug's product (I3). It runs LAST, after the audit block has read the flat projection, so a call's record is identical whether or not the client asked for groups; `group_by` is in PARAM_ALLOWLIST (a vocabulary field, not free text) so the record carries it verbatim. The envelope key is `groups`, deliberately the same word as Bugzilla's per-bug `groups` security-group list which a client may project into the bugs nested inside it — parallel to `bugs` at the same level, and depth disambiguates; do not rename one to "fix" the collision without also moving the other. Predicate gate (#337): after group_by validation and before any request the query and the status prefix are tokenized quote-aware (double quotes always, single quotes only at word boundaries, backslash escapes; unbalanced refuses; split on Perl whitespace including vertical tab and form feed): bare terms, quoted phrases, `#`-id lists with `#` optional per comma piece, `P`-shorthands and exact (never prefix) predicates over product, component, status, resolution, severity, priority, summary (`short_desc` spelling included) pass — a field name in parens groups rather than names, so surrounding parens trim before the allowlist — everything else is refused with a fixed per-class text at zero requests — link (`depends_on`/`blocked`/`dupe`/`see_also`/`cf_*`), content (`comment`/`description`/`longdesc`), attachment, visibility (`group`/`flag`/`requestee`/`setter`), `# : @ [ ! ?` shortcuts (one leading `-` negates one comma piece, judged after the strip), mapping aliases of refused targets, unknown names and unbalanced quotes. A pseudo-quoted predicate is not a phrase. Masking allocation rides the transport body cap, so no separate query length cap. A pure request function, so hidden rows cannot move it; assignee-ish fields, keywords and whiteboard stay out because summary rows do not carry them | | create_bug | product, component, summary, version, description = "", severity?, priority?, op_sys?, platform?, keywords?: Vec, groups?: Vec, custom_fields?: JsonObject | create (write), judged on the prospective bug AS IT WILL BE FILED (Guard::may_create) | there is no bug id to assess, so the request itself is classified BEFORE any upstream call (I8): the rules that hide a product by name refuse filing into it, a field the request omits fails closed (I4), and a client-claimed `groups` list is never trusted — Bugzilla unions the product's mandatory groups in server-side, so may_create forces groups to unknown, which means a group-consulting rule refuses every create request that REACHES it — creation is possible only where an earlier rule covering the create operation grants it (a rule carrying `operations = ["create"]`, placed ahead of the group-consulting rules, is how an operator permits filing without that grant shadowing reads of existing bugs — issue #26), and a policy with no such grant refuses all creation. **Judged is filed.** Bugzilla rewrites a create request before it resolves anything: it trims names (Perl `\s`), turns every run of ASCII control characters in the summary into one space and trims it (`clean_text`), and skips a keyword that is `""` or `"0"` — testing that falsiness on the RAW element, before the trim it applies to the ones it goes on to resolve. Judged raw, `product: " Security Response"` walked past a `products = ["Security*"]` deny rule into the grant behind it and was filed into Security Response all the same. So `may_create` rewrites the payload the same way IN PLACE, classifies the rewrite, and create_bug POSTs that very object — the rewritten names are ones Bugzilla's own trim leaves alone, so the name Bugzilla looks up is the name that was judged; which object that lookup returns, and whether the priority survives, are the two limits below. The NAME trim is deliberately a SUPERSET of Bugzilla's: Unicode White_Space plus U+180E, which Perl counted as `\s` until 5.20 and Rust's `is_whitespace` does not — that arm is load-bearing, not decoration. A superset is safe only because the POST carries the trimmed value and a name resolves to its stored spelling anyway; trimming less is the bug. The SUMMARY is stored as sent, so it is trimmed with White_Space alone and nothing Bugzilla keeps is dropped: an edge U+180E stays in what is judged and POSTed, a Perl before 5.20 strips it itself and a later one keeps it, so on every Perl the stored summary is the one the raw request would have produced. The judged summary therefore contains the stored one, and `summary_contains` is a positive substring test, so every needle the filed summary carries the judged one carries too. WHICH fields are rewritten is a rule rather than a list: every field create_bug sends that a Matcher reads (product, component, summary, severity, priority, keywords). Bugzilla also trims version, op_sys and platform, which no criterion reads — rewriting them would decide nothing, and a new criterion over such a field must extend the rewrite with it or reopen this bug. Evidence also needs the SHAPE create_bug sends: BugMeta reads component and keywords leniently (a lone string, an array, `{"name": ..}` objects) where Bugzilla splits a keyword string, resolves such objects itself, and refuses a list where it wants a name — so a non-string component is unknown, a keyword element the trim would turn false stays exactly as it came for Bugzilla to resolve or refuse, and a list holding one is unknown as a whole (a partly rewritten list reads as a shorter one). A list Bugzilla would skip entirely is dropped, as create_bug drops an empty one. A rewritten field that came out empty is still POSTed, so the upstream refusal answers it — `require_summary`, `require_component`, and `object_not_specified` for product, severity and (with `letsubmitterchoosepriority` on; off, the second limit below applies) priority — behind the same refusal text at the same cost. The audit record keeps the client's RAW arguments while the POST carries the rewrite: audited is what was asked, filed is what was judged. Two known limits remain, both about what the filed bug ends up carrying. First, the lookup compares under the DATABASE's collation, which can treat two spellings as equal — or a character as weightless — where a glob cannot: measured on MariaDB 10.6, the stored `Security Response` is matched by `"Śecurity Response"` under `utf8mb3_general_ci`, and under the UCA `utf8mb4_unicode_ci` by its decomposed spelling and by a leading zero-width space, BOM, word joiner or RLO; this gate judges each of those as a different product and POSTs it verbatim. ASCII-only product names are therefore no protection either, and a name glob is not a boundary on such an instance. Components, keywords, severities and priorities are looked up by name through the same comparison (`Bugzilla::Object` via `sql_istrcmp`; a component by `name = ?`, which MySQL also compares under the collation), so by reading the code, not by measurement, the limit covers their criteria too. What narrows it is an allowlist-shaped create grant ahead of a rule refusing every other create request, as the shipped example does: a spelling the allowlist does not match is refused, which leaves only products whose names the database equates with a name the allowlist does match. A create-scoped grant on exact product names ahead of a default deny narrows it furthest: an equivalent spelling can then only be refused, never admitted — a denylist create rule, by contrast, stays bypassable by such a spelling. Issue #330 carried the measurements and the options; the steering rule above is its resolution: stripping or refusing such characters was rejected because it would change what the reporter filed, so nothing here closes the limit (PostgreSQL's `LOWER()` against Rust's `to_lowercase` is the same class, by reasoning, not measurement). Second, with `letsubmitterchoosepriority` off Bugzilla replaces the requested priority with `defaultpriority`, so a `priorities` criterion judges a priority the filed bug may not carry. **Every refusal is one refusal**: a policy refusal, a refused Bug ID custom field target, an unassessable custom field value and an upstream failure all return the same fixed create_denial text after the same upstream request count — the refused path burns one classify call against bug id 0 (never a valid id, creates nothing; download_attachment's padding precedent) instead of the POST. That count is a function of the request and the field kinds as learned (a failed lookup reads every field as Unknown, so a number anywhere becomes a target): `N = [one type lookup, when custom_fields is non-empty — at most 50 keys, the cap refusing more at zero requests] + [one whoami, when there are Bug ID targets and the policy consults identity through whoami] + [one classify per distinct target] + 1 (the POST, or the pad)`. No term depends on `may_create`'s verdict, on a target's verdict or existence, or on the POST's outcome — the targets are assessed even when the gate already refused, and a hidden target answers with create_denial, never with that bug's own uniform denial. Two texts, or differing counts, would be a free policy-enumeration oracle: send a guaranteed-invalid `version` plus a probe product and read the policy off which refusal (or which latency) comes back, with nothing created. Residual, accepted: a SUCCESSFUL create still confirms the product is allowed — that is the tool doing its job, and it costs a real, attributable bug; and the padding equalizes request count, not the upstream handler's exact latency (GET classify vs rejected POST). Bugzilla's failure message is logged server-side only (it can say whether a product/component exists). `custom_fields` keys must start with `cf_` (I7): the gate runs before `may_create` and errors with ZERO upstream requests on a non-`cf_` key — distinguishable from the padded create refusal on purpose, since it decides nothing about policy or Bugzilla; the 25-id cap over the custom field values that could name a bug and the 50-key cap on `custom_fields` refuse at zero requests the same way (I7). The caller is resolved for the targets only, never for the gate (see "Identity resolution"). No Matcher criterion reads `cf_*`, so a custom field cannot move a prospective bug between rules the way `product`/`component` do, and `may_create`'s rewrite never touches a `cf_*` value. `custom_fields` is not in `PARAM_ALLOWLIST`, so the audit stream records it as `_len`, same as the updater | -| add_attachment | bug_id, data (base64), file_name, summary, content_type, comment = "", is_private = false, is_patch = false | attach (write) on bug_id | guard assessment before the upload (I8), uniform denial (I2); then global.max_attachment_bytes caps the DECODED size of `data` (0 = no cap) — the ceiling the operator set on downloads binds uploads through the same server too, measured after base64 expansion is stripped so encoding overhead cannot shrink it. The refusal names neither the payload's size nor the cap value (max_attachment_bytes is not I1-disclosable, exactly as on the download path). Over http that non-disclosure is partial and knowingly so: the transport's POST body cap is derived from this same value (#52), so its 413 boundary is probeable once the cap exceeds ~2.25 MiB decoded — accepted, with the reasoning, under "rmcp 3.5 usage notes" below. Nothing here changes: this refusal still names neither size nor cap. `comment` travels as a PLAIN string — Bug.add_attachment documents it so; the `{"comment": {"body": ..}}` shape belongs to Bug.update only | +| add_attachment | bug_id, data (base64), file_name, summary, content_type, comment = "", is_private = false, is_patch = false | attach (write) on bug_id | guard assessment before the upload (I8), uniform denial (I2); then global.max_attachment_bytes caps the DECODED size of `data` (0 = no cap) — the ceiling the operator set on downloads binds uploads through the same server too, measured after base64 expansion is stripped so encoding overhead cannot shrink it. The refusal names neither the payload's size nor the cap value (max_attachment_bytes is not I1-disclosable, exactly as on the download path). Over http that non-disclosure is partial and knowingly so: the transport's POST body cap is derived from this same value (#52), so its 413 boundary is probeable once the cap exceeds ~2.25 MiB decoded — accepted, with the reasoning, under "rmcp 3.7 usage notes" below. Nothing here changes: this refusal still names neither size nor cap. `comment` travels as a PLAIN string — Bug.add_attachment documents it so; the `{"comment": {"body": ..}}` shape belongs to Bug.update only | | add_comment | bug_id, comment, is_private: bool = false | comment (write) | an upstream refusal is the fixed line plus at most one code-selected hint — see "Upstream failures of the write tools". Success serves the envelope with id-bearing `changes` scrubbed to the assessed bug (I14); the comment id itself names no bug | | update_bug_status | bug_id, status, resolution?, comment: String = "" | status (write) | payload always carries `status`; `resolution` only when the caller gives a non-empty one — no local workflow assumption, no synthesised empty resolution. Bugzilla enforces `missing_resolution` on a closing status with none, and auto-clears any resolution when the target status is open; an upstream refusal is the fixed line plus at most one code-selected hint, and a request carrying `resolution` gets none but 109 — see "Upstream failures of the write tools". Success serves Bugzilla's update envelope with id-bearing `changes` scrubbed to the assessed bug (I14) | | assign_bug | bug_id, assignee (email), comment = "" | assign (write) | payload `{"assigned_to": ..}`; an upstream refusal is the fixed line plus at most one code-selected hint — see "Upstream failures of the write tools". Success serves Bugzilla's update envelope with id-bearing `changes` scrubbed to the assessed bug (I14) | @@ -2798,10 +2798,10 @@ names an endpoint. of the kind the invariant already names; the endpoint is the weaker bar stated under "What secrecy this actually buys". -## rmcp 3.5 usage notes +## rmcp 3.7 usage notes Reference source is the rmcp this workspace pins, unpacked in the local -registry: `~/.cargo/registry/src/*/rmcp-3.5.1/` — today's version, and the +registry: `~/.cargo/registry/src/*/rmcp-3.7.0/` — today's version, and the directory holding the `rmcp` package's `manifest_path` in `cargo metadata --format-version 1` on any day, so it follows `Cargo.lock` rather than a copy of it and is by construction the source this build compiles against. Every rmcp @@ -2811,11 +2811,17 @@ routing traps), `handler/server/router/tool.rs` (`ToolRouter`, incl. `remove_route` / `has_route` — I13), `model.rs` and `model/serde_impl.rs` (`InitializeResult`, the `_meta` strip), `service.rs` (the serve loop); the `#[tool_router]` / `#[tool_handler]` expansions are in the sibling -`rmcp-macros-3.5.1` tree. The published crate carries no `examples/` — those -live upstream at the `rmcp-v3.5.1` tag — but for how this server is actually +`rmcp-macros-3.7.0` tree. The published crate carries no `examples/` — those +live upstream at the `rmcp-v3.7.0` tag — but for how this server is actually wired, `server.rs` and `main.rs` are the reference. -- `rmcp = { version = "3.5", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }` +- `rmcp = { version = "3.6", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }` + — `3.6` because `DiscoverAnswering::framed` calls + `AsyncRwTransport::with_max_line_length`, which 3.6 (#1049) introduced, + and nothing 3.7 added over 3.6 is compiled into this crate: that release + touched only the OAuth client (`auth`, GHSA-5p52-q7pq-9fq2) and the + reqwest client's 401/403 handling (#1341), neither of which this crate's + features build. - Protocol revisions: `SUPPORTED_PROTOCOL_VERSIONS` (server.rs) lists what this build serves and `supported_protocol_versions()` returns it, narrowing the SDK default of every revision it knows. What that override bounds is @@ -2829,10 +2835,11 @@ wired, `server.rs` and `main.rs` are the reference. reads, so a record on that path names the caller or names nobody. Adoption ADDED it — `2024-11-05` through `2025-11-25` keep their handshakes and their sessions — so the two lifecycles are served side by side and - `DEFAULT_PROTOCOL_VERSION` stays `2025-11-25`, the revision rmcp 3.5 names - `LATEST_WITH_INITIALIZE`. What rmcp's own negotiation substitutes for a - fallback that has no handshake is the newest handshake-era entry of - `supported_protocol_versions()` — this build's list, equal to + `DEFAULT_PROTOCOL_VERSION` stays `2025-11-25`, the revision rmcp names + `LATEST_WITH_INITIALIZE` (since 3.5). What rmcp's own negotiation + substitutes for a fallback that has no handshake is the newest + handshake-era entry of `supported_protocol_versions()` — this build's + list, equal to `KNOWN_VERSIONS` today, with nothing enforcing that. `advertised_protocol_versions_are_the_ones_this_build_serves` pins the constant to the newest handshake-era revision in `KNOWN_VERSIONS`: a @@ -2854,7 +2861,7 @@ wired, `server.rs` and `main.rs` are the reference. SDK's `negotiate_protocol_version` (`service/server.rs`), which rmcp 3.3 wraps as `ServerHandler::negotiate_initialize`. The override does not call it and agrees with it only while `DEFAULT_PROTOCOL_VERSION` is - itself handshake-era, so re-read both on an rmcp bump (on 3.5 the + itself handshake-era, so re-read both on an rmcp bump (on 3.7 the SDK's predicate is `ProtocolVersion::has_initialize`, lexically `< 2026-07-28` — the comparison the override spells out — and a fallback without a handshake is replaced by the newest handshake-era @@ -2884,7 +2891,7 @@ wired, `server.rs` and `main.rs` are the reference. which moves with the SDK — and did, in rmcp 3.5 (#1105), to `2026-07-28`, a revision with no handshake. The first handshake's wire answer would survive inheriting it, because the SDK re-runs `negotiate_protocol_version` - over the handler's result (`service/server.rs:658`; over http too, through + over the handler's result (`service/server.rs:692`; over http too, through `serve_server`). What the pin alone keeps handshake-era is this handler's OWN answer — the revision the `initialize` record names, and the reply to a second mid-session `initialize`, which nothing corrects. From that @@ -2921,7 +2928,7 @@ wired, `server.rs` and `main.rs` are the reference. must likewise be built in this crate rather than in the library that holds the HTTP client (issue #55, "Caller identity on the wire" above). - **rmcp trap — the handshake-free lifecycle is chosen by `_meta` shape, not - by `SUPPORTED_PROTOCOL_VERSIONS`.** In rmcp 3.5.1 `is_legacy_request` + by `SUPPORTED_PROTOCOL_VERSIONS`.** In rmcp 3.7.0 `is_legacy_request` (`transport/streamable_http_server/tower.rs`) returns true — the session path — for **every** `initialize`, whatever `protocolVersion` it names and whatever `_meta` it carries (rmcp #1228: an initialize request @@ -2936,7 +2943,7 @@ wired, `server.rs` and `main.rs` are the reference. (`missing_required_keys(&V_2026_07_28).is_empty()`). That helper is called with `V_2026_07_28` as the *schema* to check, not the request's declared revision, so **a pre-2026 non-`initialize` with both keys still - hits the stateless path** (#204; re-verified on 3.5.1). Version + hits the stateless path** (#204; re-verified on 3.7.0). Version otherwise comes from the `_meta` key, falling back to the `MCP-Protocol-Version` header and then `2025-03-26` — no longer from `initialize`'s `params.protocolVersion`, which the early return already @@ -2967,8 +2974,8 @@ wired, `server.rs` and `main.rs` are the reference. that did complete a handshake too. **rmcp trap within the trap — `RequestContext::client_info()` is not a safe accessor.** It falls back to `peer_info().client_info` unless - `peer.request_metadata_required()`, which rmcp 3.5.1 sets on exactly one path - (`service/server.rs:668`, the stdio first-valid-non-`discover` case) and + `peer.request_metadata_required()`, which rmcp 3.7.0 sets on exactly one path + (`service/server.rs:653`, the stdio first-valid-non-`discover` case) and never over streamable http. Since #267 that stdio path no longer sees a `server/discover`: the transport answers it (`stdio::DiscoverAnswering`, next bullet) — and since #1248 rmcp itself answers a pre-init `discover` @@ -3010,14 +3017,14 @@ wired, `server.rs` and `main.rs` are the reference. served per-request POST, which is why `client_of` never uses the convenience accessor. - **rmcp trap — over stdio, a non-`discover` first request CHOOSES the lifecycle - before it is answered.** `serve_server_with_ct_inner` (rmcp 3.5.1 - `service/server.rs:559-688`) reads the session's lifecycle off the first + before it is answered.** `serve_server_with_ct_inner` (rmcp 3.7.0 + `service/server.rs:544-672`) reads the session's lifecycle off the first non-`ping`, non-`discover` frame with complete `_meta` at a served revision: that request is taken as a commitment to the handshake-free one, and - `Peer::require_request_metadata()` (`:668`) is called BEFORE the handler + `Peer::require_request_metadata()` (`:653`) is called BEFORE the handler answers. That flag is a sticky `AtomicBool` — no later `initialize` clears it (a later `initialize` succeeds and stores peer - info, `handler/server.rs:323`, leaving it set), and both it and its reader are + info, `handler/server.rs:322`, leaving it set), and both it and its reader are `pub(crate)`, so nothing in this build can reset it. `handler/server.rs:78-100` then refuses every subsequent request but `initialize` that carries no `_meta` with -32602. A probe used to be exactly @@ -3029,8 +3036,18 @@ wired, `server.rs` and `main.rs` are the reference. `ExpectedInitializeRequest` after answering, logging the whole frame on the way out (#261). Since #1248 rmcp answers a pre-init `discover` on a bootstrap peer without flipping the flag, and since #1322 it answers a `_meta`-free or - unserved request in-band and keeps waiting — so bare 3.5.1 no longer commits - on a probe nor ends the process for a refused request. What #1263 + unserved request in-band and keeps waiting — so bare 3.7.0 no longer commits + on a probe nor ends the process for a refused request. Since 3.6 the same + is true of a frame that is not any one JSON-RPC shape (#1283): `method` + beside `result` or `error`, those two together, or `method` with an `id` + of `null`, is refused -32600 in-band with no id, where 3.5 read it as the + nearest shape — a request with a float or null id was a NOTIFICATION, + which before `initialize` ended the process (#261's path) and in-session + was dropped — and an integral float id (`1.0`) is read as that integer + (#1346). Pinned over the real pipes by + `stdio_a_pre_initialize_float_id_request_is_refused_in_band_and_the_session_survives` + and `stdio_an_in_session_frame_with_method_and_result_is_refused_not_served`, + both of which fail on a 3.5 lock. What #1263 changed is where that committing request runs: rmcp 3.2 answered it ahead of the serve loop, before reading another frame, and rmcp 3.4 dispatches it inside the loop like any later request. So it now honours @@ -3040,7 +3057,11 @@ wired, `server.rs` and `main.rs` are the reference. abandoned when rmcp's 5 s drain runs out after stdin closes or an over-cap frame closes the transport — its upstream call can land with no reply and no record, the gap every later stdio call already had - (#319). + (#319). Since rmcp 3.6 (#1333) the drain's end also cancels every + outstanding request's `RequestContext.ct`; the handler future itself is + not dropped, this build's handlers read no `ct`, and the record is + written after the dispatch returns, so that cancellation changes nothing + here. **Decision:** a probe is a probe and must not pick a lifecycle, so over stdio it is answered by the transport ([`DiscoverAnswering`](../crates/bugwarden/src/stdio.rs), wrapped in `main`) @@ -3053,7 +3074,7 @@ wired, `server.rs` and `main.rs` are the reference. refused -32601 by the per-request handler (`handler/server.rs:112-117`), where a probe used to leave it. The answer is QUEUED on the wrapper and driven by the next `receive`, never awaited inside one: rmcp polls `receive` as one arm - of a `select!` (`service.rs:1412`) and drops that future whenever another arm + of a `select!` (`service.rs:1398`) and drops that future whenever another arm wins, so an inline await loses the reply — and the probe with it — to any pipelined client (`a_pipelined_probe_is_answered_under_write_contention` and its binary twin; the inner transport survives the same cancellation only @@ -3064,7 +3085,7 @@ wired, `server.rs` and `main.rs` are the reference. changes its default `discover`, or anything it does between handler and wire (`handler/server.rs:246-259`), fails there. `resultType` is NOT stripped for a legacy declaration because `strip_result_type_for_legacy_peer` - (`model.rs:4789`) has no `DiscoverResult` arm; no audit record, no guard, no + (`model.rs:4951`) has no `DiscoverResult` arm; no audit record, no guard, no router, as before; and rmcp keeps sole ownership of the per-request gate, so a `_meta`-free request on a handshake-free session is still refused. One refusal CODE moves: a MID-SESSION probe whose `_meta` is wrong two ways (an unserved @@ -3089,9 +3110,22 @@ wired, `server.rs` and `main.rs` are the reference. `an_http_probe_never_commits_the_session_lifecycle` pins so an rmcp bump that changed it fails loudly. - **rmcp trap — `mcp-session-id` is a validated header on only one of the two - routes.** In rmcp 3.5.1 the session branch of `handle_post` + routes.** In rmcp 3.7.0 the session branch of `handle_post` (`transport/streamable_http_server/tower.rs`) checks the header against - `has_session` before it routes on it, and 404s an id it does not know; the + `has_session` before it routes on it, 404s an id it does not know, and + since rmcp 3.6 (#1377) answers a session-less POST that is not + `initialize` with 400 `Session ID is required` where 3.5 answered 422 + `Unexpected message` — before any handler, so unrecorded either way, as + is the stateless branch's new 400/-32600 for a posted JSON-RPC response + or error, which 3.5 accepted with a 202 (both pinned by + `a_session_less_non_initialize_post_is_refused_400_before_any_handler` + and `a_posted_json_rpc_response_on_the_stateless_path_is_refused_400`, + which fail on a 3.5 lock). The #1283/#1346 shapes reach neither branch + over http: `expect_json` (`server_side_http.rs`) refuses them 415 `fail + to deserialize request body unrecognized JSON-RPC message` — the text + any non-JSON-RPC JSON object now draws (3.5's text named serde's untagged + enum, and 3.5 parsed these shapes, so they reached the branch) — never + -32600. The stateless branch has no such check and `serve_negotiated_request_directly` injects the request Parts verbatim, so on that route the header is client free text. On `initialize` there is no header at all — rmcp mints the id @@ -3123,7 +3157,7 @@ wired, `server.rs` and `main.rs` are the reference. `COMPLETE`, and the SEP-2549 cache hints are gated on the request's revision. **rmcp trap — the version-shaped edits the SDK makes to a served result are stripping `resultType` and filling missing cache hints.** - In rmcp 3.5.1 `ServerResult::strip_result_type_for_legacy_peer` (`model.rs`) + In rmcp 3.7.0 `ServerResult::strip_result_type_for_legacy_peer` (`model.rs`) edits that one field for legacy peers, and `fill_missing_cache_hints` (`model.rs`, since #1308) fills absent `ttl_ms`/`cache_scope` with `0`/ `Private` for 2026-07-28+ peers; the same `handle_request` version-gates other @@ -3135,7 +3169,7 @@ wired, `server.rs` and `main.rs` are the reference. ours: `RequestContext::protocol_version()` — the request's own `_meta` revision, else the session's negotiated one, `None` when neither — at least `2026-07-28`, and `None` fails toward the legacy wire shape rather than - toward emission. That expression is a hand-copy of the one rmcp 3.5.1 + toward emission. That expression is a hand-copy of the one rmcp 3.7.0 computes `is_2026_07_28_or_later` from (`handler/server.rs`): the two agree today by inspection, share no constant, and no test pins the agreement. Re-read both on every rmcp bump — if upstream's predicate moves, this build @@ -3148,7 +3182,7 @@ wired, `server.rs` and `main.rs` are the reference. nothing worth caching. The exact claim, which is about listings and not about peers: **no `tools/list` response this handler constructs carries the SEP-2549 pair unless the request's revision is at least 2026-07-28.** Not - about peers because in rmcp 3.5.1 `DiscoverResult` (`model.rs`) declares + about peers because in rmcp 3.7.0 `DiscoverResult` (`model.rs`) declares `ttl_ms: u64` and `cache_scope: CacheScope` as non-`Option` fields with no `skip_serializing_if`, hard-coded to `0`/`Private` by both constructors, and this build serves rmcp's default `server/discover` result on both @@ -3166,12 +3200,12 @@ wired, `server.rs` and `main.rs` are the reference. them. - **rmcp trap — a panicking handler answers nothing, and rmcp catches nothing.** The serve loop dispatches each request through - `spawn_service_task` (`service.rs:1603`, a bare `tokio::spawn` at - `:1327`) and DROPS the returned `JoinHandle`; the reply is sent from - inside that task, at its end (`:1617`). Since rmcp 3.4 (#1263) that + `spawn_service_task` (`service.rs:1589`, a bare `tokio::spawn` at + `:1313`) and DROPS the returned `JoinHandle`; the reply is sent from + inside that task, at its end (`:1603`). Since rmcp 3.4 (#1263) that includes the first request of a stdio session opened without `initialize` (see the stdio trap above). There is no `catch_unwind` - anywhere in rmcp 3.5. So a tool handler that panics unwinds its own task + anywhere in rmcp 3.7. So a tool handler that panics unwinds its own task and nothing else: no error goes out, no transport closes, the session and every other request stay healthy — and the request that panicked is answered by nobody. An rmcp client then waits forever @@ -3292,8 +3326,8 @@ wired, `server.rs` and `main.rs` are the reference. time; it does not stop the payload's other way out. After the stdio handshake rmcp runs the session loop — its own code, and with it `DiscoverAnswering` and `BoundedLines::poll_read` — in a spawned task, - and a panic there comes back from `RunningService::waiting` (rmcp 3.5.1 - `service.rs:1099`) as tokio's `JoinError`, which quotes a string payload + and a panic there comes back from `RunningService::waiting` (rmcp 3.7.0 + `service.rs:1085`) as tokio's `JoinError`, which quotes a string payload in its Display (`task N panicked with message "…"`) and in its Debug (`JoinError::Panic(Id(N), "…", …)`) alike. `?` on it put that text on the `Error:` line Rust writes for `main`'s returned error — fd 2, which @@ -3415,26 +3449,34 @@ wired, `server.rs` and `main.rs` are the reference. behind a new knob cannot be skipped; `the_x_mcp_header_tripwire_fires_at_every_planted_position` is its canary, because a walk over zero annotations passes even when gutted. The check is - deliberately broader than rmcp's functional read, which in 3.5.1 is - top-level `properties`, string-valued only (`param_header_annotations`; - `validate_param_header_annotations` and `reject_nested_annotations` are the - client-side validation, and reject the key at ANY depth — but only down - chained `properties`, never through `items`, `$defs` or the other - applicators). Those depth rules are version-specific, so the key is banned - at any depth and any value type. There is no upstream constant for the + deliberately broader than rmcp's functional read, which in 3.7.0 is + every `properties` chain from the schema root, nested included since 3.6 + (#1359), string-valued and non-empty only (`collect_property_annotations` + behind `param_header_annotations`; `validate_param_header_annotations` is + the client-side validation, and since #1359 rejects the key on the root + and anywhere OFF such a chain — under `items`, `$defs`, `definitions`, + `patternProperties`, `dependentSchemas`, a composition or a conditional + keyword — where 3.5 refused only nested `properties`). Those depth rules + are version-specific and have moved once already, so the key is banned at + any depth and any value type. There is no upstream constant for the spelling — the test declares its own — so re-grep the vendored tree for `x-mcp-header` on every rmcp bump. **The #116 record, closed here rather than fixed.** `ServerHandler::get_tool` - is still `fn get_tool(&self, name: &str) -> Option` in 3.5.1: no - `RequestContext`, so the schema cache `tower.rs` builds for `Mcp-Param-*` + is still `fn get_tool(&self, name: &str) -> Option` in 3.7.0: no + `RequestContext`, so the schema `tower.rs` reads for `Mcp-Param-*` validation answers per DEPLOYMENT — the I13-pruned instance router, pinned by `get_tool_serves_the_pruned_instance_router_i13` — and never per - credential. The bearer gate fronts the whole router, so no `Mcp-Param-*` - validation runs for an unauthenticated request. What remains is a - validate-vs-skip oracle: a caller sending `Mcp-Param-*` headers could in - principle tell a name whose schema the cache found from one it did not, by - the validation those headers do or do not trigger. It cannot while zero + credential. Since rmcp 3.6 (#1349) there is no schema cache: the stateless + path reads `get_tool` off the service the factory builds for that very + request, the session path builds one per lookup (`tool_schema`), and so + the factory — `server.clone()`, side-effect free, no record — now runs + BEFORE the SEP-2243 check on the stateless path. The bearer gate fronts the + whole router, so no `Mcp-Param-*` validation runs for an unauthenticated + request. What remains is a validate-vs-skip oracle: a caller sending + `Mcp-Param-*` headers could in principle tell a name whose schema + `get_tool` found from one it did not, by the validation those headers do + or do not trigger. It cannot while zero annotations are served — the validation loop iterates the annotated properties, of which there are none, so the headers are inert either way — which is what the tripwire above keeps true, and what @@ -3465,7 +3507,7 @@ wired, `server.rs` and `main.rs` are the reference. follow-up. Counting them in prose is what let two fields go unlisted here before. - | field | rmcp 3.5 default | this build | + | field | rmcp 3.7 default | this build | |---|---|---| | `allowed_hosts` | `localhost`, `127.0.0.1`, `::1` | **set** — `disable_allowed_hosts()`, or the operator's `--allowed-hosts` / `MCP_ALLOWED_HOSTS` list when given | | `max_request_body_bytes` | 4 MiB | **set** — derived from `global.max_attachment_bytes`, floored at that same 4 MiB (see below) | @@ -3525,9 +3567,23 @@ wired, `server.rs` and `main.rs` are the reference. counting the bytes since the last `\n` and failing the read past the cap (`<= cap` accepted, mirroring http's `<= max_bytes`; overshoot bounded by one 8 KiB `BufReader` chunk, since the count can only be taken on bytes - already delivered). Without it rmcp's `read_until(b'\n', &mut line_buf)` - grows an unbounded `Vec` for a peer that never sends a delimiter: the http - half was bounded and the stdio half was not. The stdio peer is the process + already delivered). Without it rmcp 3.5's `read_until(b'\n', &mut + line_buf)` grew an unbounded `Vec` for a peer that never sent a delimiter: + the http half was bounded and the stdio half was not. Since rmcp 3.6 + (#1049) `AsyncRwTransport` bounds a line itself, at a fixed + `DEFAULT_MAX_LINE_LENGTH` of 16 MiB — and DISCARDS a longer one, logging + ERROR and reading on, which is the skip-and-resume rejected two bullets + down, at a number this build's derived cap may exceed (its ceiling is + 64 MiB). `DiscoverAnswering::framed` therefore builds the transport with + `with_max_line_length(usize::MAX)`, rmcp's documented off switch, so + `BoundedLines` stays the one bound and a frame the policy admits is served + above 16 MiB as below it + (`a_frame_of_exactly_this_builds_cap_is_served_past_rmcps_line_bound` on + the constructor, and + `stdio_a_frame_over_rmcps_line_bound_is_served_when_the_policy_admits_it` + on the shipped binary, the one test `main`'s wiring can fail; the 3.7.0 + adoption dump served a 17 MiB call under a 16 MiB attachment cap that + the bare transport dropped unanswered). The stdio peer is the process that spawned bugwarden and already owns its stdin, so this is a self-inflicted OOM rather than a remote one — closed for symmetry, because "the transport bounds what we buffer" should be true of both transports, @@ -3586,9 +3642,10 @@ wired, `server.rs` and `main.rs` are the reference. `=warn` enables WARN *and* ERROR and leaves the line exactly where it was — measured both ways. Everything else that target logs is `debug!` or `trace!`, so under the default filter it shows nothing but ERROR, and of its - two ERROR sites only the read-error one is reachable through this transport: - the other sits behind a `Serde` arm that already catches every error - `try_parse_with_compatibility` can return. `off` therefore removes the echo + three ERROR sites only the read-error one is reachable through this + transport: one sits behind a `Serde` arm that already catches every error + `try_parse_with_compatibility` can return, and the over-long-line one + (#1049) needs the bound `framed` switches off. `off` therefore removes the echo and nothing else. What it does cost is stated here rather than paid by default: that ERROR is EVERY read error rmcp sees, not only a refused frame. A genuine stdin I/O failure after the handshake is the same line, and there @@ -3659,7 +3716,7 @@ wired, `server.rs` and `main.rs` are the reference. comma-only now. An entry that is not a hostname or `host:port` — `*`, a scheme-carrying URL, `a;b`, a percent-encoded comma, zero-width unicode, a space-containing typo — is a startup error (`Cli::checked_allowed_hosts`); - rmcp 3.5.1's `parse_allowed_authority` would otherwise keep validation on + rmcp 3.7.0's `parse_allowed_authority` would otherwise keep validation on and store (or skip) a host no inbound `Host` matches, a silent deny-all discovered one 403 at a time. HTTP start logs one info line stating whether Host validation is on or off and, when on, the resolved list @@ -3742,7 +3799,7 @@ wired, `server.rs` and `main.rs` are the reference. create_bug, add_attachment. - API key resolution: a match on `key_custody` (resolved once at startup, see Key custody — never re-read per request): `Server(key)` => the server's key, without touching the request at all; `PerRequest` => `ctx.extensions.get::()`, then `parts.headers.get(lowercased_header_name)`. - HTTP serving: `let config = server.http_server_config()?.with_cancellation_token(ct.child_token());` — built while `server` can still be borrowed, since the body cap comes from its own guard policy; errors on an unparsable `--allowed-hosts` entry and logs the effective Host-validation state — then `StreamableHttpService::new(move || Ok(server.clone()), AuditedSessionManager::default().into(), config)` — the wrapper since #180, not a bare `LocalSessionManager`, because the audit session id comes from its stamp and nowhere else — never a bare `StreamableHttpServerConfig::default()`, see the field table above — then `axum::Router::new().nest_service("/mcp", service)`, `tokio::net::TcpListener::bind`, graceful shutdown on SIGINT or SIGTERM cancelling `ct` (`shutdown_signal` in main.rs; issue #114). -- Stdio serving: `let (stdin, stdout) = server.stdio_transport();` then `server.serve((stdin, stdout))`, never a bare `stdio()` — the same rule, and for the same reason, as never a bare `StreamableHttpServerConfig::default()`: rmcp's default reads a frame into an unbounded `Vec`, and the pair is built while `server` can still be borrowed because the frame cap comes from its own guard policy (#234, "rmcp 3.5 usage notes"). Stdio uses the same waiter across `serve` (the handshake wait an unused stdio container sits in) and `waiting`; a signal at either stage flushes the OTLP queue (when export is on) then `process::exit(0)`s, because rmcp's stdio transport reads stdin via `spawn_blocking` and that read does not unblock while the client holds the pipe — returning from `main` drops the runtime onto that thread. An over-cap frame is NOT that case and needs none of it: the refusal happens in a `poll_read` that just took a delivered chunk, so the blocking reader is idle with no `read(2)` outstanding and `main` returns normally, exit `1`, with the OTLP flush on the ordinary path. +- Stdio serving: `let (stdin, stdout) = server.stdio_transport();` then `server.serve(DiscoverAnswering::framed(stdin, stdout, server.clone()))` — never a bare `stdio()`, and never the tuple form `server.serve((stdin, stdout))`: since rmcp 3.6 that tuple goes through `AsyncRwTransport::new`, whose fixed 16 MiB line bound DISCARDS a longer frame and reads on (3.5 read it into an unbounded `Vec`), so the policy's cap would stop working above 16 MiB; `framed` switches that bound off. The same rule, and for the same reason, as never a bare `StreamableHttpServerConfig::default()`, and the pair is built while `server` can still be borrowed because the frame cap comes from its own guard policy (#234, "rmcp 3.7 usage notes"). Stdio uses the same waiter across `serve` (the handshake wait an unused stdio container sits in) and `waiting`; a signal at either stage flushes the OTLP queue (when export is on) then `process::exit(0)`s, because rmcp's stdio transport reads stdin via `spawn_blocking` and that read does not unblock while the client holds the pipe — returning from `main` drops the runtime onto that thread. An over-cap frame is NOT that case and needs none of it: the refusal happens in a `poll_read` that just took a delivered chunk, so the blocking reader is idle with no `read(2)` outstanding and `main` returns normally, exit `1`, with the OTLP flush on the ordinary path. - Request `_meta` (SEP-414, e.g. `traceparent`): over every serialized transport the wire `params._meta` does NOT arrive in the params struct (`CallToolRequestParams.meta` stays `None`) — the SDK's custom @@ -4787,7 +4844,7 @@ wired, `server.rs` and `main.rs` are the reference. was POSTed, and a read tool on the same session still serves; an oversized POST from a stranger is answered `401` by the gate and `413` only for a credentialed caller, which is what the 413-observability argument under - "rmcp 3.5 usage notes" rests on; a scope-refused call leaves exactly ONE + "rmcp 3.7 usage notes" rests on; a scope-refused call leaves exactly ONE audit record, with no guard verdict, no upstream leg and neither `principal` nor `work_context`; scopes