Skip to content

Commit f448f91

Browse files
halter73Copilot
andcommitted
Add and rework test coverage for the sessionless-draft default
- New: DraftStatefulFallbackTests (e2e draft-first client downgrades to a legacy session against a Stateless = false server), TaskDraftGatingTests (Tasks APIs are draft-only on both client and server), NegotiatedProtocolVersionTests (request-id strictness and once-only version locking), SubscriptionsListenTests, RequestIdTests, and JsonRpcMessageConverterTests. - Rework DraftHttpHandlerTests, MrtrProtocolTests, StreamableHttpServerConformanceTests, and MapMcp* to assert the strict draft-vs-session behavior and to move genuinely stateful MRTR coverage onto stdio / in-memory transports now that Streamable HTTP is always stateless for the draft revision. - Add a net472 build-compat guard in NegotiatedProtocolVersionTests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 933800b commit f448f91

22 files changed

Lines changed: 1026 additions & 210 deletions

tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@ public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMe
1919

2020
private WebApplication? _app;
2121

22-
private async Task StartAsync()
22+
private async Task StartAsync(bool stateless = false)
2323
{
2424
Builder.Services.AddMcpServer(options =>
2525
{
2626
options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" };
2727
}).WithHttpTransport(options =>
2828
{
29-
// Map the GET/DELETE endpoints so we can exercise the draft-mode rejection paths
30-
// (these endpoints are not registered in stateless mode, which is the new default).
31-
options.Stateless = false;
29+
// Stateless = false maps the GET/DELETE endpoints and opts the author into sessions, which the
30+
// draft revision cannot honor (so sessionless draft requests are refused). Stateless = true (the
31+
// default) serves sessionless draft natively.
32+
options.Stateless = stateless;
3233
});
3334

3435
_app = Builder.Build();
@@ -49,14 +50,14 @@ public async ValueTask DisposeAsync()
4950
}
5051

5152
[Fact]
52-
public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader()
53+
public async Task DraftRequest_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader()
5354
{
54-
await StartAsync();
55+
await StartAsync(stateless: true);
5556

5657
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
5758
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
5859

59-
// server/discover should succeed without creating a session.
60+
// On a stateless server, sessionless draft server/discover succeeds without creating a session.
6061
var content = new StringContent(
6162
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
6263
Encoding.UTF8, "application/json");
@@ -66,6 +67,40 @@ public async Task DraftRequest_DoesNotEmitMcpSessionIdHeader()
6667
Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Draft responses must not include Mcp-Session-Id");
6768
}
6869

70+
[Fact]
71+
public async Task DraftRequest_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError()
72+
{
73+
// The draft revision is sessionless (SEP-2567), so it cannot honor a server configured with
74+
// sessions (Stateless = false). The server refuses the draft version with
75+
// UnsupportedProtocolVersion (excluding draft from Supported) so a dual-era client falls back
76+
// to the legacy initialize handshake.
77+
await StartAsync(stateless: false);
78+
79+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
80+
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
81+
82+
var content = new StringContent(
83+
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
84+
Encoding.UTF8, "application/json");
85+
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
86+
87+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
88+
Assert.False(response.Headers.Contains("Mcp-Session-Id"));
89+
90+
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
91+
var rpcMessage = JsonSerializer.Deserialize<JsonRpcMessage>(body, McpJsonUtilities.DefaultOptions);
92+
var rpcError = Assert.IsType<JsonRpcError>(rpcMessage);
93+
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code);
94+
95+
var dataElement = (JsonElement)rpcError.Error.Data!;
96+
var errorData = dataElement.Deserialize<UnsupportedProtocolVersionErrorData>(McpJsonUtilities.DefaultOptions);
97+
Assert.NotNull(errorData);
98+
Assert.Equal(DraftVersion, errorData.Requested);
99+
// The draft version is excluded from Supported so the client downgrades to a legacy version.
100+
Assert.NotEmpty(errorData.Supported);
101+
Assert.DoesNotContain(DraftVersion, errorData.Supported);
102+
}
103+
69104
[Fact]
70105
public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError()
71106
{
@@ -95,11 +130,10 @@ public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProto
95130
}
96131

97132
[Fact]
98-
public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath()
133+
public async Task DraftRequest_WithMcpSessionIdHeader_IsRejected()
99134
{
100-
// For back-compat with clients that opted into the experimental version on top of the legacy
101-
// stateful session model (MRTR-as-extension-on-initialize), draft-version requests that DO
102-
// include an Mcp-Session-Id are still accepted via the legacy session lookup path.
135+
// The draft revision is sessionless (SEP-2567): a draft request carrying an Mcp-Session-Id is
136+
// non-conformant and is rejected with 400 regardless of the Stateless setting.
103137
await StartAsync();
104138

105139
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
@@ -111,8 +145,7 @@ public async Task DraftRequest_WithMcpSessionIdHeader_RoutesThroughLegacyPath()
111145
Encoding.UTF8, "application/json");
112146
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
113147

114-
// Legacy path returns 404 for unknown sessions.
115-
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
148+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
116149
}
117150

118151
[Fact]
@@ -127,6 +160,19 @@ public async Task DraftGet_WithoutSessionId_IsRejected()
127160
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
128161
}
129162

163+
[Fact]
164+
public async Task DraftGet_WithSessionId_IsRejected()
165+
{
166+
await StartAsync();
167+
168+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
169+
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
170+
171+
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
172+
173+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
174+
}
175+
130176
[Fact]
131177
public async Task DraftDelete_WithoutSessionId_IsRejected()
132178
{
@@ -138,4 +184,17 @@ public async Task DraftDelete_WithoutSessionId_IsRejected()
138184

139185
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
140186
}
187+
188+
[Fact]
189+
public async Task DraftDelete_WithSessionId_IsRejected()
190+
{
191+
await StartAsync();
192+
193+
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
194+
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
195+
196+
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
197+
198+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
199+
}
141200
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.AspNetCore.Tests.Utils;
4+
using ModelContextProtocol.Client;
5+
using ModelContextProtocol.Protocol;
6+
using ModelContextProtocol.Server;
7+
using ModelContextProtocol.Tests.Utils;
8+
using System.Text.Json;
9+
10+
namespace ModelContextProtocol.AspNetCore.Tests;
11+
12+
/// <summary>
13+
/// End-to-end coverage for a default (draft-first) client connecting to a real C# Streamable HTTP
14+
/// server that deliberately opted into sessions (<see cref="HttpServerTransportOptions.Stateless"/>
15+
/// is <c>false</c>). Draft is sessionless (SEP-2567 / SEP-2575), so the server refuses the
16+
/// sessionless draft probe with <c>-32004 UnsupportedProtocolVersion</c>. The client must then
17+
/// auto-downgrade to the legacy <c>initialize</c> handshake, obtain the stateful session the server
18+
/// author opted into, and continue to work — including a server→client elicitation round-trip
19+
/// resolved over the stateful session via the legacy backcompat resolver.
20+
/// </summary>
21+
public class DraftStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
22+
{
23+
private WebApplication? _app;
24+
25+
public async ValueTask DisposeAsync()
26+
{
27+
if (_app is not null)
28+
{
29+
await _app.DisposeAsync();
30+
}
31+
base.Dispose();
32+
}
33+
34+
[McpServerTool(Name = "greet")]
35+
private static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!";
36+
37+
[McpServerTool(Name = "greet_via_elicit")]
38+
private static async Task<string> GreetViaElicit(McpServer server, CancellationToken cancellationToken)
39+
{
40+
// Server→client round-trip: only works when the session is stateful, which is exactly what
41+
// the legacy fallback re-establishes for the draft-first client.
42+
var elicitResult = await server.ElicitAsync(new ElicitRequestParams
43+
{
44+
Message = "What is your name?",
45+
RequestedSchema = new(),
46+
}, cancellationToken);
47+
48+
var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true
49+
? answer.GetString()
50+
: "stranger";
51+
52+
return $"Hello, {name}!";
53+
}
54+
55+
private async Task StartStatefulServerAsync()
56+
{
57+
Builder.Services.AddMcpServer(options =>
58+
{
59+
options.ServerInfo = new Implementation { Name = nameof(DraftStatefulFallbackTests), Version = "1" };
60+
})
61+
// Stateless = false is a deliberate opt-in to sessions. Draft can never be served
62+
// statefully, so the server refuses the sessionless draft probe and the client downgrades.
63+
.WithHttpTransport(options => options.Stateless = false)
64+
.WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]);
65+
66+
_app = Builder.Build();
67+
_app.MapMcp();
68+
await _app.StartAsync(TestContext.Current.CancellationToken);
69+
}
70+
71+
private async Task<McpClient> ConnectDefaultClientAsync(Action<McpClientOptions>? configureClient = null)
72+
{
73+
await using var transport = new HttpClientTransport(new HttpClientTransportOptions
74+
{
75+
Endpoint = new Uri("http://localhost:5000/"),
76+
TransportMode = HttpTransportMode.StreamableHttp,
77+
}, HttpClient, LoggerFactory);
78+
79+
// Default options: ProtocolVersion is null, which now prefers the draft revision and probes
80+
// with server/discover before falling back to a legacy initialize handshake.
81+
var clientOptions = new McpClientOptions();
82+
configureClient?.Invoke(clientOptions);
83+
return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
84+
}
85+
86+
[Fact]
87+
public async Task DefaultDraftClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork()
88+
{
89+
await StartStatefulServerAsync();
90+
91+
await using var client = await ConnectDefaultClientAsync();
92+
93+
// The sessionless draft probe was refused (-32004), so the client downgraded to legacy.
94+
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
95+
96+
var result = await client.CallToolAsync("greet",
97+
new Dictionary<string, object?> { ["name"] = "Alice" },
98+
cancellationToken: TestContext.Current.CancellationToken);
99+
100+
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
101+
Assert.Equal("Hello, Alice!", text);
102+
}
103+
104+
[Fact]
105+
public async Task DefaultDraftClient_AgainstStatefulServer_ServerToClientElicitation_RoundTrips()
106+
{
107+
await StartStatefulServerAsync();
108+
109+
await using var client = await ConnectDefaultClientAsync(options =>
110+
{
111+
options.Handlers.ElicitationHandler = (request, ct) => new ValueTask<ElicitResult>(new ElicitResult
112+
{
113+
Action = "accept",
114+
Content = new Dictionary<string, JsonElement>
115+
{
116+
["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(),
117+
},
118+
});
119+
});
120+
121+
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
122+
123+
var result = await client.CallToolAsync("greet_via_elicit",
124+
cancellationToken: TestContext.Current.CancellationToken);
125+
126+
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
127+
Assert.Equal("Hello, Bob!", text);
128+
Assert.True(result.IsError is not true);
129+
}
130+
}

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
759759
{
760760
var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>();
761761
var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>();
762+
var requestObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
762763

763764
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
764765

@@ -788,6 +789,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
788789

789790
capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method));
790791
capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method));
792+
requestObserved.TrySetResult();
791793

792794
return result;
793795
});
@@ -806,8 +808,12 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler()
806808

807809
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
808810

809-
// The filter must have observed at least one MCP request. Don't assert an exact
810-
// minimum - the initialized notification or GET stream may not have completed yet.
811+
// The filter records into the bag *after* await next(context) returns. For a streamed SSE
812+
// response the client can observe completion (and ListToolsAsync can return) before that
813+
// server-side continuation runs, so asserting the bag immediately races. Wait for the filter
814+
// to record at least one request first. Don't assert an exact minimum - the initialized
815+
// notification or GET stream may not have completed yet.
816+
await requestObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
811817
Assert.NotEmpty(capturedSessionIds);
812818

813819
if (Stateless)

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ namespace ModelContextProtocol.AspNetCore.Tests;
1010

1111
public abstract partial class MapMcpTests
1212
{
13+
// Draft is sessionless (SEP-2567): the Streamable HTTP handler refuses a sessionless draft request
14+
// when the server opted into sessions (Stateless = false), so a draft-pinned client downgrades to
15+
// legacy instead of negotiating 2026-07-28. These draft MRTR tests therefore can't run on the
16+
// stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures.
17+
private const string DraftStatefulStreamableHttpSkipReason =
18+
"Draft is sessionless (SEP-2567); stateful Streamable HTTP refuses sessionless draft. Covered by the stateless and SSE fixtures.";
19+
1320
private ServerMessageTracker ConfigureServer(params Delegate[] tools)
1421
{
1522
var messageTracker = new ServerMessageTracker();
@@ -350,6 +357,8 @@ private static string MrtrElicit(RequestContext<CallToolRequestParams> context)
350357
[Fact]
351358
public async Task Mrtr_Roots_CompletesViaMrtr()
352359
{
360+
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
361+
353362
var messageTracker = ConfigureServer(
354363
[McpServerTool(Name = "mrtr-roots")] (RequestContext<CallToolRequestParams> context) =>
355364
{
@@ -426,6 +435,8 @@ private static string MrtrMulti(RequestContext<CallToolRequestParams> context)
426435
[InlineData(false)]
427436
public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
428437
{
438+
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
439+
429440
var messageTracker = ConfigureServer(MrtrMulti);
430441
await using var app = Builder.Build();
431442
app.MapMcp();
@@ -473,6 +484,8 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
473484
[InlineData(false)]
474485
public async Task Mrtr_IsMrtrSupported(bool experimentalClient)
475486
{
487+
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
488+
476489
ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString());
477490
await using var app = Builder.Build();
478491
app.MapMcp();
@@ -537,6 +550,8 @@ private static string MrtrConcurrentThree(RequestContext<CallToolRequestParams>
537550
[Fact]
538551
public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
539552
{
553+
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
554+
540555
var messageTracker = ConfigureServer(MrtrConcurrentThree);
541556
await using var app = Builder.Build();
542557
app.MapMcp();
@@ -589,6 +604,8 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
589604
[Fact]
590605
public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr()
591606
{
607+
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
608+
592609
var messageTracker = ConfigureServer(
593610
[McpServerTool(Name = "mrtr-loadshed")] (RequestContext<CallToolRequestParams> context) =>
594611
{

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,12 @@ await client.CallToolAsync("echo_with_user_name",
327327
cancellationToken: TestContext.Current.CancellationToken);
328328

329329
// The client now defaults to the draft revision, whose handshake is server/discover
330-
// rather than the legacy initialize request.
331-
Assert.Contains(RequestMethods.ServerDiscover, observedMethods);
330+
// rather than the legacy initialize request. On the stateful Streamable HTTP fixture the
331+
// sessionless draft request is refused, so the client downgrades to the legacy initialize.
332+
var expectedHandshakeMethod = UseStreamableHttp && !Stateless
333+
? RequestMethods.Initialize
334+
: RequestMethods.ServerDiscover;
335+
Assert.Contains(expectedHandshakeMethod, observedMethods);
332336
Assert.Contains(RequestMethods.ToolsList, observedMethods);
333337
Assert.Contains(RequestMethods.ToolsCall, observedMethods);
334338
}

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
@@ -8,7 +8,7 @@
88
<IsTestProject>true</IsTestProject>
99
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>
1010
<!-- The test suite intentionally exercises the obsoleted stateful Streamable HTTP surface. -->
11-
<NoWarn>$(NoWarn);MCP9005</NoWarn>
11+
<NoWarn>$(NoWarn);MCP9006</NoWarn>
1212
</PropertyGroup>
1313

1414
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">

0 commit comments

Comments
 (0)