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.
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.
| Field | Type | Required |
|---|---|---|
access | McpAccessRule | yes |
scopes | string | string[] | false | yes |
title | string | no |
description | string | no |
inputSchema | Zod schema | no |
outputSchema | Zod schema | no |
annotations | ToolAnnotations | no |
workload | { capabilities, handoff? } | no |
_meta | Record<string, unknown> | no |
Write one
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 write | Effect |
|---|---|
access: true | Every caller passes this gate. |
access: false | The tool is dropped from the catalog. |
access: (ctx) => … | Evaluated per caller, at list time and at call time. |
scopes: false | No 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.
| Argument | Type | What it is |
|---|---|---|
input | z.infer<inputSchema> | Parsed and typed from inputSchema. |
ctx | AppContext & Partial<RequestContext> | collections, globals, db, services, session. |
transport | "http" | "stdio" | "workload" | Where the call arrived. |
accessMode | "user" | "system" | The resolved mode for this call. |
request | Request | undefined | The HTTP request, when there is one. |
signal | AbortSignal | Aborts on timeout or client cancel. |
requestId | string | number | The MCP request id. |
correlationId | string | Matches 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.
Configuration
config/mcp.ts is the whole catalog. It names every collection, global, route and resource an agent may reach, and the rule and OAuth scope on each one.
Limits and errors
Every tool call and resource read runs inside a budget for size, depth, time and concurrency. Every failure comes back as one of eight codes with a correlation id.