Quick start
Three pieces: a policy, capabilities that name the permission they need, and the handler that ties them together.
npm install mcp-authz @modelcontextprotocol/server# optional Node helpernpm install @modelcontextprotocol/nodeNode.js 24 or newer. CI covers 24 and 26.
-
Write the policy.
import { definePolicy } from 'mcp-authz';const policy = definePolicy({roles: {reader: ['cases:read'],editor: ['cases:read', 'cases:write'],admin: ['*'],},rules: [{ match: { domain: 'acme.com' }, role: 'reader' },{ match: { email: 'alice@acme.com' }, role: 'editor' },{ match: { claim: { 'https://acme.com/groups': 'qa-leads' } }, role: 'editor' },{ match: { sub: 'auth0|departed-contractor' }, deny: true },],}); -
Declare each capability against a permission.
import { authz } from 'mcp-authz';import { z } from 'zod';const { tool, prompt, resource, server } = authz(policy);const createServer = server([tool('get_case',{ permission: 'cases:read', inputSchema: z.object({ id: z.string() }) },async ({ id }, { principal }) => fetchCase(id, principal),),tool('update_case',{permission: 'cases:write',inputSchema: z.object({ id: z.string(), title: z.string() }),audit: ({ id }) => `case:${id}`,},async ({ id, title }) => renameCase(id, title),),prompt('release_report',{ permission: 'cases:write', argsSchema: z.object({ version: z.string() }) },buildReleaseReport,),resource('case', { permission: 'cases:read', uri: 'case://{id}' }, readCaseResource),],{ name: 'acme', version: '1.0.0', onAudit: (event) => log.info(event) },); -
Serve it.
import { createMcpFetch, discoverOAuth } from 'mcp-authz';const fetch = createMcpFetch({resourceServerUrl: new URL('https://mcp.acme.com/mcp'),oauthMetadata: await discoverOAuth('https://auth.acme.com'),policy,createServer,});fetchis a plain(Request) => Promise<Response>, so it drops into a Next.js route, a Worker, orlistenMcpon a Node server.
pip install mcp-authzPython 3.10 or newer. CI covers 3.10, 3.12 and 3.13. The package builds on the
official mcp SDK v2 and imports as mcp_authz.
-
Write the policy.
from mcp_authz import define_policypolicy = define_policy({"roles": {"reader": ["cases:read"],"editor": ["cases:read", "cases:write"],},"rules": [{"match": {"domain": "acme.com"}, "role": "reader"},{"match": {"email": "alice@acme.com"}, "role": "editor"},],}) -
Build the server around it.
from mcp.server.auth.settings import AuthSettingsfrom mcp_authz import AuthorizedMCPServer, JwtVerifierserver = AuthorizedMCPServer("cases",policy=policy,token_verifier=JwtVerifier(issuer="https://auth.acme.com",jwks_uri="https://auth.acme.com/.well-known/jwks.json",resource="https://mcp.acme.com/mcp",),auth=AuthSettings(issuer_url="https://auth.acme.com",resource_server_url="https://mcp.acme.com/mcp",required_scopes=["mcp"],),) -
Price every capability, then serve it.
@server.tool(permission="cases:read")def get_case(case_id: str) -> dict[str, str]:return {"id": case_id}app = server.streamable_http_app(stateless_http=True)
What that bought you
Section titled “What that bought you”A reader never has update_case registered, so it does not appear in
tools/list, and there is no per-handler permission check to forget.
An editor does see update_case even when the current token lacks its write
scope. Calling it then returns HTTP 403 insufficient_scope, which a client can
fix by re-authorising. A reader who names the tool anyway gets
403 {"error":"forbidden","reason":"policy_denied"} with no Bearer challenge,
because a token upgrade cannot repair company policy.
In TypeScript, permission: 'cases:wrtie' is a compile error. Permission names
come from the policy literal.
No backend required
Section titled “No backend required”The policy is a plain object. Written inline it is typed. Loaded from the environment it is not, and it is still validated and still reconciled at boot.
MCP_POLICY='{"roles":{"reader":["cases:read"]}, "rules":[{"match":{"domain":"acme.com"},"role":"reader"}]}'const policy = definePolicy(JSON.parse(process.env.MCP_POLICY!));The library reads neither the environment nor the filesystem. Your application loads its configuration however it likes, YAML you parsed yourself included. You still need an IdP for login and tokens. You do not need a database for authorization.