Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)

This commit is contained in:
Stephen Halter 2026-06-23 21:24:22 -07:00 • committed by GitHub
commit 6c323e783b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 5704 additions and 712 deletions

View file

@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Tests.Utils;
@ -187,8 +188,12 @@ public static class NodeHelpers
/// the pinned version in package.json) means this also returns <see langword="true"/>
/// when a newer private build has been installed locally via
/// <c>npm install --no-save &lt;path-to-conformance&gt;</c>.
/// Additionally requires that the installed conformance package emits the draft wire
/// version this SDK speaks — see <see cref="HasMatchingDraftWireVersion"/>.
/// </summary>
public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
public static bool HasSep2243Scenarios()
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0))
&& HasMatchingDraftWireVersion();
/// <summary>
/// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance
@ -197,8 +202,47 @@ public static class NodeHelpers
/// Reading the installed version (rather than the pinned version in package.json) means
/// this also returns <see langword="true"/> when a newer private build has been installed
/// locally via <c>npm install --no-save &lt;path-to-conformance&gt;</c>.
/// Additionally requires that the installed conformance package emits the draft wire
/// version this SDK speaks — see <see cref="HasMatchingDraftWireVersion"/>.
/// </summary>
public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
public static bool HasCachingScenario()
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0))
&& HasMatchingDraftWireVersion();
/// <summary>
/// Returns <see langword="true"/> when the installed conformance package's bundled
/// dist emits the same draft protocol version string as this SDK
/// (<see cref="McpHttpHeaders.DraftProtocolVersion"/>). Used to suppress draft-only
/// conformance scenarios when the published conformance binary is still pinned to a
/// stale wire string (for example, conformance 0.2.0-alpha.2 ships
/// <c>"DRAFT-2026-v1"</c> while this SDK speaks <c>"2026-07-28"</c>).
/// </summary>
/// <remarks>
/// This check is a pragmatic alternative to inspecting the conformance package's
/// internal constants: the bundled <c>dist/index.js</c> is minified so we can't grep
/// the constant name, but the literal version string survives bundling and is unique
/// enough to be a reliable signal.
/// </remarks>
public static bool HasMatchingDraftWireVersion()
{
try
{
var repoRoot = FindRepoRoot();
var distPath = Path.Combine(
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js");
if (!File.Exists(distPath))
{
return false;
}
var bundled = File.ReadAllText(distPath);
return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Returns <see langword="true"/> when the conformance package installed in node_modules
@ -373,42 +417,20 @@ public static class NodeHelpers
}
/// <summary>
/// Checks whether the SEP-2322 (Multi Round-Trip Requests / IncompleteResult)
/// conformance scenarios are available by reading the conformance package version
/// from the repo's package.json. MRTR scenarios require a conformance package version
/// that includes SEP-2322 support (see
/// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult)
/// conformance scenarios are available, by reading the <em>installed</em> conformance
/// package version from node_modules. The <c>incomplete-result-*</c> scenarios were
/// introduced in conformance package 0.2.0 (see
/// https://github.com/modelcontextprotocol/conformance/pull/188).
/// Reading the installed version (rather than the pinned version in package.json) means
/// this also returns <see langword="true"/> when a newer private build has been installed
/// locally via <c>npm install --no-save &lt;path-to-conformance&gt;</c>.
/// Additionally requires that the installed conformance package emits the draft wire
/// version this SDK speaks — see <see cref="HasMatchingDraftWireVersion"/>.
/// </summary>
public static bool HasMrtrScenarios()
{
try
{
var repoRoot = FindRepoRoot();
var packageJsonPath = Path.Combine(repoRoot, "package.json");
if (!File.Exists(packageJsonPath))
{
return false;
}
var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
if (json.RootElement.TryGetProperty("dependencies", out var deps) &&
deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement))
{
var versionStr = versionElement.GetString();
if (versionStr is not null && Version.TryParse(versionStr, out var version))
{
// SEP-2322 scenarios are expected in conformance package >= 0.2.0
return version >= new Version(0, 2, 0);
}
}
return false;
}
catch
{
return false;
}
}
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0))
&& HasMatchingDraftWireVersion();
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
{

View file

@ -46,7 +46,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "DRAFT-2026-v1",
ProtocolVersion = "2025-11-25",
Capabilities = new() { Tools = new() },
ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" },
}, McpJsonUtilities.DefaultOptions)
@ -146,7 +146,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// Register the tool WITHOUT calling ListToolsAsync first — this is the core scenario from issue #1577
@ -186,7 +186,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
client.AddKnownTools([CreateToolWithHeaders()]);
@ -222,7 +222,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
client.AddKnownTools([CreateToolWithHeaders()]);
@ -252,7 +252,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// Call the tool without AddKnownTools or ListToolsAsync — no Mcp-Param-* headers should be sent
@ -285,7 +285,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// Register the tool first
@ -322,7 +322,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// Register then remove — headers should no longer be sent
@ -376,7 +376,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// Register tool, then ListToolsAsync returns empty list from server
@ -411,7 +411,7 @@ public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelI
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory,
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// Register with header "SchemaA", then overwrite with "SchemaB"

View file

@ -105,12 +105,14 @@ 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 DRAFT-2026-v1) and uses the stateless lifecycle.
/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when
/// running against the currently-pinned package, so it activates automatically once a
/// conformance package containing the caching scenario is installed (including a local private
/// build installed via <c>npm install --no-save &lt;path-to-conformance&gt;</c>). The stateless
/// server is started only after the gates pass, so a skipped run binds no port.
/// 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
/// started only after the gates pass, so a skipped run binds no port.
/// </remarks>
public class CachingConformanceTests(ITestOutputHelper output)
{
@ -128,7 +130,7 @@ public class CachingConformanceTests(ITestOutputHelper output)
// explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a
// conflicting duplicate --spec-version flag).
var result = await NodeHelpers.RunServerConformanceAsync(
$"server --url {server.ServerUrl} --scenario caching --spec-version DRAFT-2026-v1",
$"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28",
line => { try { output.WriteLine(line); } catch { } },
appendProtocolVersionFromEnv: false,
cancellationToken: TestContext.Current.CancellationToken);

View file

@ -0,0 +1,305 @@
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 draft-protocol-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 draft 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 modern draft
/// error codes (<c>-32004</c>, <c>-32003</c>, <c>-32001</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 draft <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 DraftHttpFallbackTests(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 draft 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 DraftClient_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;
}
// Draft 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: draft");
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);
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
{
ProtocolVersion = McpSession.DraftProtocolVersion,
}, 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>-32004</c> with
/// <c>data.supported[]</c> on a draft probe so the client retries legacy
/// <c>initialize</c> with one of the advertised versions.
/// </summary>
[Fact]
public async Task DraftClient_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)
{
// -32004 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 = "draft",
}, 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);
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
{
ProtocolVersion = McpSession.DraftProtocolVersion,
}, loggerFactory: LoggerFactory, cancellationToken: ct);
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
}
/// <summary>
/// A 400 with a JSON-RPC <c>-32001 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 DraftClient_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 = McpSession.DraftProtocolVersion,
}, loggerFactory: LoggerFactory, cancellationToken: ct);
});
Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode);
Assert.False(initializeReceived);
}
}

View file

@ -0,0 +1,200 @@
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 draft protocol revision (SEP-2575 + SEP-2567): verify that the server
/// suppresses the <c>Mcp-Session-Id</c> header for draft requests and returns structured
/// <see cref="McpErrorCode.UnsupportedProtocolVersion"/> errors instead of plain 400s.
/// </summary>
public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private WebApplication? _app;
private async Task StartAsync(bool stateless = false)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" };
}).WithHttpTransport(options =>
{
// Stateless = false maps the GET/DELETE endpoints and opts the author into sessions, which the
// draft revision cannot honor (so sessionless draft requests are refused). Stateless = true (the
// default) serves sessionless draft 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 DraftRequest_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader()
{
await StartAsync(stateless: true);
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover");
// On a stateless server, sessionless draft 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"), "Draft responses must not include Mcp-Session-Id");
}
[Fact]
public async Task DraftRequest_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError()
{
// The draft revision is sessionless (SEP-2567), so it cannot honor a server configured with
// sessions (Stateless = false). The server refuses the draft version with
// UnsupportedProtocolVersion (excluding draft from Supported) so a dual-era client falls back
// to the legacy initialize handshake.
await StartAsync(stateless: false);
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
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(DraftVersion, errorData.Requested);
// The draft version is excluded from Supported so the client downgrades to a legacy version.
Assert.NotEmpty(errorData.Supported);
Assert.DoesNotContain(DraftVersion, 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 DraftRequest_WithMcpSessionIdHeader_IsRejected()
{
// The draft revision is sessionless (SEP-2567): a draft 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", DraftVersion);
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 DraftGet_WithoutSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DraftGet_WithSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
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 DraftDelete_WithoutSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DraftDelete_WithSessionId_IsRejected()
{
await StartAsync();
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion);
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 (draft-first) client connecting to a real C# Streamable HTTP
/// server that deliberately opted into sessions (<see cref="HttpServerTransportOptions.Stateless"/>
/// is <c>false</c>). Draft is sessionless (SEP-2567 / SEP-2575), so the server refuses the
/// sessionless draft probe with <c>-32004 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 DraftStatefulFallbackTests(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 draft-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(DraftStatefulFallbackTests), Version = "1" };
})
// Stateless = false is a deliberate opt-in to sessions. Draft can never be served
// statefully, so the server refuses the sessionless draft 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 draft 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 DefaultDraftClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork()
{
await StartStatefulServerAsync();
await using var client = await ConnectDefaultClientAsync();
// The sessionless draft probe was refused (-32004), 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 DefaultDraftClient_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

@ -122,7 +122,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "union_test");
request.Headers.Add("Mcp-Param-Priority", "42");
@ -141,7 +141,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "union_test");
request.Headers.Add("Mcp-Param-Priority", "9007199254740993");
@ -161,7 +161,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
@ -185,7 +185,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call");
request.Headers.TryAddWithoutValidation("Mcp-Name", " header_test ");
request.Headers.Add("Mcp-Param-Region", "us-west1");
@ -208,7 +208,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.TryAddWithoutValidation("Mcp-Method", " tools/call ");
request.Headers.TryAddWithoutValidation("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "us-west1");
@ -232,7 +232,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "us-west1");
@ -255,7 +255,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "us-west1");
@ -281,7 +281,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", encodedValue!);
@ -306,7 +306,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
@ -334,7 +334,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
@ -363,7 +363,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
@ -391,7 +391,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
@ -414,7 +414,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
@ -476,7 +476,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("MCP-Protocol-Version", "2026-07-28");
request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call");
// Raw UTF-8 non-ASCII value in Mcp-Name — server must reject this
request.Headers.TryAddWithoutValidation("Mcp-Name", "café☕");
@ -555,7 +555,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
#region Version gating tests
[Theory]
[InlineData("DRAFT-2026-v1", true)]
[InlineData("2026-07-28", true)]
[InlineData("2025-11-25", false)]
[InlineData("2025-06-18", false)]
[InlineData("2024-11-05", false)]
@ -576,15 +576,15 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(InitializeRequestDraft);
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
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);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
// Draft protocol revision (SEP-2567) is sessionless: the server does not return a
// mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28
// to route through the sessionless path.
}
private async Task InitializeWithNonDraftVersionAsync()
@ -594,9 +594,8 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
// 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.
}
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
@ -616,8 +615,9 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre
""";
private static string InitializeRequestDraft => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
""";
#endregion
}

View file

@ -211,7 +211,7 @@ public class HttpMcpServerBuilderExtensionsTests(ITestOutputHelper testOutputHel
{
Builder.Services
.AddMcpServer()
.WithHttpTransport();
.WithHttpTransport(options => options.Stateless = false);
using var app = Builder.Build();
@ -220,7 +220,7 @@ public class HttpMcpServerBuilderExtensionsTests(ITestOutputHelper testOutputHel
await idleTrackingService.StartAsync(TestContext.Current.CancellationToken);
// In the default (stateful) mode the timer loop must start, so ExecuteTask should be set.
// In stateful mode the timer loop must start, so ExecuteTask should be set.
Assert.NotNull(idleTrackingService.ExecuteTask);
await idleTrackingService.StopAsync(TestContext.Current.CancellationToken);

View file

@ -34,7 +34,9 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
// Arrange
// Act
await using var client = await GetClientAsync();
// 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.
await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" });
await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert
@ -47,7 +49,9 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
// Arrange
// Act
await using var client = await GetClientAsync();
// 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.
await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" });
// Assert
Assert.NotNull(client.ServerCapabilities);

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@ -21,7 +21,7 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
[InlineData("/mcp/secondary")]
public async Task Allows_Customizing_Route(string pattern)
{
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true);
Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; });
await using var app = Builder.Build();
app.MapMcp(pattern);
@ -53,7 +53,7 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
Name = "TestCustomRouteServer",
Version = "1.0.0",
};
}).WithHttpTransport(options => options.EnableLegacySse = true);
}).WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; });
await using var app = Builder.Build();
app.MapMcp(routePattern);
@ -83,7 +83,7 @@ public class MapMcpSseTests(ITestOutputHelper outputHelper) : MapMcpTests(output
return "Complete";
}, options: new() { Name = "polling_tool" });
Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]);
Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }).WithTools([pollingTool]);
await using var app = Builder.Build();
app.MapMcp();

View file

@ -410,7 +410,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
OwnsSession = false,
}, HttpClient, LoggerFactory);
await using (var initialClient = await McpClient.CreateAsync(initialTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
await using (var initialClient = await McpClient.CreateAsync(initialTransport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
{
resumedSessionId = initialClient.SessionId ?? throw new InvalidOperationException("SessionId not negotiated.");
serverCapabilities = initialClient.ServerCapabilities;
@ -486,7 +486,9 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await app.StartAsync(TestContext.Current.CancellationToken);
await using var mcpClient = await ConnectAsync();
// 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.
await using var mcpClient = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25");
await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken);
@ -538,7 +540,9 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
},
};
await using var mcpClient = await ConnectAsync(transportOptions: transportOptions);
// 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.
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
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
@ -589,7 +593,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
OwnsSession = false,
}, HttpClient, LoggerFactory);
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
// Call a tool to ensure the session is fully established
var result = await client.CallToolAsync(
@ -657,7 +661,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
OwnsSession = false,
}, HttpClient, LoggerFactory);
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var result = await client.CallToolAsync(
"echo_claims_principal",
@ -717,7 +721,9 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await app.StartAsync(TestContext.Current.CancellationToken);
// Connect the first client and verify it works.
var client1 = await ConnectAsync();
// 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.
var client1 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25");
var originalSessionId = client1.SessionId;
Assert.NotNull(originalSessionId);
@ -739,7 +745,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await client1.DisposeAsync();
// Reconnect with a brand-new session.
await using var client2 = await ConnectAsync();
await using var client2 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25");
Assert.NotNull(client2.SessionId);
Assert.NotEqual(originalSessionId, client2.SessionId);
@ -753,6 +759,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
{
var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>();
var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>();
var requestObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
@ -782,18 +789,31 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method));
capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method));
requestObserved.TrySetResult();
return result;
});
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectAsync();
// 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.
await using var client = await ConnectAsync(configureClient: options =>
{
if (!Stateless)
{
options.ProtocolVersion = "2025-11-25";
}
});
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// The filter must have observed at least one MCP request. Don't assert an exact
// minimum - the initialized notification or GET stream may not have completed yet.
// The filter records into the bag *after* await next(context) returns. For a streamed SSE
// response the client can observe completion (and ListToolsAsync can return) before that
// server-side continuation runs, so asserting the bag immediately races. Wait for the filter
// to record at least one request first. Don't assert an exact minimum - the initialized
// notification or GET stream may not have completed yet.
await requestObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
Assert.NotEmpty(capturedSessionIds);
if (Stateless)

View file

@ -10,6 +10,13 @@ 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.";
private ServerMessageTracker ConfigureServer(params Delegate[] tools)
{
var messageTracker = new ServerMessageTracker();
@ -17,7 +24,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.
// DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get
// 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get
// the latest non-draft.
messageTracker.AddFilters(options.Filters.Message);
})
@ -30,11 +37,17 @@ public abstract partial class MapMcpTests
ConnectAsync(configureClient: options =>
{
ConfigureMrtrHandlers(options);
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
});
private Task<McpClient> ConnectDefaultAsync() =>
ConnectAsync(configureClient: ConfigureMrtrHandlers);
// 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.
private Task<McpClient> ConnectLegacyAsync() =>
ConnectAsync(configureClient: options =>
{
ConfigureMrtrHandlers(options);
options.ProtocolVersion = "2025-11-25";
});
/// <summary>Configures elicitation, sampling, and roots handlers on client options.</summary>
private static void ConfigureMrtrHandlers(McpClientOptions options)
@ -79,7 +92,7 @@ public abstract partial class MapMcpTests
// =====================================================================
// MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases.
// Each test creates its own server with DRAFT-2026-v1 enabled.
// Each test creates its own server with 2026-07-28 enabled.
// =====================================================================
[McpServerTool(Name = "mrtr-mixed")]
@ -156,8 +169,15 @@ public abstract partial class MapMcpTests
[InlineData(true)]
public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient)
{
// The server always supports DRAFT-2026-v1 (it's in SupportedProtocolVersions). The
// client opts in by pinning ProtocolVersion = "DRAFT-2026-v1"; otherwise it negotiates
// 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
// 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.");
// 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
// legacy JSON-RPC resolution.
var messageTracker = ConfigureServer(MrtrMixed);
@ -167,8 +187,9 @@ public abstract partial class MapMcpTests
await app.StartAsync(TestContext.Current.CancellationToken);
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
? 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.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
// The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3.
// In stateless mode, those calls succeed only when the request is still open on the same
@ -180,6 +201,7 @@ public abstract partial class MapMcpTests
// and no persistent server instance for the backcompat retry loop). The server returns
// a JSON-RPC error.
await using var client = await ConnectAsync(configureClient: configureClient);
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
client.CallToolAsync("mrtr-mixed",
cancellationToken: TestContext.Current.CancellationToken).AsTask());
@ -202,7 +224,7 @@ public abstract partial class MapMcpTests
// Stateful path - both client modes complete all 3 rounds.
await using var statefulClient = await ConnectAsync(configureClient: configureClient);
Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25",
Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25",
statefulClient.NegotiatedProtocolVersion);
var result = await statefulClient.CallToolAsync("mrtr-mixed",
@ -267,6 +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
// case for the same reason as Mrtr_MixedExceptionAndAwaitStyle.
Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) is sessionless.");
ConfigureServer(MrtrParallelAwait);
await using var app = Builder.Build();
@ -274,8 +300,9 @@ public abstract partial class MapMcpTests
await app.StartAsync(TestContext.Current.CancellationToken);
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
? 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.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
await using var client = await ConnectAsync(configureClient: configureClient);
@ -283,7 +310,7 @@ public abstract partial class MapMcpTests
{
// MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second
// call throws InvalidOperationException, which the tool catches and returns as text.
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-parallel-await",
cancellationToken: TestContext.Current.CancellationToken);
@ -330,6 +357,8 @@ public abstract partial class MapMcpTests
[Fact]
public async Task Mrtr_Roots_CompletesViaMrtr()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(
[McpServerTool(Name = "mrtr-roots")] (RequestContext<CallToolRequestParams> context) =>
{
@ -351,7 +380,7 @@ public abstract partial class MapMcpTests
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectExperimentalAsync();
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-roots",
cancellationToken: TestContext.Current.CancellationToken);
@ -406,6 +435,8 @@ public abstract partial class MapMcpTests
[InlineData(false)]
public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient)
{
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(MrtrMulti);
await using var app = Builder.Build();
app.MapMcp();
@ -413,8 +444,9 @@ public abstract partial class MapMcpTests
// Configure client - experimental or default based on parameter.
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
? 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.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
await using var client = await ConnectAsync(configureClient: configureClient);
if (!experimentalClient && Stateless)
@ -437,7 +469,7 @@ public abstract partial class MapMcpTests
if (experimentalClient)
{
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
messageTracker.AssertMrtrUsed();
}
else
@ -452,6 +484,8 @@ public abstract partial class MapMcpTests
[InlineData(false)]
public async Task Mrtr_IsMrtrSupported(bool experimentalClient)
{
Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString());
await using var app = Builder.Build();
app.MapMcp();
@ -459,10 +493,11 @@ public abstract partial class MapMcpTests
// Configure client - experimental or default based on parameter.
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
? 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.
: options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; };
await using var client = await ConnectAsync(configureClient: configureClient);
Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion);
Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-check",
cancellationToken: TestContext.Current.CancellationToken);
@ -515,6 +550,8 @@ public abstract partial class MapMcpTests
[Fact]
public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(MrtrConcurrentThree);
await using var app = Builder.Build();
app.MapMcp();
@ -526,7 +563,7 @@ public abstract partial class MapMcpTests
await using var client = await ConnectAsync(configureClient: options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
options.ProtocolVersion = "2026-07-28";
options.Handlers.ElicitationHandler = async (request, ct) =>
{
elicitCalled.TrySetResult();
@ -553,7 +590,7 @@ public abstract partial class MapMcpTests
};
};
});
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-concurrent-three",
cancellationToken: TestContext.Current.CancellationToken);
@ -567,6 +604,8 @@ public abstract partial class MapMcpTests
[Fact]
public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr()
{
Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason);
var messageTracker = ConfigureServer(
[McpServerTool(Name = "mrtr-loadshed")] (RequestContext<CallToolRequestParams> context) =>
{
@ -582,7 +621,7 @@ public abstract partial class MapMcpTests
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectExperimentalAsync();
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-loadshed",
cancellationToken: TestContext.Current.CancellationToken);
@ -617,7 +656,7 @@ public abstract partial class MapMcpTests
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectDefaultAsync();
await using var client = await ConnectLegacyAsync();
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-roots-backcompat",
@ -668,7 +707,7 @@ public abstract partial class MapMcpTests
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectDefaultAsync();
await using var client = await ConnectLegacyAsync();
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-multi-input",
@ -707,6 +746,7 @@ public abstract partial class MapMcpTests
await using var client = await ConnectAsync(configureClient: options =>
{
ConfigureMrtrHandlers(options);
options.ProtocolVersion = "2025-11-25";
var originalHandler = options.Handlers.ElicitationHandler!;
options.Handlers.ElicitationHandler = (request, ct) =>
{
@ -739,7 +779,7 @@ public abstract partial class MapMcpTests
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectDefaultAsync();
await using var client = await ConnectLegacyAsync();
Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
@ -762,6 +802,7 @@ public abstract partial class MapMcpTests
await using var client = await ConnectAsync(configureClient: options =>
{
ConfigureMrtrHandlers(options);
options.ProtocolVersion = "2025-11-25";
options.Handlers.ElicitationHandler = (request, ct) =>
{
throw new InvalidOperationException("Client-side elicitation failure");

View file

@ -111,7 +111,10 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
await app.StartAsync(TestContext.Current.CancellationToken);
var httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(() => ConnectAsync());
// 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.
var httpRequestException = await Assert.ThrowsAsync<HttpRequestException>(
() => ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"));
Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode);
}
@ -159,6 +162,10 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
var sampleCount = 0;
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.
options.ProtocolVersion = "2025-11-25";
options.Handlers.SamplingHandler = async (parameters, _, _) =>
{
Assert.NotNull(parameters?.Messages);
@ -319,7 +326,13 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
new Dictionary<string, object?> { ["message"] = "hi" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(RequestMethods.Initialize, observedMethods);
// The client now defaults to the draft 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.
var expectedHandshakeMethod = UseStreamableHttp && !Stateless
? RequestMethods.Initialize
: RequestMethods.ServerDiscover;
Assert.Contains(expectedHandshakeMethod, observedMethods);
Assert.Contains(RequestMethods.ToolsList, observedMethods);
Assert.Contains(RequestMethods.ToolsCall, observedMethods);
}
@ -373,6 +386,9 @@ 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.
opts.ProtocolVersion = "2025-11-25";
opts.Capabilities = clientOptions.Capabilities;
opts.Handlers = clientOptions.Handlers;
});

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
@ -7,6 +7,8 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ModelContextProtocol.AspNetCore.Tests</RootNamespace>
<!-- The test suite intentionally exercises the obsoleted stateful Streamable HTTP surface. -->
<NoWarn>$(NoWarn);MCP9006</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">

View file

@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@ -14,12 +13,19 @@ using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// Protocol-level tests for Multi Round-Trip Requests (MRTR).
/// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior
/// including InputRequiredResult structure, retry with inputResponses, and error handling.
/// 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
/// 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
/// <c>StreamableHttpServerConformanceTests.PostRequest_IsNotFound_WithUnrecognizedSessionId</c>.
/// </summary>
public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private WebApplication? _app;
private async Task StartAsync()
@ -31,24 +37,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
Name = nameof(MrtrProtocolTests),
Version = "1",
};
options.ProtocolVersion = "DRAFT-2026-v1";
}).WithTools([
McpServerTool.Create(
async (string message, McpServer server, CancellationToken ct) =>
{
var result = await server.ElicitAsync(new ElicitRequestParams
{
Message = message,
RequestedSchema = new()
}, ct);
return $"{result.Action}:{result.Content?.FirstOrDefault().Value}";
},
new McpServerToolCreateOptions
{
Name = "elicit-tool",
Description = "Elicits from client"
}),
McpServerTool.Create(
static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams),
new McpServerToolCreateOptions
@ -56,14 +45,110 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
Name = "throwing-tool",
Description = "A tool that throws immediately"
}),
McpServerTool.Create(
static CallToolResult (RequestContext<CallToolRequestParams> context) =>
{
// Mirrors ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState:
// R1 (no requestState) issues a requestState; R2 with a tampered requestState
// surfaces a JSON-RPC error rather than a complete result or a re-prompt.
if (context.Params!.RequestState is { } state)
{
if (state != "valid-request-state-token")
{
throw new McpProtocolException(
"requestState failed integrity verification.", McpErrorCode.InvalidParams);
}
return new CallToolResult { Content = [new TextContentBlock { Text = "state-ok" }] };
}
throw new InputRequiredException(
new Dictionary<string, InputRequest>
{
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "Please confirm",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["ok"] = new ElicitRequestParams.BooleanSchema(),
},
Required = ["ok"],
},
}),
},
requestState: "valid-request-state-token");
},
new McpServerToolCreateOptions
{
Name = "tampered-state-tool",
Description = "Rejects a tampered requestState with a JSON-RPC error"
}),
McpServerTool.Create(
static CallToolResult (RequestContext<CallToolRequestParams> context) =>
{
// Mirrors ConformanceServer.Tools.IncompleteResultTools.ToolWithCapabilityCheck:
// emit inputRequests only for capabilities declared on the per-request _meta envelope.
var caps = context.JsonRpcRequest.Context?.ClientCapabilities;
var inputRequests = new Dictionary<string, InputRequest>();
if (caps?.Sampling is not null)
{
inputRequests["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams
{
Messages =
[
new SamplingMessage
{
Role = Role.User,
Content = [new TextContentBlock { Text = "What is the capital of France?" }],
},
],
MaxTokens = 100,
});
}
if (caps?.Elicitation is not null)
{
inputRequests["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["name"] = new ElicitRequestParams.StringSchema(),
},
Required = ["name"],
},
});
}
if (inputRequests.Count == 0)
{
return new CallToolResult { Content = [new TextContentBlock { Text = "no-caps" }] };
}
throw new InputRequiredException(inputRequests);
},
new McpServerToolCreateOptions
{
Name = "capability-check-tool",
Description = "Gates inputRequests on the per-request _meta clientCapabilities envelope"
}),
]).WithHttpTransport();
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
// Drive the server with sessionless draft requests: every request carries the draft
// 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);
}
public async ValueTask DisposeAsync()
@ -79,7 +164,6 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult()
{
await StartAsync();
await InitializeWithMrtrAsync();
var response = await PostJsonRpcAsync(CallTool("throwing-tool"));
@ -94,123 +178,71 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
}
[Fact]
public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError()
public async Task TamperedRequestState_ReturnsJsonRpcError()
{
await StartAsync();
await InitializeWithMrtrAsync();
// Send a retry with a requestState that doesn't match any active continuation
var retryParams = new JsonObject
{
["name"] = "elicit-tool",
["arguments"] = new JsonObject { ["message"] = "test" },
["inputResponses"] = new JsonObject { ["key1"] = new JsonObject { ["action"] = "confirm" } },
["requestState"] = "nonexistent-state-id"
};
// Round 1: no requestState -> InputRequiredResult carrying the issued requestState.
using var r1 = await PostJsonRpcAsync(CallTool("tampered-state-tool"));
var r1Response = await AssertSingleSseResponseAsync(r1);
var r1Result = Assert.IsType<JsonObject>(r1Response.Result);
Assert.Equal("input_required", r1Result["resultType"]?.GetValue<string>());
var response = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString()));
var requestState = r1Result["requestState"]!.GetValue<string>();
var inputKey = r1Result["inputRequests"]!.AsObject().First().Key;
// Read as a generic JsonRpcMessage to check if it's an error
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken));
var message = JsonSerializer.Deserialize<JsonRpcMessage>(sseData, McpJsonUtilities.DefaultOptions);
// Invalid requestState should result in a fresh tool invocation
// (the tool will return InputRequiredResult since it calls ElicitAsync)
// or an error, depending on the implementation.
// In our implementation, unrecognized requestState triggers a new invocation.
Assert.True(
message is JsonRpcResponse or JsonRpcError,
$"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}");
}
[Fact]
public async Task SessionDelete_CancelsPendingMrtrContinuation()
{
await StartAsync();
await InitializeWithMrtrAsync();
// 1. Call a tool that suspends at ElicitAsync (implicit MRTR path).
var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}"""));
var rpcResponse = await AssertSingleSseResponseAsync(response);
// Verify we got an InputRequiredResult (handler is now suspended, continuation stored).
var resultObj = Assert.IsType<JsonObject>(rpcResponse.Result);
Assert.Equal("input_required", resultObj["resultType"]?.GetValue<string>());
var requestState = resultObj["requestState"]!.GetValue<string>();
Assert.False(string.IsNullOrEmpty(requestState));
// 2. DELETE the session while the handler is suspended.
using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
// Poll for the async cancellation to propagate through the handler task.
// Under thread pool starvation, this can take significantly longer than 100ms.
var deadline = DateTime.UtcNow.AddSeconds(30);
while (true)
{
if (MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation"))
|| DateTime.UtcNow >= deadline)
{
break;
}
await Task.Delay(100, TestContext.Current.CancellationToken);
}
// 3. Verify that the MRTR cancellation was logged at Debug level.
var mrtrCancelledLog = MockLoggerProvider.LogMessages
.Where(m => m.Message.Contains("pending MRTR continuation"))
.ToList();
Assert.Single(mrtrCancelledLog);
Assert.Equal(LogLevel.Debug, mrtrCancelledLog[0].LogLevel);
Assert.Contains("1", mrtrCancelledLog[0].Message);
// 4. Verify no error-level log was emitted for the cancellation.
// The handler's OperationCanceledException should be silently observed, not logged as an error.
var errorLogs = MockLoggerProvider.LogMessages
.Where(m => m.LogLevel >= LogLevel.Error && m.Message.Contains("elicit"))
.ToList();
Assert.Empty(errorLogs);
}
[Fact]
public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound()
{
await StartAsync();
await InitializeWithMrtrAsync();
// 1. Call a tool that suspends at ElicitAsync.
var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}"""));
var rpcResponse = await AssertSingleSseResponseAsync(response);
var resultObj = Assert.IsType<JsonObject>(rpcResponse.Result);
var requestState = resultObj["requestState"]!.GetValue<string>();
var inputRequests = resultObj["inputRequests"]!.AsObject();
var inputKey = inputRequests.First().Key;
// 2. DELETE the session.
using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
// 3. Attempt to retry with the old requestState - session is gone.
// Round 2: tamper the requestState the way the conformance harness does and retry.
// The tool MUST reject it with a JSON-RPC error (not a complete result, not a re-prompt).
var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" });
var retryParams = new JsonObject
{
["name"] = "elicit-tool",
["arguments"] = new JsonObject { ["message"] = "Please confirm" },
["requestState"] = requestState,
["name"] = "tampered-state-tool",
["arguments"] = new JsonObject(),
["requestState"] = requestState + "-TAMPERED",
["inputResponses"] = new JsonObject
{
[inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse)))
},
};
using var retryResponse = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString()));
using var r2 = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString()));
Assert.Equal(HttpStatusCode.OK, r2.StatusCode);
// The session was deleted, so we should get a 404 with a JSON-RPC error.
Assert.Equal(HttpStatusCode.NotFound, retryResponse.StatusCode);
Assert.Equal("application/json", retryResponse.Content.Headers.ContentType?.MediaType);
var sseData = Assert.Single(await ReadSseAsync(r2.Content).ToListAsync(TestContext.Current.CancellationToken));
var message = JsonSerializer.Deserialize<JsonRpcMessage>(sseData, McpJsonUtilities.DefaultOptions);
var error = Assert.IsType<JsonRpcError>(message);
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
}
[Fact]
public async Task CapabilityCheck_OnlyEmitsInputRequestsForDeclaredCapabilities()
{
await StartAsync();
// Per SEP-2575 the client declares capabilities per request in
// _meta['io.modelcontextprotocol/clientCapabilities']. Declare ONLY sampling: the tool
// must emit a sampling/createMessage inputRequest but no elicitation/create.
var callParams = new JsonObject
{
["name"] = "capability-check-tool",
["arguments"] = new JsonObject(),
["_meta"] = new JsonObject
{
["io.modelcontextprotocol/clientCapabilities"] = new JsonObject
{
["sampling"] = new JsonObject(),
},
},
};
using var response = await PostJsonRpcAsync(Request("tools/call", callParams.ToJsonString()));
var rpcResponse = await AssertSingleSseResponseAsync(response);
var resultObj = Assert.IsType<JsonObject>(rpcResponse.Result);
Assert.Equal("input_required", resultObj["resultType"]?.GetValue<string>());
var inputRequests = resultObj["inputRequests"]!.AsObject();
Assert.Contains(inputRequests, kvp => kvp.Value!["method"]?.GetValue<string>() == "sampling/createMessage");
Assert.DoesNotContain(inputRequests, kvp => kvp.Value!["method"]?.GetValue<string>() == "elicitation/create");
}
/// <summary>
@ -229,9 +261,9 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
[Fact]
public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream()
{
// Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current
// Configure a server that does NOT pin 2026-07-28 so it can negotiate the current
// protocol with a legacy client. The backcompat resolver path only runs when the
// negotiated version is not DRAFT-2026-v1.
// negotiated version is not 2026-07-28.
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
@ -262,7 +294,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
Name = "backcompat-roots-tool",
Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list",
}),
]).WithHttpTransport();
]).WithHttpTransport(options => options.Stateless = false);
_app = Builder.Build();
_app.MapMcp();
@ -395,7 +427,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
{
var content = JsonContent(json);
// DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243.
// 2026-07-28 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243.
// Parse the body to derive them and attach to this request only.
var bodyNode = JsonNode.Parse(json);
if (bodyNode is JsonObject obj)
@ -437,33 +469,4 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory
Request("tools/call", $$"""
{"name":"{{toolName}}","arguments":{{arguments}}}
""");
/// <summary>
/// Initialize a session requesting the experimental protocol version that enables MRTR.
/// </summary>
private async Task InitializeWithMrtrAsync()
{
var initJson = """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}}
""";
using var response = await PostJsonRpcAsync(initJson);
var rpcResponse = await AssertSingleSseResponseAsync(response);
Assert.NotNull(rpcResponse.Result);
// Verify the server negotiated to the experimental version
var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue<string>();
Assert.Equal("DRAFT-2026-v1", protocolVersion);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
// Set the MCP-Protocol-Version header for subsequent requests
HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version");
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
// Reset request ID counter since initialize used ID 1
_lastRequestId = 1;
}
}

View file

@ -62,7 +62,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
});
Builder.Services.AddAuthorization();
Builder.Services.AddMcpServer().WithHttpTransport();
Builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = false);
}
public async ValueTask DisposeAsync()

View file

@ -0,0 +1,219 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// 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.
/// </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;
private async Task StartAsync()
{
Builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" };
})
.WithHttpTransport()
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]);
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
/// <summary>
/// Reads either a direct JSON response or a single SSE message containing JSON-RPC and returns the
/// parsed JsonNode. The Streamable HTTP server can return either content type depending on negotiation;
/// raw HttpClient tests should accept either.
/// </summary>
private static async Task<JsonNode> ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var contentType = response.Content.Headers.ContentType?.MediaType;
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (contentType == "text/event-stream")
{
// Pull the first non-empty data: line out of the SSE payload.
foreach (var line in body.Split('\n'))
{
if (line.StartsWith("data:", StringComparison.Ordinal))
{
var data = line.Substring("data:".Length).Trim();
if (data.Length > 0)
{
return JsonNode.Parse(data)!;
}
}
}
throw new InvalidOperationException("SSE response did not contain a JSON data event. Body: " + body);
}
return JsonNode.Parse(body)!;
}
private static string DraftMetaFragment(string protocolVersion = DraftVersion) =>
@"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion +
@""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," +
@"""io.modelcontextprotocol/clientCapabilities"":{}}";
[Fact]
public async Task DraftToolsCall_WithFullMeta_Succeeds_200()
{
await StartAsync();
var body =
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," +
DraftMetaFragment() + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "echo");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue<string>());
// Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id.
Assert.False(response.Headers.Contains("mcp-session-id"));
}
[Fact]
public async Task ServerDiscover_RawPost_ReturnsDiscoverResult()
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
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);
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind());
Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue<long>());
Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue<string>());
}
[Fact]
public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004()
{
await StartAsync();
var body =
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," +
DraftMetaFragment("9999-99-99") + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, "9999-99-99");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "echo");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
// Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32004 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);
Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue<int>());
var data = json["error"]!["data"];
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);
}
[Fact]
public async Task DraftPost_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32001()
{
await StartAsync();
// The MCP-Protocol-Version header declares the draft 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
// legacy one and falling back to the initialize handshake.
var body =
@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" +
DraftMetaFragment("2025-11-25") + "}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
request.Headers.Add(ProtocolVersionHeader, DraftVersion);
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue<int>());
}
[Fact]
public async Task LegacyInitialize_StillSucceeds_OnDefaultServer()
{
await StartAsync();
var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue<string>());
}
[Fact]
public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405()
{
await StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "");
request.Headers.Accept.Add(new("text/event-stream"));
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
// Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE
// stream is replaced by subscriptions/listen POST requests. Existing routing in
// McpEndpointRouteBuilderExtensions only maps GET when Stateless == false.
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
}
}

View file

@ -0,0 +1,162 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Net.Http.Headers;
using System.Text;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// 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
/// 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
/// promptly when the client goes away.
/// </para>
/// </summary>
public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion;
private WebApplication? _app;
private readonly TaskCompletionSource _toolStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _toolCanceled = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _requestAborted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private async Task StartAsync(bool stateless)
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = nameof(RequestAbortCancellationTests), Version = "1" };
})
.WithHttpTransport(options =>
{
options.Stateless = stateless;
})
.WithTools([McpServerTool.Create(
async (CancellationToken cancellationToken) =>
{
_toolStarted.TrySetResult();
try
{
// Block until the request handler's CancellationToken fires. If cancellation never
// flows from the aborted HTTP request, this hangs and the test times out.
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException)
{
_toolCanceled.TrySetResult();
throw;
}
return "unreachable";
},
new() { Name = "blockingTool" })]);
_app = Builder.Build();
// Record when the server observes the client abort so we can assert the abort (not some unrelated
// cancellation path) is what tears down the in-flight tool.
_app.Use(async (context, next) =>
{
context.RequestAborted.Register(() => _requestAborted.TrySetResult());
await next();
});
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
[Fact]
public async Task DraftSessionlessRequest_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.
await StartAsync(stateless: true);
using var request = CreateBlockingToolRequest(draft: true);
await AssertAbortCancelsToolAsync(request);
}
[Fact]
public async Task StatelessRequest_AbortFlowsCancellationToToolHandler()
{
await StartAsync(stateless: true);
using var request = CreateBlockingToolRequest(draft: false);
await AssertAbortCancelsToolAsync(request);
}
private static HttpRequestMessage CreateBlockingToolRequest(bool draft)
{
// Draft 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
? """
{"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"}}""";
var request = new HttpRequestMessage(HttpMethod.Post, "")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (draft)
{
request.Headers.Add("MCP-Protocol-Version", DraftVersion);
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "blockingTool");
}
return request;
}
private async Task AssertAbortCancelsToolAsync(HttpRequestMessage request)
{
using var requestCts = new CancellationTokenSource();
// Send the request without awaiting completion. The blockingTool will not return until its
// CancellationToken fires, so this Task only completes once we abort the request below.
// ResponseContentRead (the default) keeps SendAsync pending on the response body, so cancelling
// requestCts actually aborts the in-flight connection. (With ResponseHeadersRead, SendAsync would
// return as soon as the server flushed the SSE response headers and the cancel would be a no-op.)
var sendTask = HttpClient.SendAsync(request, requestCts.Token);
// Wait for the server to actually start running the tool before aborting.
await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Abort the in-flight HTTP request, simulating the client disconnecting.
requestCts.Cancel();
// The server must observe the abort...
await _requestAborted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// ...and that abort must cancel the running tool's CancellationToken.
await _toolCanceled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// The HttpClient call itself should observe the cancellation we requested.
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sendTask);
}
}

View file

@ -490,6 +490,8 @@ public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOut
var serverBuilder = Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
// Resumability is a stateful concern; pin Stateless = false now that the new default is true.
options.Stateless = false;
options.EventStreamStore = eventStreamStore;
configureTransport?.Invoke(options);
})
@ -515,7 +517,11 @@ public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOut
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
return await McpClient.CreateAsync(transport, loggerFactory: 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.
return await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" },
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
}

View file

@ -143,7 +143,7 @@ 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 (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it
// 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
// 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).
@ -151,7 +151,7 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
TestContext.Current.CancellationToken, basePort: 3021);
var result = await RunStatelessConformanceTestAsync(
$"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1");
$"server --url {server.ServerUrl} --scenario http-header-validation --spec-version 2026-07-28");
Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
@ -169,32 +169,48 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
TestContext.Current.CancellationToken, basePort: 3024);
var result = await RunStatelessConformanceTestAsync(
$"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1");
$"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28");
Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
// SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios.
// SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) conformance scenarios.
// The csharp-sdk ConformanceServer surfaces the matching tools/prompts via
// ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts.
// Each scenario uses the conformance harness's RawMcpSession, which negotiates DRAFT-2026-v1
// 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
// upstream conformance package ships with SEP-2322 scenarios
// (https://github.com/modelcontextprotocol/conformance/pull/188).
// installed conformance package ships SEP-2322 scenarios <em>and</em> emits this SDK's
// draft wire string (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.
[Theory]
[InlineData("incomplete-result-basic-elicitation")]
[InlineData("incomplete-result-basic-sampling")]
[InlineData("incomplete-result-basic-list-roots")]
[InlineData("incomplete-result-request-state")]
[InlineData("incomplete-result-multiple-input-requests")]
[InlineData("incomplete-result-multi-round")]
[InlineData("incomplete-result-missing-input-response")]
[InlineData("incomplete-result-non-tool-request")]
[InlineData("input-required-result-basic-elicitation")]
[InlineData("input-required-result-basic-sampling")]
[InlineData("input-required-result-basic-list-roots")]
[InlineData("input-required-result-request-state")]
[InlineData("input-required-result-multiple-input-requests")]
[InlineData("input-required-result-multi-round")]
[InlineData("input-required-result-missing-input-response")]
[InlineData("input-required-result-non-tool-request")]
[InlineData("input-required-result-result-type")]
[InlineData("input-required-result-unsupported-methods")]
[InlineData("input-required-result-tampered-state")]
[InlineData("input-required-result-capability-check")]
[InlineData("input-required-result-ignore-extra-params")]
[InlineData("input-required-result-validate-input")]
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.");
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).");
var result = await RunConformanceTestsAsync(
$"server --url {fixture.ServerUrl} --scenario {scenario}");

Some files were not shown because too many files have changed in this diff Show more