using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore.Tests; /// /// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize /// handshake over Streamable HTTP. These /// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's /// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body /// on a 2026-07-28 probe; vanilla Go does the same on POST /) so the client's HTTP-fallback /// logic can be exercised in isolation without the cross-SDK harness. /// /// /// /// Two latent bugs were discovered during cross-SDK testing and fixed by the SEP-2575 / SEP-2567 /// branch: /// /// /// /// only surfaced the three error codes /// introduced by the 2026-07-28 revision (-32022, -32021, -32020) as ; /// any other JSON-RPC error code in a 400 body (e.g. -32600 from an initialize-handshake server /// that doesn't understand the 2026-07-28 _meta envelope) threw /// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger /// on ANY non-SEP-2575 JSON-RPC error in a 400 body. /// /// /// treated any non-2xx HTTP response as a /// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked /// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would /// either fail with "session id required" or succeed against a different endpoint and lose /// the actual signal. /// /// /// public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { private WebApplication? _app; public async ValueTask DisposeAsync() { if (_app is not null) { await _app.DisposeAsync(); } base.Dispose(); } private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false) { Builder.Services.Configure(options => { options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); }); _app = Builder.Build(); if (acceptGet) { _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler); } else { _app.MapPost("/mcp", handler); } await _app.StartAsync(TestContext.Current.CancellationToken); } private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatusCode statusCode, int code, string message) { var rpcError = new JsonRpcError { Id = default, Error = new JsonRpcErrorDetail { Code = code, Message = message }, }; context.Response.StatusCode = (int)statusCode; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), context.RequestAborted); } /// /// Mimics Python's simple-streamablehttp-stateless on a 2026-07-28 probe: returns /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the /// initial server/discover, then performs a normal initialize handshake /// when the client falls back. /// [Fact] public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() { var ct = TestContext.Current.CancellationToken; await StartServerAsync(async context => { var message = await JsonSerializer.DeserializeAsync( context.Request.Body, GetJsonTypeInfo(), ct); if (message is not JsonRpcRequest request) { context.Response.StatusCode = StatusCodes.Status202Accepted; return; } // 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). if (request.Method == RequestMethods.ServerDiscover) { await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: -32600, message: "Bad Request: Unsupported protocol version: 2026-07-28"); return; } // Initialize handshake: respond with the highest version this server speaks. if (request.Method == RequestMethods.Initialize) { var response = new JsonRpcResponse { Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; context.Response.ContentType = "application/json"; await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); return; } if (request.Method == RequestMethods.ToolsList) { var response = new JsonRpcResponse { Id = request.Id, Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), }; context.Response.ContentType = "application/json"; await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); return; } context.Response.StatusCode = StatusCodes.Status202Accepted; }); // Default AutoDetect transport — exercises BOTH fixes (AutoDetect adopting StreamableHttp // on JSON-RPC-error 400, and SendMessageAsync surfacing -32600 as McpProtocolException). await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); // Sanity: subsequent traffic still works post-fallback. var tools = await client.ListToolsAsync(cancellationToken: ct); Assert.Empty(tools); } /// /// Mimics vanilla Go: returns 400 + JSON-RPC -32022 with /// data.supported[] on a 2026-07-28 probe so the client retries /// initialize with one of the advertised versions. /// [Fact] public async Task Client_OnUnsupportedProtocolVersion_AdoptsStreamableHttp_NoSseFallback() { var ct = TestContext.Current.CancellationToken; await StartServerAsync(async context => { var message = await JsonSerializer.DeserializeAsync( context.Request.Body, GetJsonTypeInfo(), ct); if (message is not JsonRpcRequest request) { context.Response.StatusCode = StatusCodes.Status202Accepted; return; } if (request.Method == RequestMethods.ServerDiscover) { // -32022 with the spec-shaped data: client should retry with one of supported[]. // Use the typed payload type so the source-generated serializer can handle it. var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData { Supported = new List { McpProtocolVersions.November2025ProtocolVersion }, Requested = McpProtocolVersions.July2026ProtocolVersion, }, GetJsonTypeInfo()); var rpcError = new JsonRpcError { Id = request.Id, Error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, Message = "Unsupported protocol version", Data = data, }, }; context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), ct); return; } if (request.Method == RequestMethods.Initialize) { var response = new JsonRpcResponse { Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; context.Response.ContentType = "application/json"; await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); return; } context.Response.StatusCode = StatusCodes.Status202Accepted; }); await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } /// /// A 400 with a JSON-RPC -32020 HeaderMismatch envelope must be surfaced to the /// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope. /// [Fact] public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() { var ct = TestContext.Current.CancellationToken; bool initializeReceived = false; await StartServerAsync(async context => { var message = await JsonSerializer.DeserializeAsync( context.Request.Body, GetJsonTypeInfo(), ct); if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) { initializeReceived = true; } if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }) { await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: (int)McpErrorCode.HeaderMismatch, message: "Header mismatch: MCP-Protocol-Version did not match body _meta"); return; } context.Response.StatusCode = StatusCodes.Status202Accepted; }); await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); var exception = await Assert.ThrowsAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); Assert.False(initializeReceived); } [Theory] [InlineData(HttpStatusCode.Unauthorized)] [InlineData(HttpStatusCode.Forbidden)] [InlineData(HttpStatusCode.InternalServerError)] public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode) { var ct = TestContext.Current.CancellationToken; var initializeRequests = 0; var sseGetRequests = 0; await StartServerAsync(async context => { if (HttpMethods.IsGet(context.Request.Method)) { sseGetRequests++; context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; return; } var message = await JsonSerializer.DeserializeAsync( context.Request.Body, GetJsonTypeInfo(), ct); if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) { initializeRequests++; } await WriteJsonRpcErrorAsync( context, statusCode, code: (int)McpErrorCode.InvalidRequest, message: "non-modern structured error"); }, acceptGet: true); await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp"), TransportMode = HttpTransportMode.AutoDetect, }, HttpClient, LoggerFactory); var exception = await Assert.ThrowsAsync(async () => { await using var client = await McpClient.CreateAsync( transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.Equal(statusCode, exception.StatusCode); Assert.Contains("non-modern structured error", exception.Message); Assert.Equal(0, initializeRequests); Assert.Equal(0, sseGetRequests); } [Fact] public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() { var ct = TestContext.Current.CancellationToken; string? toolsListSessionId = null; await StartServerAsync(async context => { var message = await JsonSerializer.DeserializeAsync( context.Request.Body, GetJsonTypeInfo(), ct); if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) { var response = new JsonRpcResponse { Id = request.Id, Result = JsonSerializer.SerializeToNode(new DiscoverResult { SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), Meta = new JsonObject { [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions), }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), }; context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session"; context.Response.ContentType = "application/json"; await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); return; } if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest) { toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); var response = new JsonRpcResponse { Id = toolsListRequest.Id, Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), }; context.Response.ContentType = "application/json"; await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); return; } context.Response.StatusCode = StatusCodes.Status202Accepted; }); await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); await client.ListToolsAsync(cancellationToken: ct); Assert.Null(client.SessionId); Assert.Equal("", toolsListSessionId); } [Fact] public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest() { var ct = TestContext.Current.CancellationToken; string? discoverSessionId = null; await StartServerAsync(async context => { var message = await JsonSerializer.DeserializeAsync( context.Request.Body, GetJsonTypeInfo(), ct); if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) { discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); var response = new JsonRpcResponse { Id = request.Id, Result = JsonSerializer.SerializeToNode(new DiscoverResult { SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), Meta = new JsonObject { [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions), }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), }; context.Response.ContentType = "application/json"; await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); return; } context.Response.StatusCode = StatusCodes.Status202Accepted; }); await using var transport = new HttpClientTransport(new() { Endpoint = new("http://localhost:5000/mcp"), TransportMode = HttpTransportMode.StreamableHttp, KnownSessionId = "legacy-session", OwnsSession = false, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); Assert.Equal("", discoverSessionId); } }