Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)
This commit is contained in:
parent
3368bb7349
commit
6c323e783b
65 changed files with 5704 additions and 712 deletions
|
|
@ -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 <path-to-conformance></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 <path-to-conformance></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 <path-to-conformance></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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <path-to-conformance></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 <path-to-conformance></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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue