Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1635,8 +1638,12 @@ private void ConfigureLogging(McpServerOptions options)
return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken);
}

// Otherwise, consider it handled.
return new ValueTask<EmptyResult>(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<EmptyResult>(new EmptyResult());
},
McpJsonUtilities.JsonContext.Default.SetLevelRequestParams,
McpJsonUtilities.JsonContext.Default.EmptyResult);
Expand Down Expand Up @@ -1707,7 +1714,11 @@ private void SetHandler<TParams, TResult>(
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;
Expand All @@ -1723,7 +1734,12 @@ private void SetHandler<TParams, TResult>(
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";
}
Expand All @@ -1750,7 +1766,12 @@ private void SetWithAlternateHandler<TParams, TResult>(
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";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>());

// 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]
Expand Down
67 changes: 46 additions & 21 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<InitializeResult>(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);
Expand Down Expand Up @@ -386,7 +386,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<InitializeResult>(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"));
});
Expand All @@ -406,7 +406,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<InitializeResult>(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"));
});
Expand Down Expand Up @@ -438,7 +438,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<InitializeResult>(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
Expand Down Expand Up @@ -485,7 +485,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<CompleteResult>(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);
Expand Down Expand Up @@ -529,7 +529,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(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);

Expand Down Expand Up @@ -573,7 +573,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(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();
Expand Down Expand Up @@ -623,7 +623,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(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);

Expand Down Expand Up @@ -679,7 +679,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(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);
Expand Down Expand Up @@ -727,7 +727,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
Assert.NotNull(response);
var result = JsonSerializer.Deserialize<CompleteResult>(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();
Expand Down Expand Up @@ -766,7 +766,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<ListResourceTemplatesResult>(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);
});
Expand Down Expand Up @@ -796,7 +796,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<ListResourcesResult>(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);
});
Expand Down Expand Up @@ -832,7 +832,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<ReadResourceResult>(response, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result?.Contents);
Assert.Equal("complete", result.ResultType);
Assert.Null(result.ResultType);
Assert.NotEmpty(result.Contents);

TextResourceContents textResource = Assert.IsType<TextResourceContents>(result.Contents[0]);
Expand Down Expand Up @@ -870,7 +870,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<ListPromptsResult>(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);
});
Expand Down Expand Up @@ -900,7 +900,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<GetPromptResult>(response, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.Equal("complete", result.ResultType);
Assert.Null(result.ResultType);
Assert.Equal("test", result.Description);
});
}
Expand Down Expand Up @@ -935,7 +935,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<ListToolsResult>(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);
});
Expand Down Expand Up @@ -971,7 +971,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.Equal("complete", result.ResultType);
Assert.Null(result.ResultType);
Assert.NotEmpty(result.Content);
Assert.Equal("test", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
});
Expand Down Expand Up @@ -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<EmptyResult>(response, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.Null(result.ResultType);

// The wire response must be exactly {} with no additional properties.
var obj = Assert.IsType<JsonObject>(response);
Assert.Empty(obj);
});
}

[Fact]
public async Task Can_Handle_Call_Tool_Requests_With_McpException()
{
Expand All @@ -1033,7 +1058,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<CallToolResult>(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<TextContentBlock>(result.Content[0]);
Expand Down Expand Up @@ -1062,7 +1087,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<CallToolResult>(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<TextContentBlock>(result.Content[0]);
Expand Down Expand Up @@ -1098,7 +1123,7 @@ await Can_Handle_Requests(
{
var result = JsonSerializer.Deserialize<CallToolResult>(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<TextContentBlock>(result.Content[0]);
Expand Down Expand Up @@ -1359,7 +1384,7 @@ await transport.SendClientMessageAsync(new JsonRpcNotification
Assert.NotNull(response.Result);
var initResult = JsonSerializer.Deserialize<InitializeResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(initResult);
Assert.Equal("complete", initResult.ResultType);
Assert.Null(initResult.ResultType);
Assert.NotNull(initResult.ServerInfo);

await transport.DisposeAsync();
Expand Down
Loading