ChaiBuilder Logo

MCP Custom Tools

Available from chaipro v0.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 to onToolCall.
  • 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:

  • permissions is 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:read as well as pages: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/list to 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.`)
  },
)
  • resolvePage accepts a full URL, a slug or a partial id, resolves language variants, and records the page on the audit event for you.
  • guardMcpPageWrite returns a message string when the write must not happen, or null when 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.
  • buildPreviewUrl builds a preview link from the plugin's previewUrl option, 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/list with 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/inspector opens a browser UI against your endpoint.
  • Unit test the handler directly: it is a plain async function of (args, ctx). Mock runChaiMcpAction and 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"> name is unique, lowercase with underscores.
  • <input disabled="" type="checkbox"> description says what it returns and what to call first.
  • <input disabled="" type="checkbox"> Every input field has .describe().
  • <input disabled="" type="checkbox"> permissions lists everything the handler needs, including pages:read for page writes.
  • <input disabled="" type="checkbox"> kind is right, and livePageWrite is set if it skips the draft.
  • <input disabled="" type="checkbox"> Actions go through runChaiMcpAction.
  • <input disabled="" type="checkbox"> Page writes use resolvePage and guardMcpPageWrite.
  • <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.

© ChaiBuilder. All rights reserved.