csharp-sdk/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs
pardhu c6a97d94d6
Add HttpServerSessionMode for hybrid stateful/stateless HTTP serving (#1796)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
2026-08-12 23:36:36 -07:00

504 lines
23 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Net;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the 2026-07-28 protocol revision.
/// Under that revision (SEP-2575 + SEP-2567) Streamable HTTP no longer supports sessions, so these tests
/// drive the default <see cref="ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless"/> server with raw
/// JSON-RPC requests (no <c>initialize</c>, no <c>Mcp-Session-Id</c>) and verify the explicit
/// MRTR <see cref="InputRequiredResult"/> structure, retry with inputResponses, and error handling.
/// Stateful-session MRTR behaviors (implicit handler suspension, disposal cancellation) are covered
/// over stdio by <c>MrtrHandlerLifecycleTests</c>, and unknown-session rejection by
/// <c>StreamableHttpServerConformanceTests.PostRequest_IsNotFound_WithUnrecognizedSessionId</c>.
/// </summary>
public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
private async Task StartAsync()
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
{
Name = nameof(MrtrProtocolTests),
Version = "1",
};
}).WithTools([
McpServerTool.Create(
static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams),
new McpServerToolCreateOptions
{
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 raw requests: every request carries the 2026-07-28 protocol
// MCP-Protocol-Version header and (via PostJsonRpcAsync) the SEP-2243 Mcp-Method/Mcp-Name
// headers. No initialize handshake and no Mcp-Session-Id.
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion);
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
[Fact]
public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult()
{
await StartAsync();
var response = await PostJsonRpcAsync(CallTool("throwing-tool"));
// Should be a JSON-RPC error, not an InputRequiredResult
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);
var error = Assert.IsType<JsonRpcError>(message);
Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code);
Assert.Contains("Tool validation failed", error.Error.Message);
}
[Fact]
public async Task TamperedRequestState_ReturnsJsonRpcError()
{
await StartAsync();
// 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 requestState = r1Result["requestState"]!.GetValue<string>();
var inputKey = r1Result["inputRequests"]!.AsObject().First().Key;
// 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"] = "tampered-state-tool",
["arguments"] = new JsonObject(),
["requestState"] = requestState + "-TAMPERED",
["inputResponses"] = new JsonObject
{
[inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse)))
},
};
using var r2 = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString()));
Assert.Equal(HttpStatusCode.OK, r2.StatusCode);
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>
/// Regression test for a CI hang where the server-side MRTR backcompat resolver routed its
/// outgoing <c>roots/list</c> request through the session-level transport, which silently
/// dropped the message when the client's GET stream had not been established yet. The
/// outgoing request must instead go through the POST's response stream (the request's
/// <see cref="ModelContextProtocol.Protocol.JsonRpcMessageContext.RelatedTransport"/>) so it
/// reaches the client without depending on the GET stream at all.
///
/// This test deliberately never opens a GET stream - it only POSTs the initialize, the
/// initialized notification, the <c>tools/call</c>, and the <c>roots/list</c> response. If the
/// server falls back to <c>_transport.SendMessageAsync</c>, the test times out instead of
/// reading the expected <c>roots/list</c> SSE event off the <c>tools/call</c> POST response.
/// </summary>
[Fact]
public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream()
{
// Configure a server that does NOT pin 2026-07-28 so it can negotiate the current
// initialize-handshake protocol. The backcompat resolver path only runs when the
// negotiated version is not 2026-07-28.
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
{
Name = nameof(MrtrProtocolTests),
Version = "1",
};
}).WithTools([
McpServerTool.Create(
static string (RequestContext<CallToolRequestParams> context) =>
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("roots", out var response))
{
var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots;
return $"roots-ok:{roots?.FirstOrDefault()?.Name}";
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams())
},
requestState: "roots-state");
},
new McpServerToolCreateOptions
{
Name = "backcompat-roots-tool",
Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list",
}),
]).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful);
_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"));
// Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs.
var initJson = """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}}
""";
string sessionId;
using (var initResponse = await PostJsonRpcAsync(initJson))
{
var initRpcResponse = await AssertSingleSseResponseAsync(initResponse);
Assert.NotNull(initRpcResponse.Result);
Assert.Equal("2025-11-25", initRpcResponse.Result["protocolVersion"]?.GetValue<string>());
sessionId = Assert.Single(initResponse.Headers.GetValues("mcp-session-id"));
}
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version");
HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-11-25");
// Send the initialized notification.
using (var initializedResponse = await PostJsonRpcAsync(
"""{"jsonrpc":"2.0","method":"notifications/initialized"}"""))
{
Assert.True(initializedResponse.IsSuccessStatusCode);
}
_lastRequestId = 1;
// POST the tools/call and start reading the response SSE stream. We deliberately do NOT
// open a GET stream - the server-to-client roots/list must be delivered on this POST's
// response. Use HttpCompletionOption.ResponseHeadersRead so the POST returns as soon as
// the response headers arrive instead of waiting for the SSE stream to close.
var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null)
{
Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)),
};
callRequest.Content.Headers.Add("Mcp-Method", "tools/call");
callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool");
using var callResponse = await HttpClient.SendAsync(
callRequest,
HttpCompletionOption.ResponseHeadersRead,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, callResponse.StatusCode);
Assert.Equal("text/event-stream", callResponse.Content.Headers.ContentType?.MediaType);
var sseEvents = ReadSseAsync(callResponse.Content)
.GetAsyncEnumerator(TestContext.Current.CancellationToken);
try
{
// First SSE event on this POST should be the server-initiated roots/list request.
Assert.True(await sseEvents.MoveNextAsync(),
"Server did not send a roots/list request on the tools/call POST response stream. " +
"If this hangs/times out, the MRTR backcompat resolver is routing the outgoing request " +
"through the session-level transport instead of the POST's RelatedTransport.");
var rootsRequestNode = JsonNode.Parse(sseEvents.Current) as JsonObject;
Assert.NotNull(rootsRequestNode);
Assert.Equal("roots/list", rootsRequestNode["method"]?.GetValue<string>());
var rootsRequestId = rootsRequestNode["id"];
Assert.NotNull(rootsRequestId);
// POST the roots/list response on a separate connection. The server's pending
// RequestRootsAsync await will complete and the backcompat resolver will retry the tool.
var rootsIdLiteral = rootsRequestId.ToJsonString();
var rootsResponseJson =
"{\"jsonrpc\":\"2.0\",\"id\":" + rootsIdLiteral +
",\"result\":{\"roots\":[{\"uri\":\"file:///workspace\",\"name\":\"Workspace\"}]}}";
using (var rootsResponseHttp = await PostJsonRpcAsync(rootsResponseJson))
{
Assert.True(rootsResponseHttp.IsSuccessStatusCode);
}
// Next SSE event on the original POST should be the final tools/call response.
Assert.True(await sseEvents.MoveNextAsync(), "Server did not return the final tools/call response.");
var finalResponse = JsonSerializer.Deserialize(sseEvents.Current, GetJsonTypeInfo<JsonRpcResponse>());
Assert.NotNull(finalResponse);
Assert.NotNull(finalResponse.Result);
var content = finalResponse.Result["content"]?.AsArray();
Assert.NotNull(content);
var firstContent = Assert.Single(content);
Assert.Equal("roots-ok:Workspace", firstContent?["text"]?.GetValue<string>());
}
finally
{
await sseEvents.DisposeAsync();
}
}
// --- Helpers ---
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static async IAsyncEnumerable<string> ReadSseAsync(HttpContent responseContent)
{
var responseStream = await responseContent.ReadAsStreamAsync(TestContext.Current.CancellationToken);
await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken))
{
Assert.Equal("message", sseItem.EventType);
yield return sseItem.Data;
}
}
private static async Task<JsonRpcResponse> AssertSingleSseResponseAsync(HttpResponseMessage response)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
var sseItem = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken));
var jsonRpcResponse = JsonSerializer.Deserialize(sseItem, GetJsonTypeInfo<JsonRpcResponse>());
Assert.NotNull(jsonRpcResponse);
return jsonRpcResponse;
}
private Task<HttpResponseMessage> PostJsonRpcAsync(string json)
{
var content = JsonContent(json);
// 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)
{
if (obj["method"]?.GetValue<string>() is { } method)
{
content.Headers.Add("Mcp-Method", method);
if (obj["params"] is JsonObject paramsObj)
{
string? mcpName = method switch
{
"tools/call" or "prompts/get" => paramsObj["name"]?.GetValue<string>(),
"resources/read" => paramsObj["uri"]?.GetValue<string>(),
_ => null,
};
if (mcpName is not null)
{
content.Headers.Add("Mcp-Name", mcpName);
}
}
}
}
return HttpClient.PostAsync("", content, TestContext.Current.CancellationToken);
}
private long _lastRequestId = 1;
private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true)
{
var id = Interlocked.Increment(ref _lastRequestId);
var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject();
if (includePerRequestMetadata)
{
AddJuly2026ProtocolMeta(paramsObj);
}
var request = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = id,
["method"] = method,
["params"] = paramsObj,
};
return request.ToJsonString();
}
private static void AddJuly2026ProtocolMeta(JsonObject paramsObj)
{
if (paramsObj["_meta"] is not JsonObject meta)
{
meta = [];
paramsObj["_meta"] = meta;
}
meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion;
meta[MetaKeys.ClientInfo] = new JsonObject
{
["name"] = "MrtrTestClient",
["version"] = "1.0",
};
if (meta[MetaKeys.ClientCapabilities] is not JsonObject)
{
meta[MetaKeys.ClientCapabilities] = new JsonObject();
}
}
private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) =>
Request("tools/call", $$"""
{"name":"{{toolName}}","arguments":{{arguments}}}
""", includePerRequestMetadata);
}