From fcb98fdfb00fe6f2b7f6610ab482f2932870aa64 Mon Sep 17 00:00:00 2001 From: Tony Goodhew Date: Fri, 31 Jul 2026 09:03:45 -0700 Subject: [PATCH] Validate the Streamable HTTP request-metadata headers (#110) SEP-2243 mirrors a few body fields into HTTP headers so an intermediary can route without parsing JSON. That only holds if the two agree - otherwise a load balancer and this server act on different requests - so a disagreement is HeaderMismatch (-32020) with a 400. Mcp-Method must equal the body method; Mcp-Name must equal params.name or params.uri, decoded first from the =?base64?...?= sentinel, or a tool named in anything but ASCII would look like a mismatch against its own body; MCP-Protocol-Version must equal what _meta declares. Enforcement is two-speed, like the rest of this revision: a request declaring 2026-07-28 MUST carry them, while a 2025-06-18 client - which is every HTTP client we serve today - never sent them and is not asked to start. What holds in both cases is that a header which IS present must be true. The rest of the issues list: - GET and DELETE both answer 405 now. The GET stream became subscriptions/listen and DELETE tore down a session that no longer exists in the protocol - and never existed here, since no session id was ever minted. Nothing can break: a client that never received one has nothing to tear down. - CORS drops Mcp-Session-Id and allows Mcp-Method/Mcp-Name. - Mcp-Session-Id and Last-Event-ID are ignored, with a test proving no session id is ever echoed back. - Batch arrays: kept. Batching left MCP in 2025-06-18 and nothing we serve sends one, but accepting it costs nothing and refusing would help nobody. The metadata headers describe ONE message, so a batch skips that validation - there is no meaningful Mcp-Method for an array of different methods. - x-mcp-header (custom headers from tool parameters) deliberately not implemented: it is optional for servers, and none of our tools would gain from having a parameter mirrored into a header for routing. 10 tests. --- README.md | 11 +- src/GpibMcp.Http/HttpTransport.cs | 125 ++++++++++++++-- tests/GpibMcp.Tests/HttpTransportTests.cs | 165 ++++++++++++++++++++++ 3 files changed, 292 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1fb96cf..fd12c2f 100644 --- a/README.md +++ b/README.md @@ -870,7 +870,16 @@ than launching a process. Configuration: | `GPIB_MCP_HTTP_TOKEN` | *(none)* | if set, every request must send `Authorization: Bearer ` | `POST /mcp` carries one JSON-RPC message or a batch and returns the response as `application/json` (202 when -the POST held only notifications). The server initiates no messages, so the `GET` SSE stream is not offered. +the POST held only notifications). `GET` and `DELETE` both return **405**: the standalone SSE stream became +`subscriptions/listen`, and session teardown no longer exists in the protocol — this server never minted a +session id, so there was never anything to tear down. `Mcp-Session-Id` and `Last-Event-ID` are ignored. + +The **request-metadata headers** (`Mcp-Method`, `Mcp-Name`, `MCP-Protocol-Version`) are validated against the +body: they exist so an intermediary can route without parsing JSON, which only holds if the two agree, so a +disagreement is rejected with `400` and `HeaderMismatch` (-32020). Enforcement is two-speed, like the rest of +the 2026-07-28 work — a request declaring that revision **must** carry them; a 2025-06-18 client, which is +every HTTP client today, never sent them and isn't asked to start. A header that *is* present must be true +either way. `Mcp-Name` is decoded from the `=?base64?…?=` sentinel before comparison. Security: it binds loopback and rejects non-loopback `Origin` headers (DNS-rebinding guard). Since the server must run next to the GPIB hardware, reaching it from a cloud assistant means **tunnelling** it (dev tunnel / ngrok) — set `GPIB_MCP_HTTP_TOKEN` (and ideally your tunnel's own auth) when you do. Requests are serialized, diff --git a/src/GpibMcp.Http/HttpTransport.cs b/src/GpibMcp.Http/HttpTransport.cs index 437c48f..b077681 100644 --- a/src/GpibMcp.Http/HttpTransport.cs +++ b/src/GpibMcp.Http/HttpTransport.cs @@ -101,12 +101,15 @@ private void HandleContext(HttpListenerContext ctx, IMcpDispatcher dispatcher) HandlePost(req, res, dispatcher); break; case "GET": - // No server-initiated messages, so no SSE stream to open. - Respond(res, 405, "text/plain", "the GET event stream is not supported"); - break; case "DELETE": - // Sessionless: nothing to terminate. - Respond(res, 200, "text/plain", "ok"); + // Both belonged to mechanisms 2026-07-28 removed: the standalone SSE stream (now + // subscriptions/listen) and session teardown (there are no sessions). 405 is what the + // spec tells a server to answer, and it is equally right for an older client here - + // we have never minted a session id, so there was never anything to tear down (#110). + Respond(res, 405, "text/plain", + req.HttpMethod == "GET" + ? "the GET event stream is not supported; use subscriptions/listen" + : "sessionless: there is nothing to terminate"); break; default: Respond(res, 405, "text/plain", "method not allowed"); @@ -138,9 +141,24 @@ private void HandlePost(HttpListenerRequest req, HttpListenerResponse res, IMcpD return; } - // A single JSON-RPC message or a batch array. + // A single JSON-RPC message, or a batch array. Batching left MCP in 2025-06-18 and no client we + // serve sends one; accepting it anyway costs nothing and refusing it would help nobody. The + // request-metadata headers describe ONE message, so they are only checked for a single body - + // there is no meaningful Mcp-Method for an array of different methods (#110). + bool isBatch = parsed is JArray; var messages = parsed is JArray arr ? arr.OfType().ToList() : new System.Collections.Generic.List { parsed as JObject }; + + if (!isBatch && messages.Count == 1 && messages[0] != null) + { + JObject headerError = ValidateRequestMetadata(req, messages[0]); + if (headerError != null) + { + Respond(res, 400, "application/json", headerError.ToString(Formatting.None)); + return; + } + } + var responses = new JArray(); foreach (var m in messages) { @@ -160,6 +178,93 @@ private void HandlePost(HttpListenerRequest req, HttpListenerResponse res, IMcpD Respond(res, 200, "application/json", payload.ToString(Formatting.None)); } + /// + /// Checks the request-metadata headers against the body (#110, SEP-2243). The transport mirrors a few + /// body fields into headers so an intermediary can route without parsing JSON - which only holds if + /// the two agree. Where they disagree, a load balancer and the server would be acting on different + /// requests, so the spec makes that HeaderMismatch (-32020) with a 400. + /// + /// Two-speed enforcement, for the same reason the rest of this revision is gated: the headers are + /// REQUIRED from 2026-07-28, so a request declaring that revision must carry them, while a + /// 2025-06-18 client - which is every client we serve over HTTP today - never sent them and is not + /// asked to start. What is checked in both cases is agreement: a header that is present must be true. + /// + /// A JSON-RPC error response to send with 400, or null when the request is acceptable. + internal static JObject ValidateRequestMetadata(HttpListenerRequest req, JObject message) + { + string method = (string)message["method"]; + if (method == null) return null; // a response, not a request: no metadata to mirror + + var prms = message["params"] as JObject; + var meta = prms != null ? prms["_meta"] as JObject : null; + string declaredVersion = meta != null ? (string)meta[RequestContext.ProtocolVersionKey] : null; + bool required = declaredVersion != null && + string.CompareOrdinal(declaredVersion, RequestContext.StatelessRevision) >= 0; + + JToken id = message["id"]; + + // MCP-Protocol-Version: must agree with what the body declares. + string versionHeader = req.Headers["MCP-Protocol-Version"]; + if (versionHeader != null && declaredVersion != null && versionHeader != declaredVersion) + return Mismatch(id, "MCP-Protocol-Version", declaredVersion, versionHeader); + if (required && versionHeader == null) + return Missing(id, "MCP-Protocol-Version"); + + // Mcp-Method: the body's method, on every request. + string methodHeader = req.Headers["Mcp-Method"]; + if (methodHeader != null && methodHeader != method) + return Mismatch(id, "Mcp-Method", method, methodHeader); + if (required && methodHeader == null) + return Missing(id, "Mcp-Method"); + + // Mcp-Name: params.name (tools/call) or params.uri (resources/read, prompts/get). + string expectedName = prms == null ? null : ((string)prms["name"] ?? (string)prms["uri"]); + string nameHeader = DecodeHeaderValue(req.Headers["Mcp-Name"]); + if (nameHeader != null && expectedName != null && nameHeader != expectedName) + return Mismatch(id, "Mcp-Name", expectedName, nameHeader); + if (required && expectedName != null && nameHeader == null) + return Missing(id, "Mcp-Name"); + + return null; + } + + /// + /// Undoes the =?base64?…?= sentinel a client uses for a value that cannot travel as plain + /// ASCII. Servers MUST decode before comparing, or a tool named in anything but ASCII would look + /// like a mismatch against its own body. + /// + private static string DecodeHeaderValue(string value) + { + const string prefix = "=?base64?", suffix = "?="; + if (value == null || !value.StartsWith(prefix, StringComparison.Ordinal) || + !value.EndsWith(suffix, StringComparison.Ordinal)) + return value; + + string encoded = value.Substring(prefix.Length, value.Length - prefix.Length - suffix.Length); + try { return Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); } + catch (FormatException) { return value; } // not decodable: compare it as-is and let it mismatch + } + + private static JObject Mismatch(JToken id, string header, string expected, string actual) => + ErrorResponse(id, McpError.HeaderMismatch(header, expected, actual)); + + private static JObject Missing(JToken id, string header) => + ErrorResponse(id, new McpError(McpError.HeaderMismatchCode, + "Missing required header '" + header + "'", + new JObject { ["header"] = header })); + + private static JObject ErrorResponse(JToken id, McpError error) + { + var body = new JObject { ["code"] = error.Code, ["message"] = error.Message }; + if (error.ErrorData != null) body["data"] = error.ErrorData; + return new JObject + { + ["jsonrpc"] = "2.0", + ["id"] = id ?? JValue.CreateNull(), + ["error"] = body + }; + } + private bool IsAuthorized(HttpListenerRequest req) { string auth = req.Headers["Authorization"]; @@ -178,8 +283,12 @@ private static void AddCors(HttpListenerRequest req, HttpListenerResponse res) { string origin = req.Headers["Origin"]; res.AddHeader("Access-Control-Allow-Origin", string.IsNullOrEmpty(origin) ? "*" : origin); - res.AddHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS"); - res.AddHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept"); + res.AddHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + // Mcp-Session-Id is gone with sessions; Mcp-Method/Mcp-Name are the request metadata a + // 2026-07-28 client mirrors from the body, so a browser-origin client must be allowed to send + // them (#110). + res.AddHeader("Access-Control-Allow-Headers", + "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Accept"); } private static void Respond(HttpListenerResponse res, int status, string contentType, string body) diff --git a/tests/GpibMcp.Tests/HttpTransportTests.cs b/tests/GpibMcp.Tests/HttpTransportTests.cs index d704186..086f7d6 100644 --- a/tests/GpibMcp.Tests/HttpTransportTests.cs +++ b/tests/GpibMcp.Tests/HttpTransportTests.cs @@ -86,6 +86,171 @@ public async Task Post_Initialize_ReturnsServerInfo() } } + // ---- request metadata headers (#110, SEP-2243) -------------------------- + + private const string StatelessCall = + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"visa_list_resources\"," + + "\"arguments\":{},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\"}}}"; + + private const string LegacyCall = + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"visa_list_resources\",\"arguments\":{}}}"; + + private static Action Headers(params string[] nameValuePairs) => msg => + { + for (int i = 0; i + 1 < nameValuePairs.Length; i += 2) + msg.Headers.TryAddWithoutValidation(nameValuePairs[i], nameValuePairs[i + 1]); + }; + + [Fact] + public async Task Headers_ThatDisagreeWithTheBody_AreRejected() + { + // The headers exist so an intermediary can route without parsing the body. If the two disagree, + // a load balancer and this server would be acting on different requests. + using (var h = new Harness()) + { + var resp = await Post(h.Url, LegacyCall, Headers("Mcp-Method", "tools/list")); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + + var json = JObject.Parse(await resp.Content.ReadAsStringAsync()); + Assert.Equal(McpError.HeaderMismatchCode, (int)json["error"]["code"]); + Assert.Equal(9, (int)json["id"]); + } + } + + [Fact] + public async Task McpName_ThatDisagreesWithTheToolBeingCalled_IsRejected() + { + using (var h = new Harness()) + { + var resp = await Post(h.Url, LegacyCall, + Headers("Mcp-Method", "tools/call", "Mcp-Name", "some_other_tool")); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var json = JObject.Parse(await resp.Content.ReadAsStringAsync()); + Assert.Equal(McpError.HeaderMismatchCode, (int)json["error"]["code"]); + } + } + + [Fact] + public async Task McpName_IsDecodedFromTheBase64Sentinel_BeforeComparing() + { + // A name that cannot travel as plain ASCII arrives encoded; comparing it raw would reject a + // request that is perfectly correct. + string encoded = "=?base64?" + + Convert.ToBase64String(Encoding.UTF8.GetBytes("visa_list_resources")) + "?="; + + using (var h = new Harness()) + { + var resp = await Post(h.Url, LegacyCall, Headers("Mcp-Method", "tools/call", "Mcp-Name", encoded)); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + } + + [Fact] + public async Task HeadersThatAgree_AreAccepted() + { + using (var h = new Harness()) + { + var resp = await Post(h.Url, LegacyCall, + Headers("Mcp-Method", "tools/call", "Mcp-Name", "visa_list_resources")); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + } + + [Fact] + public async Task AClientOnTheOlderRevision_IsNotAskedForHeadersItNeverSent() + { + // Every client we serve over HTTP today speaks 2025-06-18 and sends none of these. + using (var h = new Harness()) + { + var resp = await Post(h.Url, LegacyCall); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + } + + [Fact] + public async Task AClientOnTheNewRevision_MustSendTheRequiredHeaders() + { + using (var h = new Harness()) + { + var missing = await Post(h.Url, StatelessCall); + Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); + Assert.Equal(McpError.HeaderMismatchCode, + (int)JObject.Parse(await missing.Content.ReadAsStringAsync())["error"]["code"]); + + var complete = await Post(h.Url, StatelessCall, Headers( + "MCP-Protocol-Version", "2026-07-28", + "Mcp-Method", "tools/call", + "Mcp-Name", "visa_list_resources")); + Assert.Equal(HttpStatusCode.OK, complete.StatusCode); + } + } + + [Fact] + public async Task AProtocolVersionHeaderThatContradictsTheBody_IsRejected() + { + using (var h = new Harness()) + { + var resp = await Post(h.Url, StatelessCall, Headers( + "MCP-Protocol-Version", "2025-06-18", + "Mcp-Method", "tools/call", + "Mcp-Name", "visa_list_resources")); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var json = JObject.Parse(await resp.Content.ReadAsStringAsync()); + Assert.Equal(McpError.HeaderMismatchCode, (int)json["error"]["code"]); + Assert.Equal("MCP-Protocol-Version", (string)json["error"]["data"]["header"]); + } + } + + [Fact] + public async Task StaleSessionHeaders_AreIgnoredRatherThanHonoured() + { + // Sessions and stream resumability are both gone: neither header may change anything, and we + // must never mint or echo a session id. + using (var h = new Harness()) + { + var resp = await Post(h.Url, LegacyCall, + Headers("Mcp-Session-Id", "stale-session", "Last-Event-ID", "42")); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.False(resp.Headers.Contains("Mcp-Session-Id")); + } + } + + [Fact] + public async Task GetAndDelete_AreBothMethodNotAllowed() + { + // The GET stream became subscriptions/listen and DELETE tore down a session that no longer + // exists - and never did here, since no session id was ever issued. + using (var h = new Harness()) + using (var client = new HttpClient()) + { + await Post(h.Url, LegacyCall); // wait for the listener + + var get = await client.GetAsync(h.Url); + var del = await client.DeleteAsync(h.Url); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, del.StatusCode); + } + } + + [Fact] + public async Task ABatchIsStillAccepted_AndSkipsHeaderValidation() + { + // Batching left MCP in 2025-06-18; accepting one costs nothing. The metadata headers describe a + // single message, so there is nothing meaningful to validate them against here. + using (var h = new Harness()) + { + var batch = "[" + LegacyCall + "," + + "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/list\"}]"; + var resp = await Post(h.Url, batch, Headers("Mcp-Method", "tools/call")); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal(2, JArray.Parse(await resp.Content.ReadAsStringAsync()).Count); + } + } + [Fact] public async Task Post_ToolsCall_RunsTheTool() {