# MCP over OAuth 2.1 (/docs/agents/mcp-oauth)

---
title: MCP over OAuth 2.1
description: 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.
kind: guide
package: "@questpie/mcp"
---

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.

```ts title="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;
```

```bash
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.

<Callout type="info" title="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.
</Callout>

## 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`.

```ts title="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](/docs/agents/mcp-oauth/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.

<Callout type="info" title="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](/docs/agents/mcp).
</Callout>

## 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](/docs/agents/mcp-oauth/tokens) |
| Overrides, custom tools and the consent screen | [Scopes](/docs/agents/mcp-oauth/scopes)               |
| Tools, `config/mcp.ts` and the stdio transport | [MCP](/docs/agents/mcp)                               |
| The rules that run before any scope            | [Access control](/docs/schema/access-control)         |
| The login page the flow redirects to           | [Admin auth](/docs/admin/auth)                        |
| How a route opts into becoming a tool          | [Routes](/docs/code/routes)                           |

## Next

**[Connect an AI agent](/docs/guides/connect-an-agent)** runs this end to end,
from an empty folder to an editor writing real rows.
