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/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.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() { 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() {