Skip to content

Quick start

Three pieces: a policy, capabilities that name the permission they need, and the handler that ties them together.

Terminal window
npm install mcp-authz @modelcontextprotocol/server
# optional Node helper
npm install @modelcontextprotocol/node

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

  1. 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 },
    ],
    });
  2. 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) },
    );
  3. 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,
    });

    fetch is a plain (Request) => Promise<Response>, so it drops into a Next.js route, a Worker, or listenMcp on a Node server.

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.

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.

Terminal window
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.