Skip to content

Commit b4dd962

Browse files
halter73Copilot
andcommitted
Add deferred task creation docs, revert MrtrContext to dictionary flow, fix logging
Document DeferTaskCreation and CreateTaskAsync in MRTR and Tasks conceptual docs with cross-references and matching test coverage. Revert MrtrContext flow from JsonRpcMessageContext property back to _mrtrContextsByRequestId ConcurrentDictionary with try/finally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d917c80 commit b4dd962

5 files changed

Lines changed: 205 additions & 21 deletions

File tree

docs/concepts/mrtr/mrtr.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,89 @@ When a server has MRTR enabled but the connected client does not:
331331
- The low-level API reports `IsMrtrSupported == false`, allowing the tool to provide a custom fallback message.
332332
- Throwing `IncompleteResultException` when MRTR is not supported results in a JSON-RPC error being returned to the client.
333333

334+
## Transitioning from MRTR to Tasks
335+
336+
<!-- mlc-disable-next-line -->
337+
> [!WARNING]
338+
> Deferred task creation depends on both the [MRTR](xref:mrtr) and [Tasks](xref:tasks) experimental features.
339+
340+
Some tools need user input before they can decide whether to start a long-running background task. For example, a VM provisioning tool might confirm costs with the user before committing to a task that takes minutes. **Deferred task creation** lets a tool perform ephemeral MRTR exchanges first, then transition to a background task only when ready.
341+
342+
### How it works
343+
344+
1. The tool sets `DeferTaskCreation = true` on its attribute or options.
345+
2. When the client sends task metadata with the `tools/call` request, the SDK runs the tool through the normal MRTR-wrapped path instead of creating a task immediately.
346+
3. The tool calls `ElicitAsync` or `SampleAsync` as usual — these use MRTR (incomplete result / retry cycles).
347+
4. When the tool is ready, it calls `await server.CreateTaskAsync(cancellationToken)` to transition to a background task.
348+
5. After `CreateTaskAsync`, the MRTR phase ends. Any subsequent `ElicitAsync` or `SampleAsync` calls use the task's own `input_required` / `tasks/input_response` mechanism instead.
349+
6. If the tool returns without calling `CreateTaskAsync`, a normal (non-task) result is sent to the client.
350+
351+
### Server example
352+
353+
```csharp
354+
McpServerTool.Create(
355+
async (string vmName, McpServer server, CancellationToken ct) =>
356+
{
357+
// Phase 1: Ephemeral MRTR — confirm with user before starting expensive work.
358+
var confirmation = await server.ElicitAsync(new ElicitRequestParams
359+
{
360+
Message = $"Provision VM '{vmName}'? This will incur costs.",
361+
RequestedSchema = new()
362+
}, ct);
363+
364+
if (confirmation.Action != "confirm")
365+
{
366+
return "Cancelled by user.";
367+
}
368+
369+
// Phase 2: Transition to a background task.
370+
await server.CreateTaskAsync(ct);
371+
372+
// Phase 3: Background work — runs as a task, client polls for status.
373+
await Task.Delay(TimeSpan.FromMinutes(5), ct);
374+
return $"VM '{vmName}' provisioned successfully.";
375+
},
376+
new McpServerToolCreateOptions
377+
{
378+
Name = "provision-vm",
379+
Description = "Provisions a VM with user confirmation",
380+
DeferTaskCreation = true,
381+
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional },
382+
})
383+
```
384+
385+
The attribute-based equivalent uses `DeferTaskCreation` on <xref:ModelContextProtocol.Server.McpServerToolAttribute>:
386+
387+
```csharp
388+
[McpServerTool(DeferTaskCreation = true, TaskSupport = ToolTaskSupport.Optional)]
389+
[Description("Provisions a VM with user confirmation")]
390+
public static async Task<string> ProvisionVm(
391+
string vmName, McpServer server, CancellationToken ct)
392+
{
393+
var confirmation = await server.ElicitAsync(new ElicitRequestParams
394+
{
395+
Message = $"Provision VM '{vmName}'? This will incur costs.",
396+
RequestedSchema = new()
397+
}, ct);
398+
399+
if (confirmation.Action != "confirm")
400+
return "Cancelled by user.";
401+
402+
await server.CreateTaskAsync(ct);
403+
404+
await Task.Delay(TimeSpan.FromMinutes(5), ct);
405+
return $"VM '{vmName}' provisioned successfully.";
406+
}
407+
```
408+
409+
### Key points
410+
411+
- **One-way transition**: Once `CreateTaskAsync` is called, the tool cannot go back to ephemeral MRTR. All subsequent input requests use the task workflow.
412+
- **Optional task creation**: A `DeferTaskCreation` tool can return a normal result without ever calling `CreateTaskAsync`. The tool decides at runtime whether to create a task.
413+
- **No task metadata, no deferral**: If the client calls the tool without task metadata, the tool runs normally with MRTR — `DeferTaskCreation` has no effect.
414+
415+
For more details on task configuration and lifecycle, see the [Tasks](xref:tasks) documentation.
416+
334417
## Choosing between high-level and low-level APIs
335418

336419
| Consideration | High-level API | Low-level API |

docs/concepts/tasks/tasks.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,58 @@ Task support levels:
137137
- `Optional` (default for async methods): Tool can be called with or without task augmentation
138138
- `Required`: Tool must be called with task augmentation
139139

140+
### Deferred Task Creation with MRTR
141+
142+
<!-- mlc-disable-next-line -->
143+
> [!WARNING]
144+
> Deferred task creation depends on both the [Tasks](xref:tasks) and [MRTR](xref:mrtr) experimental features.
145+
146+
By default, when a client sends task metadata with a `tools/call` request, the SDK creates a task immediately and runs the tool in the background. **Deferred task creation** delays the task creation, letting the tool perform ephemeral [MRTR](xref:mrtr) exchanges first — for example, to confirm an action with the user or gather required parameters — before committing to a background task.
147+
148+
To opt in, set `DeferTaskCreation = true` on the tool:
149+
150+
```csharp
151+
McpServerTool.Create(
152+
async (string vmName, McpServer server, CancellationToken ct) =>
153+
{
154+
// Ephemeral MRTR — uses incomplete result / retry cycle.
155+
var confirmation = await server.ElicitAsync(new ElicitRequestParams
156+
{
157+
Message = $"Provision VM '{vmName}'? This will incur costs.",
158+
RequestedSchema = new()
159+
}, ct);
160+
161+
if (confirmation.Action != "confirm")
162+
{
163+
return "Cancelled by user.";
164+
}
165+
166+
// Transition to a background task.
167+
await server.CreateTaskAsync(ct);
168+
169+
// Background work — runs as a task, client polls for status.
170+
await Task.Delay(TimeSpan.FromMinutes(5), ct);
171+
return $"VM '{vmName}' provisioned successfully.";
172+
},
173+
new McpServerToolCreateOptions
174+
{
175+
Name = "provision-vm",
176+
Description = "Provisions a VM with user confirmation",
177+
DeferTaskCreation = true,
178+
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional },
179+
})
180+
```
181+
182+
After <xref:ModelContextProtocol.Server.McpServer.CreateTaskAsync*> returns:
183+
184+
- The MRTR phase ends. The client receives a `CreateTaskResult` with the `taskId`.
185+
- Any subsequent `ElicitAsync` or `SampleAsync` calls in the handler use the task's `input_required` / `tasks/input_response` workflow instead of MRTR.
186+
- The handler's cancellation token is re-linked to the task's lifecycle (TTL expiration, explicit `tasks/cancel`).
187+
188+
If the tool returns without calling `CreateTaskAsync`, a normal (non-task) result is sent to the client — no task is created.
189+
190+
For more details on the MRTR mechanism and the transition flow, see [Transitioning from MRTR to Tasks](xref:mrtr#transitioning-from-mrtr-to-tasks).
191+
140192
### Explicit Task Creation with `IMcpTaskStore`
141193

142194
For more control over task lifecycle, tools can directly interact with <xref:ModelContextProtocol.IMcpTaskStore> and return an `McpTask`. This approach allows you to:

src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,4 @@ public sealed class JsonRpcMessageContext
8585
/// to flow the protocol version header so the server can determine client capabilities.
8686
/// </remarks>
8787
public string? ProtocolVersion { get; set; }
88-
89-
/// <summary>
90-
/// Gets or sets the MRTR context for this request, if any.
91-
/// </summary>
92-
/// <remarks>
93-
/// Set by <see cref="McpServer"/> when an MRTR-aware handler invocation is in progress,
94-
/// so that the per-request <see cref="DestinationBoundMcpServer"/> can intercept
95-
/// server-to-client requests (e.g. ElicitAsync, SampleAsync) and route them through the MRTR mechanism.
96-
/// </remarks>
97-
internal MrtrContext? MrtrContext { get; set; }
9888
}

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal sealed partial class McpServerImpl : McpServer
3030
private readonly SemaphoreSlim _disposeLock = new(1, 1);
3131
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
3232
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
33+
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
3334

3435
// Track MRTR handler tasks using the same inFlightCount + TCS pattern as
3536
// McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself.
@@ -822,11 +823,13 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
822823
}
823824
catch (Exception e)
824825
{
825-
// Skip logging for OperationCanceledException during server disposal —
826-
// MRTR handler cancellation during session teardown is expected, not an error.
826+
// Skip logging for OperationCanceledException when the cancellation token
827+
// is signaled — tool handler cancellation is an expected lifecycle event
828+
// (client request cancellation, session shutdown, MRTR teardown), not a
829+
// tool error.
827830
// Skip logging for IncompleteResultException — it's normal MRTR control flow,
828831
// not an error (the low-level API uses it to signal an IncompleteResult).
829-
if (!(e is OperationCanceledException && _disposed) && e is not IncompleteResultException)
832+
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not IncompleteResultException)
830833
{
831834
ToolCallError(request.Params?.Name ?? string.Empty, e);
832835
}
@@ -1075,14 +1078,14 @@ async ValueTask<TResult> InvokeScopedAsync(
10751078
}
10761079

10771080
/// <summary>
1078-
/// Creates a per-request <see cref="DestinationBoundMcpServer"/> and attaches any
1079-
/// MRTR context that was set on the request by <see cref="WrapHandlerWithMrtr"/>.
1081+
/// Creates a per-request <see cref="DestinationBoundMcpServer"/> and attaches any pending
1082+
/// MRTR context that was stored by <see cref="WrapHandlerWithMrtr"/>.
10801083
/// </summary>
10811084
private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest)
10821085
{
10831086
var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport);
10841087

1085-
if (jsonRpcRequest.Context?.MrtrContext is { } mrtrContext)
1088+
if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext))
10861089
{
10871090
server.ActiveMrtrContext = mrtrContext;
10881091
}
@@ -1288,10 +1291,19 @@ private void WrapHandlerWithMrtr(string method)
12881291
// calling Cancel/Dispose inside locks or Interlocked guards.
12891292
var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
12901293

1291-
// Flow the MrtrContext to the handler via the request's Context, where
1292-
// CreateDestinationBoundServer will pick it up to set on the per-request server.
1293-
(request.Context ??= new()).MrtrContext = mrtrContext;
1294-
var handlerTask = originalHandler(request, handlerCts.Token);
1294+
// Store the MrtrContext so CreateDestinationBoundServer can pick it up and set it
1295+
// on the per-request DestinationBoundMcpServer. This is picked up synchronously
1296+
// before any await, so the finally cleanup is safe.
1297+
_mrtrContextsByRequestId[request.Id] = mrtrContext;
1298+
Task<JsonNode?> handlerTask;
1299+
try
1300+
{
1301+
handlerTask = originalHandler(request, handlerCts.Token);
1302+
}
1303+
finally
1304+
{
1305+
_mrtrContextsByRequestId.TryRemove(request.Id, out _);
1306+
}
12951307

12961308
// Wrap handler state into a continuation for lifecycle management across retries.
12971309
var continuation = new MrtrContinuation(handlerCts, handlerTask, mrtrContext);

tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ModelContextProtocol.Protocol;
55
using ModelContextProtocol.Server;
66
using ModelContextProtocol.Tests.Utils;
7+
using System.ComponentModel;
78
using System.Text.Json;
89

910
namespace ModelContextProtocol.Tests.Client;
@@ -31,7 +32,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
3132
options.ExperimentalProtocolVersion = "2026-06-XX";
3233
});
3334

34-
mcpServerBuilder.WithTools([
35+
mcpServerBuilder.WithTools<DeferredTaskToolType>()
36+
.WithTools([
3537
// Tool that elicits before creating a task, then does work in background.
3638
McpServerTool.Create(
3739
async (string vmName, McpServer server, CancellationToken ct) =>
@@ -284,4 +286,49 @@ public async Task BackwardsCompat_ImmediateTaskCreation_WorksUnchanged()
284286
var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId);
285287
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
286288
}
289+
290+
[Fact]
291+
public async Task DeferredTaskCreation_AttributeBased_ElicitThenCreateTask()
292+
{
293+
StartServer();
294+
await using var client = await CreateMcpClientForServer(CreateClientOptions());
295+
296+
var result = await CallToolWithTaskMetadataAsync(client, "provision_vm",
297+
new Dictionary<string, object?> { ["vmName"] = "test-vm" });
298+
299+
// The attribute-based tool should create a task after MRTR elicitation.
300+
Assert.NotNull(result.Task);
301+
Assert.NotEmpty(result.Task.TaskId);
302+
303+
var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId);
304+
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
305+
}
306+
307+
/// <summary>
308+
/// Attribute-based tool type demonstrating deferred task creation.
309+
/// Matches the pattern shown in the MRTR conceptual documentation.
310+
/// </summary>
311+
[McpServerToolType]
312+
private sealed class DeferredTaskToolType
313+
{
314+
[McpServerTool(DeferTaskCreation = true, TaskSupport = ToolTaskSupport.Optional)]
315+
[Description("Provisions a VM with user confirmation")]
316+
public static async Task<string> ProvisionVm(
317+
string vmName, McpServer server, CancellationToken ct)
318+
{
319+
var confirmation = await server.ElicitAsync(new ElicitRequestParams
320+
{
321+
Message = $"Provision VM '{vmName}'? This will incur costs.",
322+
RequestedSchema = new()
323+
}, ct);
324+
325+
if (confirmation.Action != "confirm")
326+
return "Cancelled by user.";
327+
328+
await server.CreateTaskAsync(ct);
329+
330+
await Task.Delay(50, ct);
331+
return $"VM '{vmName}' provisioned successfully.";
332+
}
333+
}
287334
}

0 commit comments

Comments
 (0)