Skip to content

Commit b5195b4

Browse files
jeffhandleyCopilot
andcommitted
Add generic public seams to Core for Tasks extension extraction
- Add ResultOrAlternate<TResult> replacing task-specific types in server pipeline - Add McpServerRequestHandler for custom request handler registration (seam #1) - Add McpServerOptions.RequestHandlers property with wiring in McpServerImpl - Rename CallToolWithTaskHandler/Filters to CallToolWithAlternateHandler/Filters - Rename SetTaskAugmented to SetWithAlternate (remove tasks/get guard) - Rename InvokeToolAsTask to InvokeToolWithAlternate - Rename BuildInitialTaskToolFilter to BuildInitialAlternateToolFilter - Make McpClient.ResolveInputRequestsAsync public (seam #4) - Update test references to use new names - Adapt TaskHandlerConfigurationValidationTests for removed guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e23be1d commit b5195b4

17 files changed

Lines changed: 379 additions & 200 deletions

src/ModelContextProtocol.Core/Client/McpClient.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ protected McpClient()
8282
/// </param>
8383
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
8484
/// <returns>A dictionary of responses keyed by the same identifiers as the input requests.</returns>
85-
private protected abstract ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
85+
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
86+
public abstract ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
8687
IDictionary<string, InputRequest> inputRequests, CancellationToken cancellationToken);
8788

8889
/// <summary>

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
182182
private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls;
183183

184184
/// <inheritdoc/>
185-
private protected override async ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
185+
public override async ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
186186
IDictionary<string, InputRequest> inputRequests,
187187
CancellationToken cancellationToken)
188188
{
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.Text.Json.Serialization.Metadata;
2+
3+
namespace ModelContextProtocol.Protocol;
4+
5+
/// <summary>
6+
/// Represents the result of a request that may return either the standard result or an alternate
7+
/// <see cref="Result"/> subtype for scenarios like asynchronous task execution.
8+
/// </summary>
9+
/// <typeparam name="TResult">The standard result type for the request (e.g., <see cref="CallToolResult"/>).</typeparam>
10+
/// <remarks>
11+
/// <para>
12+
/// Extensions that augment request handling (such as the Tasks extension) use this type to indicate
13+
/// that the server returned an alternate result instead of the normal one. The alternate carries its
14+
/// own <see cref="JsonTypeInfo"/> so the transport layer can serialize it without compile-time knowledge
15+
/// of the concrete type.
16+
/// </para>
17+
/// <para>
18+
/// Use <see cref="IsAlternate"/> to determine which variant was returned, then access either
19+
/// <see cref="Result"/> for the immediate result or <see cref="Alternate"/> for the alternate.
20+
/// </para>
21+
/// </remarks>
22+
public class ResultOrAlternate<TResult> where TResult : Result
23+
{
24+
private readonly TResult? _result;
25+
private readonly Result? _alternate;
26+
private readonly JsonTypeInfo? _alternateTypeInfo;
27+
28+
/// <summary>
29+
/// Initializes a new instance of <see cref="ResultOrAlternate{TResult}"/> with an immediate result.
30+
/// </summary>
31+
/// <param name="result">The standard result returned by the server.</param>
32+
public ResultOrAlternate(TResult result)
33+
{
34+
Throw.IfNull(result);
35+
_result = result;
36+
}
37+
38+
/// <summary>
39+
/// Initializes a new instance of <see cref="ResultOrAlternate{TResult}"/> with an alternate result.
40+
/// </summary>
41+
/// <param name="alternate">The alternate result.</param>
42+
/// <param name="alternateTypeInfo">The <see cref="JsonTypeInfo"/> used to serialize the alternate result.</param>
43+
public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo)
44+
{
45+
Throw.IfNull(alternate);
46+
Throw.IfNull(alternateTypeInfo);
47+
_alternate = alternate;
48+
_alternateTypeInfo = alternateTypeInfo;
49+
}
50+
51+
/// <summary>
52+
/// Gets a value indicating whether the server returned an alternate result instead of the standard result.
53+
/// </summary>
54+
public bool IsAlternate => _alternate is not null;
55+
56+
/// <summary>
57+
/// Gets the immediate result, or <see langword="null"/> if the server returned an alternate.
58+
/// </summary>
59+
public TResult? Result => _result;
60+
61+
/// <summary>
62+
/// Gets the alternate result, or <see langword="null"/> if the server returned the standard result.
63+
/// </summary>
64+
public Result? Alternate => _alternate;
65+
66+
/// <summary>
67+
/// Gets the <see cref="JsonTypeInfo"/> for serializing the alternate result, or <see langword="null"/>
68+
/// if the server returned the standard result.
69+
/// </summary>
70+
public JsonTypeInfo? AlternateTypeInfo => _alternateTypeInfo;
71+
72+
/// <summary>
73+
/// Implicitly converts a <typeparamref name="TResult"/> to a <see cref="ResultOrAlternate{TResult}"/>
74+
/// wrapping the immediate result.
75+
/// </summary>
76+
/// <param name="result">The result to wrap.</param>
77+
public static implicit operator ResultOrAlternate<TResult>(TResult result) => new(result);
78+
79+
/// <summary>
80+
/// Implicitly converts a <see cref="CreateTaskResult"/> to a <see cref="ResultOrAlternate{TResult}"/>
81+
/// wrapping the task handle as an alternate result using the default serialization context.
82+
/// </summary>
83+
/// <param name="taskCreated">The task creation result to wrap.</param>
84+
public static implicit operator ResultOrAlternate<TResult>(CreateTaskResult taskCreated) =>
85+
new(taskCreated, McpJsonUtilities.JsonContext.Default.CreateTaskResult);
86+
}

src/ModelContextProtocol.Core/RequestHandlers.cs

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,44 +47,29 @@ public void Set<TParams, TResult>(
4747
}
4848

4949
/// <summary>
50-
/// Registers a handler that may return either a standard result or a <see cref="CreateTaskResult"/>
51-
/// for task-augmented execution.
50+
/// Registers a handler that may return either a standard result or an alternate <see cref="Result"/>
51+
/// subtype for scenarios like task-augmented execution.
5252
/// </summary>
53-
public void SetTaskAugmented<TParams, TResult>(
53+
public void SetWithAlternate<TParams, TResult>(
5454
string method,
55-
Func<TParams, JsonRpcRequest, CancellationToken, ValueTask<ResultOrCreatedTask<TResult>>> handler,
55+
Func<TParams, JsonRpcRequest, CancellationToken, ValueTask<ResultOrAlternate<TResult>>> handler,
5656
JsonTypeInfo<TParams> requestTypeInfo,
57-
JsonTypeInfo<TResult> responseTypeInfo,
58-
JsonTypeInfo<CreateTaskResult> taskResultTypeInfo)
57+
JsonTypeInfo<TResult> responseTypeInfo)
5958
where TResult : Result
6059
{
6160
Throw.IfNull(method);
6261
Throw.IfNull(handler);
6362
Throw.IfNull(requestTypeInfo);
6463
Throw.IfNull(responseTypeInfo);
65-
Throw.IfNull(taskResultTypeInfo);
6664

6765
this[method] = async (request, cancellationToken) =>
6866
{
6967
TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!;
7068
var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false);
7169

72-
if (augmented.IsTask)
70+
if (augmented.IsAlternate)
7371
{
74-
// Guard against a misconfiguration where a handler opts into task-augmented
75-
// execution but the server has no task lifecycle handlers wired up. Without
76-
// tasks/get, a client that received a CreateTaskResult would have no way to
77-
// poll the task to completion. Configure McpServerOptions.TaskStore or set
78-
// the task handlers explicitly via McpServerOptions.Handlers.
79-
if (!ContainsKey(RequestMethods.TasksGet))
80-
{
81-
throw new InvalidOperationException(
82-
$"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " +
83-
$"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " +
84-
"or set the task handlers explicitly in McpServerOptions.Handlers before starting the server.");
85-
}
86-
87-
return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo);
72+
return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!);
8873
}
8974

9075
return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo);

src/ModelContextProtocol.Core/Server/McpRequestFilters.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public IList<McpRequestFilter<ListToolsRequestParams, ListToolsResult>> ListTool
4242
/// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results.
4343
/// </para>
4444
/// <para>
45-
/// Cannot be used together with <see cref="CallToolWithTaskFilters"/>. If both are non-empty at configuration time,
45+
/// Cannot be used together with <see cref="CallToolWithAlternateFilters"/>. If both are non-empty at configuration time,
4646
/// an <see cref="InvalidOperationException"/> will be thrown.
4747
/// </para>
4848
/// </remarks>
@@ -57,21 +57,21 @@ public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFi
5757
}
5858

5959
/// <summary>
60-
/// Gets or sets the filters for the call-tool handler pipeline with task support.
60+
/// Gets or sets the filters for the call-tool handler pipeline with alternate result support.
6161
/// </summary>
6262
/// <remarks>
6363
/// <para>
64-
/// These filters wrap the task-augmented call-tool handler whose return type is
65-
/// <see cref="ResultOrCreatedTask{TResult}"/>. Use these filters when the server's tool pipeline
66-
/// supports returning either an immediate <see cref="CallToolResult"/> or a <see cref="CreateTaskResult"/>
67-
/// for asynchronous execution.
64+
/// These filters wrap the alternate-result call-tool handler whose return type is
65+
/// <see cref="ResultOrAlternate{TResult}"/>. Use these filters when the server's tool pipeline
66+
/// supports returning either an immediate <see cref="CallToolResult"/> or an alternate <see cref="Result"/>
67+
/// subtype for asynchronous execution.
6868
/// </para>
6969
/// <para>
7070
/// Cannot be used together with <see cref="CallToolFilters"/>. If both are non-empty at configuration time,
7171
/// an <see cref="InvalidOperationException"/> will be thrown.
7272
/// </para>
7373
/// </remarks>
74-
public IList<McpRequestFilter<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>>> CallToolWithTaskFilters
74+
public IList<McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
7575
{
7676
get => field ??= [];
7777
set

0 commit comments

Comments
 (0)