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" });
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));
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.");
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"}'.");
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;
}
}
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");
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)]
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();
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();
The Claude Desktop configuration looks like this:
{
"mcpServers": {
"my-api": {
"command": "dotnet",
"args": ["run", "--project", "MyApi", "--", "--mcp-stdio"]
}
}
}
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.*" />
Repository, wiki, and examples: github.com/ZeroMcp/ZeroMCP.net
Top comments (0)