diff --git a/src/bin/taguru-mcp.rs b/src/bin/taguru-mcp.rs index a45c6f6c..6631e23a 100644 --- a/src/bin/taguru-mcp.rs +++ b/src/bin/taguru-mcp.rs @@ -272,6 +272,14 @@ fn main() { cancel(&tracked_calls, &active_by_id, &target_id.to_string()); continue; } + // Checked before the tool-call fast path below, so a request + // naming an unimplemented per-request protocol version is + // refused with the supported list rather than run under + // semantics this build never promised. + if let Some(rejection) = mcp::modern_version_rejection(&message) { + emit(&stdout, &rejection); + continue; + } match mcp::classify(&message) { mcp::Message::Request { id, @@ -635,6 +643,10 @@ fn dispatch(bridge: &Bridge, instructions: &str, classified: mcp::Message) -> Op mcp::initialize_result(protocol_version.as_deref(), instructions), ), mcp::Call::Ping => mcp::response(id, serde_json::json!({})), + // 2026-07-28's mandatory RPC, and the probe a dual-era client + // sends FIRST on stdio to learn which era this server speaks — + // so it must answer even without modern `_meta` on the request. + mcp::Call::Discover => mcp::response(id, mcp::discover_result(instructions)), mcp::Call::ToolsList => mcp::response(id, mcp::tools_result()), mcp::Call::Tool { name, arguments } => mcp::response( id, @@ -650,12 +662,19 @@ fn dispatch(bridge: &Bridge, instructions: &str, classified: mcp::Message) -> Op /// — used directly by the tests below. `main`'s loop does not call /// this: it needs to see the classified message itself first, to queue /// a `tools/call` for the tool worker pool instead (see `dispatch`'s -/// doc). +/// doc). This is therefore a MIRROR of that loop's gate order — batch +/// rejection, then version rejection, then dispatch — and a gate +/// added or reordered there must be reflected here in the same move, +/// or the tests keep passing against an order production no longer +/// runs. #[cfg(test)] fn handle(bridge: &Bridge, instructions: &str, message: &Value) -> Option { if message.is_array() { return Some(batch_rejected()); } + if let Some(rejection) = mcp::modern_version_rejection(message) { + return Some(rejection); + } dispatch(bridge, instructions, mcp::classify(message)) } @@ -855,6 +874,52 @@ mod tests { ); } + /// The stdio backward-compatibility probe: a dual-era client's + /// FIRST message is `server/discover`, typically before any + /// version agreement — a server that -32601'd it would be + /// misdiagnosed as legacy-only. Answered locally, bridge untouched. + #[test] + fn server_discover_is_answered_as_the_era_probe() { + let reply = handle( + &bridge(), + "manual", + &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover"}), + ) + .expect("the era probe must be answered"); + assert_eq!( + reply["result"]["supportedVersions"], + serde_json::json!(mcp::MODERN_PROTOCOL_VERSIONS), + "{reply}" + ); + assert_eq!(reply["result"]["instructions"], "manual", "{reply}"); + assert_eq!(reply["result"]["resultType"], "complete", "{reply}"); + } + + /// A request declaring a per-request protocol version this build + /// does not implement is refused with the list to retry from — + /// before dispatch, so the bridge (and the server behind it) never + /// runs work under semantics nobody agreed to. + #[test] + fn an_unimplemented_per_request_version_is_refused_with_the_supported_list() { + let reply = handle( + &bridge(), + "", + &serde_json::json!({ + "jsonrpc": "2.0", "id": 3, "method": "tools/list", + "params": {"_meta": { + "io.modelcontextprotocol/protocolVersion": "2099-01-01", + }}, + }), + ) + .expect("an unimplemented version must be answered"); + assert_eq!(reply["error"]["code"], -32022, "{reply}"); + assert_eq!( + reply["error"]["data"]["supported"], + serde_json::json!(mcp::MODERN_PROTOCOL_VERSIONS), + "{reply}" + ); + } + #[test] fn a_zero_or_unparseable_timeout_falls_back_to_the_default() { // A positive override is honored verbatim. diff --git a/src/main.rs b/src/main.rs index d18fafa3..ab5f497c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -547,12 +547,14 @@ async fn serve(serve_args: cli::ServeArgs, auth_source: auth::AuthSource) { move |deadline: axum::Extension, key: Option>, scope: Option>, + headers: axum::http::HeaderMap, body: axum::body::Bytes| { remote_mcp::serve( mcp_dispatch.clone(), Arc::clone(&mcp_instructions), key.map(|extension| extension.0), scope.map(|extension| extension.0), + headers, body, mcp_max_result_bytes, deadline.0, diff --git a/src/mcp.rs b/src/mcp.rs index 896de38e..907f1a65 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -34,9 +34,11 @@ mod schema; // further out. #[allow(unused_imports)] pub use protocol::{ - Call, FALLBACK_PROTOCOL_VERSION, Message, SUPPORTED_PROTOCOL_VERSIONS, ToolError, - cancelled_request_id, classify, error_response, initialize_result, meta_trace_headers, - response, tool_response, tools_result, + Call, FALLBACK_PROTOCOL_VERSION, HEADER_MISMATCH, META_PROTOCOL_VERSION, + MODERN_PROTOCOL_VERSIONS, Message, SUPPORTED_PROTOCOL_VERSIONS, ToolError, + cancelled_request_id, classify, discover_result, error_response, initialize_result, + meta_trace_headers, modern_version_rejection, request_protocol_version, response, + tool_response, tools_result, unsupported_version_error, }; #[allow(unused_imports)] pub use retrieve::{ @@ -1034,6 +1036,162 @@ mod tests { ); } + /// The two version lists must stay disjoint: a version in both + /// would let `initialize` echo — and thereby promise — a wire + /// contract under which `initialize` does not exist. + #[test] + fn legacy_and_modern_version_lists_are_disjoint() { + for version in MODERN_PROTOCOL_VERSIONS { + assert!(!SUPPORTED_PROTOCOL_VERSIONS.contains(version), "{version}"); + } + } + + /// The regression the split guards against: a client proposing + /// 2026-07-28 through legacy `initialize` gets the newest + /// initialize-era version back, never the stateless one. + #[test] + fn initialize_never_echoes_a_modern_version() { + for version in MODERN_PROTOCOL_VERSIONS { + assert_eq!( + initialize_result(Some(version), "manual")["protocolVersion"], + FALLBACK_PROTOCOL_VERSION + ); + } + } + + #[test] + fn classify_routes_server_discover() { + assert!(matches!( + classify(&json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover"})), + Message::Request { + call: Call::Discover, + .. + } + )); + } + + /// The 2026-07-28 result envelope on the one RPC that revision + /// makes mandatory: versions to retry with, capabilities, cache + /// metadata, identity, and the manual. + #[test] + fn discover_result_carries_the_stateless_contract() { + let result = discover_result("manual"); + assert_eq!(result["supportedVersions"], json!(MODERN_PROTOCOL_VERSIONS)); + assert_eq!(result["resultType"], "complete"); + assert!(result["capabilities"]["tools"].is_object(), "{result}"); + assert_eq!(result["instructions"], "manual"); + assert!(result["ttlMs"].is_u64(), "{result}"); + assert_eq!(result["cacheScope"], "private"); + assert_eq!( + result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "taguru" + ); + } + + /// `tools/list` under 2026-07-28 is a `CacheableResult`: the + /// required `ttlMs`/`cacheScope` ride alongside the tools, plus the + /// `resultType` every result now carries — all additive, so a + /// legacy client still finds `tools` exactly where it always was. + #[test] + fn tools_result_is_a_cacheable_complete_result() { + let result = tools_result(); + assert!(result["tools"].is_array(), "{result}"); + assert_eq!(result["resultType"], "complete"); + assert!(result["ttlMs"].is_u64(), "{result}"); + assert_eq!(result["cacheScope"], "private"); + assert_eq!( + result["_meta"]["io.modelcontextprotocol/serverInfo"]["version"], + env!("CARGO_PKG_VERSION") + ); + } + + /// Every tool outcome — success and failure alike — declares + /// `resultType: "complete"`: this server never returns an interim + /// `input_required`, and a modern client must not have to guess. + #[test] + fn tool_response_declares_result_type_complete() { + assert_eq!(tool_response(Ok("fine".into()))["resultType"], "complete"); + assert_eq!( + tool_response(Err("broken".to_string().into()))["resultType"], + "complete" + ); + } + + #[test] + fn request_protocol_version_reads_only_the_meta_key() { + assert_eq!( + request_protocol_version(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": {"_meta": {META_PROTOCOL_VERSION: "2026-07-28"}}, + })), + Some("2026-07-28") + ); + // No params, no _meta, or a wrong-typed value: all read as "not + // a modern request", never an error. + assert_eq!( + request_protocol_version(&json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})), + None + ); + assert_eq!( + request_protocol_version(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": {"_meta": {META_PROTOCOL_VERSION: 2026}}, + })), + None + ); + } + + /// The stdio-side gate: a declared-but-unimplemented per-request + /// version earns `UnsupportedProtocolVersionError` with the list to + /// retry from; a supported one, a legacy message, and a + /// notification (nothing is waiting) all pass untouched. + #[test] + fn modern_version_rejection_refuses_only_unsupported_declared_versions() { + let refused = modern_version_rejection(&json!({ + "jsonrpc": "2.0", "id": 7, "method": "tools/list", + "params": {"_meta": {META_PROTOCOL_VERSION: "2099-01-01"}}, + })) + .expect("an unimplemented version must be refused"); + assert_eq!(refused["id"], 7); + assert_eq!(refused["error"]["code"], -32022); + assert_eq!( + refused["error"]["data"]["supported"], + json!(MODERN_PROTOCOL_VERSIONS) + ); + assert_eq!(refused["error"]["data"]["requested"], "2099-01-01"); + + assert!( + modern_version_rejection(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + "params": {"_meta": {META_PROTOCOL_VERSION: MODERN_PROTOCOL_VERSIONS[0]}}, + })) + .is_none() + ); + assert!( + modern_version_rejection(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + })) + .is_none() + ); + assert!( + modern_version_rejection(&json!({ + "jsonrpc": "2.0", "method": "notifications/whatever", + "params": {"_meta": {META_PROTOCOL_VERSION: "2099-01-01"}}, + })) + .is_none() + ); + // An id of a type JSON-RPC forbids never reaches -32022 either: + // it falls through to the ordinary InvalidId refusal, so the + // malformed id is not echoed into this error's body. + assert!( + modern_version_rejection(&json!({ + "jsonrpc": "2.0", "id": [1], "method": "tools/list", + "params": {"_meta": {META_PROTOCOL_VERSION: "2099-01-01"}}, + })) + .is_none() + ); + } + #[test] fn tool_response_marks_errors_without_aborting_the_rpc() { let ok = tool_response(Ok("fine".into())); diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index 9b9592a1..fa586ff5 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -18,6 +18,37 @@ pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2024-11-05", "2025-03-26", " pub const FALLBACK_PROTOCOL_VERSION: &str = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.len() - 1]; +/// The versions a client may declare per request in `params._meta` +/// (revision 2026-07-28's stateless replacement for `initialize`). +/// Kept apart from [`SUPPORTED_PROTOCOL_VERSIONS`] on purpose: these +/// versions have no `initialize` at all, so folding them into that +/// list would let `initialize` echo — and thereby promise — a wire +/// contract under which `initialize` does not exist. +pub const MODERN_PROTOCOL_VERSIONS: &[&str] = &["2026-07-28"]; + +/// The `_meta` key a modern request's protocol version travels under. +pub const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; + +/// `UnsupportedProtocolVersion` (2026-07-28): the request named a +/// per-request protocol version this build does not implement. From +/// the `-32020`..`-32099` sub-range the spec reserves for itself. +pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022; + +/// `HeaderMismatch` (2026-07-28): a required Streamable HTTP header is +/// missing, malformed, or disagrees with the request body. +#[allow(dead_code)] // consumed by the HTTP transport; stdio has no headers to mismatch +pub const HEADER_MISMATCH: i64 = -32020; + +/// How long a client may cache the (compile-time static) tool list and +/// discovery result before re-asking — the `ttlMs` half of 2026-07-28's +/// `CacheableResult`. An hour: the list only changes with a redeploy. +const CACHE_TTL_MS: u64 = 3_600_000; + +/// The `cacheScope` half: `/mcp` sits behind bearer auth, so nothing +/// is gained by letting shared intermediaries store its responses — +/// "private" confines caching to the client itself. +const CACHE_SCOPE: &str = "private"; + /// One decoded JSON-RPC message, sorted by what it obliges us to do. pub enum Message { /// Carries an id: the sender expects exactly one response. @@ -39,6 +70,7 @@ pub enum Message { pub enum Call { Initialize { protocol_version: Option }, Ping, + Discover, ToolsList, Tool { name: String, arguments: Value }, Unknown { method: String }, @@ -78,6 +110,7 @@ pub fn classify(message: &Value) -> Message { .map(str::to_string), }, "ping" => Call::Ping, + "server/discover" => Call::Discover, "tools/list" => Call::ToolsList, "tools/call" => Call::Tool { name: params @@ -133,6 +166,88 @@ pub fn meta_trace_headers(message: &Value) -> http::HeaderMap { headers } +/// The protocol version a modern (2026-07-28+) request declares in +/// `params._meta`, or `None` for a legacy message that has no such +/// field — the presence of this key is what sorts a request into the +/// stateless era at all. +pub fn request_protocol_version(message: &Value) -> Option<&str> { + message + .get("params")? + .get("_meta")? + .get(META_PROTOCOL_VERSION)? + .as_str() +} + +/// The `UnsupportedProtocolVersionError` reply owed when a request +/// declares a per-request protocol version this build does not +/// implement, or `None` when the message either names a supported one +/// or is not a modern request at all. Notifications get no reply even +/// here — nothing is waiting — so only a classified request earns the +/// error. +#[allow(dead_code)] // consumed by the stdio bridge; the HTTP transport folds this check into its header gate +pub fn modern_version_rejection(message: &Value) -> Option { + let version = request_protocol_version(message)?; + if MODERN_PROTOCOL_VERSIONS.contains(&version) { + return None; + } + let Message::Request { id, .. } = classify(message) else { + return None; + }; + Some(unsupported_version_error(id, version)) +} + +/// The `UnsupportedProtocolVersionError` reply itself — `data.supported` +/// is where a modern client looks for the versions to retry with, so +/// both transports build it here and the list can never drift. +pub fn unsupported_version_error(id: Value, requested: &str) -> Value { + error_response_with_data( + id, + UNSUPPORTED_PROTOCOL_VERSION, + "unsupported protocol version".to_string(), + json!({ "supported": MODERN_PROTOCOL_VERSIONS, "requested": requested }), + ) +} + +/// This build's identity, exactly as 2026-07-28 wants it repeated in +/// each result's `_meta` — the stateless replacement for the one-shot +/// `serverInfo` that `initialize` used to hand out. +fn server_info_meta() -> Value { + json!({ + "io.modelcontextprotocol/serverInfo": { + "name": "taguru", + "version": env!("CARGO_PKG_VERSION"), + }, + }) +} + +/// Stamps the 2026-07-28 result envelope onto `result`: the required +/// `resultType` ("complete" — this server never returns an interim +/// `input_required`) and the SHOULD-level serverInfo `_meta`. Applied +/// unconditionally: to a legacy client both are unknown result fields, +/// which JSON-RPC obliges it to ignore, and one shape for both eras +/// beats a fork. +fn complete_result(mut result: Value) -> Value { + let fields = result + .as_object_mut() + .expect("every MCP result this server builds is an object"); + fields.insert("resultType".to_string(), json!("complete")); + fields.insert("_meta".to_string(), server_info_meta()); + result +} + +/// The `server/discover` result — the one RPC 2026-07-28 makes +/// mandatory. Doubles as the stdio backward-compatibility probe, so it +/// answers on either era's framing. +pub fn discover_result(instructions: &str) -> Value { + complete_result(json!({ + "supportedVersions": MODERN_PROTOCOL_VERSIONS, + "capabilities": { "tools": {} }, + "instructions": instructions, + "ttlMs": CACHE_TTL_MS, + "cacheScope": CACHE_SCOPE, + })) +} + /// The `initialize` result: capabilities plus the full protocol manual /// as `instructions`, so the agent learns the discipline the moment it /// connects. @@ -148,9 +263,15 @@ pub fn initialize_result(client_protocol_version: Option<&str>, instructions: &s }) } -/// The `tools/list` result. +/// The `tools/list` result. `ttlMs`/`cacheScope` are 2026-07-28's +/// required `CacheableResult` fields; to older clients they are just +/// two more unknown fields alongside `resultType`. pub fn tools_result() -> Value { - json!({ "tools": tool_definitions() }) + complete_result(json!({ + "tools": tool_definitions(), + "ttlMs": CACHE_TTL_MS, + "cacheScope": CACHE_SCOPE, + })) } /// One tool call's failure. `text` is the prose every transport has @@ -183,7 +304,7 @@ impl From for ToolError { /// on the wire, so older clients that only read `content` see no /// change. pub fn tool_response(outcome: Result) -> Value { - match outcome { + complete_result(match outcome { Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }), Err(ToolError { text, @@ -200,7 +321,7 @@ pub fn tool_response(outcome: Result) -> Value { "isError": true, "structuredContent": structured, }), - } + }) } pub fn response(id: Value, result: Value) -> Value { @@ -210,3 +331,12 @@ pub fn response(id: Value, result: Value) -> Value { pub fn error_response(id: Value, code: i64, message: String) -> Value { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) } + +/// [`error_response`] with the optional JSON-RPC `error.data` member. +fn error_response_with_data(id: Value, code: i64, message: String, data: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message, "data": data }, + }) +} diff --git a/src/remote_mcp.rs b/src/remote_mcp.rs index 15001a65..93be595c 100644 --- a/src/remote_mcp.rs +++ b/src/remote_mcp.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use axum::Router; use axum::body::{Body, Bytes}; -use axum::http::{Request, StatusCode, header}; +use axum::http::{HeaderMap, Request, StatusCode, header}; use axum::response::{IntoResponse, Response}; use http_body_util::LengthLimitError; use serde_json::{Value, json}; @@ -45,11 +45,13 @@ use crate::mcp; /// stamped onto the dispatched call the same way — without this, a /// tool call would run past `enforce_timeout`'s race unchecked, since /// the dispatched request never passes back through that layer. +#[allow(clippy::too_many_arguments)] // the outer request's context, spread flat pub async fn serve( dispatch: Router, instructions: Arc, key: Option, scope: Option, + headers: HeaderMap, body: Bytes, max_result_bytes: usize, deadline: Deadline, @@ -95,12 +97,26 @@ pub async fn serve( mcp::Message::Request { id, call } => (id, call), }; + // 2026-07-28's per-request contract, applied before any dispatch: a + // request that declares the stateless era (via `_meta` or the + // mirrored header) must carry consistent, supported metadata — and + // one that declares neither runs under the legacy `initialize` + // contract untouched, exactly the dual-era split the spec draws. + let era = match modern_gate(&headers, &message, &id) { + Ok(era) => era, + Err(refusal) => return rpc_over_http(StatusCode::BAD_REQUEST, refusal), + }; + let reply = match call { mcp::Call::Initialize { protocol_version } => mcp::response( id, mcp::initialize_result(protocol_version.as_deref(), &instructions), ), mcp::Call::Ping => mcp::response(id, json!({})), + // Mandatory under 2026-07-28, and answered on the legacy era + // too: a dual-era client's first message may be exactly this + // probe, sent to learn which era the server speaks. + mcp::Call::Discover => mcp::response(id, mcp::discover_result(&instructions)), mcp::Call::ToolsList => mcp::response(id, mcp::tools_result()), mcp::Call::Tool { name, arguments } if name == "retrieve" => { // retrieve issues a variable number of dispatched calls @@ -218,12 +234,213 @@ pub async fn serve( mcp::response(id, mcp::tool_response(outcome)) } mcp::Call::Unknown { method } => { - mcp::error_response(id, -32601, format!("unknown method '{method}'")) + // 2026-07-28 pins the HTTP status for an unimplemented RPC + // to 404 — the JSON-RPC body is what tells it apart from a + // legacy server that does not host /mcp at all. The legacy + // era keeps its 200: those clients read only the body, and + // some treat a non-2xx envelope as transport failure. + let status = match era { + Era::Modern => StatusCode::NOT_FOUND, + Era::Legacy => StatusCode::OK, + }; + return rpc_over_http( + status, + mcp::error_response(id, -32601, format!("unknown method '{method}'")), + ); } }; rpc_over_http(StatusCode::OK, reply) } +/// Which wire contract one request runs under. +enum Era { + /// The `initialize`-handshake revisions (2025-11-25 and earlier): + /// no required headers, no per-request `_meta` version. + Legacy, + /// Revision 2026-07-28 and later: version and identity travel on + /// every request, mirrored into headers for intermediaries. + Modern, +} + +/// Sorts one request into its [`Era`] and enforces every header rule +/// the modern era imposes: `MCP-Protocol-Version` agreeing with the +/// body's `_meta`, the version actually being implemented, `Mcp-Method` +/// agreeing with `method`, and `Mcp-Name` agreeing with `params.name` +/// on a `tools/call`. Any failure is the JSON-RPC error body to send +/// back — the spec's `HeaderMismatch` (-32020) or +/// `UnsupportedProtocolVersion` (-32022), each under HTTP 400, which +/// the caller stamps on. +/// +/// A non-modern version in `MCP-Protocol-Version` alone selects +/// [`Era::Legacy`]: clients have mirrored that header since 2025-06-18, +/// so its mere presence proves nothing about the era — and it may name +/// a legacy revision this build never listed (2025-11-25 was skipped +/// entirely), which deserves the same tolerance `initialize` extends +/// to an unrecognized proposed version, not a refusal. +fn modern_gate(headers: &HeaderMap, message: &Value, id: &Value) -> Result { + // "Present but unreadable" must not collapse into "absent": a + // malformed value is exactly what the spec's server validation + // names for -32020, and folding it to None would let it ride the + // legacy path unexamined. + let header_version = match headers.get("mcp-protocol-version") { + None => None, + Some(value) => match value.to_str() { + Ok(text) => Some(text), + Err(_) => { + return Err(header_mismatch( + id, + "MCP-Protocol-Version header is not readable as ASCII".to_string(), + )); + } + }, + }; + let meta_version = mcp::request_protocol_version(message); + let version = match (meta_version, header_version) { + (None, Some(header)) if mcp::MODERN_PROTOCOL_VERSIONS.contains(&header) => { + return Err(header_mismatch( + id, + format!( + "MCP-Protocol-Version is '{header}' but the body carries no \ + _meta {}", + mcp::META_PROTOCOL_VERSION + ), + )); + } + // No `_meta` and no modern header: the legacy contract, whatever + // (if anything) the header names — see the doc comment above. + (None, _) => return Ok(Era::Legacy), + (Some(meta), Some(header)) if meta != header => { + return Err(header_mismatch( + id, + format!( + "MCP-Protocol-Version header '{header}' does not match the body's '{meta}'" + ), + )); + } + (Some(meta), None) => { + return Err(header_mismatch( + id, + format!( + "the body declares protocol version '{meta}' but the required \ + MCP-Protocol-Version header is missing" + ), + )); + } + (Some(meta), Some(_)) => meta, + }; + if !mcp::MODERN_PROTOCOL_VERSIONS.contains(&version) { + return Err(unsupported_version(id, version)); + } + let body_method = message.get("method").and_then(Value::as_str).unwrap_or(""); + match headers + .get("mcp-method") + .and_then(|value| value.to_str().ok()) + { + Some(method) if method == body_method => {} + Some(method) => { + return Err(header_mismatch( + id, + format!("Mcp-Method header '{method}' does not match body method '{body_method}'"), + )); + } + None => { + return Err(header_mismatch( + id, + "the required Mcp-Method header is missing".to_string(), + )); + } + } + if body_method == "tools/call" { + let body_name = message + .get("params") + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + match headers + .get("mcp-name") + .and_then(|value| value.to_str().ok()) + .map(decode_sentinel) + { + Some(Ok(name)) if name == body_name => {} + Some(Ok(name)) => { + return Err(header_mismatch( + id, + format!("Mcp-Name header '{name}' does not match body name '{body_name}'"), + )); + } + Some(Err(())) => { + return Err(header_mismatch( + id, + "Mcp-Name header carries an undecodable base64 sentinel".to_string(), + )); + } + None => { + return Err(header_mismatch( + id, + "the required Mcp-Name header is missing".to_string(), + )); + } + } + } + Ok(Era::Modern) +} + +fn header_mismatch(id: &Value, message: String) -> Value { + mcp::error_response(id.clone(), mcp::HEADER_MISMATCH, message) +} + +fn unsupported_version(id: &Value, requested: &str) -> Value { + mcp::unsupported_version_error(id.clone(), requested) +} + +/// A header value, through the spec's Base64 sentinel if it wears one: +/// `=?base64?…?=` marks a value that could not ride as plain ASCII +/// (servers MUST decode before comparing against the body — a client +/// is free to encode even a plain-ASCII name). Anything unmarked is +/// itself the value. +fn decode_sentinel(value: &str) -> Result { + let Some(encoded) = value + .strip_prefix("=?base64?") + .and_then(|rest| rest.strip_suffix("?=")) + else { + return Ok(value.to_string()); + }; + base64_decode(encoded).and_then(|bytes| String::from_utf8(bytes).map_err(|_| ())) +} + +/// RFC 4648 §4 decoding (the standard `+`/`/` alphabet the sentinel +/// carries), padding optional — decode-only, the mirror of oauth.rs's +/// encode-only `base64url`, and just as deliberately dependency-free. +fn base64_decode(text: &str) -> Result, ()> { + fn sextet(byte: u8) -> Result { + match byte { + b'A'..=b'Z' => Ok(u32::from(byte - b'A')), + b'a'..=b'z' => Ok(u32::from(byte - b'a') + 26), + b'0'..=b'9' => Ok(u32::from(byte - b'0') + 52), + b'+' => Ok(62), + b'/' => Ok(63), + _ => Err(()), + } + } + let stripped = text.trim_end_matches('=').as_bytes(); + let mut out = Vec::with_capacity(stripped.len() * 3 / 4); + for chunk in stripped.chunks(4) { + let mut acc: u32 = 0; + for &byte in chunk { + acc = (acc << 6) | sextet(byte)?; + } + match chunk.len() { + 4 => out.extend_from_slice(&[(acc >> 16) as u8, (acc >> 8) as u8, acc as u8]), + 3 => out.extend_from_slice(&[(acc >> 10) as u8, (acc >> 2) as u8]), + 2 => out.push((acc >> 4) as u8), + // A lone trailing sextet encodes fewer bits than a byte — + // no valid base64 ends this way. + _ => return Err(()), + } + } + Ok(out) +} + /// One in-process round trip against the API routes — the transport /// twin of the stdio bridge's ureq call, down to the error text, so a /// tool failure reads identically on both transports. @@ -375,6 +592,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), usize::MAX, Deadline::unbounded(), @@ -434,6 +652,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), 1024, Deadline::unbounded(), @@ -477,6 +696,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), 1024, Deadline::unbounded(), @@ -544,6 +764,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), 1024, Deadline::unbounded(), @@ -624,6 +845,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), 1024, Deadline::unbounded(), @@ -645,6 +867,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), usize::MAX, Deadline::unbounded(), @@ -685,6 +908,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), usize::MAX, already_expired, @@ -723,6 +947,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), usize::MAX, already_expired, @@ -737,6 +962,308 @@ mod tests { assert!(text.contains("exceeded its budget"), "{text}"); } + /// One serve() round trip with explicit headers, against a router + /// that answers `GET /contexts` — enough for a `list_contexts` + /// tools/call or any dispatch-free method. + async fn roundtrip(headers: HeaderMap, body: Value) -> (u16, Value) { + let router = Router::new().route("/contexts", axum::routing::get(|| async { "[]" })); + let response = serve( + router, + Arc::new("manual".to_string()), + None, + None, + headers, + Bytes::from(body.to_string()), + usize::MAX, + Deadline::unbounded(), + ) + .await; + let status = response.status().as_u16(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) + } + + /// The standard 2026-07-28 request headers, body `_meta` half in + /// [`modern_body`]. + fn modern_headers(method: &str, name: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("mcp-protocol-version", "2026-07-28".parse().unwrap()); + headers.insert("mcp-method", method.parse().unwrap()); + if let Some(name) = name { + headers.insert("mcp-name", name.parse().unwrap()); + } + headers + } + + fn modern_body(method: &str, mut params: Value) -> Value { + params.as_object_mut().unwrap().insert( + "_meta".to_string(), + json!({ "io.modelcontextprotocol/protocolVersion": "2026-07-28" }), + ); + json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }) + } + + /// The modern happy path end to end: matching headers and `_meta`, + /// a `tools/call` naming its tool in `Mcp-Name`, a 200 whose result + /// wears the 2026-07-28 envelope. + #[tokio::test] + async fn a_conforming_modern_tools_call_runs_and_answers_complete() { + let (status, reply) = roundtrip( + modern_headers("tools/call", Some("list_contexts")), + modern_body( + "tools/call", + json!({ "name": "list_contexts", "arguments": {} }), + ), + ) + .await; + assert_eq!(status, 200, "{reply}"); + assert_eq!(reply["result"]["resultType"], "complete", "{reply}"); + assert_ne!(reply["result"]["isError"], json!(true), "{reply}"); + } + + /// `server/discover` answers on both eras — with modern headers, + /// and bare (the backward-compatibility probe of a client that has + /// not yet learned which era this server speaks). + #[tokio::test] + async fn server_discover_answers_on_either_era() { + let (status, reply) = roundtrip( + HeaderMap::new(), + json!({ "jsonrpc": "2.0", "id": 1, "method": "server/discover" }), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + reply["result"]["supportedVersions"][0], "2026-07-28", + "{reply}" + ); + assert_eq!(reply["result"]["instructions"], "manual", "{reply}"); + + let (status, reply) = roundtrip( + modern_headers("server/discover", None), + modern_body("server/discover", json!({})), + ) + .await; + assert_eq!(status, 200, "{reply}"); + assert_eq!(reply["result"]["resultType"], "complete", "{reply}"); + } + + /// Requests carrying neither modern `_meta` nor any version header + /// — and ones carrying the header legacy clients have mirrored + /// since 2025-06-18 — run under the initialize contract untouched. + #[tokio::test] + async fn legacy_requests_are_untouched_by_the_modern_gate() { + let (status, reply) = roundtrip( + HeaderMap::new(), + json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }), + ) + .await; + assert_eq!(status, 200); + assert!(reply["error"].is_null(), "{reply}"); + + let mut legacy_header = HeaderMap::new(); + legacy_header.insert("mcp-protocol-version", "2025-06-18".parse().unwrap()); + let (status, reply) = roundtrip( + legacy_header, + json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }), + ) + .await; + assert_eq!(status, 200); + assert!(reply["error"].is_null(), "{reply}"); + + // A legacy revision this build never listed (2025-11-25 was + // skipped entirely) still selects the legacy contract — before + // the modern gate existed this header was ignored outright, and + // growing a 400 here would break every client speaking it. + let mut unlisted_header = HeaderMap::new(); + unlisted_header.insert("mcp-protocol-version", "2025-11-25".parse().unwrap()); + let (status, reply) = roundtrip( + unlisted_header, + json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }), + ) + .await; + assert_eq!(status, 200, "{reply}"); + assert!(reply["error"].is_null(), "{reply}"); + } + + /// "Present but unreadable" is not "absent": a version header that + /// cannot even be read as ASCII is malformed per the spec's server + /// validation — refused with -32020, never quietly ridden into the + /// legacy path as if no header had been sent. + #[tokio::test] + async fn an_unreadable_version_header_is_refused_not_treated_as_absent() { + let mut headers = HeaderMap::new(); + headers.insert( + "mcp-protocol-version", + axum::http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(), + ); + let (status, reply) = roundtrip( + headers, + json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }), + ) + .await; + assert_eq!(status, 400, "{reply}"); + assert_eq!(reply["error"]["code"], -32020, "{reply}"); + } + + /// Every shape of header/body disagreement the spec's server + /// validation names is a 400 with `HeaderMismatch` (-32020): a + /// version header contradicting `_meta`, a declared version whose + /// required header is missing, a missing `Mcp-Method`, and an + /// `Mcp-Name` that does not name the called tool. + #[tokio::test] + async fn header_body_disagreements_are_refused_with_header_mismatch() { + let mut contradicting = modern_headers("tools/list", None); + contradicting.insert("mcp-protocol-version", "2025-11-25".parse().unwrap()); + let disagreements = [ + (contradicting, modern_body("tools/list", json!({}))), + ( + HeaderMap::new(), + modern_body("tools/list", json!({})), // _meta but no headers at all + ), + ( + { + let mut headers = modern_headers("tools/list", None); + headers.remove("mcp-method"); + headers + }, + modern_body("tools/list", json!({})), + ), + ( + // Present but disagreeing with the body's method: a + // different arm from the missing-header case above. + modern_headers("tools/call", Some("list_contexts")), + modern_body( + "tools/list", + json!({ "name": "list_contexts", "arguments": {} }), + ), + ), + ( + modern_headers("tools/call", Some("delete_context")), + modern_body( + "tools/call", + json!({ "name": "list_contexts", "arguments": {} }), + ), + ), + ( + modern_headers("tools/call", None), // Mcp-Name required for tools/call + modern_body( + "tools/call", + json!({ "name": "list_contexts", "arguments": {} }), + ), + ), + ]; + for (headers, body) in disagreements { + let (status, reply) = roundtrip(headers, body.clone()).await; + assert_eq!(status, 400, "{body} → {reply}"); + assert_eq!(reply["error"]["code"], -32020, "{body} → {reply}"); + } + } + + /// A version this build does not implement, DECLARED in `_meta`, + /// earns 400 with `UnsupportedProtocolVersionError` and the list to + /// retry from. The header alone triggers no such refusal: without + /// `_meta` the request is a legacy one whatever the header names + /// (see `legacy_requests_are_untouched_by_the_modern_gate`) — only + /// a client that actually speaks the stateless era, proven by the + /// `_meta` field, can be asked to retry from `data.supported`. + #[tokio::test] + async fn an_unimplemented_version_is_refused_with_the_supported_list() { + let mut headers = HeaderMap::new(); + headers.insert("mcp-protocol-version", "2099-01-01".parse().unwrap()); + headers.insert("mcp-method", "tools/list".parse().unwrap()); + let mut body = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }); + body["params"]["_meta"] = + json!({ "io.modelcontextprotocol/protocolVersion": "2099-01-01" }); + let (status, reply) = roundtrip(headers, body).await; + assert_eq!(status, 400); + assert_eq!(reply["error"]["code"], -32022, "{reply}"); + assert_eq!( + reply["error"]["data"]["supported"][0], "2026-07-28", + "{reply}" + ); + assert_eq!(reply["error"]["data"]["requested"], "2099-01-01", "{reply}"); + + let mut header_only = HeaderMap::new(); + header_only.insert("mcp-protocol-version", "2099-01-01".parse().unwrap()); + let (status, reply) = roundtrip( + header_only, + json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }), + ) + .await; + assert_eq!(status, 200, "{reply}"); + assert!(reply["error"].is_null(), "{reply}"); + } + + /// 2026-07-28 pins an unimplemented RPC to HTTP 404 (the JSON-RPC + /// body tells it apart from a server with no /mcp at all); the + /// legacy era keeps its 200 envelope for the same -32601. + #[tokio::test] + async fn an_unknown_method_is_404_modern_but_200_legacy() { + let (status, reply) = roundtrip( + modern_headers("subscriptions/listen", None), + modern_body("subscriptions/listen", json!({})), + ) + .await; + assert_eq!(status, 404, "{reply}"); + assert_eq!(reply["error"]["code"], -32601, "{reply}"); + + let (status, reply) = roundtrip( + HeaderMap::new(), + json!({ "jsonrpc": "2.0", "id": 1, "method": "subscriptions/listen" }), + ) + .await; + assert_eq!(status, 200, "{reply}"); + assert_eq!(reply["error"]["code"], -32601, "{reply}"); + } + + /// An `Mcp-Name` wearing the Base64 sentinel must be decoded before + /// comparison — a client is free to encode even a plain-ASCII name. + #[tokio::test] + async fn an_mcp_name_in_base64_sentinel_form_is_decoded_before_comparing() { + // "list_contexts", RFC 4648 standard alphabet with padding. + let (status, reply) = roundtrip( + modern_headers("tools/call", Some("=?base64?bGlzdF9jb250ZXh0cw==?=")), + modern_body( + "tools/call", + json!({ "name": "list_contexts", "arguments": {} }), + ), + ) + .await; + assert_eq!(status, 200, "{reply}"); + assert_ne!(reply["result"]["isError"], json!(true), "{reply}"); + } + + /// The sentinel decoder against the spec's own examples, plus the + /// shapes it must refuse. + #[test] + fn decode_sentinel_matches_the_spec_examples() { + assert_eq!(decode_sentinel("us-west1"), Ok("us-west1".to_string())); + assert_eq!( + decode_sentinel("=?base64?SGVsbG8sIOS4lueVjA==?="), + Ok("Hello, 世界".to_string()) + ); + assert_eq!( + decode_sentinel("=?base64?IHBhZGRlZCA=?="), + Ok(" padded ".to_string()) + ); + assert_eq!( + decode_sentinel("=?base64?bGluZTEKbGluZTI=?="), + Ok("line1\nline2".to_string()) + ); + // A sentinel wrapping a doubly-encoded literal decodes to the + // literal itself. + assert_eq!( + decode_sentinel("=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="), + Ok("=?base64?literal?=".to_string()) + ); + // Not base64 inside the sentinel, and a length no valid base64 + // has: refused, not passed through as if literal. + assert_eq!(decode_sentinel("=?base64?!!!?="), Err(())); + assert_eq!(decode_sentinel("=?base64?A?="), Err(())); + } + /// An id of a disallowed JSON-RPC type (object/array/bool) must come /// back as -32600 with a null id, per the spec's own rule for a /// reply whose id could not be established — never echoed back @@ -754,6 +1281,7 @@ mod tests { Arc::new(String::new()), None, None, + HeaderMap::new(), Bytes::from(body.to_string()), usize::MAX, Deadline::unbounded(), diff --git a/src/route/server.rs b/src/route/server.rs index 5bcad498..f790b3d5 100644 --- a/src/route/server.rs +++ b/src/route/server.rs @@ -124,10 +124,13 @@ pub(crate) async fn run(config: Option) { reattach_authorization, )); async move { - // Only `initialize` reads the manual — a tool call - // must never spend its budget probing shards for - // text it will not use (with every shard down, the - // probes would eat the whole deadline first). + // Only `initialize` and `server/discover` read the + // manual — both carry it as `instructions` — so only + // they pay the shard probe (once; the result is + // cached). A tool call must never spend its budget + // probing shards for text it will not use (with + // every shard down, the probes would eat the whole + // deadline first). let instructions = if wants_instructions(&body) { state.mcp_instructions(deadline).await } else { @@ -141,6 +144,7 @@ pub(crate) async fn run(config: Option) { instructions, None, None, + headers, body, mcp_max_result_bytes, deadline, @@ -246,9 +250,10 @@ fn routes(state: RouterState) -> Router { })) } -/// Whether an MCP message is an `initialize` — the one method whose -/// reply carries the manual. A cheap peek, not a validation: anything -/// unparseable goes to `remote_mcp::serve` for its own refusal. +/// Whether an MCP message is an `initialize` or a `server/discover` — +/// the two methods whose reply carries the manual (one per era). A +/// cheap peek, not a validation: anything unparseable goes to +/// `remote_mcp::serve` for its own refusal. fn wants_instructions(body: &Bytes) -> bool { serde_json::from_slice::(body) .ok() @@ -256,7 +261,7 @@ fn wants_instructions(body: &Bytes) -> bool { message .get("method") .and_then(Value::as_str) - .map(|method| method == "initialize") + .map(|method| method == "initialize" || method == "server/discover") }) .unwrap_or(false) } diff --git a/tests/fixtures/wire/mcp/assemble_evidence_call.json b/tests/fixtures/wire/mcp/assemble_evidence_call.json index b6daf3e9..37885497 100644 --- a/tests/fixtures/wire/mcp/assemble_evidence_call.json +++ b/tests/fixtures/wire/mcp/assemble_evidence_call.json @@ -8,12 +8,19 @@ ] }, "response": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "taguru", + "version": "0.0.0" + } + }, "content": [ { "text": "{\"result\":{\"budget\":{\"bytes_used\":260,\"items_used\":1,\"limits\":{\"max_bytes\":65536,\"max_items\":40,\"max_tokens\":4000},\"tokens_used\":65},\"citations\":[],\"items\":[{\"association\":{\"attributions\":[],\"count\":1,\"label\":\"rel\",\"object\":\"b\",\"subject\":\"a\",\"weight\":1.0},\"bytes\":256,\"candidate_id\":\"association\\u0000a\\u0000rel\\u0000b\",\"citation_refs\":[],\"estimated_tokens\":64,\"fused_rank\":1,\"kind\":\"association\",\"lane_ranks\":[{\"lane\":\"graph_activate\",\"rank\":1}]}],\"omitted\":[],\"omitted_by_reason\":{},\"omitted_total\":0,\"plan\":{\"lanes\":{\"activate\":{\"ran\":true},\"citations\":{\"ran\":true},\"communities\":{\"ran\":false,\"reason\":\"include_communities was false\"},\"passages\":{\"ran\":true},\"query\":{\"ran\":false,\"reason\":\"no 'labels' given\"},\"resolve\":{\"ran\":true}},\"reranker\":{\"configured\":false,\"ran\":false},\"selection\":{\"contradiction_groups\":0,\"dedup_dropped\":0,\"diversity_tier_width\":10}}},\"status\":\"ok\",\"time\":0.0}", "type": "text" } - ] + ], + "resultType": "complete" }, "route": "tools/call assemble_evidence", "status": 200 diff --git a/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json b/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json index e024cf4c..609417b2 100644 --- a/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json +++ b/tests/fixtures/wire/mcp/assemble_evidence_tool_error.json @@ -5,13 +5,20 @@ "context": "mcp-error-corpus" }, "response": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "taguru", + "version": "0.0.0" + } + }, "content": [ { "text": "missing required argument 'origins'", "type": "text" } ], - "isError": true + "isError": true, + "resultType": "complete" }, "route": "tools/call assemble_evidence", "status": 200 diff --git a/tests/fixtures/wire/shapes.json b/tests/fixtures/wire/shapes.json index 7a29a12d..c5138075 100644 --- a/tests/fixtures/wire/shapes.json +++ b/tests/fixtures/wire/shapes.json @@ -17,6 +17,7 @@ "/contexts/{name}/evidence": ["origins"] }, "enums": { + "response.resultType": ["complete"], "response.code": [ "malformed_request", "invalid_argument",