Skip to content

Commit 30782f6

Browse files
halter73Copilot
andcommitted
Add raw HTTP conformance tests + document MCP9005
Phase 5b + 6 of the SEP-2575/SEP-2567 work. - tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs: 5 new tests that drive the C# server directly with hand-crafted HttpClient requests, no McpClient involvement. Covers draft tools/call with full _meta, server/discover, -32004 on unsupported MCP-Protocol-Version, legacy initialize on a default (stateless+draft) server, and GET returning 405 when not stateful. - docs/list-of-diagnostics.md: add MCP9005 row describing the stateful Streamable HTTP options as back-compat-only knobs since the draft revision is sessionless by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 04da5c8 commit 30782f6

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

docs/list-of-diagnostics.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the
3838
| `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. |
3939
| `MCP9003` | In place | The `RequestContext<TParams>(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext<TParams>(McpServer, JsonRpcRequest, TParams)`. |
4040
| `MCP9004` | In place | <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. |
41+
| `MCP9005` | In place | The stateful Streamable HTTP configuration knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions>`EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. The draft protocol revision (`2026-07-28`) is sessionless, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. |
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.AspNetCore.Tests.Utils;
4+
using ModelContextProtocol.Protocol;
5+
using ModelContextProtocol.Server;
6+
using ModelContextProtocol.Tests.Utils;
7+
using System.Net;
8+
using System.Text;
9+
using System.Text.Json.Nodes;
10+
11+
namespace ModelContextProtocol.AspNetCore.Tests;
12+
13+
/// <summary>
14+
/// Wire-format conformance tests for the Streamable HTTP server driven directly via <see cref="HttpClient"/>,
15+
/// without going through <see cref="ModelContextProtocol.Client.McpClient"/>. These hand-craft HTTP
16+
/// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 +
17+
/// SEP-2567 (sessionless, no-initialize) draft revision.
18+
/// </summary>
19+
public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
20+
{
21+
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
22+
private const string ProtocolVersionHeader = "MCP-Protocol-Version";
23+
24+
private WebApplication? _app;
25+
26+
private async Task StartAsync()
27+
{
28+
Builder.Services
29+
.AddMcpServer(options =>
30+
{
31+
options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" };
32+
})
33+
.WithHttpTransport()
34+
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]);
35+
36+
_app = Builder.Build();
37+
_app.MapMcp();
38+
await _app.StartAsync(TestContext.Current.CancellationToken);
39+
40+
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
41+
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
42+
}
43+
44+
public async ValueTask DisposeAsync()
45+
{
46+
if (_app is not null)
47+
{
48+
await _app.DisposeAsync();
49+
}
50+
base.Dispose();
51+
}
52+
53+
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
54+
55+
/// <summary>
56+
/// Reads either a direct JSON response or a single SSE message containing JSON-RPC and returns the
57+
/// parsed JsonNode. The Streamable HTTP server can return either content type depending on negotiation;
58+
/// raw HttpClient tests should accept either.
59+
/// </summary>
60+
private static async Task<JsonNode> ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
61+
{
62+
var contentType = response.Content.Headers.ContentType?.MediaType;
63+
var body = await response.Content.ReadAsStringAsync(cancellationToken);
64+
65+
if (contentType == "text/event-stream")
66+
{
67+
// Pull the first non-empty data: line out of the SSE payload.
68+
foreach (var line in body.Split('\n'))
69+
{
70+
if (line.StartsWith("data:", StringComparison.Ordinal))
71+
{
72+
var data = line.Substring("data:".Length).Trim();
73+
if (data.Length > 0)
74+
{
75+
return JsonNode.Parse(data)!;
76+
}
77+
}
78+
}
79+
throw new InvalidOperationException("SSE response did not contain a JSON data event. Body: " + body);
80+
}
81+
82+
return JsonNode.Parse(body)!;
83+
}
84+
85+
private static string DraftMetaFragment(string protocolVersion = DraftVersion) =>
86+
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
87+
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
88+
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
89+
90+
[Fact]
91+
public async Task DraftToolsCall_WithFullMeta_Succeeds_200()
92+
{
93+
await StartAsync();
94+
95+
var body =
96+
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," +
97+
DraftMetaFragment() + "}}";
98+
99+
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
100+
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
101+
request.Headers.Add("Mcp-Method", "tools/call");
102+
request.Headers.Add("Mcp-Name", "echo");
103+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
104+
105+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
106+
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
107+
Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
108+
109+
// Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id.
110+
Assert.False(response.Headers.Contains("mcp-session-id"));
111+
}
112+
113+
[Fact]
114+
public async Task ServerDiscover_RawPost_ReturnsDiscoverResult()
115+
{
116+
await StartAsync();
117+
118+
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}";
119+
120+
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
121+
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
122+
request.Headers.Add("Mcp-Method", "server/discover");
123+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
124+
125+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
126+
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
127+
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
128+
Assert.Contains(DraftVersion, supported);
129+
}
130+
131+
[Fact]
132+
public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004()
133+
{
134+
await StartAsync();
135+
136+
var body =
137+
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
138+
DraftMetaFragment("9999-99-99") + "}}";
139+
140+
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
141+
request.Headers.Add(ProtocolVersionHeader, "9999-99-99");
142+
request.Headers.Add("Mcp-Method", "tools/call");
143+
request.Headers.Add("Mcp-Name", "echo");
144+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
145+
146+
// Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32004 and a data payload
147+
// listing the supported versions. The dual-era client uses this to switch versions without fallback.
148+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
149+
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
150+
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
151+
152+
var data = json["error"]!["data"];
153+
Assert.NotNull(data);
154+
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
155+
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
156+
Assert.Contains(DraftVersion, supported);
157+
}
158+
159+
[Fact]
160+
public async Task LegacyInitialize_StillSucceeds_OnDefaultServer()
161+
{
162+
await StartAsync();
163+
164+
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}";
165+
166+
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
167+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
168+
169+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
170+
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
171+
Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue<string>());
172+
}
173+
174+
[Fact]
175+
public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405()
176+
{
177+
await StartAsync();
178+
179+
using var request = new HttpRequestMessage(HttpMethod.Get, "");
180+
request.Headers.Accept.Add(new("text/event-stream"));
181+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
182+
183+
// Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE
184+
// stream is replaced by subscriptions/listen POST requests. Existing routing in
185+
// McpEndpointRouteBuilderExtensions only maps GET when Stateless == false.
186+
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
187+
}
188+
}
189+

0 commit comments

Comments
 (0)