How to Test WebMCP Tools (Without a Flagged Chrome)
Short answer: To unit-test WebMCP tools, you install a mock navigator.modelContext in your test environment and drive it with a small helper. @mcptrail/webmcp-testing gives you installModelContext() — a spec-faithful fake plus a driver whose call() invokes a registered tool exactly the way an agent would, returning the structured result so you can assert on it. Your tools become ordinary Vitest tests: no Chrome 150 behind a flag, no live model, no browser at all.
If you’ve tried to write a test for a WebMCP tool, you’ve hit the wall: the tool only exists once navigator.modelContext is present, and that API only exists in a recent Chrome behind an experimental flag — and even then, actually calling the tool means wiring up a live agent. That’s a miserable loop for a function that, at its core, just takes JSON in and returns JSON out. This is how you skip all of it. (New to the API itself? Start with What is WebMCP?)
Why WebMCP tools are hard to test
A WebMCP tool isn’t a normal exported function. It’s registered against a browser-provided object, navigator.modelContext, and its handler is invoked by the agent runtime, not by your code. That indirection is the whole point in production — and the whole problem in a test.
Three things get in your way:
- No
navigator.modelContextin jsdom or Node. Your test environment doesn’t ship the API, soregisterToolthrows before you assert anything. - The real API needs a flagged browser. Chrome exposes WebMCP behind an experimental flag. You can’t reasonably gate CI on it.
- Calling a tool “for real” needs an agent. Even with the API present, the handler is meant to be driven by a model deciding what to call. You don’t want a live model in a unit test.
The fix is to replace the browser and the agent with a small, deterministic stand-in — and then your tools are just functions again.
Install a mock modelContext
@mcptrail/webmcp-testing (github.com/ElBartoTn/webmcp-testing, MIT) installs a faithful navigator.modelContext onto the current global and hands you a driver to call tools like an agent would.
npm install -D @mcptrail/webmcp-testing
import { installModelContext } from "@mcptrail/webmcp-testing";
import { expect, test } from "vitest";
import { renderApp } from "./app";
test("book_hotel reserves the right number of guests", async () => {
const mcp = installModelContext(); // mocks navigator.modelContext
renderApp(); // your app registers its WebMCP tools
const result = await mcp.call("book_hotel", { guests: 2, nights: 3 });
expect(result.confirmed).toBe(true);
expect(result.guests).toBe(2);
});
installModelContext() mounts the mock, so any code that calls navigator.modelContext.registerTool(...) during renderApp() registers against the fake. The driver’s mcp.call(name, args) looks up that tool, runs its handler with args, and returns whatever the handler returned — exactly the round trip an agent performs, minus the agent. No flag, no browser, no model.
Validate arguments the way an agent would
An agent picks arguments from your tool’s inputSchema. If your handler assumes the schema is honored but never checks, a malformed call slips through in production and your test never notices. @mcptrail/webmcp-testing closes that gap with schema-aware calls.
const mcp = installModelContext({ validate: true, strict: true });
renderApp();
// Throws: `guests` must be a number per the tool's inputSchema
await expect(
mcp.call("book_hotel", { guests: "two" }),
).rejects.toThrow(/inputSchema/);
| Option | What it does |
|---|---|
validate: true | Checks call arguments against the tool’s inputSchema before invoking the handler |
strict: true | Rejects unknown/extra properties instead of silently passing them through |
With validate/strict on, your test asserts the contract — that the tool accepts exactly what its schema advertises — not just the happy path. That’s the cheapest place to catch a schema that drifted away from its handler.
Assert against drift with snapshot()
The most valuable WebMCP test isn’t “does one call work” — it’s “did my published toolset change without me noticing.” snapshot() returns a stable hash of every registered tool’s name, description, and schema, so you can pin it.
test("published toolset is unchanged", () => {
const mcp = installModelContext();
renderApp();
// Fails the moment a tool is added, renamed, or its schema changes
expect(mcp.snapshot()).toMatchSnapshot();
});
Because the hash is stable across runs, a diff means a real change — a renamed tool, a new required field, a description edit. That turns silent toolset drift into a failing test on the pull request that caused it, which is exactly where you want to see it. For the browser-side, runtime version of this same idea, see Detect WebMCP toolset drift.
Where this fits in your test suite
You don’t need a new test runner. @mcptrail/webmcp-testing runs under Vitest (or Jest) in a jsdom or Node environment like any other unit test, so WebMCP tools sit right alongside the rest of your suite and run in milliseconds.
- Unit tests — one tool, one call, assert the result. Fast and deterministic.
- Contract tests —
validate/strictto prove handlers match their schemas. - Drift tests —
snapshot()to catch unintended toolset changes in CI.
If you’re building the tools these tests cover, the type-safe WebMCP kit gives you typed handlers, and the polyfill lets the same tools run in any browser once you ship.
FAQ
Do I need Chrome or a flag to run these tests?
No. That’s the whole point. installModelContext() installs a mock navigator.modelContext onto the test global, so your tools register and run in plain Node or jsdom. Your CI doesn’t need a flagged Chrome, a headed browser, or a live model — the tests are ordinary Vitest/Jest tests that finish in milliseconds.
How is calling a tool in a test different from production?
Functionally it isn’t — that’s the design. In production an agent reads your tool’s schema, picks arguments, and the runtime invokes your handler. mcp.call(name, args) does the same lookup-and-invoke round trip with arguments you supply, and returns the handler’s structured result. With validate: true it even enforces the inputSchema the way a well-behaved agent would.
Can I test that my toolset hasn’t changed?
Yes — use snapshot(). It produces a stable hash of every registered tool’s name, description, and schema. Pin it with toMatchSnapshot(), and any add, rename, or schema change fails the test. It’s the unit-test complement to runtime drift detection, catching changes on the pull request instead of in production.
Explore features · Open MCP Trail
Related articles
- Type-safe WebMCP tools with TypeScript — write the tools these tests cover, with typed handlers and schemas.
- Use WebMCP on any browser with a polyfill — ship the same tools where the native API isn’t available yet.
- Detect WebMCP toolset drift — the runtime counterpart to
snapshot(), catching changes in the browser. - What is WebMCP? — the plain-English guide to the API these tools are built on.