Skip to content

Commit d1bd8ad

Browse files
halter73Copilot
andcommitted
Make IsMrtrSupported reflect low-level API availability
IsMrtrSupported now returns true whenever the low-level MRTR API (IncompleteResultException) can be used, not just when the client natively negotiated MRTR. The only case where it returns false is stateless mode with a non-MRTR client, where nobody can drive the retry loop. - Add IsLowLevelMrtrAvailable() to McpServerImpl - Update DestinationBoundMcpServer.IsMrtrSupported to use it - Error explicitly for stateless + non-MRTR in the IncompleteResult handler instead of silently serializing an unusable response - Add Stateless_IncompleteResultException_WithoutMrtrClient_ReturnsError test verifying the error path - Fix existing tests: stateless tests now properly negotiate MRTR, protocol test verifies IsMrtrSupported=true via backcompat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 34f974b commit d1bd8ad

File tree

5 files changed

+107
-18
lines changed

5 files changed

+107
-18
lines changed

src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
2222
/// </summary>
2323
internal MrtrContext? ActiveMrtrContext { get; set; }
2424

25-
public override bool IsMrtrSupported => server.ClientSupportsMrtr();
25+
public override bool IsMrtrSupported => server.IsLowLevelMrtrAvailable();
2626

2727
public override ValueTask DisposeAsync() => server.DisposeAsync();
2828

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,17 @@ internal bool ClientSupportsMrtr() =>
11881188
_negotiatedProtocolVersion is not null &&
11891189
_negotiatedProtocolVersion == ServerOptions.ExperimentalProtocolVersion;
11901190

1191+
/// <summary>
1192+
/// Checks whether the low-level MRTR API (<see cref="IncompleteResultException"/>) is available
1193+
/// for the current request. Returns <see langword="true"/> in all cases except stateless mode
1194+
/// with a client that hasn't negotiated MRTR — that's the one configuration where nobody can
1195+
/// drive the retry loop (the server can't send JSON-RPC requests to the client, and the client
1196+
/// doesn't know about <c>IncompleteResult</c>).
1197+
/// </summary>
1198+
internal bool IsLowLevelMrtrAvailable() =>
1199+
ClientSupportsMrtr() ||
1200+
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
1201+
11911202
/// <summary>
11921203
/// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync,
11931204
/// an IncompleteResult is returned early and the handler is suspended until the retry arrives.
@@ -1341,14 +1352,23 @@ private void WrapHandlerWithMrtr(string method)
13411352
}
13421353
catch (IncompleteResultException ex)
13431354
{
1344-
// If the client supports MRTR or the server is stateless, serialize and return directly.
1345-
// In stateless mode, the tool handler has explicitly chosen to return an IncompleteResult
1346-
// via the low-level API, so we trust that decision regardless of negotiated version.
1347-
if (ClientSupportsMrtr() || _sessionTransport is StreamableHttpServerTransport { Stateless: true })
1355+
// If the client natively supports MRTR, serialize and return directly —
1356+
// the client will drive the retry loop.
1357+
if (ClientSupportsMrtr())
13481358
{
13491359
return SerializeIncompleteResult(ex.IncompleteResult);
13501360
}
13511361

1362+
// In stateless mode without MRTR, the server can't resolve input requests via
1363+
// JSON-RPC (no persistent session for server-to-client requests), and the client
1364+
// won't recognize the IncompleteResult. This is the one unsupported configuration.
1365+
if (_sessionTransport is StreamableHttpServerTransport { Stateless: true })
1366+
{
1367+
throw new McpException(
1368+
"A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " +
1369+
"MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex);
1370+
}
1371+
13521372
// Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler.
13531373
if (ex.IncompleteResult.InputRequests is not { Count: > 0 } inputRequests)
13541374
{

tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ private async Task StartAsync()
108108
Name = "simple-tool",
109109
Description = "A tool that does not use MRTR"
110110
}),
111+
McpServerTool.Create(
112+
static string (McpServer server) => server.IsMrtrSupported.ToString(),
113+
new McpServerToolCreateOptions
114+
{
115+
Name = "check-mrtr-tool",
116+
Description = "Returns IsMrtrSupported"
117+
}),
111118
McpServerTool.Create(
112119
static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams),
113120
new McpServerToolCreateOptions
@@ -815,19 +822,20 @@ public async Task LowLevel_IncompleteResult_HasCorrectJsonStructure()
815822
}
816823

817824
[Fact]
818-
public async Task LowLevel_ToolFallsBackGracefully_WithoutMrtr()
825+
public async Task LowLevel_IsMrtrSupported_ReturnsTrue_WithoutMrtrNegotiation()
819826
{
820827
await StartAsync();
821828
await InitializeWithoutMrtrAsync();
822829

823-
// Call the lowlevel-tool that checks IsMrtrSupported and returns a fallback message
824-
var response = await PostJsonRpcAsync(CallTool("lowlevel-tool"));
830+
// On a non-stateless HTTP server, IsMrtrSupported returns true even without
831+
// MRTR negotiation because the backcompat layer can resolve IncompleteResultException
832+
// via standard JSON-RPC server-to-client requests.
833+
var response = await PostJsonRpcAsync(CallTool("check-mrtr-tool"));
825834
var rpcResponse = await AssertSingleSseResponseAsync(response);
826835

827836
var callToolResult = AssertType<CallToolResult>(rpcResponse.Result);
828837
var content = Assert.Single(callToolResult.Content);
829-
var text = Assert.IsType<TextContentBlock>(content).Text;
830-
Assert.Equal("lowlevel-unsupported:MRTR is not available", text);
838+
Assert.Equal("True", Assert.IsType<TextContentBlock>(content).Text);
831839
}
832840

833841
[Fact]

tests/ModelContextProtocol.AspNetCore.Tests/StatelessMrtrTests.cs

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ public class StatelessMrtrTests(ITestOutputHelper outputHelper) : KestrelInMemor
2525
TransportMode = HttpTransportMode.StreamableHttp,
2626
};
2727

28-
private Task StartAsync() => StartAsync(configureOptions: null);
28+
private Task StartAsync() => StartAsync(
29+
options => options.ExperimentalProtocolVersion = "2026-06-XX");
2930

3031
private async Task StartAsync(Action<McpServerOptions>? configureOptions, params McpServerTool[] additionalTools)
3132
{
@@ -249,7 +250,7 @@ private Task<McpClient> ConnectAsync(McpClientOptions? clientOptions = null)
249250

250251
private McpClientOptions CreateClientOptionsWithAllHandlers()
251252
{
252-
var options = new McpClientOptions();
253+
var options = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
253254
options.Handlers.ElicitationHandler = (request, ct) =>
254255
{
255256
return new ValueTask<ElicitResult>(new ElicitResult
@@ -353,7 +354,7 @@ public async Task Stateless_AllThreeConcurrent_ClientResolvesAllInputRequests()
353354
var samplingHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
354355
var rootsHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
355356

356-
var options = new McpClientOptions();
357+
var options = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
357358
options.Handlers.ElicitationHandler = async (request, ct) =>
358359
{
359360
elicitHandlerCalled.TrySetResult();
@@ -409,7 +410,7 @@ public async Task Stateless_MultiRoundTrip_CompletesAcrossMultipleRetries()
409410
int samplingCalls = 0;
410411
int elicitCalls = 0;
411412

412-
var options = new McpClientOptions();
413+
var options = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
413414
options.Handlers.SamplingHandler = (request, progress, ct) =>
414415
{
415416
Interlocked.Increment(ref samplingCalls);
@@ -643,4 +644,53 @@ await StartAsync(
643644
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
644645
Assert.Equal("resumed:deferred-work", text);
645646
}
647+
648+
[Fact]
649+
public async Task Stateless_IncompleteResultException_WithoutMrtrClient_ReturnsError()
650+
{
651+
// When a tool throws IncompleteResultException in stateless mode and the client doesn't
652+
// support MRTR, nobody can drive the retry loop: the server can't send JSON-RPC requests
653+
// to the client (stateless), and the client doesn't recognize IncompleteResult (no MRTR).
654+
// IsMrtrSupported correctly returns false for this configuration.
655+
var nativeToolWithoutGuard = McpServerTool.Create(
656+
static string (McpServer server, RequestContext<CallToolRequestParams> context) =>
657+
{
658+
var inputResponses = context.Params!.InputResponses;
659+
if (inputResponses is not null)
660+
{
661+
return $"resolved:{inputResponses["user_input"].ElicitationResult?.Action}";
662+
}
663+
664+
// IsMrtrSupported is false in stateless + non-MRTR, but tool ignores the check
665+
throw new IncompleteResultException(
666+
inputRequests: new Dictionary<string, InputRequest>
667+
{
668+
["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams
669+
{
670+
Message = "Please confirm",
671+
RequestedSchema = new()
672+
})
673+
},
674+
requestState: "awaiting-confirmation");
675+
},
676+
new McpServerToolCreateOptions
677+
{
678+
Name = "native-tool-no-guard",
679+
Description = "MRTR-native tool that doesn't check IsMrtrSupported"
680+
});
681+
682+
await StartAsync(
683+
options => options.ExperimentalProtocolVersion = "2026-06-XX",
684+
nativeToolWithoutGuard);
685+
686+
// Client does NOT opt in to MRTR
687+
await using var client = await ConnectAsync();
688+
689+
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
690+
client.CallToolAsync("native-tool-no-guard",
691+
cancellationToken: TestContext.Current.CancellationToken).AsTask());
692+
693+
Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase);
694+
Assert.Contains("MRTR", ex.Message);
695+
}
646696
}

tests/ModelContextProtocol.Tests/Client/McpClientMrtrLowLevelTests.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,20 +255,31 @@ public async Task IsMrtrSupported_ReturnsTrue_WhenBothExperimental()
255255
}
256256

257257
[Fact]
258-
public async Task IsMrtrSupported_ReturnsFalse_WhenClientNotExperimental()
258+
public async Task IsMrtrSupported_ReturnsTrue_WhenClientNotExperimental_BackcompatAvailable()
259259
{
260260
StartServer();
261-
// Client does NOT set ExperimentalProtocolVersion
261+
// Client does NOT set ExperimentalProtocolVersion, but backcompat resolves the tool
262262
var clientOptions = new McpClientOptions();
263+
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
264+
{
265+
return new ValueTask<ElicitResult>(new ElicitResult
266+
{
267+
Action = "accept",
268+
Content = new Dictionary<string, JsonElement>
269+
{
270+
["answer"] = JsonDocument.Parse("\"backcompat\"").RootElement.Clone()
271+
}
272+
});
273+
};
263274

264275
await using var client = await CreateMcpClientForServer(clientOptions);
265276

266-
// The lowlevel-elicit tool checks IsMrtrSupported and returns a fallback message
277+
// The lowlevel-elicit tool checks IsMrtrSupported — now true with backcompat
267278
var result = await client.CallToolAsync("lowlevel-elicit",
268279
cancellationToken: TestContext.Current.CancellationToken);
269280

270281
var content = Assert.Single(result.Content);
271-
Assert.Equal("fallback:MRTR not supported", Assert.IsType<TextContentBlock>(content).Text);
282+
Assert.Equal("completed:accept:backcompat", Assert.IsType<TextContentBlock>(content).Text);
272283
}
273284

274285
[Fact]

0 commit comments

Comments
 (0)