using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.AspNetCore.Tests; /// /// Wire-format conformance tests for the Streamable HTTP server driven directly via , /// without going through . These hand-craft HTTP /// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 + /// SEP-2567 2026-07-28 protocol revision. /// public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { private const string ProtocolVersionHeader = "MCP-Protocol-Version"; private WebApplication? _app; private async Task StartAsync(string? protocolVersion = null) { Builder.Services .AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]) .WithTools(); _app = Builder.Build(); _app.MapMcp(); await _app.StartAsync(TestContext.Current.CancellationToken); HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); } public async ValueTask DisposeAsync() { if (_app is not null) { await _app.DisposeAsync(); } base.Dispose(); } private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); /// /// Reads either a direct JSON response or a single SSE message containing JSON-RPC and returns the /// parsed JsonNode. The Streamable HTTP server can return either content type depending on negotiation; /// raw HttpClient tests should accept either. /// private static async Task ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var contentType = response.Content.Headers.ContentType?.MediaType; var body = await response.Content.ReadAsStringAsync(cancellationToken); if (contentType == "text/event-stream") { // Pull the first non-empty data: line out of the SSE payload. foreach (var line in body.Split('\n')) { if (line.StartsWith("data:", StringComparison.Ordinal)) { var data = line.Substring("data:".Length).Trim(); if (data.Length > 0) { return JsonNode.Parse(data)!; } } } throw new InvalidOperationException("SSE response did not contain a JSON data event. Body: " + body); } return JsonNode.Parse(body)!; } private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; [Fact] public async Task July2026ToolsCall_WithFullMeta_Succeeds_200() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); Assert.Equal( nameof(RawHttpConformanceTests), json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); // Per SEP-2567, starting with the 2026-07-28 protocol revision Streamable HTTP no longer // supports sessions: the server MUST NOT issue a Mcp-Session-Id. Assert.False(response.Headers.Contains("mcp-session-id")); } [Fact] public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); // 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, json["result"]!["ttlMs"]!.GetValueKind()); Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue()); Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); Assert.Null(json["result"]!["serverInfo"]); Assert.Equal( nameof(RawHttpConformanceTests), json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); } [Fact] public async Task July2026Post_UnknownMethod_Returns404_WithMethodNotFound() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":17,""method"":""unknown/method"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "unknown/method"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal(17, json["id"]!.GetValue()); Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue()); } [Theory] [InlineData("initialize")] [InlineData("ping")] [InlineData("logging/setLevel")] [InlineData("resources/subscribe")] [InlineData("resources/unsubscribe")] public async Task July2026Post_RemovedMethod_Returns404_WithMethodNotFound(string method) { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":18,""method"":""" + method + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", method); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal(18, json["id"]!.GetValue()); Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue()); } [Fact] public async Task July2026Post_MissingRequiredCapability_Returns400() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":19,""method"":""tools/call"",""params"":{""name"":""requires_sampling"",""arguments"":{}," + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "requires_sampling"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal(19, json["id"]!.GetValue()); Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); } [Fact] public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() { await StartAsync(McpProtocolVersions.July2026ProtocolVersion); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + July2026ProtocolMetaFragment("9999-99-99") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, "9999-99-99"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload // listing the supported versions. The dual-path client uses this to switch versions without fallback. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); var data = json["error"]!["data"]; Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); // The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a // different (still individually supported) version. Per SEP-2575 the server MUST reject the // disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body // checks) so a conformant client on this revision surfaces the error instead of mistaking the // per-request-metadata server for an initialize-handshake one and falling back to initialize. var body = @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); // The body parsed successfully, so per the base protocol responses section (and SEP-2243's error // response format) this error MUST echo the request id rather than emitting id=null (see #1677). Assert.Equal(4242, json["id"]!.GetValue()); } [Fact] public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_EchoesRequestId() { await StartAsync(); // A well-formed tools/call whose body parses, but the required Mcp-Name header is absent. The server // rejects it with -32020 HeaderMismatch, and because the request id was readable the JSON-RPC error // MUST carry that same id (regression guard for #1677). var body = @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); Assert.Equal(4242, json["id"]!.GetValue()); } [Fact] public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion() { await StartAsync(McpProtocolVersions.November2025ProtocolVersion); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); } [Fact] public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // A missing (rather than mismatched) _meta protocol version is an Invalid params rejection // per SEP-2575; -32020 HeaderMismatch is reserved for values present on both sides that // disagree. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } [Fact] public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } [Fact] public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", RequestMethods.Initialize); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); } [Fact] public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); // resultType is a 2026-07-28 result field, and the initialize handshake only ever negotiates // 2025-11-25 or earlier. It must not appear on the InitializeResult, or strict 2025-11-25 clients // reject the handshake (issue #1721). Assert.False(json["result"]!.AsObject().ContainsKey("resultType"), "InitializeResult must not carry resultType on a 2025-11-25 handshake."); } [Fact] public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints() { await StartAsync(); // A 2025-11-25 client completes the initialize handshake and then sends subsequent requests with // the MCP-Protocol-Version header pinned to the negotiated revision. The server must not decorate // the result with the 2026-07-28-exclusive resultType/ttlMs/cacheScope fields (issue #1721). var initBody = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; using (var initRequest = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(initBody) }) using (var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken)) { Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); } var body = @"{""jsonrpc"":""2.0"",""id"":2,""method"":""tools/list"",""params"":{}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var result = json["result"]!.AsObject(); Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result."); Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result."); Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); } [Fact] public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() { await StartAsync(); using var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.Accept.Add(new("text/event-stream")); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE // stream is replaced by subscriptions/listen POST requests. Existing routing in // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } [Fact] public async Task July2026Post_MissingClientCapabilities_Returns400_WithInvalidParams() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":20,""method"":""server/discover"",""params"":{""_meta"":{" + @"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal(20, json["id"]!.GetValue()); Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } [Theory] [InlineData("5")] [InlineData("\"caps\"")] [InlineData("[]")] [InlineData("true")] public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvalidParams(string clientCapabilitiesJson) { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":21,""method"":""server/discover"",""params"":{""_meta"":{" + @"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""," + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":" + clientCapabilitiesJson + "}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // A present-but-wrong-shape clientCapabilities must be rejected up front with -32602 / 400, // not surface later as a generic internal error on a 200 response. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal(21, json["id"]!.GetValue()); Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } [McpServerToolType] private sealed class CapabilityTools { [McpServerTool(Name = "requires_sampling")] public static string RequiresSampling() => throw new MissingRequiredClientCapabilityException( new ClientCapabilities { Sampling = new() }, "sampling capability required but not declared by client"); } }