Skip to content
Open
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
19 changes: 16 additions & 3 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public static partial class McpJsonUtilities
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// <para>
/// Property names are matched case-insensitively, as with <see cref="JsonSerializerDefaults.Web"/>. The SDK itself
/// deserializes incoming protocol messages with case-sensitive contracts, so a protocol property such as

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The SDK itself deserializes incoming protocol messages with case-sensitive contracts" is broader than the implementation. Some internal readers still use the case-insensitive public options, including task-based tool-result readers and transport-level error-detail parsing.

Could we qualify this wording and explicitly document that protocol types deserialized through DefaultOptions retain case-insensitive matching? That would describe the boundary accurately without requiring those follow-ups to block this fix.

/// <c>params.name</c> is only recognized with the casing defined by the MCP specification.
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();

Expand All @@ -39,7 +44,12 @@ public static partial class McpJsonUtilities
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
JsonSerializerOptions options = new(JsonContext.Default.Options)
{
// The source generated context matches protocol property names case-sensitively. These options
// are also used for user-defined types (for example tool parameters), so keep the web default.
PropertyNameCaseInsensitive = true,
};

// Chain with all supported types from MEAI.
options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!);
Expand Down Expand Up @@ -101,10 +111,13 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
internal static bool IsValidToolOutputSchema(JsonElement element) =>
element.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False;

// Keep in sync with CreateDefaultOptions above.
// Keep in sync with CreateDefaultOptions above. Unlike DefaultOptions, property names are matched
// case-sensitively: these contracts deserialize protocol messages, whose property names are defined
// by the MCP and JSON-RPC specifications, and must agree with the JSON-RPC envelope converter.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
NumberHandling = JsonNumberHandling.AllowReadingFromString,
PropertyNameCaseInsensitive = false)]

// JSON-RPC
[JsonSerializable(typeof(JsonRpcMessage))]
Expand Down
19 changes: 19 additions & 0 deletions tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ public static void DefaultOptions_IsSingleton()
Assert.Same(options, McpJsonUtilities.DefaultOptions);
}

[Fact]
public static void DefaultOptions_MatchesPropertyNamesCaseInsensitively()
{
var options = McpJsonUtilities.DefaultOptions;

Assert.True(options.PropertyNameCaseInsensitive);

if (JsonSerializer.IsReflectionEnabledByDefault)
{
var query = JsonSerializer.Deserialize<WeatherQuery>("""{"CITY":"Hefei"}""", options);
Assert.Equal("Hefei", query?.City);
}
}

private sealed class WeatherQuery
{
public string? City { get; set; }
}

[Fact]
public static void DefaultOptions_UseReflectionWhenEnabled()
{
Expand Down
118 changes: 118 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,124 @@ await transport.SendMessageAsync(
await runTask;
}

[Fact]
public async Task Call_Tool_Request_Params_Are_Matched_Case_Sensitively()
{
await using var transport = new TestServerTransport();
var options = CreateOptions(new ServerCapabilities { Tools = new() });
var invocations = new List<CallToolRequestParams?>();
options.Handlers.CallToolHandler = (request, ct) =>
{
invocations.Add(request.Params);
return new ValueTask<CallToolResult>(new CallToolResult());
};
options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();

await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(TestContext.Current.CancellationToken);

var replies = new Dictionary<string, TaskCompletionSource<JsonRpcMessageWithId>>
{
["1"] = new(),
["2"] = new(),
["3"] = new(),
["4"] = new(),
["5"] = new(),
};
transport.OnMessageSent = message =>
{
if (message is JsonRpcResponse or JsonRpcError &&
replies.TryGetValue(((JsonRpcMessageWithId)message).Id.ToString(), out var tcs))
{
tcs.TrySetResult((JsonRpcMessageWithId)message);
}
};

// "Name" is not the protocol's "name", so the required property is missing and the tool is not invoked.
await transport.SendMessageAsync(
new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(1),
Params = JsonNode.Parse("""{"Name":"echo","arguments":{}}"""),
},
TestContext.Current.CancellationToken);
var rejected = await replies["1"].Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.IsType<JsonRpcError>(rejected);
Assert.Empty(invocations);

// An alternately cased optional property is not bound either.
await transport.SendMessageAsync(
new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(2),
Params = JsonNode.Parse("""{"name":"echo","Arguments":{"text":"hi"}}"""),
},
TestContext.Current.CancellationToken);
Assert.IsType<JsonRpcResponse>(await replies["2"].Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));

await transport.SendMessageAsync(
new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(3),
Params = JsonNode.Parse("""{"name":"echo","arguments":{"text":"hi"}}"""),
},
TestContext.Current.CancellationToken);
Assert.IsType<JsonRpcResponse>(await replies["3"].Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));

// When both spellings are present, only "name" is bound, regardless of order. Keys inside "arguments"
// are user data and are preserved as distinct entries.
await transport.SendMessageAsync(
new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(4),
Params = JsonNode.Parse("""{"name":"echo","Name":"other","arguments":{"text":"hi","Text":"HI"}}"""),
},
TestContext.Current.CancellationToken);
Assert.IsType<JsonRpcResponse>(await replies["4"].Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));

await transport.SendMessageAsync(
new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(5),
Params = JsonNode.Parse("""{"Name":"other","name":"echo"}"""),
},
TestContext.Current.CancellationToken);
Assert.IsType<JsonRpcResponse>(await replies["5"].Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));

Assert.Collection(invocations,
p =>
{
Assert.Equal("echo", p?.Name);
Assert.Null(p?.Arguments);
},
p =>
{
Assert.Equal("echo", p?.Name);
Assert.Equal("hi", p?.Arguments?["text"].GetString());
},
p =>
{
Assert.Equal("echo", p?.Name);
Assert.NotNull(p?.Arguments);
Assert.Equal(2, p.Arguments.Count);
Assert.Equal("hi", p.Arguments["text"].GetString());
Assert.Equal("HI", p.Arguments["Text"].GetString());
},
p =>
{
Assert.Equal("echo", p?.Name);
Assert.Null(p?.Arguments);
});

await transport.DisposeAsync();
await runTask;
}

private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action<McpServerOptions>? configureOptions, Action<McpServer, JsonNode?> assertResult)
{
await using var transport = new TestServerTransport();
Expand Down
Loading