From 61f1c12690737d03a59847f4a9fcb2e223d7539f Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Tue, 30 Jun 2026 17:00:25 -0700 Subject: [PATCH 1/3] Fix resultType emission for complete server responses Set resultType at server response construction paths instead of relying on a model default. This ensures complete responses consistently carry resultType=complete while preserving task/input-required variants. Also expand server/client tests to assert deserialized ResultType across methods covered by #1676, including server/discover. Fixes #1676 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Protocol/EmptyResult.cs | 2 +- .../Protocol/Result.cs | 2 +- .../Server/McpServerImpl.cs | 40 ++++++++++++++++++- .../Client/July2026ProtocolConnectionTests.cs | 2 + .../Server/McpServerTests.cs | 21 ++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs b/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs index cf26cc3d5..6bf9d633d 100644 --- a/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs @@ -9,5 +9,5 @@ namespace ModelContextProtocol.Protocol; public sealed class EmptyResult : Result { [JsonIgnore] - internal static EmptyResult Instance { get; } = new(); + internal static EmptyResult Instance { get; } = new() { ResultType = "complete" }; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 15eb6fa46..f88638f6f 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -27,7 +27,7 @@ private protected Result() /// /// /// - /// When absent or set to "complete", the result is a normal completed response. + /// When set to "complete", the result is a normal completed response. /// When set to "input_required", the result is an indicating /// that additional input is needed before the request can be completed. /// When set to "task", the result is a indicating that the server diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..f79bf8ef7 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -384,6 +384,7 @@ private void ConfigureInitialize(McpServerOptions options) Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), + ResultType = "complete", }; }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, @@ -414,6 +415,7 @@ private void ConfigureDiscover(McpServerOptions options) // their "do not cache" behavior while satisfying the wire requirement. TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, + ResultType = "complete", }); }, McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, @@ -458,7 +460,7 @@ private void ConfigureSubscriptions(McpServerOptions options) await SendSubscriptionAckAsync(statelessSubscription, cancellationToken).ConfigureAwait(false); - return new EmptyResult(); + return EmptyResult.Instance; } // Filter the requested notifications against what the server actually supports. @@ -498,7 +500,7 @@ private void ConfigureSubscriptions(McpServerOptions options) _activeSubscriptions.TryRemove(jsonRpcRequest.Id, out _); } - return new EmptyResult(); + return EmptyResult.Instance; }, McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); @@ -1664,6 +1666,21 @@ private void SetHandler( }; } + if (typeof(Result).IsAssignableFrom(typeof(TResult))) + { + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (result is Result protocolResult && protocolResult.ResultType is null) + { + protocolResult.ResultType = "complete"; + } + + return result; + }; + } + _requestHandlers.Set(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), @@ -1678,6 +1695,25 @@ private void SetTaskAugmentedHandler( JsonTypeInfo taskResultTypeInfo) where TResult : Result { + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (result.IsTask) + { + if (result.TaskCreated is { ResultType: null } taskCreated) + { + taskCreated.ResultType = "task"; + } + } + else if (result.Result is { ResultType: null } immediateResult) + { + immediateResult.ResultType = "complete"; + } + + return result; + }; + _requestHandlers.SetTaskAugmented(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index a06548a4f..ba5ea3b83 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -67,6 +67,7 @@ public async Task LegacyClient_CanCallServerDiscover() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); + Assert.Equal("complete", discoverResult.ResultType); Assert.NotEmpty(discoverResult.SupportedVersions); Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name); @@ -85,6 +86,7 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); + Assert.Equal("complete", discoverResult.ResultType); Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 87f363f03..a1ba48653 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -283,6 +283,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024", result.ProtocolVersion); @@ -304,6 +305,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -323,6 +325,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -354,6 +357,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", 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 @@ -400,6 +404,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -443,6 +448,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.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -486,6 +492,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.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -535,6 +542,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.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -590,6 +598,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); // Custom handler values + auto-populated values should be combined Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values); Assert.Equal(3, result.Completion.Total); @@ -637,6 +646,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.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -675,6 +685,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -704,6 +715,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -739,6 +751,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -776,6 +789,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -805,6 +819,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.Equal("test", result.Description); }); } @@ -839,6 +854,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -874,6 +890,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Content); Assert.Equal("test", Assert.IsType(result.Content[0]).Text); }); @@ -907,6 +924,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -935,6 +953,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -970,6 +989,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", 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]); @@ -1230,6 +1250,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.NotNull(initResult.ServerInfo); await transport.DisposeAsync(); From 462cab9626b37f01afe6aed4677819d95458e4dd Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Sun, 12 Jul 2026 16:41:26 -0700 Subject: [PATCH 2/3] Emit resultType on custom logging/setLevel responses and add coverage Fill resultType=complete on the logging/setLevel path when a custom handler returns a result without it, matching the other server response paths. Add raw-response coverage for logging/setLevel and for the task-augmented handler filling resultType=task when a handler omits it. --- .../Server/McpServerImpl.cs | 11 ++++++-- .../Server/McpServerTaskTests.cs | 28 +++++++++++++++++++ .../Server/McpServerTests.cs | 23 +++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index ef3486f3a..4911ae75d 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1810,7 +1810,7 @@ private void ConfigureLogging(McpServerOptions options) _requestHandlers.Set( RequestMethods.LoggingSetLevel, - (request, jsonRpcRequest, cancellationToken) => + async (request, jsonRpcRequest, cancellationToken) => { if (IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) { @@ -1833,11 +1833,16 @@ private void ConfigureLogging(McpServerOptions options) // If a handler was provided, now delegate to it. if (setLoggingLevelHandler is not null) { - return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken); + var handlerResult = await InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + // Custom handlers may return a result without a resultType. Fill it in so this path + // always carries "complete" on the wire, matching the other server response paths. + handlerResult.ResultType ??= "complete"; + return handlerResult; } // Otherwise, consider it handled. - return new ValueTask(EmptyResult.Instance); + return EmptyResult.Instance; }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9708722f3..e019b7b1d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -58,6 +58,21 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; } + if (toolName == "async-tool-default-resulttype") + { + // Intentionally leaves ResultType unset so the server boundary is responsible for + // filling in "task" on the wire. + var taskId = store.CreateTask(); + return new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + }; + } + if (toolName == "input-required-tool") { var taskId = store.CreateTask(McpTaskStatus.InputRequired); @@ -292,6 +307,19 @@ public async Task CreateTaskResult_HasResultTypeTask() Assert.Equal("task", augmented.TaskCreated!.ResultType); } + [Fact] + public async Task CreateTaskResult_WithoutExplicitResultType_ServerFillsTask() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool-default-resulttype" }, + TestContext.Current.CancellationToken); + + Assert.True(augmented.IsTask); + Assert.Equal("task", augmented.TaskCreated!.ResultType); + } + [Fact] public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index e916d49fd..70c620667 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -983,6 +983,29 @@ public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_A await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); } + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests() + { + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: options => + { + // Custom handler intentionally returns a result without a resultType so the server + // boundary is responsible for filling in "complete" on the wire. + options.Handlers.SetLoggingLevelHandler = async (request, ct) => new EmptyResult(); + }, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); + }); + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_With_McpException() { From 565e7364c63bdffd367fe22743fcb9daa496542e Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Sun, 12 Jul 2026 17:37:49 -0700 Subject: [PATCH 3/3] Keep logging/setLevel response empty to satisfy conformance Adding resultType to the logging/setLevel response breaks the MCP conformance suite, which requires that response to be exactly {}. The method is a 2025-06-18 endpoint removed in 2026-07-28 and is already gated off (MethodNotFound) on July2026 and later, so on every version where it is served the schema has no resultType. Return the handler result as-is and assert the response is exactly {} with no resultType. --- src/ModelContextProtocol.Core/Server/McpServerImpl.cs | 11 +++-------- .../Server/McpServerTests.cs | 11 ++++++++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 4911ae75d..ef3486f3a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1810,7 +1810,7 @@ private void ConfigureLogging(McpServerOptions options) _requestHandlers.Set( RequestMethods.LoggingSetLevel, - async (request, jsonRpcRequest, cancellationToken) => + (request, jsonRpcRequest, cancellationToken) => { if (IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) { @@ -1833,16 +1833,11 @@ private void ConfigureLogging(McpServerOptions options) // If a handler was provided, now delegate to it. if (setLoggingLevelHandler is not null) { - var handlerResult = await InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken).ConfigureAwait(false); - - // Custom handlers may return a result without a resultType. Fill it in so this path - // always carries "complete" on the wire, matching the other server response paths. - handlerResult.ResultType ??= "complete"; - return handlerResult; + return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken); } // Otherwise, consider it handled. - return EmptyResult.Instance; + return new ValueTask(EmptyResult.Instance); }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 70c620667..c0f85a94a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -994,15 +994,20 @@ await Can_Handle_Requests( method: RequestMethods.LoggingSetLevel, configureOptions: options => { - // Custom handler intentionally returns a result without a resultType so the server - // boundary is responsible for filling in "complete" on the wire. + // logging/setLevel is a legacy (2025-06-18) method whose result must serialize as an + // empty object {}. The custom handler returns a bare result and the server must not + // add a resultType, otherwise the MCP conformance suite rejects the response. options.Handlers.SetLoggingLevelHandler = async (request, ct) => new EmptyResult(); }, assertResult: (_, response) => { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); }); }