From 57859eca76794603ecaaa44af3c0b0887223d1db Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Wed, 9 Jul 2025 18:33:41 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8Add=20ToolFilters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Server/AIFunctionMcpServerTool.cs | 12 +++++- .../Server/McpServer.cs | 22 ++++++++++- .../Server/McpServerTool.cs | 4 ++ .../Server/McpServerToolCreateOptions.cs | 4 ++ src/ModelContextProtocol.Core/ToolFilter.cs | 39 +++++++++++++++++++ 5 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 src/ModelContextProtocol.Core/ToolFilter.cs diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index afd3912b6..779efa904 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -9,6 +9,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; +using ModelContextProtocol.Core; namespace ModelContextProtocol.Server; @@ -146,7 +147,7 @@ options.OpenWorld is not null || } } - return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping); + return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Filters ?? []); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -185,6 +186,9 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe { newOptions.Description ??= descAttr.Description; } + + var filters = method.GetCustomAttributes().OrderBy(f => f.Order).ToArray(); + newOptions.Filters = filters; return newOptions; } @@ -193,10 +197,11 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe internal AIFunction AIFunction { get; } /// Initializes a new instance of the class. - private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping) + private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IToolFilter[] filters) { AIFunction = function; ProtocolTool = tool; + Filters = filters; _logger = serviceProvider?.GetService()?.CreateLogger() ?? (ILogger)NullLogger.Instance; _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; } @@ -204,6 +209,9 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider /// public override Tool ProtocolTool { get; } + /// + public override IToolFilter[] Filters { get; } + /// public override async ValueTask InvokeAsync( RequestContext request, CancellationToken cancellationToken = default) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 6c5858f91..4484a77db 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -448,6 +448,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { foreach (var t in tools) { + if (t.Filters.Any(f => !f.OnToolListed(t.ProtocolTool,request))) + { + continue; + } + result.Tools.Add(t.ProtocolTool); } } @@ -461,7 +466,22 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) if (request.Params is not null && tools.TryGetPrimitive(request.Params.Name, out var tool)) { - return tool.InvokeAsync(request, cancellationToken); + foreach (var filter in tool.Filters) + { + var filterResult = filter.OnToolCalling(tool.ProtocolTool, request); + if(filterResult != null) + return filterResult.Value; + } + + var result = tool.InvokeAsync(request, cancellationToken); + + foreach (var filter in tool.Filters) + { + var filterResult = filter.OnToolCalled(tool.ProtocolTool, request, result); + if(filterResult != null) + return filterResult.Value; + } + return result; } return originalCallToolHandler(request, cancellationToken); diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index e3958271b..09ee8c112 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol; using System.Reflection; using System.Text.Json; +using ModelContextProtocol.Core; namespace ModelContextProtocol.Server; @@ -140,6 +141,9 @@ protected McpServerTool() /// Gets the protocol type for this instance. public abstract Tool ProtocolTool { get; } + + /// Gets the filters () associated to this tool. + public abstract IToolFilter[] Filters { get; } /// Invokes the . /// The request information resulting in the invocation of this tool. diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index bdb4ecb8d..d747edc35 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Text.Json; +using ModelContextProtocol.Core; namespace ModelContextProtocol.Server; @@ -155,6 +156,9 @@ public sealed class McpServerToolCreateOptions /// public AIJsonSchemaCreateOptions? SchemaCreateOptions { get; set; } + /// TODO + public IToolFilter[] Filters { get; set; } = []; + /// /// Creates a shallow clone of the current instance. /// diff --git a/src/ModelContextProtocol.Core/ToolFilter.cs b/src/ModelContextProtocol.Core/ToolFilter.cs new file mode 100644 index 000000000..039de7cd0 --- /dev/null +++ b/src/ModelContextProtocol.Core/ToolFilter.cs @@ -0,0 +1,39 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Core; + +/// TODO: +public interface IToolFilter +{ + /// TODO: + public int Order { get; } + + /// TODO: + bool OnToolListed(Tool tool, RequestContext context); + + /// TODO: + ValueTask? OnToolCalling(Tool tool, RequestContext context); + + /// TODO: + ValueTask? OnToolCalled(Tool tool, RequestContext context, ValueTask callResult); +} + +/// TODO: +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public abstract class ToolFilter(int order) : Attribute, IToolFilter +{ + /// + public int Order { get; } = order; + + /// + public virtual bool OnToolListed(Tool tool, RequestContext context) => true; + + /// + public virtual ValueTask? OnToolCalling(Tool tool, RequestContext context) => + null; + + /// + public virtual ValueTask? OnToolCalled(Tool tool, RequestContext context, + ValueTask callResult) => null; +} \ No newline at end of file From 7a0522620977cad489881520265429588c902481 Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Wed, 9 Jul 2025 18:34:35 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=85Update=20AspNetCore=20sample=20to?= =?UTF-8?q?=20showcase=20ToolFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Attributes/LimitCalls.cs | 31 +++++++++++++++++++ samples/AspNetCoreSseServer/Tools/EchoTool.cs | 2 ++ samples/AspNetCoreSseServer/appsettings.json | 3 +- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 samples/AspNetCoreSseServer/Attributes/LimitCalls.cs diff --git a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs new file mode 100644 index 000000000..5307426da --- /dev/null +++ b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs @@ -0,0 +1,31 @@ +using ModelContextProtocol.Core; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace AspNetCoreSseServer.Attributes; + +public class LimitCalls(int maxCalls, int order = 0) : ToolFilter(order) +{ + private int _callCount; + + public override ValueTask? OnToolCalling(Tool tool, RequestContext context) + { + _callCount++; + Console.Out.WriteLine($"Tool: {tool.Name} called {_callCount} time(s)"); + + if (_callCount <= maxCalls) + return null; //do nothing + + return new ValueTask(new CallToolResult() + { + Content = [new TextContentBlock { Text = $"This tool can only be called {maxCalls} time(s)" }] + }); + } + + public override bool OnToolListed(Tool tool, RequestContext context) + { + var configuration = context.Services?.GetService(); + var hide = configuration?["hide-tools-above-limit"] == "True"; + return _callCount <= maxCalls || !hide; + } +} diff --git a/samples/AspNetCoreSseServer/Tools/EchoTool.cs b/samples/AspNetCoreSseServer/Tools/EchoTool.cs index 7913b73e4..d4fb23139 100644 --- a/samples/AspNetCoreSseServer/Tools/EchoTool.cs +++ b/samples/AspNetCoreSseServer/Tools/EchoTool.cs @@ -1,5 +1,6 @@ using ModelContextProtocol.Server; using System.ComponentModel; +using AspNetCoreSseServer.Attributes; namespace TestServerWithHosting.Tools; @@ -7,6 +8,7 @@ namespace TestServerWithHosting.Tools; public sealed class EchoTool { [McpServerTool, Description("Echoes the input back to the client.")] + [LimitCalls(maxCalls: 10)] public static string Echo(string message) { return "hello " + message; diff --git a/samples/AspNetCoreSseServer/appsettings.json b/samples/AspNetCoreSseServer/appsettings.json index 10f68b8c8..8552e10d6 100644 --- a/samples/AspNetCoreSseServer/appsettings.json +++ b/samples/AspNetCoreSseServer/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "hide-tools-above-limit": true } From 67cb2fe0b1e2d70b0a931c9b3d4af4d20080081b Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Thu, 10 Jul 2025 10:06:14 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A7=B5Fix=20filter=20example=20for=20?= =?UTF-8?q?multi=20threading=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + add a few comments --- .../Attributes/LimitCalls.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs index 5307426da..974d8c52f 100644 --- a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs +++ b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs @@ -4,19 +4,24 @@ namespace AspNetCoreSseServer.Attributes; -public class LimitCalls(int maxCalls, int order = 0) : ToolFilter(order) +public class LimitCallsAttribute(int maxCalls, int order = 0) : ToolFilter(order) { private int _callCount; public override ValueTask? OnToolCalling(Tool tool, RequestContext context) { - _callCount++; - Console.Out.WriteLine($"Tool: {tool.Name} called {_callCount} time(s)"); + //Thread-safe increment + var currentCount = Interlocked.Add(ref _callCount, 1); + + //Log count + Console.Out.WriteLine($"Tool: {tool.Name} called {currentCount} time(s)"); - if (_callCount <= maxCalls) + //If under threshold, do nothing + if (currentCount <= maxCalls) return null; //do nothing - return new ValueTask(new CallToolResult() + //If above threshold, return error message + return new ValueTask(new CallToolResult { Content = [new TextContentBlock { Text = $"This tool can only be called {maxCalls} time(s)" }] }); @@ -24,8 +29,12 @@ public class LimitCalls(int maxCalls, int order = 0) : ToolFilter(order) public override bool OnToolListed(Tool tool, RequestContext context) { + //With the provided request context, you can access the dependency injection var configuration = context.Services?.GetService(); 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; } } From e64e51fd76d56b0fac9e15225a8c4d70b9d1f098 Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Thu, 10 Jul 2025 11:07:19 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=8F=EF=B8=8FFix=20ToolFilterAttribute?= =?UTF-8?q?=20class=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- samples/AspNetCoreSseServer/Attributes/LimitCalls.cs | 2 +- src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs | 2 +- src/ModelContextProtocol.Core/Server/McpServerTool.cs | 2 +- src/ModelContextProtocol.Core/ToolFilter.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs index 974d8c52f..08eccd071 100644 --- a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs +++ b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs @@ -4,7 +4,7 @@ namespace AspNetCoreSseServer.Attributes; -public class LimitCallsAttribute(int maxCalls, int order = 0) : ToolFilter(order) +public class LimitCallsAttribute(int maxCalls, int order = 0) : ToolFilterAttribute(order) { private int _callCount; diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 779efa904..92ee071fa 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -187,7 +187,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Description ??= descAttr.Description; } - var filters = method.GetCustomAttributes().OrderBy(f => f.Order).ToArray(); + var filters = method.GetCustomAttributes().OrderBy(f => f.Order).ToArray(); newOptions.Filters = filters; return newOptions; diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index 09ee8c112..0b6d0e10c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -142,7 +142,7 @@ protected McpServerTool() /// Gets the protocol type for this instance. public abstract Tool ProtocolTool { get; } - /// Gets the filters () associated to this tool. + /// Gets the filters () associated to this tool. public abstract IToolFilter[] Filters { get; } /// Invokes the . diff --git a/src/ModelContextProtocol.Core/ToolFilter.cs b/src/ModelContextProtocol.Core/ToolFilter.cs index 039de7cd0..f5c53a4ac 100644 --- a/src/ModelContextProtocol.Core/ToolFilter.cs +++ b/src/ModelContextProtocol.Core/ToolFilter.cs @@ -21,7 +21,7 @@ public interface IToolFilter /// TODO: [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] -public abstract class ToolFilter(int order) : Attribute, IToolFilter +public abstract class ToolFilterAttribute(int order) : Attribute, IToolFilter { /// public int Order { get; } = order; From 1544302cfa7e423c047b416d89fc4aa98b6b09ce Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Thu, 10 Jul 2025 11:08:42 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=99=BB=EF=B8=8FHave=20Order=20only=20def?= =?UTF-8?q?ined=20on=20ToolFilterAttribute=20not=20IToolFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ModelContextProtocol.Core/ToolFilter.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/ToolFilter.cs b/src/ModelContextProtocol.Core/ToolFilter.cs index f5c53a4ac..cbc31ab7c 100644 --- a/src/ModelContextProtocol.Core/ToolFilter.cs +++ b/src/ModelContextProtocol.Core/ToolFilter.cs @@ -6,9 +6,6 @@ namespace ModelContextProtocol.Core; /// TODO: public interface IToolFilter { - /// TODO: - public int Order { get; } - /// TODO: bool OnToolListed(Tool tool, RequestContext context); @@ -23,7 +20,10 @@ public interface IToolFilter [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public abstract class ToolFilterAttribute(int order) : Attribute, IToolFilter { - /// + /// + /// Gets the order value for determining the order of execution of filters. Filters execute in + /// ascending numeric value of the property. + /// public int Order { get; } = order; /// From 6300f19975f2eb5ad377878676792bfa0fbd4f14 Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Thu, 10 Jul 2025 11:10:41 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BBMake=20att?= =?UTF-8?q?ribute's=20order=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- samples/AspNetCoreSseServer/Attributes/LimitCalls.cs | 2 +- src/ModelContextProtocol.Core/ToolFilter.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs index 08eccd071..8ca7ecace 100644 --- a/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs +++ b/samples/AspNetCoreSseServer/Attributes/LimitCalls.cs @@ -4,7 +4,7 @@ namespace AspNetCoreSseServer.Attributes; -public class LimitCallsAttribute(int maxCalls, int order = 0) : ToolFilterAttribute(order) +public class LimitCallsAttribute(int maxCalls) : ToolFilterAttribute { private int _callCount; diff --git a/src/ModelContextProtocol.Core/ToolFilter.cs b/src/ModelContextProtocol.Core/ToolFilter.cs index cbc31ab7c..579055b44 100644 --- a/src/ModelContextProtocol.Core/ToolFilter.cs +++ b/src/ModelContextProtocol.Core/ToolFilter.cs @@ -18,7 +18,7 @@ public interface IToolFilter /// TODO: [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] -public abstract class ToolFilterAttribute(int order) : Attribute, IToolFilter +public abstract class ToolFilterAttribute(int order = 0) : Attribute, IToolFilter { /// /// Gets the order value for determining the order of execution of filters. Filters execute in