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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -870,7 +870,16 @@ than launching a process. Configuration:
| `GPIB_MCP_HTTP_TOKEN` | *(none)* | if set, every request must send `Authorization: Bearer <token>` |

`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,
Expand Down
125 changes: 117 additions & 8 deletions src/GpibMcp.Http/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<JObject>().ToList()
: new System.Collections.Generic.List<JObject> { 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)
{
Expand All @@ -160,6 +178,93 @@ private void HandlePost(HttpListenerRequest req, HttpListenerResponse res, IMcpD
Respond(res, 200, "application/json", payload.ToString(Formatting.None));
}

/// <summary>
/// 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 <c>HeaderMismatch</c> (-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.
/// </summary>
/// <returns>A JSON-RPC error response to send with 400, or null when the request is acceptable.</returns>
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;
}

/// <summary>
/// Undoes the <c>=?base64?…?=</c> 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.
/// </summary>
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"];
Expand All @@ -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)
Expand Down
165 changes: 165 additions & 0 deletions tests/GpibMcp.Tests/HttpTransportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpRequestMessage> 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()
{
Expand Down
Loading