Skip to content

Commit 7e65a72

Browse files
halter73Copilot
andcommitted
Test MRTR-to-IncompleteResult transition and simplify AwaitMrtrHandlerAsync
Add test verifying a tool can use the high-level MRTR elicit API then throw IncompleteResultException to drop to the low-level API in a single call. Replace try/finally with 'using var' for the CancellationTokenRegistration. Fix task/mrtr comment and use modern indexing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b4dd962 commit 7e65a72

6 files changed

Lines changed: 144 additions & 51 deletions

File tree

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,56 +1369,49 @@ private void WrapHandlerWithMrtr(string method)
13691369
// On the initial call this is redundant (handlerCts is already linked to cancellationToken)
13701370
// but on retries this is critical: the retry's combinedCts cancellation must flow to the handler.
13711371
// This is how notifications/cancelled for the retry's request ID reaches the handler.
1372-
var registration = cancellationToken.Register(
1372+
using var registration = cancellationToken.Register(
13731373
static state => ((MrtrContinuation)state!).CancelHandler(), continuation);
13741374

1375-
try
1376-
{
1377-
var deferredTask = continuation.MrtrContext.DeferredTask;
1375+
var deferredTask = continuation.MrtrContext.DeferredTask;
13781376

1379-
// Race handler against MRTR exchange and optionally the deferred task creation signal.
1380-
Task completedTask;
1381-
if (deferredTask is not null)
1382-
{
1383-
completedTask = await Task.WhenAny(handlerTask, exchangeTask, deferredTask.SignalTask).ConfigureAwait(false);
1384-
}
1385-
else
1386-
{
1387-
completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false);
1388-
}
1377+
// Race handler against MRTR exchange and optionally the deferred task creation signal.
1378+
Task completedTask;
1379+
if (deferredTask is not null)
1380+
{
1381+
completedTask = await Task.WhenAny(handlerTask, exchangeTask, deferredTask.SignalTask).ConfigureAwait(false);
1382+
}
1383+
else
1384+
{
1385+
completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false);
1386+
}
13891387

1390-
if (completedTask == handlerTask)
1391-
{
1392-
// Handler completed - return its result, propagate its exception, or handle IncompleteResultException.
1393-
return await AwaitHandlerWithIncompleteResultHandlingAsync(handlerTask).ConfigureAwait(false);
1394-
}
1388+
if (completedTask == handlerTask)
1389+
{
1390+
// Handler completed - return its result, propagate its exception, or handle IncompleteResultException.
1391+
return await AwaitHandlerWithIncompleteResultHandlingAsync(handlerTask).ConfigureAwait(false);
1392+
}
13951393

1396-
if (deferredTask is not null && completedTask == deferredTask.SignalTask)
1397-
{
1398-
// Handler called CreateTaskAsync() — transition to task mode.
1399-
return await HandleDeferredTaskCreationAsync(handlerTask, continuation, deferredTask, cancellationToken).ConfigureAwait(false);
1400-
}
1394+
if (deferredTask is not null && completedTask == deferredTask.SignalTask)
1395+
{
1396+
// Handler called CreateTaskAsync() — transition to task mode.
1397+
return await HandleDeferredTaskCreationAsync(handlerTask, continuation, deferredTask, cancellationToken).ConfigureAwait(false);
1398+
}
14011399

1402-
// Exchange arrived - handler needs input from the client (high-level MRTR path).
1403-
var exchange = await exchangeTask.ConfigureAwait(false);
1400+
// Exchange arrived - handler needs input from the client (high-level MRTR path).
1401+
var exchange = await exchangeTask.ConfigureAwait(false);
14041402

1405-
var correlationId = Guid.NewGuid().ToString("N");
1406-
var incompleteResult = new IncompleteResult
1407-
{
1408-
InputRequests = new Dictionary<string, InputRequest> { [exchange.Key] = exchange.InputRequest },
1409-
RequestState = correlationId,
1410-
};
1403+
var correlationId = Guid.NewGuid().ToString("N");
1404+
var incompleteResult = new IncompleteResult
1405+
{
1406+
InputRequests = new Dictionary<string, InputRequest> { [exchange.Key] = exchange.InputRequest },
1407+
RequestState = correlationId,
1408+
};
14111409

1412-
// Store the continuation so the retry can resume the handler.
1413-
continuation.PendingExchange = exchange;
1414-
_mrtrContinuations[correlationId] = continuation;
1410+
// Store the continuation so the retry can resume the handler.
1411+
continuation.PendingExchange = exchange;
1412+
_mrtrContinuations[correlationId] = continuation;
14151413

1416-
return SerializeIncompleteResult(incompleteResult);
1417-
}
1418-
finally
1419-
{
1420-
registration.Dispose();
1421-
}
1414+
return SerializeIncompleteResult(incompleteResult);
14221415
}
14231416

14241417
/// <summary>
@@ -1694,8 +1687,8 @@ private async ValueTask<CallToolResult> ExecuteToolAsTaskAsync(
16941687
NotifyTaskStatusFunc = NotifyTaskStatusAsync
16951688
};
16961689

1697-
// Task-augmented execution is fire-and-forget; MRTR doesn't apply here because
1698-
// the original request was already answered with CreateTaskResult.
1690+
// MRTR doesn't apply here because the task hasn't opted into deferred creation,
1691+
// and the original request was already answered with CreateTaskResult.
16991692
if (request.Server is DestinationBoundMcpServer destinationServer)
17001693
{
17011694
destinationServer.ActiveMrtrContext = null;

tests/ModelContextProtocol.Tests/Client/McpClientMrtrCompatTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public async Task CallToolAsync_NeitherExperimental_UsesLegacyRequests()
6767
var clientOptions = new McpClientOptions();
6868
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
6969
{
70-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
70+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
7171
return new ValueTask<CreateMessageResult>(new CreateMessageResult
7272
{
7373
Content = [new TextContentBlock { Text = $"Legacy: {text}" }],
@@ -97,7 +97,7 @@ public async Task CallToolAsync_ClientExperimentalServerNot_FallsBackToLegacy()
9797
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
9898
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
9999
{
100-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
100+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
101101
return new ValueTask<CreateMessageResult>(new CreateMessageResult
102102
{
103103
Content = [new TextContentBlock { Text = $"Legacy: {text}" }],

tests/ModelContextProtocol.Tests/Client/McpClientMrtrLowLevelTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ public async Task MixedHighAndLowLevelTools_WorkInSameSession()
243243
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
244244
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
245245
{
246-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
246+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
247247
return new ValueTask<CreateMessageResult>(new CreateMessageResult
248248
{
249249
Content = [new TextContentBlock { Text = $"Sampled: {text}" }],

tests/ModelContextProtocol.Tests/Client/McpClientMrtrMessageFilterTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire()
115115
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
116116
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
117117
{
118-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
118+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
119119
return new ValueTask<CreateMessageResult>(new CreateMessageResult
120120
{
121121
Content = [new TextContentBlock { Text = $"Sampled: {text}" }],

tests/ModelContextProtocol.Tests/Client/McpClientMrtrTests.cs

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,48 @@ await server.ElicitAsync(new ElicitRequestParams
280280
{
281281
Name = "incomplete-result-tool",
282282
Description = "A tool that throws IncompleteResultException for low-level MRTR"
283+
}),
284+
McpServerTool.Create(
285+
async (McpServer server, RequestContext<CallToolRequestParams> context, CancellationToken ct) =>
286+
{
287+
var requestState = context.Params!.RequestState;
288+
var inputResponses = context.Params!.InputResponses;
289+
290+
// Final round: we have the requestState from the IncompleteResultException
291+
if (requestState == "got-name" && inputResponses is not null
292+
&& inputResponses.TryGetValue("age", out var ageResponse))
293+
{
294+
var age = ageResponse.ElicitationResult?.Content?.FirstOrDefault().Value;
295+
// Decode the name from requestState — in a real scenario, requestState
296+
// would carry the accumulated state, but here we just verify the flow works.
297+
return $"age={age}";
298+
}
299+
300+
// First round: use high-level ElicitAsync (handler suspends)
301+
var nameResult = await server.ElicitAsync(new ElicitRequestParams
302+
{
303+
Message = "What is your name?",
304+
RequestedSchema = new()
305+
}, ct);
306+
307+
var name = nameResult.Content?.FirstOrDefault().Value;
308+
309+
// Second round: switch to low-level IncompleteResultException (handler dies)
310+
throw new IncompleteResultException(
311+
inputRequests: new Dictionary<string, InputRequest>
312+
{
313+
["age"] = InputRequest.ForElicitation(new ElicitRequestParams
314+
{
315+
Message = $"How old are you, {name}?",
316+
RequestedSchema = new()
317+
})
318+
},
319+
requestState: "got-name");
320+
},
321+
new McpServerToolCreateOptions
322+
{
323+
Name = "elicit-then-incomplete-result-tool",
324+
Description = "A tool that uses high-level ElicitAsync then throws IncompleteResultException"
283325
})
284326
]);
285327
}
@@ -291,7 +333,7 @@ public async Task CallToolAsync_WithSamplingTool_ResolvesViaMrtr()
291333
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
292334
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
293335
{
294-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
336+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
295337
return new ValueTask<CreateMessageResult>(new CreateMessageResult
296338
{
297339
Content = [new TextContentBlock { Text = $"Sampled: {text}" }],
@@ -426,7 +468,7 @@ public async Task CallToolAsync_ServerExperimentalClientNot_UsesLegacyRequests()
426468
var clientOptions = new McpClientOptions();
427469
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
428470
{
429-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
471+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
430472
return new ValueTask<CreateMessageResult>(new CreateMessageResult
431473
{
432474
Content = [new TextContentBlock { Text = $"Legacy: {text}" }],
@@ -454,7 +496,7 @@ public async Task CallToolAsync_BothExperimental_UsesMrtr()
454496
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
455497
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
456498
{
457-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
499+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
458500
return new ValueTask<CreateMessageResult>(new CreateMessageResult
459501
{
460502
Content = [new TextContentBlock { Text = $"MRTR: {text}" }],
@@ -751,4 +793,62 @@ await Assert.ThrowsAsync<McpException>(() => client.CallToolAsync(
751793
m.LogLevel == LogLevel.Error &&
752794
m.Exception is IncompleteResultException);
753795
}
796+
797+
[Fact]
798+
public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEnd()
799+
{
800+
// Verify that a handler can mix high-level MRTR (ElicitAsync) with low-level MRTR
801+
// (IncompleteResultException) in a single logical flow. The handler:
802+
// 1. Calls ElicitAsync (high-level: handler suspends, IncompleteResult returned)
803+
// 2. Gets the response, then throws IncompleteResultException (low-level: handler dies)
804+
// 3. On the next retry, a fresh handler invocation processes requestState + inputResponses
805+
StartServer();
806+
int elicitationCallCount = 0;
807+
808+
var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" };
809+
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
810+
{
811+
elicitationCallCount++;
812+
if (request?.Message == "What is your name?")
813+
{
814+
return new ValueTask<ElicitResult>(new ElicitResult
815+
{
816+
Action = "accept",
817+
Content = new Dictionary<string, JsonElement>
818+
{
819+
["name"] = JsonDocument.Parse("\"Alice\"").RootElement.Clone()
820+
}
821+
});
822+
}
823+
824+
// Second elicitation from the IncompleteResultException path
825+
return new ValueTask<ElicitResult>(new ElicitResult
826+
{
827+
Action = "accept",
828+
Content = new Dictionary<string, JsonElement>
829+
{
830+
["age"] = JsonDocument.Parse("\"30\"").RootElement.Clone()
831+
}
832+
});
833+
};
834+
835+
await using var client = await CreateMcpClientForServer(clientOptions);
836+
837+
var result = await client.CallToolAsync(
838+
"elicit-then-incomplete-result-tool",
839+
cancellationToken: TestContext.Current.CancellationToken);
840+
841+
// Verify the final result came through correctly
842+
var content = Assert.Single(result.Content);
843+
Assert.Equal("age=30", Assert.IsType<TextContentBlock>(content).Text);
844+
Assert.NotEqual(true, result.IsError);
845+
846+
// Two elicitations: one from ElicitAsync, one from IncompleteResultException's inputRequests
847+
Assert.Equal(2, elicitationCallCount);
848+
849+
// Verify no error-level logs for IncompleteResultException
850+
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
851+
m.LogLevel == LogLevel.Error &&
852+
m.Exception is IncompleteResultException);
853+
}
754854
}

tests/ModelContextProtocol.Tests/Client/McpClientMrtrWithTasksTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ public async Task MrtrToolCall_ThenTaskBasedSampling_BothWorkCorrectly()
242242
{
243243
SamplingHandler = (request, progress, ct) =>
244244
{
245-
var text = request?.Messages[request.Messages.Count - 1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
245+
var text = request?.Messages[^1].Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
246246
return new ValueTask<CreateMessageResult>(new CreateMessageResult
247247
{
248248
Content = [new TextContentBlock { Text = $"Response: {text}" }],

0 commit comments

Comments
 (0)