Skip to content

Conversation

@ouvreboite
Copy link

@ouvreboite ouvreboite commented Jul 9, 2025

ToolFilters are akin to ASP.NET Core ActionFilters, but for tools. They allow to trigger reusable behaviors when a tool is listed or called.

Motivation and Context

MCP uses HTTP, but mostly relies on streaming, with means several tool calls can happen within a single HTTP connection.
As such, we cannot easily rely on existing ASP.NET Core patterns (middleware/ActionFilters/...) to implement reusable behavior when a tool is called (logging custom metrics, performing custom authz checks, ...).

Taking inspiration on ActionFilters, this PR introduces IToolFilter (and an abstract ToolFilterAttribute) as a convenient way to inject custom behavior within a tool lifecycle.

public interface IToolFilter
{    
    //Called when the tool is listed. Returning false hide the tool.
    //ℹ️The context gives access to the servicesProvider and so to the DI
    bool OnToolListed(Tool tool, RequestContext<ListToolsRequestParams> context);

    //Called before the tool is called
    //Returning non-null will bypass the tool itself (ex: returning an unauthorized error message)
    ValueTask<CallToolResult>? OnToolCalling(Tool tool, RequestContext<CallToolRequestParams> context);
    
   //Called after the tool has been called (but before the result has been sent)
   //Returning non-null override the tool's result
    ValueTask<CallToolResult>? OnToolCalled(Tool tool, RequestContext<CallToolRequestParams> context, ValueTask<CallToolResult> callResult);
}

How Has This Been Tested?

ℹ️ I'm waiting for an approval on the design to write dedicated tests.
But an example of usage is available in the AspNetCoreSseServer sample.

[McpServerTool, Description("Echoes the input back to the client.")]
[LimitCalls(maxCalls: 10)] //🟢
public static string Echo(string message)
{
    return "hello " + message;
}

...

//As an example, this filter allows to block a tool after a set number of calls have been made
public class LimitCallsAttribute(int maxCalls) : ToolFilterAttribute
{
    private int _callCount;

    public override ValueTask<CallToolResult>? OnToolCalling(Tool tool, RequestContext<CallToolRequestParams> context)
    {
        //Thread-safe increment
        var currentCount = Interlocked.Add(ref _callCount, 1);
        
        //Log count
        Console.Out.WriteLine($"Tool: {tool.Name} called {currentCount} time(s)");

        //If under threshold, do nothing
        if (currentCount <= maxCalls)
            return null; //do nothing

        //If above threshold, return error message
        return new ValueTask<CallToolResult>(new CallToolResult
        {
            Content = [new TextContentBlock { Text = $"This tool can only be called {maxCalls} time(s)" }]
        });
    }

    public override bool OnToolListed(Tool tool, RequestContext<ListToolsRequestParams> context)
    {
        //With the provided request context, you can access the dependency injection
        var configuration = context.Services?.GetService<IConfiguration>();
        var hide = configuration?["hide-tools-above-limit"] == "True";
        
        //Prevent the tool being listed (return false)
        //if the hide flag is true and the call count is above the threshold
        return _callCount <= maxCalls || !hide;
    }
}

Breaking Changes

No breaking change.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Cf #598

jb.muscat added 2 commits July 9, 2025 18:33
ToolFilters are similar to ASP.NET Core ActionFilters.

They offer three injection points:
- OnToolListed: before a tool is listed, allowing the tool to be filtered
- OnToolCalling: before a tool is called, allowing to return a response and bypass the tool
- OnToolCalled: after a tool is called, allowing to change the response

public override ValueTask<CallToolResult>? OnToolCalling(Tool tool, RequestContext<CallToolRequestParams> context)
{
_callCount++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This technically has a race condition, and should be using Interlocked.Add

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'll update the example.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done ✅

public interface IToolFilter
{
/// TODO:
public int Order { get; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be necessary, just like it is not necessary for aspnetcore filters

Copy link
Author

@ouvreboite ouvreboite Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order is defined on aspnetcore's ActionFilterAttribute.

It's not strictly necessary, but without it filters' implementors cannot define fine grained execution order.
You could not including it today, but I suppose that we would soon see issues created for "how can I make filter X always run before filter Y?"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I could remove the order from IToolFilter and let it on the ToolFilterAttribute?

  • Non-attribute filters can be provided in McpServerToolCreateOptions.Filters, which is an array. So the order can be explicited here. No need for an order property here.
  • Attribute filters are extracted using method.GetCustomAttributes<>() (which as a non-guaranteed order), so the order property can be used to defining ordering among attribute-based filters.

newOptions.Description ??= descAttr.Description;
}

var filters = method.GetCustomAttributes<ToolFilter>().OrderBy(f => f.Order).ToArray<IToolFilter>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't be compatible with aot compilation. That is likely a blocker.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How so? If you mean that the trimmer could have removed attribute annotations that's generally not the case. As long as you have safely looked up the MethodInfo you're guaranteed to have access to all its annotations.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetCustomAttribute<> is already used (5 lines above my code 🙂).
That's how the tools metadata and description are extracted from [McpServerTool] and [Description].

jb.muscat added 4 commits July 10, 2025 10:06
Attributes cannot be extracted with a guaranteed order, so an Order property is useful for attribute-based filters, but not required for non-attribute based ones.
@ouvreboite ouvreboite requested a review from DavidParks8 July 10, 2025 09:12
@halter73
Copy link
Contributor

Hi! Thank you for taking the time to contribute this PR. Filters/middleware are an often-requested feature that we've already discussed a bit in #267.

Long term, we would like to introduce a pipeline for all requests and notifications, not just tool calls. I think we need to consider the design of this entire pipeline before adding a tool-specific feature like this, but we don't want to design this feature by committee in a PR.

If you're still interested in guiding the design of this feature, please follow #267 and feel free to provide any API suggestion there. Once we reach an agreement on the API shape, we'll figure out how to proceed with an implementation and who should do it. Thanks again!

@halter73 halter73 closed this Jul 10, 2025
@ouvreboite
Copy link
Author

I'll have a look :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants