diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 5ed8ba0d..c6ab1d42 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -17,6 +17,7 @@ + diff --git a/samples/UrlRoutingSseServer/Program.cs b/samples/UrlRoutingSseServer/Program.cs new file mode 100644 index 00000000..bf911433 --- /dev/null +++ b/samples/UrlRoutingSseServer/Program.cs @@ -0,0 +1,31 @@ +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using ModelContextProtocol.AspNetCore; +using UrlRoutingSseServer.Tools; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + +builder.Services.AddOpenTelemetry() + .WithTracing(b => b.AddSource("*") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(b => b.AddMeter("*") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithLogging() + .UseOtlpExporter(); + +var app = builder.Build(); + +app.MapMcpWithRouting("mcp"); + +app.Run(); \ No newline at end of file diff --git a/samples/UrlRoutingSseServer/Properties/launchSettings.json b/samples/UrlRoutingSseServer/Properties/launchSettings.json new file mode 100644 index 00000000..da24c915 --- /dev/null +++ b/samples/UrlRoutingSseServer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "sse-server", + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7133;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "sse-server", + } + } + } +} diff --git a/samples/UrlRoutingSseServer/README.md b/samples/UrlRoutingSseServer/README.md new file mode 100644 index 00000000..6ba22205 --- /dev/null +++ b/samples/UrlRoutingSseServer/README.md @@ -0,0 +1,133 @@ +# ASP.NET Core MCP Server with Routing + +This sample demonstrates route-aware tool filtering in MCP servers, allowing different tool sets to be exposed at different HTTP endpoints based on the `[McpServerToolRoute]` attribute. + +## Overview + +The routing feature enables you to: + +- Expose different tools at different HTTP endpoints +- Create context-specific tool collections (admin, utilities, etc.) +- Maintain global tools available on all routes +- Filter tool visibility based on the requested route + +## Route Configuration + +### Available Routes + +| Route | Available Tools | Description | +|-------|----------------|-------------| +| `/mcp` (global) | All tools | Default route with complete tool set | +| `/mcp/admin` | Admin tools + Global tools | Administrative functions | +| `/mcp/weather` | Weather tools + Global tools | Weather-related operations | +| `/mcp/math` | Math tools + Global tools | Mathematical calculations | +| `/mcp/utilities` | Utility tools + Global tools | General utility functions | +| `/mcp/echo` | Echo tools + Global tools | Echo and text operations | + +### Tool Categories + +- **Global Tools**: `SampleLLM` (available on all routes) +- **Admin Tools**: `GetSystemStatus`, `RestartService` (admin route only) +- **Weather Tools**: `GetWeather`, `GetForecast` (weather + utilities routes) +- **Math Tools**: `Add`, `Factorial` (math + utilities routes) +- **Echo Tools**: `Echo`, `EchoAdvanced` (echo + utilities routes) + +## Running the Sample + +1. Start the server: + ```bash + cd samples/UrlRoutingSseServer + dotnet run + ``` + +2. The server will start at `http://localhost:5000` (or port shown in console) + +## Testing Different Routes + +You can test the routing behavior using curl or any HTTP client: + +### List All Tools (Global Route) +```bash +curl -X POST http://localhost:5000/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +### List Admin Tools Only +```bash +curl -X POST http://localhost:5000/mcp/admin \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +### List Weather Tools Only +```bash +curl -X POST http://localhost:5000/mcp/weather \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +### Call a Tool +```bash +curl -X POST http://localhost:5000/mcp/weather \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Seattle"}}}' +``` + +## Expected Results + +- **Global route** (`/mcp`): Returns all 9 tools +- **Admin route** (`/mcp/admin`): Returns 3 tools (2 admin + 1 global) +- **Weather route** (`/mcp/weather`): Returns 3 tools (2 weather + 1 global) +- **Math route** (`/mcp/math`): Returns 3 tools (2 math + 1 global) +- **Utilities route** (`/mcp/utilities`): Returns 4 tools (3 utility + 1 global) + +## Implementation Details + +### Key Configuration Changes + +The routing feature requires two configuration changes: + +```csharp +// Use routing-enabled transport +builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() // Instead of .WithHttpTransport() + +// Map with routing support +app.MapMcpWithRouting("mcp"); // Instead of app.MapMcp() +``` + +### Route Attribute Usage + +```csharp +[McpServerTool, Description("Admin-only tool")] +[McpServerToolRoute("admin")] // Single route +public static string AdminTool() { ... } + +[McpServerTool, Description("Multi-route tool")] +[McpServerToolRoute("weather", "utilities")] // Multiple routes +public static string UtilityTool() { ... } + +[McpServerTool, Description("Global tool")] +// No [McpServerToolRoute] = available everywhere +public static string GlobalTool() { ... } +``` + +## Use Cases + +This routing feature enables scenarios like: + +- **Multi-agent system coordination**: Different agent types access specialized tool sets (research agents get web search, execution agents get file operations) +- **Context-aware tool separation**: Specialized agents with distinct purposes and capabilities working within the same MCP server +- **Agent workflow orchestration**: Route-specific tools for different phases of multi-step agent workflows +- **Specialized agent environments**: Domain-specific agents (coding, research, planning) each with their appropriate toolset +- **Agent collaboration patterns**: Enabling agent-to-agent handoffs with context-appropriate tools at each stage + +## Key Files + +- `Program.cs`: Server configuration with routing enabled +- `Tools/AdminTool.cs`: Administrative tools (admin route only) +- `Tools/EchoTool.cs`: Basic echo tools with route filtering +- `Tools/MathTool.cs`: Mathematical calculation tools +- `Tools/SampleLlmTool.cs`: Global tool (no route restriction) +- `Tools/WeatherTool.cs`: Weather-related tools \ No newline at end of file diff --git a/samples/UrlRoutingSseServer/Tools/AdminTool.cs b/samples/UrlRoutingSseServer/Tools/AdminTool.cs new file mode 100644 index 00000000..f53cfbc6 --- /dev/null +++ b/samples/UrlRoutingSseServer/Tools/AdminTool.cs @@ -0,0 +1,22 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace UrlRoutingSseServer.Tools; + +[McpServerToolType] +public sealed class AdminTool +{ + [McpServerTool, Description("Gets system status information - admin only.")] + [McpServerToolRoute("admin")] + public static string GetSystemStatus() + { + return $"System Status: Running | Uptime: {Environment.TickCount64 / 1000}s | Memory: {GC.GetTotalMemory(false) / 1024 / 1024}MB"; + } + + [McpServerTool, Description("Restarts a service - admin only.")] + [McpServerToolRoute("admin")] + public static string RestartService([Description("Name of the service to restart")] string serviceName) + { + return $"Service '{serviceName}' restart initiated (simulated)"; + } +} \ No newline at end of file diff --git a/samples/UrlRoutingSseServer/Tools/EchoTool.cs b/samples/UrlRoutingSseServer/Tools/EchoTool.cs new file mode 100644 index 00000000..2aeaf2a0 --- /dev/null +++ b/samples/UrlRoutingSseServer/Tools/EchoTool.cs @@ -0,0 +1,25 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace UrlRoutingSseServer.Tools; + +[McpServerToolType] +public sealed class EchoTool +{ + [McpServerTool, Description("Echoes the input back to the client.")] + [McpServerToolRoute("echo")] + public static string Echo(string message) + { + return "hello " + message; + } + + [McpServerTool(Name = "echo_advanced"), Description("Advanced echo with formatting options.")] + [McpServerToolRoute("echo", "utilities")] + public static string EchoAdvanced( + [Description("The message to echo")] string message, + [Description("Whether to convert to uppercase")] bool uppercase = false) + { + var result = $"Advanced echo: {message}"; + return uppercase ? result.ToUpper() : result; + } +} diff --git a/samples/UrlRoutingSseServer/Tools/MathTool.cs b/samples/UrlRoutingSseServer/Tools/MathTool.cs new file mode 100644 index 00000000..c01fc381 --- /dev/null +++ b/samples/UrlRoutingSseServer/Tools/MathTool.cs @@ -0,0 +1,32 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace UrlRoutingSseServer.Tools; + +[McpServerToolType] +public sealed class MathTool +{ + [McpServerTool, Description("Adds two numbers together.")] + [McpServerToolRoute("math", "utilities")] + public static int Add( + [Description("First number")] int a, + [Description("Second number")] int b) + { + return a + b; + } + + [McpServerTool, Description("Calculates factorial of a number.")] + [McpServerToolRoute("math")] + public static long Factorial([Description("Number to calculate factorial for")] int n) + { + if (n < 0) return -1; + if (n == 0 || n == 1) return 1; + + long result = 1; + for (int i = 2; i <= n; i++) + { + result *= i; + } + return result; + } +} diff --git a/samples/UrlRoutingSseServer/Tools/SampleLlmTool.cs b/samples/UrlRoutingSseServer/Tools/SampleLlmTool.cs new file mode 100644 index 00000000..8a75e9f1 --- /dev/null +++ b/samples/UrlRoutingSseServer/Tools/SampleLlmTool.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace UrlRoutingSseServer.Tools; + +/// +/// This tool uses dependency injection and async method +/// +[McpServerToolType] +public sealed class SampleLlmTool +{ + [McpServerTool(Name = "sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")] + public static async Task SampleLLM( + IMcpServer thisServer, + [Description("The prompt to send to the LLM")] string prompt, + [Description("Maximum number of tokens to generate")] int maxTokens, + CancellationToken cancellationToken) + { + ChatMessage[] messages = + [ + new(ChatRole.System, "You are a helpful test server."), + new(ChatRole.User, prompt), + ]; + + ChatOptions options = new() + { + MaxOutputTokens = maxTokens, + Temperature = 0.7f, + }; + + var samplingResponse = await thisServer.AsSamplingChatClient().GetResponseAsync(messages, options, cancellationToken); + + return $"LLM sampling result: {samplingResponse}"; + } +} diff --git a/samples/UrlRoutingSseServer/Tools/WeatherTool.cs b/samples/UrlRoutingSseServer/Tools/WeatherTool.cs new file mode 100644 index 00000000..60d191b0 --- /dev/null +++ b/samples/UrlRoutingSseServer/Tools/WeatherTool.cs @@ -0,0 +1,26 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace UrlRoutingSseServer.Tools; + +[McpServerToolType] +public sealed class WeatherTool +{ + [McpServerTool, Description("Gets current weather for a location.")] + [McpServerToolRoute("weather", "utilities")] + public static string GetWeather([Description("City name")] string city) + { + var temps = new[] { 72, 68, 75, 80, 77 }; + var conditions = new[] { "Sunny", "Cloudy", "Rainy", "Partly Cloudy", "Clear" }; + var random = new Random(city.GetHashCode()); // Deterministic based on city + + return $"Weather in {city}: {temps[random.Next(temps.Length)]}°F, {conditions[random.Next(conditions.Length)]}"; + } + + [McpServerTool, Description("Gets 5-day weather forecast.")] + [McpServerToolRoute("weather")] + public static string GetForecast([Description("City name")] string city) + { + return $"5-day forecast for {city}: Mon 75°F, Tue 73°F, Wed 78°F, Thu 72°F, Fri 76°F"; + } +} diff --git a/samples/UrlRoutingSseServer/UrlRoutingSseServer.csproj b/samples/UrlRoutingSseServer/UrlRoutingSseServer.csproj new file mode 100644 index 00000000..960d9721 --- /dev/null +++ b/samples/UrlRoutingSseServer/UrlRoutingSseServer.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/samples/UrlRoutingSseServer/appsettings.Development.json b/samples/UrlRoutingSseServer/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/UrlRoutingSseServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/UrlRoutingSseServer/appsettings.json b/samples/UrlRoutingSseServer/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/UrlRoutingSseServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/ModelContextProtocol.AspNetCore/McpRouteAwarenessExtension.cs b/src/ModelContextProtocol.AspNetCore/McpRouteAwarenessExtension.cs new file mode 100644 index 00000000..e6ff2489 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/McpRouteAwarenessExtension.cs @@ -0,0 +1,228 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Provides extension methods for configuring MCP servers with route-aware tool filtering capabilities. +/// +/// +/// +/// This class extends the standard MCP configuration to support route-based tool filtering, +/// allowing different tool sets to be exposed at different URL paths based on the +/// applied to tool methods. +/// +/// +/// Route-aware filtering enables scenarios where different contexts require different +/// tool capabilities, such as admin-only routes, read-only endpoints, or domain-specific +/// tool collections. +/// +/// +public static class McpRouteAwarenessExtension +{ + /// + /// Configures the MCP server with HTTP transport and route-aware tool filtering capabilities. + /// + /// The MCP server builder to configure. + /// Optional action to configure additional HTTP transport options. + /// The configured for method chaining. + /// + /// + /// This method enhances the standard HTTP transport with route-aware session configuration + /// that filters available tools based on the requested URL path. Tools without route + /// attributes are considered global and available on all routes. + /// + /// + /// The filtering is applied per-session, ensuring that each client connection only + /// sees tools appropriate for the requested route. + /// + /// + /// Thrown when is null. + public static IMcpServerBuilder WithHttpTransportAndRouting(this IMcpServerBuilder builder, Action? configureOptions = null) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + // Configure HTTP transport with route-aware session options + builder.WithHttpTransport(options => + { + // Set up the route-aware session configuration + options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => + { + var routeService = httpContext.RequestServices.GetService(); + var requestPath = NormalizePath(httpContext.Request.Path.Value); + + // If no route service or requesting the global route, serve all tools + if (routeService?.GlobalRoute is null || string.Equals(requestPath, routeService.GlobalRoute, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var toolCollection = mcpOptions.Capabilities?.Tools?.ToolCollection; + if (toolCollection is null) + { + return; + } + + // Create a snapshot of current tools before filtering + var allTools = toolCollection.ToList(); + toolCollection.Clear(); + + // Filter tools based on the requested route + foreach (var tool in allTools) + { + if (ShouldIncludeTool(tool, requestPath, routeService)) + { + toolCollection.Add(tool); + } + } + + // Note: Resources and prompts are not filtered - they remain available on all routes + }; + + // Apply any additional configuration + configureOptions?.Invoke(options); + }); + + // Register the route service as a singleton + builder.Services.AddSingleton(); + + return builder; + } + + /// + /// Maps MCP endpoints with route-aware capabilities, creating endpoints for both the global route and all discovered tool routes. + /// + /// The endpoint route builder to configure. + /// The route pattern prefix for the global MCP endpoint. + /// An for configuring additional endpoint conventions. + /// + /// + /// This method discovers all tool routes from registered instances + /// and creates MCP endpoints for each unique route. Tools without route attributes are + /// available on the global route and all specific routes. + /// + /// + /// For example, with a global pattern of "mcp" and tools having routes "admin" and "readonly": + /// + /// /mcp - serves all tools (global route) + /// /mcp/admin - serves admin-specific tools + global tools + /// /mcp/readonly - serves readonly-specific tools + global tools + /// + /// + /// + /// Thrown when is null. + public static IEndpointConventionBuilder MapMcpWithRouting(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + { + if (endpoints is null) + throw new ArgumentNullException(nameof(endpoints)); + + var tools = endpoints.ServiceProvider.GetServices(); + var routeService = endpoints.ServiceProvider.GetService(); + + // Fallback to standard mapping if route service is not available + if (routeService is null) + { + return endpoints.MapMcp(pattern); + } + + // Normalize and register the global route + var normalizedGlobalRoute = NormalizePath(pattern); + routeService.RegisterGlobalRoute(normalizedGlobalRoute); + + // Discover and register all tool-specific routes + foreach (var tool in tools) + { + if (tool.Routes?.Count > 0) + { + var toolRoutes = tool.Routes + .Select(route => NormalizePath($"{normalizedGlobalRoute}/{route}")) + .ToArray(); + + routeService.RegisterOtherRoutes(toolRoutes); + } + } + + // Map MCP endpoints for all discovered routes + foreach (var discoveredRoute in routeService.OtherRoutes) + { + endpoints.MapMcp(discoveredRoute); + } + + // Map the global route and return its convention builder + return endpoints.MapMcp(normalizedGlobalRoute); + } + + /// + /// Determines whether a tool should be included in the filtered tool collection for the specified route. + /// + /// The tool to evaluate for inclusion. + /// The requested URL path (normalized). + /// The route service containing route information. + /// if the tool should be included; otherwise, . + /// + /// A tool is included if: + /// + /// It has no route attributes (global tool), OR + /// One of its routes matches the requested path + /// + /// + private static bool ShouldIncludeTool(McpServerTool tool, string requestPath, RouteAwareToolService routeService) + { + // Global tools (no routes) are always included + if (tool.Routes is null || tool.Routes.Count == 0) + { + return true; + } + + // Check if any of the tool's routes match the requested path + return tool.Routes.Any(route => + { + var fullToolPath = NormalizePath($"{routeService.GlobalRoute}/{route}"); + return string.Equals(fullToolPath, requestPath, StringComparison.OrdinalIgnoreCase); + }); + } + + /// + /// Normalizes a path to ensure consistent formatting for route comparison. + /// + /// The path to normalize. + /// A normalized path with consistent formatting. + /// + /// This method applies the same normalization rules as + /// to ensure consistent path handling throughout the routing system. + /// + private static string NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + // Trim whitespace + path = path.Trim(); + + // Ensure it starts with a slash + if (!path.StartsWith('/')) + { + path = "/" + path; + } + + // Remove trailing slashes (except for root "/") + if (path.Length > 1 && path.EndsWith('/')) + { + path = path.TrimEnd('/'); + } + + // Handle multiple consecutive slashes + while (path.Contains("//")) + { + path = path.Replace("//", "/"); + } + + return path; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.AspNetCore/RouteAwareToolService.cs b/src/ModelContextProtocol.AspNetCore/RouteAwareToolService.cs new file mode 100644 index 00000000..64da5179 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/RouteAwareToolService.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Service that handles route-aware tool filtering and automatic route discovery. +/// +/// +/// +/// This service maintains a registry of all available routes in the MCP server, +/// including the global route that serves all tools and specific routes that +/// serve filtered tool sets based on the . +/// +/// +/// Routes are normalized to ensure consistent formatting and case-insensitive +/// comparison for reliable matching during request processing. +/// +/// +public class RouteAwareToolService +{ + private readonly HashSet _otherRoutes = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the base route for the MCP server. + /// + /// + /// The global route serves all available tools regardless of their route attributes. + /// Multiple calls to will overwrite the previous value. + /// + public string? GlobalRoute { get; private set; } + + /// + /// Gets all discovered routes from tool attributes. + /// + /// + /// This collection contains all routes discovered from + /// applied to tool methods, normalized for consistent comparison. + /// + public IReadOnlySet OtherRoutes => _otherRoutes; + + /// + /// Registers routes with their associated tools. + /// + /// Array of route paths to register. Null values are ignored. + /// + /// Routes are automatically normalized to ensure consistent formatting. + /// Duplicate routes are ignored due to the underlying implementation. + /// + public void RegisterOtherRoutes(string[]? otherRoutes) + { + if (otherRoutes == null) + return; + + foreach (string route in otherRoutes) + { + var normalizedRoute = NormalizePath(route); + _otherRoutes.Add(normalizedRoute); + } + } + + /// + /// Registers the global route that serves all tools. + /// + /// The global route path. + /// + /// Multiple calls to this method will overwrite the previous global route value, + /// following Microsoft's convention of "last registration wins". + /// + /// Thrown when is null or whitespace. + public void RegisterGlobalRoute(string globalRoute) + { + if (string.IsNullOrWhiteSpace(globalRoute)) + throw new ArgumentException("Global route cannot be null or empty.", nameof(globalRoute)); + + GlobalRoute = NormalizePath(globalRoute); + } + + /// + /// Normalizes a path to ensure it follows consistent formatting rules. + /// + /// The path to normalize. + /// A normalized path with consistent formatting. + /// + /// Normalization rules: + /// + /// Null or whitespace paths become "/" + /// Ensures paths start with "/" + /// Removes trailing slashes except for root "/" + /// Collapses multiple consecutive slashes to single slash + /// + /// + private static string NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + // Trim whitespace + path = path.Trim(); + + // Ensure it starts with a slash + if (!path.StartsWith('/')) + { + path = "/" + path; + } + + // Remove trailing slashes (except for root "/") + if (path.Length > 1 && path.EndsWith('/')) + { + path = path.TrimEnd('/'); + } + + // Handle multiple consecutive slashes + while (path.Contains("//")) + { + path = path.Replace("//", "/"); + } + + return path; + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index afd3912b..7ce11306 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -146,7 +146,7 @@ options.OpenWorld is not null || } } - return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping); + return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Routes); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -186,6 +186,11 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Description ??= descAttr.Description; } + if (method.GetCustomAttribute() is { } routeAttr) + { + newOptions.Routes ??= routeAttr.Routes; + } + return newOptions; } @@ -193,12 +198,13 @@ 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, IReadOnlyList? routes = null) { AIFunction = function; ProtocolTool = tool; _logger = serviceProvider?.GetService()?.CreateLogger() ?? (ILogger)NullLogger.Instance; _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; + Routes = routes; } /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index e3958271..21d645e0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -141,6 +141,21 @@ protected McpServerTool() /// Gets the protocol type for this instance. public abstract Tool ProtocolTool { get; } + /// + /// Gets the HTTP routes where this tool should be available. + /// + /// + /// + /// If , the tool is available on all routes (global tool). + /// Global tools are always accessible regardless of the specific route requested. + /// + /// + /// This property is used in AspNetCore scenarios to control which HTTP endpoints + /// expose this tool. It is ignored in stdio/console scenarios. + /// + /// + public IReadOnlyList? Routes { get; protected set; } + /// Invokes the . /// The request information resulting in the invocation of this tool. /// The to monitor for cancellation requests. The default is . diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index bdb4ecb8..86ea9988 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -64,6 +64,22 @@ public sealed class McpServerToolCreateOptions /// public string? Title { get; set; } + /// + /// Gets or sets the HTTP routes where this tool should be available. + /// + /// + /// + /// Routes are used in AspNetCore scenarios to control which HTTP endpoints expose this tool. + /// If , the tool will be available on all routes (global tool). + /// This setting is ignored in stdio/console scenarios. + /// + /// + /// Routes are typically populated from during tool creation, + /// but can be set programmatically when creating tools without attributes. + /// + /// + public IReadOnlyList? Routes { get; set; } + /// /// Gets or sets whether the tool may perform destructive updates to its environment. /// @@ -172,5 +188,6 @@ internal McpServerToolCreateOptions Clone() => UseStructuredContent = UseStructuredContent, SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, + Routes = Routes }; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolRouteAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolRouteAttribute.cs new file mode 100644 index 00000000..2be96324 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpServerToolRouteAttribute.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; + +namespace ModelContextProtocol.Server; + +/// +/// Specifies which HTTP route(s) this MCP tool should be available on. +/// This attribute is only used in AspNetCore scenarios and is ignored in stdio/console scenarios. +/// +/// +/// +/// This attribute works alongside the to provide +/// HTTP routing capabilities. Tool methods can have both routing and core tool attributes +/// to control where they are accessible via HTTP endpoints. +/// +/// +/// Tools without this attribute are considered global and available on all routes. +/// Multiple routes can be specified in a single attribute or by applying the attribute multiple times. +/// +/// +/// Example usage: +/// +/// [McpServerTool, Description("Echoes the input back to the client.")] +/// [McpServerToolRoute("echo")] +/// public static string Echo(string message) { ... } +/// +/// // Tool available on multiple routes +/// [McpServerTool, Description("Gets weather data")] +/// [McpServerToolRoute("weather", "utilities")] +/// public static string GetWeather(string location) { ... } +/// +/// // Global tool (available everywhere) +/// [McpServerTool, Description("System diagnostics")] +/// public static string GetDiagnostics() { ... } +/// +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class McpServerToolRouteAttribute : Attribute +{ + /// + /// Gets the route names this tool should be available on. + /// + /// + /// Route names are case-insensitive and will be matched against HTTP route segments. + /// For example, a route name "echo" will be accessible at /mcp/echo when the global + /// route is configured as "/mcp". + /// + public IReadOnlyList Routes { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The route names this tool should be available on. + /// Thrown when no routes are specified or any route is null/empty. + public McpServerToolRouteAttribute(params string[] routes) + { + if (routes == null || routes.Length == 0) + throw new ArgumentException("At least one route must be specified", nameof(routes)); + + if (routes.Any(route => string.IsNullOrWhiteSpace(route))) + throw new ArgumentException("Route names cannot be null or empty", nameof(routes)); + + Routes = routes.Select(route => route.Trim()).ToArray(); + } +} \ No newline at end of file diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpRoutingTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpRoutingTests.cs new file mode 100644 index 00000000..c55f4586 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpRoutingTests.cs @@ -0,0 +1,241 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class MapMcpRoutingTests(ITestOutputHelper outputHelper) : MapMcpTests(outputHelper) +{ + protected override bool UseStreamableHttp => true; + protected override bool Stateless => false; + + [Fact] + public async Task WithHttpTransportAndRouting_ThrowsInvalidOperationException_IfWithHttpTransportIsNotCalled() + { + Builder.Services.AddMcpServer(); + await using var app = Builder.Build(); + var exception = Assert.Throws(() => app.MapMcpWithRouting()); + Assert.Contains("WithHttpTransport", exception.Message); + } + + [Theory] + [InlineData("/mcp", 4)] // Global route - all tools (global + admin + weather + multi_route) + [InlineData("/mcp/admin", 2)] // Admin route - admin tools + global tools + [InlineData("/mcp/weather", 3)] // Weather route - weather + multi_route + global tools + [InlineData("/mcp/math", 2)] // Math route - multi_route + global tools + public async Task Route_Filtering_ReturnsCorrectToolCount(string route, int expectedToolCount) + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(route); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(expectedToolCount, tools.Count); + } + + [Fact] + public async Task GlobalRoute_ReturnsAllTools() + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync("/mcp"); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var toolNames = tools.Select(t => t.Name).ToHashSet(); + Assert.Contains("global_tool", toolNames); + Assert.Contains("admin_tool", toolNames); + Assert.Contains("weather_tool", toolNames); + } + + [Fact] + public async Task AdminRoute_OnlyReturnsAdminAndGlobalTools() + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync("/mcp/admin"); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var toolNames = tools.Select(t => t.Name).ToHashSet(); + Assert.Contains("global_tool", toolNames); + Assert.Contains("admin_tool", toolNames); + Assert.DoesNotContain("weather_tool", toolNames); + } + + [Fact] + public async Task WeatherRoute_OnlyReturnsWeatherAndGlobalTools() + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync("/mcp/weather"); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var toolNames = tools.Select(t => t.Name).ToHashSet(); + Assert.Contains("global_tool", toolNames); + Assert.Contains("weather_tool", toolNames); + Assert.DoesNotContain("admin_tool", toolNames); + } + + [Theory] + [InlineData("/mcp/")] + [InlineData("/mcp")] + public async Task TrailingSlash_HandledCorrectly_ForGlobalRoute(string route) + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(route); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Should return all tools (global route behavior) + Assert.Equal(4, tools.Count); + } + + [Theory] + [InlineData("/mcp/admin")] + [InlineData("/mcp/admin/")] + public async Task TrailingSlash_HandledCorrectly_ForSpecificRoute(string route) + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(route); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Should return admin + global tools only + Assert.Equal(2, tools.Count); + var toolNames = tools.Select(t => t.Name).ToHashSet(); + Assert.Contains("admin_tool", toolNames); + Assert.Contains("global_tool", toolNames); + } + + [Fact] + public async Task MultiRouteTools_AppearOnAllSpecifiedRoutes() + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Check math route + await using var mathClient = await ConnectAsync("/mcp/math"); + var mathTools = await mathClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var mathToolNames = mathTools.Select(t => t.Name).ToHashSet(); + Assert.Contains("multi_route_tool", mathToolNames); + + // Check weather route + await using var weatherClient = await ConnectAsync("/mcp/weather"); + var weatherTools = await weatherClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var weatherToolNames = weatherTools.Select(t => t.Name).ToHashSet(); + Assert.Contains("multi_route_tool", weatherToolNames); + } + + [Fact] + public async Task ToolInvocation_WorksOnCorrectRoute() + { + Builder.Services.AddMcpServer() + .WithHttpTransportAndRouting() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcpWithRouting("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync("/mcp/admin"); + var result = await mcpClient.CallToolAsync( + "admin_tool", + new Dictionary(), + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(result.Content.OfType()); + Assert.Equal("Admin tool executed", content.Text); + } + + [Fact] + public async Task BackwardCompatibility_StandardMapMcp_StillWorks() + { + Builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcp("mcp"); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync("/mcp"); + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Should return all tools when not using routing + Assert.Equal(4, tools.Count); // All tools including multi-route + } +} + +[McpServerToolType] +public sealed class RoutingTestTools +{ + [McpServerTool(Name = "global_tool"), Description("Global tool available on all routes")] + public static string GlobalTool() + { + return "Global tool executed"; + } + + [McpServerTool(Name = "admin_tool"), Description("Admin-only tool")] + [McpServerToolRoute("admin")] + public static string AdminTool() + { + return "Admin tool executed"; + } + + [McpServerTool(Name = "weather_tool"), Description("Weather-specific tool")] + [McpServerToolRoute("weather")] + public static string WeatherTool() + { + return "Weather tool executed"; + } + + [McpServerTool(Name = "multi_route_tool"), Description("Tool available on multiple routes")] + [McpServerToolRoute("math", "weather")] + public static string MultiRouteTool() + { + return "Multi-route tool executed"; + } +} \ No newline at end of file