From 8b63bb36b923b096712524345233dcb801cc0f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B0=D1=80=D0=B0=D1=82=D0=B0=D0=B5=D0=B2=20=D0=94?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 26 Oct 2026 11:56:18 +0300 Subject: [PATCH 1/2] Keep non-ASCII unescaped in wire serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP payloads are read by LLMs, not embedded into HTML. The conservative default encoder escapes every non-ASCII char into a six-character sequence, costing x2-3 tokens on Cyrillic/CJK tool results. Escaping is semantically neutral in JSON; MEAI (AIJsonUtilities) and ASP.NET Core already default to JavaScriptEncoder.UnsafeRelaxedJsonEscaping. - Set the relaxed encoder on McpJsonUtilities.DefaultOptions - Serialize wire messages (stdio server/client, HTTP client, SSE writer) via DefaultOptions instead of the frozen generated JsonContext options - Propagate the encoder into the SSE Utf8JsonWriter Addresses #626. Signed-off-by: Каратаев Дмитрий --- .../Client/McpHttpClient.cs | 6 +++--- .../Client/StreamClientSessionTransport.cs | 2 +- .../McpJsonUtilities.cs | 8 ++++++++ .../Server/McpSseEventWriterExtensions.cs | 5 +++-- .../Server/StreamServerTransport.cs | 2 +- .../McpJsonUtilitiesTests.cs | 18 +++++++++++++++++- 6 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs index f4df789c7..d628266db 100644 --- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; using System.Diagnostics; using System.Net.Http.Headers; @@ -32,9 +32,9 @@ internal virtual async Task SendAsync(HttpRequestMessage re } #if NET - return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage, s_applicationJsonContentType); + return JsonContent.Create(message, McpJsonUtilities.DefaultOptions.GetTypeInfo(), s_applicationJsonContentType); #else - var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); + var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions.GetTypeInfo()); var content = new ByteArrayContent(bytes); content.Headers.ContentType = s_applicationJsonContentType; return content; diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs index 7a69c1c83..62d824e2f 100644 --- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs @@ -80,7 +80,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false); try { - var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); + var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _serverInputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false); await _serverInputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false); await _serverInputStream.FlushAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 13eaf8a99..560d5dc59 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -79,6 +80,13 @@ private static JsonSerializerOptions CreateDefaultOptions(bool propertyNameCaseI options.Converters.Add(new JsonStringEnumConverter()); } + // MCP payloads are read by LLMs, not embedded into HTML: keep non-ASCII as raw UTF-8. + // The conservative default encoder turns every non-ASCII char into a six-character + // escape sequence, costing x2-3 tokens on Cyrillic/CJK tool results. Escaping is + // semantically neutral in JSON; this matches the MEAI (AIJsonUtilities) and ASP.NET + // Core defaults. + options.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + options.MakeReadOnly(); return options; } diff --git a/src/ModelContextProtocol.Core/Server/McpSseEventWriterExtensions.cs b/src/ModelContextProtocol.Core/Server/McpSseEventWriterExtensions.cs index 3f62021f0..2843418c2 100644 --- a/src/ModelContextProtocol.Core/Server/McpSseEventWriterExtensions.cs +++ b/src/ModelContextProtocol.Core/Server/McpSseEventWriterExtensions.cs @@ -1,6 +1,7 @@ using ModelContextProtocol.Protocol; using System.Buffers; using System.Net.ServerSentEvents; +using System.Text.Encodings.Web; using System.Text.Json; namespace ModelContextProtocol.Server; @@ -45,14 +46,14 @@ private static void FormatJsonRpcMessage(SseItem item, IBufferW if (_jsonWriter is null) { - _jsonWriter = new Utf8JsonWriter(writer); + _jsonWriter = new Utf8JsonWriter(writer, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); } else { _jsonWriter.Reset(writer); } - JsonSerializer.Serialize(_jsonWriter, item.Data, McpJsonUtilities.JsonContext.Default.JsonRpcMessage!); + JsonSerializer.Serialize(_jsonWriter, item.Data, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } /// diff --git a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs index cc9765066..ab985ad17 100644 --- a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs @@ -74,7 +74,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation try { - var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); + var json = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions.GetTypeInfo()); LogTransportSendingMessageSensitive(message); await _outputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false); await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false); diff --git a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs index 6d87d9827..22a568cc7 100644 --- a/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using ModelContextProtocol.Protocol; @@ -81,6 +82,21 @@ public static void ProtocolOptions_DoesNotBindAlternatelyCasedCallToolResultFiel Assert.True(result.IsError); } + [Fact] + public static void DefaultOptions_KeepsNonAsciiUnescaped() + { + JsonRpcResponse message = new() + { + Id = new RequestId(1), + Result = new JsonObject { ["text"] = "привет 你好" }, + }; + + string json = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + + Assert.Contains("привет 你好", json); + Assert.DoesNotContain(((char)92) + "u043f", json); + } + [Fact] public static void DefaultOptions_UseReflectionWhenEnabled() { From 028096a7173789b0988e291e84dfa033503ad0a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B0=D1=80=D0=B0=D1=82=D0=B0=D0=B5=D0=B2=20=D0=94?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 21:03:32 +0300 Subject: [PATCH 2/2] Propagate the relaxed encoder into the resumability replay writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay path (SseEventStreamReaderExtensions.CopyToAsync) created its Utf8JsonWriter without options, and a writer's encoder overrides JsonSerializerOptions.Encoder - so a reconnecting client with an event store received replayed events escaped while live ones were unescaped. Pass the same JsonWriterOptions as McpSseEventWriterExtensions, and cover both writers with an integration test that reads progress notifications from the live POST response stream and from a Last-Event-ID replay. Signed-off-by: Каратаев Дмитрий --- .../SseEventStreamReaderExtensions.cs | 5 +- .../ResumabilityIntegrationTestsBase.cs | 129 ++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs b/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs index f17df37a1..c6015cd7d 100644 --- a/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Server; using System.Buffers; using System.Net.ServerSentEvents; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -42,7 +43,9 @@ void FormatEvent(SseItem item, IBufferWriter writer) if (jsonWriter is null) { - jsonWriter = new Utf8JsonWriter(writer); + // The writer's encoder overrides JsonSerializerOptions.Encoder when serializing into a Utf8JsonWriter, + // so the relaxed encoder must be propagated here as well to match McpSseEventWriterExtensions. + jsonWriter = new Utf8JsonWriter(writer, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); } else { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 87218202f..db0117818 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -337,6 +337,135 @@ public virtual async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconne Assert.Equal(1, reconnectNotificationReceivedCount); } + [Fact] + public async Task ResumedStreamData_KeepsNonAsciiUnescaped() + { + // Arrange - a tool that emits two progress notifications before its result. All three + // messages are stored on the POST response stream, so resuming from the first event + // replays the later ones through SseEventStreamReaderExtensions' Utf8JsonWriter. + const string ProgressToolName = "progress_tool"; + const string FirstMessage = "первое 你好"; + const string ReplayedMessage = "повтор 你好"; + var progressTool = McpServerTool.Create(async (RequestContext context, IProgress progress) => + { + progress.Report(new() { Progress = 0, Message = FirstMessage }); + progress.Report(new() { Progress = 50, Message = ReplayedMessage }); + return "Complete"; + }, options: new() { Name = ProgressToolName }); + + await using var app = await CreateServerAsync(configureServer: builder => + { + builder.WithTools([progressTool]); + }); + + // Initialize the session. + using var initRequest = new HttpRequestMessage(HttpMethod.Post, "/") + { + Headers = + { + Accept = { new("application/json"), new("text/event-stream") } + }, + Content = new StringContent(InitializeRequest, Encoding.UTF8, "application/json"), + }; + using var initResponse = await HttpClient.SendAsync(initRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + initResponse.EnsureSuccessStatusCode(); + + var sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First(); + + await using var initStream = await initResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); + await foreach (var _ in SseParser.Create(initStream).EnumerateAsync(TestContext.Current.CancellationToken)) + { + // Consume the stream. + } + + // Send the initialized notification to complete the handshake. + using var initializedRequest = new HttpRequestMessage(HttpMethod.Post, "/") + { + Headers = + { + Accept = { new("application/json"), new("text/event-stream") } + }, + Content = new StringContent("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json"), + }; + initializedRequest.Headers.Add("Mcp-Session-Id", sessionId); + + using var initializedResponse = await HttpClient.SendAsync(initializedRequest, TestContext.Current.CancellationToken); + initializedResponse.EnsureSuccessStatusCode(); + + // Act - call the tool over SSE and read the live response stream to completion. + // This exercises the Utf8JsonWriter in McpSseEventWriterExtensions. + using var callRequest = new HttpRequestMessage(HttpMethod.Post, "/") + { + Headers = + { + Accept = { new("application/json"), new("text/event-stream") } + }, + Content = new StringContent(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 2, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = ProgressToolName, + ["arguments"] = new JsonObject(), + ["_meta"] = new JsonObject + { + // Without a progress token the server injects NullProgress and never sends progress notifications. + ["progressToken"] = "progress-token-1", + }, + }, + }.ToJsonString(), Encoding.UTF8, "application/json"), + }; + callRequest.Headers.Add("Mcp-Session-Id", sessionId); + + using var callResponse = await HttpClient.SendAsync(callRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + callResponse.EnsureSuccessStatusCode(); + + string? firstStreamEventId = null; + var liveData = new List(); + await using var callStream = await callResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); + await foreach (var sseItem in SseParser.Create(callStream).EnumerateAsync(TestContext.Current.CancellationToken)) + { + liveData.Add(sseItem.Data); + firstStreamEventId ??= sseItem.EventId; + } + + Assert.NotNull(firstStreamEventId); + Assert.Contains(liveData, data => data.Contains(FirstMessage, StringComparison.Ordinal)); + Assert.DoesNotContain(liveData, data => IsNonAsciiEscaped(data)); + + // Resume the POST response stream from its first event. + // This exercises the Utf8JsonWriter in SseEventStreamReaderExtensions serving the replay. + using var resumeRequest = new HttpRequestMessage(HttpMethod.Get, "/") + { + Headers = + { + Accept = { new("text/event-stream") } + }, + }; + resumeRequest.Headers.Add("Mcp-Session-Id", sessionId); + resumeRequest.Headers.Add("Last-Event-ID", firstStreamEventId); + + using var resumeResponse = await HttpClient.SendAsync(resumeRequest, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + resumeResponse.EnsureSuccessStatusCode(); + + var replayedData = new List(); + await using var resumeStream = await resumeResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); + await foreach (var sseItem in SseParser.Create(resumeStream).EnumerateAsync(TestContext.Current.CancellationToken)) + { + replayedData.Add(sseItem.Data); + } + + // Assert - the replayed events include both progress notifications with non-ASCII intact. + Assert.Contains(replayedData, data => data.Contains(FirstMessage, StringComparison.Ordinal)); + Assert.Contains(replayedData, data => data.Contains(ReplayedMessage, StringComparison.Ordinal)); + Assert.DoesNotContain(replayedData, data => IsNonAsciiEscaped(data)); + } + + private static bool IsNonAsciiEscaped(string data) + => data.Contains("\\u04", StringComparison.Ordinal) || data.Contains("\\u4f", StringComparison.Ordinal); + [Fact] public virtual async Task Server_Returns400_WhenLastEventIdRefersToWrongSession() {