From 54e853bfba8e4fdd62841dc0a13690c940d5f739 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Thu, 8 Oct 2026 18:59:07 +0800 Subject: [PATCH 1/2] Match protocol property names case-sensitively Protocol DTOs were deserialized with the source-generated context, which uses the web defaults and binds property names case-insensitively, while the JSON-RPC envelope converter matches its properties exactly. A request such as tools/call with params.Name was therefore dispatched even though middleware inspecting params.name would not recognize it. The source-generated context now matches property names case-sensitively. DefaultOptions keeps case-insensitive matching because it is also used for user-defined types such as tool parameters. Fixes #1897 Signed-off-by: FenjuFu --- .../McpJsonUtilities.cs | 19 ++- .../McpJsonUtilitiesTests.cs | 19 +++ .../Server/McpServerTests.cs | 118 ++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index b193bb8de..f15dba450 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -27,6 +27,11 @@ public static partial class McpJsonUtilities /// Enables as the default number handling for number types. /// /// + /// + /// Property names are matched case-insensitively, as with . The SDK itself + /// deserializes incoming protocol messages with case-sensitive contracts, so a protocol property such as + /// params.name is only recognized with the casing defined by the MCP specification. + /// /// public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); @@ -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!); @@ -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))] diff --git a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs index cc55746fe..8a06804cd 100644 --- a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs @@ -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("""{"CITY":"Hefei"}""", options); + Assert.Equal("Hefei", query?.City); + } + } + + private sealed class WeatherQuery + { + public string? City { get; set; } + } + [Fact] public static void DefaultOptions_UseReflectionWhenEnabled() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 0ab19a373..bc7221a27 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -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(); + options.Handlers.CallToolHandler = (request, ct) => + { + invocations.Add(request.Params); + return new ValueTask(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> + { + ["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(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(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(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(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(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? configureOptions, Action assertResult) { await using var transport = new TestServerTransport(); From 39ec6e5e06e9618fd3b59d7f07e338c91b7240a5 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Sun, 18 Oct 2026 15:16:03 +0800 Subject: [PATCH 2/2] Use case-sensitive ProtocolOptions for all protocol readers Add McpJsonUtilities.ProtocolOptions, a cached read-only copy of DefaultOptions that matches property names case-sensitively, and use it wherever the SDK deserializes protocol messages through options rather than the source-generated context: the stdio client and server transports, the ASP.NET Core Streamable HTTP and SSE request reader, and the Tasks extension (task-based CallToolResult, InputRequest, InputResponse and error details). DefaultOptions and caller-supplied options are still used for user-defined data. Its docs now point to ProtocolOptions and state that protocol types deserialized through DefaultOptions keep case-insensitive matching. Signed-off-by: FenjuFu --- .../StreamableHttpHandler.cs | 2 +- .../Client/StreamClientSessionTransport.cs | 2 +- .../McpJsonUtilities.cs | 39 +++++++++++----- .../Server/StreamServerTransport.cs | 2 +- .../Client/McpTasksClientExtensions.cs | 8 ++-- .../Protocol/GetTaskResult.cs | 4 +- .../Protocol/TaskStatusNotificationParams.cs | 4 +- .../Server/McpTasksBuilderExtensions.cs | 14 +++--- .../Server/McpTasksServerExtensions.cs | 2 +- .../McpJsonUtilitiesTests.cs | 45 +++++++++++++++++++ .../Server/McpServerToolTests.cs | 31 +++++++++++++ .../Transport/StdioServerTransportTests.cs | 24 ++++++++++ 12 files changed, 148 insertions(+), 29 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 42a759fef..48098ff3b 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -714,7 +714,7 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, return null; } - internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.ProtocolOptions.GetTypeInfo(typeof(T)); /// /// Validates that a request riding a per-request-metadata protocol revision carries the required diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs index d805656f5..7a69c1c83 100644 --- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs @@ -153,7 +153,7 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati { try { - var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); + var message = (JsonRpcMessage?)JsonSerializer.Deserialize(line.AsSpan().Trim(), McpJsonUtilities.ProtocolOptions.GetTypeInfo(typeof(JsonRpcMessage))); if (message != null) { await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index f15dba450..13eaf8a99 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -28,27 +28,46 @@ public static partial class McpJsonUtilities /// /// /// - /// Property names are matched case-insensitively, as with . The SDK itself - /// deserializes incoming protocol messages with case-sensitive contracts, so a protocol property such as - /// params.name is only recognized with the casing defined by the MCP specification. + /// Property names are matched case-insensitively, as with , because these + /// options are also used for user-defined data such as tool arguments and results. The SDK deserializes protocol + /// messages with instead, so a protocol property such as params.name is only + /// recognized with the casing defined by the MCP specification. Protocol types deserialized through these options + /// keep case-insensitive matching. /// /// - public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(propertyNameCaseInsensitive: true); + + /// + /// Gets the singleton used to deserialize MCP and JSON-RPC protocol messages. + /// + /// + /// + /// These options are configured like , except that property names are matched + /// case-sensitively. Protocol property names are defined by the MCP and JSON-RPC specifications, so a property such + /// as params.Name or error.Code is not bound to name or code. + /// + /// + /// Use these options when deserializing protocol messages yourself. User-defined data, such as tool arguments, + /// should continue to use or caller-supplied options. + /// + /// + public static JsonSerializerOptions ProtocolOptions { get; } = CreateDefaultOptions(propertyNameCaseInsensitive: false); /// /// Creates default options to use for MCP-related serialization. /// + /// Whether property names are matched case-insensitively. /// The configured options. [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] - private static JsonSerializerOptions CreateDefaultOptions() + private static JsonSerializerOptions CreateDefaultOptions(bool propertyNameCaseInsensitive) { - // Copy the configuration from the source generated context. + // Copy the configuration from the source generated context. The context matches protocol property names + // case-sensitively; DefaultOptions is also used for user-defined types (for example tool parameters), + // so it keeps the web default. 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, + PropertyNameCaseInsensitive = propertyNameCaseInsensitive, }; // Chain with all supported types from MEAI. @@ -111,7 +130,7 @@ 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. Unlike DefaultOptions, property names are matched + // Keep in sync with CreateDefaultOptions above. Like ProtocolOptions, 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, diff --git a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs index c8c5d8408..cc9765066 100644 --- a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs @@ -117,7 +117,7 @@ private async Task ReadMessagesAsync() try { - if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))) is JsonRpcMessage message) + if (JsonSerializer.Deserialize(line, McpJsonUtilities.ProtocolOptions.GetTypeInfo(typeof(JsonRpcMessage))) is JsonRpcMessage message) { await WriteMessageAsync(message, shutdownToken).ConfigureAwait(false); } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs index 833f2b250..79bde6655 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -37,7 +37,7 @@ public static async ValueTask> CallToolAsTas JsonRpcRequest jsonRpcRequest = new() { Method = RequestMethods.ToolsCall, - Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.ProtocolOptions.GetTypeInfo()), }; JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); @@ -51,7 +51,7 @@ public static async ValueTask> CallToolAsTas return new ResultOrCreatedTask(taskCreated); } - var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.ProtocolOptions.GetTypeInfo()) ?? throw new JsonException("Failed to deserialize CallToolResult from response."); return new ResultOrCreatedTask(callToolResult); } @@ -163,7 +163,7 @@ public static async ValueTask UpdateTaskAsync( { paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( inputResponses, - McpJsonUtilities.DefaultOptions.GetTypeInfo>()); + McpJsonUtilities.ProtocolOptions.GetTypeInfo>()); } JsonRpcRequest jsonRpcRequest = CreateTaskRequest( @@ -268,7 +268,7 @@ private static async ValueTask PollTaskToCompletionAsync( switch (taskResult) { case CompletedTaskResult completed: - return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.ProtocolOptions.GetTypeInfo()) ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); case FailedTaskResult failed: diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs index 4d083d8a1..607e7f3b2 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs @@ -169,7 +169,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.ProtocolOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -315,7 +315,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs index 020896a1f..bf51353ef 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs @@ -171,7 +171,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.ProtocolOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -311,7 +311,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index e61466a69..fcbc23052 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -225,7 +225,7 @@ private async Task ExecuteTaskAsync( try { var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } catch (Exception storeEx) @@ -259,12 +259,12 @@ private async Task ExecuteToolPipelineAsync( Code = (int)McpErrorCode.InternalError, Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.", }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); return; } - var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); } catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) @@ -278,14 +278,14 @@ private async Task ExecuteToolPipelineAsync( Code = (int)McpErrorCode.InvalidRequest, Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } catch (McpProtocolException mcpEx) { // SEP-2663 ยง186: protocol exceptions store as failed with JSON-RPC error shape. var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } catch (Exception ex) @@ -302,7 +302,7 @@ private async Task ExecuteToolPipelineAsync( : $"An error occurred invoking '{request.Params?.Name}'.", }], }; - var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); } } @@ -336,7 +336,7 @@ private async Task ExecuteToolPipelineAsync( // InputResponsesCore backing property on RequestParams. The extension's source-gen // context cannot see that internal member. var inputResponses = request.Params?["inputResponses"]?.Deserialize( - McpJsonUtilities.DefaultOptions.GetTypeInfo>()) + McpJsonUtilities.ProtocolOptions.GetTypeInfo>()) ?? new Dictionary(); await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs index 74b227f5d..9bddcdacb 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs @@ -61,7 +61,7 @@ internal static McpServer WithMcpTaskOutgoingRequestInterceptor( Method = method, Params = paramsNode is null ? default - : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.ProtocolOptions.GetTypeInfo()), }; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs index 8a06804cd..6d87d9827 100644 --- a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs @@ -36,6 +36,51 @@ private sealed class WeatherQuery public string? City { get; set; } } + [Fact] + public static void ProtocolOptions_IsSingletonAndMatchesPropertyNamesCaseSensitively() + { + var options = McpJsonUtilities.ProtocolOptions; + + Assert.NotNull(options); + Assert.True(options.IsReadOnly); + Assert.Same(options, McpJsonUtilities.ProtocolOptions); + Assert.NotSame(McpJsonUtilities.DefaultOptions, options); + Assert.False(options.PropertyNameCaseInsensitive); + } + + [Fact] + public static void ProtocolOptions_DoesNotBindAlternatelyCasedErrorDetail() + { + const string Json = """{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"boom","Code":1,"Message":"other"}}"""; + + var message = JsonSerializer.Deserialize(Json, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); + var error = Assert.IsType(message); + Assert.Equal(-32603, error.Error.Code); + Assert.Equal("boom", error.Error.Message); + + // DefaultOptions keeps case-insensitive matching, so the later spelling wins there. + message = JsonSerializer.Deserialize(Json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + error = Assert.IsType(message); + Assert.Equal(1, error.Error.Code); + Assert.Equal("other", error.Error.Message); + } + + [Fact] + public static void ProtocolOptions_DoesNotBindAlternatelyCasedCallToolResultFields() + { + const string Json = """{"content":[{"type":"text","text":"ok"}],"Content":[],"IsError":true}"""; + + var result = JsonSerializer.Deserialize(Json, McpJsonUtilities.ProtocolOptions.GetTypeInfo()); + Assert.NotNull(result); + Assert.Equal("ok", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Null(result.IsError); + + result = JsonSerializer.Deserialize(Json, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + Assert.NotNull(result); + Assert.Empty(result.Content); + Assert.True(result.IsError); + } + [Fact] public static void DefaultOptions_UseReflectionWhenEnabled() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 8fd1d9954..32e20f73d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -67,6 +67,37 @@ public async Task SupportsMcpServer() Assert.Equal("42", (result.Content[0] as TextContentBlock)?.Text); } + [Fact] + public async Task BindsNestedArgumentPropertiesCaseInsensitively() + { + Mock mockServer = new(); + + McpServerTool tool = McpServerTool.Create((WeatherRequest request) => $"{request.Location?.City}/{request.Days}"); + + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new() + { + Name = "", + Arguments = new Dictionary + { + ["request"] = JsonDocument.Parse("""{"LOCATION":{"CITY":"Hefei"},"Days":3}""").RootElement, + }, + }), + TestContext.Current.CancellationToken); + Assert.Equal("Hefei/3", (result.Content[0] as TextContentBlock)?.Text); + } + + public sealed class WeatherRequest + { + public WeatherLocation? Location { get; set; } + public int Days { get; set; } + } + + public sealed class WeatherLocation + { + public string? City { get; set; } + } + [Fact] public async Task SupportsCtorInjection() { diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs index 42472556d..e287cf4d0 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -116,6 +116,30 @@ public async Task ReadMessagesAsync_Should_Read_Messages() Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString()); } + [Fact] + public async Task ReadMessagesAsync_Should_Match_Error_Properties_Case_Sensitively() + { + Pipe pipe = new(); + using var input = pipe.Reader.AsStream(); + + await using var transport = new StreamServerTransport( + input, + Stream.Null, + loggerFactory: LoggerFactory); + + await pipe.Writer.WriteAsync( + Encoding.UTF8.GetBytes("""{"jsonrpc":"2.0","id":7,"error":{"code":-32603,"message":"boom","Code":1,"Message":"other"}}""" + "\n"), + TestContext.Current.CancellationToken); + + var canRead = await transport.MessageReader.WaitToReadAsync(TestContext.Current.CancellationToken); + + Assert.True(canRead, "Nothing to read here from transport message reader"); + Assert.True(transport.MessageReader.TryPeek(out var readMessage)); + var error = Assert.IsType(readMessage); + Assert.Equal(-32603, error.Error.Code); + Assert.Equal("boom", error.Error.Message); + } + [Fact] public async Task CleanupAsync_Should_Cleanup_Resources() {