MCP
mcpModule turns your app into a Model Context Protocol server. Collections, globals and opted-in routes become tools an agent calls, under the access rules you already wrote.
An agent connects and asks what it can do. What should it see?
You answer that in one file. Nothing is reachable until you name it there, so the empty answer is the safe one.
Add the module
import { mcpModule } from "@questpie/mcp/modules/mcp";
export default [mcpModule];questpie generateThe endpoint is live at /mcp, under your handler's base path, so /api/mcp
in the starters. It speaks Streamable HTTP and keeps no session between calls.
A fresh endpoint has no tools
Ask it for a tool list and you get an empty array. Every collection, global
and route stays hidden until config/mcp.ts names it. Adding the module
exposes nothing on its own.
Name what an agent may reach
Create config/mcp.ts and default-export mcpConfig(...). Name each entity,
then name each operation on it.
import { mcpConfig } from "@questpie/mcp";
export default mcpConfig({
name: "acme-cms",
version: "1.0.0",
crud: {
collections: {
posts: {
operations: { list: true, get: true, create: true, update: true },
fields: { exclude: ["internalNotes"] },
},
},
globals: {
siteSettings: { operations: { get: true } },
},
},
routes: {
routes: { "reports/generate": { operations: { execute: true } } },
},
resources: {
collections: { posts: true },
},
});Re-run codegen and reconnect. The agent now sees six tools.
| Tool | What it calls |
|---|---|
collections.posts.list | find(), with where, sort and limit |
collections.posts.get | findOne() by id |
collections.posts.create | create(), input { data } |
collections.posts.update | updateById(), input { id, data } |
globals.siteSettings.get | get() |
routes.reports.generate | the route handler, input is its .schema() |
count and delete never appear, because the config did not ask for them.
internalNotes is gone from every argument and every result. posts also gets
a schema resource, readable at questpie://schema/collections/posts.
The route needs a second opt-in. Naming it here is half of it, and the route
file itself must carry meta.mcp.expose: true. See
Metadata.
Re-run codegen after the module or a tool file changes
modules.ts and files under mcp-tools/ both change what codegen finds. Run
questpie generate, or keep questpie dev running. Editing config/mcp.ts
or a handler body needs no regen.
Four gates, every call
A collection or global tool is reachable only when all four of these pass.
| Gate | What it checks | Where you set it |
|---|---|---|
| Catalog | the entity is named and the operation is not false | config/mcp.ts |
| MCP rule | the operation's value, when you wrote a function | config/mcp.ts |
| OAuth scope | an OAuth caller holds the scope this operation needs | the user's consent screen |
| Access rules | the entity's own .access(), run as the connecting caller | the collection or global file |
All four run twice. Once when the server builds its tool list, so a tool the caller cannot use never appears. Once again when the tool is called.
A route tool swaps the fourth gate for the route's own .access() rule. A
custom tool has no entity behind it, so its own access rule and the scope
gate are the whole check.
Rules, not just booleans
An operation takes a function as well as a boolean. The function is the MCP gate, and it runs before your access rules.
collections: {
posts: {
operations: {
list: true,
delete: ({ session }) => session?.user?.role === "admin",
},
},
}The MCP config decides what is reachable through MCP at all. Access control decides what this caller may do. Neither one can widen the other.
Tools of your own
CRUD is not everything. Put an mcpTool() under mcp-tools/ and it becomes a
tool with a typed input and the full app context.
import { mcpTool } from "@questpie/mcp";
import { z } from "zod";
export default mcpTool("recalculate-stats", {
description: "Recompute cached site statistics.",
access: ({ session }) => session?.user?.role === "admin",
scopes: false,
inputSchema: z.object({ since: z.string().optional() }),
}).handler(async ({ input, ctx }) => {
const updated = await ctx.services.stats.recompute(input.since);
return {
structuredContent: { updated },
content: [{ type: "text", text: `Updated ${updated} rows.` }],
};
});access and scopes are both required. Leave either one out and the tool is
dropped from the catalog rather than exposed.
Run questpie add mcp-tool recalculate-stats to write the file and regenerate
in one step.
Where each topic lives
| Topic | Page |
|---|---|
Every McpConfig field, and what each default is | Configuration |
The mcpTool builder and its handler arguments | Custom tools |
| The HTTP route, stdio, and servers you mount yourself | Transports |
| Size caps, timeouts, concurrency and error codes | Limits and errors |
Turning one route into a tool with meta.mcp | Metadata |
| The rules the fourth gate runs | Access control |
| Doing all of it end to end, against a real client | Connect an AI agent |
Next
MCP over OAuth 2.1 is how an agent on someone
else's machine gets a principal at all. Without one the HTTP endpoint answers
401 and points at your discovery metadata.