From 45b55fa3f6d736c415a1caebc27f94903db2401f Mon Sep 17 00:00:00 2001 From: "Kyle Rubenok (from Dev Box)" Date: Fri, 6 Nov 2026 21:32:36 -0800 Subject: [PATCH] Avoid re-copying request _meta during typed params deserialization Deserializing typed request parameters from the JsonRpcRequest.Params node re-serialized and re-parsed the whole node. Because RequestParams.Meta is a JsonObject, System.Text.Json copied the entire _meta payload into a new unpooled buffer, which put a second full copy of large metadata on the large object heap for every request. When _meta is an object, it is now left out of the re-parse and assigned with DeepClone(). Params nodes are backed by non-disposable documents, so the clone shares the already-parsed bytes and is still an independent, detached node. Any other shape uses the existing path, so behavior and errors don't change. For a stateless HTTP tools/call with a large _meta, allocations per request drop from about 2.0x to 1.0x of the _meta size for a single large string, and from 3.2x to 1.6x for a nested object. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RequestHandlers.cs | 75 ++++++- .../Server/RequestParamsMetaTests.cs | 191 ++++++++++++++++++ 2 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/RequestParamsMetaTests.cs diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index 2a21008f4..289063d0a 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -1,4 +1,6 @@ using ModelContextProtocol.Protocol; +using System.Net.ServerSentEvents; +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; @@ -40,7 +42,7 @@ public void Set( this[method] = async (request, cancellationToken) => { - TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; + TParams typedRequest = DeserializeParams(request.Params, requestTypeInfo)!; object? result = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); return JsonSerializer.SerializeToNode(result, responseTypeInfo); }; @@ -65,7 +67,7 @@ public void SetWithAlternate( this[method] = async (request, cancellationToken) => { - TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; + TParams typedRequest = DeserializeParams(request.Params, requestTypeInfo)!; var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); if (augmented.IsAlternate) @@ -77,4 +79,73 @@ public void SetWithAlternate( }; } #pragma warning restore MCPEXP002 + + /// + /// Deserializes request parameters into their typed representation. + /// + /// + /// + /// Deserializing a directly re-serializes it and then re-parses every property. For the + /// property this means System.Text.Json copies the entire _meta payload + /// into a new, unpooled buffer, which for large metadata lands on the large object heap on every request. + /// + /// + /// When _meta is an object, it is therefore excluded from the re-parse and assigned afterwards via + /// . The params node is backed by a non-disposable document, so cloning shares + /// the already-parsed UTF-8 bytes instead of copying them while still producing an independent node. + /// Any other shape uses the standard deserialization path so behavior, including errors, is unchanged. + /// + /// + internal static TParams? DeserializeParams(JsonNode? parameters, JsonTypeInfo typeInfo) + { + if (parameters is not JsonObject paramsObject || + typeInfo.Kind is not JsonTypeInfoKind.Object || + !typeof(RequestParams).IsAssignableFrom(typeof(TParams)) || + !paramsObject.TryGetPropertyValue("_meta", out JsonNode? metaNode) || + metaNode is not JsonObject meta) + { + return JsonSerializer.Deserialize(parameters, typeInfo); + } + + using var buffer = new PooledByteBufferWriter(); + try + { + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (KeyValuePair property in paramsObject) + { + if (property.Key == "_meta") + { + continue; + } + + writer.WritePropertyName(property.Key); + if (property.Value is null) + { + writer.WriteNullValue(); + } + else + { + property.Value.WriteTo(writer, typeInfo.Options); + } + } + + writer.WriteEndObject(); + } + + TParams? result = JsonSerializer.Deserialize(buffer.WrittenMemory.Span, typeInfo); + if (result is RequestParams requestParams) + { + requestParams.Meta = (JsonObject)meta.DeepClone(); + } + + return result; + } + finally + { + // The buffer is returned to a shared pool; don't leave request content behind in it. + MemoryMarshal.AsMemory(buffer.WrittenMemory).Span.Clear(); + } + } } diff --git a/tests/ModelContextProtocol.Tests/Server/RequestParamsMetaTests.cs b/tests/ModelContextProtocol.Tests/Server/RequestParamsMetaTests.cs new file mode 100644 index 000000000..ca87d3f87 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/RequestParamsMetaTests.cs @@ -0,0 +1,191 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies how request _meta is surfaced on typed request parameters. +/// +public class RequestParamsMetaTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + [Fact] + public async Task TypedMeta_MatchesRequestMeta_AndIsIndependentOfIt() + { + string json = """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"echo", + "arguments":{"text":"hi"}, + "_meta":{"progressToken":"token-1","example.com/nested":{"a":[1,2,{"b":null}],"c":"\u00e9"},"example.com/flag":true} + }} + """; + + var (response, captured) = await InvokeToolAsync(json, LoggerFactory); + + Assert.IsType(response); + Assert.NotNull(captured); + CallToolRequestParams typed = captured.Value.Typed; + JsonObject rawParams = Assert.IsType(captured.Value.RawParams); + JsonObject rawMeta = Assert.IsType(rawParams["_meta"]); + + Assert.Equal("echo", typed.Name); + Assert.Equal("hi", typed.Arguments!["text"].GetString()); + Assert.NotNull(typed.Meta); + Assert.True(JsonNode.DeepEquals(rawMeta, typed.Meta)); + Assert.Equal("token-1", typed.ProgressToken?.ToString()); + + // The typed metadata is a detached, independent node. + Assert.NotSame(rawMeta, typed.Meta); + Assert.Null(typed.Meta.Parent); + typed.Meta["example.com/added"] = 42; + ((JsonObject)typed.Meta["example.com/nested"]!)["c"] = "changed"; + Assert.False(rawMeta.ContainsKey("example.com/added")); + Assert.Equal("\u00e9", rawMeta["example.com/nested"]!["c"]!.GetValue()); + + // It can be attached to another node without conflicting with the original request. + var outgoing = new JsonObject { ["_meta"] = typed.Meta }; + Assert.Same(outgoing, typed.Meta.Parent); + } + + [Fact] + public async Task TypedMeta_IsNull_WhenRequestHasNoMeta() + { + string json = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}"""; + + var (response, captured) = await InvokeToolAsync(json, LoggerFactory); + + Assert.IsType(response); + Assert.NotNull(captured); + Assert.Equal("echo", captured.Value.Typed.Name); + Assert.Null(captured.Value.Typed.Meta); + } + + [Fact] + public async Task TypedMeta_IsNull_WhenRequestMetaIsNull() + { + string json = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","_meta":null}}"""; + + var (response, captured) = await InvokeToolAsync(json, LoggerFactory); + + Assert.IsType(response); + Assert.NotNull(captured); + Assert.Null(captured.Value.Typed.Meta); + } + + [Theory] + [InlineData("\"not-an-object\"")] + [InlineData("[1,2]")] + [InlineData("42")] + public async Task NonObjectMeta_IsRejected(string metaJson) + { + string json = $$$"""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","_meta":{{{metaJson}}}}}"""; + + var (response, captured) = await InvokeToolAsync(json, LoggerFactory); + + Assert.Null(captured); + Assert.IsType(response); + } + + [Fact] + public async Task InvalidNonMetaParams_AreStillRejected_WhenMetaIsAnObject() + { + string json = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":42,"_meta":{"a":1}}}"""; + + var (response, captured) = await InvokeToolAsync(json, LoggerFactory); + + Assert.Null(captured); + Assert.IsType(response); + } + + internal static async Task<(JsonRpcMessage Response, (CallToolRequestParams Typed, JsonNode? RawParams)? Captured)> InvokeToolAsync( + string requestJson, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) + { + // Deserialize the request the same way transports do so that params are backed by parsed JSON. + var request = Assert.IsType(JsonSerializer.Deserialize( + Encoding.UTF8.GetBytes(requestJson), McpJsonUtilities.DefaultOptions)); + + return await InvokeToolAsync(request, loggerFactory); + } + + internal static async Task<(JsonRpcMessage Response, (CallToolRequestParams Typed, JsonNode? RawParams)? Captured)> InvokeToolAsync( + JsonRpcRequest request, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) + { + (CallToolRequestParams Typed, JsonNode? RawParams)? captured = null; + + var options = new McpServerOptions + { + ProtocolVersion = "2024-11-05", + Capabilities = new ServerCapabilities { Tools = new() }, + }; + options.Handlers.ListToolsHandler = (_, _) => new ValueTask(new ListToolsResult()); + options.Handlers.CallToolHandler = (context, _) => + { + captured = (context.Params!, ((JsonRpcRequest)context.JsonRpcMessage).Params); + return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = "ok" }] }); + }; + + await using var transport = new TestServerTransport(); + await using var server = McpServer.Create(transport, options, loggerFactory); + + var responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + transport.OnMessageSent = message => + { + if (message is JsonRpcMessageWithId withId && withId.Id == request.Id) + { + responseTcs.TrySetResult(message); + } + }; + + var runTask = server.RunAsync(TestContext.Current.CancellationToken); + await transport.SendClientMessageAsync(request, TestContext.Current.CancellationToken); + var response = await responseTcs.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + await transport.DisposeAsync(); + await runTask.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + return (response, captured); + } +} + +#if NET +/// +/// Guards against reintroducing an extra full copy of large request _meta payloads when request +/// parameters are deserialized into their typed representation. +/// +[Collection(nameof(DisableParallelization))] +public class RequestParamsMetaAllocationTests +{ + [Fact] + public async Task TypedParamsDeserialization_DoesNotCopyLargeMeta() + { + const int MetaSize = 4 * 1024 * 1024; + string json = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"_meta\":{\"example.com/blob\":\"" + + new string('a', MetaSize) + + "\"}}}"; + byte[] utf8 = Encoding.UTF8.GetBytes(json); + + // Warm up the server pipeline so one-time initialization isn't measured. + await RequestParamsMetaTests.InvokeToolAsync(ParseRequest(utf8), Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + + var request = ParseRequest(utf8); + long before = GC.GetTotalAllocatedBytes(precise: true); + var (response, captured) = await RequestParamsMetaTests.InvokeToolAsync(request, Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + long allocated = GC.GetTotalAllocatedBytes(precise: true) - before; + + Assert.IsType(response); + Assert.NotNull(captured); + Assert.Equal(MetaSize, captured.Value.Typed.Meta!["example.com/blob"]!.GetValue().Length); + + // Handling the request (excluding the initial message parse) previously copied the entire _meta + // payload again. Allow generous headroom for unrelated allocations while still catching that copy. + Assert.True(allocated < MetaSize / 2, $"Handling the request allocated {allocated:N0} bytes."); + + static JsonRpcRequest ParseRequest(byte[] utf8) => + Assert.IsType(JsonSerializer.Deserialize(utf8, McpJsonUtilities.DefaultOptions)); + } +} +#endif