feat: speak MCP 2026-07-28 without breaking older peers - #105
Conversation
MCP 2026-07-28 drops the initialize handshake and protocol-level sessions, so every request now carries its own version and identity. We sit on both sides of the wire and every backend we proxy is still on 2025-11-25 or older, so picking one revision was never an option. Version is negotiated per request. The client probes server/discover and falls back to the legacy handshake on any failure, including a backend that dies on the unknown method. The proxy answers both shapes and only emits resultType, cache hints and the new routing headers to a peer that declared the new revision, so a legacy client sees the same bytes as before. Authorization got the hardening that shipped with the revision. The AS emits iss, the client validates it byte exact, metadata issuers are checked against where they were fetched from, and client registrations are keyed by issuer with existing on-disk stores migrating in place. subscriptions/listen, CIMD and the tasks extension stay out, tracked separately. DCR remains supported, it is deprecated with a 12 month window. Fixes #104 Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds dual-stack support for MCP 2026-07-28 while preserving byte-for-byte behavior for legacy peers/backends, including per-request protocol negotiation, Streamable HTTP request-metadata headers, MRTR relay-through, and OAuth-AS hardening/migration work needed by the new revision.
Changes:
- Introduces per-request stateless semantics (
server/discover,_meta-carried version/identity,resultType, cache hints) while keeping legacyinitializebehavior intact. - Implements/validates Streamable HTTP metadata headers (
Mcp-Method,Mcp-Name,MCP-Protocol-Version) and adds transport support for per-requestMcp-Param-*mirroring (x-mcp-header). - Hardens OAuth AS/client registration handling (RFC 9207
iss, SEP-837application_type, issuer-keyed client registration storage with on-disk migration), plus extensive e2e coverage for the seam and HTTP contract.
Reviewed changes
Copilot reviewed 23 out of 24 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/transport/mod.rs | Extends the Transport trait with per-request header mirroring and negotiated protocol version signaling. |
| src/transport/http.rs | Implements stateless-vs-legacy HTTP behavior, request-metadata headers, Mcp-Param-* mirroring, and regression tests for wire headers across revisions. |
| src/server_auth/oauth_as/register.rs | Accepts and conditionally echoes DCR application_type per SEP-837/RFC 7591; adds tests for compatibility and validation. |
| src/server_auth/oauth_as/e2e_tests.rs | Adds an end-to-end assertion that iss on the authorize redirect matches advertised issuer. |
| src/server_auth/oauth_as/authorize.rs | Adds RFC 9207 iss to authorization redirects with normalization + unit tests. |
| src/serve/stdio.rs | Plumbs per-request stateless detection into stdio serving path (no headers available). |
| src/serve/proxy.rs | Adds server/discover, initialize version echoing, MRTR continuation passthrough, and backend-param rewriting helpers + tests. |
| src/serve/mod.rs | Wires new serve e2e test modules for seam + conformance coverage. |
| src/serve/http.rs | Validates routing/version headers, returns spec-pinned HTTP status codes for modern peers, and adds router support for integration tests. |
| src/serve/e2e_tests.rs | New seam e2e suite covering negotiation, legacy compatibility, routing headers, MRTR relay, and x-mcp-header mirroring through the proxy. |
| src/serve/e2e_conformance_tests.rs | New end-to-end socket tests pinning /mcp HTTP status + header contract and server/discover wire shape. |
| src/protocol.rs | Updates protocol constants/version sets, adds header sentinel encoding/decoding, stateless metadata helpers, cache hints, and new protocol structs. |
| src/cli/server.rs | Adds optional proxy routing via MCP_PROXY_URL, strips/rewrites namespacing in listings/calls, and reports negotiated protocol version in JSON health output. |
| src/auth/store.rs | Migrates auth-store schema to issuer-keyed client registrations with legacy fallback and extensive migration/behavior tests. |
| docs/reference/environment-variables.md | Documents the special 3s server/discover probe timeout behavior. |
| docs/reference/cli.md | Documents --health and JSON protocolVersion, including proxy caveat. |
| docs/reference/architecture.md | Updates architecture docs for discovery-first negotiation, stateless headers, and issuer-keyed credential storage. |
| docs/howto/oauth-as.md | Documents iss, issuer validation, issuer-keyed registration storage + migration, and SEP-837 behavior. |
| docs/guides/proxy-mode.md | Updates proxy-mode docs for multi-revision behavior, header validation, and HTTP+SSE deprecation warning behavior. |
| docs/guides/configuration.md | Updates HTTP transport docs for revision-dependent session handling and routing headers. |
| .gitignore | Ignores .claude/worktrees/. |
Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 24 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/auth/store.rs:130
AuthStore::set_clientcan persist an issuer-keyed registration while leavingversionat 0 if the store was constructed viaAuthStore::default()(or otherwise not passed throughmigrate). On the nextload_auth_store(),migratewill treat it as v0 and moveclientsintolegacy_clients, effectively demoting issuer-keyed registrations. Settingversionhere makes the invariant self-contained and prevents accidental re-demotion.
pub fn set_client(&mut self, issuer: &str, server_key: &str, reg: ClientRegistration) {
self.legacy_clients.remove(server_key);
self.clients.insert(issuer_key(issuer), reg);
}
src/serve/http.rs:129
check_routing_headerstreats a present-but-non-UTF8 routing header as if it were absent (to_str().ok()), which contradicts the comment above about rejecting invalid header characters. This can let a gateway-route/meter claim go unvalidated in the one case where the header is malformed. Consider explicitly rejecting any present routing header whose value failsto_str().
fn check_routing_headers(headers: &HeaderMap, req: &JsonRpcRequest) -> Option<String> {
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 27 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/serve/http.rs:207
check_protocol_version_headercurrently ignores anMCP-Protocol-Versionheader whose value can’t be decoded viaHeaderValue::to_str()(it becomesNoneand the header is treated as absent). That makes malformed header bytes silently bypass the unsupported-version check and can lead to confusing legacy-vs-stateless behavior selection. It’s safer to treat a present-but-invalid header value as a-32020 HeaderMismatch(400).
let declared = headers
.get(crate::protocol::HEADER_MCP_PROTOCOL_VERSION)
.and_then(|v| v.to_str().ok())?;
if crate::protocol::is_version_supported(declared) {
src/serve/http.rs:126
check_routing_headerstreats a routing header whose value contains non-ASCII/invalid bytes as if the header were absent because it usesto_str().ok(). That means a malformedMcp-Method/Mcp-Namecan bypass mismatch validation entirely, even though the spec calls out invalid header characters as a rejectable condition. Consider treating non-decodable header values as an immediate mismatch instead of silently ignoring them.
This issue also appears on line 204 of the same file.
fn check_routing_headers(headers: &HeaderMap, req: &JsonRpcRequest) -> Option<String> {
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
MCP 2026-07-28 drops the initialize handshake and protocol-level sessions, so every request now carries its own version and identity. We sit on both sides of the wire and every backend we proxy is still on 2025-11-25 or older, so picking one revision was never an option.
Version is negotiated per request. The client probes server/discover and falls back to the legacy handshake on any failure, including a backend that dies on the unknown method. The proxy answers both shapes and only emits resultType, cache hints and the new routing headers to a peer that declared the new revision, so a legacy client sees the same bytes as before. Authorization got the hardening that shipped with the revision. The AS emits iss, the client validates it byte exact, metadata issuers are checked against where they were fetched from, and client registrations are keyed by issuer with existing on-disk stores migrating in place.
subscriptions/listen, CIMD and the tasks extension stay out, tracked separately. DCR remains supported, it is deprecated with a 12 month window.
Fixes #104