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/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: 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.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 b193bb8de..13eaf8a99 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -27,19 +27,48 @@ public static partial class McpJsonUtilities /// Enables as the default number handling for number types. /// /// + /// + /// 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(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 DefaultOptions { get; } = CreateDefaultOptions(); + 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. - JsonSerializerOptions options = new(JsonContext.Default.Options); + // 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) + { + PropertyNameCaseInsensitive = propertyNameCaseInsensitive, + }; // Chain with all supported types from MEAI. options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); @@ -101,10 +130,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. 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, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - NumberHandling = JsonNumberHandling.AllowReadingFromString)] + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PropertyNameCaseInsensitive = false)] // JSON-RPC [JsonSerializable(typeof(JsonRpcMessage))] diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index d0b1b80ec..dadca2215 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; @@ -24,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. /// /// @@ -76,6 +85,28 @@ private protected ContentBlock() [EditorBrowsable(EditorBrowsableState.Never)] public sealed class Converter : JsonConverter { + 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_nestedOptions.GetValue(options, CreateNestedOptions) + : CreateNestedOptions(options); + return nestedOptions.GetTypeInfo(); + } + + private static JsonSerializerOptions CreateNestedOptions(JsonSerializerOptions callerOptions) + { + // 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); + nestedOptions.TypeInfoResolverChain.Add(McpJsonUtilities.JsonContext.Default); + nestedOptions.MakeReadOnly(); + return nestedOptions; + } + /// public override ContentBlock? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -172,7 +203,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, GetNestedTypeInfo(options)) ?? throw new JsonException("Unexpected null item in icons array.")); } } @@ -183,7 +214,7 @@ public sealed class Converter : JsonConverter break; case "annotations": - annotations = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.Annotations); + annotations = JsonSerializer.Deserialize(ref reader, GetNestedTypeInfo(options)); break; case "_meta": @@ -357,7 +388,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(); } @@ -394,7 +425,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/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.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/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/Client/ProtocolPropertyCasingTests.cs b/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs new file mode 100644 index 000000000..8d2957803 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/ProtocolPropertyCasingTests.cs @@ -0,0 +1,100 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +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 TestMcpClient.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/McpJsonUtilitiesTests.cs b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs index cc55746fe..6d87d9827 100644 --- a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs @@ -17,6 +17,70 @@ 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 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/Protocol/ContentBlockTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs index 6e943f025..326044acf 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs @@ -1,11 +1,251 @@ 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); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void StandaloneContext_PrefersCallerNestedContracts(bool wrapped) + { + 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, 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, 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 = customizeContracts + ? GetCallerNestedOptions(wrapped) + : GetStandaloneOptions(wrapped, caseInsensitive: false); + 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); + 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)); + + 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 = wrapped + ? new JsonSerializerOptions(MetadataCallToolContentJsonContext.Default.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"; + } + } + })); + return options; + } + + 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)] + [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 +802,19 @@ 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 +} + +[JsonSerializable(typeof(ContentBlock))] +internal partial class StandaloneContentJsonContext : JsonSerializerContext; + +[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; 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() { 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(); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 8fd1d9954..c77151884 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -67,6 +67,41 @@ public async Task SupportsMcpServer() Assert.Equal("42", (result.Content[0] as TextContentBlock)?.Text); } + [Fact] + public async Task BindsNestedArgumentPropertiesCaseInsensitively() + { + Mock mockServer = new(); + + 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() + { + 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() { @@ -1272,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 ===== 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() {