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
6 changed files with 1035 additions and 116 deletions

View file

@ -169,6 +169,78 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
Assert.Equal("42", headers["Mcp-Param-Priority"]);
}
[Theory]
[InlineData("42.0", "42")] // decimal body form canonicalized
[InlineData("-7.00", "-7")] // trailing zeros canonicalized
[InlineData("-0.0", "0")] // negative zero canonicalized
[InlineData("4.2e1", "42")] // exponent body form canonicalized
[InlineData("9007199254740991", "9007199254740991")] // max safe integer preserved exactly
[InlineData("-9007199254740991", "-9007199254740991")] // min safe integer preserved exactly
public async Task CallTool_EmitsCanonicalIntegerHeader(string bodyValue, string expectedHeader)
{
await StartAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
client.AddKnownTools([CreateToolWithHeaders()]);
// Pass the raw JSON number so the body retains the exact form under test.
var result = await client.CallToolAsync(
"my_tool",
new Dictionary<string, object?>
{
["region"] = "us-west-2",
["priority"] = JsonDocument.Parse(bodyValue).RootElement,
},
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
var headers = _capturedHeaders.Values.First();
Assert.Equal(expectedHeader, headers["Mcp-Param-Priority"]);
}
[Theory]
[InlineData("9007199254740993")] // 2^53 + 1, above the safe range
[InlineData("-9007199254740993")] // -(2^53 + 1), below the safe range
[InlineData("42.5")] // not a whole number
[InlineData("12e-1")] // 1.2 in exponent form, not a whole number
[InlineData("42.0000000000000000000000000001")] // high-precision fraction (decimal would round this to 42)
public async Task CallTool_ThrowsForInvalidIntegerHeaderValue(string bodyValue)
{
await StartAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
client.AddKnownTools([CreateToolWithHeaders()]);
// Values outside the JavaScript safe integer range (or non-integral) must be rejected
// before the request is sent.
await Assert.ThrowsAsync<McpException>(async () => await client.CallToolAsync(
"my_tool",
new Dictionary<string, object?>
{
["region"] = "us-west-2",
["priority"] = JsonDocument.Parse(bodyValue).RootElement,
},
cancellationToken: TestContext.Current.CancellationToken));
Assert.Empty(_capturedHeaders);
}
[Fact]
public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders()
{

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()
{

View file

@ -59,10 +59,9 @@ public class McpHeaderEncoderTests
[Theory]
[InlineData(42, "42")]
[InlineData(3.14, "3.14")]
[InlineData(0, "0")]
[InlineData(-1, "-1")]
public void EncodeValue_Number_ConvertsToString(object input, string expected)
public void EncodeValue_Integer_ConvertsToString(object input, string expected)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(expected, result);
@ -106,10 +105,12 @@ public class McpHeaderEncoderTests
}
[Fact]
public void DecodeValue_CaseInsensitivePrefix_Decodes()
public void DecodeValue_CaseSensitivePrefix_ReturnsLiteralValue()
{
// Per SEP-2243: sentinel markers are case-sensitive and MUST appear exactly as shown (lowercase).
// An uppercase prefix should NOT be decoded as base64.
var result = McpHeaderEncoder.DecodeValue("=?BASE64?SGVsbG8=?=");
Assert.Equal("Hello", result);
Assert.Equal("=?BASE64?SGVsbG8=?=", result);
}
[Fact]
@ -119,6 +120,15 @@ public class McpHeaderEncoderTests
Assert.Null(result);
}
[Fact]
public void DecodeValue_ValidBase64ButInvalidUtf8_ReturnsNull()
{
// "//4=" is valid Base64 that decodes to the bytes 0xFF 0xFE, which are not valid UTF-8.
// A strict decoder must reject this rather than substituting U+FFFD replacement characters.
var result = McpHeaderEncoder.DecodeValue("=?base64?//4=?=");
Assert.Null(result);
}
[Fact]
public void DecodeValue_MissingPrefix_ReturnsLiteralValue()
{
@ -160,4 +170,34 @@ public class McpHeaderEncoderTests
var decoded = McpHeaderEncoder.DecodeValue(result);
Assert.Equal("col1\tcol2", decoded);
}
[Theory]
[InlineData("=?base64?literal?=")]
[InlineData("=?base64?SGVsbG8=?=")]
[InlineData("=?base64??=")]
public void EncodeValue_SentinelCollision_Base64Encodes(string input)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.NotNull(result);
Assert.StartsWith("=?base64?", result);
Assert.EndsWith("?=", result);
// The encoded value must be different from the input to avoid ambiguity
Assert.NotEqual(input, result);
// Verify round-trip: decode must recover the original literal value
var decoded = McpHeaderEncoder.DecodeValue(result);
Assert.Equal(input, decoded);
}
[Theory]
[InlineData("=?BASE64?literal?=")] // Case-sensitive: uppercase prefix does not match sentinel
[InlineData("=?base64?start")] // Missing suffix: no sentinel match
[InlineData("end?=")] // Missing prefix: no sentinel match
[InlineData("plain-text")] // No sentinel pattern
public void EncodeValue_NonSentinelPattern_NotBase64Encoded(string input)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(input, result);
}
}

View file

@ -0,0 +1,218 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Client;
/// <summary>
/// Tests for SEP-2243 x-mcp-header validation changes:
/// - RFC 9110 tchar validation for header names
/// - "number" type rejection (only integer/string/boolean allowed)
/// - Nested property support for x-mcp-header annotations
/// </summary>
public class McpHeaderExtractorValidationTests : ClientServerTestBase
{
public McpHeaderExtractorValidationTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
// Valid baseline tool
mcpServerBuilder.WithTools([McpServerTool.Create(
(string input) => $"echo {input}",
new() { Name = "ValidTool" })]);
// Tool with "number" type (should be rejected per updated SEP-2243)
var numberTool = McpServerTool.Create((string x) => x, new() { Name = "NumberTypeTool" });
numberTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "value": { "type": "number", "x-mcp-header": "Value" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([numberTool]);
// Tool with "integer" type (should be accepted)
var integerTool = McpServerTool.Create((string x) => x, new() { Name = "IntegerTypeTool" });
integerTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "count": { "type": "integer", "x-mcp-header": "Count" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([integerTool]);
// Tool with non-tchar header name (should be rejected)
var nonTcharTool = McpServerTool.Create((string x) => x, new() { Name = "BadTcharTool" });
nonTcharTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Region(1)" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([nonTcharTool]);
// Tool with valid nested x-mcp-header
var nestedValidTool = McpServerTool.Create((string x) => x, new() { Name = "NestedValidTool" });
nestedValidTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "config": { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([nestedValidTool]);
// Tool with invalid nested x-mcp-header (colon in header name)
var nestedInvalidTool = McpServerTool.Create((string x) => x, new() { Name = "NestedInvalidTool" });
nestedInvalidTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "config": { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Invalid:Header" } } } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([nestedInvalidTool]);
// Tool with duplicate header names across nesting levels
var duplicateTool = McpServerTool.Create((string x) => x, new() { Name = "DuplicateHeaderTool" });
duplicateTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "topRegion": { "type": "string", "x-mcp-header": "Region" }, "nested": { "type": "object", "properties": { "innerRegion": { "type": "string", "x-mcp-header": "region" } } } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([duplicateTool]);
// Tool with nested "number" type (should be rejected)
var nestedNumberTool = McpServerTool.Create((string x) => x, new() { Name = "NestedNumberTool" });
nestedNumberTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "config": { "type": "object", "properties": { "threshold": { "type": "number", "x-mcp-header": "Threshold" } } } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([nestedNumberTool]);
// Tool with a nullable union type ["string", "null"] (should be accepted)
var nullableUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NullableUnionTool" });
nullableUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "region": { "type": ["string", "null"], "x-mcp-header": "Region" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([nullableUnionTool]);
// Tool with a union type containing a disallowed type ["number", "null"] (should be rejected)
var numberUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NumberUnionTool" });
numberUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "value": { "type": ["number", "null"], "x-mcp-header": "Value" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([numberUnionTool]);
// Tool with a "null"-only union type (should be rejected: no allowed primitive present)
var nullOnlyTool = McpServerTool.Create((string x) => x, new() { Name = "NullOnlyTool" });
nullOnlyTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "value": { "type": ["null"], "x-mcp-header": "Value" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([nullOnlyTool]);
// Tool whose annotated property omits "type" (should be accepted: type is unknown, not invalid)
var missingTypeTool = McpServerTool.Create((string x) => x, new() { Name = "MissingTypeTool" });
missingTypeTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
{ "type": "object", "properties": { "region": { "x-mcp-header": "Region" } } }
""").RootElement.Clone();
mcpServerBuilder.WithTools([missingTypeTool]);
}
[Fact]
public async Task ListToolsAsync_NumberType_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "NumberTypeTool");
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Message.Contains("NumberTypeTool") &&
log.Message.Contains("excluded"));
}
[Fact]
public async Task ListToolsAsync_IntegerType_AcceptsTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "IntegerTypeTool");
}
[Fact]
public async Task ListToolsAsync_NonTcharHeaderName_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "BadTcharTool");
}
[Fact]
public async Task ListToolsAsync_NestedValidHeader_AcceptsTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "NestedValidTool");
}
[Fact]
public async Task ListToolsAsync_NestedInvalidHeader_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "NestedInvalidTool");
}
[Fact]
public async Task ListToolsAsync_NestedDuplicateHeaders_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "DuplicateHeaderTool");
}
[Fact]
public async Task ListToolsAsync_NestedNumberType_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "NestedNumberTool");
}
[Fact]
public async Task ListToolsAsync_NullableUnionType_AcceptsTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "NullableUnionTool");
}
[Fact]
public async Task ListToolsAsync_NumberUnionType_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "NumberUnionTool");
}
[Fact]
public async Task ListToolsAsync_NullOnlyUnionType_ExcludesTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "ValidTool");
Assert.DoesNotContain(tools, t => t.Name == "NullOnlyTool");
}
[Fact]
public async Task ListToolsAsync_MissingType_AcceptsTool()
{
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "MissingTypeTool");
}
}

View file

@ -9,6 +9,9 @@ public class McpHeaderAttributeTests
[InlineData("TenantId")]
[InlineData("Priority")]
[InlineData("X-Custom")]
[InlineData("x!header")]
[InlineData("x#header")]
[InlineData("x~header")]
public void Constructor_ValidHeaderName_Succeeds(string name)
{
var attr = new McpHeaderAttribute(name);
@ -27,6 +30,23 @@ public class McpHeaderAttributeTests
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute("Region:Primary"));
}
[Theory]
[InlineData("Region(1)")]
[InlineData("path/to")]
[InlineData("key=value")]
[InlineData("name@host")]
[InlineData("with,comma")]
[InlineData("with;semi")]
[InlineData("with[bracket")]
[InlineData("with{brace")]
[InlineData("with\"quote")]
[InlineData("with\\backslash")]
[InlineData("with?question")]
public void Constructor_NonTcharCharacter_Throws(string name)
{
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute(name));
}
[Fact]
public void Constructor_NullName_Throws()
{

View file

@ -1221,6 +1221,15 @@ public partial class McpServerToolTests
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNonPrimitiveHeader))!));
}
[Fact]
public void Create_WithMcpHeaderOnUInt64Type_ThrowsInvalidOperationException()
{
// ulong is excluded per SEP-2243 because its domain extends beyond the JavaScript safe
// integer range (and beyond long), so it cannot be represented as a signed integer header.
Assert.Throws<InvalidOperationException>(() =>
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithUInt64Header))!));
}
[Fact]
public void Create_WithMcpHeaderOnNumericType_AddsExtension()
{
@ -1308,5 +1317,10 @@ public partial class McpServerToolTests
[McpServerTool]
public static string ToolWithoutHeaders(string region, string query)
=> "result";
[McpServerTool]
public static string ToolWithUInt64Header(
[McpHeader("Count")] ulong count)
=> "result";
}
}