Skip to content

Pre-dispatch rmcp refusals (-32022/-32602) produce no audit record, and bugwarden has no hook to record them #182

Description

@plusky

rmcp validates a per-request protocol revision inside the blanket
impl<H: ServerHandler> Service<RoleServer> for H (handler/server.rs:50,
check at :64-72 in 3.1.4; unsupported_protocol_version-32022,
model.rs:601) before dispatch reaches call_tool — so a request whose
_meta declares a revision this build does not serve is refused by the SDK
and bugwarden writes no audit record for it at all.

The example this issue originally named is overtaken: 2026-07-28 is in
SUPPORTED_PROTOCOL_VERSIONS (server.rs:49-55) and served since #34
stage 2. The live class is any _meta revision outside the served five —
rmcp's ProtocolVersion deserializer accepts arbitrary strings
(model.rs:204-221), so "9999-01-01" reproduces it on both transports. A
sibling shape is equally pre-dispatch and equally unrecorded: _meta
declaring the served 2026-07-28 but missing clientCapabilities → handler
-32602 (missing_required_keys, handler/server.rs:90-92).

Subclass structure: 3.1.4's KNOWN_VERSIONS (model.rs:181-187) equals the
served five exactly, so today the class holds only fabricated strings. The
"SDK-known but bugwarden-unserved" subclass is empty now and is reborn at the
first rmcp bump that widens KNOWN_VERSIONS before
SUPPORTED_PROTOCOL_VERSIONS follows — the moment real clients start being
refused unrecorded, and the operationally interesting one.

bugwarden's own recorded refusal covers only what reaches the handler:
lifecycle_of (server.rs:523) routes any out-of-contract _meta
declaration to Lifecycle::OutOfContract, which call_tool records before
refusing (server.rs:4123, mixed_lifecycle_refused at :547).

Not silent — logged, not streamed

The original claim that an operator cannot distinguish "nobody tried" from
"tried an unserved revision" does not hold. Both transports funnel through
rmcp's serve loop (stdio directly; HTTP via serve_directly_with_ct and
serve_server, tower.rs:1218/:1309), and a handler Err emits
tracing::warn!(%id, ?error, "response error") (service.rs:1585) whose
Debug-printed ErrorData carries data: {requested, supported: [...]}. The
distinction exists — in the log, not the stream. Source-read, not observed:
bugwarden's default filter is info (main.rs:49), which warn passes, but
whether the line survives the shipped subscriber in operation is unconfirmed.

That removes the strongest argument for recording. What remains is the crux:

There is no hook

The check lives in the blanket impl, so bugwarden cannot interpose on it. A
hand-written Service<RoleServer> wrapper works for stdio, but
StreamableHttpService::new requires S: ServerHandler
(tower.rs:1083-1088), which re-acquires the blanket impl — HTTP has no
interposition point, and a stdio-only wrapper would make audit coverage
differ by transport: worse unevenness than today's. Recording this class
is not implementable from bugwarden alone; it needs upstream rmcp
pre-dispatch refusal observability first.

Considered and rejected:

  • Service wrapper — stdio-only, above.
  • Widening supported_protocol_versions() so these reach call_tool — the
    same list drives initialize negotiation, the stateless path and
    server/discover (server.rs:4113), so widening makes the server agree
    to revisions it cannot serve.
  • Sniffing response bodies in the axum layer — HTTP-only (stdio has no axum
    layer), the same by-transport unevenness; and it reconstructs a policy
    event from wire bytes instead of from the decision that produced it.

Why the boundary is safe to accept

  • Degradation is graceful: if a future rmcp stops refusing first, these fail
    lifecycle_of's contains guard (server.rs:528) into
    Lifecycle::OutOfContract and become recorded refusals. The class
    cannot silently become served.
  • I2 does not bear: audit records are operator-side, and the served-revision
    set is public twice over — rmcp's -32022 quotes it back, and
    server/discover advertises it.

Recommended settlement (not yet actioned)

Do not record. Document the stream's lower boundary in DESIGN.md beside the
existing "Refusals are logged, not audited" bullet (DESIGN.md:934, which
settled #32's bearer class on the same line), and pin it with a test
asserting the -32022 request produces no record — an rmcp bump that moves
the check then shows up in CI. No such test exists today.

Open question

File the upstream rmcp ask for pre-dispatch refusal observability, or only
note it in the rmcp-upstream-watch memory? It is the sole path to ever
recording this class.

Found while planning #180; out of its scope. Planned 2026-09-01; set aside
before implementation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-assistedAuthored or substantially written with an AI coding agentauditAudit log subsystem

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions