Skip to content

Commit 4bb9d9b

Browse files
committed
Merge branch 'main' into halter73/split-filters
# Conflicts: # tests/ModelContextProtocol.TestSseServer/Program.cs
2 parents 85956c7 + 80b1ad2 commit 4bb9d9b

File tree

53 files changed

+1016
-158
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

+1016
-158
lines changed

ModelContextProtocol.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,6 @@
7878
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
7979
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
8080
<Project Path="tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj" />
81+
<Project Path="tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj" />
8182
</Folder>
8283
</Solution>

docs/concepts/tasks/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ uid: tasks
88
# MCP Tasks
99

1010
> [!WARNING]
11-
> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases.
11+
> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs.
1212
1313
The Model Context Protocol (MCP) supports [task-based execution] for long-running operations. Tasks enable a "call-now, fetch-later" pattern where clients can initiate operations that may take significant time to complete, then poll for status and retrieve results when ready.
1414

docs/experimental.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: Experimental APIs
3+
author: MackinnonBuck
4+
description: Working with experimental APIs in the MCP C# SDK
5+
uid: experimental
6+
---
7+
8+
The Model Context Protocol C# SDK uses the [`[Experimental]`](https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.experimentalattribute) attribute to mark APIs that are still in development and may change without notice. For more details on the SDK's versioning policy around experimental APIs, see the [Versioning](versioning.md) documentation.
9+
10+
## Suppressing experimental diagnostics
11+
12+
When you use an experimental API, the compiler produces a diagnostic (e.g., `MCPEXP001`) to ensure you're aware the API may change. If you want to use the API, suppress the diagnostic in one of these ways:
13+
14+
### Project-wide suppression
15+
16+
Add the diagnostic ID to `<NoWarn>` in your project file:
17+
18+
```xml
19+
<PropertyGroup>
20+
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
21+
</PropertyGroup>
22+
```
23+
24+
### Per-call suppression
25+
26+
Use `#pragma warning disable` around specific call sites:
27+
28+
```csharp
29+
#pragma warning disable MCPEXP001 // The Tasks feature is experimental per the MCP specification and is subject to change.
30+
tool.Execution = new ToolExecution { ... };
31+
#pragma warning restore MCPEXP001
32+
```
33+
34+
For a full list of experimental diagnostic IDs and their descriptions, see the [list of diagnostics](list-of-diagnostics.md#experimental-apis).
35+
36+
## Serialization behavior
37+
38+
Experimental properties on protocol types are fully serialized and deserialized when using the SDK's built-in serialization via <xref:ModelContextProtocol.McpJsonUtilities.DefaultOptions>. This means experimental data is transmitted on the wire even if your application code doesn't directly interact with it, preserving protocol compatibility.
39+
40+
The behavior of experimental properties differs depending on whether you use [reflection-based or source-generated](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) serialization:
41+
42+
- **Reflection-based serialization** (the default when no `JsonSerializerContext` is used): Experimental properties are included. No special configuration is needed.
43+
- **Source-generated serialization** (using a custom `JsonSerializerContext`): Experimental properties are **not** included in your context's serialization contract. This is by design, as it protects your compiled code against binary breaking changes to experimental APIs.
44+
45+
This means that switching between reflection-based and source-generated serialization can silently change which properties are serialized. To avoid this, source-generation users should configure a `TypeInfoResolverChain` as described below.
46+
47+
### Custom `JsonSerializerContext`
48+
49+
If you define your own `JsonSerializerContext` that includes MCP protocol types, configure a `TypeInfoResolverChain` so the SDK's resolver handles MCP types:
50+
51+
```csharp
52+
using ModelContextProtocol;
53+
54+
JsonSerializerOptions options = new()
55+
{
56+
TypeInfoResolverChain =
57+
{
58+
McpJsonUtilities.DefaultOptions.TypeInfoResolver!,
59+
MyCustomContext.Default,
60+
}
61+
};
62+
```
63+
64+
By placing the SDK's resolver first, MCP types are serialized using the SDK's contract (which includes experimental properties), while your custom context handles your own types. This is recommended even if you aren't currently using experimental APIs, since it ensures your serialization configuration remains correct as new experimental properties are introduced or as you adopt experimental features in the future.
65+
66+
## See also
67+
68+
- [Versioning](versioning.md)
69+
- [List of diagnostics](list-of-diagnostics.md#experimental-apis)
70+
- [Tasks](concepts/tasks/tasks.md) (an experimental feature)

docs/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ items:
55
href: api/ModelContextProtocol.yml
66
- name: Versioning
77
href: versioning.md
8+
- name: Experimental APIs
9+
href: experimental.md
810
- name: GitHub
911
href: https://github.com/ModelContextProtocol/csharp-sdk

samples/EverythingServer/Resources/SimpleResourceType.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ModelContextProtocol.Protocol;
22
using ModelContextProtocol.Server;
33
using System.ComponentModel;
4+
using System.Text;
45

56
namespace EverythingServer.Resources;
67

@@ -31,7 +32,7 @@ public static ResourceContents TemplateResource(RequestContext<ReadResourceReque
3132
} :
3233
new BlobResourceContents
3334
{
34-
Blob = resource.Description!,
35+
Blob = Encoding.UTF8.GetBytes(resource.Description!),
3536
MimeType = resource.MimeType,
3637
Uri = resource.Uri,
3738
};

samples/EverythingServer/Tools/AnnotatedMessageTool.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ModelContextProtocol.Protocol;
22
using ModelContextProtocol.Server;
33
using System.ComponentModel;
4+
using System.Text;
45

56
namespace EverythingServer.Tools;
67

@@ -41,7 +42,7 @@ public static IEnumerable<ContentBlock> AnnotatedMessage(MessageType messageType
4142
{
4243
contents.Add(new ImageContentBlock
4344
{
44-
Data = TinyImageTool.MCP_TINY_IMAGE.Split(",").Last(),
45+
Data = Encoding.UTF8.GetBytes(TinyImageTool.MCP_TINY_IMAGE.Split(",").Last()),
4546
MimeType = "image/png",
4647
Annotations = new() { Audience = [Role.User], Priority = 0.5f }
4748
});

src/Common/EncodingUtilities.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using System.Buffers;
2+
using System.Buffers.Text;
3+
using System.Diagnostics;
4+
using System.Text;
5+
6+
namespace ModelContextProtocol;
7+
8+
/// <summary>Provides helper methods for encoding operations.</summary>
9+
internal static class EncodingUtilities
10+
{
11+
/// <summary>
12+
/// Converts UTF-16 characters to UTF-8 bytes without intermediate string allocations.
13+
/// </summary>
14+
/// <param name="utf16">The UTF-16 character span to convert.</param>
15+
/// <returns>A byte array containing the UTF-8 encoded bytes.</returns>
16+
public static byte[] GetUtf8Bytes(ReadOnlySpan<char> utf16)
17+
{
18+
byte[] bytes = new byte[Encoding.UTF8.GetByteCount(utf16)];
19+
Encoding.UTF8.GetBytes(utf16, bytes);
20+
return bytes;
21+
}
22+
23+
/// <summary>
24+
/// Encodes binary data to base64-encoded UTF-8 bytes.
25+
/// </summary>
26+
/// <param name="data">The binary data to encode.</param>
27+
/// <returns>A ReadOnlyMemory containing the base64-encoded UTF-8 bytes.</returns>
28+
public static ReadOnlyMemory<byte> EncodeToBase64Utf8(ReadOnlyMemory<byte> data)
29+
{
30+
int maxLength = Base64.GetMaxEncodedToUtf8Length(data.Length);
31+
byte[] buffer = new byte[maxLength];
32+
OperationStatus status = Base64.EncodeToUtf8(data.Span, buffer, out _, out int bytesWritten);
33+
Debug.Assert(status == OperationStatus.Done, "Base64 encoding should succeed for valid input data");
34+
Debug.Assert(bytesWritten == buffer.Length, "Base64 encoding should always produce the same length as the max length");
35+
return buffer.AsMemory(0, bytesWritten);
36+
}
37+
38+
/// <summary>
39+
/// Decodes base64-encoded UTF-8 bytes to binary data.
40+
/// </summary>
41+
/// <param name="base64Data">The base64-encoded UTF-8 bytes to decode.</param>
42+
/// <returns>A ReadOnlyMemory containing the decoded binary data.</returns>
43+
/// <exception cref="FormatException">The input is not valid base64 data.</exception>
44+
public static ReadOnlyMemory<byte> DecodeFromBase64Utf8(ReadOnlyMemory<byte> base64Data)
45+
{
46+
int maxLength = Base64.GetMaxDecodedFromUtf8Length(base64Data.Length);
47+
byte[] buffer = new byte[maxLength];
48+
if (Base64.DecodeFromUtf8(base64Data.Span, buffer, out _, out int bytesWritten) == OperationStatus.Done)
49+
{
50+
// Base64 decoding may produce fewer bytes than the max length, due to whitespace anywhere in the string or padding.
51+
Debug.Assert(bytesWritten <= buffer.Length, "Base64 decoding should never produce more bytes than the max length");
52+
return buffer.AsMemory(0, bytesWritten);
53+
}
54+
else
55+
{
56+
throw new FormatException("Invalid base64 data");
57+
}
58+
}
59+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#if !NET
5+
6+
namespace System.Text;
7+
8+
internal static class EncodingExtensions
9+
{
10+
/// <summary>
11+
/// Gets the number of bytes required to encode the specified characters.
12+
/// </summary>
13+
public static int GetByteCount(this Encoding encoding, ReadOnlySpan<char> chars)
14+
{
15+
if (chars.IsEmpty)
16+
{
17+
return 0;
18+
}
19+
20+
unsafe
21+
{
22+
fixed (char* charsPtr = chars)
23+
{
24+
return encoding.GetByteCount(charsPtr, chars.Length);
25+
}
26+
}
27+
}
28+
29+
/// <summary>
30+
/// Encodes the specified characters into the specified byte span.
31+
/// </summary>
32+
public static int GetBytes(this Encoding encoding, ReadOnlySpan<char> chars, Span<byte> bytes)
33+
{
34+
if (chars.IsEmpty)
35+
{
36+
return 0;
37+
}
38+
39+
unsafe
40+
{
41+
fixed (char* charsPtr = chars)
42+
fixed (byte* bytesPtr = bytes)
43+
{
44+
return encoding.GetBytes(charsPtr, chars.Length, bytesPtr, bytes.Length);
45+
}
46+
}
47+
}
48+
}
49+
50+
#endif

src/Common/ServerSentEvents/SseEventWriterHelpers.cs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,12 @@ public static void WriteUtf8String(this IBufferWriter<byte> writer, ReadOnlySpan
4747
return;
4848
}
4949

50-
#if NET
5150
int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length);
5251
Span<byte> buffer = writer.GetSpan(maxByteCount);
5352
Debug.Assert(buffer.Length >= maxByteCount);
5453

5554
int bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
5655
writer.Advance(bytesWritten);
57-
#else
58-
// netstandard2.0 doesn't have the Span overload of GetBytes
59-
byte[] bytes = Encoding.UTF8.GetBytes(value.ToString());
60-
Span<byte> buffer = writer.GetSpan(bytes.Length);
61-
bytes.AsSpan().CopyTo(buffer);
62-
writer.Advance(bytes.Length);
63-
#endif
6456
}
6557

6658
public static bool ContainsLineBreaks(this ReadOnlySpan<char> text) =>

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,34 @@ internal sealed class StreamableHttpHandler(
2323
ILoggerFactory loggerFactory)
2424
{
2525
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
26+
private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version";
2627
private const string LastEventIdHeaderName = "Last-Event-ID";
2728

29+
/// <summary>
30+
/// All protocol versions supported by this implementation.
31+
/// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core.
32+
/// </summary>
33+
private static readonly HashSet<string> s_supportedProtocolVersions =
34+
[
35+
"2024-11-05",
36+
"2025-03-26",
37+
"2025-06-18",
38+
"2025-11-25",
39+
];
40+
2841
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
2942
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
3043

3144
public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value;
3245

3346
public async Task HandlePostRequestAsync(HttpContext context)
3447
{
48+
if (!ValidateProtocolVersionHeader(context, out var errorMessage))
49+
{
50+
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
51+
return;
52+
}
53+
3554
// The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream.
3655
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
3756
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
@@ -74,6 +93,12 @@ await WriteJsonRpcErrorAsync(context,
7493

7594
public async Task HandleGetRequestAsync(HttpContext context)
7695
{
96+
if (!ValidateProtocolVersionHeader(context, out var errorMessage))
97+
{
98+
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
99+
return;
100+
}
101+
77102
if (!context.Request.GetTypedHeaders().Accept.Any(MatchesTextEventStreamMediaType))
78103
{
79104
await WriteJsonRpcErrorAsync(context,
@@ -171,6 +196,12 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex
171196

172197
public async Task HandleDeleteRequestAsync(HttpContext context)
173198
{
199+
if (!ValidateProtocolVersionHeader(context, out var errorMessage))
200+
{
201+
await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest);
202+
return;
203+
}
204+
174205
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
175206
if (sessionManager.TryRemove(sessionId, out var session))
176207
{
@@ -391,6 +422,24 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session,
391422

392423
internal static JsonTypeInfo<T> GetRequiredJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
393424

425+
/// <summary>
426+
/// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility,
427+
/// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec.
428+
/// </summary>
429+
private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage)
430+
{
431+
var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString();
432+
if (!string.IsNullOrEmpty(protocolVersionHeader) &&
433+
!s_supportedProtocolVersions.Contains(protocolVersionHeader))
434+
{
435+
errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.";
436+
return false;
437+
}
438+
439+
errorMessage = null;
440+
return true;
441+
}
442+
394443
private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
395444
=> acceptHeaderValue.MatchesMediaType("application/json");
396445

0 commit comments

Comments
 (0)