Skip to content

API reference

Terminal window
pnpm add mcp-authz @modelcontextprotocol/server
# optional Node helper
pnpm add @modelcontextprotocol/node

Node.js 24 or newer. CI covers 24 and 26.

Returns (request: Request) => Promise<Response>.

Option Purpose
resourceServerUrl Public URL Claude reaches, e.g. https://mcp.acme.com/mcp
oauthMetadata Your AS’s RFC 8414 fields, or await discoverOAuth(issuer)
verifier Built-in JWT/JWKS verification and claim options
tokenVerifier Custom SDK verifier, including opaque-token introspection
identityFromAuth Map custom AuthInfo to an Identity
requiredScopes Baseline scopes (default ['mcp'])
toolScopes Declarative tool name → scope(s) for safe step-up
capabilityScopes Tools, prompts and resource URIs → scope(s)
supportedScopes Additional scopes advertised in resource metadata
scopesForRequest Advanced (request, trustedRoute) => scopes resolver
policy definePolicy(...); matching no rule is HTTP 403
authorize Async alternative to policy for external entitlements
resolve Optional: (identity, principal) => context to enrich context
onDecision Awaited allow/deny decision sink
createServer (context) => McpServer once per request; see authz
permissions Optional: the boot-time map, when a factory of yours hides it
maxRequestBytes Cap on the post-auth body read that names a capability. 1 MiB
legacy 2025 handling; strict reject by default

legacy needs care. The SDK client 2.0.0 still opens with an initialize handshake, and 2026-07-28 removed it (SEP-2567) — so from this handler’s side every client shipping today is a legacy client, and the default reject turns all of them away. A deployment real clients must reach wants legacy: 'stateless' until a client ships without the handshake. The refusal says so itself rather than returning a bare protocol error. The gate applies identically on both paths; src/e2e.story.test.ts drives a real client through each.

oauthMetadata: await discoverOAuth('https://auth.acme.com'),

Reads the endpoints off the authorization server rather than out of a config file, jwks_uri included, which leaves the verifier block optional. It tries the RFC 8414 location first and the OIDC one second, because Auth0 and Google publish only the latter. A document declaring a different issuer is refused: its endpoints would send your users somewhere else to sign in.

The cost is a fetch during boot. An AS that is down now stops your deploy rather than only your logins, so keep passing oauthMetadata by hand if you would rather own that trade.

The built-in JWT verifier requires email_verified: true before email or domain rules can run. Use verifier.emailVerifiedClaim for a provider-specific assurance claim, or set requireEmailVerified: false only when the issuer contract guarantees the email another way. For opaque tokens, pass any SDK-compatible tokenVerifier plus identityFromAuth.

An agent that signs in as itself, with an OAuth client-credentials token, carries a sub and no email. Set verifier.requireEmail: false to accept it, and name its sub in a rule to grant it roles. Email and domain rules never match it and allowedDomain refuses it, but a rule with no match admits it.

Covered in full under Policies, including matching, deny precedence, wildcard vocabularies and policy.explain.

One call binds everything to one policy. Each builder accepts only a permission that policy can grant, so cases:wrtie is a build error in all three.

const { tool, prompt, resource, server } = authz(policy);

See Capabilities for what registration does, what the handler receives, and how onAudit reports it. See Human approval for approval and onApproval.

A permission map over a server whose tools were never declared with one. See Servers you did not write.

throw AccessDeniedError.notPermitted(email);
throw AccessDeniedError.noCredential(email, 'a TestRail API key');

The gate turns either one into HTTP 403 with {"error":"forbidden","reason":"policy_denied"} and the message as error_description. It carries no Bearer challenge on purpose: a token upgrade cannot repair company policy. error.reason is 'not_permitted' or 'no_credential' for your logs. A caller who matches no rule gets the first of these before your resolve runs.

The policy half on its own (definePolicy, definePermissions, createPrincipal, Principal, Identity, reconcile) with no MCP code in the module graph:

import { definePolicy, type Identity } from 'mcp-authz/policy';

The decision is identity → principal → permissions, and MCP is one surface that consumes it. Gating an Agent SDK tool loop or a plain HTTP tool API reuses the same policy object and the same file an administrator edits.

This shrinks what you load, not what you install. @modelcontextprotocol/server remains a dependency of the package. Splitting it out only becomes worth doing if a non-MCP consumer turns up.

import { listenMcp } from 'mcp-authz/node';
await listenMcp(fetch, { port: 8200, name: 'acme-mcp', info: { resource: url } });

Binds every interface. The bearer gate is the security boundary.