Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)

This commit is contained in:
Stephen Halter 2026-06-23 21:24:22 -07:00 • committed by GitHub
commit 6c323e783b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 5704 additions and 712 deletions

View file

@ -9,15 +9,15 @@ namespace ModelContextProtocol.Tests.Server;
/// Verifies that the server-to-client request methods (<see cref="McpServer.ElicitAsync(ElicitRequestParams, CancellationToken)"/>,
/// <see cref="McpServer.SampleAsync(CreateMessageRequestParams, CancellationToken)"/>,
/// <see cref="McpServer.RequestRootsAsync"/>) keep working when the negotiated protocol revision is
/// <c>DRAFT-2026-v1</c> on a stateful session - for example, stdio.
/// <c>2026-07-28</c> on a stateful session - for example, stdio.
/// </summary>
/// <remarks>
/// Under <c>DRAFT-2026-v1</c> the spec removes the corresponding server-to-client request methods, but
/// Under <c>2026-07-28</c> the spec removes the corresponding server-to-client request methods, but
/// the SDK only fails fast in stateless mode (where the existing <c>ThrowIf*Unsupported</c> guards already
/// throw "X is not supported in stateless mode" because <see cref="McpServer.ClientCapabilities"/> is
/// <see langword="null"/>). Stdio is implicitly stateful - one <see cref="McpServer"/> per process - so the
/// legacy <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> flow still works.
/// A future PR is expected to force <c>DRAFT-2026-v1</c> Streamable HTTP servers to stateless mode, at which
/// A future PR is expected to force <c>2026-07-28</c> Streamable HTTP servers to stateless mode, at which
/// point those configurations will start throwing through the existing stateless guard.
/// </remarks>
public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
@ -31,7 +31,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
{
services.Configure<McpServerOptions>(options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
});
mcpServerBuilder.WithTools([
@ -47,7 +47,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
StartServer();
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = "DRAFT-2026-v1",
ProtocolVersion = "2026-07-28",
Capabilities = new ClientCapabilities
{
Elicitation = new ElicitationCapability(),
@ -69,7 +69,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
StartServer();
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = "DRAFT-2026-v1",
ProtocolVersion = "2026-07-28",
Capabilities = new ClientCapabilities
{
Sampling = new SamplingCapability(),
@ -96,7 +96,7 @@ public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
StartServer();
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = "DRAFT-2026-v1",
ProtocolVersion = "2026-07-28",
Capabilities = new ClientCapabilities
{
Roots = new RootsCapability(),

View file

@ -31,7 +31,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.Configure<McpServerOptions>(options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
_messageTracker.AddFilters(options.Filters.Message);
});
@ -191,7 +191,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
StartServer();
var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
{
// Cancel the token during the callback. The retry loop will throw
@ -219,7 +219,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
StartServer();
var elicitHandlerCalled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = async (request, ct) =>
{
// Signal that the MRTR round trip reached the client, then block indefinitely.
@ -250,6 +250,29 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
// The client call should fail (server disposed mid-MRTR).
await Assert.ThrowsAnyAsync<Exception>(async () => await callTask);
// Disposing the server while a continuation is suspended should log the cancellation of the
// pending MRTR continuation once at Debug level (this is the only path that reaches the
// continuation-cancellation log now that HTTP draft is always sessionless). Poll for the
// async cancellation to propagate through the handler task.
var deadline = DateTime.UtcNow.AddSeconds(30);
while (!MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation"))
&& DateTime.UtcNow < deadline)
{
await Task.Delay(50, TestContext.Current.CancellationToken);
}
var mrtrCancelledLog = MockLoggerProvider.LogMessages
.Where(m => m.Message.Contains("pending MRTR continuation"))
.ToList();
var log = Assert.Single(mrtrCancelledLog);
Assert.Equal(LogLevel.Debug, log.LogLevel);
Assert.Contains("1", log.Message);
// The handler's OperationCanceledException must be silently observed during disposal, not
// logged as an error.
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
m.LogLevel >= LogLevel.Error && m.Message.Contains("cancellation-test-tool"));
}
[Fact]
@ -263,7 +286,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
// (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts.
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
@ -301,7 +324,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
StartServer();
int elicitationCount = 0;
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
{
Interlocked.Increment(ref elicitationCount);
@ -345,7 +368,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
StartServer();
bool handlerCompleted = false;
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
@ -387,7 +410,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
// (after resuming from ElicitAsync), the error is logged at Error level.
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
@ -417,7 +440,7 @@ public class MrtrHandlerLifecycleTests : ClientServerTestBase
// not an error. It should not be logged via ToolCallError at Error level.
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });

View file

@ -23,7 +23,7 @@ public class MrtrInputRequiredExceptionTests : ClientServerTestBase
{
services.Configure<McpServerOptions>(options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
_messageTracker.AddFilters(options.Filters.Message);
});

View file

@ -26,7 +26,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
{
services.Configure<McpServerOptions>(options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
_messageTracker.AddFilters(options.Filters.Message);
});
@ -73,14 +73,14 @@ public class MrtrMessageFilterTests : ClientServerTestBase
// When both sides are on the experimental protocol, the server should use MRTR
// (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests.
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
{
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
};
await using var client = await CreateMcpClientForServer(clientOptions);
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("elicit-tool",
new Dictionary<string, object?> { ["message"] = "test" },
@ -95,7 +95,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire()
{
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
{
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
@ -107,7 +107,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
};
await using var client = await CreateMcpClientForServer(clientOptions);
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("sample-tool",
new Dictionary<string, object?> { ["prompt"] = "test" },
@ -126,7 +126,7 @@ public class MrtrMessageFilterTests : ClientServerTestBase
var sawIncompleteResult = false;
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
{
// If we reach this handler, it means the client received an InputRequiredResult

View file

@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for the legacy MRTR backcompat resolver in <c>McpServerImpl.InvokeWithInputRequiredResultHandlingAsync</c>.
/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful -
/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful -
/// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler
/// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks
/// a non-draft version during initialize negotiation.

View file

@ -49,7 +49,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase
{
services.Configure<McpServerOptions>(options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
_messageTracker.AddFilters(options.Filters.Message);
// Outgoing filter: detect InputRequiredResult responses and track per session.
@ -131,7 +131,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase
// Verify that an outgoing message filter can observe InputRequiredResult responses
// and track the pending MRTR flow count per session using context.Server.SessionId.
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
@ -165,7 +165,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase
_maxFlowsPerSession = 0;
StartServer();
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" };
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });

View file

@ -0,0 +1,153 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.ComponentModel;
using System.IO.Pipelines;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Verifies the server establishes its negotiated protocol version exactly once per stateful session: the
/// initial <see langword="null"/>-to-value transition is allowed and re-sending the same version is an
/// idempotent no-op, but a request that switches to a different (even if otherwise supported) version is
/// rejected with <see cref="McpErrorCode.InvalidRequest"/>. The session is driven over a raw stream
/// transport (the stdio-shaped, stateful path) so the per-request <c>_meta</c> protocol version is fully
/// controlled - the SDK's own client normalizes it on every outgoing request, so only a misbehaving peer
/// can trigger a mid-session change.
/// </summary>
public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposable
{
private readonly Pipe _clientToServer = new();
private readonly Pipe _serverToClient = new();
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
private readonly ServiceProvider _services;
private readonly Task _serverTask;
private readonly StreamWriter _writer;
private readonly StreamReader _reader;
public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddLogging();
serviceCollection.AddSingleton<ILoggerProvider>(XunitLoggerProvider);
serviceCollection
.AddMcpServer()
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
.WithTools<EchoTools>();
_services = serviceCollection.BuildServiceProvider(validateScopes: true);
var server = _services.GetRequiredService<McpServer>();
_serverTask = server.RunAsync(_cts.Token);
_writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true };
_reader = new StreamReader(_serverToClient.Reader.AsStream());
}
[Fact]
public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterChange()
{
var ct = TestContext.Current.CancellationToken;
// The first request establishes the draft version for the stateful session (null -> draft).
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 1, McpSession.DraftProtocolVersion, ct));
// Re-sending the same version is an idempotent no-op, not an error.
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 2, McpSession.DraftProtocolVersion, ct));
// Switching to a different (still-supported) version mid-session is rejected.
var error = Assert.IsType<JsonRpcError>(await RoundTripAsync(id: 3, McpSession.LatestProtocolVersion, ct));
Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase);
// The rejected request must not have mutated the negotiated version: the original draft version still works.
Assert.IsType<JsonRpcResponse>(await RoundTripAsync(id: 4, McpSession.DraftProtocolVersion, ct));
}
private async Task<JsonRpcMessage> RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken)
{
// tools/list is available under both the legacy and draft revisions (unlike ping/initialize,
// which the draft revision removed), so it exercises the version guard rather than the
// per-method availability gate.
var request = new JsonRpcRequest
{
Id = new RequestId(id),
Method = RequestMethods.ToolsList,
Params = new JsonObject
{
["_meta"] = new JsonObject
{
[MetaKeys.ProtocolVersion] = protocolVersion,
},
},
};
string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)));
#if NET
await _writer.WriteLineAsync(json.AsMemory(), cancellationToken);
#else
cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(json);
#endif
while (true)
{
#if NET
string? line = await _reader.ReadLineAsync(cancellationToken)
.AsTask()
.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
#else
string? line = await _reader.ReadLineAsync()
.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
#endif
if (line is null)
{
throw new InvalidOperationException("Server stream closed before responding.");
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var message = (JsonRpcMessage)JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)))!;
// Ignore anything that isn't the response to the request we just sent (e.g. notifications).
if (message is JsonRpcMessageWithId withId && withId.Id.Equals(request.Id))
{
return message;
}
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_clientToServer.Writer.Complete();
_serverToClient.Writer.Complete();
try
{
await _serverTask;
}
catch (OperationCanceledException)
{
}
await _services.DisposeAsync();
_cts.Dispose();
Dispose();
}
[McpServerToolType]
private sealed class EchoTools
{
[McpServerTool, Description("Echoes the input back to the caller.")]
public static string Echo([Description("The message to echo.")] string message) => message;
}
}

View file

@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Verifies that the built-in <c>ping</c> handler is gated by protocol version.
/// SEP-2575 (the draft 2026-07-28 revision) removes <c>ping</c>; servers must
/// respond with <c>-32601 MethodNotFound</c>. Legacy protocol versions still
/// support <c>ping</c> per the spec.
/// </summary>
public sealed class PingProtocolGatingTests : ClientServerTestBase
{
public PingProtocolGatingTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper, startServer: false)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
}
[Fact]
public async Task Ping_OnDraftSession_ReturnsMethodNotFound()
{
StartServer();
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpSession.DraftProtocolVersion,
});
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode);
}
[Fact]
public async Task Ping_OnLegacySession_StillSucceeds()
{
// Default server config; client pinned to 2025-11-25.
StartServer();
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = "2025-11-25",
});
var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
}
}

View file

@ -0,0 +1,188 @@
#if !NET472
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Wire-format conformance tests for <see cref="McpServer"/> driven directly against the underlying
/// stream — without going through <see cref="ModelContextProtocol.Client.McpClient"/>. This exercises the
/// SEP-2575 (sessionless / no-initialize) and SEP-2567 (server/discover) flows by hand-crafting JSON-RPC
/// messages and asserting on the exact responses the server emits.
/// </summary>
/// <remarks>
/// The tests use a paired <see cref="Pipe"/> the way <see cref="ClientServerTestBase"/> does, but instead
/// of constructing an <c>McpClient</c> we read and write JSON-RPC envelopes directly. This is the closest
/// approximation we have to a third-party / non-SDK client and is what conformance tooling will exercise.
/// </remarks>
public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private readonly Pipe _clientToServer = new();
private readonly Pipe _serverToClient = new();
private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
private readonly Task _serverTask;
private readonly ServiceProvider _services;
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
public RawStreamConformanceTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddProvider(XunitLoggerProvider));
services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = "raw-conformance-server", Version = "1.0.0" };
})
.WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream())
.WithTools([
McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" }),
]);
_services = services.BuildServiceProvider(validateScopes: true);
var server = _services.GetRequiredService<McpServer>();
_serverTask = server.RunAsync(_cts.Token);
_writer = new StreamWriter(_clientToServer.Writer.AsStream(), new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(_serverToClient.Reader.AsStream(), Encoding.UTF8);
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_clientToServer.Writer.Complete();
_serverToClient.Writer.Complete();
try { await _serverTask; } catch { /* expected on cancellation */ }
await _services.DisposeAsync();
_cts.Dispose();
Dispose();
}
private async Task SendAsync(string json) => await _writer.WriteLineAsync(json);
private async Task<JsonNode> ReadAsync()
{
var line = await _reader.ReadLineAsync(_cts.Token).ConfigureAwait(false);
Assert.NotNull(line);
return JsonNode.Parse(line!)!;
}
private static string DraftMetaFragment(string protocolVersion = DraftVersion) =>
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
[Fact]
public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft()
{
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}");
var response = await ReadAsync();
Assert.Equal("2.0", response["jsonrpc"]!.GetValue<string>());
Assert.Equal(1, response["id"]!.GetValue<int>());
var result = response["result"];
Assert.NotNull(result);
var supportedVersions = result!["supportedVersions"]!.AsArray()
.Select(n => n!.GetValue<string>())
.ToList();
Assert.Contains(DraftVersion, supportedVersions);
// Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575.
Assert.NotNull(result["capabilities"]);
Assert.NotNull(result["serverInfo"]);
Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue<string>());
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind());
Assert.Equal(0, result["ttlMs"]!.GetValue<long>());
Assert.Equal("private", result["cacheScope"]!.GetValue<string>());
}
[Fact]
public async Task DraftToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided()
{
// Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long
// as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities.
await SendAsync(
@"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," +
DraftMetaFragment() + "}}");
var response = await ReadAsync();
Assert.Equal(42, response["id"]!.GetValue<int>());
var result = response["result"];
Assert.NotNull(result);
var content = result!["content"]!.AsArray();
Assert.Single(content);
Assert.Equal("echo:hello", content[0]!["text"]!.GetValue<string>());
}
[Fact]
public async Task DraftRequest_WithUnsupportedProtocolVersion_ReturnsMinus32004WithSupported()
{
// Server should respond with UnsupportedProtocolVersionError (-32004) and a data.supported[] list.
await SendAsync(
@"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
DraftMetaFragment("9999-99-99") + "}}");
var response = await ReadAsync();
Assert.Equal(7, response["id"]!.GetValue<int>());
var error = response["error"];
Assert.NotNull(error);
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error!["code"]!.GetValue<int>());
var data = error["data"];
Assert.NotNull(data);
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
Assert.Contains(DraftVersion, supported);
}
[Fact]
public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer()
{
// Dual-era: a draft-default server (ProtocolVersion = DraftVersion in McpServerOptions) must still
// accept the legacy initialize handshake from clients that don't speak the new protocol.
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}");
var response = await ReadAsync();
Assert.Equal(1, response["id"]!.GetValue<int>());
var result = response["result"];
Assert.NotNull(result);
Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue<string>());
}
[Fact]
public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed()
{
// Dual-era servers must accept draft and legacy traffic on the same connection. The exact mix below
// is what a permissive client running against an unknown server would emit while probing.
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}");
var discover = await ReadAsync();
Assert.NotNull(discover["result"]);
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}");
var init = await ReadAsync();
Assert.NotNull(init["result"]);
Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue<string>());
await SendAsync(@"{""jsonrpc"":""2.0"",""method"":""notifications/initialized"",""params"":{}}");
await SendAsync(@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""after-init""}}}");
var call = await ReadAsync();
Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue<string>());
}
}
#endif

View file

@ -0,0 +1,161 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// End-to-end tests for the SEP-2575 <c>subscriptions/listen</c> list-changed delivery over an
/// in-memory stream transport (the stdio-shaped path exercised by <see cref="ClientServerTestBase"/>).
/// Validates that a draft client receives only the change notifications it subscribed to, each tagged
/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast.
/// </summary>
public class SubscriptionsListenTests : ClientServerTestBase
{
public SubscriptionsListenTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.WithTools<ListenTools>();
mcpServerBuilder.WithPrompts<ListenPrompts>();
}
[Fact]
public async Task Draft_ToolsListChangedSubscription_DeliversTaggedNotification_AndWithholdsUnsubscribed()
{
await using McpClient client = await CreateMcpClientForServer();
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
var promptsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
await using var promptsReg = client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification,
(notification, _) => { promptsChannel.Writer.TryWrite(notification); return default; });
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var listenTask = SendSubscriptionsListenAsync(client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token);
// SEP-2575: the acknowledgement is always sent first, tagged with the subscription id.
var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
var subscriptionId = GetSubscriptionId(ack);
Assert.NotNull(subscriptionId);
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var serverTools = serverOptions.ToolCollection!;
var serverPrompts = serverOptions.PromptCollection!;
// A prompt change must never reach this client: it only subscribed to tool list changes. Because the
// fan-out skips it without sending anything, the prompts channel stays empty for the rest of the test.
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "AddedPrompt")] () => "added"));
// A tool change must arrive on the subscription stream, tagged with the same subscription id as the ack.
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
var toolsNotification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.Equal(subscriptionId, GetSubscriptionId(toolsNotification));
// Tear down the open subscription request before the client is disposed.
await CancelSubscriptionAsync(listenCts, listenTask);
// The prompt change fired before the (delivered) tool change, and notifications arrive in order
// on the subscription stream, so any prompt notification would already be buffered by now.
// Complete the writer and assert the channel drains empty - i.e. nothing was ever delivered,
// not merely "nothing is buffered at this instant". WaitToReadAsync returns false only when the
// channel is both empty and completed; a buffered erroneous notification would make it true.
promptsChannel.Writer.Complete();
Assert.False(await promptsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Draft_WithoutSubscription_DoesNotBroadcastListChanged()
{
await using McpClient client = await CreateMcpClientForServer();
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
// The change notification must not be broadcast to a draft client that never opened a
// subscriptions/listen stream. The list-changed handler runs synchronously during Add (before
// the ListTools round-trip below completes), so any erroneous broadcast would already be
// buffered once the round-trip returns.
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Complete the writer and assert the channel drains empty rather than just checking the current
// buffer: WaitToReadAsync returns false only when the channel is both empty and completed.
toolsChannel.Writer.Complete();
Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription()
{
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpSession.LatestProtocolVersion,
});
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
// Legacy sessions keep the session-wide broadcast and the notification carries no subscription id.
var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.Null(GetSubscriptionId(notification));
}
private static Task SendSubscriptionsListenAsync(McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken)
{
var request = new JsonRpcRequest
{
Method = RequestMethods.SubscriptionsListen,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsListenRequestParams { Notifications = notifications },
McpJsonUtilities.DefaultOptions),
};
return client.SendRequestAsync(request, cancellationToken);
}
private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask)
{
await listenCts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listenTask);
}
private static string? GetSubscriptionId(JsonRpcNotification notification)
=> ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString();
[McpServerToolType]
private sealed class ListenTools
{
[McpServerTool, Description("Echoes the input back to the caller.")]
public static string Echo([Description("The message to echo.")] string message) => message;
}
[McpServerPromptType]
private sealed class ListenPrompts
{
[McpServerPrompt, Description("A simple prompt.")]
public static ChatMessage Simple() => new(ChatRole.User, "hello");
}
}

View file

@ -0,0 +1,189 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Verifies that the SEP-2663 Tasks extension is gated to the draft protocol revision on both the
/// client and the server. Explicit task operations throw on a legacy session; best-effort task
/// augmentation silently downgrades to a direct result so that legacy peers never see a task.
/// </summary>
public class TaskDraftGatingTests : ClientServerTestBase
{
private const string LatestStableVersion = "2025-11-25";
private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";
private const string ExtensionsKey = "extensions";
public TaskDraftGatingTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
#if !NET
Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587");
#endif
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
{
options.TaskStore = new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
};
});
mcpServerBuilder.WithTools([McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct);
return $"Processed: {input}";
},
new McpServerToolCreateOptions
{
Name = "test-tool",
Description = "A test tool"
})]);
}
private static IDictionary<string, JsonElement> CreateArguments(string key, string value)
{
return new Dictionary<string, JsonElement>
{
[key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone()
};
}
private static JsonObject CreateForgedTaskOptInMeta() =>
new()
{
[ClientCapabilitiesMetaKey] = new JsonObject
{
[ExtensionsKey] = new JsonObject
{
[McpExtensions.Tasks] = new JsonObject(),
},
},
};
[Fact]
public async Task LegacyClient_GetTaskAsync_ThrowsInvalidOperationException()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await client.GetTaskAsync("some-task-id", ct));
Assert.Contains("draft protocol revision", ex.Message);
}
[Fact]
public async Task LegacyClient_UpdateTaskAsync_ThrowsInvalidOperationException()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await client.UpdateTaskAsync(new UpdateTaskRequestParams { TaskId = "some-task-id" }, ct));
Assert.Contains("draft protocol revision", ex.Message);
}
[Fact]
public async Task LegacyClient_CancelTaskAsync_ThrowsInvalidOperationException()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await client.CancelTaskAsync("some-task-id", ct));
Assert.Contains("draft protocol revision", ex.Message);
}
[Fact]
public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "test-tool",
Arguments = CreateArguments("input", "legacy"),
}, ct);
Assert.False(result.IsTask);
Assert.NotNull(result.Result);
}
[Fact]
public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
// Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy
// request. The server must still refuse to create a task because the per-request protocol
// version is not the draft revision.
var result = await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "test-tool",
Arguments = CreateArguments("input", "forged"),
Meta = CreateForgedTaskOptInMeta(),
}, ct);
Assert.False(result.IsTask);
Assert.NotNull(result.Result);
}
[Fact]
public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
// Bypass the typed GetTaskAsync client guard by sending a raw tasks/get request. The server
// gates tasks/* to the draft revision and must reject this legacy request with MethodNotFound.
var request = new JsonRpcRequest
{
Method = RequestMethods.TasksGet,
Params = JsonSerializer.SerializeToNode(
new GetTaskRequestParams { TaskId = "some-task-id" },
McpJsonUtilities.DefaultOptions),
};
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.SendRequestAsync(request, ct));
Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode);
}
[Fact]
public async Task DraftClient_CallToolRaw_CreatesTask()
{
// Sanity: the default client negotiates the draft revision, so the task flow still works.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var result = await client.CallToolRawAsync(
new CallToolRequestParams
{
Name = "test-tool",
Arguments = CreateArguments("input", "draft"),
}, ct);
Assert.True(result.IsTask);
Assert.NotNull(result.TaskCreated);
}
}