Skip to content

mcp-authz

Dana reads cases. Alice also writes them. Your MCP server can tell them apart, and it costs you one dependency.

An OAuth 2.1 resource server plus an access policy, shipped as a library you import. You bring the authorization server. This verifies tokens whose audience is your public MCP URL, works out what the caller may do, and registers only the capabilities they may use.

const policy = definePolicy({
roles: {
reader: ['cases:read'],
editor: ['cases:read', 'cases:write'],
},
rules: [
{ match: { domain: 'acme.com' }, role: 'reader' },
{ match: { email: 'alice@acme.com' }, role: 'editor' },
],
});
const { tool, server } = authz(policy);
const createServer = server(
[
tool('get_case', { permission: 'cases:read', inputSchema }, getCase),
tool('update_case', { permission: 'cases:write', inputSchema }, updateCase),
],
{ name: 'acme', version: '1.0.0' },
);

Dana calls tools/list and sees get_case. Alice sees both. update_case is not hidden from Dana. It was never registered for her request.

Typos fail the build

Permission names come from the policy literal, so permission: 'cases:wrtie' is a compile error in TypeScript rather than a 403 at 3am.

Startup catches dead rules

A capability that no role can reach stops the server booting. A gateway cannot run that check, because it does not learn your tool list until traffic arrives.

The principal reaches your handler

A proxy can decide whether Dana may call list_cases. Your handler can also scope the query to her organisation, in the one place that knows how.

Nothing new to operate

No sidecar, no policy service, no extra hop. It deploys wherever your server already deploys, including Vercel, Workers and Lambda.

Terminal window
npm install mcp-authz @modelcontextprotocol/server

Both packages run the same versioned conformance fixtures, so a policy decision means the same thing in either language. Their releases are independent.