Fix HTTP task filter composition (#1722)
This commit is contained in:
parent
ef37c2156e
commit
a5179df39b
8 changed files with 1164 additions and 286 deletions
|
|
@ -0,0 +1,84 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
#pragma warning disable MCPEXP002
|
||||
internal sealed class ComposedCallToolInvocationState
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly List<ToolCallLifecycle> _pendingOrdinaryLifecycles = [];
|
||||
private bool _outerCompleted;
|
||||
private bool _outerReturnedAlternate;
|
||||
|
||||
public IReadOnlyList<ToolCallLifecycle> RecordOrdinaryResult(CallToolResult result) =>
|
||||
RecordOrdinaryLifecycle(new(result, null, false));
|
||||
|
||||
public IReadOnlyList<ToolCallLifecycle> RecordOrdinaryException(
|
||||
Exception exception,
|
||||
bool cancellationRequested) =>
|
||||
RecordOrdinaryLifecycle(new(null, exception, cancellationRequested));
|
||||
|
||||
public IReadOnlyList<ToolCallLifecycle> CompleteOuter(ResultOrAlternate<CallToolResult> result)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (result.IsAlternate)
|
||||
{
|
||||
_outerReturnedAlternate = true;
|
||||
return DrainPendingLifecycles();
|
||||
}
|
||||
|
||||
_outerCompleted = true;
|
||||
var exceptions = _pendingOrdinaryLifecycles
|
||||
.Where(lifecycle => lifecycle.Exception is not null)
|
||||
.ToArray();
|
||||
_pendingOrdinaryLifecycles.Clear();
|
||||
return exceptions.Length > 0 ?
|
||||
exceptions :
|
||||
[new(result.Result!, null, false)];
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ToolCallLifecycle> CompleteOuterException(
|
||||
Exception exception,
|
||||
bool cancellationRequested)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_outerCompleted = true;
|
||||
_pendingOrdinaryLifecycles.Clear();
|
||||
return [new(null, exception, cancellationRequested)];
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<ToolCallLifecycle> RecordOrdinaryLifecycle(ToolCallLifecycle lifecycle)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_outerReturnedAlternate)
|
||||
{
|
||||
return [lifecycle];
|
||||
}
|
||||
|
||||
if (!_outerCompleted)
|
||||
{
|
||||
_pendingOrdinaryLifecycles.Add(lifecycle);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<ToolCallLifecycle> DrainPendingLifecycles()
|
||||
{
|
||||
if (_pendingOrdinaryLifecycles.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var lifecycles = _pendingOrdinaryLifecycles.ToArray();
|
||||
_pendingOrdinaryLifecycles.Clear();
|
||||
return lifecycles;
|
||||
}
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
|
@ -8,6 +8,9 @@ namespace ModelContextProtocol.Server;
|
|||
/// </summary>
|
||||
public sealed class McpRequestFilters
|
||||
{
|
||||
#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
|
||||
private IList<McpRequestFilter<CallToolRequestParams, CallToolResult>>? _callToolFilters;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filters for the list-tools handler pipeline.
|
||||
/// </summary>
|
||||
|
|
@ -43,23 +46,26 @@ public sealed class McpRequestFilters
|
|||
/// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be used together with <see cref="CallToolWithAlternateFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that
|
||||
/// register different tool-call filter styles, such as authorization filters (which use this collection) and the
|
||||
/// tasks extension (which uses <see cref="CallToolWithAlternateFilters"/>).
|
||||
/// These filters run inside <see cref="CallToolWithAlternateFilters"/>. Each ordinary filter runs exactly once
|
||||
/// when an alternate-result filter invokes the ordinary tool pipeline. For task-backed calls, that invocation
|
||||
/// occurs in the background after the task record is created and before the matched tool is executed. Filters
|
||||
/// that must run before task creation should use the alternate-result pipeline instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These filters cannot be used with an explicit <see cref="McpServerHandlers.CallToolWithAlternateHandler"/>,
|
||||
/// which replaces the ordinary tool-call pipeline rather than augmenting it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters
|
||||
{
|
||||
get => field ??= [];
|
||||
get => _callToolFilters ??= [];
|
||||
set
|
||||
{
|
||||
Throw.IfNull(value);
|
||||
field = value;
|
||||
_callToolFilters = value;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
|
||||
/// <summary>
|
||||
/// Gets or sets the filters for the call-tool handler pipeline with alternate result support.
|
||||
/// </summary>
|
||||
|
|
@ -71,14 +77,15 @@ public sealed class McpRequestFilters
|
|||
/// subtype for asynchronous execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be used together with <see cref="CallToolFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that
|
||||
/// register different tool-call filter styles, such as the tasks extension (which uses this collection) and
|
||||
/// authorization filters (which use <see cref="CallToolFilters"/>).
|
||||
/// When no explicit <see cref="McpServerHandlers.CallToolWithAlternateHandler"/> is configured, these filters
|
||||
/// compose outside <see cref="CallToolFilters"/>. Primitive matching occurs before either filter family runs, then
|
||||
/// the ordinary pipeline is adapted to <see cref="ResultOrAlternate{TResult}"/> before these filters are applied.
|
||||
/// Alternate-result filters run in registration order. If one filter dispatches the remainder of the pipeline
|
||||
/// asynchronously, filters registered after it execute as part of that asynchronous operation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public IList<McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
|
||||
public IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
|
||||
{
|
||||
get => field ??= [];
|
||||
set
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Delegate type for filtering a single incoming MCP request invocation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TParams">The type of the parameters sent with the request.</typeparam>
|
||||
/// <typeparam name="TResult">The type of the response returned by the handler.</typeparam>
|
||||
/// <param name="context">The context for the current request.</param>
|
||||
/// <param name="next">The next request handler in the pipeline for this invocation.</param>
|
||||
/// <param name="cancellationToken">The cancellation token for the current request.</param>
|
||||
/// <returns>The result of the filtered request invocation.</returns>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public delegate ValueTask<TResult> McpRequestInvocationFilter<TParams, TResult>(
|
||||
RequestContext<TParams> context,
|
||||
McpRequestHandler<TParams, TResult> next,
|
||||
CancellationToken cancellationToken);
|
||||
|
|
@ -23,50 +23,27 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
private static Dictionary<string, HashSet<string>>? s_elicitAllowedProperties = null;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient interceptor that, when installed, redirects server-initiated requests
|
||||
/// (<see cref="SampleAsync(CreateMessageRequestParams, CancellationToken)"/>,
|
||||
/// <see cref="ElicitAsync(ElicitRequestParams, CancellationToken)"/>, and
|
||||
/// <see cref="RequestRootsAsync(ListRootsRequestParams, CancellationToken)"/>) away from the
|
||||
/// transport. The interceptor receives the request method and pre-serialized parameters and
|
||||
/// returns the serialized result. Used by extensions (such as the Tasks extension) to surface
|
||||
/// these requests through an alternate channel during background execution.
|
||||
/// </summary>
|
||||
private static readonly AsyncLocal<Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>?> s_outgoingRequestInterceptor = new();
|
||||
internal virtual Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any.
|
||||
/// </summary>
|
||||
internal static Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Installs an interceptor that redirects server-initiated requests for the duration of the
|
||||
/// returned scope on the current asynchronous execution context.
|
||||
/// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor.
|
||||
/// </summary>
|
||||
/// <param name="interceptor">
|
||||
/// The interceptor invoked for each outgoing request. It receives the request method, the
|
||||
/// pre-serialized request parameters (or <see langword="null"/>), and a cancellation token, and
|
||||
/// returns the serialized result (or <see langword="null"/> to indicate no result).
|
||||
/// </param>
|
||||
/// <returns>An <see cref="IDisposable"/> that restores the previous interceptor when disposed.</returns>
|
||||
/// <returns>A server facade that uses <paramref name="interceptor"/> for outgoing requests.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="interceptor"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// While an interceptor is installed, the redirected methods skip their client-capability checks,
|
||||
/// On the returned facade, redirected methods skip their client-capability checks,
|
||||
/// because the alternate channel is responsible for delivering the request to the client.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public IDisposable InterceptOutgoingRequests(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor)
|
||||
public McpServer WithOutgoingRequestInterceptor(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor)
|
||||
{
|
||||
Throw.IfNull(interceptor);
|
||||
|
||||
var previous = s_outgoingRequestInterceptor.Value;
|
||||
s_outgoingRequestInterceptor.Value = interceptor;
|
||||
return new OutgoingRequestInterceptorScope(previous);
|
||||
}
|
||||
|
||||
private sealed class OutgoingRequestInterceptorScope(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? previous) : IDisposable
|
||||
{
|
||||
public void Dispose() => s_outgoingRequestInterceptor.Value = previous;
|
||||
return new OutgoingRequestInterceptingMcpServer(this, interceptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -119,7 +96,7 @@ public abstract partial class McpServer : McpSession
|
|||
// redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are
|
||||
// intentionally skipped because the interceptor's alternate channel is responsible for
|
||||
// delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
|
||||
if (CurrentOutgoingRequestInterceptor is { } interceptor)
|
||||
if (OutgoingRequestInterceptor is { } interceptor)
|
||||
{
|
||||
return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
||||
|
|
@ -326,7 +303,7 @@ public abstract partial class McpServer : McpSession
|
|||
// redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped
|
||||
// because the interceptor's alternate channel is responsible for delivering the request to
|
||||
// the client. See SendRequestViaInterceptorAsync remarks.
|
||||
if (CurrentOutgoingRequestInterceptor is { } interceptor)
|
||||
if (OutgoingRequestInterceptor is { } interceptor)
|
||||
{
|
||||
return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
||||
|
|
@ -372,7 +349,7 @@ public abstract partial class McpServer : McpSession
|
|||
// redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are
|
||||
// intentionally skipped because the interceptor's alternate channel is responsible for
|
||||
// delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
|
||||
if (CurrentOutgoingRequestInterceptor is { } interceptor)
|
||||
if (OutgoingRequestInterceptor is { } interceptor)
|
||||
{
|
||||
var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams);
|
||||
var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false);
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ public sealed class McpServerHandlers
|
|||
/// a <see cref="CallToolResult"/> for immediate results or an alternate <see cref="Result"/> subtype.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be set if <see cref="CallToolHandler"/> is already set.
|
||||
/// This is a low-level full replacement for the ordinary tool-call pipeline. It cannot be set if
|
||||
/// <see cref="CallToolHandler"/> is already set, and it cannot be composed with ordinary
|
||||
/// <see cref="McpRequestFilters.CallToolFilters"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException"><see cref="CallToolHandler"/> is already set.</exception>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
|
||||
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
|
||||
|
||||
private static readonly string[] s_perRequestMetadataKeys =
|
||||
[
|
||||
MetaKeys.ProtocolVersion,
|
||||
|
|
@ -1321,20 +1320,13 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
var callToolFilters = options.Filters.Request.CallToolFilters;
|
||||
var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters;
|
||||
|
||||
// Validate: cannot mix non-alternate filters/handler with alternate filters/handler.
|
||||
bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0;
|
||||
bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0;
|
||||
|
||||
if (hasNonAlternatePath && hasAlternatePath)
|
||||
if (callToolWithAlternateHandler is not null && callToolFilters.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
|
||||
$"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " +
|
||||
$"These two styles cannot currently be composed on the same server. " +
|
||||
$"This most commonly happens when combining features that register different tool-call filter styles, " +
|
||||
$"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " +
|
||||
$"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " +
|
||||
$"Configure only one style, or avoid combining features that require different styles.");
|
||||
$"Cannot apply {nameof(McpRequestFilters.CallToolFilters)} when an explicit " +
|
||||
$"{nameof(McpServerHandlers.CallToolWithAlternateHandler)} is configured. The alternate handler " +
|
||||
$"replaces the ordinary tool-call pipeline. Move the behavior to " +
|
||||
$"{nameof(McpRequestFilters.CallToolWithAlternateFilters)} or remove the explicit alternate handler.");
|
||||
}
|
||||
|
||||
// Handle tools provided via DI by augmenting the list handler.
|
||||
|
|
@ -1376,18 +1368,16 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters);
|
||||
|
||||
// Build the unified alternate-result handler from one of the two paths.
|
||||
if (hasAlternatePath)
|
||||
// An explicit alternate handler replaces the ordinary tool-call pipeline.
|
||||
if (callToolWithAlternateHandler is not null)
|
||||
{
|
||||
// Case 2: alternate filter + alternate handler
|
||||
callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
|
||||
|
||||
// Augment with DI tools.
|
||||
if (tools is not null)
|
||||
{
|
||||
var originalHandler = callToolWithAlternateHandler;
|
||||
callToolWithAlternateHandler = (request, cancellationToken) =>
|
||||
{
|
||||
MatchTool(request, tools);
|
||||
if (request.MatchedPrimitive is McpServerTool tool)
|
||||
{
|
||||
return InvokeToolWithAlternate(tool, request, cancellationToken);
|
||||
|
|
@ -1397,11 +1387,13 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
};
|
||||
}
|
||||
|
||||
callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools));
|
||||
callToolWithAlternateHandler = BuildInvocationFilterPipeline(
|
||||
callToolWithAlternateHandler,
|
||||
callToolWithAlternateFilters,
|
||||
BuildInitialAlternateToolFilter(tools));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based
|
||||
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
|
||||
|
||||
// Augment with DI tools.
|
||||
|
|
@ -1419,12 +1411,12 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
};
|
||||
}
|
||||
|
||||
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));
|
||||
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters);
|
||||
|
||||
// Convert to alternate-based.
|
||||
var finalCallToolHandler = callToolHandler;
|
||||
callToolWithAlternateHandler = async (request, cancellationToken) =>
|
||||
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
callToolWithAlternateHandler = BuildComposedCallToolHandler(
|
||||
callToolHandler,
|
||||
callToolWithAlternateFilters,
|
||||
tools);
|
||||
}
|
||||
ServerCapabilities.Tools.ListChanged = listChanged;
|
||||
|
||||
|
|
@ -1448,58 +1440,79 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private McpRequestFilter<CallToolRequestParams, CallToolResult> BuildInitialCallToolFilter(
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
|
||||
async (request, cancellationToken) =>
|
||||
private McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildComposedCallToolHandler(
|
||||
McpRequestHandler<CallToolRequestParams, CallToolResult> callToolHandler,
|
||||
IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> callToolWithAlternateFilters,
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools)
|
||||
{
|
||||
return async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.Params?.Name is { } toolName && tools is not null &&
|
||||
tools.TryGetPrimitive(toolName, out var tool))
|
||||
{
|
||||
request.MatchedPrimitive = tool;
|
||||
}
|
||||
MatchTool(request, tools);
|
||||
|
||||
var invocation = new ComposedCallToolInvocationState();
|
||||
var composedHandler = BuildInvocationFilterPipeline(
|
||||
InvokeOrdinaryPipelineAsync,
|
||||
callToolWithAlternateFilters);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
|
||||
var result = await composedHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
LogToolCallLifecycles(request, invocation.CompleteOuter(result));
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Skip logging for InputRequiredException - it's normal MRTR control flow,
|
||||
// not an error (tools throw it to signal an InputRequiredResult).
|
||||
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
|
||||
{
|
||||
ToolCallError(request.Params?.Name ?? string.Empty, e);
|
||||
}
|
||||
LogToolCallLifecycles(
|
||||
request,
|
||||
invocation.CompleteOuterException(
|
||||
e,
|
||||
cancellationToken.IsCancellationRequested));
|
||||
|
||||
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
return new()
|
||||
return CreateToolCallErrorResult(request, e);
|
||||
}
|
||||
|
||||
async ValueTask<ResultOrAlternate<CallToolResult>> InvokeOrdinaryPipelineAsync(
|
||||
RequestContext<CallToolRequestParams> ordinaryRequest,
|
||||
CancellationToken ordinaryCancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
MatchTool(ordinaryRequest, tools);
|
||||
var result = await callToolHandler(ordinaryRequest, ordinaryCancellationToken).ConfigureAwait(false);
|
||||
LogToolCallLifecycles(ordinaryRequest, invocation.RecordOrdinaryResult(result));
|
||||
return result;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LogToolCallLifecycles(
|
||||
ordinaryRequest,
|
||||
invocation.RecordOrdinaryException(
|
||||
exception,
|
||||
ordinaryCancellationToken.IsCancellationRequested));
|
||||
|
||||
if ((exception is OperationCanceledException && ordinaryCancellationToken.IsCancellationRequested) ||
|
||||
exception is McpProtocolException ||
|
||||
exception is InputRequiredException)
|
||||
{
|
||||
Text = e is McpException ?
|
||||
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
|
||||
$"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
throw;
|
||||
}
|
||||
|
||||
return CreateToolCallErrorResult(ordinaryRequest, exception);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter(
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
|
||||
async (request, cancellationToken) =>
|
||||
private McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter(
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools) =>
|
||||
async (request, handler, cancellationToken) =>
|
||||
{
|
||||
if (request.Params?.Name is { } toolName && tools is not null &&
|
||||
tools.TryGetPrimitive(toolName, out var tool))
|
||||
{
|
||||
request.MatchedPrimitive = tool;
|
||||
}
|
||||
MatchTool(request, tools);
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -1525,18 +1538,54 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
throw;
|
||||
}
|
||||
|
||||
return new CallToolResult
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
{
|
||||
Text = e is McpException ?
|
||||
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
|
||||
$"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
return CreateToolCallErrorResult(request, e);
|
||||
}
|
||||
};
|
||||
|
||||
private static void MatchTool(
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools)
|
||||
{
|
||||
if (request.Params?.Name is { } toolName && tools is not null &&
|
||||
tools.TryGetPrimitive(toolName, out var tool))
|
||||
{
|
||||
request.MatchedPrimitive = tool;
|
||||
}
|
||||
}
|
||||
|
||||
private static CallToolResult CreateToolCallErrorResult(
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
Exception exception) =>
|
||||
new()
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
{
|
||||
Text = exception is McpException ?
|
||||
$"An error occurred invoking '{request.Params?.Name}': {exception.Message}" :
|
||||
$"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
|
||||
private void LogToolCallLifecycles(
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
IReadOnlyList<ToolCallLifecycle> lifecycles)
|
||||
{
|
||||
string toolName = request.Params?.Name ?? string.Empty;
|
||||
foreach (var lifecycle in lifecycles)
|
||||
{
|
||||
if (lifecycle.Result is { } result)
|
||||
{
|
||||
ToolCallCompleted(toolName, result.IsError is true);
|
||||
}
|
||||
else if (!(lifecycle.Exception is OperationCanceledException && lifecycle.CancellationRequested) &&
|
||||
lifecycle.Exception is not InputRequiredException)
|
||||
{
|
||||
ToolCallError(toolName, lifecycle.Exception!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
private void ConfigureLogging(McpServerOptions options)
|
||||
|
|
@ -1730,6 +1779,31 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return current;
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002
|
||||
private static McpRequestHandler<TParams, TResult> BuildInvocationFilterPipeline<TParams, TResult>(
|
||||
McpRequestHandler<TParams, TResult> baseHandler,
|
||||
IList<McpRequestInvocationFilter<TParams, TResult>> filters,
|
||||
McpRequestInvocationFilter<TParams, TResult>? initialHandler = null)
|
||||
{
|
||||
var current = baseHandler;
|
||||
|
||||
for (int i = filters.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var next = current;
|
||||
var filter = filters[i];
|
||||
current = (request, cancellationToken) => filter(request, next, cancellationToken);
|
||||
}
|
||||
|
||||
if (initialHandler is not null)
|
||||
{
|
||||
var next = current;
|
||||
current = (request, cancellationToken) => initialHandler(request, next, cancellationToken);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter> filters)
|
||||
{
|
||||
if (filters.Count == 0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
#pragma warning disable MCPEXP002
|
||||
internal sealed class OutgoingRequestInterceptingMcpServer(
|
||||
McpServer server,
|
||||
Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor) : McpServer
|
||||
#pragma warning restore MCPEXP002
|
||||
{
|
||||
internal override Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => interceptor;
|
||||
|
||||
public override string? SessionId => server.SessionId;
|
||||
|
||||
public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion;
|
||||
|
||||
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
|
||||
|
||||
public override Implementation? ClientInfo => server.ClientInfo;
|
||||
|
||||
public override McpServerOptions ServerOptions => server.ServerOptions;
|
||||
|
||||
public override IServiceProvider? Services => server.Services;
|
||||
|
||||
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
||||
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
|
||||
|
||||
public override bool IsMrtrSupported => server.IsMrtrSupported;
|
||||
|
||||
public override ValueTask DisposeAsync() => server.DisposeAsync();
|
||||
|
||||
public override IAsyncDisposable RegisterNotificationHandler(
|
||||
string method,
|
||||
Func<JsonRpcNotification, CancellationToken, ValueTask> handler) =>
|
||||
server.RegisterNotificationHandler(method, handler);
|
||||
|
||||
public override Task RunAsync(CancellationToken cancellationToken = default) =>
|
||||
server.RunAsync(cancellationToken);
|
||||
|
||||
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) =>
|
||||
server.SendMessageAsync(message, cancellationToken);
|
||||
|
||||
public override async Task<JsonRpcResponse> SendRequestAsync(
|
||||
JsonRpcRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(request);
|
||||
|
||||
return new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = await interceptor(request.Method, request.Params, cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
internal sealed record ToolCallLifecycle(
|
||||
CallToolResult? Result,
|
||||
Exception? Exception,
|
||||
bool CancellationRequested);
|
||||
Loading…
Reference in a new issue