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

This commit is contained in:
Stephen Halter 2026-06-23 21:24:22 -07:00 • committed by GitHub
commit 6c323e783b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

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