Skip to content

Commit 6787c0c

Browse files
tarekghTarek Mahmoud Sayed
andauthored
Gate 2026-07-28 result fields on negotiated protocol version (#1721) (#1753)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com> Copilot-Session: 80653343-5dcb-43cb-89b0-8ac8e572c4f7
1 parent 7838900 commit 6787c0c

4 files changed

Lines changed: 219 additions & 27 deletions

File tree

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,10 @@ private void ConfigureInitialize(McpServerOptions options)
609609
Instructions = options.ServerInstructions,
610610
ServerInfo = options.ServerInfo ?? DefaultImplementation,
611611
Capabilities = ServerCapabilities ?? new(),
612-
ResultType = "complete",
612+
613+
// resultType is a 2026-07-28 result field. The initialize handshake is only available on
614+
// 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw
615+
// above), so InitializeResult must never carry resultType (issue #1721).
613616
};
614617
},
615618
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
@@ -1635,8 +1638,12 @@ private void ConfigureLogging(McpServerOptions options)
16351638
return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken);
16361639
}
16371640

1638-
// Otherwise, consider it handled.
1639-
return new ValueTask<EmptyResult>(EmptyResult.Instance);
1641+
// Otherwise, consider it handled. logging/setLevel is a legacy (<= 2025-11-25) method
1642+
// (2026-07-28+ is rejected above), so the response must not carry the 2026-07-28 resultType
1643+
// field. Return a fresh EmptyResult rather than the shared EmptyResult.Instance, which is
1644+
// pre-stamped with resultType="complete" for the 2026-07-28-only subscriptions/listen path
1645+
// (issue #1721).
1646+
return new ValueTask<EmptyResult>(new EmptyResult());
16401647
},
16411648
McpJsonUtilities.JsonContext.Default.SetLevelRequestParams,
16421649
McpJsonUtilities.JsonContext.Default.EmptyResult);
@@ -1707,7 +1714,11 @@ private void SetHandler<TParams, TResult>(
17071714
handler = async (request, cancellationToken) =>
17081715
{
17091716
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
1710-
if (result is ICacheableResult cacheable)
1717+
1718+
// ttlMs and cacheScope are 2026-07-28 result fields; only stamp them when the request
1719+
// was negotiated under that revision or later. Earlier revisions (e.g. 2025-11-25) reject
1720+
// these as unrecognized keys (issue #1721).
1721+
if (result is ICacheableResult cacheable && IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
17111722
{
17121723
cacheable.TimeToLive ??= TimeSpan.Zero;
17131724
cacheable.CacheScope ??= CacheScope.Private;
@@ -1723,7 +1734,12 @@ private void SetHandler<TParams, TResult>(
17231734
handler = async (request, cancellationToken) =>
17241735
{
17251736
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
1726-
if (result is Result protocolResult && protocolResult.ResultType is null)
1737+
1738+
// resultType is a 2026-07-28 result field; only stamp it when the request was negotiated
1739+
// under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an
1740+
// unrecognized key (issue #1721).
1741+
if (result is Result protocolResult && protocolResult.ResultType is null &&
1742+
IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
17271743
{
17281744
protocolResult.ResultType = "complete";
17291745
}
@@ -1750,7 +1766,12 @@ private void SetWithAlternateHandler<TParams, TResult>(
17501766
handler = async (request, cancellationToken) =>
17511767
{
17521768
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
1753-
if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult)
1769+
1770+
// resultType is a 2026-07-28 result field; only stamp it when the request was negotiated
1771+
// under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an
1772+
// unrecognized key (issue #1721).
1773+
if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult &&
1774+
IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
17541775
{
17551776
immediateResult.ResultType = "complete";
17561777
}

tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,40 @@ public async Task InitializeHandshake_StillSucceeds_OnDefaultServer()
322322
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
323323
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
324324
Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue<string>());
325+
326+
// resultType is a 2026-07-28 result field, and the initialize handshake only ever negotiates
327+
// 2025-11-25 or earlier. It must not appear on the InitializeResult, or strict 2025-11-25 clients
328+
// reject the handshake (issue #1721).
329+
Assert.False(json["result"]!.AsObject().ContainsKey("resultType"),
330+
"InitializeResult must not carry resultType on a 2025-11-25 handshake.");
331+
}
332+
333+
[Fact]
334+
public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints()
335+
{
336+
await StartAsync();
337+
338+
// A 2025-11-25 client completes the initialize handshake and then sends subsequent requests with
339+
// the MCP-Protocol-Version header pinned to the negotiated revision. The server must not decorate
340+
// the result with the 2026-07-28-exclusive resultType/ttlMs/cacheScope fields (issue #1721).
341+
var initBody = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
342+
using (var initRequest = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(initBody) })
343+
using (var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken))
344+
{
345+
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);
346+
}
347+
348+
var body = @"{""jsonrpc"":""2.0"",""id"":2,""method"":""tools/list"",""params"":{}}";
349+
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
350+
request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion);
351+
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
352+
353+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
354+
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
355+
var result = json["result"]!.AsObject();
356+
Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result.");
357+
Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result.");
358+
Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result.");
325359
}
326360

327361
[Fact]

tests/ModelContextProtocol.Tests/Server/McpServerTests.cs

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ await Can_Handle_Requests(
283283
{
284284
var result = JsonSerializer.Deserialize<InitializeResult>(response, McpJsonUtilities.DefaultOptions);
285285
Assert.NotNull(result);
286-
Assert.Equal("complete", result.ResultType);
286+
Assert.Null(result.ResultType);
287287
Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name);
288288
Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version);
289289
Assert.Equal("2024-11-05", result.ProtocolVersion);
@@ -386,7 +386,7 @@ await Can_Handle_Requests(
386386
{
387387
var result = JsonSerializer.Deserialize<InitializeResult>(response, McpJsonUtilities.DefaultOptions);
388388
Assert.NotNull(result);
389-
Assert.Equal("complete", result.ResultType);
389+
Assert.Null(result.ResultType);
390390
Assert.NotNull(result.Capabilities.Extensions);
391391
Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext"));
392392
});
@@ -406,7 +406,7 @@ await Can_Handle_Requests(
406406
{
407407
var result = JsonSerializer.Deserialize<InitializeResult>(response, McpJsonUtilities.DefaultOptions);
408408
Assert.NotNull(result);
409-
Assert.Equal("complete", result.ResultType);
409+
Assert.Null(result.ResultType);
410410
Assert.NotNull(result.Capabilities.Experimental);
411411
Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature"));
412412
});
@@ -438,7 +438,7 @@ await Can_Handle_Requests(
438438
{
439439
var result = JsonSerializer.Deserialize<InitializeResult>(response, McpJsonUtilities.DefaultOptions);
440440
Assert.NotNull(result);
441-
Assert.Equal("complete", result.ResultType);
441+
Assert.Null(result.ResultType);
442442

443443
// Use reflection to verify every public property on ServerCapabilities is non-null.
444444
// This catches cases where new capability properties are added but not copied
@@ -485,7 +485,7 @@ await Can_Handle_Requests(
485485
{
486486
var result = JsonSerializer.Deserialize<CompleteResult>(response, McpJsonUtilities.DefaultOptions);
487487
Assert.NotNull(result?.Completion);
488-
Assert.Equal("complete", result.ResultType);
488+
Assert.Null(result.ResultType);
489489
Assert.Equal(["test"], result.Completion.Values);
490490
Assert.Equal(2, result.Completion.Total);
491491
Assert.True(result.Completion.HasMore);
@@ -529,7 +529,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
529529
Assert.NotNull(response);
530530
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
531531
Assert.NotNull(result?.Completion);
532-
Assert.Equal("complete", result.ResultType);
532+
Assert.Null(result.ResultType);
533533
Assert.Equal(["cat"], result.Completion.Values);
534534
Assert.Equal(1, result.Completion.Total);
535535

@@ -573,7 +573,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
573573
Assert.NotNull(response);
574574
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
575575
Assert.NotNull(result?.Completion);
576-
Assert.Equal("complete", result.ResultType);
576+
Assert.Null(result.ResultType);
577577
Assert.Empty(result.Completion.Values);
578578

579579
await transport.DisposeAsync();
@@ -623,7 +623,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
623623
Assert.NotNull(response);
624624
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
625625
Assert.NotNull(result?.Completion);
626-
Assert.Equal("complete", result.ResultType);
626+
Assert.Null(result.ResultType);
627627
Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values);
628628
Assert.Equal(2, result.Completion.Total);
629629

@@ -679,7 +679,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
679679
Assert.NotNull(response);
680680
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
681681
Assert.NotNull(result?.Completion);
682-
Assert.Equal("complete", result.ResultType);
682+
Assert.Null(result.ResultType);
683683
// Custom handler values + auto-populated values should be combined
684684
Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values);
685685
Assert.Equal(3, result.Completion.Total);
@@ -727,7 +727,7 @@ await transport.SendMessageAsync(new JsonRpcRequest
727727
Assert.NotNull(response);
728728
var result = JsonSerializer.Deserialize<CompleteResult>(response.Result, McpJsonUtilities.DefaultOptions);
729729
Assert.NotNull(result?.Completion);
730-
Assert.Equal("complete", result.ResultType);
730+
Assert.Null(result.ResultType);
731731
Assert.Equal(["a", "b"], result.Completion.Values);
732732

733733
await transport.DisposeAsync();
@@ -766,7 +766,7 @@ await Can_Handle_Requests(
766766
{
767767
var result = JsonSerializer.Deserialize<ListResourceTemplatesResult>(response, McpJsonUtilities.DefaultOptions);
768768
Assert.NotNull(result?.ResourceTemplates);
769-
Assert.Equal("complete", result.ResultType);
769+
Assert.Null(result.ResultType);
770770
Assert.NotEmpty(result.ResourceTemplates);
771771
Assert.Equal("test", result.ResourceTemplates[0].UriTemplate);
772772
});
@@ -796,7 +796,7 @@ await Can_Handle_Requests(
796796
{
797797
var result = JsonSerializer.Deserialize<ListResourcesResult>(response, McpJsonUtilities.DefaultOptions);
798798
Assert.NotNull(result?.Resources);
799-
Assert.Equal("complete", result.ResultType);
799+
Assert.Null(result.ResultType);
800800
Assert.NotEmpty(result.Resources);
801801
Assert.Equal("test", result.Resources[0].Uri);
802802
});
@@ -832,7 +832,7 @@ await Can_Handle_Requests(
832832
{
833833
var result = JsonSerializer.Deserialize<ReadResourceResult>(response, McpJsonUtilities.DefaultOptions);
834834
Assert.NotNull(result?.Contents);
835-
Assert.Equal("complete", result.ResultType);
835+
Assert.Null(result.ResultType);
836836
Assert.NotEmpty(result.Contents);
837837

838838
TextResourceContents textResource = Assert.IsType<TextResourceContents>(result.Contents[0]);
@@ -870,7 +870,7 @@ await Can_Handle_Requests(
870870
{
871871
var result = JsonSerializer.Deserialize<ListPromptsResult>(response, McpJsonUtilities.DefaultOptions);
872872
Assert.NotNull(result?.Prompts);
873-
Assert.Equal("complete", result.ResultType);
873+
Assert.Null(result.ResultType);
874874
Assert.NotEmpty(result.Prompts);
875875
Assert.Equal("test", result.Prompts[0].Name);
876876
});
@@ -900,7 +900,7 @@ await Can_Handle_Requests(
900900
{
901901
var result = JsonSerializer.Deserialize<GetPromptResult>(response, McpJsonUtilities.DefaultOptions);
902902
Assert.NotNull(result);
903-
Assert.Equal("complete", result.ResultType);
903+
Assert.Null(result.ResultType);
904904
Assert.Equal("test", result.Description);
905905
});
906906
}
@@ -935,7 +935,7 @@ await Can_Handle_Requests(
935935
{
936936
var result = JsonSerializer.Deserialize<ListToolsResult>(response, McpJsonUtilities.DefaultOptions);
937937
Assert.NotNull(result);
938-
Assert.Equal("complete", result.ResultType);
938+
Assert.Null(result.ResultType);
939939
Assert.NotEmpty(result.Tools);
940940
Assert.Equal("test", result.Tools[0].Name);
941941
});
@@ -971,7 +971,7 @@ await Can_Handle_Requests(
971971
{
972972
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
973973
Assert.NotNull(result);
974-
Assert.Equal("complete", result.ResultType);
974+
Assert.Null(result.ResultType);
975975
Assert.NotEmpty(result.Content);
976976
Assert.Equal("test", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
977977
});
@@ -1011,6 +1011,31 @@ await Can_Handle_Requests(
10111011
});
10121012
}
10131013

1014+
[Fact]
1015+
public async Task Can_Handle_SetLoggingLevel_Requests_WithoutHandler_OmitsResultType()
1016+
{
1017+
// With no custom SetLoggingLevelHandler configured, the server uses its default logging/setLevel
1018+
// handler. logging/setLevel is a legacy (<= 2025-11-25) method, so the default handler must also
1019+
// serialize its result as an empty object {} without the 2026-07-28 resultType field (issue #1721).
1020+
await Can_Handle_Requests(
1021+
new ServerCapabilities
1022+
{
1023+
Logging = new()
1024+
},
1025+
method: RequestMethods.LoggingSetLevel,
1026+
configureOptions: null,
1027+
assertResult: (_, response) =>
1028+
{
1029+
var result = JsonSerializer.Deserialize<EmptyResult>(response, McpJsonUtilities.DefaultOptions);
1030+
Assert.NotNull(result);
1031+
Assert.Null(result.ResultType);
1032+
1033+
// The wire response must be exactly {} with no additional properties.
1034+
var obj = Assert.IsType<JsonObject>(response);
1035+
Assert.Empty(obj);
1036+
});
1037+
}
1038+
10141039
[Fact]
10151040
public async Task Can_Handle_Call_Tool_Requests_With_McpException()
10161041
{
@@ -1033,7 +1058,7 @@ await Can_Handle_Requests(
10331058
{
10341059
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
10351060
Assert.NotNull(result);
1036-
Assert.Equal("complete", result.ResultType);
1061+
Assert.Null(result.ResultType);
10371062
Assert.True(result.IsError);
10381063
Assert.NotEmpty(result.Content);
10391064
var textContent = Assert.IsType<TextContentBlock>(result.Content[0]);
@@ -1062,7 +1087,7 @@ await Can_Handle_Requests(
10621087
{
10631088
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
10641089
Assert.NotNull(result);
1065-
Assert.Equal("complete", result.ResultType);
1090+
Assert.Null(result.ResultType);
10661091
Assert.True(result.IsError);
10671092
Assert.NotEmpty(result.Content);
10681093
var textContent = Assert.IsType<TextContentBlock>(result.Content[0]);
@@ -1098,7 +1123,7 @@ await Can_Handle_Requests(
10981123
{
10991124
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
11001125
Assert.NotNull(result);
1101-
Assert.Equal("complete", result.ResultType);
1126+
Assert.Null(result.ResultType);
11021127
Assert.True(result.IsError, "Input validation errors should be returned as tool execution errors (IsError=true), not protocol errors");
11031128
Assert.NotEmpty(result.Content);
11041129
var textContent = Assert.IsType<TextContentBlock>(result.Content[0]);
@@ -1359,7 +1384,7 @@ await transport.SendClientMessageAsync(new JsonRpcNotification
13591384
Assert.NotNull(response.Result);
13601385
var initResult = JsonSerializer.Deserialize<InitializeResult>(response.Result, McpJsonUtilities.DefaultOptions);
13611386
Assert.NotNull(initResult);
1362-
Assert.Equal("complete", initResult.ResultType);
1387+
Assert.Null(initResult.ResultType);
13631388
Assert.NotNull(initResult.ServerInfo);
13641389

13651390
await transport.DisposeAsync();

0 commit comments

Comments
 (0)