# Connect an AI agent (/docs/guides/connect-an-agent)

---
title: Connect an AI agent
description: An agent reaches your data over MCP. It signs in as a real user through OAuth. It sees only the operations you opened, and every call still runs your collection access rules.
kind: guide
package: "@questpie/mcp"
---

You want your editor, or Claude, to read and write real rows. Not a copy of your
API pasted into a prompt. This page starts from an empty folder. It ends with an
editor calling `collections.posts.create` as the account you signed in with.

## Scaffold the app

```bash
bunx create-questpie@latest questpie-mcp-agent \
	--template tanstack-start \
	--modules admin,openapi,mcp \
	--yes
cd questpie-mcp-agent
```

Naming modules replaces the defaults, so list all three. `mcp` mounts
`POST /api/mcp`. `admin` pulls in `starterModule`, which pulls in `oauthModule`.
That is where the OAuth provider, the login page and the consent screen live.

## Start it

```bash
docker compose up -d
bun run db:push
bun run dev
```

Open [http://localhost:3000/admin](http://localhost:3000/admin). The first visit
sends you to `/admin/setup`. Create the admin account there and keep it.

<Callout type="warn" title="`db:push` is for your laptop">
	It runs `questpie push`, which writes the schema straight to the database.
	Never point it at production, a deploy job or an init container. Production
	uses `questpie migrate` against committed migration files.
</Callout>

## Nothing is exposed yet

A `POST` with no credential gets `401` and a `WWW-Authenticate` header. Sign a
client in and it still lists zero tools. Every collection, global and route
starts at `expose: false`. There is no read-only default.

## Open the operations you need

```ts title="src/questpie/server/config/mcp.ts"
import { mcpConfig } from "@questpie/mcp";

export default mcpConfig({
	crud: {
		collections: {
			posts: {
				operations: {
					list: true,
					count: true,
					get: true,
					create: true,
					update: true,
				},
			},
		},
		globals: {
			siteSettings: { operations: { get: true } },
		},
	},
});
```

Codegen has to see that file. Run `bun run questpie:generate`, or let the
`questpie add` below do it. Every operation is named one at a time. There is no
`read: true` shorthand and no wildcard. `delete` is missing from that list, so
`collections.posts.delete` is never built. Neither is
`globals.siteSettings.update`.

| Tool                                     | Scope an OAuth caller must hold                   |
| ---------------------------------------- | ------------------------------------------------- |
| `collections.posts.list` `.count` `.get` | `collections:posts:read`, or `collections:read`   |
| `collections.posts.create` `.update`     | `collections:posts:write`, or `collections:write` |
| `globals.siteSettings.get`               | `globals:siteSettings:read`, or `globals:read`    |

You do not write those names. They are derived as `<resource>:<name>:<verb>`
from this same config, so the catalog and the gate cannot drift. The umbrella
exists only for `read` and `write`. A `delete` needs the exact granular scope.

## Add a tool of your own

```bash
bunx questpie add mcp-tool count-published-posts
```

That writes a stub under `mcp-tools/` and runs codegen. Replace its body:

```ts title="src/questpie/server/mcp-tools/count-published-posts.ts"
import { mcpTool } from "@questpie/mcp";
import { z } from "zod";

export default mcpTool("posts.countPublished", {
	description: "Count published posts.",
	access: ({ session }) => !!session,
	scopes: "collections:posts:read",
	inputSchema: z.object({}),
	outputSchema: z.object({ count: z.number() }),
	annotations: { readOnlyHint: true },
}).handler(async ({ ctx }) => {
	const count = await ctx.collections.posts.count({
		where: { published: true },
	});

	return {
		structuredContent: { count },
		content: [{ type: "text", text: `${count} published posts` }],
	};
});
```

`access` and `scopes` are both required fields, so TypeScript rejects a tool
that omits one. The catalog also drops it at runtime, silently. A custom tool
has no operation to derive a scope from. Write `scopes: false` to require none.

Return `content` as well as `structuredContent`. The result type requires
`content`. `outputSchema` validates `structuredContent`.

```bash
bun run scaffold:verify
```

That regenerates the app and type-checks it. It does not touch the database.

## Point an editor at it

```json title=".vscode/mcp.json"
{
	"servers": {
		"questpie": {
			"type": "http",
			"url": "http://localhost:3000/api/mcp"
		}
	}
}
```

Leave `APP_URL=http://localhost:3000` in `.env`. Tokens carry
`<APP_URL>/api/mcp` as their audience and verification compares the two exactly.
Run **MCP: List Servers**, start `questpie`, then finish the login in the tab it
opens. See the
[VS Code MCP guide](https://code.visualstudio.com/docs/agent-customization/mcp-servers).

## What the sign-in actually does

1. The first call has no credential, so it gets `401` plus `WWW-Authenticate`.
2. That header names `/api/.well-known/oauth-protected-resource`. Both discovery
   documents sit under the handler's base path, `/api` here, not the site root.
3. The client registers itself at `/api/auth/oauth2/register`. Dynamic
   registration, public client, no secret for you to copy anywhere.
4. It opens `/api/auth/oauth2/authorize` with a PKCE challenge.
5. You sign in at `/admin/login` and approve scopes at `/admin/oauth/consent`.
6. It swaps the code at `/api/auth/oauth2/token` and retries. That access token
   lasts one hour.

## Make the calls

Name the tools yourself the first few times.

```text
Use collections.posts.list to show the titles of my posts.
Use collections.posts.create to add a draft titled "MCP is connected".
Call posts.countPublished.
```

Open `/admin/collections/posts`. The draft is there, created by the account you
signed in with. That is the working result.

## When a call is refused

Three gates run on every call. All three must pass, and each one can only
narrow. A scope never grants what `.access()` refuses.

| Gate         | You set it in                 | What failing looks like                 |
| ------------ | ----------------------------- | --------------------------------------- |
| MCP policy   | `config/mcp.ts`               | the tool is never built, for anyone     |
| OAuth scopes | the consent screen            | hidden from this caller, denied on call |
| Access rules | `.access()` on the collection | hidden from this caller, denied on call |

The last two run per request. A tool one caller sees can be missing for another.
A missing write tool has three suspects. `operations.create`, the granted scope,
and the collection's own create rule.

## Next

**[Deploy it and connect Claude](/docs/guides/connect-an-agent/deploy-for-claude)**
takes this app to a public HTTPS origin.

[MCP integration](/docs/agents/mcp) is the reference for tools, resources and
transports. [MCP over OAuth 2.1](/docs/agents/mcp-oauth) covers the scope model.
[Access control](/docs/schema/access-control) is the layer under both.
