diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40fc39f..2a80ba2 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.7.0". The + # ones on a line mentioning rmcp: one stamp says only "in 3.8.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.7 usage notes", "rmcp - # 3.7 default") stay unchecked -- the file also names rmcp 2.2 and + # DESIGN.md's other two-part mentions ("rmcp 3.8 usage notes", "rmcp + # 3.8 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 e21aa60..901b3b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1578,9 +1578,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.7.0" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e" +checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f" dependencies = [ "async-trait", "base64 0.23.1", @@ -1612,9 +1612,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.7.0" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" +checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839" dependencies = [ "darling", "proc-macro2", @@ -1646,9 +1646,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", diff --git a/crates/bugwarden/src/config.rs b/crates/bugwarden/src/config.rs index 8f4b8ef..9796e3f 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.7.0's `parse_allowed_authority` would + /// loopback spelling. rmcp 3.8.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.7.0 would compare to a +/// True when `entry` is a Host authority rmcp 3.8.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.7.0 would store (or skip) without + // Each of these is what rmcp 3.8.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 82e3ce5..afd4bf9 100644 --- a/crates/bugwarden/src/main.rs +++ b/crates/bugwarden/src/main.rs @@ -474,7 +474,7 @@ 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.7.0's `ServerInitializeError` +/// rmcp 3.8.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 @@ -522,7 +522,7 @@ 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.7.0: the one construction site + // Unreachable on rmcp 3.8.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 diff --git a/crates/bugwarden/src/server.rs b/crates/bugwarden/src/server.rs index 7ae4dc3..3b62087 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.7.0 sets only on the stdio handshake-free path +/// rmcp 3.8.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.7.0 routes on `_meta` shape, not on the negotiated revision and +/// rmcp 3.8.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.7 `handler/server/router/tool.rs`), so a scope-hidden tool is +/// (rmcp 3.8 `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.7 defaults are set by name rather than inherited, + /// These rmcp 3.8 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.7 usage notes". + /// DESIGN.md under "rmcp 3.8 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.7 usage notes"; the caller in `main` adds + /// under "rmcp 3.8 usage notes"; the caller in `main` adds /// `cancellation_token` so a SIGINT or SIGTERM reaches the live /// transport (issue #114). /// @@ -6117,12 +6117,12 @@ 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.7.0 strips + // The SEP-2549 hints are ours to gate: rmcp 3.8.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 - // rmcp bump. `None`, meaning no request revision and no session, is - // legacy. + // predicate is HAND-COPIED from its `is_2026_07_28_or_later` — no + // shared constant, no test pinning the agreement — so re-read both on + // an rmcp bump. `None`, meaning no request revision and no session, + // is legacy. let serves_cache_hints = context .protocol_version() .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); @@ -7721,7 +7721,7 @@ mod tests { } } - /// SEP-2243's schema annotation, byte-exact as rmcp 3.7.0 reads it — + /// SEP-2243's schema annotation, byte-exact as rmcp 3.8.0 reads it — /// `schema.get("x-mcp-header")` in `transport/common/mcp_headers.rs` /// (`collect_property_annotations`, behind `param_header_annotations` /// and `validate_param_header_annotations`). Upstream exports no @@ -7739,7 +7739,7 @@ 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.7.0 is + /// Deliberately broader than rmcp's functional read, which in 3.8.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 diff --git a/crates/bugwarden/src/stdio.rs b/crates/bugwarden/src/stdio.rs index 07e7c61..e5f61ad 100644 --- a/crates/bugwarden/src/stdio.rs +++ b/crates/bugwarden/src/stdio.rs @@ -212,7 +212,7 @@ 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.7.0 `service/server.rs:544-672`) takes any first non-`ping` +/// (rmcp 3.8.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()` (`:653`) — a /// sticky `AtomicBool` that no later `initialize` clears and no public API @@ -923,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.7.0 `service.rs:1398`) and drops the future + // `select!` (rmcp 3.8.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 @@ -1078,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.7.0 model.rs) has no + // `strip_result_type_for_legacy_peer` (rmcp 3.8.0 model.rs) has no // `DiscoverResult` arm, so rmcp never stripped it here either. assert_eq!(probe["result"]["resultType"], "complete", "{probe}"); } diff --git a/crates/bugwarden/tests/binary_discover_lifecycle.rs b/crates/bugwarden/tests/binary_discover_lifecycle.rs index 290d97c..3cbcf46 100644 --- a/crates/bugwarden/tests/binary_discover_lifecycle.rs +++ b/crates/bugwarden/tests/binary_discover_lifecycle.rs @@ -351,7 +351,7 @@ 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.7.0 + // rmcp polls `receive` as one arm of a `select!` (rmcp 3.8.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 diff --git a/crates/bugwarden/tests/binary_shutdown.rs b/crates/bugwarden/tests/binary_shutdown.rs index e8a8142..32f3779 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.7.0, `UnexpectedInitializeResponse` cannot happen while rmcp +//! rmcp 3.8.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 @@ -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.7.0 answers this frame in-band: one stdout -32602 + // rmcp 3.8.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 diff --git a/crates/bugwarden/tests/common/deadline.rs b/crates/bugwarden/tests/common/deadline.rs index 093ee75..af4347c 100644 --- a/crates/bugwarden/tests/common/deadline.rs +++ b/crates/bugwarden/tests/common/deadline.rs @@ -18,7 +18,7 @@ use std::time::Duration; /// Bound on every request one of these harnesses awaits an answer to. /// -/// rmcp 3.7.0's typed client calls send with +/// rmcp 3.8.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 diff --git a/crates/bugwarden/tests/http_transport_wiremock.rs b/crates/bugwarden/tests/http_transport_wiremock.rs index f818c5c..b04095b 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.7's `allowed_hosts` default is loopback only, so inheriting it + // rmcp 3.8'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.7.0 would store + // The same path `serve_http` and `main` take: rmcp 3.8.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.7.0 routes to its handshake-free lifecycle when the request's + // rmcp 3.8.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.7 usage notes in DESIGN.md). + // transport depends on (see the rmcp 3.8 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.7.0"}` into the record — not a missing + // `{"name":"rmcp","version":"3.8.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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fe9dcb5..824f5cc 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.7 MCP server, stdio and +- `crates/bugwarden` — the binary: clap CLI, rmcp 3.8 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.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_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.8 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.7 usage notes +## rmcp 3.8 usage notes Reference source is the rmcp this workspace pins, unpacked in the local -registry: `~/.cargo/registry/src/*/rmcp-3.7.0/` — today's version, and the +registry: `~/.cargo/registry/src/*/rmcp-3.8.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,17 +2811,18 @@ 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.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 +`rmcp-macros-3.8.0` tree. The published crate carries no `examples/` — those +live upstream at the `rmcp-v3.8.0` tag — but for how this server is actually wired, `server.rs` and `main.rs` are the reference. - `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 + and nothing 3.7 or 3.8 added over 3.6 is compiled into this crate: 3.7 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. + reqwest client's 401/403 handling (#1341), 3.8 only the streamable HTTP + client's 429/5xx status errors (#1386) and SSE reconnect stop (#1363), + none 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 @@ -2861,7 +2862,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.7 the + itself handshake-era, so re-read both on an rmcp bump (on 3.8 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 @@ -2928,7 +2929,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.7.0 `is_legacy_request` + by `SUPPORTED_PROTOCOL_VERSIONS`.** In rmcp 3.8.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 @@ -2943,7 +2944,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.7.0). Version + hits the stateless path** (#204; re-verified on 3.8.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 @@ -2974,7 +2975,7 @@ 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.7.0 sets on exactly one path + `peer.request_metadata_required()`, which rmcp 3.8.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`, @@ -3017,7 +3018,7 @@ 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.7.0 + before it is answered.** `serve_server_with_ct_inner` (rmcp 3.8.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 @@ -3036,7 +3037,7 @@ 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.7.0 no longer commits + unserved request in-band and keeps waiting — so bare 3.8.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` @@ -3110,7 +3111,7 @@ 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.7.0 the session branch of `handle_post` + routes.** In rmcp 3.8.0 the session branch of `handle_post` (`transport/streamable_http_server/tower.rs`) checks the header against `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 @@ -3157,7 +3158,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.7.0 `ServerResult::strip_result_type_for_legacy_peer` (`model.rs`) + In rmcp 3.8.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 @@ -3169,7 +3170,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.7.0 + toward emission. That expression is a hand-copy of the one rmcp 3.8.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 @@ -3182,7 +3183,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.7.0 `DiscoverResult` (`model.rs`) declares + about peers because in rmcp 3.8.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 @@ -3205,7 +3206,7 @@ wired, `server.rs` and `main.rs` are the reference. 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.7. So a tool handler that panics unwinds its own task + anywhere in rmcp 3.8. 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 @@ -3326,7 +3327,7 @@ 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.7.0 + and a panic there comes back from `RunningService::waiting` (rmcp 3.8.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 @@ -3449,7 +3450,7 @@ 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.7.0 is + deliberately broader than rmcp's functional read, which in 3.8.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 @@ -3463,7 +3464,7 @@ wired, `server.rs` and `main.rs` are the reference. `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.7.0: no + is still `fn get_tool(&self, name: &str) -> Option` in 3.8.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 @@ -3507,7 +3508,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.7 default | this build | + | field | rmcp 3.8 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) | @@ -3581,7 +3582,7 @@ wired, `server.rs` and `main.rs` are the reference. (`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 + on the shipped binary, the one test `main`'s wiring can fail; the rmcp 3.7 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 @@ -3716,7 +3717,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.7.0's `parse_allowed_authority` would otherwise keep validation on + rmcp 3.8.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 @@ -3799,7 +3800,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(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. +- 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.8 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 @@ -4847,7 +4848,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.7 usage notes" rests on; a scope-refused call leaves exactly ONE + "rmcp 3.8 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