Follow-ups to #1610: de-draft 2026-07-28 terminology, renumber error codes, ProtocolVersion floor (#1671)

This commit is contained in:
Stephen Halter 2026-06-25 14:07:52 -07:00 • committed by GitHub
commit d6b1615988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 785 additions and 898 deletions

View file

@ -4,8 +4,9 @@ using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
/// <summary>
/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft
/// SEP-2549 "caching" conformance scenario requires. Started on demand (so it is not bound
/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the
/// SEP-2549 "caching" conformance scenario (new in the 2026-07-28 protocol revision)
/// requires. Started on demand (so it is not bound
/// when the caching test is skipped) and torn down via <see cref="DisposeAsync"/>. Uses a
/// distinct port range from the stateful <c>ConformanceServerFixture</c> (3001/3002/3003) so
/// the two can run in parallel without TCP conflicts.
@ -105,13 +106,9 @@ internal sealed class StatelessConformanceServer : IAsyncDisposable
/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read).
/// </summary>
/// <remarks>
/// The scenario is draft-only (introduced in spec wire version 2026-07-28) and uses the
/// stateless lifecycle. It is gated on the installed conformance package version (>= 0.2.0)
/// AND on the installed package emitting the draft wire string this SDK speaks (so it stays
/// skipped under conformance 0.2.0-alpha.2 which still ships the placeholder
/// <c>DRAFT-2026-v1</c>). It activates automatically once a conformance package emitting
/// <c>2026-07-28</c> is installed (e.g. via
/// <c>npm install --no-save &lt;path-to-conformance&gt;</c>). The stateless server is
/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle.
/// It is gated on the installed conformance
/// package version (>= 0.2.0). The stateless server is
/// started only after the gates pass, so a skipped run binds no port.
/// </remarks>
public class CachingConformanceTests(ITestOutputHelper output)
@ -126,7 +123,7 @@ public class CachingConformanceTests(ITestOutputHelper output)
await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken);
// The caching scenario only exists in the draft spec, so pin the spec version
// The caching scenario only exists in the 2026-07-28 protocol revision, so pin the spec version
// explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a
// conflicting duplicate --spec-version flag).
var result = await NodeHelpers.RunServerConformanceAsync(

View file

@ -65,8 +65,11 @@ public class ClientConformanceTests
// HTTP Standardization (SEP-2243)
[Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))]
[InlineData("http-standard-headers")]
[InlineData("http-custom-headers")]
[InlineData("http-invalid-tool-headers")]
// Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header,
// which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers,
// failing every positive check. Re-enable once a conformant conformance package ships (#1655).
// [InlineData("http-custom-headers")]
public async Task RunConformanceTest_Sep2243(string scenario)
{
// Run the conformance test suite

View file

@ -114,7 +114,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsUnionIntegerCanonicalForm()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body
// carries the decimal form 42.0. The server must treat the union type as integer and match.
@ -135,7 +135,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_RejectsUnionIntegerOutsideSafeRange()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
var callJson = CallTool("union_test", """{"priority":9007199254740993}""");
@ -154,7 +154,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsExponentBodyMatchingDecimalHeader()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100".
var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}""");
@ -177,7 +177,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Per SEP-2243: servers MUST accept extra whitespace around header values
// and compare the trimmed value to the request body.
@ -201,7 +201,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Per SEP-2243: servers MUST accept extra whitespace around header values
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
@ -224,7 +224,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Send a tools/call with an empty string param that has an x-mcp-header.
// The header should be present with an empty value, matching the body's empty string.
@ -248,7 +248,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Send a tools/call where the body has a non-empty value but the header is empty
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}""");
@ -271,7 +271,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsBase64EncodedHeaderWithControlChars()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Encode a value with a newline control character using Base64
var valueWithNewline = "line1\nline2";
@ -297,7 +297,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without
// losing precision through a double conversion.
@ -326,7 +326,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Per SEP-2243 integer values MUST be within the JavaScript safe integer range.
// A matching header and body that are both outside the range must still be rejected.
@ -355,7 +355,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and
// "4.2e1" are preserved in the body exactly as another SDK might serialize them.
@ -382,7 +382,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// For an integer-typed parameter a non-whole numeric value is invalid and must be rejected
// even when the header and body strings are byte-for-byte identical (it must not slip through
@ -407,7 +407,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Header says "99" but body says priority:42 — must reject even with numeric comparison
var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}""");
@ -427,17 +427,17 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
}
[Fact]
public async Task Server_SkipsHeaderValidation_ForNonDraftVersion()
public async Task Server_SkipsHeaderValidation_ForLegacyVersion()
{
await StartAsync();
await InitializeWithNonDraftVersionAsync();
await InitializeWithLegacyVersionAsync();
// With non-draft version, Mcp-Param-* headers are NOT validated even if mismatched
// With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
// Send the WRONG header value — this should still succeed because version is non-draft
// Send the WRONG header value. This should still succeed because the version is legacy.
request.Headers.Add("MCP-Protocol-Version", "2025-11-25");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
@ -451,7 +451,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
public async Task Server_RejectsInvalidUtf8EncodedHeaderValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
await InitializeWithJuly2026ProtocolVersionAsync();
// Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers
// instead of properly base64-encoding non-ASCII values.
@ -570,32 +570,32 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
#region Helpers
private async Task InitializeWithDraftVersionAsync()
private async Task InitializeWithJuly2026ProtocolVersionAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(InitializeRequestDraft);
request.Content = JsonContent(InitializeRequestJuly2026Protocol);
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "initialize");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Draft protocol revision (SEP-2567) is sessionless: the server does not return a
// Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a
// mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28
// to route through the sessionless path.
// so each one is handled independently.
}
private async Task InitializeWithNonDraftVersionAsync()
private async Task InitializeWithLegacyVersionAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Server is stateless by default (SEP-2567), so initializing with the non-draft protocol does not return
// a mcp-session-id header. Subsequent requests are independent, just like the draft path.
// Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return
// a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision.
}
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
@ -614,7 +614,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
""";
private static string InitializeRequestDraft => """
private static string InitializeRequestJuly2026Protocol => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
""";

View file

@ -34,8 +34,8 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
// Arrange
// Act
// ping was removed in the draft revision (SEP-2575), so pin to the latest stable protocol
// version to keep exercising the legacy ping RPC. Draft liveness relies on the transport.
// ping was removed in the 2026-07-28 protocol revision (SEP-2575), so pin to the latest stable
// protocol version to keep exercising the legacy ping RPC. On the 2026-07-28 protocol, liveness relies on the transport.
await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" });
await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
@ -49,8 +49,8 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
// Arrange
// Act
// Stateful Streamable HTTP only provisions a session ID under the legacy handshake; the draft
// revision is sessionless. Pin to the latest stable version to keep covering session-ID provisioning.
// Stateful Streamable HTTP only provisions a session ID under the legacy handshake. Starting with the
// 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version to keep covering session-ID provisioning.
await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" });
// Assert

View file

@ -0,0 +1,303 @@
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;
/// <summary>
/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These
/// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's
/// <c>simple-streamablehttp-stateless</c> returns a JSON-RPC error envelope in a <c>400</c> body
/// on a 2026-07-28 probe; vanilla Go does the same on <c>POST /</c>) so the client's HTTP-fallback
/// logic can be exercised in isolation without the cross-SDK harness.
/// </summary>
/// <remarks>
/// <para>
/// Two latent bugs were discovered during cross-SDK testing and fixed by the SEP-2575 / SEP-2567
/// branch:
/// </para>
/// <list type="number">
/// <item>
/// <see cref="StreamableHttpClientSessionTransport"/> only surfaced the three error codes
/// introduced by the 2026-07-28 revision (<c>-32022</c>, <c>-32021</c>, <c>-32020</c>) as <see cref="McpProtocolException"/>;
/// any other JSON-RPC error code in a <c>400</c> body (e.g. <c>-32600</c> from a legacy server
/// that doesn't understand the 2026-07-28 <c>_meta</c> envelope) threw <see cref="HttpRequestException"/>
/// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger
/// on ANY non-modern JSON-RPC error in a <c>400</c> body.
/// </item>
/// <item>
/// <see cref="AutoDetectingClientSessionTransport"/> 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 modern codes) because the SSE GET would
/// either fail with "session id required" or succeed against a different endpoint and lose
/// the actual signal.
/// </item>
/// </list>
/// </remarks>
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)
{
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
_app.MapPost("/mcp", handler);
await _app.StartAsync(TestContext.Current.CancellationToken);
}
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)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<JsonRpcMessage>()), context.RequestAborted);
}
/// <summary>
/// Mimics Python's <c>simple-streamablehttp-stateless</c> on a 2026-07-28 probe: returns
/// <c>400</c> + JSON-RPC <c>-32600</c> ("Bad Request: Unsupported protocol version") for the
/// initial <c>server/discover</c>, then performs a normal legacy <c>initialize</c> handshake
/// when the client falls back.
/// </summary>
[Fact]
public async Task Client_AgainstLegacyHttpServer_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<JsonRpcMessage>(),
ct);
if (message is not JsonRpcRequest request)
{
context.Response.StatusCode = StatusCodes.Status202Accepted;
return;
}
// 2026-07-28 probe: simulate a legacy 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;
}
// Legacy initialize: respond with the highest version the legacy server speaks.
if (request.Method == RequestMethods.Initialize)
{
var response = new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "2025-06-18",
Capabilities = new() { Tools = new() },
ServerInfo = new Implementation { Name = "legacy", Version = "1.0" },
}, McpJsonUtilities.DefaultOptions),
};
context.Response.ContentType = "application/json";
await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo<JsonRpcMessage>(), 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<JsonRpcMessage>(), 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 a legacy server.
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
loggerFactory: LoggerFactory, cancellationToken: ct);
Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion);
// Sanity: subsequent traffic still works post-fallback.
var tools = await client.ListToolsAsync(cancellationToken: ct);
Assert.Empty(tools);
}
/// <summary>
/// Mimics vanilla Go: returns <c>400</c> + JSON-RPC <c>-32022</c> with
/// <c>data.supported[]</c> on a 2026-07-28 probe so the client retries legacy
/// <c>initialize</c> with one of the advertised versions.
/// </summary>
[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<JsonRpcMessage>(),
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<string> { "2025-11-25" },
Requested = "2026-07-28",
}, GetJsonTypeInfo<UnsupportedProtocolVersionErrorData>());
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<JsonRpcMessage>()), ct);
return;
}
if (request.Method == RequestMethods.Initialize)
{
var response = new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "2025-11-25",
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<JsonRpcMessage>(), 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 a legacy server.
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
loggerFactory: LoggerFactory, cancellationToken: ct);
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
}
/// <summary>
/// A 400 with a JSON-RPC <c>-32020 HeaderMismatch</c> envelope must be surfaced to the
/// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope.
/// </summary>
[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<JsonRpcMessage>(),
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<McpProtocolException>(async () =>
{
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
{
ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion,
}, loggerFactory: LoggerFactory, cancellationToken: ct);
});
Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode);
Assert.False(initializeReceived);
}
}

View file

@ -0,0 +1,199 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using System.Net;
using System.Text;
using System.Text.Json;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server
/// suppresses the <c>Mcp-Session-Id</c> header for those requests and returns structured
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> errors instead of plain 400s.
/// </summary>
public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
private async Task StartAsync(bool stateless = false)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" };
}).WithHttpTransport(options =>
{
// Stateless = false maps the GET/DELETE endpoints and opts the author into sessions. Starting with
// the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so such a request is
// refused on a session-enabled server. Stateless = true (the default) serves them natively.
options.Stateless = stateless;
});
_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();
}
[Fact]
public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader()
{
await StartAsync(stateless: true);
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
// On a stateless server, server/discover succeeds without creating a session.
var content = new StringContent(
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
Encoding.UTF8, "application/json");
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Responses on the 2026-07-28 revision must not include Mcp-Session-Id");
}
[Fact]
public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError()
{
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567),
// so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that
// version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back
// to the legacy initialize handshake.
await StartAsync(stateless: false);
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
var content = new StringContent(
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
Encoding.UTF8, "application/json");
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.False(response.Headers.Contains("Mcp-Session-Id"));
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
var rpcMessage = JsonSerializer.Deserialize<JsonRpcMessage>(body, McpJsonUtilities.DefaultOptions);
var rpcError = Assert.IsType<JsonRpcError>(rpcMessage);
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code);
var dataElement = (JsonElement)rpcError.Error.Data!;
var errorData = dataElement.Deserialize<UnsupportedProtocolVersionErrorData>(McpJsonUtilities.DefaultOptions);
Assert.NotNull(errorData);
Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested);
// The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version.
Assert.NotEmpty(errorData.Supported);
Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported);
}
[Fact]
public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31");
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
var content = new StringContent(
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
Encoding.UTF8, "application/json");
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
var rpcMessage = JsonSerializer.Deserialize<JsonRpcMessage>(body, McpJsonUtilities.DefaultOptions);
var rpcError = Assert.IsType<JsonRpcError>(rpcMessage);
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code);
// Validate the structured data payload (SEP-2575 §"Unsupported Protocol Versions").
var dataElement = (JsonElement)rpcError.Error.Data!;
var errorData = dataElement.Deserialize<UnsupportedProtocolVersionErrorData>(McpJsonUtilities.DefaultOptions);
Assert.NotNull(errorData);
Assert.Equal("2099-12-31", errorData.Requested);
Assert.NotEmpty(errorData.Supported);
}
[Fact]
public async Task Request_WithMcpSessionIdHeader_IsRejected()
{
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567):
// a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the
// Stateless setting.
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
var content = new StringContent(
"""{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""",
Encoding.UTF8, "application/json");
using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Get_WithoutSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Get_WithSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Delete_WithoutSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Delete_WithSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id");
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View file

@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// End-to-end coverage for a default (2026-07-28-first) client connecting to a real C# Streamable HTTP
/// server that deliberately opted into sessions (<see cref="HttpServerTransportOptions.Stateless"/>
/// is <c>false</c>). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports
/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with <c>-32022 UnsupportedProtocolVersion</c>.
/// The client must then auto-downgrade to the legacy <c>initialize</c> handshake, obtain the stateful session
/// the server author opted into, and continue to work, including a server→client elicitation round-trip
/// resolved over the stateful session via the legacy backcompat resolver.
/// </summary>
public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
[McpServerTool(Name = "greet")]
private static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!";
[McpServerTool(Name = "greet_via_elicit")]
private static async Task<string> GreetViaElicit(McpServer server, CancellationToken cancellationToken)
{
// Server→client round-trip: only works when the session is stateful, which is exactly what
// the legacy fallback re-establishes for the 2026-07-28-first client.
var elicitResult = await server.ElicitAsync(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new(),
}, cancellationToken);
var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true
? answer.GetString()
: "stranger";
return $"Hello, {name}!";
}
private async Task StartStatefulServerAsync()
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" };
})
// Stateless = false is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision,
// Streamable HTTP can never be served statefully, so the server refuses the probe and the client downgrades.
.WithHttpTransport(options => options.Stateless = false)
.WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]);
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
}
private async Task<McpClient> ConnectDefaultClientAsync(Action<McpClientOptions>? configureClient = null)
{
await using var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost:5000/"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
// Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes
// with server/discover before falling back to a legacy initialize handshake.
var clientOptions = new McpClientOptions();
configureClient?.Invoke(clientOptions);
return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken);
}
[Fact]
public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork()
{
await StartStatefulServerAsync();
await using var client = await ConnectDefaultClientAsync();
// The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy.
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("greet",
new Dictionary<string, object?> { ["name"] = "Alice" },
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
Assert.Equal("Hello, Alice!", text);
}
[Fact]
public async Task DefaultClient_AgainstStatefulServer_ServerToClientElicitation_RoundTrips()
{
await StartStatefulServerAsync();
await using var client = await ConnectDefaultClientAsync(options =>
{
options.Handlers.ElicitationHandler = (request, ct) => new ValueTask<ElicitResult>(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(),
},
});
});
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("greet_via_elicit",
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
Assert.Equal("Hello, Bob!", text);
Assert.True(result.IsError is not true);
}
}

View file

@ -486,8 +486,8 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await app.StartAsync(TestContext.Current.CancellationToken);
// Polling via an event-stream store is a stateful-session feature. Under draft, Streamable HTTP
// is sessionless, so pin to the latest stable version to keep exercising the stateful path.
// Polling via an event-stream store is a stateful-session feature. Starting with the 2026-07-28
// protocol revision, Streamable HTTP no longer supports sessions, so pin to the latest stable version to keep exercising the stateful path.
await using var mcpClient = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25");
await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken);
@ -541,7 +541,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
};
// DELETE requests are only sent when there's a session ID to delete - a legacy stateful
// behavior. Under draft, Streamable HTTP is sessionless. Pin to the latest stable version.
// behavior. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version.
await using var mcpClient = await ConnectAsync(transportOptions: transportOptions, configureClient: options => options.ProtocolVersion = "2025-11-25");
// Do a tool call to ensure there's more than just the initialize request
@ -722,7 +722,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
// Connect the first client and verify it works.
// Server-side session expiry and reconnect rely on session IDs, a legacy stateful behavior.
// Under draft, Streamable HTTP is sessionless. Pin both clients to the latest stable version.
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin both clients to the latest stable version.
var client1 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25");
var originalSessionId = client1.SessionId;
Assert.NotNull(originalSessionId);
@ -797,7 +797,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await app.StartAsync(TestContext.Current.CancellationToken);
// The stateful (else) branch below asserts session-ID behavior, which only exists under the
// legacy handshake; the draft revision is sessionless. Pin legacy only for the stateful variant.
// legacy handshake. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin legacy only for the stateful variant.
await using var client = await ConnectAsync(configureClient: options =>
{
if (!Stateless)

View file

@ -10,12 +10,12 @@ namespace ModelContextProtocol.AspNetCore.Tests;
public abstract partial class MapMcpTests
{
// Draft is sessionless (SEP-2567): the Streamable HTTP handler refuses a sessionless draft request
// when the server opted into sessions (Stateless = false), so a draft-pinned client downgrades to
// legacy instead of negotiating 2026-07-28. These draft MRTR tests therefore can't run on the
// stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures.
private const string DraftStatefulStreamableHttpSkipReason =
"Draft is sessionless (SEP-2567); stateful Streamable HTTP refuses sessionless draft. Covered by the stateless and SSE fixtures.";
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567):
// the handler refuses a request when the server opted into sessions (Stateless = false), so a client pinned
// to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't
// run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures.
private const string July2026StatefulStreamableHttpSkipReason =
"Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567); stateful Streamable HTTP refuses it. Covered by the stateless and SSE fixtures.";
private ServerMessageTracker ConfigureServer(params Delegate[] tools)
{
@ -25,7 +25,7 @@ public abstract partial class MapMcpTests
options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" };
// Do not pin a protocol version - let it be negotiated based on what the client requests.
// 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get
// the latest non-draft.
// the latest legacy version.
messageTracker.AddFilters(options.Filters.Message);
})
.WithHttpTransport(ConfigureStateless)
@ -40,8 +40,8 @@ public abstract partial class MapMcpTests
options.ProtocolVersion = "2026-07-28";
});
// The default client now negotiates draft (2026-07-28). The legacy JSON-RPC MRTR back-compat
// resolver only applies to legacy clients, so pin these to the latest non-draft version.
// The default client now negotiates the 2026-07-28 protocol revision. The legacy
// JSON-RPC MRTR back-compat resolver only applies to legacy clients, so pin these to the latest legacy version.
private Task<McpClient> ConnectLegacyAsync() =>
ConnectAsync(configureClient: options =>
{
@ -169,16 +169,16 @@ public abstract partial class MapMcpTests
[InlineData(true)]
public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient)
{
// The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3,
// which requires server-to-client requests - only available in stateful sessions. Under
// the draft protocol revision (SEP-2567), Streamable HTTP is implicitly stateless, so the
// the await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3,
// which requires server-to-client requests - only available in stateful sessions. Starting with
// the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP is implicitly stateless, so the
// experimental-client + HTTP combination cannot resolve the await-style portion. Stdio
// coverage for this scenario lives in DraftProtocolBackcompatTests.
Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) makes Streamable HTTP sessionless. See DraftProtocolBackcompatTests for stdio coverage.");
// coverage for this scenario lives in July2026ProtocolBackcompatTests.
Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; starting with the 2026-07-28 protocol revision (SEP-2567) Streamable HTTP no longer supports sessions. See July2026ProtocolBackcompatTests for stdio coverage.");
// The server always supports 2026-07-28 (it's in SupportedProtocolVersions). The
// client opts in by pinning ProtocolVersion = "2026-07-28"; otherwise it negotiates
// the latest non-draft version and the server falls back to the exception path with
// the latest legacy version and the server falls back to the exception path with
// legacy JSON-RPC resolution.
var messageTracker = ConfigureServer(MrtrMixed);
@ -188,7 +188,7 @@ public abstract partial class MapMcpTests
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; }
// ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage.
// ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
// The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3.
@ -289,10 +289,10 @@ public abstract partial class MapMcpTests
// Parallel awaits work with regular JSON-RPC but fail with MRTR because
// MrtrContext only supports one exchange at a time (TrySetResult gate).
Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only).");
// Under the draft protocol revision (SEP-2567), the server is implicitly stateless for draft
// clients, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client
// Starting with the 2026-07-28 protocol revision (SEP-2567), the server is implicitly stateless for
// clients on that revision, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client
// case for the same reason as Mrtr_MixedExceptionAndAwaitStyle.
Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) is sessionless.");
Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; starting with the 2026-07-28 protocol revision (SEP-2567) Streamable HTTP no longer supports sessions.");
ConfigureServer(MrtrParallelAwait);
await using var app = Builder.Build();
@ -301,7 +301,7 @@ public abstract partial class MapMcpTests
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; }
// ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage.
// ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
await using var client = await ConnectAsync(configureClient: configureClient);
@ -357,7 +357,7 @@ public abstract partial class MapMcpTests
[Fact]
public async Task Mrtr_Roots_CompletesViaMrtr()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(
[McpServerTool(Name = "mrtr-roots")] (RequestContext<CallToolRequestParams> context) =>
@ -435,7 +435,7 @@ public abstract partial class MapMcpTests
[InlineData(false)]
public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
{
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(MrtrMulti);
await using var app = Builder.Build();
@ -445,7 +445,7 @@ public abstract partial class MapMcpTests
// Configure client - experimental or default based on parameter.
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; }
// ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage.
// ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
await using var client = await ConnectAsync(configureClient: configureClient);
@ -484,7 +484,7 @@ public abstract partial class MapMcpTests
[InlineData(false)]
public async Task Mrtr_IsMrtrSupported(bool experimentalClient)
{
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);
ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString());
await using var app = Builder.Build();
@ -494,7 +494,7 @@ public abstract partial class MapMcpTests
// Configure client - experimental or default based on parameter.
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; }
// ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage.
// ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
await using var client = await ConnectAsync(configureClient: configureClient);
Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion);
@ -550,7 +550,7 @@ public abstract partial class MapMcpTests
[Fact]
public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(MrtrConcurrentThree);
await using var app = Builder.Build();
@ -604,7 +604,7 @@ public abstract partial class MapMcpTests
[Fact]
public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(
[McpServerTool(Name = "mrtr-loadshed")] (RequestContext<CallToolRequestParams> context) =>

View file

@ -111,8 +111,8 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
await app.StartAsync(TestContext.Current.CancellationToken);
// Session-scoped user validation across requests is a legacy stateful-session behavior; the
// draft revision is sessionless. Pin to the latest stable version to keep covering it.
// Session-scoped user validation across requests is a legacy stateful-session behavior. Starting with the
// 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version to keep covering it.
var httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(
() => ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"));
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
@ -163,8 +163,8 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
await using var mcpClient = await ConnectAsync(configureClient: options =>
{
// Server->client sampling over the open response stream is a stateful-session behavior.
// Under draft, Streamable HTTP is forced sessionless, so the implicit-MRTR suspend path
// doesn't apply over HTTP (draft sampling is covered by the stdio MRTR tests). Pin legacy.
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so the implicit-MRTR suspend path
// doesn't apply over HTTP (this sampling path is covered by the stdio MRTR tests). Pin legacy.
options.ProtocolVersion = "2025-11-25";
options.Handlers.SamplingHandler = async (parameters, _, _) =>
{
@ -326,9 +326,9 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
new Dictionary<string, object?> { ["message"] = "hi" },
cancellationToken: TestContext.Current.CancellationToken);
// The client now defaults to the draft revision, whose handshake is server/discover
// The client now defaults to the 2026-07-28 protocol revision, whose handshake is server/discover
// rather than the legacy initialize request. On the stateful Streamable HTTP fixture the
// sessionless draft request is refused, so the client downgrades to the legacy initialize.
// request is refused, so the client downgrades to the legacy initialize.
var expectedHandshakeMethod = UseStreamableHttp && !Stateless
? RequestMethods.Initialize
: RequestMethods.ServerDiscover;
@ -387,7 +387,7 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
await using var client = await ConnectAsync(configureClient: opts =>
{
// Server-originated sampling requests and the initialize response are legacy stateful
// behaviors; the draft revision routes sampling through MRTR and drops initialize.
// behaviors; the 2026-07-28 protocol revision routes sampling through MRTR and drops initialize.
opts.ProtocolVersion = "2025-11-25";
opts.Capabilities = clientOptions.Capabilities;
opts.Handlers = clientOptions.Handlers;

View file

@ -13,10 +13,10 @@ using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the draft revision.
/// Under the draft protocol (SEP-2575 + SEP-2567) Streamable HTTP is sessionless, so these tests
/// drive the default <see cref="ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless"/> server with raw, sessionless
/// draft JSON-RPC requests (no <c>initialize</c>, no <c>Mcp-Session-Id</c>) and verify the explicit
/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the 2026-07-28 protocol revision.
/// Under that revision (SEP-2575 + SEP-2567) Streamable HTTP no longer supports sessions, so these tests
/// drive the default <see cref="ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless"/> server with raw
/// JSON-RPC requests (no <c>initialize</c>, no <c>Mcp-Session-Id</c>) and verify the explicit
/// MRTR <see cref="InputRequiredResult"/> structure, retry with inputResponses, and error handling.
/// Stateful-session MRTR behaviors (implicit handler suspension, disposal cancellation) are covered
/// over stdio by <c>MrtrHandlerLifecycleTests</c>, and unknown-session rejection by
@ -24,7 +24,6 @@ namespace ModelContextProtocol.AspNetCore.Tests;
/// </summary>
public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private WebApplication? _app;
@ -143,12 +142,12 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
// Drive the server with sessionless draft requests: every request carries the draft
// Drive the server with raw requests: every request carries the 2026-07-28 protocol
// MCP-Protocol-Version header and (via PostJsonRpcAsync) the SEP-2243 Mcp-Method/Mcp-Name
// headers. No initialize handshake and no Mcp-Session-Id.
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
}
public async ValueTask DisposeAsync()
@ -303,7 +302,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
// Initialize with the current (non-draft) protocol so the server's backcompat resolver runs.
// Initialize with the current legacy protocol so the server's backcompat resolver runs.
var initJson = """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}}
""";

View file

@ -15,11 +15,10 @@ namespace ModelContextProtocol.AspNetCore.Tests;
/// Wire-format conformance tests for the Streamable HTTP server driven directly via <see cref="HttpClient"/>,
/// without going through <see cref="ModelContextProtocol.Client.McpClient"/>. These hand-craft HTTP
/// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 +
/// SEP-2567 (sessionless, no-initialize) draft revision.
/// SEP-2567 2026-07-28 protocol revision.
/// </summary>
public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private const string ProtocolVersionHeader = "MCP-Protocol-Version";
private WebApplication? _app;
@ -83,22 +82,22 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
return JsonNode.Parse(body)!;
}
private static string DraftMetaFragment(string protocolVersion = DraftVersion) =>
private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) =>
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
[Fact]
public async Task DraftToolsCall_WithFullMeta_Succeeds_200()
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""}," +
DraftMetaFragment() + "}}";
July2026ProtocolMetaFragment() + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "echo");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
@ -107,7 +106,8 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
// Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id.
// 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"));
}
@ -116,17 +116,17 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}";
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, DraftVersion);
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.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<string>()).ToList();
Assert.Contains(DraftVersion, supported);
Assert.Contains(McpHttpHeaders.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.
@ -136,13 +136,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
}
[Fact]
public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004()
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""}," +
DraftMetaFragment("9999-99-99") + "}}";
July2026ProtocolMetaFragment("9999-99-99") + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, "9999-99-99");
@ -150,7 +150,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
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 -32004 and a data payload
// Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload
// listing the supported versions. The dual-era client uses this to switch versions without fallback.
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
@ -160,25 +160,25 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.NotNull(data);
Assert.Equal("9999-99-99", data!["requested"]!.GetValue<string>());
var supported = data["supported"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
Assert.Contains(DraftVersion, supported);
Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported);
}
[Fact]
public async Task DraftPost_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32001()
public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32020()
{
await StartAsync();
// The MCP-Protocol-Version header declares the draft revision, but the per-request _meta declares a
// 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 -32001 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body
// checks) so a conformant draft client surfaces the error instead of mistaking the modern server for a
// 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 modern server for a
// legacy one and falling back to the initialize handshake.
var body =
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" +
DraftMetaFragment("2025-11-25") + "}}";
July2026ProtocolMetaFragment("2025-11-25") + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);

View file

@ -13,7 +13,7 @@ namespace ModelContextProtocol.AspNetCore.Tests;
/// Verifies that aborting an HTTP request flows cancellation into the running request handler's
/// <see cref="CancellationToken"/>.
/// <para>
/// Under the draft protocol revision (SEP-2575 + SEP-2567) the HTTP request lifetime <em>is</em> the
/// Starting with the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) the HTTP request lifetime <em>is</em> the
/// request lifetime: there are no sessions, so a dropped connection is equivalent to cancelling the
/// in-flight request. The same holds for legacy stateless mode, where each request is independent and
/// outlived by nothing. These tests pin that behavior so a tool's <see cref="CancellationToken"/> fires
@ -22,7 +22,6 @@ namespace ModelContextProtocol.AspNetCore.Tests;
/// </summary>
public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private WebApplication? _app;
@ -84,13 +83,14 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
}
[Fact]
public async Task DraftSessionlessRequest_AbortFlowsCancellationToToolHandler()
public async Task July2026Request_AbortFlowsCancellationToToolHandler()
{
// Draft is sessionless (SEP-2567) and is served natively only on a stateless server; a
// Stateless=false server refuses sessionless draft so dual-era clients fall back to initialize.
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is
// served natively only on a stateless server; a Stateless=false server refuses these requests so dual-era
// clients fall back to initialize.
await StartAsync(stateless: true);
using var request = CreateBlockingToolRequest(draft: true);
using var request = CreateBlockingToolRequest(july2026Protocol: true);
await AssertAbortCancelsToolAsync(request);
}
@ -100,19 +100,19 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
{
await StartAsync(stateless: true);
using var request = CreateBlockingToolRequest(draft: false);
using var request = CreateBlockingToolRequest(july2026Protocol: false);
await AssertAbortCancelsToolAsync(request);
}
private static HttpRequestMessage CreateBlockingToolRequest(bool draft)
private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protocol)
{
// Draft tools/call requires the SEP-2243 Mcp-Method/Mcp-Name headers and the per-request _meta
// A 2026-07-28 tools/call requires the SEP-2243 Mcp-Method/Mcp-Name headers and the per-request _meta
// (protocol version, client info, capabilities) that replaces the initialize handshake (SEP-2567).
var body = draft
var body = july2026Protocol
? """
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"DRAFT_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
""".Replace("DRAFT_VERSION", DraftVersion)
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
""".Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion)
: """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}""";
var request = new HttpRequestMessage(HttpMethod.Post, "")
@ -123,9 +123,9 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (draft)
if (july2026Protocol)
{
request.Headers.Add("MCP-Protocol-Version", DraftVersion);
request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion);
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "blockingTool");
}

View file

@ -517,9 +517,9 @@ public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOut
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
// Resumability (Last-Event-ID) and Mcp-Session-Id are removed in the draft revision
// (SEP-2567). Pin the client to the latest stable version so it negotiates the stateful,
// resumable legacy handshake instead of the sessionless draft default.
// Resumability (Last-Event-ID) and Mcp-Session-Id are removed in the 2026-07-28 protocol
// revision (SEP-2567). Pin the client to the latest stable version so it negotiates the stateful,
// resumable legacy handshake instead of the 2026-07-28 default.
return await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" },
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);

View file

@ -88,13 +88,46 @@ public class ConformanceServerFixture : IAsyncLifetime
}
}
/// <summary>
/// Shared fixture that starts a single stateless ConformanceServer for the
/// SEP-2322 MRTR scenarios in <see cref="ServerConformanceTests"/>. Those scenarios negotiate the
/// 2026-07-28 revision, which is served only on a stateless server, so they
/// cannot reuse the stateful <see cref="ConformanceServerFixture"/>. Reusing one server across all
/// the MRTR theory rows avoids the TCP TIME_WAIT conflicts that per-test restarts on a single port
/// cause on Windows. Uses a dedicated port range (303x) so it runs in parallel with the stateful
/// fixture (300x), the caching server (301x), and the SEP-2243 servers (302x) without colliding.
/// </summary>
public sealed class StatelessMrtrConformanceServerFixture : IAsyncLifetime
{
private StatelessConformanceServer? _server;
public string ServerUrl => _server?.ServerUrl
?? throw new InvalidOperationException("The stateless conformance server has not been started.");
public async ValueTask InitializeAsync()
{
_server = await StatelessConformanceServer.StartAsync(CancellationToken.None, basePort: 3031);
}
public async ValueTask DisposeAsync()
{
if (_server is not null)
{
await _server.DisposeAsync();
}
}
}
/// <summary>
/// Runs the official MCP conformance tests against the ConformanceServer.
/// Uses a shared <see cref="ConformanceServerFixture"/> so the server is started once
/// and reused across all tests, avoiding TCP port conflicts on Windows.
/// </summary>
public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output)
: IClassFixture<ConformanceServerFixture>
public class ServerConformanceTests(
ConformanceServerFixture fixture,
StatelessMrtrConformanceServerFixture statelessFixture,
ITestOutputHelper output)
: IClassFixture<ConformanceServerFixture>, IClassFixture<StatelessMrtrConformanceServerFixture>
{
[Fact]
public async Task RunConformanceTests()
@ -143,8 +176,8 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
!NodeHelpers.HasSep2243Scenarios(),
"SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).");
// SEP-2243 is a draft (2026-07-28) scenario that uses the stateless lifecycle, so it
// requires a stateless server (a stateful server rejects the un-initialized list/call
// SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless
// lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call
// requests with JSON-RPC -32000). Use a dedicated port range so it never collides with
// the stateful class fixture (300x) or the caching stateless server (301x).
await using var server = await StatelessConformanceServer.StartAsync(
@ -180,18 +213,20 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
// ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts
// (the class names predate the conformance-suite rename from "incomplete-result-*" to
// "input-required-result-*"; the wire-level tool names now match the new convention).
// Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28
// so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the
// installed conformance package ships SEP-2322 scenarios <em>and</em> emits this SDK's
// draft wire string (see <see cref="NodeHelpers.HasMrtrScenarios"/>).
// Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28,
// so the csharp-sdk emits InputRequiredResult on the wire. Because the 2026-07-28 revision is
// served only on a stateless server, the scenarios run against a dedicated stateless server
// (StatelessMrtrConformanceServerFixture); a stateful server refuses these requests.
// These tests skip until the installed conformance package ships SEP-2322 scenarios
// (see <see cref="NodeHelpers.HasMrtrScenarios"/>).
//
// input-required-result-tampered-state and input-required-result-capability-check are
// implemented by ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState
// (HMAC-protected requestState; a tampered requestState surfaces a -32602 JSON-RPC error)
// and ToolWithCapabilityCheck (gates inputRequests on the per-request
// _meta clientCapabilities envelope). Both behaviors also have in-process wire-level
// regression coverage in MrtrProtocolTests so they stay verified even while the published
// conformance package's draft wire string lags this SDK.
// regression coverage in MrtrProtocolTests so they stay verified independent of the
// published conformance package.
[Theory]
[InlineData("input-required-result-basic-elicitation")]
[InlineData("input-required-result-basic-sampling")]
@ -210,10 +245,10 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
public async Task RunMrtrConformanceTest(string scenario)
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package (or installed version uses a stale draft wire string).");
Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package.");
var result = await RunConformanceTestsAsync(
$"server --url {fixture.ServerUrl} --scenario {scenario}");
var result = await RunStatelessConformanceTestAsync(
$"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28");
Assert.True(result.Success,
$"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
@ -227,7 +262,7 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
cancellationToken: TestContext.Current.CancellationToken);
}
// For draft scenarios that pin --spec-version explicitly, suppress the
// For 2026-07-28 protocol scenarios that pin --spec-version explicitly, suppress the
// MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended.
private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments)
{

View file

@ -20,7 +20,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
private WebApplication? _app;
private readonly List<string> _deleteRequestSessionIds = [];
// Don't add the delete endpoint by default to ensure the client still works with basic sessionless servers.
// Don't add the delete endpoint by default to ensure the client still works with basic stateless servers.
private async Task StartAsync(bool enableDelete = false)
{
Builder.Services.Configure<JsonOptions>(options =>
@ -128,7 +128,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
}
[Fact]
public async Task CanCallToolOnSessionlessStreamableHttpServer()
public async Task CanCallToolOnStatelessStreamableHttpServer()
{
await StartAsync();

View file

@ -916,13 +916,13 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
#region SEP-2243 Header Validation Tests
[Fact]
public async Task DraftVersion_RejectsMissingMcpMethodHeader()
public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader()
{
// Draft is sessionless and served only on a stateless server (SEP-2567).
// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server.
await StartAsync(stateless: true);
// Initialize with draft version to enable header validation
await CallInitializeWithDraftVersionAndValidateAsync();
// Initialize with the 2026-07-28 protocol version to enable header validation
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
// Send a tools/call request without Mcp-Method header — should be rejected
using var request = new HttpRequestMessage(HttpMethod.Post, "");
@ -935,10 +935,10 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
}
[Fact]
public async Task DraftVersion_RejectsMismatchedMcpMethodHeader()
public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader()
{
await StartAsync(stateless: true);
await CallInitializeWithDraftVersionAndValidateAsync();
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
// Send a tools/call request but set Mcp-Method to wrong value
using var request = new HttpRequestMessage(HttpMethod.Post, "");
@ -951,10 +951,10 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
}
[Fact]
public async Task DraftVersion_AcceptsCorrectMcpMethodHeader()
public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader()
{
await StartAsync(stateless: true);
await CallInitializeWithDraftVersionAndValidateAsync();
await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync();
// Send a tools/call request with correct Mcp-Method and Mcp-Name headers
using var request = new HttpRequestMessage(HttpMethod.Post, "");
@ -968,12 +968,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
}
[Fact]
public async Task NonDraftVersion_DoesNotRequireMcpMethodHeader()
public async Task LegacyVersion_DoesNotRequireMcpMethodHeader()
{
await StartAsync();
await CallInitializeAndValidateAsync();
// With non-draft version, Mcp-Method header is not required
// With the legacy version, Mcp-Method header is not required
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
request.Headers.Add("MCP-Protocol-Version", "2025-03-26");
@ -983,12 +983,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private async Task CallInitializeWithDraftVersionAndValidateAsync()
private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(InitializeRequestDraft);
request.Content = JsonContent(InitializeRequestJuly2026Protocol);
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "initialize");
@ -996,11 +996,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertServerInfo(rpcResponse);
// Draft protocol revision (SEP-2567) is sessionless; the server does not return mcp-session-id.
// Subsequent requests carry MCP-Protocol-Version=2026-07-28 to opt back into the draft path.
// Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id.
// Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently.
}
private static string InitializeRequestDraft => """
private static string InitializeRequestJuly2026Protocol => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
""";