QUESTPIE
Guides

Connect an AI agent

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.

View markdown

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

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

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

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

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

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

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.

ToolScope an OAuth caller must hold
collections.posts.list .count .getcollections:posts:read, or collections:read
collections.posts.create .updatecollections:posts:write, or collections:write
globals.siteSettings.getglobals: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

bunx questpie add mcp-tool count-published-posts

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

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.

bun run scaffold:verify

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

Point an editor at it

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

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.

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.

GateYou set it inWhat failing looks like
MCP policyconfig/mcp.tsthe tool is never built, for anyone
OAuth scopesthe consent screenhidden from this caller, denied on call
Access rules.access() on the collectionhidden 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 takes this app to a public HTTPS origin.

MCP integration is the reference for tools, resources and transports. MCP over OAuth 2.1 covers the scope model. Access control is the layer under both.

On this page