Skip to content

Commit 6af4fbe

Browse files
committed
Accept "type":"number" as a valid x-mcp-header primitive per SEP-2243
Per the SEP-2243 spec text (specification/draft/server/tools.mdx, line 340): > MUST only be applied to parameters with primitive types (number, string, boolean) Our McpHeaderExtractor.ValidateToolSchema was rejecting tools whose x-mcp-header annotation targeted a property with "type":"number", contradicting the spec and causing the upstream conformance scenario `http-custom-headers` to fail (the schema declares `priority` and `float_val` as "type":"number" and expects them to be emitted as Mcp-Param-Priority / Mcp-Param-FloatVal headers). McpHeaderEncoder.ConvertToHeaderValue already handles JsonValueKind.Number by emitting element.GetRawText(), so number-typed values flow through the encoder unchanged — only the validator needed updating. Integer canonicalization (per the SEP's JavaScript safe integer rule) remains restricted to "type":"integer"; "type":"number" values are emitted using their raw JSON representation (e.g. 2.5, 3.14, -7), which is what the conformance suite expects. Updates: - Add "number" to IsAllowedPrimitiveTypeName. - Refresh doc comments + rejection message to match the spec wording. - Flip three previously-asserted-as-rejected test cases in McpHeaderExtractorValidationTests to assert acceptance, and add an ArrayTypeTool to retain a negative-case coverage point. - Add CallTool_NumberType_EmitsRawJsonNumberHeader in AddKnownToolsHeaderTests for end-to-end wire coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dbb7a20 commit 6af4fbe

3 files changed

Lines changed: 113 additions & 28 deletions

File tree

src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -267,16 +267,16 @@ private static bool ValidateProperties(Tool tool, JsonElement properties, HashSe
267267
return false;
268268
}
269269

270-
// MUST only be applied to parameters with primitive types (string, integer, boolean).
271-
// Parameters with type "number" (or any other non-primitive type) are not permitted.
272-
// The "type" keyword may be omitted (treated as unknown, not rejected, since many valid
273-
// schemas constrain the value via enum/const/$ref instead) or expressed as a JSON Schema
274-
// union array such as ["string", "null"]; only an explicitly disallowed or malformed type
270+
// MUST only be applied to parameters with primitive types (number, string, boolean) per
271+
// SEP-2243. We also accept "integer" as a JSON Schema refinement of "number". The "type"
272+
// keyword may be omitted (treated as unknown, not rejected, since many valid schemas
273+
// constrain the value via enum/const/$ref instead) or expressed as a JSON Schema union
274+
// array such as ["string", "null"]; only an explicitly disallowed or malformed type
275275
// causes rejection.
276276
if (property.Value.TryGetProperty("type", out var typeElement) &&
277277
!IsAllowedHeaderType(typeElement))
278278
{
279-
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', and 'boolean' are allowed.";
279+
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', 'number', and 'boolean' are allowed.";
280280
return false;
281281
}
282282
}
@@ -286,10 +286,11 @@ private static bool ValidateProperties(Tool tool, JsonElement properties, HashSe
286286

287287
/// <summary>
288288
/// Determines whether a JSON Schema <c>type</c> keyword is compatible with <c>x-mcp-header</c>,
289-
/// which per SEP-2243 may only be applied to <c>string</c>, <c>integer</c>, or <c>boolean</c>
290-
/// parameters. A union array (e.g., <c>["string", "null"]</c>) is allowed as long as it contains
291-
/// at least one allowed primitive; <c>"null"</c> is tolerated only as an additional union member.
292-
/// Any other shape (a disallowed type name, a non-string array element, an empty array, or a
289+
/// which per SEP-2243 may only be applied to <c>number</c>, <c>string</c>, or <c>boolean</c>
290+
/// parameters. We additionally accept <c>integer</c> as a JSON Schema refinement of <c>number</c>.
291+
/// A union array (e.g., <c>["string", "null"]</c>) is allowed as long as it contains at least
292+
/// one allowed primitive; <c>"null"</c> is tolerated only as an additional union member. Any
293+
/// other shape (a disallowed type name, a non-string array element, an empty array, or a
293294
/// non-string/non-array value) is treated as incompatible.
294295
/// </summary>
295296
private static bool IsAllowedHeaderType(JsonElement typeElement)
@@ -331,7 +332,7 @@ private static bool IsAllowedHeaderType(JsonElement typeElement)
331332
}
332333

333334
private static bool IsAllowedPrimitiveTypeName(string? typeName) =>
334-
typeName is "string" or "integer" or "boolean";
335+
typeName is "string" or "integer" or "number" or "boolean";
335336

336337
// Valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2:
337338
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /

tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,74 @@ public async Task RemoveKnownTools_ThenCallTool_NoMcpParamHeaders()
342342
Assert.Empty(headers);
343343
}
344344

345+
private static Tool CreateToolWithNumberHeaders()
346+
{
347+
// Schema using "type": "number" for both an integer-valued and a fractional-valued
348+
// header parameter. Per SEP-2243 the "number" primitive type is permitted alongside
349+
// "string" and "boolean"; unlike "integer", values aren't canonicalized — they are
350+
// emitted using their raw JSON representation.
351+
var schemaJson = """
352+
{
353+
"type": "object",
354+
"properties": {
355+
"priority": {
356+
"type": "number",
357+
"x-mcp-header": "Priority"
358+
},
359+
"ratio": {
360+
"type": "number",
361+
"x-mcp-header": "Ratio"
362+
}
363+
},
364+
"required": ["priority", "ratio"]
365+
}
366+
""";
367+
368+
return new Tool
369+
{
370+
Name = "number_tool",
371+
InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(),
372+
};
373+
}
374+
375+
[Theory]
376+
[InlineData("2", "0.5", "2", "0.5")]
377+
[InlineData("42", "3.14", "42", "3.14")]
378+
[InlineData("-7", "-0.25", "-7", "-0.25")]
379+
public async Task CallTool_NumberType_EmitsRawJsonNumberHeader(
380+
string priorityValue,
381+
string ratioValue,
382+
string expectedPriorityHeader,
383+
string expectedRatioHeader)
384+
{
385+
await StartAsync();
386+
387+
await using var transport = new HttpClientTransport(new()
388+
{
389+
Endpoint = new("http://localhost:5000/mcp"),
390+
TransportMode = HttpTransportMode.StreamableHttp,
391+
}, HttpClient, LoggerFactory);
392+
393+
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
394+
cancellationToken: TestContext.Current.CancellationToken);
395+
396+
client.AddKnownTools([CreateToolWithNumberHeaders()]);
397+
398+
var result = await client.CallToolAsync(
399+
"number_tool",
400+
new Dictionary<string, object?>
401+
{
402+
["priority"] = JsonDocument.Parse(priorityValue).RootElement,
403+
["ratio"] = JsonDocument.Parse(ratioValue).RootElement,
404+
},
405+
cancellationToken: TestContext.Current.CancellationToken);
406+
407+
Assert.NotNull(result);
408+
var headers = _capturedHeaders.Values.First();
409+
Assert.Equal(expectedPriorityHeader, headers["Mcp-Param-Priority"]);
410+
Assert.Equal(expectedRatioHeader, headers["Mcp-Param-Ratio"]);
411+
}
412+
345413
private static Tool CreateToolWithSingleHeader(string toolName, string headerName)
346414
{
347415
var schemaJson = $$"""

tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ namespace ModelContextProtocol.Tests.Client;
1010
/// <summary>
1111
/// Tests for SEP-2243 x-mcp-header validation changes:
1212
/// - RFC 9110 tchar validation for header names
13-
/// - "number" type rejection (only integer/string/boolean allowed)
13+
/// - "number" type acceptance (along with integer/string/boolean) per the SEP's
14+
/// "primitive types (number, string, boolean)" rule
1415
/// - Nested property support for x-mcp-header annotations
1516
/// </summary>
1617
public class McpHeaderExtractorValidationTests : ClientServerTestBase
@@ -27,7 +28,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
2728
(string input) => $"echo {input}",
2829
new() { Name = "ValidTool" })]);
2930

30-
// Tool with "number" type (should be rejected per updated SEP-2243)
31+
// Tool with "number" type (should be accepted per SEP-2243 "number, string, boolean" rule)
3132
var numberTool = McpServerTool.Create((string x) => x, new() { Name = "NumberTypeTool" });
3233
numberTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
3334
{ "type": "object", "properties": { "value": { "type": "number", "x-mcp-header": "Value" } } }
@@ -41,6 +42,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
4142
""").RootElement.Clone();
4243
mcpServerBuilder.WithTools([integerTool]);
4344

45+
// Tool with "array" type (should be rejected - not a primitive type)
46+
var arrayTool = McpServerTool.Create((string x) => x, new() { Name = "ArrayTypeTool" });
47+
arrayTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
48+
{ "type": "object", "properties": { "value": { "type": "array", "items": { "type": "string" }, "x-mcp-header": "Value" } } }
49+
""").RootElement.Clone();
50+
mcpServerBuilder.WithTools([arrayTool]);
51+
4452
// Tool with non-tchar header name (should be rejected)
4553
var nonTcharTool = McpServerTool.Create((string x) => x, new() { Name = "BadTcharTool" });
4654
nonTcharTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
@@ -69,7 +77,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
6977
""").RootElement.Clone();
7078
mcpServerBuilder.WithTools([duplicateTool]);
7179

72-
// Tool with nested "number" type (should be rejected)
80+
// Tool with nested "number" type (should be accepted per SEP-2243)
7381
var nestedNumberTool = McpServerTool.Create((string x) => x, new() { Name = "NestedNumberTool" });
7482
nestedNumberTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
7583
{ "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
8391
""").RootElement.Clone();
8492
mcpServerBuilder.WithTools([nullableUnionTool]);
8593

86-
// Tool with a union type containing a disallowed type ["number", "null"] (should be rejected)
94+
// Tool with a union type containing "number" and "null" (should be accepted)
8795
var numberUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NumberUnionTool" });
8896
numberUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse("""
8997
{ "type": "object", "properties": { "value": { "type": ["number", "null"], "x-mcp-header": "Value" } } }
@@ -106,18 +114,13 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
106114
}
107115

108116
[Fact]
109-
public async Task ListToolsAsync_NumberType_ExcludesTool()
117+
public async Task ListToolsAsync_NumberType_AcceptsTool()
110118
{
111119
await using var client = await CreateMcpClientForServer();
112120
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
113121

114122
Assert.Contains(tools, t => t.Name == "ValidTool");
115-
Assert.DoesNotContain(tools, t => t.Name == "NumberTypeTool");
116-
117-
Assert.Contains(MockLoggerProvider.LogMessages, log =>
118-
log.LogLevel == LogLevel.Warning &&
119-
log.Message.Contains("NumberTypeTool") &&
120-
log.Message.Contains("excluded"));
123+
Assert.Contains(tools, t => t.Name == "NumberTypeTool");
121124
}
122125

123126
[Fact]
@@ -129,6 +132,21 @@ public async Task ListToolsAsync_IntegerType_AcceptsTool()
129132
Assert.Contains(tools, t => t.Name == "IntegerTypeTool");
130133
}
131134

135+
[Fact]
136+
public async Task ListToolsAsync_ArrayType_ExcludesTool()
137+
{
138+
await using var client = await CreateMcpClientForServer();
139+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
140+
141+
Assert.Contains(tools, t => t.Name == "ValidTool");
142+
Assert.DoesNotContain(tools, t => t.Name == "ArrayTypeTool");
143+
144+
Assert.Contains(MockLoggerProvider.LogMessages, log =>
145+
log.LogLevel == LogLevel.Warning &&
146+
log.Message.Contains("ArrayTypeTool") &&
147+
log.Message.Contains("excluded"));
148+
}
149+
132150
[Fact]
133151
public async Task ListToolsAsync_NonTcharHeaderName_ExcludesTool()
134152
{
@@ -169,13 +187,12 @@ public async Task ListToolsAsync_NestedDuplicateHeaders_ExcludesTool()
169187
}
170188

171189
[Fact]
172-
public async Task ListToolsAsync_NestedNumberType_ExcludesTool()
190+
public async Task ListToolsAsync_NestedNumberType_AcceptsTool()
173191
{
174192
await using var client = await CreateMcpClientForServer();
175193
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
176194

177-
Assert.Contains(tools, t => t.Name == "ValidTool");
178-
Assert.DoesNotContain(tools, t => t.Name == "NestedNumberTool");
195+
Assert.Contains(tools, t => t.Name == "NestedNumberTool");
179196
}
180197

181198
[Fact]
@@ -188,13 +205,12 @@ public async Task ListToolsAsync_NullableUnionType_AcceptsTool()
188205
}
189206

190207
[Fact]
191-
public async Task ListToolsAsync_NumberUnionType_ExcludesTool()
208+
public async Task ListToolsAsync_NumberUnionType_AcceptsTool()
192209
{
193210
await using var client = await CreateMcpClientForServer();
194211
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
195212

196-
Assert.Contains(tools, t => t.Name == "ValidTool");
197-
Assert.DoesNotContain(tools, t => t.Name == "NumberUnionTool");
213+
Assert.Contains(tools, t => t.Name == "NumberUnionTool");
198214
}
199215

200216
[Fact]

0 commit comments

Comments
 (0)