Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -42,7 +43,9 @@ void FormatEvent(SseItem<JsonRpcMessage?> item, IBufferWriter<byte> 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
{
Expand Down
6 changes: 3 additions & 3 deletions src/ModelContextProtocol.Core/Client/McpHttpClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Protocol;
using System.Diagnostics;
using System.Net.Http.Headers;

Expand Down Expand Up @@ -32,9 +32,9 @@ internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage re
}

#if NET
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage, s_applicationJsonContentType);
return JsonContent.Create(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>(), s_applicationJsonContentType);
#else
var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcMessage>());
var content = new ByteArrayContent(bytes);
content.Headers.ContentType = s_applicationJsonContentType;
return content;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonRpcMessage>());
await _serverInputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
await _serverInputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
await _serverInputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
Expand Down
8 changes: 8 additions & 0 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -45,14 +46,14 @@ private static void FormatJsonRpcMessage(SseItem<JsonRpcMessage?> 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<JsonRpcMessage>());
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonRpcMessage>());
LogTransportSendingMessageSensitive(message);
await _outputStream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallToolRequestParams> context, IProgress<ProgressNotificationValue> 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<string>();
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<string>();
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()
{
Expand Down
18 changes: 17 additions & 1 deletion tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<JsonRpcMessage>());

Assert.Contains("привет 你好", json);
Assert.DoesNotContain(((char)92) + "u043f", json);
}

[Fact]
public static void DefaultOptions_UseReflectionWhenEnabled()
{
Expand Down