WebMCP tools backed by Next.js Server Actions
The backend pattern for WebMCP in Next.js: Server Actions as execute handlers, strict Zod re-validation, authorization inside the action, output budgets, and a two-call confirm step for consequential tools like bookings and checkout.
By Ran Erez · Updated 2026-09-05
A WebMCP tool is two halves. The half everyone writes about is the registration: a name, a
description, a JSON Schema. The half that decides whether the tool is safe to ship is the
execute function, and in Next.js the right implementation of that function is a
Server Action. This guide is the backend companion to the
Next.js WebMCP guide. It covers the four-step action
pattern Vercel used for its storefront, then the parts a storefront demo does not need:
authorization, consequential actions, and output budgets.
Why Server Actions fit WebMCP
An agent calling a WebMCP tool runs inside the visitor's tab, as the visitor. That is the whole point of the protocol and it is what makes Server Actions the natural backend:
- Same origin, same cookies. A
"use server"function is invoked as a POST to the current route and carries the session automatically. A logged-in user's agent can book, reorder, or cancel without you building OAuth for machines. - No public API surface. Actions are addressed by hashed IDs and are only callable from your own client bundle. You are not opening a documented endpoint that scrapers will find.
- Type-safe hand-off. You import the action into the Client Component and pass it as
execute. There is no fetch, no URL, no JSON plumbing to get wrong. - Serialization is already solved. Arguments and return values must be serializable, which is also exactly what an agent can consume.
The trade-off is that actions are App Router only and they always POST. If you are on the Pages Router, or you need a tool that agents outside a browser can also call, use a route handler instead; the last section shows that variant.
The four-step action
Every action follows the same shape: validate, call one existing data function, shape a small result, return it. Resist the urge to put business logic in the action itself. The tool should wrap a capability your site already has, so that a bug fix in the booking service fixes the agent path too.
// lib/webmcp/actions.ts
"use server";
import { z } from "zod";
import { getSession } from "@/lib/auth";
import { findSlots, createBooking } from "@/lib/bookings";
// A shared, tiny error contract. Codes let the agent decide whether to retry.
type ErrorCode = "INVALID_INPUT" | "UNAUTHENTICATED" | "NOT_FOUND" | "CONFIRM_REQUIRED" | "UPSTREAM_UNAVAILABLE";
const fail = (code: ErrorCode, message: string) => ({ error: { code, message } });
const AvailabilityInput = z.strictObject({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
partySize: z.number().int().min(1).max(12)
});
export async function checkAvailability(input: unknown) {
const parsed = AvailabilityInput.safeParse(input);
if (!parsed.success) return fail("INVALID_INPUT", "Send date as YYYY-MM-DD and partySize 1-12.");
try {
const slots = await findSlots(parsed.data.date, parsed.data.partySize);
return {
date: parsed.data.date,
slots: slots.slice(0, 8).map((s) => ({ time: s.time, slotId: s.id })),
moreSlotsAvailable: slots.length > 8
};
} catch (err) {
console.error("WebMCP availability failed", err);
return fail("UPSTREAM_UNAVAILABLE", "Availability is unavailable right now. Try again in a minute.");
}
}
Validate strictly, and again
The inputSchema you register tells the agent what to send. Nothing enforces it.
Chrome does not reject a call that omits a required field, and a model that has
misread your description will happily send { } to a tool that needs an ID. One
production report describes a navigation tool that received an empty object and sent the user
to /templates/undefined. Use z.strictObject so unknown keys fail
too, keep the constraints tighter than the schema's, and return a message that tells the
model how to fix the call. Chrome's own guidance is "validate strictly in code, loosely in
schema" with "descriptive errors to allow the model to self-correct".
Never accept an identifier you did not hand out
If your search tool returns product handles, your add-to-cart tool should accept a handle and derive the variant server-side. Accepting a raw variant ID, cart ID, or user ID from the agent turns a confused model, or a prompt-injected one, into a way to act on objects the user never saw. Vercel's storefront re-resolves the variant from the product on every add and never exposes cart IDs or checkout URLs in results at all.
Authorization belongs inside the action
The Next.js docs make a point that matters twice as much for WebMCP: Server Actions are not
separate routes. They are POSTs to the page they are used on, so a proxy.ts
matcher that excludes a path also skips the proxy for every action called from that path. A
refactor that moves a component can silently remove your auth check. Read the session from
cookies inside each action, and treat the agent exactly like a button click from the same
user: if the user could not do it in the UI, the action must refuse.
The other half of authorization is exposure. A tool that only makes sense for signed-in users
should not be registered for anonymous ones. Gate the component that registers it from the
parent (or pass enabled: !!session) rather than checking inside the tool, so the
registration is aborted the moment the session ends.
Consequential tools: the two-call confirm step
Reads can execute freely. Anything that spends money, sends a message, or cannot be undone
should not complete on a single agent call, no matter how confident the model sounds. The spec
added a consequentialHint annotation in September 2026 so agent runtimes can
surface these tools differently, but the annotation is a hint, not a gate. Build the gate into
the action: the first call returns a summary and a short-lived token, the second call with
the token performs the action.
const BookInput = z.strictObject({
slotId: z.string().min(1).max(64),
partySize: z.number().int().min(1).max(12),
// The agent must echo this back. It proves the user saw the summary.
confirmationToken: z.string().min(1).max(64).optional()
});
export async function bookTable(input: unknown) {
const parsed = BookInput.safeParse(input);
if (!parsed.success) return fail("INVALID_INPUT", "slotId and partySize are required.");
const session = await getSession(); // reads the request's cookies
if (!session) return fail("UNAUTHENTICATED", "Ask the user to sign in on this page, then retry.");
const slot = await findSlotById(parsed.data.slotId);
if (!slot) return fail("NOT_FOUND", "That slot no longer exists. Call check_availability again.");
// First call: return a summary and a token, do not book.
if (!parsed.data.confirmationToken) {
const token = await issueConfirmationToken(session.userId, slot.id);
return {
status: "confirmation_required",
summary: { time: slot.time, partySize: parsed.data.partySize, cancellationPolicy: "Free until 2h before." },
confirmationToken: token,
instruction: "Show the summary to the user. Call book_table again with confirmationToken only after they agree."
};
}
// Second call: verify the token belongs to this user and slot, then book.
const ok = await verifyConfirmationToken(parsed.data.confirmationToken, session.userId, slot.id);
if (!ok) return fail("CONFIRM_REQUIRED", "Confirmation token is invalid or expired. Start again.");
const booking = await createBooking({ userId: session.userId, slotId: slot.id, partySize: parsed.data.partySize });
return { status: "booked", bookingId: booking.publicId, manageUrl: "/bookings/" + booking.publicId };
}
The token is tied to the user and the slot on the server, so an agent cannot skip the summary
by inventing one, and cannot reuse a token for a different reservation. The
instruction field in the first response is deliberate: it is text the model will
read, so tell it what to do next in plain language. This is the same "human stays in the
loop" rule from our tool cookbook, implemented rather
than hoped for.
Wire it up in the Client Component
// components/webmcp-tools.tsx
"use client";
import { useWebMCP } from "use-webmcp-tool";
import { checkAvailability, bookTable } from "@/lib/webmcp/actions";
function reportErrors(result: unknown) {
if (result && typeof result === "object" && "error" in result) {
const e = (result as { error: { code: string; message: string } }).error;
return { content: [{ type: "text", text: e.code + ": " + e.message }], isError: true };
}
return result;
}
export function BookingTools() {
useWebMCP({
name: "check_availability",
description: "List open reservation times for a date and party size. Call this before book_table.",
inputSchema: { type: "object",
properties: {
date: { type: "string", description: "YYYY-MM-DD" },
partySize: { type: "integer", minimum: 1, maximum: 12 } },
required: ["date", "partySize"], additionalProperties: false },
annotations: { readOnlyHint: true },
execute: checkAvailability,
formatOutput: reportErrors
});
useWebMCP({
name: "book_table",
description:
"Reserve a table. The first call returns a summary and confirmationToken; show the summary to the user, then call again with the token to book.",
inputSchema: { type: "object",
properties: {
slotId: { type: "string" },
partySize: { type: "integer", minimum: 1, maximum: 12 },
confirmationToken: { type: "string" } },
required: ["slotId", "partySize"], additionalProperties: false },
annotations: { readOnlyHint: false, consequentialHint: true },
execute: bookTable,
formatOutput: reportErrors
});
return null;
}
The reportErrors formatter is not optional. Without it the hook serializes
{ error: ... } as a successful result, and the agent cannot tell a failed
lookup from an empty one. Turning the code and message into an MCP isError
result is what lets the model read "NOT_FOUND: call check_availability again" and actually do
that.
Budget the output
Tool results go into the agent's context window, on every call, for the rest of the task. Big results make the model slower and dumber, and ChatGPT disables WebMCP on pages whose configuration exceeds its limits. Vercel's storefront caps every result at 1,450 characters and paginates with cursors; the helper is ten lines.
const MAX_OUTPUT_CHARACTERS = 1_450;
// Add items until the serialized result would exceed the budget.
function fitToBudget<Item, Out>(items: Item[], build: (included: Item[]) => Out): Out {
const included: Item[] = [];
for (const item of items) {
if (JSON.stringify(build([...included, item])).length > MAX_OUTPUT_CHARACTERS) break;
included.push(item);
}
return build(included);
}
- Truncate free text (titles to 80 characters, descriptions to a sentence).
- Return handles and public IDs, not internal IDs, tokens, or URLs with secrets in them.
- Return a
nextcursor or offset instead of a bigger page. - Never pass upstream error text through. Log it, return a fixed message.
- Mark tools that return user-generated or third-party content with
untrustedContentHint: true, so the runtime can treat the text as data rather than instructions.
Caching, rate limits and bots
- Actions are never cached. For read tools that hit an expensive backend, wrap the data function with
"use cache"or your existing cache layer, not the action. - Rate limit per session, not per IP. An agent working through a task will call a read tool several times in a few seconds from the user's own browser. Limits tuned for scrapers will break legitimate agent use.
- Bot protection needs a decision. BotID and similar checks on a cart endpoint will see a real browser with a real session; that is usually a pass. Timing-based honeypots ("submitted in under three seconds") are not, and one Next.js team found their lead-form tool rejected every agent submission for exactly that reason.
- Tag the source. Add a
source: "webmcp"field to bookings and leads created through tools. It is the cheapest analytics you will ever set up, and the first thing you will want when someone asks whether this is working.
Route handlers: when and how
Use a route handler instead of an action when you are on the Pages Router, when the tool
result should be cacheable at the CDN, or when the same endpoint must serve non-browser
callers such as a classic MCP server. The tool's execute then fetches it, and it
should pass along the signal Chrome provides so an agent that cancels the call
cancels the request.
// app/api/agent/search/route.ts
import { z } from "zod";
import { searchIndexProducts } from "@/lib/products";
const Input = z.strictObject({ query: z.string().trim().min(1).max(120) });
export async function POST(req: Request) {
const parsed = Input.safeParse(await req.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Send { query }." }, { status: 400 });
const products = await searchIndexProducts(parsed.data.query);
return Response.json({ products: products.slice(0, 5) });
}
// and in the tool:
// execute: async (input, { signal }) => {
// const res = await fetch("/api/agent/search", { method: "POST",
// headers: { "content-type": "application/json" }, body: JSON.stringify(input), signal });
// return res.json();
// }
Checklist
- One action per tool, four steps, no business logic.
z.strictObjectre-validation with a self-correcting error message.- Session read inside the action; registration gated from the parent.
- Two-call confirm step and
consequentialHinton anything irreversible. formatOutputconverts{ error }toisError.- Output under 1,500 characters, paginated, redacted.
- A
sourcetag on everything an agent creates.
Which tools deserve this treatment on your site is a design question before it is a code question. The cookbook has the per-industry patterns, and a free strategy call turns them into a blueprint for your pages. When the tools register but something still fails, see the Next.js troubleshooting guide.
Sources
- Vercel Commerce: WebMCP tools PR (actions as execute, output budget, redaction)
- Next.js: proxy.ts (Server Actions are handled as POSTs to the route where they are used)
- Chrome: WebMCP best practices and tool security
- OpenAI: site tools in ChatGPT
- WebMCP draft (annotations, AbortSignal cancellation)