Skip to content

onboardiq/mcp_authorization_node

Repository files navigation

mcp-authorization

Per-request schema-level authorization for MCP tool servers. Type definitions ARE authorization policies.

The Problem

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.

Three Layers of Authorization

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

Quick Start

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.

API Reference

extendZodWithMcpAuth(z)

Call once at startup. Adds three methods to all Zod types:

  • .requires(permission) — Gate this field/variant by permission. Removed from schema if ctx.can(permission) returns false.
  • .dependsOn(field) — Generates dependentRequired in JSON Schema (this field is required only when the named field is present).
  • .defaultFor(key) — Resolves a dynamic default from ctx.defaultFor(key) at schema compilation time.

defineTool(definition)

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

mcpAuthMiddleware(options) / createRequestHandler(options)

Create an HTTP handler that materializes a fresh MCP Server per request:

{
  name: string,
  version: string,
  tools: ToolDefinition[],
  contextBuilder: (req: IncomingMessage) => McpAuthContext,
}

McpAuthContext

The interface your app implements to bridge its auth system:

interface McpAuthContext {
  can(permission: string): boolean;
  defaultFor?(key: string): unknown;
}

Architecture

Each HTTP request creates a fresh MCP Server in stateless mode (no SSE, no sessions). The flow:

  1. contextBuilder extracts user identity from the request → McpAuthContext
  2. ToolRegistry.materialize(ctx) filters tools by tool-level permissions
  3. For each surviving tool, compileSchema(zodSchema, ctx) walks the Zod tree, builds a permission map, converts to JSON Schema via zod-to-json-schema, then prunes fields/variants where ctx.can() returns false
  4. A low-level MCP Server is created with setRequestHandler for tools/list (returns pre-compiled schemas) and tools/call (executes handlers)
  5. Connected to StreamableHTTPServerTransport in stateless mode

This mirrors the architecture of mcp_authorization (Ruby gem), adapted for TypeScript/Zod.

Integrating with Existing RBAC

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

License

MIT

About

Per-request schema-level authorization for MCP tool servers — Zod/TypeScript reference implementation

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors