QUESTPIE
Agents

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.

View markdown

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

src/questpie/server/modules.ts
import { mcpModule } from "@questpie/mcp/modules/mcp";

export default [mcpModule];
questpie generate

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

src/questpie/server/config/mcp.ts
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.

ToolWhat it calls
collections.posts.listfind(), with where, sort and limit
collections.posts.getfindOne() by id
collections.posts.createcreate(), input { data }
collections.posts.updateupdateById(), input { id, data }
globals.siteSettings.getget()
routes.reports.generatethe 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.

GateWhat it checksWhere you set it
Catalogthe entity is named and the operation is not falseconfig/mcp.ts
MCP rulethe operation's value, when you wrote a functionconfig/mcp.ts
OAuth scopean OAuth caller holds the scope this operation needsthe user's consent screen
Access rulesthe entity's own .access(), run as the connecting callerthe 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.

src/questpie/server/mcp-tools/recalculate-stats.ts
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

TopicPage
Every McpConfig field, and what each default isConfiguration
The mcpTool builder and its handler argumentsCustom tools
The HTTP route, stdio, and servers you mount yourselfTransports
Size caps, timeouts, concurrency and error codesLimits and errors
Turning one route into a tool with meta.mcpMetadata
The rules the fourth gate runsAccess control
Doing all of it end to end, against a real clientConnect 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.

On this page