diff --git a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs index 99fe5462a..1be48bd6d 100644 --- a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs +++ b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs @@ -267,16 +267,16 @@ private static bool ValidateProperties(Tool tool, JsonElement properties, HashSe return false; } - // MUST only be applied to parameters with primitive types (string, integer, boolean). - // Parameters with type "number" (or any other non-primitive type) are not permitted. - // The "type" keyword may be omitted (treated as unknown, not rejected, since many valid - // schemas constrain the value via enum/const/$ref instead) or expressed as a JSON Schema - // union array such as ["string", "null"]; only an explicitly disallowed or malformed type + // MUST only be applied to parameters with primitive types (number, string, boolean) per + // SEP-2243. We also accept "integer" as a JSON Schema refinement of "number". The "type" + // keyword may be omitted (treated as unknown, not rejected, since many valid schemas + // constrain the value via enum/const/$ref instead) or expressed as a JSON Schema union + // array such as ["string", "null"]; only an explicitly disallowed or malformed type // causes rejection. if (property.Value.TryGetProperty("type", out var typeElement) && !IsAllowedHeaderType(typeElement)) { - rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', and 'boolean' are allowed."; + rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', 'number', and 'boolean' are allowed."; return false; } } @@ -286,10 +286,11 @@ private static bool ValidateProperties(Tool tool, JsonElement properties, HashSe /// /// Determines whether a JSON Schema type keyword is compatible with x-mcp-header, - /// which per SEP-2243 may only be applied to string, integer, or boolean - /// parameters. A union array (e.g., ["string", "null"]) is allowed as long as it contains - /// at least one allowed primitive; "null" is tolerated only as an additional union member. - /// Any other shape (a disallowed type name, a non-string array element, an empty array, or a + /// which per SEP-2243 may only be applied to number, string, or boolean + /// parameters. We additionally accept integer as a JSON Schema refinement of number. + /// A union array (e.g., ["string", "null"]) is allowed as long as it contains at least + /// one allowed primitive; "null" is tolerated only as an additional union member. Any + /// other shape (a disallowed type name, a non-string array element, an empty array, or a /// non-string/non-array value) is treated as incompatible. /// private static bool IsAllowedHeaderType(JsonElement typeElement) @@ -331,7 +332,7 @@ private static bool IsAllowedHeaderType(JsonElement typeElement) } private static bool IsAllowedPrimitiveTypeName(string? typeName) => - typeName is "string" or "integer" or "boolean"; + typeName is "string" or "integer" or "number" or "boolean"; // Valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2: // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs index 852fb122e..fc1b34d5c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -342,6 +342,74 @@ public async Task RemoveKnownTools_ThenCallTool_NoMcpParamHeaders() Assert.Empty(headers); } + private static Tool CreateToolWithNumberHeaders() + { + // Schema using "type": "number" for both an integer-valued and a fractional-valued + // header parameter. Per SEP-2243 the "number" primitive type is permitted alongside + // "string" and "boolean"; unlike "integer", values aren't canonicalized — they are + // emitted using their raw JSON representation. + var schemaJson = """ + { + "type": "object", + "properties": { + "priority": { + "type": "number", + "x-mcp-header": "Priority" + }, + "ratio": { + "type": "number", + "x-mcp-header": "Ratio" + } + }, + "required": ["priority", "ratio"] + } + """; + + return new Tool + { + Name = "number_tool", + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Theory] + [InlineData("2", "0.5", "2", "0.5")] + [InlineData("42", "3.14", "42", "3.14")] + [InlineData("-7", "-0.25", "-7", "-0.25")] + public async Task CallTool_NumberType_EmitsRawJsonNumberHeader( + string priorityValue, + string ratioValue, + string expectedPriorityHeader, + string expectedRatioHeader) + { + 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([CreateToolWithNumberHeaders()]); + + var result = await client.CallToolAsync( + "number_tool", + new Dictionary + { + ["priority"] = JsonDocument.Parse(priorityValue).RootElement, + ["ratio"] = JsonDocument.Parse(ratioValue).RootElement, + }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var headers = _capturedHeaders.Values.First(); + Assert.Equal(expectedPriorityHeader, headers["Mcp-Param-Priority"]); + Assert.Equal(expectedRatioHeader, headers["Mcp-Param-Ratio"]); + } + private static Tool CreateToolWithSingleHeader(string toolName, string headerName) { var schemaJson = $$""" diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs index ff3916d2a..14ee44e66 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs @@ -10,7 +10,8 @@ namespace ModelContextProtocol.Tests.Client; /// /// Tests for SEP-2243 x-mcp-header validation changes: /// - RFC 9110 tchar validation for header names -/// - "number" type rejection (only integer/string/boolean allowed) +/// - "number" type acceptance (along with integer/string/boolean) per the SEP's +/// "primitive types (number, string, boolean)" rule /// - Nested property support for x-mcp-header annotations /// public class McpHeaderExtractorValidationTests : ClientServerTestBase @@ -27,7 +28,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer (string input) => $"echo {input}", new() { Name = "ValidTool" })]); - // Tool with "number" type (should be rejected per updated SEP-2243) + // Tool with "number" type (should be accepted per SEP-2243 "number, string, boolean" rule) 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" } } } @@ -41,6 +42,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer """).RootElement.Clone(); mcpServerBuilder.WithTools([integerTool]); + // Tool with "array" type (should be rejected - not a primitive type) + var arrayTool = McpServerTool.Create((string x) => x, new() { Name = "ArrayTypeTool" }); + arrayTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "value": { "type": "array", "items": { "type": "string" }, "x-mcp-header": "Value" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([arrayTool]); + // Tool with non-tchar header name (should be rejected) var nonTcharTool = McpServerTool.Create((string x) => x, new() { Name = "BadTcharTool" }); nonTcharTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" @@ -69,7 +77,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer """).RootElement.Clone(); mcpServerBuilder.WithTools([duplicateTool]); - // Tool with nested "number" type (should be rejected) + // Tool with nested "number" type (should be accepted per SEP-2243) 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" } } } } } @@ -83,7 +91,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer """).RootElement.Clone(); mcpServerBuilder.WithTools([nullableUnionTool]); - // Tool with a union type containing a disallowed type ["number", "null"] (should be rejected) + // Tool with a union type containing "number" and "null" (should be accepted) 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" } } } @@ -106,18 +114,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task ListToolsAsync_NumberType_ExcludesTool() + public async Task ListToolsAsync_NumberType_AcceptsTool() { 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")); + Assert.Contains(tools, t => t.Name == "NumberTypeTool"); } [Fact] @@ -129,6 +132,21 @@ public async Task ListToolsAsync_IntegerType_AcceptsTool() Assert.Contains(tools, t => t.Name == "IntegerTypeTool"); } + [Fact] + public async Task ListToolsAsync_ArrayType_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 == "ArrayTypeTool"); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.LogLevel == LogLevel.Warning && + log.Message.Contains("ArrayTypeTool") && + log.Message.Contains("excluded")); + } + [Fact] public async Task ListToolsAsync_NonTcharHeaderName_ExcludesTool() { @@ -169,13 +187,12 @@ public async Task ListToolsAsync_NestedDuplicateHeaders_ExcludesTool() } [Fact] - public async Task ListToolsAsync_NestedNumberType_ExcludesTool() + public async Task ListToolsAsync_NestedNumberType_AcceptsTool() { 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"); + Assert.Contains(tools, t => t.Name == "NestedNumberTool"); } [Fact] @@ -188,13 +205,12 @@ public async Task ListToolsAsync_NullableUnionType_AcceptsTool() } [Fact] - public async Task ListToolsAsync_NumberUnionType_ExcludesTool() + public async Task ListToolsAsync_NumberUnionType_AcceptsTool() { 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"); + Assert.Contains(tools, t => t.Name == "NumberUnionTool"); } [Fact]