Skip to content

Avoid re-copying request _meta during typed params deserialization - #2002

Open
krubenok wants to merge 1 commit into
modelcontextprotocol:mainfrom
krubenok:u/krubenok/pooled-params-deserialization
Open

krubenok wants to merge 1 commit into
modelcontextprotocol:mainfrom
krubenok:u/krubenok/pooled-params-deserialization

Conversation

@krubenok

@krubenok krubenok commented Nov 7, 2026

Copy link
Copy Markdown

Motivation

We profiled allocations on a production stateless Streamable HTTP server built on this SDK. Clients there routinely send a large params._meta. About 16% of sampled allocations were large-object-heap byte[] arrays, and about a third of those came from the SDK copying _meta while turning a request's Params into typed params. The data is a small sample, so take the proportions as directional.

Each request with _meta larger than ~85 KB currently makes two full, unpooled copies of it, and both go on the large object heap:

  1. Reading the message. JsonRpcMessage.Converter reads params as a JsonNode, and System.Text.Json copies the value into a new, non-pooled JsonDocument. Reading into a single contiguous buffer first doesn't help: the span path makes the same copy.
  2. Typed params. RequestHandlers calls JsonSerializer.Deserialize(request.Params, typeInfo), which writes the node out and parses it again. RequestParams.Meta is a JsonObject, so _meta gets copied a second time.

This PR removes the second copy.

Change

RequestHandlers now goes through a DeserializeParams helper. When the target type derives from RequestParams, uses the default object converter, and params._meta is a JSON object:

  • every property except _meta is written to a pooled buffer and deserialized from there; the buffer is cleared before it goes back to the pool;
  • Meta is then set to _meta.DeepClone(). The params node comes from a document that is never disposed, so JsonElement.Clone() hands back the same element. The clone reuses the bytes already parsed instead of copying them.

The clone is still a detached, independent node. Changing it doesn't affect request.Params, and it can be attached to another object. Any other shape of _meta or params uses the existing path unchanged, including its errors.

There are no public API changes.

Measurements

Allocated bytes per request, relative to _meta size, on net10.0:

Scenario _meta shape Before After
Stateless HTTP tools/call one large string 2.0× 1.0×
Stateless HTTP tools/call nested object 3.2× 1.6×
Typed params only, 1 MB one large string ~1,024 KB ~2 KB

Large-object-heap bytes per request also halve; at 1 MB they go from 2,048 KB to 1,024 KB.

Trade-off: in an unusual case the change costs slightly more. That's a _meta with tens of thousands of flat top-level keys whose dictionary has already been materialized, for example by trace-context extraction. The clone duplicates the top-level dictionary entries, so total allocations go up about 5%, while large-object-heap bytes still drop about 16%.

Not addressed

  • The copy made while reading the message. Removing it would mean tying a pooled document's lifetime to the request. That would break code that keeps Params after the handler returns, such as filters, captured state, or task-augmented work.
  • Other _meta costs. Diagnostics and per-request metadata projection materialize the whole top-level _meta dictionary to read a few keys, and line-based transports also allocate a UTF-16 string per message.

These may be worth following up on separately.

Tests

  • RequestParamsMetaTests covers:

    • typed Meta matches the request _meta;
    • the two are independent and Meta can be attached elsewhere;
    • missing or null _meta;
    • non-object _meta and invalid non-_meta params are still rejected.

    These pass both with and without the change.

  • RequestParamsMetaAllocationTests (DisableParallelization, .NET only) handles a request with a 4 MB _meta. Before the change it allocated about 12.6 MB and failed; it now stays well under the 2 MB threshold.

  • ModelContextProtocol.Tests passes on net10.0, net8.0 and net472, except that on net10.0 the ClientIntegrationTests fail locally because they need npm ci. ModelContextProtocol.AspNetCore.Tests passes on net10.0. Tests weren't run on net9.0 because that runtime isn't installed locally.

Note

This PR description and change were generated with GitHub Copilot.

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 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant