QUESTPIE

Custom tools

mcpTool() gives an agent one operation of your own, with a Zod input and the full app context. It runs the same authorization gates the generated CRUD tools run.

View markdown

mcpTool(name, config) returns a builder. Call .handler(fn) and you have a tool definition. Put the file under mcp-tools/, default-export it, and codegen registers it.

FieldTypeRequired
accessMcpAccessRuleyes
scopesstring | string[] | falseyes
titlestringno
descriptionstringno
inputSchemaZod schemano
outputSchemaZod schemano
annotationsToolAnnotationsno
workload{ capabilities, handoff? }no
_metaRecord<string, unknown>no

Write one

src/questpie/server/mcp-tools/publish-summary.ts
import { mcpTool } from "@questpie/mcp";
import { z } from "zod";

export default mcpTool("publish-summary", {
	title: "Publish summary",
	description: "Summarise one post for the newsroom feed.",
	access: ({ session }) => session?.user?.role === "editor",
	scopes: "collections:posts:read",
	inputSchema: z.object({ postId: z.string() }),
	outputSchema: z.object({ title: z.string(), words: z.number() }),
	annotations: { readOnlyHint: true },
}).handler(async ({ input, ctx }) => {
	const post = await ctx.collections.posts.findOne({
		where: { id: input.postId },
	});
	if (!post) throw new Error("No such post");
	const words = post.body.split(/\s+/).length;
	return {
		structuredContent: { title: post.title, words },
		content: [{ type: "text", text: `${post.title}: ${words} words.` }],
	};
});

Run questpie generate and the tool is called publish-summary. An editor sees it. Nobody else does. An OAuth editor also needs collections:posts:read or the collections:read umbrella.

questpie add mcp-tool publish-summary writes the file and regenerates for you.

The two required fields

access and scopes have no default, and leaving one out is not a shortcut to a public tool. The catalog drops any tool whose access or scopes is missing, so the tool silently does not exist.

You writeEffect
access: trueEvery caller passes this gate.
access: falseThe tool is dropped from the catalog.
access: (ctx) => …Evaluated per caller, at list time and at call time.
scopes: falseNo OAuth scope needed. The usual choice for a local tool.
scopes: "a:b:c"An OAuth caller must hold that scope.
scopes: ["a", "b"]An OAuth caller must hold both.

A CRUD tool derives its scope from the entity and the operation. A custom tool has neither, so there is nothing to derive. You have to say. See MCP over OAuth 2.1.

Nothing else gates a custom tool

There is no collection behind it, so no .access() rule runs. Your access function and the scope gate are the whole check. Read the session off the rule context and decide there.

Handler arguments

The handler receives one object.

ArgumentTypeWhat it is
inputz.infer<inputSchema>Parsed and typed from inputSchema.
ctxAppContext & Partial<RequestContext>collections, globals, db, services, session.
transport"http" | "stdio" | "workload"Where the call arrived.
accessMode"user" | "system"The resolved mode for this call.
requestRequest | undefinedThe HTTP request, when there is one.
signalAbortSignalAborts on timeout or client cancel.
requestIdstring | numberThe MCP request id.
correlationIdstringMatches the id in an error's _meta.

Honour signal. Work that ignores it keeps its concurrency slot until it actually finishes. That starves the next caller. See Limits and errors.

Input and output

inputSchema is parsed before your handler runs, so input is already the narrow type. Leave inputSchema out and the tool takes a strict empty object, which rejects any argument at all.

The handler returns an MCP CallToolResult. The type requires content, so a result with only structuredContent does not compile. Add structuredContent when the model should get a machine-readable answer alongside the text.

`outputSchema` is checked, not just declared

The agent sees it, and QUESTPIE also parses your structuredContent against it after the handler returns. A mismatch fails the call with an internal error rather than sending the wrong shape.

Registration

Codegen scans mcp-tools/. The tool name is the string you passed to mcpTool(), not the file name, so mcpTool("reports.revenue", …) in revenue.ts is called reports.revenue. A name that collides with a route tool throws Duplicate MCP tool name when the server starts.

A module can ship tools too, by putting them on mcpTools in its ModuleDefinition. They land in the same catalog and pass the same gates. See Modules.

Remote workloads

workload is what makes a tool visible to a server built with createWorkloadMcpServer. Without it the tool stays hidden from that server, whatever access says.

workload: {
	capabilities: ["messages.write"],
	handoff: "messages.commit",
},

capabilities are names your own authorizer reads. QUESTPIE never interprets them. handoff sends the call to your handoff, which gets an invoke function and decides whether to run the handler. Declare a handoff on a boundary that has none and the call is denied. See Transports.

TypeScript

import type {
	McpToolConfig,
	McpToolDefinition,
	McpToolHandlerArgs,
	McpAccessRule,
	McpWorkloadRequirement,
} from "@questpie/mcp";

The handler's input is inferred from inputSchema, so you never write that type yourself.

On this page