⏺ webmcp-ai.dev

home / guides / guide

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:

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);
}

Caching, rate limits and bots

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

  1. One action per tool, four steps, no business logic.
  2. z.strictObject re-validation with a self-correcting error message.
  3. Session read inside the action; registration gated from the parent.
  4. Two-call confirm step and consequentialHint on anything irreversible.
  5. formatOutput converts { error } to isError.
  6. Output under 1,500 characters, paginated, redacted.
  7. A source tag 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