Per-request schema-level authorization for MCP tool servers. Type definitions ARE authorization policies.
MCP servers expose tools to LLM clients via tools/list. Today, every user sees the same tool schemas. If a user lacks permission to use a feature, the best you can do is reject the call with an error after the LLM already knows the capability exists and tried to use it.
Schema-level authorization solves this by shaping the JSON Schema each user sees before they can act on it. Different users hitting the same endpoint receive different tool lists, different input fields, and different output variants — based on their permissions. An LLM cannot hallucinate options it was never shown.
| Layer | What it gates | Mechanism |
|---|---|---|
| Tool visibility | Entire tools | authorization: 'permission' on the tool definition |
| Input field visibility | Individual input fields | .requires('permission') on Zod schema fields |
| Output variant visibility | Union branches in output | .requires('permission') on Zod union members |
import express from 'express';
import { z } from 'zod';
import {
extendZodWithMcpAuth,
defineTool,
mcpAuthMiddleware,
} from 'mcp-authorization';
// Extend Zod once at startup
extendZodWithMcpAuth(z);
// Define a tool with authorization annotations
const listOrders = defineTool({
name: 'list_orders',
description: 'List orders',
authorization: 'view_orders', // tool-level gate
input: z.object({
status: z.enum(['pending', 'active']),
includeArchived: z.boolean()
.optional()
.requires('admin'), // field-level gate
}),
output: z.discriminatedUnion('type', [
z.object({ type: z.literal('summary'), count: z.number() }),
z.object({
type: z.literal('detailed'),
orders: z.array(z.object({ id: z.string() })),
}).requires('export_data'), // variant-level gate
]),
handler: async (params, ctx) => {
return { type: 'summary', count: 42 };
},
});
// Wire up Express
const app = express();
app.use(express.json());
app.use('/mcp', mcpAuthMiddleware({
name: 'my-server',
version: '1.0.0',
tools: [listOrders],
contextBuilder: (req) => ({
can: (permission) => getUserPermissions(req).includes(permission),
}),
}));Viewer calls tools/list and sees:
{
"name": "list_orders",
"inputSchema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["pending", "active"] }
}
}
}Admin calls tools/list and sees:
{
"name": "list_orders",
"inputSchema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["pending", "active"] },
"includeArchived": { "type": "boolean" }
}
}
}The viewer's LLM never knows includeArchived exists.
Call once at startup. Adds three methods to all Zod types:
.requires(permission)— Gate this field/variant by permission. Removed from schema ifctx.can(permission)returns false..dependsOn(field)— GeneratesdependentRequiredin JSON Schema (this field is required only when the named field is present)..defaultFor(key)— Resolves a dynamic default fromctx.defaultFor(key)at schema compilation time.
Define an MCP tool with typed schemas and authorization:
defineTool({
name: string,
description: string | ((ctx: McpAuthContext) => string),
authorization?: string, // tool-level permission gate
input: ZodSchema,
output?: ZodSchema,
handler: (params, ctx) => Promise<unknown>,
})Create an HTTP handler that materializes a fresh MCP Server per request:
{
name: string,
version: string,
tools: ToolDefinition[],
contextBuilder: (req: IncomingMessage) => McpAuthContext,
}The interface your app implements to bridge its auth system:
interface McpAuthContext {
can(permission: string): boolean;
defaultFor?(key: string): unknown;
}Each HTTP request creates a fresh MCP Server in stateless mode (no SSE, no sessions). The flow:
contextBuilderextracts user identity from the request →McpAuthContextToolRegistry.materialize(ctx)filters tools by tool-level permissions- For each surviving tool,
compileSchema(zodSchema, ctx)walks the Zod tree, builds a permission map, converts to JSON Schema viazod-to-json-schema, then prunes fields/variants wherectx.can()returns false - A low-level MCP
Serveris created withsetRequestHandlerfortools/list(returns pre-compiled schemas) andtools/call(executes handlers) - Connected to
StreamableHTTPServerTransportin stateless mode
This mirrors the architecture of mcp_authorization (Ruby gem), adapted for TypeScript/Zod.
The McpAuthContext interface is deliberately minimal. For systems with complex RBAC (role matrices, action keys, feature flags), the shim is typically a one-liner:
// Example: wx-system RBAC integration
contextBuilder: (req) => ({
can: (action) => checkRoleAuthorizedAction(jwt.authzRoleUuid, action),
defaultFor: (key) => userProfile[key],
})MIT