Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **The `rmcp` floor moves to `3.5.1`, and a refusal over `--listen` on `2026-07-28` is answered in
band.** Before it, a JSON-RPC error a handler returned on the stateless route — this server
refusing a tool off the caller's surface, the SDK's own `tool not found`, an argument that did
not parse — went out as an HTTP `400`, which on that route is the signal the revision's
backward-compatibility rule gives a client for a server that predates it, to be answered by
falling back to `initialize`; a `--tools crash` listener refusing `debug_batch` could demote a
sessionless client to the handshake era mid-conversation. `rmcp` 3.5.1
([upstream #1322](https://github.com/modelcontextprotocol/rust-sdk/pull/1322)) sends it as `200`
with the error in the body; a request the transport itself refuses — a `_meta` short a key, a
header missing or mismatched, an unsupported revision — keeps its `400`, and an unknown method its
`404`. The smoke tier pins both arms against a real listener, which is what makes this a floor
rather than a lockfile entry: the test fails against 3.5.0, checked by pinning it. The
session-bearing route and stdio always carried errors in band and are unchanged, and nothing else
here depends on the release — its other change, a stdio `serve` that keeps waiting after a
malformed opener instead of exiting ([upstream
#1248](https://github.com/modelcontextprotocol/rust-sdk/pull/1248)), needs no code on this side.

- **The live Secure Kernel tier says what a pass asserts about protection, and takes the VTL0 half
when configured** (`FOLLOWUPS.md` item 124). Its stopped read is checked for the
stop's epoch, for bytes beginning with the guarded instruction and for the `gpa` its VTL1 walk
Expand Down
15 changes: 10 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ license = "MIT"

[dependencies]
dbgscope = { git = "https://github.com/glslang/dbgscope", rev = "f5d697b82d0bea2f822e5f1679a81071553dfdae" }
# 3.2.0 is a floor, not a preference, and by now for two unrelated reasons. Every 3.x before 3.1.1
# 3.5.1 is a floor, not a preference, and by now for three unrelated reasons. Every 3.x before 3.1.1
# generates a `tools/list` that omits SEP-2549's required `ttlMs`/`cacheScope`, which a spec-strict
# `2026-07-28` client rejects outright — the server connects and appears to expose no tools at all
# (upstream issue #1114). And every 3.x before 3.2.0 echoes `2026-07-28` straight back out of
# (upstream issue #1114). Every 3.x before 3.2.0 echoes `2026-07-28` straight back out of
# `initialize`; 3.2.0 settles that handshake on a revision which still *has* a handshake (upstream
# #1228, `is_legacy_version`). The smoke tier asserts the second behaviour, so a build that resolved
# 3.1.x would fail it rather than merely serve the older shape — which is what makes this a floor.
rmcp = { version = "3.2.0", features = [
# #1228, `is_legacy_version`). And every 3.x before 3.5.1 sends a handler's `-32602` on the stateless
# route as an HTTP 400 — this server's surface refusal included — which a `2026-07-28` client reads
# as a server that predates the revision and falls back to `initialize` on; 3.5.1 answers a handler's
# `-32602` in band and leaves the transport's own refusals where they were — a `_meta` short a key,
# a header missing or mismatched, an unsupported revision are 400, an unknown method 404 (upstream
# #1322). The smoke tier asserts the last two, so a build that resolved an older release would fail
# it rather than merely serve the older shape — which is what makes this a floor.
rmcp = { version = "3.5.1", features = [
"server",
"transport-io",
"macros",
Expand Down
18 changes: 15 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,24 @@ handshake, moving what it settled into per-request `_meta`. So the revision is s
by a client on `2026-07-28`, and by `initialize` for the legacy era; and an `initialize` that names
`2026-07-28` anyway is answered with `2025-11-25`, because a handshake cannot negotiate its way to a
revision that has none. That is `rmcp` 3.2.0's rule rather than this server's (upstream #1228), and
it is why the dependency carries a `3.2.0` floor — earlier 3.x echoed the offer back, so the same
it is one reason the dependency carries a floor — earlier 3.x echoed the offer back, so the same
client saw a different answer depending on which patch release was resolved.

The floor sits at `3.5.1` for a third rule, on the listener. A JSON-RPC error a *handler* returns
on `2026-07-28` — this server refusing a tool that is off the caller's surface, the SDK's own `tool
not found`, an argument that does not parse — is answered `200` with the error in the body
(upstream #1322). Every 3.x before it sent each of those as `400`, which on this route is the signal
the revision's backward-compatibility rule gives a client for a server that predates it, to be
answered by falling back to `initialize` — so a narrowed listener refusing one call could demote a
sessionless client to the handshake era mid-conversation. What moved is the handler's error and
nothing else: a request the transport itself refuses before any handler runs keeps its status — a
`_meta` short a required key, a header missing or mismatched, an unsupported revision are `400`, and
an unknown method is `404`. The session-bearing route and stdio always carried errors in band, so
neither changed.

`2026-07-28` also makes SEP-2549's cache fields mandatory on a paginated result, so `tools/list`
answers a client **on** that revision with `ttlMs: 0` and `cacheScope: public`, and omits both for
the older revisions, which never defined them. Since the fields follow the revision actually in
force, they appear on the stateless path and not after a handshake that negotiated down. This is the
other half of why `rmcp` has a floor at all: every 3.x before `3.1.1` omitted the fields on every
revision, and a client that validates against the spec schema then rejects the whole tool list.
oldest of the three reasons `rmcp` has a floor at all: every 3.x before `3.1.1` omitted the fields on
every revision, and a client that validates against the spec schema then rejects the whole tool list.
14 changes: 10 additions & 4 deletions docs/smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -1024,10 +1024,10 @@ reach is the wiring, which is what these assert against a real listener on a loo
hand-written HTTP client (a library that normalised a `409` into an exception, or hid the session
header, would be asserting on this server's behalf).

**Ten of them need no debugger**, because none of them needs a session to be open: the six below,
the three after them, and *Two clients are served two surfaces* from the section after that — whose
third test, the token file, is already one of the three. Counting them by grepping for
`Listener::start` gives nine and is wrong: *It will not start without a token* spawns the exe
**Eleven of them need no debugger**, because none of them needs a session to be open: the seven
below, the three after them, and *Two clients are served two surfaces* from the section after that —
whose third test, the token file, is already one of the three. Counting them by grepping for
`Listener::start` gives ten and is wrong: *It will not start without a token* spawns the exe
itself, because a listener that will not start cannot be started by the helper.

Six are the lease's own, and not one of them presents a **second credential** — so what they prove,
Expand Down Expand Up @@ -1073,6 +1073,12 @@ everything](#two-clients-two-of-everything) below are what closed it:
- *An under-specified stateless request is told which part it is missing*, rather than merely
refused. Both shapes the probe sent are pinned here, so the same `400` cannot be read as a broken
listener a second time.
- *A refusal on the stateless route is in band, and a malformed request is not.* A `--tools crash`
listener refusing `debug_batch` on `2026-07-28` answers `200` with the `-32602` in the body, and
the same call one `_meta` key short answers `400` naming the key. The split is `rmcp` 3.5.1's
(upstream #1322; every 3.x before it sent a handler's `-32602` as `400`, which a client on that
route reads as a server that predates the revision), so this is the assertion that makes `3.5.1`
a floor: it fails against 3.5.0, checked by pinning it.

Three more need no debugger either, and none of them is about the lease:

Expand Down
88 changes: 88 additions & 0 deletions tests/mcp_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5925,6 +5925,94 @@ fn an_under_specified_stateless_request_is_told_which_part_it_is_missing() {
);
}

/// A handler's refusal on `2026-07-28` is answered in band, and a request the transport refuses
/// keeps its HTTP `400`.
///
/// The surface refusal is this server's own `-32602` — a tool this build has that the caller's
/// surface does not — and on the stateless route rmcp maps a JSON-RPC error's code to an HTTP
/// status before deciding whether to open a stream. Every 3.x before 3.5.1 mapped `-32602` to
/// `400` unconditionally (upstream #1322 split the two), and `400` on this route is the signal
/// the revision's backward-compatibility rule gives a client for a server that predates it, to be
/// answered by falling back to `initialize` — so a `--tools crash` listener refusing `debug_batch`
/// told a spec-compliant client, mid-conversation, that it had never heard of `2026-07-28`. The
/// same mapper covers rmcp's own `tool not found` and an argument that does not parse. 3.5.1
/// answers `200` with the refusal where the client reads it, and leaves a request the transport
/// itself refuses — a `_meta` short a key, a header missing or mismatched, an unsupported revision
/// — on the `400` the status was for (an unknown method stays `404`). Both arms are
/// asserted because the property is the split rather than one code: a mapper answering `200` to
/// everything passes the first and fails the second. Checked against 3.5.0 by pinning it, where
/// the first arm came back `400`; this is the third reason `Cargo.toml` carries a floor, and the
/// one that puts it at `3.5.1`.
///
/// Protocol tier: the refusal is answered before anything is routed, so no target is needed.
#[test]
fn a_refusal_on_the_stateless_route_is_in_band_and_a_malformed_request_is_not() {
let mut server = Listener::start_with_args(&[], &["--tools", "crash"]);

let refused = server.stateless(
"tools/call",
json!({ "name": "debug_batch", "arguments": {} }),
);
assert_eq!(
refused.status,
200,
"a handler's -32602 on {STATELESS_REVISION} must be in band, not a {} a client reads as a \
server that predates the revision: {}\n--- stderr ---\n{}",
refused.status,
refused.body,
server.stderr()
);
let payload = refused
.payload
.clone()
.unwrap_or_else(|| panic!("the refusal carried no JSON-RPC payload: {}", refused.body));
assert_eq!(
payload["error"]["code"], -32602,
"the refusal is invalid params, in the body: {}",
refused.body
);
let message = payload["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("`debug_batch` is a tool this server has") && message.contains("--tools"),
"the body has to carry the surface refusal rather than the router's `tool not found`: {}",
refused.body
);

// The control, one key short rather than the whole `_meta` missing — the test above pins that
// shape, and the missing `Mcp-Method` header beside it. The request never reaches a handler,
// so the status is the only place the answer can say so; this is one of the `400`s the
// transport keeps, not the only one.
let (mut body, name) = Listener::stateless_body(
server.next_id,
"tools/call",
json!({ "name": "debug_batch", "arguments": {} }),
);
server.next_id += 1;
body["params"]["_meta"] = json!({
"io.modelcontextprotocol/protocolVersion": STATELESS_REVISION,
});
let token = server.token.clone();
let malformed = server.send_with(
"POST",
Some(&token),
None,
Some(&body),
&Listener::stateless_headers("tools/call", name.as_deref()),
);
assert_eq!(
malformed.status, 400,
"a request short a `_meta` key is still a malformed request: {}",
malformed.body
);
assert!(
malformed
.body
.contains("io.modelcontextprotocol/clientCapabilities"),
"the refusal has to name the key it wanted: {}",
malformed.body
);
}

/// Going quiet is not leaving; saying goodbye is.
///
/// The distinction is the whole reason a lease exists. Every request here is its own connection —
Expand Down
Loading