# MCP

Source: https://permdock.com/docs/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.

`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 [#purpose]

MCP servers are OAuth 2.1 resource servers under the [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization). 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](/docs/concepts/subject), [boundary validation](/docs/concepts/validation) of arguments, three-outcome [decisions](/docs/concepts/decisions) and [audit](/docs/concepts/audit-and-observability). 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](/docs/security/owasp-agentic)).

## API [#api]

```ts
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 takes an SDK v2 `McpServer` and returns the same server with permission-aware `registerTool`, `registerResource` and `registerPrompt`. Registering any of them without a `permission` throws a `TypeError` at startup. There is no separate `requireScopes()` export: a handler registered outside `protectServer` is not guarded.
* `permission` is required, and each tool declares exactly one, so a refusal's `alternatives` are always those of that permission's resource. Collection actions (`permissions.post.list`) need no `data`; instance actions take `data(args)` so the adapter evaluates `where` conditions against the real row. A `data` loader that throws or returns `null` is a `validation` denial, never a grant.
* `subject` receives the SDK `AuthInfo` and returns the principal (or `null` for anonymous). `subjectFromMcp(authInfo, { claims, groupRoles, schema, delegation, actorKind })` is the ready-made mapper when the verifier puts token claims on `authInfo.extra`; it returns the anonymous subject for missing or malformed material and never throws. The adapter fills `actor` with `{ id: authInfo.clientId, kind: actorKind }` and `delegation` with `authInfo.scopes` and, when present, RFC 9396 `authorization_details`, so a decision is the principal's grants intersected with what the client was delegated.
* `actorKind` (default `'mcp-client'`) is the `kind` of that actor; pass the same value to `subjectFromMcp`. `permdock/supabase`, `permdock/jwt` and `permdock/a2a` give an OAuth client `kind: 'oauth-client'`, so a server whose tools call the same app with the caller's token sets `actorKind: 'oauth-client'`: one `deny(..., { to: actor('oauth-client') })` or one policy delegation then covers the token on every surface, instead of one per kind. A kind that is not a non-empty string throws a `TypeError` from `createPermDock` and makes `subjectFromMcp` return the anonymous subject.
* `clients` (a `ClientNames`, on `createPermDock` and `subjectFromMcp`) names client ids: the actor gets `client` set to the name of `authInfo.clientId`, which a policy delegation matches with `to: { kind, client }` ([policy delegations](/docs/security/delegation#policy-delegations)). A CIMD URL or a dynamically registered id maps through the function form.
* `requireAuthInfo: true` refuses every call that arrives without `authInfo` (an HTTP server behind bearer middleware). Without it a call with no `authInfo`, such as a stdio server run by the local user, skips the scope check and is decided on `subject` alone.
* `resource` is this server's RFC 8707 resource identifier (the URL clients call). A token whose `authInfo.resource` is absent or names another server is refused with `invalid_token` and sees an empty tool list. The comparison is on the parsed URL, so `https://MCP.example.com/mcp` matches `https://mcp.example.com/mcp`.
* `store` (an `ApprovalStore`) holds `approval-required` calls; `sink`, `limits`, `memberships`, `customRoles`, `tenant` and `otel` behave as on every adapter; `customRoles` may be a `RoleSourceFactory` that builds the source for each call's subject.
* `context(authInfo)` returns plain JSON merged into `subject.context` under the policy's `context`. Read it from the verified `authInfo`, never from tool arguments; a throw adds nothing and reports `on('error')` ([request data](/docs/guides/extending#request-data)).
* `onDenied({ decision, permission, text })` may return replacement refusal text, for a localised or product-specific message. `isError`, `structuredContent` and the approval shape stay as PermDock built them; `undefined`, an empty string or a throw keeps PermDock's text.
* `wrap` wraps each call's instance after `otel`; build it with `wrapPermDock` ([wrapping an instance](/docs/guides/extending#wrapping-an-instance)).
* `approval: { at, hint }` is where a human approves (the same hint HTTP adapters put on the problem body). With `at` set, a client that declares URL elicitation gets an `input_required` result instead of the refusal (below). `requestState` takes the codec from the SDK's `createRequestStateCodec` when the server verifies request state; without it the request state is the approval token itself.
* `stepUp: { at }` is where the user signs in again. With it, an `insufficient-user-authentication` denial answers a client that declares URL elicitation with an `input_required` URL request to `at`, with `acr_values` and `max_age` appended from the failing [`assurance`](/docs/concepts/policies) grants.
* Tool `annotations` the author leaves out are filled from the permission: `readOnlyHint` from `meta.readOnly` (or a `read` / `list` action), `destructiveHint` from `meta.destructive` and `idempotentHint` from `meta.idempotent`. Annotations are hints for the client; every call is still decided on the server.
* `longRunning: true` on a tool re-runs the decision when the handler resolves, reloading `data` and building a fresh subject (so a changed membership or role source counts). The re-check never consumes quota. It passes when the outcome is `granted`, when it is `approval-required` with the same token that was approved at submission, or when the only denial is a spent `limit`; anything else, including a throw, returns the refusal instead of the result. The re-check withholds the result; it cannot undo work the handler already did, so a handler that writes should write last.
* `protectServer` also filters `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list` per caller, including handlers the SDK installed before `protectServer` ran. A filtered result of a 2026-07-28 request is marked `cacheScope: 'private'`, over any server-level `cacheHints`; a 2025-era result gets no cache fields, because that revision does not define them.

### Tools that call a protected procedure [#tools-that-call-a-protected-procedure]

When a tool's handler calls an oRPC procedure that already runs `protect`, guarding the tool too would decide twice. `protectServer(server, { enforce: 'procedure', permissionFor })` keeps `tools/list` filtering, tool annotations and the scope challenge, but runs the handler without a decision. The procedure's `protect` makes the one decision per call, and its refusal reaches the handler as the `ORPCError` it throws.

```ts
import { call, ORPCError } from "@orpc/server";
import { permissionOf } from "permdock/orpc";

const procedures = new Map(Object.entries(router));
const { protectServer } = createPermDock(policy, { subject });
const guarded = protectServer(server, {
  enforce: "procedure",
  permissionFor: (name) => permissionOf(procedures.get(name)),
});

guarded.registerTool(
  "update_post",
  { inputSchema: byId },
  async (args, ctx) => {
    try {
      const out = await call(router.update_post, args, {
        context: { user: userFor(ctx.http?.authInfo) },
      });
      return { content: [{ type: "text", text: JSON.stringify(out) }] };
    } catch (error) {
      if (error instanceof ORPCError)
        return {
          content: [{ type: "text", text: JSON.stringify(error.data) }],
          isError: true,
        };
      throw error;
    }
  },
);
```

* `permissionFor(name)` names each tool's permission; `operationPermissions(...).forOperation` from `permdock/openapi` reads it from the declaration the REST routes use ([one declaration for REST and MCP](/docs/adapters/openapi#one-declaration-for-rest-and-mcp)). A `permission` in the tool config overrides it; neither throws a `TypeError` at registration.
* `oauthScopesFor(name)` (optional) names a tool's own OAuth scopes the same way, for the listing; `operationPermissions(...).oauthScopesForOperation` reads them from the same declaration, and an `oauthScopes` in the tool config overrides it.
* `permissionOf(procedure)` from `permdock/orpc` returns the permission of the procedure's first `protect`, or `undefined`. It reads the procedure definition and never decides.
* `data` and `longRunning` throw at registration in this mode: the procedure loads its own row and decides on its own terms.
* Resources and prompts are still guarded at the call.
* The mode applies to the whole server. Register tools the adapter should guard on a separate `McpServer`.

## Hosting [#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`](https://github.com/vercel/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.

```ts
// 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](/docs/cli/collect)).

* **Supabase Auth as the authorization server.** When Supabase Auth is the OAuth 2.1 server, [`@supabase/server`](https://github.com/supabase/server)'s `withOAuthProtectedResource` (alpha) replaces `protectedResourceHandler`: it serves the RFC 9728 document at `<resource>/oauth-protected-resource` with `authorization_servers` pointing at the project's Auth issuer, and enriches any `401` the inner handler returns with `WWW-Authenticate: Bearer resource_metadata="…"` unless the handler set its own challenge. Wrap the `withMcpAuth` result with it and point `createJwtSubjectResolver` at the same issuer (`https://<project>.supabase.co/auth/v1`, `algorithms: ['ES256']`, `audience` the resource URL); the token flows through `permdock/jwt` unchanged and `subjectFromSupabase` is not involved because the claims are OAuth access-token claims, not a Supabase session. Outside Edge Functions pass `resourceServer` explicitly (RFC 9728 requires it to equal the URL the client called) and `authorizationServer: fromSupabaseUrl(projectUrl)`. Its metadata does not list `scopes_supported`; publish the catalog's scope list through your own metadata response if clients need it up front ([Supabase provider](/docs/adapters/supabase)).

* **Stateless serving.** The 2026-07-28 handler holds no session, so an `approval-required` call (an `input_required` result carrying the token as `requestState`, or the structured refusal carrying `token`) is resumed on a later 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](/docs/adapters/approvals)). `permdock doctor` warns when it detects this combination.

* **better-supabase `createMcp`.** `createMcp(betterSupabase, options)` is its own MCP server, not an SDK `McpServer`, so `protectServer` cannot wrap it ([better-supabase](/docs/adapters/better-supabase#mcp-tools)). Plug PermDock into its per-tool hooks instead: carry the permission as the tool's `meta`, narrow it with `isPermission` (a tool without one is hidden and refused), answer `visible` with `mayUse` and `authorize` with `decide`. Both hooks get the tool context, whose `auth` is the verified caller. better-supabase serves the RFC 9728 metadata and the `insufficient_scope` challenge; the recipe adds no import to better-supabase, because the hooks are structural.

  ```ts
  import { createMcp, defineTool } from "better-supabase/mcp";
  import { createPermDock, isPermission, mayUse } from "permdock";
  import { subjectFromBetterSupabase } from "permdock/better-supabase";
  import { betterSupabase } from "@/lib/supabase";
  import { policy, permissions } from "@/permissions";

  type Instance = Awaited<ReturnType<typeof createPermDock>>;

  // One instance per request: `visible` runs once per tool.
  const instances = new WeakMap<object, Promise<Instance>>();
  const permdockFor = (ctx: { auth: object }) => {
    let instance = instances.get(ctx.auth);
    if (instance === undefined) {
      instance = createPermDock(policy, subjectFromBetterSupabase(ctx.auth));
      instances.set(ctx.auth, instance);
    }
    return instance;
  };

  const bs = createMcp(betterSupabase, {
    name: "posts",
    version: "1.0.0",
    advertisedScopes: ["posts:read", "posts:delete"],
    requiredScopes: ["posts:read"],
    // Table tools take a permission per operation; one without is hidden and refused.
    resources: {
      posts: {
        operations: ["list", "get"],
        meta: { list: permissions.post.list, get: permissions.post.read },
      },
    },
    tools: [
      defineTool({
        name: "delete_post",
        description: "Delete a post",
        meta: permissions.post.delete,
        input,
        run,
      }),
    ],
    visible: async (ctx, tool) =>
      isPermission(tool.meta) && mayUse(await permdockFor(ctx), tool.meta),
    authorize: async (ctx, tool, args) => {
      if (!isPermission(tool.meta))
        return { allowed: false, reason: "no-grant" };
      const decision = (await permdockFor(ctx)).decide(tool.meta, args);
      if (decision.outcome === "granted") return { allowed: true };
      return {
        allowed: false,
        reason:
          decision.outcome === "denied"
            ? decision.denials[0]?.reason
            : "approval-required",
      };
    },
  });

  export const POST = bs.endpoint;
  ```

  Refuse a tool whose `meta` is not a permission in both hooks: `authorize` is optional in better-supabase, and a missing or permissive one lets the call through. An `approval-required` outcome is refused here, because `createMcp` has no approval store; use `permdock/mcp` on an SDK server when tools need human approval. `createMcp` validates `args` against the tool's `input` before `authorize`, which covers invariant 9 when `input` is the resource schema.

  `advertisedScopes` (formerly `scopes`) only publishes `scopes_supported`; `requiredScopes` is what better-supabase enforces, answering 403 `insufficient_scope` to an `oauth-client` token without one. Neither limits a support or impersonation session, which PermDock reaches only through a policy delegation ([Supabase](/docs/adapters/supabase#support-and-impersonation-actors)). `mayUse` follows the policy delegation ceiling too, so a read-only support session is shown only read-only tools.

* **Serving the metadata with Supabase.** Whichever server answers MCP, the Protected Resource Metadata document and the `WWW-Authenticate` challenges come from the host, and PermDock only needs the same resource URL. With `@supabase/server` in front of an SDK server:

  ```ts
  import { pipeline } from "@supabase/middleware";
  import {
    fromSupabaseUrl,
    withOAuthProtectedResource,
  } from "@supabase/server";
  import { createMcpHandler, withMcpAuth } from "mcp-handler";
  import { createPermDock } from "permdock/mcp";

  const resource = `${process.env.APP_URL}/mcp`;
  const { protectServer } = createPermDock(policy, {
    subject,
    resource,
    requireAuthInfo: true,
  });
  const mcp = withMcpAuth(
    createMcpHandler((server) => register(protectServer(server))),
    verifyToken,
    { required: true },
  );

  const handler = pipeline(
    [
      withOAuthProtectedResource({
        resourceServer: resource,
        authorizationServer: fromSupabaseUrl(process.env.SUPABASE_URL!),
      }),
    ],
    (req) => mcp(req),
  );

  export { handler as GET, handler as POST };
  ```

  `withOAuthProtectedResource` is a pipeline entry and runs before any auth gate: it answers `GET {resource}/oauth-protected-resource` with the metadata document and adds `WWW-Authenticate: Bearer resource_metadata="…"` to a `401` from below that has no challenge yet. Serve it on `GET` too, or clients cannot fetch the document. Off Edge Functions `resourceServer` is required; without it the entry answers `500 MISSING_RESOURCE_SERVER`. With `withSupabase({ auth: 'user' })` as the gate instead of `withMcpAuth`, list it second in the same array.

  With better-supabase, `createMcp` serves the document and the challenges itself, with `advertisedScopes` as `scopes_supported`; pass the same URL as `resource` wherever a PermDock adapter checks the token. `withOAuthProtectedResource` omits `scopes_supported`, so publish the catalog's scope list through your own metadata response if clients need it up front.

* **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](/docs/research/ecosystem-index)).

* **SDK version.** `permdock/mcp` targets `@modelcontextprotocol/server` 2.x (a types-only optional peer; `protectServer` duck-types the server at runtime, [installation](/docs/getting-started/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 [#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. `tools/list`: the adapter builds a request-scoped `PermDock` from `authInfo` and returns only tools the caller could use: the token carries the scope (or the call has no `authInfo` and `requireAuthInfo` is off), and some grant for the permission could match for this principal, tenant and delegation. A tool with row conditions stays listed when any grant could match, because the row decides at call time. The model never sees tools this caller cannot use. After each call the adapter compares the visible set with what the session last listed and sends `notifications/tools/list_changed` when it changed, for example after a role promotion.
3. `tools/call`: the SDK validates arguments against `inputSchema`; if `data` is declared, the resource is loaded and validated against the resource schema (boundary mode).
4. Resource and scope check: a token issued for another `resource` is refused with `invalid_token`. 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", resource_metadata="…"`. The challenge lists the scopes the operation needs (the tool's one permission scope, or the first coarse scope that covers it under [`oauthScopes`](/docs/security/delegation#coarse-oauth-scopes)), not the scopes the token already carries: the 2026-07-28 authorization text makes scope accumulation the client's job, so the client requests the union of what it held and what the challenge names. A tool whose scopes differ from its permission's sets `oauthScopes: ['mcp:read']` in its config: those scopes, any one of them, replace the ones derived from the permission for the listing, the check and the challenge, which names the first. They only narrow: the decision still needs the token's delegation to cover the permission, so list the permission under every coarse scope that may reach it in the policy's `oauthScopes` and let each tool name the one it needs. Two tools that share one permission can then need different coarse scopes, such as reading an export with `mcp:read` and creating one with `mcp:write`. An empty list throws at registration.
5. Decision: `permdock.decide(permission, data)` runs with the two-principal subject.
6. Outcome: `granted` runs the handler (and, for a `longRunning` tool, decides again when it resolves); `denied` returns a refusal; `approval-required` parks the call in the `ApprovalStore` and returns a refusal carrying the token (below).
7. `on('decision')` fires with outcome, permission key, actor and delegation for audit and `permdock/otel`.

## What it validates [#what-it-validates]

| Input | Validation |
| --- | --- |
| Tool arguments | `inputSchema` (any Standard Schema), by the SDK 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`; skipped only for calls without `authInfo` when `requireAuthInfo` is off |
| `authorization_details` | Parsed into `delegation` and intersected with grants |
| Model-supplied subject or approval token | Never trusted; the subject comes from `authInfo` only, and a token in the arguments is ignored |

Validation failures are reported as `isError: true` results with the issue list, not as protocol errors, so the model can correct its arguments. Resources and prompts have no error result, so their refusals are thrown and reach the client as JSON-RPC errors.

## How denials surface [#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](/docs/concepts/decisions):

```json
{
  "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. Where the SDK's challenge does not apply (a transport without HTTP), the refusal carries `error: 'insufficient_scope'`, the needed `scope`, `resource_metadata` and the equivalent `www_authenticate` value.
* `insufficient-user-authentication` carries `error: 'insufficient_user_authentication'`, `acr_values`, `max_age` and `www_authenticate` (RFC 9470), and becomes a URL elicitation when `stepUp` is set.
* `approval-required` parks a pending request in the `store`. When `approval.at` is set and the client declares URL elicitation, the call answers with a multi-round-trip `input_required` result: one URL-mode `elicitation/create` request pointing at `at?token=…`, and the approval token as `requestState`. The client shows the URL, the reviewer approves, and the client retries with the same `requestState`, which resumes the call. Otherwise it returns `isError: true` with `structuredContent: { outcome: 'approval-required', token, … }`. A reviewer resolves it through [`approvalsHandler`](/docs/adapters/approvals) or `resolveApproval`. The client retries the same call, optionally with the token under `_meta["dev.permdock/approval"]` (`APPROVAL_META_KEY`); without it the adapter recomputes the token, which binds permission, resource id, principal, actor and arguments, and finds the record in the store. The approval is consumed on the first run, so a replay is refused with `approval-consumed`, and a token for different arguments or another caller never matches. A token in the tool arguments is ignored. See [approvals](/docs/security/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 [#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` parking never run. The binding already exists in the description: every operation PermDock covers carries `x-permdock-permissions` ([OpenAPI adapter](/docs/adapters/openapi)).

Recipe: run the bridge on the **applied** description (the producer's output with PermDock's [Overlay](/docs/standards/openapi-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 have the bridge register its tools on the server `protectServer` returns. `protectServer` takes no mapping: a tool registered on the raw server without a `permission` stays listed and callable, so a bridge that bypasses the guarded server is not guarded. `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 ([adapters](/docs/adapters), [ecosystem index](/docs/research/ecosystem-index)). 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 [#example-app]

`apps/examples/mcp-server`: a real SDK v2 `McpServer` with `list_posts`, `update_post`, `delete_post` and `publish_post`, served over Streamable HTTP (`createMcpHandler` behind `verifyBearerToken`, `requireAuthInfo: true`) and over stdio as the local user. Called through the SDK `Client`, a request without a token gets `401`, `tools/list` differs per token, a narrow token gets the `403` step-up naming `post:update`, and `delete_post` parks, runs once after `POST /approvals` and refuses the replay. `/rpc/mcp` serves the same posts as oRPC procedures through `enforce: 'procedure'` (`src/procedures.ts`).

## Why [#why]

* **The challenge names the operation's scopes, not the held set.** The 2026-07-28 authorization text says a runtime `insufficient_scope` challenge SHOULD carry the scopes the current operation needs, in one challenge, and makes accumulation a client responsibility. Echoing the held scopes back would look like the server granting them and would go stale as soon as the token changes.
* **`input_required` only with somewhere to send the user.** MRTR lets a client retry at once when the result has no `inputRequests`, so an approval answered with a bare `requestState` would spin until the client's round limit. The adapter returns `input_required` only when it has a URL for a human (`approval.at`, `stepUp.at`) and the client declared URL elicitation; otherwise the structured refusal lets the model tell the user. The request state is the approval token, which already binds principal, actor, permission, resource and arguments, so a tampered value can only fail to match; a server that verifies request state passes its codec as `requestState`.
* **Re-check at completion, opt-in.** A call that runs for minutes can outlive the grant that allowed it: a role is removed, a membership expires, the row changes owner. For those tools the adapter decides again before handing the result to the model and fails closed. It is opt-in because a second `data` load doubles the cost of every short call, and it is `longRunning` on the tool rather than tied to MCP tasks because SDK 2.x carries the task wire types but no task runtime; a tool that runs long today is an ordinary call that takes long. When the SDK ships task handlers, the same re-check runs before the task result is stored.
* **Delegation chains stay in the token layer.** The adapter reads `authInfo` only. Verifying an RFC 8693 `act` chain, and deciding how many hops are acceptable, belongs to the verifier that produced `authInfo` ([`permdock/jwt`](/docs/adapters/jwt) with `act` handling, or the SDK's bearer middleware). Doing it twice would let the two disagree about the same token.

## Related standards [#related-standards]

* [MCP authorization](/docs/standards/mcp-authorization): `scopeChallenge`, `requireScopes`, CIMD, RFC 9207, RFC 8707, EMA / ID-JAG, MRTR.
* [OAuth agent delegation](/docs/standards/oauth-agent-delegation): RFC 9396 `authorization_details`, RFC 8693 token exchange, delegation chains.
* [Approvals](/docs/security/approvals): `approval: 'human'`, replay-safe tokens.
* [Problem Details](/docs/standards/problem-details): shared vocabulary for the `structuredContent` refusal body.
* [OWASP Agentic Top 10](/docs/security/owasp-agentic): ASI02 Tool Misuse, ASI03 Identity and Privilege Abuse.
* [OpenAPI](/docs/standards/openapi) and the [ecosystem index](/docs/research/ecosystem-index): `x-permdock-permissions` as the binding for generated MCP servers.
