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