PermDock
Adapters

MCP

permdock/mcp guards MCP tools with typed permissions, scope step-up challenges, per-caller tool lists, boundary-validated arguments and model-readable refusals.

Status: planned Phase: 2

permdock/mcp wraps an MCP server built with the official TypeScript SDK v2 so that every tool declares the permission it needs. The adapter turns that declaration into an OAuth scope challenge, a filtered list_tools response, validated arguments and a structured refusal, so the model learns why a call was refused and what it may do instead.

Purpose

MCP servers are OAuth 2.1 resource servers under the 2026-07-28 specification. The SDK exposes ctx.http.authInfo (scopes, client id, expiry) and a scopeChallenge option on registerTool, but leaves per-tool authorization to hand-written checks in each handler. permdock/mcp replaces those checks with one permission reference per tool and applies the rest of PermDock to the call: the two-principal subject, boundary validation of arguments, three-outcome decisions and audit. The OWASP Top 10 for Agentic Applications asks for exactly this: per-tool least-privilege profiles attached to each tool as authorization policy, plus deterministic argument validation (ASI02 / ASI03).

API

import { createPermDock } from 'permdock/mcp'

const { protectServer } = createPermDock(policy, {
  subject: (authInfo) => userFrom(authInfo), // principal; actor = client_id, delegation = scopes / authorization_details
})

const guarded = protectServer(server)

guarded.registerTool(
  'delete_post',
  {
    permission: permissions.post.delete,   // typed reference; scope 'post:delete' derived from it
    inputSchema,                           // Standard Schema for the tool arguments
    data: (args) => loadPost(args.id),     // resolves the resource instance for an instance-level action
  },
  handler,
)

guarded.registerTool('list_posts', { permission: permissions.post.list, inputSchema }, listHandler)
  • createPermDock(policy, options) returns protectServer, which wraps an SDK McpServer (or a compatible object) and returns the same server with a permission-aware registerTool.
  • permission is required. Collection actions (permissions.post.list) need no data; instance actions require data so the adapter can evaluate where conditions against the real row.
  • subject receives the SDK AuthInfo and returns the principal (or null for anonymous). The adapter fills actor with authInfo.clientId and delegation with authInfo.scopes and, when present, RFC 9396 authorization_details from the token, so a decision is the principal's grants intersected with what the client was delegated.
  • protectServer also installs a list_tools filter and an elicitation bridge for approval-required decisions.

Hosting

protectServer wraps an SDK v2 McpServer wherever it is created, so the hosting layer needs no PermDock code. The common host for Fetch frameworks is mcp-handler 2.x, which turns an McpServer definition into a (Request) => Promise<Response> handler for Next.js route handlers, Nuxt and Nitro, SvelteKit, Hono and any Fetch-compatible framework, serves the 2026-07-28 stateless protocol natively and falls back to 2025-era Streamable HTTP for older clients.

// app/api/mcp/route.ts (Next.js), or the equivalent route in Nuxt, SvelteKit or Hono
import { createMcpHandler, withMcpAuth } from 'mcp-handler'
import { createPermDock } from 'permdock/mcp'
import { createJwtSubjectResolver } from 'permdock/jwt'
import { policy, permissions } from '@/permissions'

const verify = createJwtSubjectResolver({ issuer: process.env.AUTH_ISSUER!, audience: process.env.MCP_RESOURCE! })

const { protectServer } = createPermDock(policy, {
  subject: (authInfo) => authInfo.extra?.subject ?? null,   // the Subject `verifyToken` placed on AuthInfo
})

const handler = createMcpHandler((server) => {
  const guarded = protectServer(server)                      // the SDK v2 McpServer the callback receives
  guarded.registerTool('delete_post', { permission: permissions.post.delete, inputSchema, data: loadPost }, deletePost)
  guarded.registerTool('list_posts', { permission: permissions.post.list, inputSchema: listSchema }, listPosts)
})

// Step 1 of the request lifecycle on a Fetch host: verify the bearer token and attach AuthInfo
const authed = withMcpAuth(
  handler,
  async (_req, token) => {
    const subject = await verify(token)                       // never throws; anonymous on failure
    if (!subject.principal) return undefined                  // 401 with the RFC 9728 challenge
    return { token, clientId: subject.claims.client_id, scopes: subject.claims.scope?.split(' ') ?? [], expiresAt: subject.expiresAt, extra: { subject } }
  },
  { required: true },
)

export { authed as GET, authed as POST }
  • withMcpAuth is where the bearer token is verified on a Fetch host; it answers 401 and 403 with WWW-Authenticate challenges pointing at the protected resource metadata. The verifyToken callback returns the SDK AuthInfo (token, clientId, scopes, expiresAt, extra) that subject receives; the recipe builds it from permdock/jwt, which verifies against the issuer's JWKS and never throws. scopes becomes delegation.scopes and clientId becomes actor.id exactly as with the SDK's own bearer middleware.
  • protectedResourceHandler from mcp-handler serves the RFC 9728 Protected Resource Metadata document. PermDock does not touch it, but its scopes_supported should list the scope of every permission the server exposes so clients can request them up front; permdock collect emits that list in the catalog (CLI: collect).
  • Stateless serving. The 2026-07-28 handler holds no session, so the approval-required elicitation is a multi-round-trip request and the pending approval lives only in the ApprovalStore. On Vercel Functions, Cloudflare Workers or any other host where invocations do not share memory, memoryApprovalStore() loses pending approvals between calls; use a durable store (approvals adapter). permdock doctor warns when it detects this combination.
  • Other hosts. The official MCP framework middleware for Express (Node IncomingMessage servers), Cloudflare's McpAgent in the Agents SDK (a Durable Object per session, which is also a natural ApprovalStore), FastMCP for TypeScript and xmcp each construct or expose the same McpServer; protectServer wraps it at the point of construction. None of them needs a PermDock entry (ecosystem index).
  • SDK version. permdock/mcp targets @modelcontextprotocol/server 2.x (a types-only optional peer; protectServer duck-types the server at runtime, installation). SDK 1.x (@modelcontextprotocol/sdk) and mcp-handler 1.x are not supported: scopeChallenge and ctx.http.authInfo are v2 features, and 1.x's variadic server.tool() and extra.authInfo have no equivalents the adapter can wrap. The mcp-handler migration notes cover the move (registerTool instead of server.tool, ctx.http?.authInfo instead of extra.authInfo, Standard Schema for inputSchema).

Request lifecycle

  1. Transport: the SDK's bearer middleware (or withMcpAuth on a Fetch host, above) validates the access token, checks iss per RFC 9207 and attaches authInfo.
  2. list_tools: the adapter builds a request-scoped PermDock from authInfo and returns only tools whose collection-level check passes (can(permission) for collection actions; for instance actions, whether any grant exists for that permission). The model never sees tools this caller cannot use.
  3. call_tool: arguments are validated against inputSchema; if data is declared, the resource is loaded and validated against the resource schema (boundary mode).
  4. Scope check: if the token lacks the permission's scope, the adapter answers with the SDK scopeChallenge, producing a 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="post:delete ...". Scope accumulation follows SEP-2350: the challenge lists the union of previously granted scopes plus the missing one, so a step-up never drops authority the client already had.
  5. Decision: permdock.decide(permission, data) runs with the two-principal subject.
  6. Outcome: granted runs the handler; denied returns a refusal; approval-required starts an elicitation round trip (below).
  7. on('decision') fires with outcome, permission key, actor and delegation for audit and permdock/otel.

What it validates

InputValidation
Tool argumentsinputSchema (any Standard Schema), before data runs
Resource instance from dataResource schema, validate: 'boundary'
Tokeniss (RFC 9207), audience, expiry: performed by the SDK middleware, not by PermDock
ScopesPermission scope must be present in authInfo.scopes
authorization_detailsParsed into delegation and intersected with grants
Model-supplied subjectNever trusted; the subject comes from authInfo only

Validation failures surface as PermDockValidationError and are reported as isError: true results with the issue list, not as protocol errors, so the model can correct its arguments.

How denials surface

A denied call returns an MCP tool result with isError: true, a plain-language content entry and a structuredContent object carrying the Decision:

{
  "isError": true,
  "content": [{ "type": "text", "text": "Denied: post.delete on post_42. You may: post.read, post.update." }],
  "structuredContent": {
    "outcome": "denied",
    "permission": "post.delete",
    "resource": { "type": "post", "id": "post_42" },
    "denials": [{ "role": "member", "reason": "not-author" }],
    "alternatives": ["post.read", "post.update"]
  }
}
  • Missing scope is not a denial: it is a 403 insufficient_scope step-up challenge at the HTTP layer, so the client can obtain more authority and retry.
  • approval-required uses MCP elicitation. Because the 2026-07-28 core is stateless, the elicitation is a multi-round-trip request: the server returns an elicitation request carrying Decision.token; the client answers in a new request; the adapter re-runs the check and compares the token so the approval cannot be replayed onto different arguments. See approvals.
  • Enterprise-Managed Authorization clients (ID-JAG obtained via RFC 8693 token exchange, redeemed with an RFC 7523 JWT-bearer grant) and Client ID Metadata Document clients need no extra configuration: the adapter only reads authInfo.

Generated MCP servers from OpenAPI

Orval, Scalar, Speakeasy and similar tools generate an MCP server from an OpenAPI description, one tool per operation. Agents then reach the API through that server, and unless the generated tools carry a permission, list_tools filtering, scope step-up and approval-required elicitation never run. The binding already exists in the description: every operation PermDock covers carries x-permdock-permissions (OpenAPI adapter).

Recipe: run the bridge on the applied description (the producer's output with PermDock's Overlay merged), then map each generated tool to registerTool with permission: findPermission(operation['x-permdock-permissions'][0]). Where the bridge exposes a per-operation hook, do it there; otherwise wrap the generated server with protectServer and supply the mapping from operationId to permission key, which permdock openapi can emit as part of the catalog. findPermission is the one place a string key enters the public API, and it throws on an unknown key, so a bridge cannot register a tool for an operation the catalog does not know. Operations without x-permdock-permissions are not registered (fail closed), the same rule simulate applies to Arazzo steps.

This is a recipe, not a package: PermDock composes with the bridge through the extension it already writes (ADR 0023, OpenAPI ecosystem). Standard security is not enough here because the bridge needs the permission key, not the OAuth scope; it is the one consumer for which x-permdock-* carries information the standard fields cannot.

Example app

apps/examples/mcp-server: a server with post tools mounted through mcp-handler on Hono (the framework-agnostic proof; the same route file runs unchanged on Next.js, Nuxt and SvelteKit), withMcpAuth backed by permdock/jwt, a fake authorization server issuing scoped tokens, a script that demonstrates a list_tools difference between two callers, a scope step-up, a denied call with alternatives and an elicitation-backed delete. apps/examples/next mounts the same tools at app/api/mcp/route.ts (Next.js adapter, MCP route).

Open questions

  • Whether list_tools should hide tools with instance-level conditions or show them with a permdock annotation stating that per-row checks apply.
  • How to represent alternatives for tools that map several permissions to one tool name.
  • Whether the adapter should expose requireScopes() for handlers registered outside protectServer.
  • Where MCP Tasks (long-running tool calls) re-check the decision: at submission only, or again at completion.
  • Whether to verify delegation chains in the adapter or leave that to the token layer (tracked on the roadmap).

On this page