Connect an AI agent with MCP and OAuth
Scaffold a QUESTPIE app with MCP, expose safe CRUD and a custom tool, then connect Claude and VS Code through per-user OAuth 2.1.
This tutorial starts with a fresh TanStack Start app and ends with two AI clients calling your QUESTPIE data through the remote MCP endpoint:
- Claude, through a remote custom connector.
- VS Code, through a workspace
mcp.jsonentry.
Both clients authenticate as a real QUESTPIE user. They register through Dynamic Client Registration (DCR), use authorization code + PKCE, show the user a consent screen, and receive a token bound to this app's /api/mcp endpoint. Every tool call is then limited by all three gates:
MCP policy ∩ consented OAuth scopes ∩ collection/access rulesYou will expose read and write tools for the starter posts collection, keep delete disabled, and add a typed posts.countPublished custom tool.
Prerequisites
- Bun 1.3 or newer.
- Docker for the local Postgres database.
- A public HTTPS deployment for Claude. Claude's remote connectors run from Anthropic's cloud, including when you use Claude Desktop, so they cannot reach a server that only exists on
localhost. See Anthropic's remote connector network requirements. - VS Code with GitHub Copilot if you want to follow the IDE section. VS Code can connect to a local or remote HTTP MCP server.
1. Scaffold the app with MCP
Run the published scaffolder non-interactively:
bunx create-questpie@latest questpie-mcp-agent \
--template tanstack-start \
--modules admin,openapi,mcp \
--yes
cd questpie-mcp-agentExplicit module selection replaces the defaults, so include all three modules. The result contains:
adminModule, which also brings the OAuth provider, account tables, login, and consent UI.openApiModule, for the REST reference at/api/docs.mcpModule, for the OAuth-protected Streamable HTTP endpoint at/api/mcp.
Verify the generated app before changing it:
bun run scaffold:verifyThis command regenerates the app and runs TypeScript. It does not mutate the database.
2. Start the local database
Start Postgres, then create the local development schema:
docker compose up -d
bun run db:pushdb:push is local development only
bun run db:push calls questpie push. Never run it against production, in a deployment job, or in an init container. --force does not make it production-safe. For production, generate a migration with bun run migrate:create, review and commit it, then apply it with bun run migrate during deployment.
Start the app:
bun run devOpen http://localhost:3000/admin. The first visit redirects to the one-time setup screen. Create the first admin account and keep those credentials for the OAuth sign-in later.
3. Open only the MCP operations you need
HTTP MCP is read-only by default. Add config/mcp.ts to allow authenticated agents to read and create/update posts while keeping deletion closed:
import { mcpConfig } from "@questpie/mcp";
export default mcpConfig({
crud: {
collections: {
posts: { read: true, write: true, delete: false },
},
globals: {
siteSettings: { read: true, write: false },
},
},
});This contributes these relevant tools:
| Tool | MCP policy | OAuth scope |
|---|---|---|
collections.posts.list / count / get | allowed | collections:posts:read or collections:read |
collections.posts.create / update | allowed | collections:posts:write or collections:write |
collections.posts.delete | hidden | unavailable even if a token has a delete scope |
globals.siteSettings.get | allowed | globals:siteSettings:read |
globals.siteSettings.update | hidden | unavailable |
The OAuth scope does not grant database access by itself. The signed-in user's normal collection .access() rules still run and can further narrow every operation.
4. Add a typed custom tool
Create a file-convention MCP tool that counts published posts:
import { mcpTool } from "@questpie/mcp";
import { z } from "zod";
export default mcpTool("posts.countPublished", {
description: "Count published posts.",
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` }],
};
});Custom tool results include both structuredContent for the model and an MCP content block. The public handler type requires content; do not return only structuredContent.
This example does not declare an extra custom-tool scope. The HTTP endpoint still requires an authenticated principal, and the ctx.collections.posts.count() call still runs the user's collection access rules. Add scopes or access to the tool when it needs a narrower rule; see custom tool scopes.
Regenerate and type-check both new files:
bun run scaffold:verifyCodegen should report one discovered mcpTools entry.
5. Understand the OAuth flow
Pointing a compliant client at /api/mcp is enough to start the flow:
- The unauthenticated MCP request receives
401and aWWW-Authenticatechallenge pointing to protected-resource metadata. - The client reads
/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-server. - DCR creates a public OAuth client at
/api/auth/oauth2/register; no hand-created client secret is needed. - The client opens
/api/auth/oauth2/authorizewith a PKCE challenge and the/api/mcpresource indicator. - You sign in at
/admin/loginand approve the requested scopes at/admin/oauth/consent. - The client exchanges the code at
/api/auth/oauth2/token, then retries/api/mcpwith the audience-bound access token.
The access token expires after one hour. Its effective permission is the intersection of consented scopes and the signed-in user's RBAC; OAuth can narrow access but cannot elevate it.
You can inspect discovery on a running deployment without obtaining a token:
curl https://cms.example.com/.well-known/oauth-protected-resource
curl https://cms.example.com/.well-known/oauth-authorization-server
curl -i https://cms.example.com/api/mcpThe final request should be unauthorized and include the discovery challenge. A public 200 from /api/mcp without a session or token is a security problem.
6. Prepare the public deployment
Set the deployed app origin exactly; OAuth uses it to bind tokens to the MCP audience:
APP_URL=https://cms.example.com
BETTER_AUTH_SECRET=<a-strong-production-secret>
DATABASE_URL=<production-postgres-url>Generate the migration in development, review it, and commit it:
bun run migrate:create
git add src/questpie/server/migrations
git commit -m "chore: add initial database migration"During deployment, apply only committed migrations:
bun run migrateNever replace migrate with push
Production uses questpie migrate, not questpie push. Do not put push, db:push, or push --force in a container command, init container, CI deploy step, or production runbook.
Before connecting a client, confirm that /api/mcp, both root /.well-known/* documents, /api/auth/oauth2/register, /admin/login, and /admin/oauth/consent all route to the same deployment.
7. Connect Claude
Claude remote custom connectors support Streamable HTTP, OAuth, and DCR. QUESTPIE therefore needs only the endpoint URL; leave the optional advanced client ID and secret empty.
For an individual account:
- Open Customize → Connectors.
- Select + → Add custom connector.
- Enter a name and
https://cms.example.com/api/mcp. - Leave advanced OAuth credentials empty and add the connector.
- Select Connect, sign in with the admin account you created, and review the consent screen before approving it.
- Enable the connector for a conversation from the + → Connectors menu.
Team and Enterprise plans require an Owner to add the connector under Organization settings → Connectors before members connect their own accounts. Anthropic documents both flows in Get started with custom connectors using remote MCP.
Do not put this remote server in claude_desktop_config.json. That file is for local stdio servers; Claude's remote connectors are configured through Connectors and run from Anthropic's cloud.
8. Connect VS Code
Add a workspace MCP configuration:
{
"servers": {
"questpie": {
"type": "http",
"url": "https://cms.example.com/api/mcp"
}
}
}VS Code discovers the server, registers through DCR, then completes browser login and consent. See the official VS Code MCP developer guide and MCP server configuration guide.
Open the Command Palette and run MCP: List Servers. Start questpie, confirm that you trust the server, then complete the browser login and consent flow. VS Code lists the discovered tools in Chat's tool picker. If you need to discard a stale DCR registration, run Authentication: Remove Dynamic Authentication Providers and reconnect.
For local-only testing, change the URL to http://localhost:3000/api/mcp. Keep APP_URL=http://localhost:3000 so the issued token's audience matches the endpoint exactly.
9. Exercise CRUD and the custom tool
Ask the connected client to make explicit calls while you learn the surface:
Use collections.posts.list to show the titles and published state of my posts.Use collections.posts.create to create a draft post titled "MCP is connected".Call posts.countPublished and return the count.Then open /admin/collections/posts and confirm that the created draft exists. If the write tool is missing, check both sides of the gate:
config/mcp.tsmust setposts.write: true.- The OAuth grant must include
collections:posts:writeorcollections:write. - The signed-in user must pass the collection's normal create/update access rules.
Delete remains unavailable because the server policy set delete: false, regardless of what the model asks for.
What to read next
- MCP integration is the complete tool, resource, policy, transport, and custom-tool reference.
- MCP over OAuth 2.1 covers DCR, PKCE, consent, scopes, token verification, and
scopes ∩ RBACin depth. - AI integration is the separate server-side worker and resumable execution package. You do not need
@questpie/aito expose an MCP server to Claude or an IDE. - Access control is the security boundary beneath MCP policy and OAuth scopes.
Getting started, TanStack Start
Scaffold a QUESTPIE app, boot Postgres, push your schema, and run a first typed-client query, from zero to a running full-stack app.
Collections
A collection is one typed, versioned database table defined in code, and from that single definition you get CRUD, an admin UI, REST routes, OpenAPI, and a typed client, all in sync.