QUESTPIE
Agents

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.

View markdown

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.

src/questpie/server/modules.ts
import { adminModule } from "@questpie/admin/modules/admin";
import { mcpModule } from "@questpie/mcp/modules/mcp";

const modules = [adminModule, mcpModule] as const;

export default modules;
questpie generate

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

src/questpie/server/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.

  1. It calls the endpoint with no token and gets 401. The WWW-Authenticate header names the metadata document to read next.
  2. It reads that document, then the authorization server metadata it points at.
  3. It registers itself at /api/auth/oauth2/register. There is no client secret for you to copy anywhere.
  4. It opens /api/auth/oauth2/authorize in a browser with a PKCE challenge.
  5. The person signs in at /admin/login and approves the scopes at /admin/oauth/consent.
  6. It swaps the code at /api/auth/oauth2/token and 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.

ScopeAn OAuth caller holding it may
collections:posts:readlist, count and get that collection
collections:posts:writecreate and update rows in it
collections:posts:deletedelete rows from it
globals:settings:readread that global
globals:settings:writeupdate that global
routes:reports/revenue:invokecall 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

TopicPage
The three discovery documents and the tokenDiscovery and tokens
Overrides, custom tools and the consent screenScopes
Tools, config/mcp.ts and the stdio transportMCP
The rules that run before any scopeAccess control
The login page the flow redirects toAdmin auth
How a route opts into becoming a toolRoutes

Next

Connect an AI agent runs this end to end, from an empty folder to an editor writing real rows.

On this page