From 54e853bfba8e4fdd62841dc0a13690c940d5f739 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Thu, 8 Oct 2026 18:59:07 +0800 Subject: [PATCH 01/10] 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 02/10] 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() { From de85bc8effa04daec0e89f0c0556d78d1c8bb3b0 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:28:33 +0000 Subject: [PATCH 03/10] test: register nested tool argument serialization metadata AI assistance: Codex prepared this test-only change and validation summary. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../Server/McpServerToolTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 32e20f73d..c77151884 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -72,7 +72,11 @@ public async Task BindsNestedArgumentPropertiesCaseInsensitively() { Mock mockServer = new(); - McpServerTool tool = McpServerTool.Create((WeatherRequest request) => $"{request.Location?.City}/{request.Days}"); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); + options.TypeInfoResolverChain.Add(JsonContext2.Default); + McpServerTool tool = McpServerTool.Create( + (WeatherRequest request) => $"{request.Location?.City}/{request.Days}", + new() { SerializerOptions = options }); var result = await tool.InvokeAsync( new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new() @@ -1303,6 +1307,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo [JsonSerializable(typeof(int?))] [JsonSerializable(typeof(DateTimeOffset?))] [JsonSerializable(typeof(Person))] + [JsonSerializable(typeof(WeatherRequest))] partial class JsonContext2 : JsonSerializerContext; // ===== x-mcp-header tests ===== From ab0f8d82ba68fb8049d6395d71fd7c0a59a400c0 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:24:17 +0000 Subject: [PATCH 04/10] fix(apps): match extension protocol properties case-sensitively Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- docs/concepts/apps/apps.md | 6 ++ .../Server/McpApps.cs | 4 +- .../Server/McpAppsTests.cs | 66 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index f22479966..0c3ef1ab2 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -32,6 +32,12 @@ The key concepts are: - **UI resources** — HTML content served with the MIME type `text/html;profile=mcp-app` - **Tool UI metadata** — Tools declare their associated UI resource in `_meta.ui` +### JSON property matching + +`McpApps.SerializerOptions` matches extension-defined property names case-sensitively, using `McpJsonUtilities.ProtocolOptions` together with the Apps source-generated metadata. For example, `resourceUri` and `mimeTypes` bind their respective properties, while `ResourceUri` and `MimeTypes` are ignored. When both spellings occur, the canonical spelling is used regardless of their order. This agrees with `McpApps.GetUiCapability`. + +This changes the behavior of applications that previously deserialized alternate-cased JSON through the Apps preset. Correctly cased JSON, serialization output, and strongly typed C# usage are unchanged. `McpJsonUtilities.DefaultOptions` continues to match user-defined data case-insensitively, and callers can still supply their own serializer options. + ## Associating tools with UI resources ### Using the builder extension (recommended) diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs index 53de40759..e6262e4e0 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs @@ -55,12 +55,14 @@ public static class McpApps /// /// Use these options when serializing or deserializing MCP Apps types such as /// , , and . + /// Property names are matched case-sensitively, as with . + /// Alternate spellings such as ResourceUri do not bind the protocol's resourceUri property. /// public static JsonSerializerOptions SerializerOptions { get; } = CreateSerializerOptions(); private static JsonSerializerOptions CreateSerializerOptions() { - var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions); + var options = new JsonSerializerOptions(McpJsonUtilities.ProtocolOptions); options.TypeInfoResolverChain.Insert(0, McpAppsJsonContext.Default); options.MakeReadOnly(); return options; diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs index 756417de0..c0d3194f1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs @@ -37,6 +37,72 @@ public void McpUiToolVisibility_Constants_HaveExpectedValues() #region F2: Typed Metadata Models + [Theory] + [InlineData("""{"resourceUri":"ui://app"}""", "ui://app")] + [InlineData("""{"ResourceUri":"ui://other"}""", null)] + [InlineData("""{"resourceUri":"ui://app","ResourceUri":"ui://other"}""", "ui://app")] + [InlineData("""{"ResourceUri":"ui://other","resourceUri":"ui://app"}""", "ui://app")] + public void SerializerOptions_MatchesToolMetadataCaseSensitively(string json, string? expectedUri) + { + var meta = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + + Assert.NotNull(meta); + Assert.Equal(expectedUri, meta.ResourceUri); + } + + [Theory] + [InlineData("""{"mimeTypes":["text/html"]}""", "text/html")] + [InlineData("""{"MimeTypes":["text/plain"]}""", null)] + [InlineData("""{"mimeTypes":["text/html"],"MimeTypes":["text/plain"]}""", "text/html")] + [InlineData("""{"MimeTypes":["text/plain"],"mimeTypes":["text/html"]}""", "text/html")] + public void SerializerOptions_AgreesWithGetUiCapability(string json, string? expectedMimeType) + { + var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + using var document = JsonDocument.Parse(json); +#pragma warning disable MCPEXP001 + var capabilities = new ClientCapabilities + { + Extensions = new Dictionary + { + [McpApps.ExtensionId] = document.RootElement, + }, + }; +#pragma warning restore MCPEXP001 + + var capability = McpApps.GetUiCapability(capabilities); + + Assert.NotNull(deserialized); + Assert.NotNull(capability); + if (expectedMimeType is null) + { + Assert.Null(deserialized.MimeTypes); + Assert.Null(capability.MimeTypes); + } + else + { + Assert.Equal(expectedMimeType, Assert.Single(deserialized.MimeTypes!)); + Assert.Equal(expectedMimeType, Assert.Single(capability.MimeTypes!)); + } + } + + [Fact] + public void SerializerOptions_DoesNotChangeDefaultOrCallerOptions() + { + var options = new JsonSerializerOptions(McpApps.SerializerOptions) + { + PropertyNameCaseInsensitive = true, + }; + + var meta = JsonSerializer.Deserialize("""{"ResourceUri":"ui://app"}""", options); + + Assert.NotNull(meta); + Assert.Equal("ui://app", meta.ResourceUri); + Assert.True(options.PropertyNameCaseInsensitive); + Assert.True(McpJsonUtilities.DefaultOptions.PropertyNameCaseInsensitive); + Assert.False(McpApps.SerializerOptions.PropertyNameCaseInsensitive); + Assert.True(McpApps.SerializerOptions.IsReadOnly); + } + [Fact] public void McpUiToolMeta_DefaultsToNull() { From 0d92883b1a3778e6fe922e73556c0724ecbdffb0 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:21:31 +0000 Subject: [PATCH 05/10] fix: honor caller options for nested content reads Add client API regression coverage for canonical tool-result fields in ordinary, immediate-task and polled-task responses. AI assistance was used to prepare this change and its tests. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../Protocol/ContentBlock.cs | 4 +- .../Client/ProtocolPropertyCasingTests.cs | 99 +++++++++++++++++++ .../Protocol/ContentBlockTests.cs | 53 +++++++++- 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index d0b1b80ec..3fdbf70c5 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -172,7 +172,7 @@ public sealed class Converter : JsonConverter icons = []; while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { - icons.Add(JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.Icon) ?? + icons.Add(JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()) ?? throw new JsonException("Unexpected null item in icons array.")); } } @@ -183,7 +183,7 @@ public sealed class Converter : JsonConverter break; case "annotations": - annotations = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.Annotations); + annotations = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); break; case "_meta": diff --git a/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs b/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs new file mode 100644 index 000000000..5368ce7d9 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs @@ -0,0 +1,99 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; +using System.Threading.Channels; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Client; + +public class ProtocolPropertyCasingTests +{ + [Theory] + [InlineData("ordinary", false)] + [InlineData("ordinary", true)] + [InlineData("immediate-task", false)] + [InlineData("immediate-task", true)] + [InlineData("polled-task", false)] + [InlineData("polled-task", true)] + public async Task ToolResult_CanonicalFieldsWinInEitherOrder(string path, bool alternateFirst) + { + const string Canonical = "\"content\":[{\"type\":\"text\",\"text\":\"canonical\"}],\"isError\":false"; + const string Alternate = "\"Content\":[{\"type\":\"text\",\"text\":\"alternate\"}],\"IsError\":true"; + string resultJson = alternateFirst ? $"{{{Alternate},{Canonical}}}" : $"{{{Canonical},{Alternate}}}"; + var transport = new ControlledResponseTransport(resultJson, path == "polled-task"); + var ct = TestContext.Current.CancellationToken; + await using var client = await McpClient.CreateAsync(transport, cancellationToken: ct); + var request = new CallToolRequestParams { Name = "test" }; + + CallToolResult result; + if (path == "ordinary") + { + result = await client.CallToolAsync(request, ct); + } + else if (path == "immediate-task") + { + var augmented = await client.CallToolAsTaskAsync(request, ct); + Assert.False(augmented.IsTask); + result = Assert.IsType(augmented.Result); + } + else + { + result = await client.CallToolWithPollingAsync(request, cancellationToken: ct); + } + + Assert.Equal("canonical", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.False(result.IsError); + Assert.Equal(path == "polled-task" ? 2 : 0, transport.PollCount); + } + + private sealed class ControlledResponseTransport(string resultJson, bool createTask) : IClientTransport, ITransport + { + private readonly Channel _channel = Channel.CreateUnbounded(); + + public int PollCount { get; private set; } + public string Name => "Controlled tool response"; + public string? SessionId => null; + public bool IsConnected => true; + public ChannelReader MessageReader => _channel.Reader; + + public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult(this); + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + if (message is not JsonRpcRequest request) + { + return Task.CompletedTask; + } + + const string TaskFields = "\"taskId\":\"test-task\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"lastUpdatedAt\":\"2026-01-01T00:00:00Z\",\"pollIntervalMs\":0"; + string json; + switch (request.Method) + { + case RequestMethods.ServerDiscover: + json = """{"capabilities":{"tools":{}},"supportedVersions":["2026-07-28"]}"""; + break; + case RequestMethods.ToolsCall: + json = createTask ? $"{{{TaskFields},\"resultType\":\"task\",\"status\":\"working\"}}" : resultJson; + break; + case "tasks/get": + PollCount++; + json = PollCount == 1 + ? $"{{{TaskFields},\"status\":\"working\"}}" + : $"{{{TaskFields},\"status\":\"completed\",\"result\":{resultJson}}}"; + break; + default: + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + } + + return _channel.Writer.WriteAsync(new JsonRpcResponse { Id = request.Id, Result = JsonNode.Parse(json) }, cancellationToken).AsTask(); + } + + public ValueTask DisposeAsync() + { + _channel.Writer.TryComplete(); + return default; + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs index 6e943f025..fc8dd196d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -6,6 +6,57 @@ namespace ModelContextProtocol.Tests.Protocol; public class ContentBlockTests { + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(2, true)] + public void NestedAnnotations_UsesCallerOptions(int preset, bool alternateCasing) + { + var options = GetNestedReadOptions(preset); + string priority = alternateCasing ? "Priority" : "priority"; + string json = """{"content":[{"type":"text","text":"ok","annotations":{"PRIORITY":0.5}}]}""".Replace("PRIORITY", priority); + + var result = JsonSerializer.Deserialize(json, options); + + var block = Assert.IsType(Assert.Single(Assert.IsType(result).Content)); + Assert.NotNull(block.Annotations); + Assert.Equal(alternateCasing && !options.PropertyNameCaseInsensitive ? null : (double?)0.5, block.Annotations.Priority); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(2, true)] + public void NestedIcons_UsesCallerOptions(int preset, bool alternateCasing) + { + var options = GetNestedReadOptions(preset); + string source = alternateCasing ? "Src" : "src"; + string json = """{"content":[{"type":"resource_link","uri":"file:///test","name":"test","icons":[{"SOURCE":"https://example.com/icon.png"}]}]}""".Replace("SOURCE", source); + + if (alternateCasing && !options.PropertyNameCaseInsensitive) + { + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + return; + } + + var result = JsonSerializer.Deserialize(json, options); + var block = Assert.IsType(Assert.Single(Assert.IsType(result).Content)); + Assert.Equal("https://example.com/icon.png", Assert.Single(Assert.IsAssignableFrom>(block.Icons)).Source); + } + + private static JsonSerializerOptions GetNestedReadOptions(int preset) => preset switch + { + 0 => McpJsonUtilities.DefaultOptions, + 1 => McpJsonUtilities.ProtocolOptions, + _ => new(McpJsonUtilities.ProtocolOptions) { PropertyNameCaseInsensitive = true }, + }; + [Fact] public void ResourceLinkBlock_SerializationRoundTrip_PreservesAllProperties() { @@ -562,4 +613,4 @@ public void AudioContentBlock_FromBytes_LazilyEncodesData(byte[] originalBytes) string expectedBase64 = Convert.ToBase64String(originalBytes); Assert.Equal(expectedBase64, Encoding.UTF8.GetString(audio.Data.ToArray())); } -} \ No newline at end of file +} From f7017ba6d4d6a4e480ba746bea7b7c0cf461b8f5 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:44:41 +0000 Subject: [PATCH 06/10] fix: preserve standalone content serialization contexts Resolve nested content metadata through caller-first resolvers with an SDK-generated fallback, preserving caller settings and mutable direct-converter reads. Add standalone-context, nested collection, caller-contract precedence, and mutable-options regression coverage. AI assistance was used to prepare this change and its tests. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../Protocol/ContentBlock.cs | 28 +++- .../Protocol/ContentBlockTests.cs | 131 ++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index 3fdbf70c5..56868c147 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -3,10 +3,12 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Protocol; @@ -76,6 +78,28 @@ private protected ContentBlock() [EditorBrowsable(EditorBrowsableState.Never)] public sealed class Converter : JsonConverter { + private static readonly ConditionalWeakTable s_nestedReadOptions = new(); + + private static JsonTypeInfo GetNestedTypeInfo(JsonSerializerOptions options) + { + // JsonSerializer freezes options, but direct converter callers may still change them. + JsonSerializerOptions nestedOptions = options.IsReadOnly + ? s_nestedReadOptions.GetValue(options, CreateNestedReadOptions) + : CreateNestedReadOptions(options); + return nestedOptions.GetTypeInfo(); + } + + private static JsonSerializerOptions CreateNestedReadOptions(JsonSerializerOptions callerOptions) + { + // A caller context may register ContentBlock without discovering the types read by + // this custom converter. Keep caller contracts first, then supply the SDK metadata + // (including nested collection types) using the same settings and converters. + JsonSerializerOptions nestedOptions = new(callerOptions); + nestedOptions.TypeInfoResolverChain.Add(McpJsonUtilities.JsonContext.Default); + nestedOptions.MakeReadOnly(); + return nestedOptions; + } + /// public override ContentBlock? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -172,7 +196,7 @@ public sealed class Converter : JsonConverter icons = []; while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { - icons.Add(JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()) ?? + icons.Add(JsonSerializer.Deserialize(ref reader, GetNestedTypeInfo(options)) ?? throw new JsonException("Unexpected null item in icons array.")); } } @@ -183,7 +207,7 @@ public sealed class Converter : JsonConverter break; case "annotations": - annotations = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + annotations = JsonSerializer.Deserialize(ref reader, GetNestedTypeInfo(options)); break; case "_meta": diff --git a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs index fc8dd196d..e9aaad2fd 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -1,11 +1,132 @@ using ModelContextProtocol.Protocol; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Protocol; public class ContentBlockTests { + [Fact] + public void NestedContracts_DirectConverterReadsRespectMutableOptionsChanges() + { + var options = new JsonSerializerOptions(McpJsonUtilities.ProtocolOptions); + + Assert.Null(Read().Annotations!.Priority); + options.PropertyNameCaseInsensitive = true; + Assert.Equal((float?)0.5, Read().Annotations!.Priority); + Assert.False(options.IsReadOnly); + + ContentBlock Read() + { + Utf8JsonReader reader = new("""{"type":"text","text":"ok","annotations":{"Priority":0.5}}"""u8); + Assert.True(reader.Read()); + return Assert.IsType(new ContentBlock.Converter().Read(ref reader, typeof(ContentBlock), options)); + } + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public void StandaloneContext_NestedAnnotations_UsesCallerSettings(bool wrapped, bool caseInsensitive, bool alternateCasing) + { + var options = GetStandaloneOptions(wrapped, caseInsensitive); + string priority = alternateCasing ? "Priority" : "priority"; + string audience = alternateCasing ? "Audience" : "audience"; + string json = """{"type":"text","text":"ok","annotations":{"PRIORITY":0.5,"AUDIENCE":["user","assistant"]}}""" + .Replace("PRIORITY", priority).Replace("AUDIENCE", audience); + + var block = ReadStandaloneBlock(json, wrapped, options); + + Assert.NotNull(block.Annotations); + if (alternateCasing && !caseInsensitive) + { + Assert.Null(block.Annotations.Priority); + Assert.Null(block.Annotations.Audience); + } + else + { + Assert.Equal((float?)0.5, block.Annotations.Priority); + Assert.Equal([Role.User, Role.Assistant], block.Annotations.Audience); + } + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public void StandaloneContext_NestedIcons_UsesCallerSettings(bool wrapped, bool caseInsensitive, bool alternateCasing) + { + var options = GetStandaloneOptions(wrapped, caseInsensitive); + string source = alternateCasing ? "Src" : "src"; + string sizes = alternateCasing ? "Sizes" : "sizes"; + string json = """{"type":"resource_link","uri":"file:///test","name":"test","icons":[{"SOURCE":"https://example.com/icon.png","SIZES":["48x48"]}]}""" + .Replace("SOURCE", source).Replace("SIZES", sizes); + + if (alternateCasing && !caseInsensitive) + { + Assert.Throws(() => ReadStandaloneBlock(json, wrapped, options)); + return; + } + + var block = Assert.IsType(ReadStandaloneBlock(json, wrapped, options)); + var icon = Assert.Single(Assert.IsAssignableFrom>(block.Icons)); + Assert.Equal("https://example.com/icon.png", icon.Source); + Assert.Equal(["48x48"], icon.Sizes); + } + + [Fact] + public void StandaloneContext_PrefersCallerNestedContracts() + { + var options = GetStandaloneOptions(wrapped: false, caseInsensitive: false); + options.TypeInfoResolver = JsonTypeInfoResolver.Combine( + options.TypeInfoResolver!, + CallerNestedContentJsonContext.Default.WithAddedModifier(typeInfo => + { + foreach (var property in typeInfo.Properties) + { + if (typeInfo.Type == typeof(Icon) && property.Name == "src") + { + property.Name = "callerSource"; + } + else if (typeInfo.Type == typeof(Annotations) && property.Name == "priority") + { + property.Name = "callerPriority"; + } + } + })); + const string Json = """{"type":"resource_link","uri":"file:///test","name":"test","icons":[{"callerSource":"https://example.com/icon.png","sizes":["48x48"]}],"annotations":{"callerPriority":0.5,"audience":["user"]}}"""; + + var block = Assert.IsType(ReadStandaloneBlock(Json, wrapped: false, options)); + + Assert.Equal("https://example.com/icon.png", Assert.Single(block.Icons!).Source); + Assert.Equal((float?)0.5, block.Annotations!.Priority); + Assert.Equal([Role.User], block.Annotations.Audience); + } + + private static JsonSerializerOptions GetStandaloneOptions(bool wrapped, bool caseInsensitive) => + new(wrapped ? StandaloneCallToolContentJsonContext.Default.Options : StandaloneContentJsonContext.Default.Options) + { + PropertyNameCaseInsensitive = caseInsensitive, + }; + + private static ContentBlock ReadStandaloneBlock(string json, bool wrapped, JsonSerializerOptions options) => + wrapped + ? Assert.Single(JsonSerializer.Deserialize("{\"content\":[" + json + "]}", options)!.Content) + : Assert.IsAssignableFrom(JsonSerializer.Deserialize(json, options)); + [Theory] [InlineData(0, false)] [InlineData(0, true)] @@ -614,3 +735,13 @@ public void AudioContentBlock_FromBytes_LazilyEncodesData(byte[] originalBytes) Assert.Equal(expectedBase64, Encoding.UTF8.GetString(audio.Data.ToArray())); } } + +[JsonSerializable(typeof(ContentBlock))] +internal partial class StandaloneContentJsonContext : JsonSerializerContext; + +[JsonSerializable(typeof(CallToolResult))] +internal partial class StandaloneCallToolContentJsonContext : JsonSerializerContext; + +[JsonSerializable(typeof(Icon))] +[JsonSerializable(typeof(Annotations))] +internal partial class CallerNestedContentJsonContext : JsonSerializerContext; From 32e695dca7597f850fb9f11f65964b3b3e24b446 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 23 Oct 2026 16:27:41 -0700 Subject: [PATCH 07/10] Use controlled client defaults in protocol casing tests --- .../Client/ProtocolPropertyCasingTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs b/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs index 5368ce7d9..8d2957803 100644 --- a/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs @@ -1,6 +1,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; using System.Text.Json.Nodes; using System.Threading.Channels; @@ -24,7 +25,7 @@ public async Task ToolResult_CanonicalFieldsWinInEitherOrder(string path, bool a string resultJson = alternateFirst ? $"{{{Alternate},{Canonical}}}" : $"{{{Canonical},{Alternate}}}"; var transport = new ControlledResponseTransport(resultJson, path == "polled-task"); var ct = TestContext.Current.CancellationToken; - await using var client = await McpClient.CreateAsync(transport, cancellationToken: ct); + await using var client = await TestMcpClient.CreateAsync(transport, cancellationToken: ct); var request = new CallToolRequestParams { Name = "test" }; CallToolResult result; From 851f8961a153e6901c71c4a37327e28f44af6f17 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 23 Oct 2026 16:34:03 -0700 Subject: [PATCH 08/10] Fix enterprise authorization specification link --- docs/concepts/transports/transports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index a62ba15d5..5da8b080e 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -408,7 +408,7 @@ Like [stdio](#stdio-transport), the in-memory transport is inherently single-ses ## Cross-application access -The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. +The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization/2026-07-28/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. The flow consists of two steps: From 71a6449b2112d0cafcc9e18a198311b5bc9102de Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 23 Oct 2026 16:54:17 -0700 Subject: [PATCH 09/10] Honor caller contracts when writing nested content --- .../Protocol/ContentBlock.cs | 14 ++-- .../Protocol/ContentBlockTests.cs | 64 ++++++++++++++++--- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index 56868c147..53f122618 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -78,20 +78,20 @@ private protected ContentBlock() [EditorBrowsable(EditorBrowsableState.Never)] public sealed class Converter : JsonConverter { - private static readonly ConditionalWeakTable s_nestedReadOptions = new(); + private static readonly ConditionalWeakTable s_nestedOptions = new(); private static JsonTypeInfo GetNestedTypeInfo(JsonSerializerOptions options) { // JsonSerializer freezes options, but direct converter callers may still change them. JsonSerializerOptions nestedOptions = options.IsReadOnly - ? s_nestedReadOptions.GetValue(options, CreateNestedReadOptions) - : CreateNestedReadOptions(options); + ? s_nestedOptions.GetValue(options, CreateNestedOptions) + : CreateNestedOptions(options); return nestedOptions.GetTypeInfo(); } - private static JsonSerializerOptions CreateNestedReadOptions(JsonSerializerOptions callerOptions) + private static JsonSerializerOptions CreateNestedOptions(JsonSerializerOptions callerOptions) { - // A caller context may register ContentBlock without discovering the types read by + // A caller context may register ContentBlock without discovering the types used by // this custom converter. Keep caller contracts first, then supply the SDK metadata // (including nested collection types) using the same settings and converters. JsonSerializerOptions nestedOptions = new(callerOptions); @@ -381,7 +381,7 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial writer.WriteStartArray(); foreach (var icon in resourceLink.Icons) { - JsonSerializer.Serialize(writer, icon, McpJsonUtilities.JsonContext.Default.Icon); + JsonSerializer.Serialize(writer, icon, GetNestedTypeInfo(options)); } writer.WriteEndArray(); } @@ -418,7 +418,7 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial if (value.Annotations is { } annotations) { writer.WritePropertyName("annotations"); - JsonSerializer.Serialize(writer, annotations, McpJsonUtilities.JsonContext.Default.Annotations); + JsonSerializer.Serialize(writer, annotations, GetNestedTypeInfo(options)); } if (value.Meta is not null) diff --git a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs index e9aaad2fd..474811a23 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -90,7 +90,59 @@ public void StandaloneContext_NestedIcons_UsesCallerSettings(bool wrapped, bool [Fact] public void StandaloneContext_PrefersCallerNestedContracts() { - var options = GetStandaloneOptions(wrapped: false, caseInsensitive: false); + var options = GetCallerNestedOptions(wrapped: false); + const string Json = """{"type":"resource_link","uri":"file:///test","name":"test","icons":[{"callerSource":"https://example.com/icon.png","sizes":["48x48"]}],"annotations":{"callerPriority":0.5,"audience":["user"]}}"""; + + var block = Assert.IsType(ReadStandaloneBlock(Json, wrapped: false, options)); + + Assert.Equal("https://example.com/icon.png", Assert.Single(block.Icons!).Source); + Assert.Equal((float?)0.5, block.Annotations!.Priority); + Assert.Equal([Role.User], block.Annotations.Audience); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void StandaloneContext_RoundTripPreservesCallerNestedContracts(bool wrapped, bool includeIcons) + { + var options = GetCallerNestedOptions(wrapped); + ContentBlock original = includeIcons + ? new ResourceLinkBlock + { + Uri = "file:///test", + Name = "test", + Icons = [new Icon { Source = "https://example.com/icon.png", Sizes = ["48x48"] }], + } + : new TextContentBlock { Text = "ok" }; + original.Annotations = new Annotations { Priority = 0.5f, Audience = [Role.User] }; + + string json = wrapped + ? JsonSerializer.Serialize(new CallToolResult { Content = [original] }, options) + : JsonSerializer.Serialize(original, options); + var block = wrapped + ? Assert.Single(JsonSerializer.Deserialize(json, options)!.Content) + : Assert.IsAssignableFrom(JsonSerializer.Deserialize(json, options)); + + Assert.NotNull(block.Annotations); + Assert.Equal((float?)0.5, block.Annotations.Priority); + Assert.Equal([Role.User], block.Annotations.Audience); + if (includeIcons) + { + var icon = Assert.Single(Assert.IsType(block).Icons!); + Assert.Equal("https://example.com/icon.png", icon.Source); + Assert.Equal(["48x48"], icon.Sizes); + } + else + { + Assert.Equal("ok", Assert.IsType(block).Text); + } + } + + private static JsonSerializerOptions GetCallerNestedOptions(bool wrapped) + { + var options = GetStandaloneOptions(wrapped, caseInsensitive: false); options.TypeInfoResolver = JsonTypeInfoResolver.Combine( options.TypeInfoResolver!, CallerNestedContentJsonContext.Default.WithAddedModifier(typeInfo => @@ -107,13 +159,7 @@ public void StandaloneContext_PrefersCallerNestedContracts() } } })); - const string Json = """{"type":"resource_link","uri":"file:///test","name":"test","icons":[{"callerSource":"https://example.com/icon.png","sizes":["48x48"]}],"annotations":{"callerPriority":0.5,"audience":["user"]}}"""; - - var block = Assert.IsType(ReadStandaloneBlock(Json, wrapped: false, options)); - - Assert.Equal("https://example.com/icon.png", Assert.Single(block.Icons!).Source); - Assert.Equal((float?)0.5, block.Annotations!.Priority); - Assert.Equal([Role.User], block.Annotations.Audience); + return options; } private static JsonSerializerOptions GetStandaloneOptions(bool wrapped, bool caseInsensitive) => @@ -739,6 +785,8 @@ public void AudioContentBlock_FromBytes_LazilyEncodesData(byte[] originalBytes) [JsonSerializable(typeof(ContentBlock))] internal partial class StandaloneContentJsonContext : JsonSerializerContext; +// Use metadata serialization so caller-modified contracts apply to nested content. +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(CallToolResult))] internal partial class StandaloneCallToolContentJsonContext : JsonSerializerContext; From 980eec606406d0c3dd6eca848ceb742737af8e71 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Sun, 25 Oct 2026 12:07:55 -0700 Subject: [PATCH 10/10] Document nested content source-generation requirements --- docs/experimental.md | 23 +++++++++ .../Protocol/ContentBlock.cs | 7 +++ .../Protocol/ContentBlockTests.cs | 51 ++++++++++++++----- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/docs/experimental.md b/docs/experimental.md index 0704fb9cb..369b73a43 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -63,6 +63,29 @@ JsonSerializerOptions options = new() By placing the SDK's resolver first, MCP types are serialized using the SDK's contract (which includes experimental properties), while your custom context handles your own types. This is recommended even if you aren't currently using experimental APIs, since it ensures your serialization configuration remains correct as new experimental properties are introduced or as you adopt experimental features in the future. +### Customizing nested content contracts + +If you intentionally override the SDK's contracts for nested `Icon` or `Annotations` values, place your custom resolver before the SDK's resolver. Unlike the SDK-first configuration above, this selects your contracts, so you are responsible for preserving any experimental properties you need. + +When combining these customizations with an application-defined source-generated context, use `JsonSourceGenerationMode.Metadata` for every context that supplies an enclosing type, such as `CallToolResult`, not just the context that supplies `Icon` or `Annotations`: + +```csharp +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(CallToolResult))] +[JsonSerializable(typeof(Icon))] +[JsonSerializable(typeof(Annotations))] +internal partial class CustomContentContext : JsonSerializerContext; +``` + +Apply your contract modifiers through the resolver used by the `JsonSerializerOptions` passed to both serialization and deserialization. The SDK's content converter uses caller-supplied icon and annotation contracts first and supplies SDK metadata for missing nested types. + +The default source-generation mode can generate optimized serialization handlers for enclosing types. These handlers can pass their context's own options to the content converter, bypassing the caller's modified nested contracts. This can cause serialization to emit the original property names while deserialization expects the customized names, losing annotation values or rejecting icons with missing required properties. Changing only the nested context to metadata mode does not prevent the enclosing handler from doing this. + +Metadata-mode source generation avoids this mismatch and remains compatible with Native AOT and reflection-disabled applications. This requirement applies to intentional nested-contract customizations with caller-owned contexts; ordinary use of the SDK's serializer presets does not require a custom context. Keep the specification-defined property names when exchanging MCP protocol messages. + ## See also - [Versioning](versioning.md) diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index 53f122618..dadca2215 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -26,6 +26,13 @@ namespace ModelContextProtocol.Protocol; /// and other communication between clients and servers. /// /// +/// When customizing nested or contracts through a +/// caller-supplied source-generated resolver, configure the context for any enclosing type, such as +/// , with . +/// Optimized serialization handlers can use their context's own options instead of the caller's modified +/// nested contracts. Metadata-mode source generation preserves those customizations without requiring reflection. +/// +/// /// See the schema for more details. /// /// diff --git a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs index 474811a23..326044acf 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -87,13 +87,15 @@ public void StandaloneContext_NestedIcons_UsesCallerSettings(bool wrapped, bool Assert.Equal(["48x48"], icon.Sizes); } - [Fact] - public void StandaloneContext_PrefersCallerNestedContracts() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void StandaloneContext_PrefersCallerNestedContracts(bool wrapped) { - var options = GetCallerNestedOptions(wrapped: false); + var options = GetCallerNestedOptions(wrapped); const string Json = """{"type":"resource_link","uri":"file:///test","name":"test","icons":[{"callerSource":"https://example.com/icon.png","sizes":["48x48"]}],"annotations":{"callerPriority":0.5,"audience":["user"]}}"""; - var block = Assert.IsType(ReadStandaloneBlock(Json, wrapped: false, options)); + var block = Assert.IsType(ReadStandaloneBlock(Json, wrapped, options)); Assert.Equal("https://example.com/icon.png", Assert.Single(block.Icons!).Source); Assert.Equal((float?)0.5, block.Annotations!.Priority); @@ -101,13 +103,19 @@ public void StandaloneContext_PrefersCallerNestedContracts() } [Theory] - [InlineData(false, false)] - [InlineData(false, true)] - [InlineData(true, false)] - [InlineData(true, true)] - public void StandaloneContext_RoundTripPreservesCallerNestedContracts(bool wrapped, bool includeIcons) + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public void StandaloneContext_RoundTripPreservesNestedContracts(bool wrapped, bool includeIcons, bool customizeContracts) { - var options = GetCallerNestedOptions(wrapped); + var options = customizeContracts + ? GetCallerNestedOptions(wrapped) + : GetStandaloneOptions(wrapped, caseInsensitive: false); ContentBlock original = includeIcons ? new ResourceLinkBlock { @@ -121,6 +129,18 @@ public void StandaloneContext_RoundTripPreservesCallerNestedContracts(bool wrapp string json = wrapped ? JsonSerializer.Serialize(new CallToolResult { Content = [original] }, options) : JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + var serializedBlock = wrapped ? document.RootElement.GetProperty("content")[0] : document.RootElement; + var annotations = serializedBlock.GetProperty("annotations"); + Assert.Equal(0.5, annotations.GetProperty(customizeContracts ? "callerPriority" : "priority").GetDouble()); + Assert.False(annotations.TryGetProperty(customizeContracts ? "priority" : "callerPriority", out _)); + if (includeIcons) + { + var icon = serializedBlock.GetProperty("icons")[0]; + Assert.Equal("https://example.com/icon.png", icon.GetProperty(customizeContracts ? "callerSource" : "src").GetString()); + Assert.False(icon.TryGetProperty(customizeContracts ? "src" : "callerSource", out _)); + } + var block = wrapped ? Assert.Single(JsonSerializer.Deserialize(json, options)!.Content) : Assert.IsAssignableFrom(JsonSerializer.Deserialize(json, options)); @@ -142,7 +162,9 @@ public void StandaloneContext_RoundTripPreservesCallerNestedContracts(bool wrapp private static JsonSerializerOptions GetCallerNestedOptions(bool wrapped) { - var options = GetStandaloneOptions(wrapped, caseInsensitive: false); + var options = wrapped + ? new JsonSerializerOptions(MetadataCallToolContentJsonContext.Default.Options) + : GetStandaloneOptions(wrapped: false, caseInsensitive: false); options.TypeInfoResolver = JsonTypeInfoResolver.Combine( options.TypeInfoResolver!, CallerNestedContentJsonContext.Default.WithAddedModifier(typeInfo => @@ -785,11 +807,14 @@ public void AudioContentBlock_FromBytes_LazilyEncodesData(byte[] originalBytes) [JsonSerializable(typeof(ContentBlock))] internal partial class StandaloneContentJsonContext : JsonSerializerContext; -// Use metadata serialization so caller-modified contracts apply to nested content. -[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(CallToolResult))] internal partial class StandaloneCallToolContentJsonContext : JsonSerializerContext; +// The enclosing context must use metadata mode when nested contracts are customized. +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(CallToolResult))] +internal partial class MetadataCallToolContentJsonContext : JsonSerializerContext; + [JsonSerializable(typeof(Icon))] [JsonSerializable(typeof(Annotations))] internal partial class CallerNestedContentJsonContext : JsonSerializerContext;