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.
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-agentNaming 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 devOpen 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
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
bunx questpie add mcp-tool count-published-postsThat writes a stub under mcp-tools/ and runs codegen. Replace its body:
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:verifyThat regenerates the app and type-checks it. It does not touch the database.
Point an editor at it
{
"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
- The first call has no credential, so it gets
401plusWWW-Authenticate. - That header names
/api/.well-known/oauth-protected-resource. Both discovery documents sit under the handler's base path,/apihere, not the site root. - The client registers itself at
/api/auth/oauth2/register. Dynamic registration, public client, no secret for you to copy anywhere. - It opens
/api/auth/oauth2/authorizewith a PKCE challenge. - You sign in at
/admin/loginand approve scopes at/admin/oauth/consent. - It swaps the code at
/api/auth/oauth2/tokenand 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.
| 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 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.