# Custom tools (/docs/agents/mcp/custom-tools)

---
title: Custom tools
description: 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.
kind: reference
package: "@questpie/mcp"
---

`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

```ts title="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 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](/docs/agents/mcp-oauth).

<Callout type="info" title="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.
</Callout>

## 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](/docs/agents/mcp/limits).

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

<Callout type="warn" title="`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.
</Callout>

## 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](/docs/code/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.

```ts
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](/docs/agents/mcp/transports).

## TypeScript

```ts
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.
