Skip to content
Kumar Chandrachooda
.NET

Your Endpoints Are Now Tools

FastEndpoints.Mcp exposes opt-in endpoints as Model Context Protocol tools - schemas generated from your DTOs and enriched from your validators, a synthetic HttpContext that runs the real pipeline, and a visibility model that deliberately ignores your REST auth. Part 11 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 02 Jul 2026 5 min read
An endpoint wearing a tool badge - the same pipeline, a new kind of caller

The newest additions to the FastEndpoints repository assume something no part of the parent series imagined: that the caller of your API might be a language model. Src/Agents holds two young packages (1.0.0-beta.3, versioned apart from the core library) - FastEndpoints.Mcp, which exposes endpoints as Model Context Protocol tools, and FastEndpoints.A2A, which exposes them as agent-to-agent skills (next week's closer). This part reads the MCP half, and the shared machinery underneath both - which turns out to be the best guided tour of FastEndpoints' own pipeline that anyone has written, because it had to reimplement calling it.

Opt-in, or nothing

Wiring is two calls straddling the usual pair, with UseMcp required to come after UseFastEndpoints so the endpoint registry exists:

bld.Services.AddFastEndpoints().AddMcp();
var app = bld.Build();
app.UseFastEndpoints().UseMcp("/mcp", route => route.RequireAuthorization());

That maps an MCP server (built on Microsoft's official ModelContextProtocol.AspNetCore SDK) at /mcp - and it exposes precisely nothing. Every tool is opt-in, per endpoint, either in Configure():

public override void Configure()
{
    Get("/inventory/{Sku}");
    this.McpTool("inventory_lookup", "Reads current stock for a SKU.",
        t => { t.Title = "Inventory Lookup"; t.Hints.ReadOnly = true; t.Hints.Idempotent = true; });
}

or as a [McpTool(...)] class attribute. The integration seam is a design decision worth copying: the extension stores its McpToolInfo on the EndpointDefinition's public metadata bag - the core library needed zero modifications to grow an MCP face. The same bag-and-attributes pattern any third party could have used; the maintainers just used it themselves.

Those Hints mirror MCP's tool annotations - ReadOnly, Idempotent, Destructive, OpenWorld, all nullable booleans, because “not stated” and “false” mean different things to an agent deciding whether a tool is safe to retry. The attribute form has a subtle problem there: attribute properties can't be nullable, so how do you tell Destructive = false from never-set? The implementation reads GetCustomAttributesData().NamedArguments - the syntactic record of which properties the attribute author actually wrote - and preserves exactly those. A false you wrote survives; a false you didn't write stays null. Reflection archaeology in service of three-valued logic.

The schema is your DTO plus your validator

An MCP tool needs a JSON Schema for its inputs. The package builds it from the request DTO via .NET's own JsonSchemaExporter, honoring the endpoint's serializer context - if your DTOs serialize snake_case through a source-generated context, the tool schema and the invocation payloads both follow. Then two passes make it a FastEndpoints schema rather than a generic one.

First, subtraction: properties an agent must never supply are removed from the schema and refused at invocation - [HasPermission] booleans, required [FromClaim] props, header/cookie-bound props hidden from docs. Your claims-derived fields don't become prompt-injectable parameters. The schema also sets additionalProperties: false, and unknown argument keys fail as validation errors instead of being dropped - loud rejection over silent tolerance, house style.

Second, addition: a FluentValidationSchemaEnricher reflects over the endpoint's validator - resolved from a fresh DI scope, so scoped validator dependencies work - and projects the expressible subset of rules into the schema: NotEmpty to required, Length to minLength/maxLength, Matches to pattern, comparisons to minimum/maximum, EmailAddress to format: email. Rules it can't express (Must, cross-property predicates) are silently skipped, and the doc comments make the right observation: they still run at request time. The schema is the advertisement; the validation pipeline remains the law. Response DTOs get an output schema too (skippable via IncludeOutputSchemas), minus any [ToHeader] properties - those leave over HTTP headers, which an MCP result doesn't have.

A synthetic HttpContext runs the real pipeline

The shared engine both agent packages compile in (from Src/Agents/Shared - deliberately internal source-sharing, not a third NuGet package, so consumers see only FastEndpoints.Mcp on the wire) is EndpointInvoker, and its job description is audacious: execute an endpoint without HTTP routing while keeping every behaviour of a real request.

It does it by manufacturing the world. An AgentRequestBuilder translates the tool call's flat JSON arguments into a correctly shaped request - route parameters substituted into the template (constraints like {id:int} stripped, catch-alls and optionals handled), query-vs-body decided by verb, headers and cookies placed, nested objects flattened to key.child and key[index] pairs, all against cached per-endpoint specs. A factory then builds a DefaultHttpContext around it - JSON body stream, route values, a synthetic endpoint feature carrying the real EndpointDefinition - opens a DI scope, temporarily swaps the ambient IHttpContextAccessor to the synthetic context (restored in a finally - remember how much resolution flows through that accessor), and runs the standard endpoint execution: binder, validators, processors, handler, disposal.

One subtlety in the result classification deserves its sentence: the invoker reports validation failures even when the endpoint returned 200, because an endpoint using DontThrowIfValidationFails() would otherwise hand the agent a success wrapping errors it cannot see. Machines need the failure channel more explicitly than humans do.

Results map by status: success returns text content plus structured content - which the package first validates against its own advertised output schema, silently omitting the structured form if the response drifted from the contract (the text still flows; the promise is never broken silently). HTTP errors come back as isError payloads with status and body. And unhandled exceptions are logged server-side but cross the wire as a generic "Endpoint invocation failed." - stack traces are for your logs, not for someone else's model.

Two auth systems, one firewall

The decision most likely to surprise: your endpoint's REST authorization is deliberately not reused for tool visibility. Instead McpOptions has a ToolVisibilityFilter - (EndpointDefinition, ClaimsPrincipal, HttpContext) => bool - defaulting to authenticated-callers-only, evaluated both when listing tools and again on every call (a tool that just became invisible fails like one that never existed). The rationale reads clearly from the shape: “this user may call POST /orders” and “this user's agent should see an order-creation tool” are different policies with different owners, and conflating them would let REST conventions accidentally publish tools. You relax it explicitly - o.ToolVisibilityFilter = (_, _, _) => true in a dev harness - or tighten it per tenant.

My favourite small find is the workaround comment: the MCP spec allows / in tool names, but the current SDK rejects slashes at construction - so the package constructs the tool under a sanitized name, then reaches into the protocol object afterward and puts the real name back. Working around your own platform's SDK, politely, in two lines.

The through-line of this chapter: FastEndpoints spent years building a disciplined front door for human-written clients, and the MCP package proves the discipline transfers - schemas from DTOs, contracts from validators, the same pipeline under a different protocol. Next week the final chapter: the A2A package, a JSON-RPC dispatcher written by hand, and the close of both series.