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)returnsprotectServer, which takes an SDK v2McpServerand returns the same server with permission-awareregisterTool,registerResourceandregisterPrompt. Registering any of them without apermissionthrows aTypeErrorat startup. There is no separaterequireScopes()export: a handler registered outsideprotectServeris not guarded.permissionis required, and each tool declares exactly one, so a refusal'salternativesare always those of that permission's resource. Collection actions (permissions.post.list) need nodata; instance actions takedata(args)so the adapter evaluateswhereconditions against the real row. Adataloader that throws or returnsnullis avalidationdenial, never a grant.subjectreceives the SDKAuthInfoand returns the principal (ornullfor anonymous).subjectFromMcp(authInfo, { claims, groupRoles, schema, delegation, actorKind })is the ready-made mapper when the verifier puts token claims onauthInfo.extra; it returns the anonymous subject for missing or malformed material and never throws. The adapter fillsactorwith{ id: authInfo.clientId, kind: actorKind }anddelegationwithauthInfo.scopesand, when present, RFC 9396authorization_details, so a decision is the principal's grants intersected with what the client was delegated.actorKind(default'mcp-client') is thekindof that actor; pass the same value tosubjectFromMcp.permdock/supabase,permdock/jwtandpermdock/a2agive an OAuth clientkind: 'oauth-client', so a server whose tools call the same app with the caller's token setsactorKind: 'oauth-client': onedeny(..., { 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 aTypeErrorfromcreatePermDockand makessubjectFromMcpreturn the anonymous subject.clients(aClientNames, oncreatePermDockandsubjectFromMcp) names client ids: the actor getsclientset to the name ofauthInfo.clientId, which a policy delegation matches withto: { kind, client }(policy delegations). A CIMD URL or a dynamically registered id maps through the function form.requireAuthInfo: truerefuses every call that arrives withoutauthInfo(an HTTP server behind bearer middleware). Without it a call with noauthInfo, such as a stdio server run by the local user, skips the scope check and is decided onsubjectalone.resourceis this server's RFC 8707 resource identifier (the URL clients call). A token whoseauthInfo.resourceis absent or names another server is refused withinvalid_tokenand sees an empty tool list. The comparison is on the parsed URL, sohttps://MCP.example.com/mcpmatcheshttps://mcp.example.com/mcp.store(anApprovalStore) holdsapproval-requiredcalls;sink,limits,memberships,customRoles,tenantandotelbehave as on every adapter;customRolesmay be aRoleSourceFactorythat builds the source for each call's subject.context(authInfo)returns plain JSON merged intosubject.contextunder the policy'scontext. Read it from the verifiedauthInfo, never from tool arguments; a throw adds nothing and reportson('error')(request data).onDenied({ decision, permission, text })may return replacement refusal text, for a localised or product-specific message.isError,structuredContentand the approval shape stay as PermDock built them;undefined, an empty string or a throw keeps PermDock's text.wrapwraps each call's instance afterotel; build it withwrapPermDock(wrapping an instance).approval: { at, hint }is where a human approves (the same hint HTTP adapters put on the problem body). Withatset, a client that declares URL elicitation gets aninput_requiredresult instead of the refusal (below).requestStatetakes the codec from the SDK'screateRequestStateCodecwhen 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, aninsufficient-user-authenticationdenial answers a client that declares URL elicitation with aninput_requiredURL request toat, withacr_valuesandmax_ageappended from the failingassurancegrants.- Tool
annotationsthe author leaves out are filled from the permission:readOnlyHintfrommeta.readOnly(or aread/listaction),destructiveHintfrommeta.destructiveandidempotentHintfrommeta.idempotent. Annotations are hints for the client; every call is still decided on the server. longRunning: trueon a tool re-runs the decision when the handler resolves, reloadingdataand building a fresh subject (so a changed membership or role source counts). The re-check never consumes quota. It passes when the outcome isgranted, when it isapproval-requiredwith the same token that was approved at submission, or when the only denial is a spentlimit; 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.protectServeralso filterstools/list,resources/list,resources/templates/listandprompts/listper caller, including handlers the SDK installed beforeprotectServerran. A filtered result of a 2026-07-28 request is markedcacheScope: 'private', over any server-levelcacheHints; 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(...).forOperationfrompermdock/openapireads it from the declaration the REST routes use (one declaration for REST and MCP). Apermissionin the tool config overrides it; neither throws aTypeErrorat registration.oauthScopesFor(name)(optional) names a tool's own OAuth scopes the same way, for the listing;operationPermissions(...).oauthScopesForOperationreads them from the same declaration, and anoauthScopesin the tool config overrides it.permissionOf(procedure)frompermdock/orpcreturns the permission of the procedure's firstprotect, orundefined. It reads the procedure definition and never decides.dataandlongRunningthrow 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 };-
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). -
Supabase Auth as the authorization server. When Supabase Auth is the OAuth 2.1 server,
@supabase/server'swithOAuthProtectedResource(alpha) replacesprotectedResourceHandler: it serves the RFC 9728 document at<resource>/oauth-protected-resourcewithauthorization_serverspointing at the project's Auth issuer, and enriches any401the inner handler returns withWWW-Authenticate: Bearer resource_metadata="…"unless the handler set its own challenge. Wrap thewithMcpAuthresult with it and pointcreateJwtSubjectResolverat the same issuer (https://<project>.supabase.co/auth/v1,algorithms: ['ES256'],audiencethe resource URL); the token flows throughpermdock/jwtunchanged andsubjectFromSupabaseis not involved because the claims are OAuth access-token claims, not a Supabase session. Outside Edge Functions passresourceServerexplicitly (RFC 9728 requires it to equal the URL the client called) andauthorizationServer: fromSupabaseUrl(projectUrl). Its metadata does not listscopes_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-requiredcall (aninput_requiredresult carrying the token asrequestState, or the structured refusal carryingtoken) is resumed on a later 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. -
better-supabase
createMcp.createMcp(betterSupabase, options)is its own MCP server, not an SDKMcpServer, soprotectServercannot wrap it (better-supabase). Plug PermDock into its per-tool hooks instead: carry the permission as the tool'smeta, narrow it withisPermission(a tool without one is hidden and refused), answervisiblewithmayUseandauthorizewithdecide. Both hooks get the tool context, whoseauthis the verified caller. better-supabase serves the RFC 9728 metadata and theinsufficient_scopechallenge; 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
metais not a permission in both hooks:authorizeis optional in better-supabase, and a missing or permissive one lets the call through. Anapproval-requiredoutcome is refused here, becausecreateMcphas no approval store; usepermdock/mcpon an SDK server when tools need human approval.createMcpvalidatesargsagainst the tool'sinputbeforeauthorize, which covers invariant 9 wheninputis the resource schema.advertisedScopes(formerlyscopes) only publishesscopes_supported;requiredScopesis what better-supabase enforces, answering 403insufficient_scopeto anoauth-clienttoken without one. Neither limits a support or impersonation session, which PermDock reaches only through a policy delegation (Supabase).mayUsefollows 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-Authenticatechallenges come from the host, and PermDock only needs the same resource URL. With@supabase/serverin 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 };withOAuthProtectedResourceis a pipeline entry and runs before any auth gate: it answersGET {resource}/oauth-protected-resourcewith the metadata document and addsWWW-Authenticate: Bearer resource_metadata="…"to a401from below that has no challenge yet. Serve it onGETtoo, or clients cannot fetch the document. Off Edge FunctionsresourceServeris required; without it the entry answers500 MISSING_RESOURCE_SERVER. WithwithSupabase({ auth: 'user' })as the gate instead ofwithMcpAuth, list it second in the same array.With better-supabase,
createMcpserves the document and the challenges itself, withadvertisedScopesasscopes_supported; pass the same URL asresourcewherever a PermDock adapter checks the token.withOAuthProtectedResourceomitsscopes_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
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. tools/list: the adapter builds a request-scopedPermDockfromauthInfoand returns only tools the caller could use: the token carries the scope (or the call has noauthInfoandrequireAuthInfois 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 sendsnotifications/tools/list_changedwhen it changed, for example after a role promotion.tools/call: the SDK validates arguments againstinputSchema; ifdatais declared, the resource is loaded and validated against the resource schema (boundary mode).- Resource and scope check: a token issued for another
resourceis refused withinvalid_token. If the token lacks the permission'sscope, the adapter answers with the SDKscopeChallenge, producing a403withWWW-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 underoauthScopes), 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 setsoauthScopes: ['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'soauthScopesand 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 withmcp:readand creating one withmcp:write. An empty list throws at registration. - Decision:
permdock.decide(permission, data)runs with the two-principal subject. - Outcome:
grantedruns the handler (and, for alongRunningtool, decides again when it resolves);deniedreturns a refusal;approval-requiredparks the call in theApprovalStoreand returns a refusal carrying the token (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), 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
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. Where the SDK's challenge does not apply (a transport without HTTP), the refusal carrieserror: 'insufficient_scope', the neededscope,resource_metadataand the equivalentwww_authenticatevalue. insufficient-user-authenticationcarrieserror: 'insufficient_user_authentication',acr_values,max_ageandwww_authenticate(RFC 9470), and becomes a URL elicitation whenstepUpis set.approval-requiredparks a pending request in thestore. Whenapproval.atis set and the client declares URL elicitation, the call answers with a multi-round-tripinput_requiredresult: one URL-modeelicitation/createrequest pointing atat?token=…, and the approval token asrequestState. The client shows the URL, the reviewer approves, and the client retries with the samerequestState, which resumes the call. Otherwise it returnsisError: truewithstructuredContent: { outcome: 'approval-required', token, … }. A reviewer resolves it throughapprovalsHandlerorresolveApproval. 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 withapproval-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_scopechallenge 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_requiredonly with somewhere to send the user. MRTR lets a client retry at once when the result has noinputRequests, so an approval answered with a barerequestStatewould spin until the client's round limit. The adapter returnsinput_requiredonly 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 asrequestState.- 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
dataload doubles the cost of every short call, and it islongRunningon 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
authInfoonly. Verifying an RFC 8693actchain, and deciding how many hops are acceptable, belongs to the verifier that producedauthInfo(permdock/jwtwithacthandling, or the SDK's bearer middleware). Doing it twice would let the two disagree about the same token.
Related standards
- MCP authorization:
scopeChallenge,requireScopes, CIMD, RFC 9207, RFC 8707, EMA / ID-JAG, MRTR. - 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 and the ecosystem index:
x-permdock-permissionsas the binding for generated MCP servers.
Last updated on
oRPC
permdock/orpc adds a request-scoped PermDock to oRPC context, a protect middleware fed by procedure input, and an OpenAPI security fragment for oRPC 2 `openapi()` metadata.
AI SDK
permdock/ai-sdk turns PermDock decisions into Vercel AI SDK 7 tool approvals, capability middleware and WorkflowAgent suspensions, fail-closed by construction.