Skip to content

Commit 983cdca

Browse files
committed
Make the alternate-result seam method-agnostic
The alternate-result augmentation seam is tools/call-shaped today, so enabling a second method to return an alternate result would mean another <Method>WithAlternateHandler/Filters pair plus new Core wiring each time. Add a method-keyed store (AlternateResultFilterCollection) and McpServerOptions.AddAlternateResultFilter<TParams, TResult>(method, filter). CallToolWithAlternateFilters becomes a typed view over the tools/call entry of that store, so filters registered through either API share one ordered list and nothing existing changes shape. Only adaptation and filter composition are generalized. Primitive matching and tool-call lifecycle logging stay per-method, so tools/call keeps its own composition in BuildComposedCallToolHandler. Registering filters for a method the server does not dispatch throws at construction rather than silently dropping them. Signed-off-by: onatozmenn <onatozmen44@gmail.com>
1 parent 79e13b3 commit 983cdca

5 files changed

Lines changed: 500 additions & 35 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using ModelContextProtocol.Protocol;
2+
using System.Diagnostics.CodeAnalysis;
3+
4+
namespace ModelContextProtocol.Server;
5+
6+
#pragma warning disable MCPEXP002 // this collection is itself part of the experimental ResultOrAlternate seam
7+
8+
/// <summary>
9+
/// Stores alternate-result filters keyed by JSON-RPC method name, so that augmenting a Core-dispatched
10+
/// method to return an alternate <see cref="Result"/> subtype does not require a new
11+
/// <c>&lt;Method&gt;WithAlternateHandler</c>/<c>&lt;Method&gt;WithAlternateFilters</c> pair per method.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// Each method may only be registered with a single (<c>TParams</c>, <c>TResult</c>) pair, matching the
16+
/// types Core dispatches that method with. Registering the same method with different types throws, because
17+
/// the mismatched filters could never be invoked.
18+
/// </para>
19+
/// <para>
20+
/// <see cref="McpRequestFilters.CallToolWithAlternateFilters"/> is a typed view over the
21+
/// <see cref="RequestMethods.ToolsCall"/> entry of this collection.
22+
/// </para>
23+
/// </remarks>
24+
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
25+
public sealed class AlternateResultFilterCollection
26+
{
27+
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
28+
29+
/// <summary>
30+
/// Gets the filter list registered for <paramref name="method"/>, creating an empty one if the method
31+
/// has not been registered yet.
32+
/// </summary>
33+
/// <typeparam name="TParams">The request parameter type Core dispatches <paramref name="method"/> with.</typeparam>
34+
/// <typeparam name="TResult">The result type Core dispatches <paramref name="method"/> with.</typeparam>
35+
/// <param name="method">The JSON-RPC method name, for example <see cref="RequestMethods.PromptsGet"/>.</param>
36+
/// <returns>The mutable filter list for <paramref name="method"/>.</returns>
37+
/// <exception cref="InvalidOperationException">
38+
/// <paramref name="method"/> was already registered with a different parameter or result type.
39+
/// </exception>
40+
public IList<McpRequestInvocationFilter<TParams, ResultOrAlternate<TResult>>> GetOrAdd<TParams, TResult>(string method)
41+
where TResult : Result
42+
{
43+
Throw.IfNullOrWhiteSpace(method);
44+
45+
if (_entries.TryGetValue(method, out var entry))
46+
{
47+
return Cast<TParams, TResult>(entry, method);
48+
}
49+
50+
var filters = new List<McpRequestInvocationFilter<TParams, ResultOrAlternate<TResult>>>();
51+
_entries[method] = new Entry(typeof(TParams), typeof(TResult), filters, () => filters.Count);
52+
return filters;
53+
}
54+
55+
/// <summary>
56+
/// Replaces the filter list registered for <paramref name="method"/>.
57+
/// </summary>
58+
/// <typeparam name="TParams">The request parameter type Core dispatches <paramref name="method"/> with.</typeparam>
59+
/// <typeparam name="TResult">The result type Core dispatches <paramref name="method"/> with.</typeparam>
60+
/// <param name="method">The JSON-RPC method name.</param>
61+
/// <param name="filters">The filter list to store.</param>
62+
public void Set<TParams, TResult>(
63+
string method,
64+
IList<McpRequestInvocationFilter<TParams, ResultOrAlternate<TResult>>> filters)
65+
where TResult : Result
66+
{
67+
Throw.IfNullOrWhiteSpace(method);
68+
Throw.IfNull(filters);
69+
70+
_entries[method] = new Entry(typeof(TParams), typeof(TResult), filters, () => filters.Count);
71+
}
72+
73+
/// <summary>
74+
/// Gets the method names that currently have at least one registered filter.
75+
/// </summary>
76+
internal IEnumerable<string> NonEmptyMethods
77+
{
78+
get
79+
{
80+
foreach (var entry in _entries)
81+
{
82+
if (entry.Value.Count > 0)
83+
{
84+
yield return entry.Key;
85+
}
86+
}
87+
}
88+
}
89+
90+
internal bool TryGetNonEmpty<TParams, TResult>(
91+
string method,
92+
[NotNullWhen(true)] out IList<McpRequestInvocationFilter<TParams, ResultOrAlternate<TResult>>>? filters)
93+
where TResult : Result
94+
{
95+
if (_entries.TryGetValue(method, out var entry) && entry.Count > 0)
96+
{
97+
filters = Cast<TParams, TResult>(entry, method);
98+
return true;
99+
}
100+
101+
filters = null;
102+
return false;
103+
}
104+
105+
private static IList<McpRequestInvocationFilter<TParams, ResultOrAlternate<TResult>>> Cast<TParams, TResult>(
106+
Entry entry,
107+
string method)
108+
where TResult : Result
109+
{
110+
if (entry.Filters is IList<McpRequestInvocationFilter<TParams, ResultOrAlternate<TResult>>> typed)
111+
{
112+
return typed;
113+
}
114+
115+
throw new InvalidOperationException(
116+
$"Alternate-result filters for method '{method}' were registered with " +
117+
$"({entry.ParamsType}, {entry.ResultType}), but ({typeof(TParams)}, {typeof(TResult)}) was requested. " +
118+
$"A method can only carry alternate-result filters for the types the server dispatches it with.");
119+
}
120+
121+
private sealed class Entry(Type paramsType, Type resultType, object filters, Func<int> count)
122+
{
123+
public Type ParamsType { get; } = paramsType;
124+
125+
public Type ResultType { get; } = resultType;
126+
127+
public object Filters { get; } = filters;
128+
129+
public int Count => count();
130+
}
131+
}

‎src/ModelContextProtocol.Core/Server/McpRequestFilters.cs‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFi
6666
}
6767
}
6868

69+
/// <summary>
70+
/// Gets the alternate-result filters registered for arbitrary JSON-RPC methods.
71+
/// </summary>
72+
/// <remarks>
73+
/// <para>
74+
/// Alternate-result filters let an extension augment a Core-dispatched method so it can return an alternate
75+
/// <see cref="Result"/> subtype (for example a task-creation result) in place of the method's normal result.
76+
/// The collection is keyed by method name, so enabling a new method is an extension-only change rather than a
77+
/// new <c>&lt;Method&gt;WithAlternateHandler</c>/<c>&lt;Method&gt;WithAlternateFilters</c> pair on Core.
78+
/// </para>
79+
/// <para>
80+
/// <see cref="CallToolWithAlternateFilters"/> is a typed view over the <see cref="RequestMethods.ToolsCall"/>
81+
/// entry of this collection, so the two stay in sync.
82+
/// </para>
83+
/// </remarks>
84+
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
85+
public AlternateResultFilterCollection AlternateResultFilters => field ??= new();
86+
6987
/// <summary>
7088
/// Gets or sets the filters for the call-tool handler pipeline with alternate result support.
7189
/// </summary>
@@ -83,15 +101,19 @@ public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFi
83101
/// Alternate-result filters run in registration order. If one filter dispatches the remainder of the pipeline
84102
/// asynchronously, filters registered after it execute as part of that asynchronous operation.
85103
/// </para>
104+
/// <para>
105+
/// This property is a view over the <see cref="RequestMethods.ToolsCall"/> entry of
106+
/// <see cref="AlternateResultFilters"/>. Filters added through either API share one ordered list.
107+
/// </para>
86108
/// </remarks>
87109
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
88110
public IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
89111
{
90-
get => field ??= [];
112+
get => AlternateResultFilters.GetOrAdd<CallToolRequestParams, CallToolResult>(RequestMethods.ToolsCall);
91113
set
92114
{
93115
Throw.IfNull(value);
94-
field = value;
116+
AlternateResultFilters.Set<CallToolRequestParams, CallToolResult>(RequestMethods.ToolsCall, value);
95117
}
96118
}
97119
#pragma warning restore MCPEXP002

0 commit comments

Comments
 (0)