From 08e678b76eb58b5aa1228440f0f914e091d8e4be Mon Sep 17 00:00:00 2001 From: KirschQAQ <114209152+KirschBluteX@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:42:45 -0700 Subject: [PATCH] fix: gate application result fields by protocol version --- .../McpSessionHandler.cs | 3 +- .../RequestHandlers.cs | 25 ++- .../Server/McpServerImpl.cs | 130 +++++++--------- .../Server/MrtrContinuation.cs | 6 +- .../ProtocolVersionResultEmissionTests.cs | 144 ++++++++++++++++++ .../ProtocolVersionResultDecorationTests.cs | 135 +++++++++++++--- 6 files changed, 340 insertions(+), 103 deletions(-) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/ProtocolVersionResultEmissionTests.cs diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 61a1872f2..0aaf9f838 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -532,7 +532,8 @@ private void HandleMessageWithId(JsonRpcMessage message, JsonRpcMessageWithId me throw new McpProtocolException($"Method '{request.Method}' is not available.", McpErrorCode.MethodNotFound); } - JsonNode? result = await handler(request, cancellationToken).ConfigureAwait(false); + RequestHandlerResult handlerResult = await handler(request, cancellationToken).ConfigureAwait(false); + JsonNode? result = _requestHandlers.PrepareForEmission(request, handlerResult); await SendMessageAsync(new JsonRpcResponse { diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index 2a21008f4..05feea049 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -5,8 +5,18 @@ namespace ModelContextProtocol; -internal sealed class RequestHandlers : Dictionary>> +internal readonly record struct RequestHandlerResult(JsonNode? Json, bool IsProtocolResult = false, bool IsCacheable = false); + +internal sealed class RequestHandlers : Dictionary>> { + private readonly Func? _prepareResponseForEmission; + + public RequestHandlers(Func? prepareResponseForEmission = null) => + _prepareResponseForEmission = prepareResponseForEmission; + + public JsonNode? PrepareForEmission(JsonRpcRequest request, RequestHandlerResult result) => + _prepareResponseForEmission?.Invoke(request, result) ?? result.Json; + /// /// Registers a handler for incoming requests of a specific method in the MCP protocol. /// @@ -41,8 +51,9 @@ public void Set( this[method] = async (request, cancellationToken) => { TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; - object? result = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); - return JsonSerializer.SerializeToNode(result, responseTypeInfo); + TResult result = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, responseTypeInfo); + return new(resultNode, result is Result, result is ICacheableResult); }; } @@ -70,10 +81,14 @@ public void SetWithAlternate( if (augmented.IsAlternate) { - return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!); + var result = augmented.Alternate!; + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, augmented.AlternateTypeInfo!); + return new(resultNode, IsProtocolResult: true, IsCacheable: result is ICacheableResult); } - return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); + var immediateResult = augmented.Result!; + JsonNode? immediateResultNode = JsonSerializer.SerializeToNode(immediateResult, responseTypeInfo); + return new(immediateResultNode, IsProtocolResult: true, IsCacheable: immediateResult is ICacheableResult); }; } #pragma warning restore MCPEXP002 diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2ce838713..69f773575 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -94,7 +94,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact UpdateEndpointNameWithClientInfo(); _notificationHandlers = new(); - _requestHandlers = []; + _requestHandlers = new(PrepareResultForEmission); // Configure all request handlers based on the supplied options. ServerCapabilities = new(); @@ -1190,10 +1190,8 @@ private void ConfigureCustomRequestHandlers(McpServerOptions options) #pragma warning restore MCPEXP002 } - private void SetRawHandler(string method, Func> handler) - { - _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask(); - } + private void SetRawHandler(string method, Func> handler) => + _requestHandlers[method] = async (request, ct) => new(await handler(request, ct).ConfigureAwait(false)); private void ConfigureResources(McpServerOptions options) { @@ -1851,57 +1849,57 @@ private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest js return server; } - private void SetHandler( - string method, - McpRequestHandler handler, - JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo) + private JsonNode? PrepareResultForEmission(JsonRpcRequest request, RequestHandlerResult result) { - // SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list, - // resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields. - // When a handler leaves them unset, fill in conservative defaults (immediately stale and not - // shareable) so the wire form always carries the fields while preserving today's "don't cache" - // behavior. Any value supplied by the handler or a filter is left untouched. - if (typeof(ICacheableResult).IsAssignableFrom(typeof(TResult))) - { - var innerHandler = handler; - handler = async (request, cancellationToken) => - { - var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (!result.IsProtocolResult || result.Json is not JsonObject resultObject) + { + return result.Json; + } - // ttlMs and cacheScope are 2026-07-28 result fields; only stamp them when the request - // was negotiated under that revision or later. Earlier revisions (e.g. 2025-11-25) reject - // these as unrecognized keys (issue #1721). - if (result is ICacheableResult cacheable && IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - cacheable.TimeToLive ??= TimeSpan.Zero; - cacheable.CacheScope ??= CacheScope.Private; - } + if (IsJuly2026OrLaterProtocolRequest(request)) + { + resultObject["resultType"] ??= "complete"; + if (result.IsCacheable) + { + resultObject["ttlMs"] ??= 0; + resultObject["cacheScope"] ??= "private"; + } - return result; - }; + return resultObject; } - if (typeof(Result).IsAssignableFrom(typeof(TResult))) + string? strippedFields = resultObject.Remove("resultType") ? "resultType" : null; + if (result.IsCacheable) { - var innerHandler = handler; - handler = async (request, cancellationToken) => + if (resultObject.Remove("ttlMs")) { - var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + strippedFields = strippedFields is null ? "ttlMs" : $"{strippedFields}, ttlMs"; + } - // resultType is a 2026-07-28 result field; only stamp it when the request was negotiated - // under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an - // unrecognized key (issue #1721). - if (result is Result protocolResult && protocolResult.ResultType is null && - IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - protocolResult.ResultType = "complete"; - } + if (resultObject.Remove("cacheScope")) + { + strippedFields = strippedFields is null ? "cacheScope" : $"{strippedFields}, cacheScope"; + } + } - return result; - }; + if (strippedFields is not null) + { + ProtocolIncompatibleResultFieldsOmitted( + _endpointName, + request.Method, + strippedFields, + request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); } + return resultObject; + } + + private void SetHandler( + string method, + McpRequestHandler handler, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo) + { _requestHandlers.Set(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), @@ -1916,23 +1914,6 @@ private void SetWithAlternateHandler( JsonTypeInfo responseTypeInfo) where TResult : Result { - var innerHandler = handler; - handler = async (request, cancellationToken) => - { - var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - - // resultType is a 2026-07-28 result field; only stamp it when the request was negotiated - // under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an - // unrecognized key (issue #1721). - if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult && - IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - immediateResult.ResultType = "complete"; - } - - return result; - }; - _requestHandlers.SetWithAlternate(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), @@ -2082,8 +2063,8 @@ internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestCon /// calls (elicitation, sampling, roots) and the handler is retried with the responses - allowing /// MRTR-native tools to work transparently with clients that don't support MRTR. /// - private async Task InvokeWithInputRequiredResultHandlingAsync( - Func> handler, + private async Task InvokeWithInputRequiredResultHandlingAsync( + Func> handler, JsonRpcRequest request, CancellationToken cancellationToken) { @@ -2102,7 +2083,7 @@ internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestCon // or by RETURNING an InputRequiredResult through the alternate result path (ResultOrAlternate). // Normalize both forms so a client that doesn't natively support MRTR gets the same server-side // resolution either way. - if (GetReturnedInputRequiredResult(result) is not { } returnedInputRequired) + if (GetReturnedInputRequiredResult(result.Json) is not { } returnedInputRequired) { return result; } @@ -2261,8 +2242,10 @@ private static async Task ResolveInputRequestAsync(McpServer dest } } - private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) => - JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); + private static RequestHandlerResult SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) => + new( + JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult), + IsProtocolResult: true); /// /// Detects an that a handler surfaced by RETURNING it through the alternate @@ -2384,7 +2367,7 @@ private void WrapHandlerWithMrtr(string method) // on the per-request DestinationBoundMcpServer. This is picked up synchronously // before any await, so the finally cleanup is safe. _mrtrContextsByRequestId[request.Id] = mrtrContext; - Task handlerTask; + Task handlerTask; try { handlerTask = originalHandler(request, handlerCts.Token); @@ -2415,8 +2398,8 @@ private void WrapHandlerWithMrtr(string method) /// If the handler throws , the result is returned directly /// without storing a continuation (explicit MRTR path). /// - private async Task AwaitMrtrHandlerAsync( - Task handlerTask, + private async Task AwaitMrtrHandlerAsync( + Task handlerTask, MrtrContinuation continuation, Task exchangeTask, CancellationToken cancellationToken) @@ -2460,7 +2443,7 @@ private void WrapHandlerWithMrtr(string method) /// double-reporting at Error) and decrements when the /// handler completes, following the same in-flight tracking pattern as . /// - private async Task ObserveHandlerCompletionAsync(Task handlerTask) + private async Task ObserveHandlerCompletionAsync(Task handlerTask) { try { @@ -2491,7 +2474,7 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) /// Awaits a handler task, catching to convert it to an /// JSON response without storing a continuation. /// - private static async Task AwaitHandlerWithInputRequiredResultHandlingAsync(Task handlerTask) + private static async Task AwaitHandlerWithInputRequiredResultHandlingAsync(Task handlerTask) { try { @@ -2529,4 +2512,7 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")] private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} application returned protocol-incompatible result field(s) '{Fields}' for '{Method}' on protocol version '{ProtocolVersion}'. The fields were omitted from the response.")] + private partial void ProtocolIncompatibleResultFieldsOmitted(string endpointName, string method, string fields, string? protocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs index f2cc65e3f..1d7cc16b7 100644 --- a/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs +++ b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Nodes; - namespace ModelContextProtocol.Server; /// @@ -11,7 +9,7 @@ internal sealed class MrtrContinuation { private readonly CancellationTokenSource _handlerCts; - public MrtrContinuation(CancellationTokenSource handlerCts, Task handlerTask, MrtrContext mrtrContext) + public MrtrContinuation(CancellationTokenSource handlerCts, Task handlerTask, MrtrContext mrtrContext) { _handlerCts = handlerCts; HandlerTask = handlerTask; @@ -27,7 +25,7 @@ public MrtrContinuation(CancellationTokenSource handlerCts, Task hand /// /// The handler task that is suspended awaiting input. /// - public Task HandlerTask { get; } + public Task HandlerTask { get; } /// /// The MRTR context for the handler's async flow. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ProtocolVersionResultEmissionTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ProtocolVersionResultEmissionTests.cs new file mode 100644 index 000000000..e25ff018a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ProtocolVersionResultEmissionTests.cs @@ -0,0 +1,144 @@ +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.Http.Headers; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public sealed class ProtocolVersionResultEmissionTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private readonly ListToolsResult _sharedResult = new() + { + ResultType = "shared", + TimeToLive = TimeSpan.FromMilliseconds(4_321), + CacheScope = CacheScope.Public, + }; + private WebApplication? _app; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task SharedResult_UsesPerRequestAndSessionProtocolsWithoutMutation() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new() { Name = "result-emission-http-test", Version = "1" }; + options.Handlers.ListToolsHandler = (_, _) => new(_sharedResult); + }) + .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + using var modernResponse = await SendModernListToolsAsync(); + Assert.Equal(HttpStatusCode.OK, modernResponse.StatusCode); + var modernResult = (await ReadJsonResponseAsync(modernResponse))["result"]!; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(""" + { + "resultType": "shared", + "tools": [], + "ttlMs": 4321, + "cacheScope": "public", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "result-emission-http-test", + "version": "1" + } + } + } + """), modernResult), $"Unexpected modern result: {modernResult}"); + + using var initializeResponse = await SendAsync(InitializeRequest); + Assert.Equal(HttpStatusCode.OK, initializeResponse.StatusCode); + var sessionId = Assert.Single(initializeResponse.Headers.GetValues("Mcp-Session-Id")); + using var initializedResponse = await SendAsync(InitializedNotification, sessionId); + Assert.Equal(HttpStatusCode.Accepted, initializedResponse.StatusCode); + using var legacyResponse = await SendAsync(ListToolsRequest, sessionId); + Assert.Equal(HttpStatusCode.OK, legacyResponse.StatusCode); + var legacyResult = (await ReadJsonResponseAsync(legacyResponse))["result"]!.AsObject(); + Assert.True(JsonNode.DeepEquals(new JsonObject { ["tools"] = new JsonArray() }, legacyResult)); + + Assert.Equal("shared", _sharedResult.ResultType); + Assert.Equal(TimeSpan.FromMilliseconds(4_321), _sharedResult.TimeToLive); + Assert.Equal(CacheScope.Public, _sharedResult.CacheScope); + } + + private Task SendModernListToolsAsync() + { + var request = CreateRequest(ListToolsModernRequest); + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", RequestMethods.ToolsList); + return HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + } + + private Task SendAsync(string json, string? sessionId = null) + { + var request = CreateRequest(json); + if (sessionId is not null) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.November2025ProtocolVersion); + } + return HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + } + + private static HttpRequestMessage CreateRequest(string json) + { + var request = new HttpRequestMessage(HttpMethod.Post, "") + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + return request; + } + + private static async Task ReadJsonResponseAsync(HttpResponseMessage response) + { + if (response.Content.Headers.ContentType?.MediaType != "text/event-stream") + { + return JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + } + + var responseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); + await foreach (var item in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken)) + { + if (item.EventType == "message") + { + return JsonNode.Parse(item.Data)!; + } + } + + throw new InvalidOperationException("SSE response did not contain a message event."); + } + + private const string InitializeRequest = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"result-emission-test","version":"1"}}} + """; + + private const string ListToolsRequest = """ + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + """; + + private const string InitializedNotification = """ + {"jsonrpc":"2.0","method":"notifications/initialized"} + """; + + private const string ListToolsModernRequest = """ + {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"result-emission-test","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; +} diff --git a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs index db0f658d7..290897658 100644 --- a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs @@ -2,26 +2,30 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; /// -/// Regression tests for issue #1721: the SDK-provided resultType, ttlMs, and -/// cacheScope result decorations are exclusive to the 2026-07-28 protocol revision and -/// must be absent from result objects on a session negotiated to an earlier revision -/// (e.g. 2025-11-25). Emitting them breaks clients that strictly validate the 2025-11-25 -/// schema. +/// Regression tests for issue #1754: application-provided resultType, ttlMs, and +/// cacheScope properties must be omitted from legacy responses and preserved on 2026-07-28 +/// responses without mutating the application result. /// /// /// These drive the in-process client/server pair and inspect the /// raw JSON-RPC result wire shape (via ) /// so the assertions are about the actual serialized fields rather than deserialized objects. -/// tools/list exercises the SetHandler decoration path (its result is both an -/// and a ), while tools/call exercises the -/// SetWithAlternateHandler path. +/// The cases cover normal, cacheable, and immediate alternate typed results. /// public class ProtocolVersionResultDecorationTests : ClientServerTestBase { + private static readonly Implementation s_serverInfo = new() { Name = "result-gating-test", Version = "1" }; + private ListToolsResult _cacheableResult = null!; + private ListResourcesResult _defaultedCacheableResult = null!; + private GetPromptResult _normalResult = null!; + private CallToolResult _immediateAlternateResult = null!; + public ProtocolVersionResultDecorationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { @@ -29,7 +33,39 @@ public ProtocolVersionResultDecorationTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.WithTools([McpServerTool.Create(() => "ok", new() { Name = "echo" })]); + _cacheableResult = new() + { + ResultType = "handler-cacheable", + TimeToLive = TimeSpan.FromSeconds(1), + CacheScope = CacheScope.Private, + }; + _defaultedCacheableResult = new(); + _normalResult = new() { ResultType = "handler-normal", Description = "normal" }; + _immediateAlternateResult = new() + { + ResultType = "handler-immediate", + Content = [new TextContentBlock { Text = "immediate" }], + }; + + services.Configure(options => + { + options.ServerInfo = s_serverInfo; + options.Handlers.ListToolsHandler = (_, _) => new(_cacheableResult); + options.Filters.Request.ListToolsFilters.Add(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.ResultType = "filter-cacheable"; + result.TimeToLive = TimeSpan.FromMilliseconds(1_234); + result.CacheScope = CacheScope.Public; + return result; + }); + options.Handlers.ListResourcesHandler = (_, _) => new(_defaultedCacheableResult); + options.Handlers.GetPromptHandler = (_, _) => new(_normalResult); +#pragma warning disable MCPEXP002 + options.Handlers.CallToolWithAlternateHandler = (_, _) => + new(new ResultOrAlternate(_immediateAlternateResult)); +#pragma warning restore MCPEXP002 + }); } [Fact] @@ -44,10 +80,10 @@ public async Task ToolsList_On2025_11_25Session_OmitsResultTypeAndCacheHints() new JsonRpcRequest { Method = RequestMethods.ToolsList }, TestContext.Current.CancellationToken); - var result = response.Result!.AsObject(); - Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result."); - Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result."); - Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); + AssertExact(new JsonObject { ["tools"] = new JsonArray() }, response.Result); + Assert.Contains(MockLoggerProvider.LogMessages, message => + message.LogLevel == Microsoft.Extensions.Logging.LogLevel.Warning && + message.Message.Contains("'resultType, ttlMs, cacheScope'", StringComparison.Ordinal)); } [Fact] @@ -67,8 +103,7 @@ public async Task ToolsCall_On2025_11_25Session_OmitsResultType() }, TestContext.Current.CancellationToken); - var result = response.Result!.AsObject(); - Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/call result."); + AssertExact(JsonNode.Parse("""{"content":[{"type":"text","text":"immediate"}]}"""), response.Result); } [Fact] @@ -83,10 +118,13 @@ public async Task ToolsList_On2026_07_28Session_IncludesResultTypeAndCacheHints( new JsonRpcRequest { Method = RequestMethods.ToolsList }, TestContext.Current.CancellationToken); - var result = response.Result!.AsObject(); - Assert.Equal("complete", result["resultType"]!.GetValue()); - Assert.True(result.ContainsKey("ttlMs"), "ttlMs must be present on a 2026-07-28 tools/list result."); - Assert.True(result.ContainsKey("cacheScope"), "cacheScope must be present on a 2026-07-28 tools/list result."); + AssertExact(ModernResult(new JsonObject + { + ["resultType"] = "filter-cacheable", + ["tools"] = new JsonArray(), + ["ttlMs"] = 1_234, + ["cacheScope"] = "public", + }), response.Result); } [Fact] @@ -106,7 +144,62 @@ public async Task ToolsCall_On2026_07_28Session_IncludesResultType() }, TestContext.Current.CancellationToken); - var result = response.Result!.AsObject(); - Assert.Equal("complete", result["resultType"]!.GetValue()); + AssertExact(ModernResult(JsonNode.Parse("""{"resultType":"handler-immediate","content":[{"type":"text","text":"immediate"}]}""")!.AsObject()), response.Result); + } + + [Fact] + public async Task ResourcesList_On2026_07_28Session_AddsDefaultsWithoutMutatingResult() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ResourcesList }, + TestContext.Current.CancellationToken); + + AssertExact(ModernResult(new JsonObject + { + ["resultType"] = "complete", + ["resources"] = new JsonArray(), + ["ttlMs"] = 0, + ["cacheScope"] = "private", + }), response.Result); + Assert.Null(_defaultedCacheableResult.ResultType); + Assert.Null(_defaultedCacheableResult.TimeToLive); + Assert.Null(_defaultedCacheableResult.CacheScope); + } + + [Theory] + [InlineData(McpProtocolVersions.November2025ProtocolVersion, false)] + [InlineData(McpProtocolVersions.July2026ProtocolVersion, true)] + public async Task PromptsGet_EmitsExactNormalResultShape(string protocolVersion, bool modern) + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = protocolVersion }); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.PromptsGet, + Params = JsonSerializer.SerializeToNode( + new GetPromptRequestParams { Name = "shared" }, McpJsonUtilities.DefaultOptions), + }, + TestContext.Current.CancellationToken); + + var expected = JsonNode.Parse(modern + ? """{"resultType":"handler-normal","description":"normal","messages":[]}""" + : """{"description":"normal","messages":[]}""")!.AsObject(); + AssertExact(modern ? ModernResult(expected) : expected, response.Result); + } + + private static JsonObject ModernResult(JsonObject result) + { + result["_meta"] = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(s_serverInfo, McpJsonUtilities.DefaultOptions), + }; + return result; } + + private static void AssertExact(JsonNode? expected, JsonNode? actual) => + Assert.True(JsonNode.DeepEquals(expected, actual), $"Expected: {expected}\nActual: {actual}"); }