Skip to content

Deployment

You ship one process: your MCP server with mcp-authz inside it. Claude dials your public URL. WorkOS, Auth0, or Stytch runs login and mints tokens. TestRail, Jira, or whatever you wrapped keeps one service credential.

flowchart LR
  Client["MCP client<br/>(Claude)"]
  AS["Authorization server<br/>(WorkOS, Auth0, Stytch)"]
  IdP["IdP<br/>(Google Workspace)"]
  MCP["Your MCP server<br/>mcp-authz embedded"]
  API["Downstream API<br/>(TestRail, Jira, …)"]

  Client -->|"OAuth login, PKCE"| AS
  AS --> IdP
  Client -->|"POST /mcp<br/>Bearer token, aud = MCP URL"| MCP
  MCP -->|"service credential"| API
One process to deploy. An authorization server you already run or buy.
Box You deploy it? What it does
MCP client No Claude, or any connector that speaks OAuth 2.1 and Streamable HTTP
Authorization server Configure, usually buy Client registration, consent, PKCE, token mint with aud = your MCP URL
IdP Usually already there Google Workspace signs the human in; the AS passes email, hd, groups through
MCP server + mcp-authz Yes Verify token, run policy, expose only permitted capabilities, call handlers
Downstream API Usually already there One API key or service account; mcp-authz decides which person may use it

MCP_PUBLIC_URL names the MCP server box. Every OAUTH_* value names the authorization server box. See Configuration.

Each request calls createServer with the principal the policy produced. Dana holds cases:read, so update_case never registers for her request. Alice holds cases:write, so it does. The server binary does not change between them.

The two seams reach that result differently. Capabilities you declare with authz() are skipped at registration. gate(), over a server somebody else built, registers each one and immediately disables what the caller may not reach. What the caller sees is identical either way: absent from the listing, refused if named.

flowchart TD
  ReqD["Dana's POST /mcp"] --> VerD["Verify token, run policy"]
  VerD --> PrD["Principal: reader<br/>cases:read"]
  PrD --> CatD["tools/list<br/>whoami, get_case"]
  ReqA["Alice's POST /mcp"] --> VerA["Verify token, run policy"]
  VerA --> PrA["Principal: editor<br/>cases:read, cases:write"]
  PrA --> CatA["tools/list<br/>whoami, get_case, update_case"]

Absence from tools/list is not the security boundary. Calling a name Dana was never shown is refused before dispatch, and that denied attempt reaches your onDecision sink. See Capabilities.

flowchart TD
  Client["MCP client"] -->|"OAuth 2.1<br/>CIMD, PKCE, resource = MCP URL"| AS["Authorization server"]
  AS -->|"OIDC login"| IdP["Google Workspace"]
  IdP -->|"verified email, hd, groups"| AS
  AS -->|"access token, aud = MCP URL"| Client
  Client -->|"Bearer on POST /mcp"| Lib["mcp-authz"]
  Lib -->|"createServer(principal)"| App["Your handlers"]

mcp-authz is the resource server. It verifies tokens and maps identity to permissions. It does not run consent, client registration, or PKCE.

Google Workspace alone cannot fill the authorization-server role: it has no client registration for your MCP audience, and it will not mint a token whose audience is your MCP endpoint. WorkOS, Stytch, and Auth0 cover both halves, Google login included.

Configure the AS before you deploy. Step 2 in Point your AS at it registers your MCP URL as the resource identifier. Skip it and the client finishes OAuth, holds a valid token, and never sends it.

Policy grants permissions. The token carries OAuth scopes. They are separate on purpose.

Axis Who sets it A gap means
Permission Administrator (policy, IdP groups, authorize) 403 policy_denied. No re-login will help.
Scope User at consent (toolScopes, capabilityScopes) 403 insufficient_scope. Client can re-authorise.

The library checks permission before scope, so an unpermitted caller never gets a futile step-up prompt. Full detail in Scopes and step-up.

Third-party MCP server. Same deployment. Wrap the builder with gate() and map tool names to permissions.

Central entitlements. Same deployment. Replace policy with authorize and call Cerbos, FGA, or your own store. mcp-authz stays the MCP enforcement point.

URL-only upstream (proxy). A second process — or a Worker — runs createMcpProxy in front of a vendor MCP you cannot wrap. Record the upstream catalogue, commit the permission map, and the proxy filters listings and refuses calls the principal may not reach.

flowchart LR
Client["MCP client"] --> Proxy["mcp-authz proxy"]
Proxy -->|"service bearer"| Upstream["Vendor MCP"]
Client -->|"OAuth"| AS["Authorization server"]

Audit. Downstream APIs see one service account. Your onAudit callback records which person triggered which action. See Capabilities.