MCP Custom Tools
Available from
chaiprov0.4.0. The tool registry, the MCP route and the page helpers below all ship in that release.
The MCP server that agents connect to is not a fixed list. The mechanism (the tool
registry, the transport, the permission gate, the audit hook) is core, and the built-in
page, SEO, publishing and redirect tools are simply the first thing registered on it, by
the Pro mcpPlugin. Your app, or a plugin you write, registers tools the same way.
Typical reasons to add one: expose your own data to the agent (inventory, leads, bookings), wrap a custom action so the agent can call it, or give the agent a site-specific workflow ("create a landing page from a campaign brief").
Set up the server first: MCP Server Setup.
Where things are imported from
| Entry point | Use it for |
|---|---|
chaipro/nextjs/server |
Defining and registering tools and instructions, plus the helpers handlers call: defineChaiMcpTool, registerChaiMcpTools, registerChaiMcpInstructions, createChaiMcpResponse, runChaiMcpAction, recordChaiMcpToolDetail, recordChaiMcpToolError, toSafeChaiMcpError |
chaipro/plugins/mcp/server |
The page helpers the built-in tools use: resolvePage, guardMcpPageWrite, leasePageIds, buildPreviewUrl, readDraftBlocks, applyPageEdit |
chaipro/mcp |
The route only: createChaiMcpRouteHandlers, handleChaiMcpRequest |
Only chaipro/mcp imports @modelcontextprotocol/sdk. Import tool code from
chaipro/nextjs/server so a plugin that ships tools does not force the SDK on an app that
never mounts the route.
Anatomy of a tool
A tool is metadata plus a handler.
import { z } from 'zod'
import { createChaiMcpResponse, defineChaiMcpTool } from 'chaipro/nextjs/server'
export const countPages = defineChaiMcpTool(
{
name: 'count_pages',
kind: 'read',
description: 'Count the pages on this site, grouped by language.',
inputSchema: {},
permissions: ['pages:read'],
},
async (_args, ctx) => {
const pages = await ctx.cb.getPages()
const counts: Record<string, number> = {}
for (const page of pages) counts[page.lang] = (counts[page.lang] ?? 0) + 1
return createChaiMcpResponse(JSON.stringify(counts, null, 2))
},
)
| Field | Required | Notes |
|---|---|---|
name |
Yes | Unique across every owner. Lowercase letters, digits and underscores (list_leads). |
description |
Yes | What the agent plans with. Say what it returns and which tool to call first. |
inputSchema |
Yes | A zod raw shape, { id: z.string() }, not z.object({...}). Use {} for no input. .describe() every field. |
permissions |
Yes | The caller must hold all of them. [] means any authenticated caller. |
kind |
Yes | 'read', 'write' or 'publish'. Drives the client's read-only hint and the publish instructions the agent gets. |
title |
No | Short label for clients that show one. |
livePageWrite |
No | A write that changes the live site immediately rather than the draft (a slug change, a redirect). The agent is told so. |
sessionOnly |
No | A write that changes no content, only an edit lease. |
annotations |
No | destructiveHint, idempotentHint, openWorldHint, passed through to the client. |
dispatches |
No | Action names the handler dispatches, documented for permission audits. |
meta |
No | Anything you want to carry alongside the tool. |
The handler receives the parsed arguments and a context:
ctx field |
What it is |
|---|---|
cb |
The request-scoped ChaiBuilder instance, bound to the caller. The same object getChaiBuilder returns. |
userId, appId |
Who is calling, and for which site. |
permissions |
The caller's effective permissions, after every ceiling is applied. |
request |
The raw Request. |
detail |
An object you can add audit detail to. It reaches onToolCall. |
extra |
Whatever the route's extra option supplied. |
Arguments are validated against inputSchema before the handler runs. An invalid call
never reaches your code, and the agent gets the validation message.
Returning results
Return createChaiMcpResponse(text). The text is what the agent reads, so write it for the
agent: short, structured, and saying what to do next.
- A message starting with
Error:is flagged as a failed call automatically. Pass{ isError: true }to be explicit. - A thrown exception is caught for you. If it is a 4xx
ActionError(missing permission, validation, not found) its message goes to the agent, so it can stop instead of retrying. Anything else becomes a generic message, because server errors can carry SQL, connection strings or file paths. The real error goes toonToolCall. - If you catch an error to write a better message, call
recordChaiMcpToolError(error)first so the original still reaches your logs.
Register the tools
Register from a server plugin's setupHooks. That is where the built-in tools register,
and it runs once the config is fully resolved.
// src/plugins/inventory/server/index.ts
import { defineChaiServerPlugin, registerChaiMcpTools } from 'chaipro/nextjs/server'
import type { ChaiServerPlugin } from 'chaipro/nextjs/server'
import { getInventory, updateStock } from './mcp-tools'
export const inventoryPlugin = (): ChaiServerPlugin =>
defineChaiServerPlugin(
(config) => ({
...config,
setupHooks: [
...(config.setupHooks ?? []),
() => registerChaiMcpTools('app:inventory', [getInventory, updateStock]),
],
}),
{ name: 'app:inventory' },
)
Then add inventoryPlugin() to src/chaibuilder.plugins.ts. The owner string
('app:inventory') is the key the tools are stored under:
- Registering again under the same owner replaces that owner's list, so re-running the hook never duplicates tools.
- Claiming a name another owner already holds throws. To replace a built-in tool on
purpose, pass
{ override: true }:registerChaiMcpTools('app:inventory', [myPublishPage], { override: true }). unregisterChaiMcpTools(owner)removes everything an owner registered.
Registering from any server module that loads before the first MCP request also works, but a plugin keeps the tool, its action and its permissions in one place. See Developing Plugins.
Example: wrap a custom action
The usual pattern: the logic lives in a custom action, and the tool is a thin, agent-friendly wrapper around it.
// src/plugins/inventory/server/mcp-tools.ts
import { z } from 'zod'
import {
createChaiMcpResponse,
defineChaiMcpTool,
recordChaiMcpToolDetail,
runChaiMcpAction,
} from 'chaipro/nextjs/server'
type Product = { id: string; name: string; stock: number }
export const getInventory = defineChaiMcpTool(
{
name: 'get_inventory',
kind: 'read',
description:
'Look up stock for products. Returns id, name and stock for each. Pass productIds to ' +
'narrow the result, or omit it to list everything.',
inputSchema: {
productIds: z.array(z.string()).optional().describe('Product ids to look up'),
},
permissions: ['inventory:read'],
},
async (args) => {
const rows = await runChaiMcpAction<Product[]>('GET_INVENTORY', {
productIds: args.productIds,
})
recordChaiMcpToolDetail({ count: rows.length })
if (rows.length === 0) return createChaiMcpResponse('No matching products.')
return createChaiMcpResponse(
rows.map((p) => `- ${p.name} (${p.id}): ${p.stock} in stock`).join('\n'),
)
},
)
export const updateStock = defineChaiMcpTool(
{
name: 'update_stock',
kind: 'write',
livePageWrite: true, // stock is live data, there is no draft
description:
'Set the stock count for one product. Takes effect on the live site immediately. ' +
'Confirm the new count with the user first.',
inputSchema: {
productId: z.string().describe('Product id from get_inventory'),
stock: z.number().int().min(0).describe('New stock count'),
},
permissions: ['inventory:update'],
annotations: { idempotentHint: true },
},
async (args) => {
await runChaiMcpAction('UPDATE_STOCK', { productId: args.productId, stock: args.stock })
return createChaiMcpResponse(`Stock for ${args.productId} is now ${args.stock}.`)
},
)
Always dispatch actions with runChaiMcpAction, never dispatchChaiAction. Actions
report the cache tags and paths they touched and leave invalidation to whoever called them.
The builder's HTTP route does that; runChaiMcpAction does it for MCP. A raw dispatch
works but leaves the live site serving its old cached render.
The action still enforces its own requiredPermission, so a tool can never do more than
the action behind it allows.
Permissions for your tools
inventory:read and inventory:update are your keys. Grant them to the roles that should
use the tools, by name, the same way any plugin seeds its permissions:
defaultRoleGrants: {
...config.defaultRoleGrants,
editor: [...(config.defaultRoleGrants?.editor ?? []), 'inventory:read', 'inventory:update'],
viewer: [...(config.defaultRoleGrants?.viewer ?? []), 'inventory:read'],
},
A caller also needs mcp:use to connect at all. Viewers do not get it by default.
Rules worth keeping:
permissionsis required, and fail-closed. A tool whose permissions the caller lacks is not listed and not callable.[]means any authenticated caller: a real choice, rarely the right one.- List every permission the handler needs, not just the obvious one. A tool that writes
a page needs
pages:readas well aspages:update, because the write is a read, modify, write. Without it the tool passes the gate and fails underneath with an unhelpful message. - Hiding is not the boundary. Ungranted tools are left out of
tools/listto save the agent's context, but every call is re-checked regardless.
Example: a tool that writes a page
A tool that changes a page must behave like the built-in ones: resolve the page the same way, refuse to write a page someone has open, and take the AI edit lease. Use the shared helpers rather than reimplementing them.
import { z } from 'zod'
import {
createChaiMcpResponse,
defineChaiMcpTool,
recordChaiMcpToolError,
runChaiMcpAction,
} from 'chaipro/nextjs/server'
import { guardMcpPageWrite, leasePageIds, resolvePage } from 'chaipro/plugins/mcp/server'
export const markPageReviewed = defineChaiMcpTool(
{
name: 'mark_page_reviewed',
kind: 'write',
description: 'Mark a page as reviewed by legal. Takes the page URL or slug.',
inputSchema: {
url: z.string().describe('Page URL or slug, for example /pricing'),
note: z.string().max(500).optional().describe('Optional review note'),
},
permissions: ['pages:read', 'pages:update'],
},
async (args) => {
const resolved = await resolvePage(args.url as string)
if (!resolved.ok) return createChaiMcpResponse(resolved.message)
const { page, targetPageId } = resolved
// Refuses if someone has the page open, then takes or renews the AI edit lease.
const busy = await guardMcpPageWrite(leasePageIds(page, targetPageId), 'the review', page.name)
if (busy) return createChaiMcpResponse(busy)
try {
await runChaiMcpAction('MARK_PAGE_REVIEWED', { pageId: page.id, note: args.note })
} catch (error) {
recordChaiMcpToolError(error)
return createChaiMcpResponse(`Error: could not mark "${page.name}" as reviewed. Nothing was changed.`)
}
return createChaiMcpResponse(`Marked "${page.name}" (${page.slug}) as reviewed.`)
},
)
resolvePageaccepts a full URL, a slug or a partial id, resolves language variants, and records the page on the audit event for you.guardMcpPageWritereturns a message string when the write must not happen, ornullwhen it may. Return the message as is. It is written for the agent and never names the person holding the page.- To edit blocks, use
applyPageEdit(url, transform, summary). It resolves the page, guards it, reads the draft, runs your transform over the blocks, saves and returns a response with a preview link. buildPreviewUrlbuilds a preview link from the plugin'spreviewUrloption, so your tool's links match the built-in ones.
Instructions
Tool descriptions are short. Workflow rules that span several tools ("always call
get_inventory before update_stock", "never publish on Fridays") go in an
instruction section. Sections are rendered into the instructions the agent receives
when it connects.
import { defineChaiMcpInstructionsSection, registerChaiMcpInstructions } from 'chaipro/nextjs/server'
const inventoryRules = defineChaiMcpInstructionsSection({
id: 'inventory',
title: 'Inventory',
order: 150,
requiresTools: ['get_inventory', 'update_stock'],
body: [
'- Call get_inventory before update_stock and show the user the current count.',
'- Stock changes are live. Ask before every update_stock call.',
].join('\n'),
})
// In the same setup hook as the tools:
registerChaiMcpInstructions('app:inventory', [inventoryRules])
| Field | Notes |
|---|---|
id |
Unique across owners. |
order |
Sort position. The built-in sections use 0 to 100, so use a higher number to come after them. |
title |
Rendered as a heading. Omit for a preamble. |
requiresTools |
The section is rendered only if the caller can see at least one of these tools, so an agent is never told about a tool it cannot call. |
body |
A string, or ({ tools, config }) => string evaluated per request, where tools is what this caller can see. |
To drop a built-in section, pass its id to mcpPlugin({ excludeInstructionSections }).
Built-in ids: preamble, page-workflow, page-management, edit-session, ai-html,
styling, icons, images, animation, data-binding, custom-blocks, language,
seo, slugs, redirects, revisions, revisions-restore, review-publish-gate.
Trimming the built-in tools
mcpPlugin takes exclude and include lists:
mcpPlugin({ exclude: ['delete_page', 'publish_page'] }) // everything except these
mcpPlugin({ include: ['get_pages_list', 'get_page_outline'] }) // only these
This removes tools for everyone. To restrict per user, use permissions. To restrict per
request, use the route's toolFilter.
Route options
createChaiMcpRouteHandlers in src/app/api/mcp/route.ts takes:
| Option | What it does |
|---|---|
getChaiBuilder |
Required. (request) => getChaiBuilder({}, request). Cookies are stripped before it runs, so only header credentials count. |
serverInfo |
Required. { name, version, title? } reported to the client. |
connectPermission |
Permission(s) needed to connect at all. Use MCP_PERMISSIONS['mcp:use']. |
delegatedPermissions |
A ceiling for the whole route, intersected with the caller's permissions. ['pages:read', 'mcp:use'] gives you a read-only MCP endpoint without touching how keys are issued. |
toolFilter |
(tool, ctx) => boolean. Last word on which tools a request sees, after permissions. |
hideUngrantedTools |
Default true. Leaves tools the caller cannot use out of tools/list. |
instructions |
A string to replace the rendered instructions, or (rendered, ctx) => string to wrap them. |
onToolCall |
Audit sink, called after every tool call. See below. |
onAuthFailure |
Called on a 401 or 403, with the reason and any missing permission. |
extra |
An object, or (request) => object, carried onto every tool context and audit event. Handy for a key label or client name. |
maxArgsBytes |
Largest arguments payload for one call. Default 2 MB. |
maxBodyBytes |
Largest request body. Default 4 MB. |
realm, resourceMetadataUrl |
Values for the 401 challenge, so an OAuth-capable client can discover where to authenticate. |
Two route files with different options are a legitimate setup: for example /api/mcp
for editors and /api/mcp-readonly with a delegatedPermissions ceiling for a reporting
agent.
The onToolCall event:
| Field | Notes |
|---|---|
toolName, outcome |
Outcome is ok, error, forbidden, invalid or not_found. |
durationMs, userId, appId |
|
args |
Size-capped copy of the arguments. Keys that look like secrets (token, password, apiKey and similar) are redacted. |
page |
The page the tool touched, recorded by resolvePage. |
detail |
Whatever the handler added with recordChaiMcpToolDetail. |
message |
What the caller was shown, when the outcome is not ok. |
error |
The real error. Never sent to the caller. |
extra |
From the route's extra option. |
A failure inside onToolCall is logged and never fails the tool call.
Non-Payload hosts
Nothing in the MCP layer is Payload specific. The route resolves identity through the same
context resolver as the builder API, so whatever your createChaiBuilder context returns
(userId, appId, permissions, delegatedPermissions) is what the MCP request runs
as. Accept whatever Authorization scheme your resolver already understands. A request
the resolver cannot identify gets a 401 challenge.
On Payload, createPayloadChaiBuilder also passes authStrategy ('jwt' or 'api-key')
to a permissions override, so you can give API keys a narrower ceiling than a person's
browser session.
Testing your tools
- List what a key sees.
tools/listwith that key's header. A missing tool almost always means a missing permission. See MCP Server Setup. - Call one by hand with the MCP inspector:
pnpm dlx @modelcontextprotocol/inspectoropens a browser UI against your endpoint. - Unit test the handler directly: it is a plain async function of
(args, ctx). MockrunChaiMcpActionand assert on the returned text. - Read the agent's view. Connect a real client and ask it what tools and rules it has. If the agent misuses your tool, the fix is almost always a clearer description or an instruction section, not more code.
Checklist
- <input disabled="" type="checkbox">
nameis unique, lowercase with underscores. - <input disabled="" type="checkbox">
descriptionsays what it returns and what to call first. - <input disabled="" type="checkbox"> Every input field has
.describe(). - <input disabled="" type="checkbox">
permissionslists everything the handler needs, includingpages:readfor page writes. - <input disabled="" type="checkbox">
kindis right, andlivePageWriteis set if it skips the draft. - <input disabled="" type="checkbox"> Actions go through
runChaiMcpAction. - <input disabled="" type="checkbox"> Page writes use
resolvePageandguardMcpPageWrite. - <input disabled="" type="checkbox"> Caught errors are passed to
recordChaiMcpToolError. - <input disabled="" type="checkbox"> Results never name another user or leak internal detail.
- <input disabled="" type="checkbox"> Cross-tool rules live in an instruction section with
requiresTools.
Related
- MCP Server Setup - enable the server and connect a client.
- Developing Plugins - the plugin these tools usually live in.
- API Reference - custom actions and the request-scoped instance.
- AI Customization - tools for the in-builder assistant.

