QUESTPIE
Integrations

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 + the jwt() 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/mcp endpoint 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 ∩ RBAC model, a first-class principal (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:

Terminal
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:

src/questpie/server/modules.ts
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;
Terminal
questpie generate

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

Mermaid

Step by step:

  1. Discovery via 401. An unauthenticated POST /mcp returns 401 with a WWW-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-server to find the endpoints below.
  2. Dynamic client registration (DCR). The client POSTs to /api/auth/oauth2/register (RFC 7591) to obtain a client_id. Registered clients are public (token_endpoint_auth_method: "none"), so PKCE is required.
  3. Authorize + PKCE. The client sends the user's browser to /api/auth/oauth2/authorize with response_type=code, a PKCE code_challenge, and - critically - resource=<your-app>/api/mcp (RFC 8707), which binds the eventual token's audience to your MCP endpoint.
  4. 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. Approving POSTs { accept: true, oauth_query } to /api/auth/oauth2/consent; the provider persists the consent and redirects back to the client with an authorization code.
  5. Token exchange. The client exchanges the code at /api/auth/oauth2/token, sending the PKCE code_verifier and the same resource. It receives a short-lived (1-hour) access token.
  6. Call the endpoint. The client sends Authorization: Bearer <token> on POST /mcp. The app verifies the token statelessly against /jwks, resolves it to an oauth principal (the real user + consented scopes), and dispatches tool calls under scopes ∩ 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 patternGrantsExample
collections:<name>:readlist / get / count on that collectioncollections:posts:read
collections:<name>:writecreate / update on that collectioncollections:posts:write
collections:<name>:deletedelete on that collectioncollections:posts:delete
globals:<name>:readread that globalglobals:settings:read
globals:<name>:writeupdate that globalglobals:settings:write
routes:<key>:invokecall that exposed route toolroutes: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.

Mermaid

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/delete stays locked even if the token holds …:write and …:delete scopes. 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.

The consent page renders each requested scope as a human-readable line, so the granting user understands exactly what they're approving:

ScopeRendered as
openidVerify your identity
emailView your email address
collections:posts:readRead the Posts collection
collections:posts:writeCreate and update the Posts collection
collections:readRead all your collections
routes:reports/revenue:invokeRun 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:

src/questpie/server/mcp-tools/recalculate-stats.ts
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 aud is your /api/mcp URL, 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/revoke endpoint for refresh tokens and consent).
  • Stateless verification. Tokens are verified locally against the published /jwks key 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 /mcp route answers 401. system mode 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.

  • 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/login page the flow redirects to.
  • Modules - mcpModule and adminModule are 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 a routes:<key>:invoke scope.

On this page