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.

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 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). 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).
  • 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).
  • 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 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

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.

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). 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

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).

  • Supabase Auth as the authorization server. When Supabase Auth is the OAuth 2.1 server, @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).

  • 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). 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). 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.

    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). 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:

    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).

  • 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. 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), 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

InputValidation
Tool argumentsinputSchema (any Standard Schema), by the SDK 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; skipped only for calls without authInfo when requireAuthInfo is off
authorization_detailsParsed into delegation and intersected with grants
Model-supplied subject or approval tokenNever 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

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. 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 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.
  • 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 parking 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 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, 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

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

  • 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 with act handling, or the SDK's bearer middleware). Doing it twice would let the two disagree about the same token.

Last updated on

On this page