Authentication & security

There are two independent layers, and both are enforced: the MCP endpoint itself, and every individual tool call replayed through your pipeline.

The two layers

The MCP endpoint. RequireAuthorization = true makes /mcp itself require an authenticated caller, optionally against a named policy (AuthorizationPolicy) and specific schemes (AuthenticationSchemes). Unauthenticated callers get a challenge; authenticated ones without the policy get a forbid.

Each tool call. Because the synthetic request traverses the real pipeline, the target action's own [Authorize], policies, roles, claims and custom filters run untouched. Nabu does not interpret, cache or shortcut them.

How identity reaches the action

Identity reaches the action two ways, which reinforce each other:

  1. Credentials-bearing headers (Authorization, Cookie, tracing headers, and anything you add to ForwardedHeaders / ForwardedHeaderPrefixes) are copied onto the synthetic request, so your authentication middleware re-authenticates it normally.
  2. The ClaimsPrincipal established for the MCP request is seeded onto the synthetic context, so schemes whose credentials cannot be replayed from headers alone still work. Authentication middleware overwrites it whenever the forwarded credentials authenticate successfully. Disable with PropagateUser = false.

Hop-by-hop and content headers (Content-Length, Transfer-Encoding, Accept-Encoding, Host, ...) are never forwarded; they are rebuilt for the synthetic request.

Protected headers

A model-supplied argument can never override the headers that carry credentials or proxy metadata. Even with ExposeHeaderParameters on, a tool argument that binds to a header in ProtectedHeaders — by default Authorization, Cookie, Host, X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host — is dropped with a warning, and the value forwarded from the MCP caller stays in place. Constants pinned by the developer through ConstantParameters are exempt, because they are developer input rather than model input. Removing a name from the set is the explicit opt-in:

options.ProtectedHeaders.Remove("Authorization");   // only if you really mean it

Recommended production configuration

The defaults are tuned for a first run: the MCP endpoint is anonymous and every discovered tool is advertised, while each invocation is still authorized by the application pipeline. That is never an authorization bypass — a caller can at most see the names and schemas of tools it cannot invoke — but for production deployments, lock the endpoint down and tailor the catalogue to the caller:

options.RequireAuthorization = true;                    // the endpoint itself challenges anonymous callers
options.ToolVisibility = McpToolVisibility.Authorized;  // advertise only what the caller may actually invoke

Advertising tools per caller

By default every discovered tool is advertised to everyone, and a caller that invokes one it is not allowed to use gets the action's own 401 or 403 back as a tool error. That is safe, but it hands the model a menu it cannot order from — and a client added before anyone has signed in sees the whole catalogue.

ToolVisibility tailors tools/list to whoever is asking:

Valuetools/list contains
All (default)Every discovered tool, whoever is asking.
AuthenticatedTools whose actions need authorization, only once the caller is authenticated.
AuthorizedTools whose actions need authorization, only once the caller satisfies their policies.

Nabu reads the requirement during discovery, from the [Authorize] and [AllowAnonymous] metadata of the action, its controller and the filter collection, and resolves it the way AuthorizationMiddleware would for a real request:

The resolved policy is then evaluated against the caller with the application's own IAuthorizationPolicyProvider and IAuthorizationService. An unauthenticated caller is therefore shown only the tools that need no authorization; the rest appear when it lists the tools again with credentials.

Nothing MCP-specific is involved: the attributes that already secure the API are the ones that decide, so an action opts out of its controller's [Authorize] the same way it always has.

[ApiController]
[Route("api/todos")]
[Authorize]                                 // the controller is protected...
public class TodosController : ControllerBase
{
    /// <summary>Lists the priority levels a todo item can be given.</summary>
    [HttpGet("priorities")]
    [AllowAnonymous]                        // ...and this one action is not
    [McpTool]
    public ActionResult<IEnumerable<string>> GetPriorities() => ...
}

todos_get_priorities is advertised to, and callable by, a caller holding no token; every other todo tool waits until it signs in.

This decides what is advertised, never what is allowed. A hidden tool that gets called anyway is still replayed through the pipeline and still refused by the action, so filtering can never be the only thing standing between a caller and an endpoint. When the requirement cannot be worked out — a custom filter Nabu cannot see, a missing authorization service — the tool stays visible, because a spurious 403 is a better failure than a capability that silently disappeared.

When authorization depends on something no attribute expresses, take the decision over entirely:

services.AddSingleton<IMcpToolAuthorizationEvaluator, MyEvaluator>();

Adding a client before it has credentials

RequireAuthorization = true makes /mcp itself reject anonymous callers, which is what triggers the OAuth flow in MCP clients that support one — but it also means a client cannot so much as initialize until credentials exist. AnonymousAccess opens a narrow door in that gate:

ValueAn unauthorized caller may
None (default)Nothing. Every request is challenged.
Discoveryinitialize, ping and the listing methods. tools/call is still challenged.
AnonymousToolsThe above, plus tools/call for tools whose actions need no authorization.
options.RequireAuthorization = true;
options.AnonymousAccess = McpAnonymousAccess.AnonymousTools;
options.ToolVisibility = McpToolVisibility.Authorized;

Now a client added without credentials connects, lists the public tools and can use them; everything else is a 401, which is exactly the signal a client needs to start authenticating; and once it does, the next tools/list returns the full set it is entitled to. Pair the two options as above — AnonymousAccess on its own would advertise tools the anonymous caller cannot call.

The door is only open to callers that hold no credentials. One that presents a token which is rejected is challenged as before, so an expired or malformed token is never quietly downgraded to the anonymous tool list.

If the application sets AuthorizationOptions.FallbackPolicy, mount UseNabuMcp() before UseAuthorization(). A fallback policy applies to every request that matches no endpoint, and the MCP endpoint is middleware rather than an endpoint, so authorization would otherwise challenge it before Nabu saw it — AnonymousAccess included. Tool calls are unaffected either way: they traverse the whole pipeline and meet the fallback policy at the action.

Because the server is stateless it cannot push notifications/tools/list_changed, so a client that caches the tool list should re-list after authenticating.

Because the MCP endpoint hands one authenticated caller the ability to invoke every published action, publish deliberately. [McpTool] is opt-in for exactly this reason, and [McpIgnore] lets you keep an action reachable over HTTP while hiding it from MCP.