Skip to content

Commit 30422d2

Browse files
committed
perf(core): UTF-8-first content blocks + lower-allocation stream/HTTP transports
This change set focuses on reducing allocations and unnecessary transcoding across the MCP wire path (JSON-RPC + MCP content), especially for line-delimited stream transports (stdio / raw streams) and for text content blocks that frequently originate as UTF-8 already. Key protocol / model updates - ContentBlock JSON converter now reads the "text" property directly into UTF-8 bytes (including unescaping) without first materializing a UTF-16 string. - Added Utf8TextContentBlock ("type":"text") to allow hot paths to keep text payloads as UTF-8; TextContentBlock now supports a cached UTF-16 string backed by a Utf8Text buffer. - Added opt-in deserialization behavior to materialize Utf8TextContentBlock instead of TextContentBlock via McpJsonUtilities.CreateOptions(materializeUtf8TextContentBlocks: true); DefaultOptions remains compatible and continues to materialize TextContentBlock. - ImageContentBlock and AudioContentBlock still have Data as a base64 string; added DataUtf8 and DecodedData helpers to avoid repeated conversions. - BlobResourceContents updated to cache and interoperate between (1) base64 string (Blob), (2) base64 UTF-8 bytes (BlobUtf8), and (3) decoded bytes (DecodedData) Serialization / JSON-RPC improvements - JsonRpcMessage converter now determines concrete message type (request/notification/response/error) by scanning the top-level payload with Utf8JsonReader.ValueTextEquals and skipping values in-place, avoiding JsonElement.GetRawText() / UTF-16 round-trips. - McpJsonUtilities now exposes CreateOptions(...) to produce an options instance that can override the ContentBlock converter (materializeUtf8TextContentBlocks) while still chaining MEAI type info resolver support. - Added McpTextUtilities helpers (UTF-8 decode, base64 encode on older TFMs, and common whitespace checks used by transports). Transport / streaming changes (stdio + streams) - StreamClientSessionTransport and StreamServerTransport reading loops were refactored to a newline-delimited (LF) byte scanner: - Parses messages directly from pooled byte buffers + a reusable MemoryStream buffer. - Handles both LF and CRLF by trimming a trailing '\r' after splitting on '\n'. - Avoids UTF-16 materialization for parsing and (optionally) for logging by only decoding to string when trace logging is enabled. - Added TextStreamClientSessionTransport to optimize the "text writer/reader" client transport: - Prefers writing UTF-8 directly when the writer is a StreamWriter. - Can read via the underlying stream when the reader is a UTF-8 StreamReader, falling back to ReadLineAsync for arbitrary TextReader implementations. HTTP transport / client plumbing - McpHttpClient now uses JsonContent.Create on NET TFMs and a new JsonTypeInfoHttpContent<T> on non-NET TFMs to serialize via JsonTypeInfo without buffering to compute Content-Length. - StreamableHttpClientSessionTransport and SseClientSessionTransport disposal paths now consistently cancel and "defuse" CTS instances to reduce races/leaks during teardown. - StreamableHttpSession updates activity tracking and shutdown ordering (dispose transport first, then cancel, then await server run) for cleaner termination. Cancellation / lifecycle utilities - Added CanceledTokenSource: a singleton already-canceled CancellationTokenSource plus a Defuse(ref cts, ...) helper to safely swap out mutable CTS fields during disposal. URI template parsing - UriTemplate parsing/formatting updated with a more explicit template-expression regex and improved query expression handling; uses GeneratedRegex and (on NET) a non-backtracking regex option for performance. Tests / samples - Added ProcessStartInfoUtilities to robustly locate executables on PATH and to handle Windows .cmd/.bat invocation semantics when UseShellExecute=false; updated integration tests accordingly. - Added TextMaterializationTestHelpers to let tests run with either TextContentBlock or Utf8TextContentBlock materialization. - Updated tests and samples to align with the new base64-string Data representation for image/audio blocks and to avoid unnecessary allocations in transport tests. Behavioral notes / compatibility - Wire format remains MCP/JSON-RPC compatible: messages are still newline-delimited JSON; CRLF continues to work. - The Utf8TextContentBlock materialization is opt-in via McpJsonUtilities.CreateOptions(materializeUtf8TextContentBlocks: true); default behavior preserves prior materialized types for text blocks.
1 parent b6e2c0d commit 30422d2

File tree

53 files changed

+2090
-545
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2090
-545
lines changed

samples/EverythingServer/Resources/SimpleResourceType.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public static ResourceContents TemplateResource(RequestContext<ReadResourceReque
3131
} :
3232
new BlobResourceContents
3333
{
34-
Blob = System.Text.Encoding.UTF8.GetBytes(resource.Description!),
34+
Blob = resource.Description!,
3535
MimeType = resource.MimeType,
3636
Uri = resource.Uri,
3737
};

samples/EverythingServer/Tools/AnnotatedMessageTool.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public static IEnumerable<ContentBlock> AnnotatedMessage(MessageType messageType
4141
{
4242
contents.Add(new ImageContentBlock
4343
{
44-
Data = System.Text.Encoding.UTF8.GetBytes(TinyImageTool.MCP_TINY_IMAGE.Split(",").Last()),
44+
Data = TinyImageTool.MCP_TINY_IMAGE.Split(",").Last(),
4545
MimeType = "image/png",
4646
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
4747
});

src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using ModelContextProtocol.Server;
1+
using ModelContextProtocol.Core;
2+
using ModelContextProtocol.Server;
23
using System.Diagnostics;
34
using System.Security.Claims;
45

@@ -16,7 +17,7 @@ internal sealed class StreamableHttpSession(
1617
private readonly object _stateLock = new();
1718

1819
private int _getRequestStarted;
19-
private readonly CancellationTokenSource _disposeCts = new();
20+
private CancellationTokenSource _disposeCts = new();
2021

2122
public string Id => sessionId;
2223
public StreamableHttpServerTransport Transport => transport;
@@ -124,7 +125,8 @@ public async ValueTask DisposeAsync()
124125
{
125126
sessionManager.DecrementIdleSessionCount();
126127
}
127-
_disposeCts.Dispose();
128+
129+
CanceledTokenSource.Defuse(ref _disposeCts);
128130
}
129131
}
130132

src/ModelContextProtocol.Core/AIContentExtensions.cs

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
using Microsoft.Extensions.AI;
22
using ModelContextProtocol.Client;
33
using ModelContextProtocol.Protocol;
4-
#if !NET
4+
using System.Buffers.Text;
55
using System.Runtime.InteropServices;
6-
#endif
76
using System.Text.Json;
87
using System.Text.Json.Nodes;
98

@@ -263,6 +262,8 @@ public static IList<PromptMessage> ToPromptMessages(this ChatMessage chatMessage
263262
AIContent? ac = content switch
264263
{
265264
TextContentBlock textContent => new TextContent(textContent.Text),
265+
266+
Utf8TextContentBlock utf8TextContent => new TextContent(utf8TextContent.Text),
266267

267268
ImageContentBlock imageContent => new DataContent(imageContent.DecodedData, imageContent.MimeType),
268269

@@ -275,7 +276,9 @@ public static IList<PromptMessage> ToPromptMessages(this ChatMessage chatMessage
275276

276277
ToolResultContentBlock toolResult => new FunctionResultContent(
277278
toolResult.ToolUseId,
278-
toolResult.Content.Count == 1 ? toolResult.Content[0].ToAIContent() : toolResult.Content.Select(c => c.ToAIContent()).OfType<AIContent>().ToList())
279+
toolResult.StructuredContent is JsonElement structured ? structured :
280+
toolResult.Content.Count == 1 ? toolResult.Content[0].ToAIContent() :
281+
toolResult.Content.Select(c => c.ToAIContent()).OfType<AIContent>().ToList())
279282
{
280283
Exception = toolResult.IsError is true ? new() : null,
281284
},
@@ -307,7 +310,7 @@ public static AIContent ToAIContent(this ResourceContents content)
307310

308311
AIContent ac = content switch
309312
{
310-
BlobResourceContents blobResource => new DataContent(blobResource.Data, blobResource.MimeType ?? "application/octet-stream"),
313+
BlobResourceContents blobResource => new DataContent(blobResource.DecodedData, blobResource.MimeType ?? "application/octet-stream"),
311314
TextResourceContents textResource => new TextContent(textResource.Text),
312315
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
313316
};
@@ -380,21 +383,25 @@ public static ContentBlock ToContentBlock(this AIContent content)
380383

381384
DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock
382385
{
383-
Data = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()),
386+
Data = MemoryMarshal.TryGetArray(dataContent.Base64Data, out ArraySegment<char> segment)
387+
? new string(segment.Array!, segment.Offset, segment.Count)
388+
: new string(dataContent.Base64Data.ToArray()),
384389
MimeType = dataContent.MediaType,
385390
},
386391

387392
DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
388393
{
389-
Data = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()),
394+
Data = MemoryMarshal.TryGetArray(dataContent.Base64Data, out ArraySegment<char> segment)
395+
? new string(segment.Array!, segment.Offset, segment.Count)
396+
: new string(dataContent.Base64Data.ToArray()),
390397
MimeType = dataContent.MediaType,
391398
},
392399

393400
DataContent dataContent => new EmbeddedResourceBlock
394401
{
395402
Resource = new BlobResourceContents
396403
{
397-
Blob = System.Text.Encoding.UTF8.GetBytes(dataContent.Base64Data.ToString()),
404+
DecodedData = dataContent.Data,
398405
MimeType = dataContent.MediaType,
399406
Uri = string.Empty,
400407
}
@@ -414,21 +421,51 @@ public static ContentBlock ToContentBlock(this AIContent content)
414421
Content =
415422
resultContent.Result is AIContent c ? [c.ToContentBlock()] :
416423
resultContent.Result is IEnumerable<AIContent> ec ? [.. ec.Select(c => c.ToContentBlock())] :
417-
[new TextContentBlock { Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo<object>()) }],
418-
StructuredContent = resultContent.Result is JsonElement je ? je : null,
424+
[new TextContentBlock { Text = "" }],
425+
StructuredContent =
426+
resultContent.Result is JsonElement je ? je :
427+
resultContent.Result is null ? null :
428+
JsonSerializer.SerializeToElement(resultContent.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo<object>()),
419429
},
420430

421-
_ => new TextContentBlock
431+
_ => CreateJsonResourceContentBlock(content)
432+
};
433+
434+
static ContentBlock CreateJsonResourceContentBlock(AIContent content)
435+
{
436+
byte[] jsonUtf8 = JsonSerializer.SerializeToUtf8Bytes(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
437+
438+
#if NET
439+
int maxLength = Base64.GetMaxEncodedToUtf8Length(jsonUtf8.Length);
440+
#else
441+
int maxLength = ((jsonUtf8.Length + 2) / 3) * 4;
442+
#endif
443+
444+
byte[] base64 = new byte[maxLength];
445+
if (Base64.EncodeToUtf8(jsonUtf8, base64, out _, out int bytesWritten) != System.Buffers.OperationStatus.Done)
422446
{
423-
Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
447+
throw new InvalidOperationException("Failed to base64-encode JSON payload.");
424448
}
425-
};
449+
450+
ReadOnlyMemory<byte> blob = base64.AsMemory(0, bytesWritten);
451+
452+
return new EmbeddedResourceBlock
453+
{
454+
Resource = new BlobResourceContents
455+
{
456+
Uri = string.Empty,
457+
MimeType = "application/json",
458+
BlobUtf8 = blob,
459+
},
460+
};
461+
}
426462

427463
contentBlock.Meta = content.AdditionalProperties?.ToJsonObject();
428464

429465
return contentBlock;
430466
}
431467

468+
432469
private sealed class ToolAIFunctionDeclaration(Tool tool) : AIFunctionDeclaration
433470
{
434471
public override string Name => tool.Name;

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Buffers.Text;
77
#endif
88
using System.Diagnostics.CodeAnalysis;
9+
using ModelContextProtocol.Internal;
910
using System.Net.Http.Headers;
1011
using System.Security.Cryptography;
1112
using System.Text;
@@ -581,8 +582,9 @@ private async Task PerformDynamicClientRegistrationAsync(
581582
Scope = GetScopeParameter(protectedResourceMetadata),
582583
};
583584

584-
var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
585-
using var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
585+
using var requestContent = new JsonTypeInfoHttpContent<DynamicClientRegistrationRequest>(
586+
registrationRequest,
587+
McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
586588

587589
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.RegistrationEndpoint)
588590
{
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace ModelContextProtocol.Core;
4+
5+
/// <summary>
6+
/// A <see cref="CancellationTokenSource"/> that is already canceled.
7+
/// Disposal is a no-op.
8+
/// </summary>
9+
public sealed class CanceledTokenSource : CancellationTokenSource
10+
{
11+
/// <summary>
12+
/// Gets a singleton instance of a canceled token source.
13+
/// </summary>
14+
public static readonly CanceledTokenSource Instance = new();
15+
16+
private CanceledTokenSource()
17+
=> Cancel();
18+
19+
/// <inheritdoc/>
20+
protected override void Dispose(bool disposing)
21+
{
22+
// No-op
23+
}
24+
25+
/// <summary>
26+
/// Defuses the given <see cref="CancellationTokenSource"/> by optionally canceling it
27+
/// and replacing it with the singleton canceled instance.
28+
/// The original token source is left for garbage collection and finalization provided
29+
/// there are no other references to it outstanding if <paramref name="dispose"/> is false.
30+
/// </summary>
31+
/// <param name="cts"> The token source to pseudo-dispose. May be null.</param>
32+
/// <param name="cancel"> Whether to cancel the token source before pseudo-disposing it.</param>
33+
/// <param name="dispose"> Whether to call Dispose on the token source.</param>
34+
[SuppressMessage("Design", "CA1062:Validate arguments of public methods")]
35+
public static void Defuse(ref CancellationTokenSource cts, bool cancel = true, bool dispose = false)
36+
{
37+
// don't null check; allow replacing null, allow throw on attempt to call Cancel
38+
var orig = cts;
39+
if (cancel) orig.Cancel();
40+
Interlocked.Exchange(ref cts, Instance);
41+
// presume the GC will finalize and dispose the original CTS as needed
42+
if (dispose) orig.Dispose();
43+
}
44+
}

src/ModelContextProtocol.Core/Client/McpClientTool.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,27 @@ result.StructuredContent is null &&
142142
case 1 when result.Content[0].ToAIContent() is { } aiContent:
143143
return aiContent;
144144

145-
case > 1 when result.Content.Select(c => c.ToAIContent()).ToArray() is { } aiContents && aiContents.All(static c => c is not null):
146-
return aiContents;
145+
case > 1:
146+
AIContent[] aiContents = new AIContent[result.Content.Count];
147+
bool allConverted = true;
148+
149+
for (int i = 0; i < aiContents.Length; i++)
150+
{
151+
if (result.Content[i].ToAIContent() is not { } c)
152+
{
153+
allConverted = false;
154+
break;
155+
}
156+
157+
aiContents[i] = c;
158+
}
159+
160+
if (allConverted)
161+
{
162+
return aiContents;
163+
}
164+
165+
break;
147166
}
148167
}
149168

src/ModelContextProtocol.Core/Client/McpHttpClient.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,9 @@ internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage re
3232
#if NET
3333
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
3434
#else
35-
return new StringContent(
36-
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
37-
Encoding.UTF8,
38-
"application/json"
35+
return new ModelContextProtocol.Internal.JsonTypeInfoHttpContent<JsonRpcMessage>(
36+
message,
37+
McpJsonUtilities.JsonContext.Default.JsonRpcMessage
3938
);
4039
#endif
4140
}

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.Logging;
22
using Microsoft.Extensions.Logging.Abstractions;
3+
using ModelContextProtocol.Core;
34
using ModelContextProtocol.Protocol;
45
using System.Diagnostics;
56
using System.Net.Http.Headers;
@@ -18,7 +19,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
1819
private readonly HttpClientTransportOptions _options;
1920
private readonly Uri _sseEndpoint;
2021
private Uri? _messageEndpoint;
21-
private readonly CancellationTokenSource _connectionCts;
22+
private CancellationTokenSource _connectionCts;
2223
private Task? _receiveTask;
2324
private readonly ILogger _logger;
2425
private readonly TaskCompletionSource<bool> _connectionEstablished;
@@ -114,7 +115,7 @@ private async Task CloseAsync()
114115
}
115116
finally
116117
{
117-
_connectionCts.Dispose();
118+
CanceledTokenSource.Defuse(ref _connectionCts, dispose: true);
118119
}
119120
}
120121
finally

src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace ModelContextProtocol.Client;
77
/// <summary>Provides the client side of a stdio-based session transport.</summary>
88
internal sealed class StdioClientSessionTransport(
99
StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
10-
StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
10+
StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, endpointName, loggerFactory)
1111
{
1212
private readonly StdioClientTransportOptions _options = options;
1313
private readonly Process _process = process;

0 commit comments

Comments
 (0)