⏺ webmcp-ai.dev

home / guides / guide

Next.js WebMCP troubleshooting

document.modelContext undefined, StrictMode duplicate tools, AbortError on navigation, tools leaking across routes, missing Origin-Trial headers, ChatGPT limits, iframe and TypeScript errors: every common WebMCP failure in Next.js, with the cause and the fix.

By Ran Erez · Updated 2026-09-05

Most WebMCP problems in Next.js are not WebMCP problems. They are the usual suspects, server rendering, StrictMode, soft navigation, build-time configuration, showing up around a new browser API that fails silently. This page collects the failures we see in audits and in the Next.js reports published so far, each with the cause and the fix. If you are starting from scratch, read the Next.js WebMCP guide first; the patterns there avoid most of this list.

Quick diagnosis

SymptomStart here
Build or SSR crashesdocument is not defined
API missing in ChromemodelContext undefined, token and origin mismatch
Registers, but no agent sees itnavigator alias, iframes, ChatGPT limits
Console errors in developmentStrictMode, AbortError
Wrong tools on the wrong pagetool leaks, stale closures
Type or build errorsTypeScript, declarative CSS

1. "ReferenceError: document is not defined" or "navigator is not defined" during build or SSR

Cause. Something touches document.modelContext at module scope or during render. Next.js executes that code on the server, where neither global exists.

Fix. Register only inside a useEffect in a "use client" component. Never import a polyfill that self-initializes (the @mcp-b/global IIFE does) into a Server Component; import it inside the same client component or use the ESM build, which has no side effects on import. Do not make the root layout a client component to work around this.

"use client";
import { useEffect } from "react";

export function WebMCPTools() {
  useEffect(() => {
    // runs in the browser only
    const mc = (document as any).modelContext;
    if (!mc) return;
    // ...register here
  }, []);
  return null;
}

2. document.modelContext is undefined in Chrome

Cause. On localhost the testing flag is off. In production there is no valid origin-trial token for this exact origin, so stable Chrome does not expose the API at all. Unit tests that mock modelContext hide this; one team shipped eight tools that never registered for a single real user.

Fix. Locally, enable chrome://flags/#enable-webmcp-testing and restart Chrome. In production, register a token for the production origin and deliver it via a meta tag in the root layout or an Origin-Trial header from proxy.ts. Verify with the DevTools WebMCP pane on the live site, not in a test.

Cause. The code uses navigator.modelContext, following a tutorial from before June 2026. Chrome 150 deprecated that alias; ChatGPT only reads document.modelContext.

Fix. Use document.modelContext everywhere. If you must support the Chrome 149 origin-trial build, fall back to the navigator alias only when the document one is missing.

const mc = (document as any).modelContext ?? (navigator as any).modelContext;

4. Registration fails on the second mount in development (duplicate name, InvalidStateError, NotAllowedError)

Cause. React StrictMode mounts, unmounts and remounts every component in development. If the first registration is never cleaned up, the second one collides on the tool name. registerTool() rejects duplicate names.

Fix. Create an AbortController in the effect, pass its signal to registerTool, and abort it in the cleanup. The hooks (use-webmcp-tool, usewebmcp, @mcp-b/react-webmcp) all do this for you. A useRef "already registered" guard also works but leaks the tool if the component really unmounts.

useEffect(() => {
  const controller = new AbortController();
  mc.registerTool(tool, { signal: controller.signal }).catch(() => {});
  return () => controller.abort();
}, []);

5. Uncaught (in promise) AbortError in the console on every navigation

Cause. registerTool() returns a promise that rejects with AbortError when its signal is aborted. Unmounting before the registration settles, which is common on fast client-side navigations, leaves an unhandled rejection.

Fix. Catch the rejection and ignore it when the signal is already aborted. Report everything else.

mc.registerTool(tool, { signal }).catch((err) => {
  if (signal.aborted) return;        // expected on unmount
  console.error("WebMCP registration failed", err);
});

6. A tool from the previous page is still listed after navigating

Cause. The App Router does not reload the page on soft navigation, so a tool registered in a layout persists across every route under it, and a tool registered in an effect without cleanup persists until the tab closes.

Fix. Register global tools in the root layout and route-specific tools in the page or segment layout that owns them, with abort-on-unmount cleanup. Use the toolchange event or getTools() in the console to confirm what is exposed after each navigation.

document.modelContext.addEventListener("toolchange", async () => {
  const tools = await document.modelContext.getTools();
  console.log(location.pathname, tools.map((t) => t.name));
});

7. The tool executes but reads stale state or the UI does not update

Cause. The execute closure captured props or state from the render that registered it. Hooks avoid re-registering on every render, so the function may be from several renders ago.

Fix. Keep the latest handler in a ref and call through it, or make execute call a Server Action and let the returned data drive a state update. Chrome's guidance is to update the interface as part of the tool, since agents may read the page to decide their next step. After a mutation, call router.refresh() or the same state setter a click would.

const latest = useRef(handler);
latest.current = handler;
useWebMCP({ name: "add_item", description: "...",
  execute: (input) => latest.current(input) });

8. ChatGPT says "WebMCP is disabled for this page because the site's WebMCP configuration exceeds supported limits"

Cause. Too many tools, or descriptions and schemas that are too long. OpenAI has not published the exact limits; one report put the practical ceiling near twenty tools with descriptions under 1,500 characters. ChatGPT also requires additionalProperties: false on every object schema.

Fix. Register four to seven sharp tools per page instead of everything the site can do. Move detailed guidance from descriptions into the results the tools return. Add additionalProperties: false to every object, including nested ones.

Cause. The headers() array in next.config is evaluated once at build time. If the environment variable was not set in the build environment, the value baked in is empty. Self-hosted runtimes that inject variables at container start are hit hardest.

Fix. Set the header from proxy.ts (middleware.ts before Next.js 16), which runs per request, or render a meta http-equiv="origin-trial" tag from the root layout. For fully static routes the layout is rendered at build, so set the variable there as well.

// proxy.ts
export function proxy(req: NextRequest) {
  const res = NextResponse.next();
  const token = process.env.WEBMCP_ORIGIN_TRIAL_TOKEN;
  if (token) res.headers.set("Origin-Trial", token);
  return res;
}

10. The token is served but the API is still missing on this deployment

Cause. Origin-trial tokens are bound to one origin. A token for example.com does nothing on www.example.com, a Vercel preview URL, or a staging domain. Tokens also expire, silently, and WebMCP is disabled entirely when the page sets document.domain or serves Origin-Agent-Cluster: ?0.

Fix. Issue one token per origin you need, or use the "match subdomains" option when registering. Test preview deployments with the flag or in the ChatGPT desktop browser. Check the expiry date and put it on a calendar; the trial ends with Chrome 156. Remove any Origin-Agent-Cluster: ?0 header.

11. Tools registered in an embedded widget are not discovered

Cause. WebMCP is gated by the "tools" Permissions Policy, which defaults to same-origin. A cross-origin iframe cannot register tools unless the parent allows it. ChatGPT currently ignores tools from any iframe, same-origin included.

Fix. Add allow="tools" to the iframe for Chrome, and register the important tools from the top-level page for ChatGPT. If a third-party widget (chat, booking) is the thing you want agents to use, wrap its API in a tool on your own page instead.

<iframe src="https://booking.example.com/widget" allow="tools"></iframe>

12. TypeScript: "Property 'modelContext' does not exist on type 'Document'" or unknown JSX attribute "toolname"

Cause. WebMCP is not in TypeScript's DOM library yet, and React's JSX types do not know the declarative form attributes.

Fix. Install the webmcp-types package from the spec authors (recommended in Chrome's docs) or @mcp-b/webmcp-types, and add a small declaration for the form attributes.

// types/webmcp.d.ts
import "react";
declare module "react" {
  interface FormHTMLAttributes<T> {
    toolname?: string; tooldescription?: string; toolautosubmit?: boolean;
  }
  interface InputHTMLAttributes<T> { toolparamdescription?: string; }
}

13. Declarative form tools: the build rejects ":tool-form-active", or respondWith() throws

Cause. CSS tooling in the Next.js pipeline may drop or reject rules with pseudo-classes it does not recognize. Separately, respondWith() must be called synchronously inside the submit handler, before any await.

Fix. Put the tool-state styles in a plain CSS file served from public/ and link it, or inject a style element at runtime. In the submit handler, call preventDefault() and respondWith(promise) first, then do the async work inside the promise. Remember ChatGPT does not execute declarative tools at all; keep the imperative registration for the actions that matter.

form.addEventListener("submit", (e) => {
  if (!e.agentInvoked) return;          // a real person: let the normal submit run
  e.preventDefault();
  e.respondWith(submitReservation(new FormData(form)));  // before any await
});

14. Agents fill in hidden inputs, or every agent submission is rejected as spam

Cause. Elements hidden with opacity or off-screen positioning are still in the accessibility tree, so agents treat them as real fields. Timing-based honeypots ("submitted in under 3 seconds") reject agents, which are fast.

Fix. Use the inert attribute (React 19 supports it as a boolean) on hidden chat widgets and drawers. For forms that agents may submit, check event.agentInvoked and skip timing heuristics on that path; the tool call already proves a real user's browser is involved.

Verify before you close the ticket

  1. Open the live URL in Chrome with the testing flag and check the DevTools WebMCP pane lists every tool with the schema you expect.
  2. Run await document.modelContext.getTools() after navigating to two different routes and confirm the list changes the way you intended.
  3. Open the same URL in the ChatGPT desktop browser and confirm "Site tools" shows them. This is the only check that exercises a real agent runtime without a token.
  4. Run the free scan. It detects tool registration on the live page and flags the content and discoverability issues that no amount of tool debugging will fix.

If the tools work and the results are still wrong, the problem is usually design rather than code: too many tools, descriptions written for humans, or actions that complete without a confirm step. The Server Actions guide covers the backend rules, and a free strategy call is the fastest way to get a second pair of eyes on a tool set.

Sources