DEV Community

Matt Anderson
Matt Anderson

Posted on

ASP.NET Core Devs: The MCP Spec Has Four Primitives. You've Only Been Using One.

When I shipped the first public version of ZeroMCP, it did one thing: expose controller actions as MCP tools via a single attribute and two lines of setup. That was enough to prove the model worked — in-process dispatch through your real ASP.NET Core pipeline, no second service, no duplicated logic.

1.5 fills out the rest of the MCP specification surface. Here's what's new.


Resources and Resource Templates

MCP distinguishes between tools (things clients can invoke) and resources (data clients can read at well-known URIs). ZeroMCP now supports both.

[HttpGet("system/status")]
[McpResource("system://status", "system_status",
    Description = "Current system status.",
    MimeType = "application/json")]
public IActionResult GetSystemStatus() => Ok(new { status = "ok" });
Enter fullscreen mode Exit fullscreen mode

Resource templates extend this to parameterised URIs using RFC 6570 patterns:

[HttpGet("products/{id}")]
[McpTemplate("catalog://products/{id}", "product_resource",
    Description = "Returns a product by ID.",
    MimeType = "application/json")]
public IActionResult GetProduct(int id) => Ok(Store.Find(id));
Enter fullscreen mode Exit fullscreen mode

Clients discover resources via resources/list and templates via resources/templates/list, then fetch content with resources/read. The dispatch model is identical to tools — the same synthetic HttpContext, the same pipeline, the same DI scope.

Both controller attributes and minimal API equivalents are supported:

app.MapGet("/api/status", () => Results.Ok(new { status = "ok" }))
   .AsResource("system://status", "system_status", "Current system status.");
Enter fullscreen mode Exit fullscreen mode

Prompts

Prompts are reusable, parameterised prompt templates. Clients call prompts/list to discover them, then prompts/get with arguments to render the result.

[HttpGet("search")]
[McpPrompt("search_products",
    Description = "Search products by keyword and optional category.")]
public IActionResult SearchProducts([Required] string keyword, string? category = null)
    => Ok($"Search for '{keyword}' in category '{category ?? "all"}'.");
Enter fullscreen mode Exit fullscreen mode

This is useful when you want to give LLM clients a structured way to construct prompts from your domain data, rather than hardcoding prompt text in the client.


Streaming via IAsyncEnumerable

Controller actions that return IAsyncEnumerable<T> are now automatically detected as streaming tools at registration time.

[HttpGet("stream")]
[Mcp("stream_orders", Description = "Streams all orders.")]
public async IAsyncEnumerable<Order> StreamOrders(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    foreach (var order in Store)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(250, ct);
        yield return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Over Streamable HTTP, each item is sent as a Server-Sent Event. Over stdio, each chunk is a separate JSON-RPC response line. Streaming tools appear with "streaming": true in tools/list. A MaxStreamingItems safety cap (default 10,000) prevents runaway enumeration.


Notifications and Subscriptions

Two new opt-in capabilities handle the MCP notification model.

EnableListChangedNotifications causes ZeroMCP to advertise list change support during handshake and push notifications/tools/list_changed, notifications/resources/list_changed, and notifications/prompts/list_changed to connected SSE clients when you call the corresponding notify methods.

EnableResourceSubscriptions lets clients subscribe to specific resource URIs and receive targeted notifications/resources/updated events when content changes. The trigger side is yours to implement — call NotifyResourceUpdatedAsync(uri) from a controller, background service, or message handler:

await _notificationService.NotifyResourceUpdatedAsync("orders://order/42");
Enter fullscreen mode Exit fullscreen mode

Tool Versioning

Tools can now be versioned, which lets you expose multiple versions of the same tool surface at distinct MCP endpoints.

[Mcp("get_order", Description = "v2 of get_order.", Version = 2)]
Enter fullscreen mode Exit fullscreen mode

Without versioning, only /mcp is registered. With versioning, you get /mcp/v1, /mcp/v2, etc., with the unversioned /mcp resolving to the highest version. The Inspector UI gains a version selector and version badges when multiple versions exist.

This is primarily aimed at teams running phased client migrations where you can't update all consumers simultaneously.


Inspector UI

The Inspector was introduced in an earlier release as a JSON endpoint (GET /mcp/tools). 1.5 adds a browser-based invocation UI at GET /mcp/ui — think Swagger UI, but for your MCP tool surface.

From the UI you can browse tools, view their JSON Schema inputs, and invoke tools/call with editable arguments directly in the browser. Streaming tools render results progressively and show a badge indicating their type.

Both endpoints are enabled by default and should be disabled in production:

options.EnableToolInspector = builder.Environment.IsDevelopment();
options.EnableToolInspectorUI = builder.Environment.IsDevelopment();
Enter fullscreen mode Exit fullscreen mode

stdio Transport

The HTTP transport is still the primary path, but 1.5 ships first-class stdio support for local and desktop MCP clients that spawn your process directly:

if (args.Contains("--mcp-stdio"))
{
    await app.RunMcpStdioAsync();
    return;
}
app.Run();
Enter fullscreen mode Exit fullscreen mode

The Claude Desktop configuration looks like this:

{
  "mcpServers": {
    "my-api": {
      "command": "dotnet",
      "args": ["run", "--project", "MyApi", "--", "--mcp-stdio"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Everything else — discovery, dispatch, auth forwarding, governance — works identically across transports.


What Hasn't Changed

The core model is unchanged. [Mcp] still goes on individual controller actions or minimal API endpoints. Dispatch still creates a fresh DI scope and a synthetic HttpContext routed through your real pipeline. Auth, validation, and filters still run as normal. The upgrade path from earlier versions is additive — existing tool configurations require no changes.


Get It

<PackageReference Include="ZeroMCP" Version="1.*" />
Enter fullscreen mode Exit fullscreen mode

Repository, wiki, and examples: github.com/ZeroMcp/ZeroMCP.net

Top comments (0)