Why Your .NET MCP Tool Classes Crash at Runtime (And the Two-Line Fix)
If you're building MCP servers in .NET with instance-based tool classes - the kind that take ILogger , HttpClient , or a repository through constructor injection - there's a good chance you've already hit this at runtime: System.Reflection.TargetException: Non-static method requires a target. No warning at startup. No compile-time error. Just a crash the moment an AI model calls your tool in production. This post explains why it happens, shows the two-line fix in DotnetFastMCP v2.1, and covers a related improvement that makes AI models call your tools correctly on the first attempt - without asking the user for clarification. The Problem: Two Paper Cuts That Bled Every Day Paper Cut 1: The Runtime Crash Nobody Warned You About The framework lets you organize your MCP tools into classes and scan them with one line: builder.WithComponentsFrom(Assembly.GetExecutingAssembly()); This scans your assembly, finds every method decorated with [McpTool] , and registers it. Clean, simple, plug-and-play. Except - if your tool class had constructor injection, you'd get a silent registration. The class would appear in the tool list. But the moment an AI model called it, the server would throw: System.Reflection.TargetException: Non-static method requires a target. At runtime. In production. With no warning during startup. The cause was straightforward: WithComponentsFrom registered the methods in the tool dictionary, but never registered the declaring class in ASP.NET Core's DI container. When the handler tried to resolve an instance for invocation, there was nothing to resolve. The workaround was to manually add every tool class: // โ v2.0: manual registration required for every non-static tool class builder.Services.AddTransient (); builder.Services.AddTransient (); builder.Services.AddTransient (); // ... one line per class, forever This is exactly the opposite of what "plug-and-play" should mean. Paper Cut 2: AI Models Were Flying Blind on Parameter Names When an AI model calls tools/list , the server returns a JSON Schema for each tool's parameters. Here's what that schema looked like in v2.0: { "name": "search_products", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "limit": { "type": "integer" }, "filter": { "type": "string" } }, "required": ["query"] } } No descriptions. Just types and names. The model had to guess what filter means. Is it a category? A regex? A SQL WHERE clause? The Fix: Two Lines of Setup, Zero Boilerplate Both issues are fixed in v2.1. Install the update: dotnet add package DotnetFastMCP --version 2.1.1 Fix 1: Constructor Injection Just Works Starting in v2.1, WithComponentsFrom automatically registers every non-static tool class in the DI container using TryAddTransient : public class ProductSearchTool { private readonly IProductRepository _repo; private readonly ILogger _logger; // Constructor injection - works automatically in v2.1 public ProductSearchTool(IProductRepository repo, ILogger logger) { _repo = repo; _logger = logger; } [McpTool("search_products", Description = "Searches the product catalog")] public async Task SearchAsync(string query, int limit = 10) { _logger.LogInformation("Searching for '{Query}'", query); var results = await _repo.SearchAsync(query, limit); return JsonSerializer.Serialize(results); } } Your Program.cs stays clean - register only the dependencies, not the tool class: var mcpServer = new FastMCPServer("my-server"); var builder = McpServerBuilder.Create(mcpServer, args); builder.Services.AddScoped (); builder.WithComponentsFrom(Assembly.GetExecutingAssembly()); var app = builder.Build(); await app.RunMcpAsync(args); TryAddTransient means existing registrations (like AddHttpClient ) are preserved - no collisions. Fix 2: [McpDescription] - Tell the AI What Each Parameter Means [McpTool("search_products", Description = "Searches the product catalog")] public async Task SearchAsync( [McpDescription("The search query string, e.g. 'red cotton saree'")] string query, [McpDescription("Maximum number of results to return (1-100)")] int limit = 10, [McpDescription("Filter by category: 'clothing', 'accessories', 'footwear'")] string? category = null) { ... } Now tools/list returns descriptions in the schema. The model knows exactly what to pass without asking the user. Framework-injected parameters (McpContext , CancellationToken , ClaimsPrincipal ) are always excluded automatically. Try It Yourself: Verify in 60 Seconds git clone https://github.com/tekspry/DotnetFastMCP.git cd DotnetFastMCP/examples/BasicServer dotnet run -- --urls http://localhost:5100 Then call the non-static tool: curl -s -X POST http://localhost:5100/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"greet_user","arguments":{"name":"Alice","style":"formal"}}}' | jq . Response: "Good day, Alice. How may I assist you today?" No manual DI registration. No runtime crash. Constructor injection just works. Migration from v2.0 No breaking changes. Update the package version, remove manual AddTransient () calls (now redundant), and add [McpDescription] to your parameters. Resources - ๐ฆ DotnetFastMCP on NuGet - ๐ป GitHub: tekspry/DotnetFastMCP - ๐งช Real-world example: FashionAccessoryPipeline DotnetFastMCP is MIT-licensed and open for contributions. Top comments (0)
Comments
No comments yet. Start the discussion.