API reference
pnpm add mcp-authz @modelcontextprotocol/server# optional Node helperpnpm add @modelcontextprotocol/nodeNode.js 24 or newer. CI covers 24 and 26.
createMcpFetch(options)
Section titled “createMcpFetch(options)”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.
discoverOAuth(issuer)
Section titled “discoverOAuth(issuer)”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.
Verifier claims
Section titled “Verifier claims”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.
definePolicy(spec)
Section titled “definePolicy(spec)”Covered in full under Policies, including
matching, deny precedence, wildcard vocabularies and policy.explain.
authz(policy)
Section titled “authz(policy)”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.
gate(server, principal, permissions)
Section titled “gate(server, principal, permissions)”A permission map over a server whose tools were never declared with one. See Servers you did not write.
Refusing a caller
Section titled “Refusing a caller”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.
mcp-authz/policy
Section titled “mcp-authz/policy”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.
Node listen helper
Section titled “Node listen helper”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.