Skip to content

Commit bb9f572

Browse files
halter73Copilot
andcommitted
Add draft-mode tests and update XML docs
Adds round-trip tests for the new draft protocol revision and updates the XML documentation on ExperimentalProtocolVersion (client + server) to describe the full SEP-2575 + SEP-2567 behavior (sessionless, handshake-less, server/discover, MRTR-only server-to-client interactions, fallback to legacy initialize on unsupported-version responses). Tests added: - DiscoverProtocolTests / SubscriptionsListenProtocolTests / DraftErrorDataTests: JSON-serialization round-trip coverage for the new protocol types and error data payloads. - DraftConnectionTests: end-to-end client/server connection flow for draft client vs. draft server, draft client vs. legacy server (fallback), legacy client vs. draft server, and explicit server/discover invocation. - DraftHttpHandlerTests (AspNetCore): HTTP-level checks that draft requests don't emit Mcp-Session-Id, unsupported protocol versions return -32004 with the structured supportedVersions payload, draft requests carrying an Mcp-Session-Id route through the legacy lookup, and draft GET/DELETE are rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 46970b6 commit bb9f572

7 files changed

Lines changed: 512 additions & 14 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClientOptions.cs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,35 @@ public McpClientHandlers Handlers
113113
public bool SendTaskStatusNotifications { get; set; } = true;
114114

115115
/// <summary>
116-
/// Gets or sets an experimental protocol version that enables draft protocol features such as
117-
/// Multi Round-Trip Requests (MRTR).
116+
/// Gets or sets an experimental protocol version that enables draft protocol features.
118117
/// </summary>
119118
/// <remarks>
120119
/// <para>
121-
/// When set, this version is used as the requested protocol version during initialization instead of
122-
/// the latest stable version. The server must also have a matching <c>ExperimentalProtocolVersion</c>
123-
/// configured for the experimental features to activate. If the server does not recognize the
124-
/// experimental version, it will negotiate to the latest stable version and the client will work
125-
/// normally without experimental features.
120+
/// When set, this version is used as the requested protocol version during connection establishment
121+
/// instead of the latest stable version. Under the draft revision (SEP-2575 + SEP-2567 + SEP-2322),
122+
/// the client:
123+
/// </para>
124+
/// <list type="bullet">
125+
/// <item><description>Skips the legacy <c>initialize</c> handshake. The server's capabilities, info,
126+
/// and instructions are learned via the <c>server/discover</c> RPC instead.</description></item>
127+
/// <item><description>Carries <c>protocolVersion</c>, <c>clientInfo</c>, and <c>clientCapabilities</c>
128+
/// in the per-request <c>_meta</c> field on every request, replacing what the handshake previously
129+
/// negotiated once.</description></item>
130+
/// <item><description>Does not include the <c>Mcp-Session-Id</c> header on HTTP requests, and does
131+
/// not expect one in responses. The protocol is sessionless at every layer.</description></item>
132+
/// <item><description>Uses MRTR (<see cref="IncompleteResult"/>) for server-initiated sampling,
133+
/// elicitation, and roots requests instead of standalone server-to-client JSON-RPC requests.</description></item>
134+
/// </list>
135+
/// <para>
136+
/// If the server does not support the requested experimental version (either it returns a
137+
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> error or its <c>server/discover</c> reply
138+
/// omits the version), the client falls back to the legacy <c>initialize</c> handshake with the
139+
/// highest mutually-supported stable version.
126140
/// </para>
127141
/// <para>
128142
/// This property is intended for proof-of-concept and testing of draft MCP specification features
129-
/// that have not yet been ratified.
143+
/// that have not yet been ratified. The legacy stateful interaction model continues to work for
144+
/// clients and servers that do not opt in.
130145
/// </para>
131146
/// </remarks>
132147
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]

src/ModelContextProtocol.Core/Server/McpServerOptions.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,15 +240,33 @@ public McpServerFilters Filters
240240
public bool SendTaskStatusNotifications { get; set; }
241241

242242
/// <summary>
243-
/// Gets or sets an experimental protocol version that enables draft protocol features such as
244-
/// Multi Round-Trip Requests (MRTR).
243+
/// Gets or sets an experimental protocol version that enables draft protocol features.
245244
/// </summary>
246245
/// <remarks>
247246
/// <para>
248-
/// When set, this version is accepted from clients during protocol version negotiation, and MRTR
249-
/// is activated when the negotiated version matches. If a client does not request this version,
250-
/// the server negotiates to the latest stable version and uses standard server-to-client JSON-RPC
251-
/// requests for sampling and elicitation.
247+
/// When set, this version is accepted from clients in addition to
248+
/// <see cref="McpSessionHandler.SupportedProtocolVersions"/>. The server advertises it in
249+
/// <c>server/discover</c> replies and reports it through the <c>MCP-Protocol-Version</c> header
250+
/// during version negotiation.
251+
/// </para>
252+
/// <para>
253+
/// Under the draft revision (SEP-2575 + SEP-2567 + SEP-2322), client requests that declare this
254+
/// version are processed sessionlessly:
255+
/// </para>
256+
/// <list type="bullet">
257+
/// <item><description>The server does not issue an <c>Mcp-Session-Id</c> response header, regardless
258+
/// of the <c>HttpServerTransportOptions.Stateless</c> setting (which governs only legacy
259+
/// clients).</description></item>
260+
/// <item><description>The legacy <c>initialize</c> handshake is bypassed. Client info, client
261+
/// capabilities, and the negotiated protocol version are read from each request's <c>_meta</c>
262+
/// fields instead.</description></item>
263+
/// <item><description>Server-initiated sampling, elicitation, and roots requests are embedded inline
264+
/// as <see cref="IncompleteResult"/> values (MRTR) instead of standalone JSON-RPC requests.</description></item>
265+
/// </list>
266+
/// <para>
267+
/// The legacy <c>initialize</c> handler, <c>Mcp-Session-Id</c> session management, and standalone
268+
/// GET endpoint continue to function for clients that do not declare the experimental version, so a
269+
/// single server can serve both legacy and draft clients concurrently.
252270
/// </para>
253271
/// <para>
254272
/// This property is intended for proof-of-concept and testing of draft MCP specification features
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.AspNetCore.Tests.Utils;
4+
using ModelContextProtocol.Protocol;
5+
using System.Net;
6+
using System.Text;
7+
using System.Text.Json;
8+
9+
namespace ModelContextProtocol.AspNetCore.Tests;
10+
11+
/// <summary>
12+
/// HTTP-level tests for the draft protocol revision (SEP-2575 + SEP-2567): verify that the server
13+
/// suppresses the <c>Mcp-Session-Id</c> header for draft requests and returns structured
14+
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> errors instead of plain 400s.
15+
/// </summary>
16+
public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
17+
{
18+
private const string DraftVersion = "2026-06-XX";
19+
20+
private WebApplication? _app;
21+
22+
private async Task StartAsync(bool experimentalServer)
23+
{
24+
#pragma warning disable MCPEXP001 // ExperimentalProtocolVersion is experimental
25+
Builder.Services.AddMcpServer(options =>
26+
{
27+
options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" };
28+
if (experimentalServer)
29+
{
30+
options.ExperimentalProtocolVersion = DraftVersion;
31+
}
32+
}).WithHttpTransport();
33+
#pragma warning restore MCPEXP001
34+
35+
_app = Builder.Build();
36+
_app.MapMcp();
37+
await _app.StartAsync(TestContext.Current.CancellationToken);
38+
39+
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
40+
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
41+
}
42+
43+
public async ValueTask DisposeAsync()
44+
{
45+
if (_app is not null)
46+
{
47+
await _app.DisposeAsync();
48+
}
49+
base.Dispose();
50+
}
51+
52+
[Fact]
53+
public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader()
54+
{
55+
await StartAsync(experimentalServer: true);
56+
57+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
58+
59+
// server/discover should succeed without creating a session.
60+
var content = new StringContent(
61+
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
62+
Encoding.UTF8, "application/json");
63+
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
64+
65+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
66+
Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Draft responses must not include Mcp-Session-Id");
67+
}
68+
69+
[Fact]
70+
public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError()
71+
{
72+
await StartAsync(experimentalServer: false);
73+
74+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31");
75+
76+
var content = new StringContent(
77+
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
78+
Encoding.UTF8, "application/json");
79+
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
80+
81+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
82+
83+
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
84+
var rpcMessage = JsonSerializer.Deserialize<JsonRpcMessage>(body, McpJsonUtilities.DefaultOptions);
85+
var rpcError = Assert.IsType<JsonRpcError>(rpcMessage);
86+
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code);
87+
88+
// Validate the structured data payload (SEP-2575 §"Unsupported Protocol Versions").
89+
var dataElement = (JsonElement)rpcError.Error.Data!;
90+
var errorData = dataElement.Deserialize<UnsupportedProtocolVersionErrorData>(McpJsonUtilities.DefaultOptions);
91+
Assert.NotNull(errorData);
92+
Assert.Equal("2099-12-31", errorData.Requested);
93+
Assert.NotEmpty(errorData.Supported);
94+
}
95+
96+
[Fact]
97+
public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath()
98+
{
99+
// For back-compat with clients that opted into the experimental version on top of the legacy
100+
// stateful session model (MRTR-as-extension-on-initialize), draft-version requests that DO
101+
// include an Mcp-Session-Id are still accepted via the legacy session lookup path.
102+
await StartAsync(experimentalServer: true);
103+
104+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
105+
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
106+
107+
var content = new StringContent(
108+
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
109+
Encoding.UTF8, "application/json");
110+
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
111+
112+
// Legacy path returns 404 for unknown sessions.
113+
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
114+
}
115+
116+
[Fact]
117+
public async Task DraftGet_WithoutSessionId_IsRejected()
118+
{
119+
await StartAsync(experimentalServer: true);
120+
121+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
122+
123+
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
124+
125+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
126+
}
127+
128+
[Fact]
129+
public async Task DraftDelete_WithoutSessionId_IsRejected()
130+
{
131+
await StartAsync(experimentalServer: true);
132+
133+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
134+
135+
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
136+
137+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
138+
}
139+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModelContextProtocol.Client;
3+
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
5+
using System.Text.Json;
6+
7+
namespace ModelContextProtocol.Tests.Client;
8+
9+
/// <summary>
10+
/// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on
11+
/// <see cref="McpClient"/> — the client should call <c>server/discover</c> instead of
12+
/// <c>initialize</c> when <see cref="McpClientOptions.ExperimentalProtocolVersion"/> is set and
13+
/// the server supports the requested version, and it should fall back to the legacy
14+
/// <c>initialize</c> handshake otherwise.
15+
/// </summary>
16+
#pragma warning disable MCPEXP002 // ExperimentalProtocolVersion
17+
public class DraftConnectionTests : ClientServerTestBase
18+
{
19+
private const string DraftVersion = "2026-06-XX";
20+
private const string LatestStableVersion = "2025-11-25";
21+
22+
private bool _serverHasExperimental;
23+
24+
public DraftConnectionTests(ITestOutputHelper testOutputHelper)
25+
: base(testOutputHelper, startServer: false)
26+
{
27+
}
28+
29+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
30+
{
31+
services.Configure<McpServerOptions>(options =>
32+
{
33+
options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" };
34+
if (_serverHasExperimental)
35+
{
36+
options.ExperimentalProtocolVersion = DraftVersion;
37+
}
38+
});
39+
}
40+
41+
[Fact]
42+
public async Task DraftClient_ConnectingToDraftServer_NegotiatesExperimentalVersion()
43+
{
44+
_serverHasExperimental = true;
45+
StartServer();
46+
47+
var options = new McpClientOptions { ExperimentalProtocolVersion = DraftVersion };
48+
await using var client = await CreateMcpClientForServer(options);
49+
50+
Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion);
51+
Assert.NotNull(client.ServerCapabilities);
52+
Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name);
53+
}
54+
55+
[Fact]
56+
public async Task DraftClient_ConnectingToLegacyServer_FallsBackToLegacyInitialize()
57+
{
58+
_serverHasExperimental = false;
59+
StartServer();
60+
61+
var options = new McpClientOptions { ExperimentalProtocolVersion = DraftVersion };
62+
await using var client = await CreateMcpClientForServer(options);
63+
64+
Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion);
65+
Assert.Equal(LatestStableVersion, client.NegotiatedProtocolVersion);
66+
}
67+
68+
[Fact]
69+
public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion()
70+
{
71+
_serverHasExperimental = true;
72+
StartServer();
73+
74+
await using var client = await CreateMcpClientForServer();
75+
76+
Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion);
77+
}
78+
79+
[Fact]
80+
public async Task LegacyClient_CanCallServerDiscover_EvenWithoutDraftConfigured()
81+
{
82+
// server/discover is registered unconditionally, so a legacy client can probe it
83+
// (e.g., to learn capabilities without doing a second initialize).
84+
_serverHasExperimental = false;
85+
StartServer();
86+
87+
await using var client = await CreateMcpClientForServer();
88+
89+
var response = await client.SendRequestAsync(
90+
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
91+
TestContext.Current.CancellationToken);
92+
93+
var discoverResult = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions);
94+
Assert.NotNull(discoverResult);
95+
Assert.NotEmpty(discoverResult.SupportedVersions);
96+
Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions);
97+
Assert.Equal(nameof(DraftConnectionTests), discoverResult.ServerInfo.Name);
98+
}
99+
100+
[Fact]
101+
public async Task DraftServer_DiscoverIncludesExperimentalVersion()
102+
{
103+
_serverHasExperimental = true;
104+
StartServer();
105+
106+
await using var client = await CreateMcpClientForServer();
107+
108+
var response = await client.SendRequestAsync(
109+
new JsonRpcRequest { Method = RequestMethods.ServerDiscover },
110+
TestContext.Current.CancellationToken);
111+
112+
var discoverResult = JsonSerializer.Deserialize<DiscoverResult>(response.Result, McpJsonUtilities.DefaultOptions);
113+
Assert.NotNull(discoverResult);
114+
Assert.Contains(DraftVersion, discoverResult.SupportedVersions);
115+
}
116+
}

0 commit comments

Comments
 (0)