Skip to content
Conduit

mcp server

mcp-authz

Authorization for MCP servers: OAuth 2.1 resource server plus a roles/permissions policy

jagreehal/mcp-authzcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • Node.js 20 or newer
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "jagreehal-mcp-authz": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-authz"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 17, 2026

mcp-authz

Give an MCP server per-user permissions without deploying an authorization system.

An OAuth 2.1 resource server plus an access policy, as a library you import rather than a proxy you deploy. Speaks MCP Streamable HTTP 2026-07-28.

Documentation · Quick start · Run the example

PackageRegistryWhat it is
mcp-authznpmTypeScript library: JWKS, policy, permission-gated tools
mcp-authzPyPIPython library built on the official mcp SDK v2
mcp-authz-node-example—Thin Node.js consumer that wires the library to a demo server
mcp-authz-python-example—Thin Python consumer using the official SDK
mcp-authz-helpscout-example—Help Scout tools for an org agent that holds a static bearer
mcp-authz-test-server—A server that misbehaves on demand, for trying wrap and proxy

You bring the authorization server (WorkOS, Stytch, Auth0, …). This package never runs consent, DCR, or PKCE. It verifies tokens whose audience is your public MCP URL, decides what the caller may do, and registers only the tools they may use.

flowchart TD
    SDK["@modelcontextprotocol/server<br/>protocol, transport, OAuth primitives"]
    AZ["mcp-authz<br/>identity → policy → permitted capabilities"]
    APP["Your MCP server<br/>tools, prompts, resources"]
    SDK --> AZ --> APP

One layer, between the SDK you already depend on and the tools you already wrote.

Install

npm install mcp-authz @modelcontextprotocol/server   # TypeScript
pip install mcp-authz                                # Python, imports as mcp_authz

Both packages run the same versioned conformance fixtures, so a policy decision means the same thing in either language. Their releases are independent: read the TypeScript API or the Python API for the one you are installing.

Where it fits

Use it when you can reach the server's code, or wrap the builder that makes it, and the callers are people who already sign in somewhere. Dana reads cases, Alice also writes them, and that decision sits next to the handler running the query. You install a dependency, and you run nothing new.

Plenty of good tools solve the neighbouring problems. Take one of them when its row describes you:

ApproachExampleWhat you take on
MCP gatewayagentgatewayA data-plane component to deploy and keep available
Gateway plus policy servicePermitThe gateway, and the policy infrastructure behind it
External decision pointCerbosA PDP to run, and a call out to it on every decision
Relationship-based permissionsAuth0 FGARicher rules than this offers, in a second authorization system
Framework featureFastMCPConvenience, tied to that framework
OAuth plumbingofficial SDK, mcp-authAuthentication and resource-server mechanics, with no permission model
Embedded dependencymcp-authzOne package, and a policy engine that stays small by design
flowchart TD
    Start["Different people need<br/>different MCP capabilities"] --> Own{"Can you reach the server's code,<br/>or wrap its builder?"}
    Own -->|no| GW["Gateway in front of it<br/>agentgateway, Permit"]
    Own -->|yes| Central{"Do you want to run<br/>central authorization infrastructure?"}
    Central -->|yes| PDP["Decision point or ReBAC store<br/>Cerbos, Auth0 FGA"]
    Central -->|no| Shape{"Are the rules relationship-shaped?<br/>'owns it', 'leads that project',<br/>'except after 6pm'"}
    Shape -->|yes| PDP
    Shape -->|no| Here["<b>mcp-authz</b><br/>roles and permissions,<br/>one dependency, nothing to operate"]

Two cases need none of this. A stdio server on one laptop already has the OS account as its boundary. A server where every caller gets identical access wants one service credential and no policy.

You need one integration seam

Which one you get decides the tool. Owning the source is the best case, not the only one.

flowchart TD
    Q["How much of the server can you reach?"]
    Q -->|"You write the tools"| A["<b>authz()</b><br/>permissions declared on the capability,<br/>names checked by tsc,<br/>catalogue reconciled at boot"]
    Q -->|"You call somebody else's<br/>builder, and it lets you<br/>wrap the server it makes"| B["<b>gate()</b><br/>a permission map over tools<br/>that were never written for one"]
    Q -->|"All you have is a URL"| C["A gateway<br/>no in-process seam exists to use"]

The middle rung matters more than it looks. A package you install, whose tools somebody else wrote, still works here as long as its builder hands you the server before returning it. That hook is two lines, and it is worth asking an upstream maintainer for. Details in TypeScript gate() and Python gate(); the FastMCP comparison covers the closest in-process alternative.

The other catalogue an agent reads

An OpenAPI document is tools/list under another name, so mcp-authz/openapi applies the same policy to an HTTP API, keyed on operationId. Each caller is served the document cut to what they may do, and every request is checked whether or not they ever read it. Same policy, same boot-time checks, same audit events, so one query covers both surfaces of a product.

See it work, without an authorization server

git clone https://github.com/jagreehal/mcp-authz && cd mcp-authz && pnpm install
pnpm --filter mcp-authz-node-example dev                  # :8200, local dev key
pnpm --filter mcp-authz-node-example token dana@acme.com  # reader
pnpm --filter mcp-authz-node-example token alice@acme.com # editor

Paste a token into the MCP Inspector. Dana sees whoami and get_case; Alice also sees update_case; sam@other.com gets HTTP 403. Dana's write tool is not hidden. It was never registered for her request. Full walkthrough in apps/node-example.

The shape

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

import { authz, createMcpFetch, definePolicy, discoverOAuth } from 'mcp-authz';
import { z } from 'zod';

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 },
  ],
});

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) },
);

const fetch = createMcpFetch({
  resourceServerUrl: new URL('https://mcp.acme.com/mcp'),
  oauthMetadata: await discoverOAuth('https://auth.acme.com'),
  policy,
  createServer,
});

permission: 'cases:wrtie' is a compile error. Permission names come from the policy literal.

A reader never has update_case registered, so it does not appear in tools/list. 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; a reader receives 403 {"error":"forbidden","reason":"policy_denied"} with no Bearer challenge.

Deployment

You ship one MCP server process with this library inside it. Claude dials your public URL. Your authorization server runs login. TestRail or Jira keeps one service credential. Full stack detail is in the docs.

flowchart LR
    Client["MCP client"] -->|"OAuth login, PKCE"| AS["Authorization server"]
    AS -->|"OIDC login"| IdP["Google Workspace"]
    Client -->|"Bearer token<br/>aud = your MCP URL"| MCP["Your MCP server<br/>mcp-authz"]
    MCP -->|"service credential"| API["Downstream API"]

mcp-authz is the resource server: it verifies tokens and calls createServer(context). Consent, client registration, and PKCE belong to the authorization server. Google Workspace signs people in but cannot mint a token whose audience is your MCP URL. WorkOS, Stytch, and Auth0 cover both halves.

What a request goes through

flowchart TD
    R["POST /mcp"] --> Path{"Path matches<br/>resourceServerUrl?"}
    Path -->|no| E404["404 naming the path<br/>this server does answer on"]
    Path -->|yes| Bearer{"Baseline token valid?<br/>signature, iss, aud, exp"}
    Bearer -->|no| E401["401 + WWW-Authenticate<br/>carrying resource_metadata"]
    Bearer -->|yes| Pol["identity → policy → Principal"]
    Pol -->|"no rule matched"| E403["403 forbidden / policy_denied<br/>no Bearer challenge"]
    Pol --> Pre["Classify the request and check<br/>Mcp-Method / Mcp-Name against the body"]
    Pre -->|"headers disagree,<br/>or are missing"| E400["400, before any tool is chosen"]
    Pre --> Perm{"Principal holds the<br/>capability's permission?"}
    Perm -->|no| E403
    Perm -->|yes| Scope{"Token carries the<br/>capability's scope?"}
    Scope -->|no| E403S["403 insufficient_scope<br/>the client can re-authorise"]
    Scope -->|yes| Ctx["resolve() adds tenant,<br/>credentials, request context"]
    Ctx --> Build["createServer registers only<br/>the capabilities this caller may use"]
    Build --> Ask{"Does this call ask<br/>for a person?"}
    Ask -->|no| H
    Ask -->|"declined, unanswered,<br/>or approved by nobody"| E403A["ApprovalRefusedError,<br/>logged as decision: deny"]
    Ask -->|approved| H["Handler runs, holding the Principal"]

Authentication precedes the body read. Permission is checked before scope, so an unpermitted caller never gets prompted to re-authorise for an action your policy will refuse anyway.

What running in-process buys you

A proxy only knows what crosses the protocol boundary: a tool name and a JSON blob. A library already has your types, your handlers and your domain.

  • Compile-time permission names. A typo fails the build, not a request at 3am.
  • Boot-time reconciliation. A tool no role can reach fails startup; a permission no tool requires warns. A gateway cannot run this check, because it does not learn your tool list until traffic arrives.
  • Authorization context is inside the handler. A proxy can decide whether Dana may call list_cases; your application can also constrain which organisation's cases she may see, exactly where the domain query is made.
  • Audit that names the resource. The library cannot know { id: 'C1234' } is a case; your audit callback does.
  • A person in the loop, on the arguments. approval: ({ force }) => force asks a human before that one call runs, and the answer lands in the same audit event as the caller. A proxy sees {"force":true} and cannot tell you it matters.
  • Deploys where a proxy cannot. Vercel, Workers, Lambda, a Next.js route. No sidecar, no extra hop.

Asking a person first

Some calls should not run because somebody holds a permission. Deleting a production run, refunding above a threshold, closing a release.

tool(
  'delete_case',
  {
    permission: 'cases:delete',
    inputSchema: z.object({ id: z.string(), force: z.boolean().default(false) }),
    audit: ({ id }) => `case:${id}`,
    // Only the destructive half of the argument space warrants a person.
    approval: ({ force }) => force,
  },
  deleteCase,
);
const createServer = server(definitions, {
  name: 'acme',
  version: '1.0.0',
  onApproval: async (request) => {
    const answer = await askInSlack(request); // who, what tool, which case
    return answer.yes ? { approved: true, by: answer.email } : { approved: false, reason: answer.why };
  },
});

{ approved: true } does not compile without by, and an approval naming nobody is refused at runtime too, because a type only binds the code that was type-checked and an adapter can always hand you an anonymous yes. Silence is a refusal after approvalTimeoutMs (45s by default), and an onApproval that throws refuses too, rather than letting a delete through on the strength of a Slack outage. A capability that asks for a person while no sink can be reached fails at boot.

The wait is in-process and holds the caller's request open, which is the point: nothing has to survive a restart, because a process that dies mid-question takes the request with it and the action correctly did not happen. Full detail, and the same options for tools you did not write, in the approval section.

No backend required

The policy is a plain object. Written inline it is typed; loaded from the environment it is not, but 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 never reads the environment or the filesystem itself. How an application loads configuration is the application's business, and that includes YAML you parsed yourself. You still need an IdP for login and tokens; you do not need a database for authorization.

When the organisation outgrows a config file, authorize can read entitlements from an IdP, database or policy service and return a principal with createPrincipal. resolve then adds request context such as a tenant or downstream credential, and that complete context reaches every handler.

Against a real authorization server

Configuring the AS comes first, and one step of it decides whether any of this works: your AS has to accept resource=https://mcp.acme.com/mcp and mint tokens whose aud equals that exact URL. Skip it and the client finishes the whole OAuth dance, holds a valid token, and never sends it. The ordered walkthrough, plus a symptom-to-cause table for when it misbehaves, is in Point your authorization server at it.

Then run the example against it:

cp apps/node-example/.env.example apps/node-example/.env
# fill MCP_PUBLIC_URL and OAUTH_*; optionally set MCP_POLICY
pnpm --filter mcp-authz-node-example start

For Python, see the official-SDK example.

Skills for coding agents

skills/ holds reference cards an agent can load when writing against this library — the API surface, the label vocabulary, and the mistakes that are easy to make and quiet when made.

SkillCovers
mcp-authz-authzDeclaring permissions on capabilities you write
mcp-authz-gateGating a server you did not write
mcp-authz-proxyFronting an upstream reachable only by URL
mcp-authz-permission-mapRecording, pricing and drift-checking the map
mcp-authz-openapiGating an HTTP API by operationId
mcp-authz-auditWiring the audit and access-decision events
mcp-authz-wrapShowing a stdio server's tools selectively

Spec stance (2026-07-28)

  • Streamable HTTP via @modelcontextprotocol/server createMcpHandler (stateless, no Mcp-Session-Id)
  • RFC 9728 Protected Resource Metadata + Bearer on every request
  • RFC 8707 audience = your public MCP URL
  • Strict 2026 request validation: missing or dishonest routing headers are HTTP 400 before scope selection
  • Declarative per-tool scopes with HTTP 403 insufficient_scope step-up
  • Prefer an AS that supports CIMD; DCR is deprecated in this revision

License

MIT

↑↓ navigate openesc close