diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 961100bc6..0827ceb9f 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -609,7 +609,10 @@ private void ConfigureInitialize(McpServerOptions options) Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), - ResultType = "complete", + + // resultType is a 2026-07-28 result field. The initialize handshake is only available on + // 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw + // above), so InitializeResult must never carry resultType (issue #1721). }; }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, @@ -1635,8 +1638,12 @@ private void ConfigureLogging(McpServerOptions options) return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken); } - // Otherwise, consider it handled. - return new ValueTask(EmptyResult.Instance); + // Otherwise, consider it handled. logging/setLevel is a legacy (<= 2025-11-25) method + // (2026-07-28+ is rejected above), so the response must not carry the 2026-07-28 resultType + // field. Return a fresh EmptyResult rather than the shared EmptyResult.Instance, which is + // pre-stamped with resultType="complete" for the 2026-07-28-only subscriptions/listen path + // (issue #1721). + return new ValueTask(new EmptyResult()); }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); @@ -1707,7 +1714,11 @@ private void SetHandler( handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (result is ICacheableResult cacheable) + + // 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; @@ -1723,7 +1734,12 @@ private void SetHandler( handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (result is Result protocolResult && protocolResult.ResultType is null) + + // 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"; } @@ -1750,7 +1766,12 @@ private void SetWithAlternateHandler( handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult) + + // 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"; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index edf58e743..f5f848e06 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -322,6 +322,40 @@ public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); + + // resultType is a 2026-07-28 result field, and the initialize handshake only ever negotiates + // 2025-11-25 or earlier. It must not appear on the InitializeResult, or strict 2025-11-25 clients + // reject the handshake (issue #1721). + Assert.False(json["result"]!.AsObject().ContainsKey("resultType"), + "InitializeResult must not carry resultType on a 2025-11-25 handshake."); + } + + [Fact] + public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints() + { + await StartAsync(); + + // A 2025-11-25 client completes the initialize handshake and then sends subsequent requests with + // the MCP-Protocol-Version header pinned to the negotiated revision. The server must not decorate + // the result with the 2026-07-28-exclusive resultType/ttlMs/cacheScope fields (issue #1721). + var initBody = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + using (var initRequest = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(initBody) }) + using (var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); + } + + var body = @"{""jsonrpc"":""2.0"",""id"":2,""method"":""tools/list"",""params"":{}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + 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 result = json["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."); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index c0f85a94a..d1f8443e7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -283,7 +283,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); @@ -386,7 +386,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -406,7 +406,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -438,7 +438,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); // Use reflection to verify every public property on ServerCapabilities is non-null. // This catches cases where new capability properties are added but not copied @@ -485,7 +485,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -529,7 +529,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -573,7 +573,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -623,7 +623,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -679,7 +679,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); // Custom handler values + auto-populated values should be combined Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values); Assert.Equal(3, result.Completion.Total); @@ -727,7 +727,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -766,7 +766,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -796,7 +796,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -832,7 +832,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -870,7 +870,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -900,7 +900,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal("test", result.Description); }); } @@ -935,7 +935,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -971,7 +971,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Content); Assert.Equal("test", Assert.IsType(result.Content[0]).Text); }); @@ -1011,6 +1011,31 @@ await Can_Handle_Requests( }); } + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests_WithoutHandler_OmitsResultType() + { + // With no custom SetLoggingLevelHandler configured, the server uses its default logging/setLevel + // handler. logging/setLevel is a legacy (<= 2025-11-25) method, so the default handler must also + // serialize its result as an empty object {} without the 2026-07-28 resultType field (issue #1721). + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: null, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); + }); + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_With_McpException() { @@ -1033,7 +1058,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1062,7 +1087,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1098,7 +1123,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.True(result.IsError, "Input validation errors should be returned as tool execution errors (IsError=true), not protocol errors"); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1359,7 +1384,7 @@ await transport.SendClientMessageAsync(new JsonRpcNotification Assert.NotNull(response.Result); var initResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(initResult); - Assert.Equal("complete", initResult.ResultType); + Assert.Null(initResult.ResultType); Assert.NotNull(initResult.ServerInfo); await transport.DisposeAsync(); diff --git a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs new file mode 100644 index 000000000..db0f658d7 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +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. +/// +/// +/// 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. +/// +public class ProtocolVersionResultDecorationTests : ClientServerTestBase +{ + public ProtocolVersionResultDecorationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([McpServerTool.Create(() => "ok", new() { Name = "echo" })]); + } + + [Fact] + public async Task ToolsList_On2025_11_25Session_OmitsResultTypeAndCacheHints() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + 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."); + } + + [Fact] + public async Task ToolsCall_On2025_11_25Session_OmitsResultType() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Params = System.Text.Json.JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = "echo" }, McpJsonUtilities.DefaultOptions), + }, + 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."); + } + + [Fact] + public async Task ToolsList_On2026_07_28Session_IncludesResultTypeAndCacheHints() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + 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."); + } + + [Fact] + public async Task ToolsCall_On2026_07_28Session_IncludesResultType() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Params = System.Text.Json.JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = "echo" }, McpJsonUtilities.DefaultOptions), + }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.Equal("complete", result["resultType"]!.GetValue()); + } +}