Align x-mcp-header implementation with SEP-2243 spec clarifications (#1619)

Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
Tarek Mahmoud Sayed 2026-06-05 15:45:08 -07:00 • committed by GitHub
commit ed19286a08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -54,7 +54,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
// Create a tool with x-mcp-header annotations in the schema.
// We set InputSchema directly because TransformSchemaNode doesn't provide
// property-level path context for lambda-based tool creation.
private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool()];
private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool(), CreateUnionHeaderTestTool()];
private static readonly JsonSerializerOptions s_reflectionOptions = new()
{
@ -65,7 +65,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
{
var tool = McpServerTool.Create(
[McpServerTool(Name = "header_test")]
static (string region, int priority, bool verbose, string emptyVal) =>
static (string region, long priority, bool verbose, string emptyVal) =>
$"region={region},priority={priority},verbose={verbose},empty={emptyVal}",
new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
@ -86,8 +86,93 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
return tool;
}
// A tool whose integer header parameter uses a JSON Schema union type (["integer", "null"]).
private static McpServerTool CreateUnionHeaderTestTool()
{
var tool = McpServerTool.Create(
[McpServerTool(Name = "union_test")]
static (long priority) => $"priority={priority}",
new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
using var doc = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"priority": { "type": ["integer", "null"], "x-mcp-header": "Priority" }
},
"required": ["priority"]
}
""");
tool.ProtocolTool.InputSchema = doc.RootElement.Clone();
return tool;
}
#region Server-side validation tests
[Fact]
public async Task Server_AcceptsUnionIntegerCanonicalForm()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body
// carries the decimal form 42.0. The server must treat the union type as integer and match.
var callJson = CallTool("union_test", """{"priority":42.0}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "union_test");
request.Headers.Add("Mcp-Param-Priority", "42");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_RejectsUnionIntegerOutsideSafeRange()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
var callJson = CallTool("union_test", """{"priority":9007199254740993}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "union_test");
request.Headers.Add("Mcp-Param-Priority", "9007199254740993");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Server_AcceptsExponentBodyMatchingDecimalHeader()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100".
var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", "100");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
{
@ -209,15 +294,15 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
}
[Fact]
public async Task Server_AcceptsLargeIntegerWithFullPrecision()
public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Use a large integer that would lose precision if converted through double
// 2^53 + 1 = 9007199254740993 (cannot be represented exactly as double)
const long largeInt = 9007199254740993L;
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{largeInt}},"verbose":false,"emptyVal":""}""");
// The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without
// losing precision through a double conversion.
const long maxSafeInt = 9007199254740991L;
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{maxSafeInt}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
@ -225,7 +310,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", largeInt.ToString());
request.Headers.Add("Mcp-Param-Priority", maxSafeInt.ToString());
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
@ -234,15 +319,47 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
}
[Theory]
[InlineData("42", 42)] // "42" header vs 42 body → exact integer match
[InlineData("42.0", 42)] // "42.0" header vs 42 body → numeric equivalence
[InlineData("42", 42.0)] // "42" header vs 42.0 body → numeric equivalence
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, double bodyValue)
[InlineData("9007199254740993")] // 2^53 + 1, just outside the safe range
[InlineData("-9007199254740993")] // -(2^53 + 1), just outside the safe range
[InlineData("100000000000000000000000000000000000000")] // far beyond decimal range
[InlineData("1e100")] // exponent form far beyond the safe range
public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
var callJson = CallTool("header_test", $$$"""{"region":"test","priority":{{{bodyValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"verbose":false,"emptyVal":""}""");
// Per SEP-2243 integer values MUST be within the JavaScript safe integer range.
// A matching header and body that are both outside the range must still be rejected.
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{outOfRangeValue}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", outOfRangeValue);
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Theory]
[InlineData("42", "42")] // "42" header vs 42 body -> exact integer match
[InlineData("42.0", "42")] // "42.0" header vs 42 body -> numeric equivalence
[InlineData("42", "42.0")] // "42" header vs 42.0 body (decimal form from another SDK) -> numeric equivalence
[InlineData("42", "4.2e1")] // "42" header vs 4.2e1 body (exponent form) -> numeric equivalence
[InlineData("420e-1", "42")] // "420e-1" header vs 42 body -> numeric equivalence
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and
// "4.2e1" are preserved in the body exactly as another SDK might serialize them.
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{bodyValue}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
@ -258,6 +375,34 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Theory]
[InlineData("42.5")] // fractional value for an integer parameter
[InlineData("12e-1")] // 1.2 in exponent form
[InlineData("42.0000000000000000000000000001")] // high-precision fraction that decimal would round to 42
public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// For an integer-typed parameter a non-whole numeric value is invalid and must be rejected
// even when the header and body strings are byte-for-byte identical (it must not slip through
// the ordinal comparison).
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{nonIntegerValue}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", nonIntegerValue);
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
{