MCP over OAuth 2.1
Your MCP endpoint is not open. An external agent signs a real person in, receives an hour-long token bound to that one endpoint, and calls tools as that person inside the scopes they approved.
An agent runs on someone else's machine. It wants your rows. Who is it, and what may it touch?
QUESTPIE answers both with a standard OAuth 2.1 flow. The agent registers
itself. A person signs in and approves a list of scopes. Your app issues an
access token bound to /api/mcp. Every later tool call runs as that person,
inside the scopes they approved.
Turn it on
The OAuth provider lives in oauthModule. starterModule bundles it, and
adminModule pulls in the starter. So on an admin app you add one module.
import { adminModule } from "@questpie/admin/modules/admin";
import { mcpModule } from "@questpie/mcp/modules/mcp";
const modules = [adminModule, mcpModule] as const;
export default modules;questpie generateadminModule brings the provider, the five OAuth tables, the login page and
the consent screen. mcpModule mounts the endpoint. There is nothing else to
wire.
Headless apps supply the user model
oauthModule carries the provider and the OAuth tables. It has no user or
session table. Add starterModule for those, or add oauthModule on top of
your own better-auth model.
Nothing is exposed until you say so
Every collection, global and route starts at expose: false. Name the
operations you want in config/mcp.ts.
import { mcpConfig } from "@questpie/mcp";
export default mcpConfig({
crud: {
collections: {
posts: { operations: { list: true, get: true, create: true } },
},
},
});That file builds three tools. It also decides the scopes. The two reads need
collections:posts:read and the create needs collections:posts:write. An
agent cannot ask for a scope you never made, so a fresh app grants nothing.
What the client does
Point a compliant MCP client at https://your-app/api/mcp. It runs the whole
flow itself. You only sign in and approve.
- It calls the endpoint with no token and gets
401. TheWWW-Authenticateheader names the metadata document to read next. - It reads that document, then the authorization server metadata it points at.
- It registers itself at
/api/auth/oauth2/register. There is no client secret for you to copy anywhere. - It opens
/api/auth/oauth2/authorizein a browser with a PKCE challenge. - The person signs in at
/admin/loginand approves the scopes at/admin/oauth/consent. - It swaps the code at
/api/auth/oauth2/tokenand retries the call with the token.
That token lasts one hour and is bound to your MCP endpoint. See Discovery and tokens.
Scopes narrow, access rules decide
An OAuth token stands for a real person. That person's .access() rules run
first, exactly as they do in the admin. The approved scopes then narrow what is
left.
The scope gate can only take access away. It never adds any. A token holding
collections:posts:delete still cannot delete when the collection's delete
rule says no. A person who may delete anything still cannot do it through an
agent that was never granted the scope.
A tool that fails either gate is missing from tools/list. Calling it by name
is refused too.
Only the OAuth caller is scoped
A first-party admin session carries no scopes, so the gate passes and the access rules alone decide. A trusted local stdio worker runs in system mode and skips both. See stdio.
The scope names
Scope names are derived from the config above, never written by hand.
| Scope | An OAuth caller holding it may |
|---|---|
collections:posts:read | list, count and get that collection |
collections:posts:write | create and update rows in it |
collections:posts:delete | delete rows from it |
globals:settings:read | read that global |
globals:settings:write | update that global |
routes:reports/revenue:invoke | call that route tool |
Four coarse scopes sit above them: collections:read, collections:write,
globals:read and globals:write. Holding one satisfies every granular scope
of the same kind and verb. There is no coarse scope for delete or invoke.
Those two always need the exact name.
The working result
A browser tab opens. You sign in and approve the list. Ask the agent to add a
post, then open /admin/collections/posts. The row is there, authored by the
account you signed in with. Take an operation out of config/mcp.ts, reconnect,
and its tool is gone.
Where each topic lives
| Topic | Page |
|---|---|
| The three discovery documents and the token | Discovery and tokens |
| Overrides, custom tools and the consent screen | Scopes |
Tools, config/mcp.ts and the stdio transport | MCP |
| The rules that run before any scope | Access control |
| The login page the flow redirects to | Admin auth |
| How a route opts into becoming a tool | Routes |
Next
Connect an AI agent runs this end to end, from an empty folder to an editor writing real rows.