MCP over OAuth 2.1
Let external AI agents (Claude, IDEs) connect to your MCP endpoint through a real OAuth 2.1 authorization-code + PKCE flow, acting as a real user and bounded by scopes ∩ that user's RBAC.
Your MCP endpoint at /api/mcp is not meant to be open. When an external AI agent (Claude, an IDE, an autonomous agent on someone else's machine) connects, it should act as a specific person, with exactly the permissions that person has, and only the slice of them they explicitly granted. QUESTPIE does this with a standard OAuth 2.1 authorization-code + PKCE flow: the agent registers itself, the user signs in and approves a consent screen, and the app issues a short-lived access token bound to your MCP endpoint. Every tool call then runs as that user, gated by scopes ∩ RBAC - the consented scopes AND the user's own .access() rules, never more than either.
This is the remote, network-facing story. Local, trusted clients (a CLI on your own machine) use the stdio transport and are unaffected - they run as a system principal with no OAuth.
Prerequisites
Read the MCP integration page first - it covers mcpModule, how collections/globals/routes become tools, and the config/mcp.ts policy surface. This page adds the OAuth 2.1 auth layer on top. Familiarity with Access control helps too, since scopes layer on
top of your existing RBAC.
What you get
- A real OAuth 2.1 provider, backed by
@better-auth/oauth-provider+ thejwt()plugin. It ships with your app's auth config - you don't stand up a separate identity server. - Dynamic client registration (RFC 7591), so an MCP client self-registers at
/api/auth/oauth2/register- no manual client-secret handoff. - Authorization-code + PKCE, the OAuth 2.1 flow for public clients. PKCE is enforced for every registered client.
- A human consent screen at
/admin/oauth/consent, rendering the requested scopes in plain English so the user approves exactly what the agent asked for. - Audience-bound, short-lived JWT access tokens (RFC 8707). A token is bound to your
/api/mcpendpoint and expires in an hour, so it can't be replayed against other resources and revocation is cheap. - Root discovery documents -
/.well-known/oauth-authorization-server,/.well-known/oauth-protected-resource, and/jwks- so a compliant MCP client discovers where to authenticate on its own. - The
scopes ∩ RBACmodel, a first-classprincipal(user/oauth/system), and least-privilege scopes layered on top of your existing access rules.
Enabling it
The starter module, bundled by the admin module, provides the OAuth provider, jwks and oauth-* tables, and root discovery endpoints. On an admin-enabled TanStack Start or Next.js app, add the MCP route to use that built-in authorization server.
Scaffolding a new app - select the MCP module in create-questpie:
bunx create-questpie my-app
# → in the module multiselect, check:
# MCP - Model Context Protocol endpoint (OAuth 2.1)On TanStack Start / Next.js the scaffold already checks Admin by default, which brings in the starter (and therefore the OAuth provider). Adding MCP mounts /api/mcp on top.
Adding it to an existing app - add mcpModule to your server modules and re-run codegen:
import { adminModule } from "@questpie/admin/modules/admin";
import { openApiModule } from "@questpie/openapi";
import { mcpModule } from "@questpie/mcp/modules/mcp";
const modules = [adminModule, openApiModule, mcpModule] as const;
export default modules;questpie generateThat is the whole setup on an admin-enabled app: adminModule provides the OAuth provider + tables + discovery (via the starter it bundles), and mcpModule mounts the OAuth-gated /api/mcp route. This composition is covered end to end by the framework test suite.
Headless runtimes (Hono, Elysia) add the OAuth provider with `oauthModule`
The OAuth provider lives in the self-contained, composable oauthModule, alongside the oauthProvider() + jwt() plugins, the oauth-* tables + jwks, and the discovery routes. starterModule bundles it and adminModule pulls the starter in, so an admin-enabled app gets it for free (as above). A headless
/ custom-auth app that ships neither can add oauthModule on top of its own better-auth model to get the same provider + tables + discovery, HTTP MCP then works there too. (This shipped in 3.3.0, it is no longer a hand-wire-it-yourself follow-up.) With no provider at all, /api/mcp still mounts but stays 401
with nothing to discover. Local stdio needs no OAuth and works everywhere.
The flow
A compliant MCP client drives the whole thing itself once you point it at https://your-app/api/mcp. Here is what happens end to end.
Step by step:
- Discovery via
401. An unauthenticatedPOST /mcpreturns401with aWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"header. The client reads the Protected Resource metadata there, which points at the authorization server, then reads the Authorization Server metadata at/.well-known/oauth-authorization-serverto find the endpoints below. - Dynamic client registration (DCR). The client
POSTs to/api/auth/oauth2/register(RFC 7591) to obtain aclient_id. Registered clients are public (token_endpoint_auth_method: "none"), so PKCE is required. - Authorize + PKCE. The client sends the user's browser to
/api/auth/oauth2/authorizewithresponse_type=code, a PKCEcode_challenge, and - critically -resource=<your-app>/api/mcp(RFC 8707), which binds the eventual token's audience to your MCP endpoint. - Sign in + consent. If the user isn't signed in, the provider redirects to
/admin/login. Once signed in, it redirects to the consent screen at/admin/oauth/consent, which shows the requested scopes in plain English. ApprovingPOSTs{ accept: true, oauth_query }to/api/auth/oauth2/consent; the provider persists the consent and redirects back to the client with an authorizationcode. - Token exchange. The client exchanges the
codeat/api/auth/oauth2/token, sending the PKCEcode_verifierand the sameresource. It receives a short-lived (1-hour) access token. - Call the endpoint. The client sends
Authorization: Bearer <token>onPOST /mcp. The app verifies the token statelessly against/jwks, resolves it to anoauthprincipal (the real user + consented scopes), and dispatches tool calls underscopes ∩ RBAC.
Discovery lives at the server root, not under `/api/auth`
Better Auth serves OAuth metadata under its own base path (/api/auth/*), but MCP clients expect it at the server root. QUESTPIE's core module (always included) re-exposes all three documents at the root as ordinary routes - /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, and
/jwks - each delegating to the provider's own helpers, so the advertised issuer/JWKS/audience always match how tokens are actually issued.
A token is a verifiable JWT only when the client sets `resource`
The resource=<mcp-endpoint-url> parameter (RFC 8707) at both authorize and token is what makes the provider mint a JWT whose aud is your MCP endpoint. Without it you get an opaque access token that the stateless /jwks verification can't validate, and the /mcp route will reject it. A compliant MCP client sets resource automatically from the Protected Resource metadata; if you drive the flow by hand, you must set it yourself.
The scope model
Scopes describe what slice of your app an agent asked for. They are declarative and derived from your data model - one per resource and operation - plus two coarse umbrellas for convenience.
| Scope pattern | Grants | Example |
|---|---|---|
collections:<name>:read | list / get / count on that collection | collections:posts:read |
collections:<name>:write | create / update on that collection | collections:posts:write |
collections:<name>:delete | delete on that collection | collections:posts:delete |
globals:<name>:read | read that global | globals:settings:read |
globals:<name>:write | update that global | globals:settings:write |
routes:<key>:invoke | call that exposed route tool | routes:reports/revenue:invoke |
collections:read (umbrella) | the :read verb across all collections | - |
collections:write (umbrella) | the :write verb across all collections | - |
The mapping is pure data, parameterized by (resource kind, name, verb) - adding a collection, global, or route contributes its scopes automatically, with no framework code to touch.
Umbrellas exist for `read` and `write` only - never `delete` or `invoke`
A granular collections:posts:read requirement is also satisfied by holding the coarse collections:read umbrella (and likewise for write). But there is no umbrella for :delete or routes:…:invoke: those always require the exact granular scope. read and write umbrellas are independent - holding
collections:write never satisfies a :read requirement, and vice versa - and an umbrella never crosses resource kinds (collections:read never grants a globals:… scope). This keeps the convenience narrow and least-privilege.
Effective permission = scopes ∩ RBAC
This is the load-bearing guarantee. An oauth token represents a real person, so that person's access rules always apply first - then the consented scopes narrow further. The scope gate is additive-only: it can remove access from an OAuth caller, never grant it.
Both gates must pass, in either direction of narrowing:
- Scopes narrow RBAC. A collection whose RBAC allows a user everything, reached with a token that only holds
collections:posts:read, exposes only the read tools - create/update/delete stay hidden and are denied even on a direct call. - RBAC narrows scopes. A collection whose RBAC denies
update/deletestays locked even if the token holds…:writeand…:deletescopes. The scope gate can't grant past what.access()allows.
Out-of-scope (and out-of-RBAC) tools are absent from tools/list and rejected if the client calls them directly by name.
`user` and `system` principals are unchanged
The scope gate only applies to the oauth principal. A first-party admin cookie/bearer session is a user principal - no scopes, RBAC as today. A trusted local stdio worker is a system principal - it bypasses both the RBAC and scope gates entirely (full access). Only the
network-facing OAuth path is scope-gated.
What the user sees on the consent screen
The consent page renders each requested scope as a human-readable line, so the granting user understands exactly what they're approving:
| Scope | Rendered as |
|---|---|
openid | Verify your identity |
email | View your email address |
collections:posts:read | Read the Posts collection |
collections:posts:write | Create and update the Posts collection |
collections:read | Read all your collections |
routes:reports/revenue:invoke | Run the Reports/revenue action |
Unknown scopes fall back to a readable rendering of the raw string rather than a blank row, so a new resource never shows up unlabeled.
Consent uses the admin page
The consent screen lives at /admin/oauth/consent, the configured consentPage. It reads the signed authorization query, presents the requested scopes, and submits the decision to POST /oauth2/consent. The provider approves trusted clients with skip_consent server-side.
Custom tool scopes
Custom tools defined with mcpTool participate in the same gate. Declare the scopes a tool requires via its scopes config, and the gate enforces them for OAuth callers alongside the tool's access rule:
import { mcpTool } from "@questpie/mcp";
import { z } from "zod";
export default mcpTool("recalculate-stats", {
description: "Recompute cached site statistics.",
inputSchema: z.object({ since: z.string().optional() }),
scopes: ["collections:stats:write"], // OAuth callers must hold this
access: ({ session }) => session?.user?.role === "admin",
}).handler(async ({ input, ctx }) => {
const updated = await ctx.services.stats.recompute(input.since);
return {
structuredContent: { updated },
content: [{ type: "text", text: `Updated ${updated} rows.` }],
};
});As always, scopes is an OAuth-only narrowing on top of access - a user or system caller ignores it, and it can never grant past the access rule.
Security
- Audience-bound tokens (RFC 8707). Each token's
audis your/api/mcpURL, and verification rejects any token minted for a different resource - a token issued for one app's MCP endpoint can't be replayed against another endpoint. - Short TTL. Access tokens expire in 1 hour, so a leaked token has a small window and revocation stays cheap (there's a
/oauth2/revokeendpoint for refresh tokens and consent). - Stateless verification. Tokens are verified locally against the published
/jwkskey set on every request - no database round-trip on the hot path. The token is signed with EdDSA (Ed25519); an unsigned or wrong-key token can never pass verification. - No token ever elevates. A malformed, expired, wrong-audience, or wrong-issuer token resolves to no principal - the request falls through to the normal (unauthenticated) path and the
/mcproute answers401.systemmode is only ever set by a trusted transport (stdio), never derived from a token. - PKCE, always. Every DCR-registered client is public and must use PKCE.
RBAC is the floor, scopes are the ceiling
Because effective permission is scopes ∩ RBAC, an over-broad scope grant can never bypass your access rules, and a misconfigured access rule can never be widened by a scope. Design your .access() rules as the real security boundary; treat scopes as the user's least-privilege consent on top.
Related
- Connect an AI agent with MCP and OAuth - scaffold, configure, deploy, and connect Claude plus VS Code end to end.
- MCP integration - the base MCP module: how collections/globals/routes become tools,
config/mcp.ts, custom tools, and the stdio transport. - Access control - the
.access()RBAC rules that every OAuth tool call runs through first; scopes narrow, never widen, what they allow. - Admin auth - the auth config the OAuth provider ships in, and the
/admin/loginpage the flow redirects to. - Modules -
mcpModuleandadminModuleare modules you add; admin bundles the starter that carries the OAuth provider. - Routes - how a route opts into MCP tool exposure via
meta.mcp.expose, gaining aroutes:<key>:invokescope.
MCP
Add the mcpModule and your QUESTPIE app exposes a Model Context Protocol server, collections, globals, and annotated routes become tools an AI client can call, gated by your existing access rules.
OpenAPI and Scalar
Add one module and your whole API documents itself, an OpenAPI 3.1 spec generated from your collections, globals, routes, auth, and search, served as interactive docs through Scalar UI at /api/docs.