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)returnsprotectServer, which wraps an SDKMcpServer(or a compatible object) and returns the same server with a permission-awareregisterTool.permissionis required. Collection actions (permissions.post.list) need nodata; instance actions requiredataso the adapter can evaluatewhereconditions against the real row.subjectreceives the SDKAuthInfoand returns the principal (ornullfor anonymous). The adapter fillsactorwithauthInfo.clientIdanddelegationwithauthInfo.scopesand, when present, RFC 9396authorization_detailsfrom the token, so a decision is the principal's grants intersected with what the client was delegated.protectServeralso installs alist_toolsfilter and an elicitation bridge forapproval-requireddecisions.
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 }withMcpAuthis where the bearer token is verified on a Fetch host; it answers401and403withWWW-Authenticatechallenges pointing at the protected resource metadata. TheverifyTokencallback returns the SDKAuthInfo(token,clientId,scopes,expiresAt,extra) thatsubjectreceives; the recipe builds it frompermdock/jwt, which verifies against the issuer's JWKS and never throws.scopesbecomesdelegation.scopesandclientIdbecomesactor.idexactly as with the SDK's own bearer middleware.protectedResourceHandlerfrommcp-handlerserves the RFC 9728 Protected Resource Metadata document. PermDock does not touch it, but itsscopes_supportedshould list thescopeof every permission the server exposes so clients can request them up front;permdock collectemits that list in the catalog (CLI: collect).- Stateless serving. The 2026-07-28 handler holds no session, so the
approval-requiredelicitation is a multi-round-trip request and the pending approval lives only in theApprovalStore. 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 doctorwarns when it detects this combination. - Other hosts. The official MCP framework middleware for Express (Node
IncomingMessageservers), Cloudflare'sMcpAgentin the Agents SDK (a Durable Object per session, which is also a naturalApprovalStore), FastMCP for TypeScript and xmcp each construct or expose the sameMcpServer;protectServerwraps it at the point of construction. None of them needs a PermDock entry (ecosystem index). - SDK version.
permdock/mcptargets@modelcontextprotocol/server2.x (a types-only optional peer;protectServerduck-types the server at runtime, installation). SDK 1.x (@modelcontextprotocol/sdk) andmcp-handler1.x are not supported:scopeChallengeandctx.http.authInfoare v2 features, and 1.x's variadicserver.tool()andextra.authInfohave no equivalents the adapter can wrap. Themcp-handlermigration notes cover the move (registerToolinstead ofserver.tool,ctx.http?.authInfoinstead ofextra.authInfo, Standard Schema forinputSchema).
Request lifecycle
- Transport: the SDK's bearer middleware (or
withMcpAuthon a Fetch host, above) validates the access token, checksissper RFC 9207 and attachesauthInfo. list_tools: the adapter builds a request-scopedPermDockfromauthInfoand 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.call_tool: arguments are validated againstinputSchema; ifdatais declared, the resource is loaded and validated against the resource schema (boundary mode).- Scope check: if the token lacks the permission's
scope, the adapter answers with the SDKscopeChallenge, producing a403withWWW-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. - Decision:
permdock.decide(permission, data)runs with the two-principal subject. - Outcome:
grantedruns the handler;deniedreturns a refusal;approval-requiredstarts an elicitation round trip (below). on('decision')fires with outcome, permission key, actor and delegation for audit andpermdock/otel.
What it validates
| Input | Validation |
|---|---|
| Tool arguments | inputSchema (any Standard Schema), before data runs |
Resource instance from data | Resource schema, validate: 'boundary' |
| Token | iss (RFC 9207), audience, expiry: performed by the SDK middleware, not by PermDock |
| Scopes | Permission scope must be present in authInfo.scopes |
authorization_details | Parsed into delegation and intersected with grants |
| Model-supplied subject | Never 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_scopestep-up challenge at the HTTP layer, so the client can obtain more authority and retry. approval-requireduses MCP elicitation. Because the 2026-07-28 core is stateless, the elicitation is a multi-round-trip request: the server returns an elicitation request carryingDecision.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).
Related standards
- MCP authorization:
scopeChallenge,requireScopes, CIMD, RFC 9207, EMA / ID-JAG, elicitation. - OAuth agent delegation: RFC 9396
authorization_details, RFC 8693 token exchange, delegation chains. - Approvals:
approval: 'human', replay-safe tokens. - Problem Details: shared vocabulary for the
structuredContentrefusal body. - OWASP Agentic Top 10: ASI02 Tool Misuse, ASI03 Identity and Privilege Abuse.
- OpenAPI 3.2 and OpenAPI ecosystem:
x-permdock-permissionsas the binding for generated MCP servers.
Open questions
- Whether
list_toolsshould hide tools with instance-level conditions or show them with apermdockannotation stating that per-row checks apply. - How to represent
alternativesfor tools that map several permissions to one tool name. - Whether the adapter should expose
requireScopes()for handlers registered outsideprotectServer. - 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).
oRPC
permdock/orpc adds a request-scoped PermDock to oRPC context, a protect middleware fed by procedure input, and an oo.spec hook that emits OpenAPI 3.2 security for the generated document.
AI SDK
permdock/ai-sdk turns PermDock decisions into Vercel AI SDK 7 tool approvals, capability middleware and WorkflowAgent suspensions, fail-closed by construction.