Interceptors and HTTP mocking
@frontmcp/testing fakes things at two ends. mcp.mock and mcp.intercept live in the test client: they see and change the requests it sends and the answers it gets, and a mocked request never reaches your server. MockAPIServer and httpMock fake the HTTP APIs your tools call: MockAPIServer is a real HTTP server you point your tools at, and httpMock replaces fetch in the process that runs the tests. The server test.use({ server }) starts runs in a process of its own, where httpMock can't see its requests, so to fake an API for it, use MockAPIServer. None of this is in the Playground: every example on this page was run with frontmcp test and Jest 30 on FrontMCP 1.9.1. The mocks are unchanged in 1.9.2's and 1.9.3's @frontmcp/testing.
mcp.mock.tool(name, result, { times?, delay? }); // and toolError, resource, resourceError, toolsList, resourcesList, add
mcp.intercept.request((ctx) => ({ action: "passthrough" })); // or "modify", "mock", "error"
mcp.intercept.response((ctx) => ({ action: "passthrough" })); // or "modify"
new MockAPIServer({ port, openApiSpec, routes }).start();
httpMock.interceptor().get(url, response); // and post, put, delete, any, mock
Reference
Which one to use
| To test… | Use | Works with a server from test.use({ server }) |
|---|---|---|
| What the client sends, or how your test code copes with an answer or a failure | mcp.intercept, mcp.mock | Yes: they work in the client, and the server never sees a mocked request. |
| A tool that calls an HTTP API, against canned answers | MockAPIServer | Yes, when the API's URL comes from the environment: pass the mock's URL in test.use({ env }). |
The same, with per-test answers and a record of each fetch() | httpMock | No. Only when the server runs in the test's own process: see Running the server in the test process. |
mcp.mock
Answers the client's requests itself, without sending them. Every member returns a handle, except clear() and all().
| Member | Answers |
|---|---|
tool(name, result, { times?, delay? }) | tools/call for name, with one text block: result, JSON-encoded unless it's a string. There's no structuredContent; json() parses the text. The tool doesn't have to exist. |
toolError(name, code, message, { times? }) | tools/call for name, with a JSON-RPC error: result.error is { code, message }, so toBeError(code) matches it. It's not a tool error, which a real failing tool returns: see toBeError. |
resource(uri, content, { times?, delay? }) | resources/read for uri. A string content is { uri, text: content }, with no mimeType; an object is { uri, text?, blob?, mimeType? }. |
resourceError(uri, { times? }) | resources/read for uri, with -32002 Resource not found: <uri> and data: { uri }. |
toolsList(tools, { times? }), resourcesList(resources, { times? }) | tools/list or resources/list, with these entries. |
add(definition) | Whatever definition matches: see below. |
clear() | Removes every mock. |
all() | The mocks' definitions. |
mcp.mock.add(definition)
| Field | Type | Description |
|---|---|---|
method | string | The JSON-RPC method, like "tools/call". Required. |
params | object, or (params) => boolean | Which requests match. An object matches when every key in it equals the request's, recursively, so { name: "search_tickets" } matches any arguments; arrays must have the same length. A function decides for itself. Without params, every request of method matches. |
response | JSON-RPC response, or (request) => response | What to answer. A function receives the whole request and can return a promise. The answer's id is replaced with the request's. |
times | number | How many requests it answers. After that it stops matching, and requests go to the next mock that matches, or to the server. Default: unlimited. |
delay | number | Milliseconds to wait before answering. |
Mocks are tried in the order they were added, and the first that matches answers.
Mock handles
| Member | Description |
|---|---|
callCount() | How many requests it answered. |
calls() | Those requests, as sent: { jsonrpc, id, method, params }. |
remove() | Removes the mock. |
mockResponse
Builds JSON-RPC responses for mcp.mock.add() and interceptors. Import it from @frontmcp/testing.
| Helper | Returns |
|---|---|
success(result) | { jsonrpc: "2.0", id: 1, result } |
error(code, message, data?) | { jsonrpc: "2.0", id: 1, error: { code, message, data } } |
toolResult(content), toolsList(tools), resourcesList(resources), resourceRead(contents) | A result of { content }, { tools }, { resources } or { contents }. |
errors.methodNotFound(method), errors.toolNotFound(name) | -32601, Method not found: <method> or Tool not found: <name> (with data: { name }). |
errors.invalidParams(message), errors.internalError(message) | -32602 or -32603 with message. |
errors.resourceNotFound(uri) | -32002, Resource not found: <uri>, data: { uri }. |
errors.unauthorized(), errors.forbidden() | -32001 Unauthorized, -32003 Forbidden. |
mcp.intercept
Runs your functions on each request before it's sent, and on each answer before the client reads it. Each adder returns a function that removes what it added.
| Member | Description |
|---|---|
request(fn) | Adds a request interceptor: see below. |
response(fn) | Adds a response interceptor: see below. |
delay(ms) | Waits ms before sending every request. |
failMethod(method, message?) | Rejects every request of method without sending it, with an Error whose message is message, by default Intercepted: <method>. |
clear() | Removes every interceptor, and keeps the mocks. |
clearAll() | Removes the interceptors and the mocks. |
Request interceptors
fn(ctx) receives ctx.request, the JSON-RPC request (jsonrpc, id, method, params), and ctx.meta: { timestamp, transport: "streamable-http", sessionId }. It returns, or resolves to, one of:
| Result | What happens |
|---|---|
{ action: "passthrough" } | Nothing: the request goes on. |
{ action: "modify", request } | request is sent instead. Later interceptors see it. |
{ action: "mock", response } | response is the answer, and nothing is sent. Its id is replaced with the request's. |
{ action: "error", error } | Nothing is sent, and the call rejects with error itself: mcp.tools.call(), mcp.resources.read(), mcp.prompts.get(), the list() methods and mcp.raw.request() all reject with the object you returned. (Before 1.8.7 the first three resolved to a failed result whose error was { code: -32603, message }, and the list() methods threw Failed to list tools: <message>.) |
Response interceptors
fn(ctx) receives ctx.request, ctx.response (the JSON-RPC response, with its result or error) and ctx.durationMs. It returns { action: "passthrough" }, or { action: "modify", response } to hand the client response instead.
The order things run in
- Mocks. The first that matches answers, and the request interceptors don't run.
- Request interceptors, in the order they were added.
- The server, if nothing answered or failed the request.
- Response interceptors, in the order they were added, for mocked answers too.
Notifications, from mcp.raw.notify() and mcp.notifications.send(), go straight to the server.
interceptors
Ready-made interceptors, to pass to mcp.intercept.request() or .response():
| Helper | Kind | What it does |
|---|---|---|
logger(log?) | request | Calls log("[MCP Request] <method>", params). Default console.log. |
delay(ms) | request | Waits ms. |
failWhen(condition, error) | request | Rejects the requests condition(ctx) is true for, with error (an Error or a message). |
modifyMethod(method, modify) | request | Sends modify(request) instead, for requests of method. |
responseLogger(log?) | response | Calls log("[MCP Response] <method> OK (12ms)", response), or ERROR. |
modifyResponse(method, modify) | response | Hands the client modify(response), for requests of method. |
Caveats
- Mocks and interceptors belong to one client. The next test's
mcpstarts with none, and a client fromserver.createClient()has its own. - A mock tests the client side, not your tool. The server never sees a mocked request, so the tool's code doesn't run. To test a tool against a fake of the API it calls, mock the API: see
MockAPIServer. - Delays don't count toward the client's timeout.
mcp.setTimeout(ms)only times the HTTP request, which starts after the delays ofdelay(), interceptors and mocks. A request that does time out fails with-32000andRequest timeout after 200ms, whatever the number of milliseconds is. (Before 1.8.7 it failed with-32603Unknown error.)
MockAPIServer
A real HTTP server that answers the routes you give it, so a tool in any process can call it.
import { MockAPIServer } from "@frontmcp/testing";
const crm = new MockAPIServer({
port: 50910,
openApiSpec: {},
routes: [{ method: "GET", path: "/customers/C-1", response: { body: { name: "Acme", plan: "pro" } } }],
});
| Option | Type | Default | Description |
|---|---|---|---|
port | number | a random free port | The port to listen on. Without one, put the URL start() resolves to in test.use()'s env object in beforeAll: the server reads env when it starts, after beforeAll (since 1.9.0). |
openApiSpec | any | Required. Served at /openapi.json and /openapi.yaml, for tests of the OpenAPI adapter. Pass {} if you don't need it. | |
routes | MockRoute[] | [] | What to answer: see below. |
maxBodyBytes, bodyTimeoutMs | number | 1048576, 10000 | Limits on reading a request's body. |
debug | boolean | false | Logs each request. |
| Member | Description |
|---|---|
start() | Starts listening. Resolves to { baseUrl, port, specUrl }, like { baseUrl: "http://localhost:50910", port: 50910, specUrl: "http://localhost:50910/openapi.json" }. |
stop() | Stops it. |
info | What start() resolved to. Throws when it isn't running. |
addRoute(route) | Adds a route after the others. |
clearRoutes() | Removes every route. |
Routes
A route is { method, path, response } or { method, path, handler }, with method one of GET, POST, PUT, DELETE and PATCH. A route with neither response nor handler, or both, throws when you pass it: Mock route GET /x must define either 'handler' or 'response'.
pathis compared with the request's path, without its query string: exactly, or with:namesegments that match any one segment, like/customers/:id.responseis{ status?, headers?, body }.statusdefaults to200. The body is sent as JSON.handler(req, res)answers for itself.reqhasurl(the path and query),method,headers(names in lower case),params(the:namesegments),queryandbody(parsed as JSON when it is, forPOST,PUTandPATCH). Answer withres.json(body, status?)orres.send(body, status?, headers?); both send JSON. A handler can also recordreq, to check what the tool sent.
Routes are tried in the order they were given, and the first that matches answers: a route added with addRoute() never answers a request that an earlier route, like /customers/:id, already matches. A request no route matches gets 404 with {"error":"not_found","message":"No mock for GET /customers/C-7"}.
httpMock
Replaces globalThis.fetch in the process that runs the tests, and answers from the mocks you register.
| Member | Description |
|---|---|
httpMock.interceptor() | A new interceptor. The first one replaces fetch. |
httpMock.disable() | Puts the real fetch back and removes every interceptor. Call it in afterEach. |
httpMock.enable(), httpMock.isEnabled() | Replace fetch without making an interceptor, and whether it's replaced. |
httpMock.clearAll() | Removes every interceptor, but leaves fetch replaced, so every request throws until you call disable(). |
Interceptors
What httpMock.interceptor() returns.
| Member | Description |
|---|---|
get(url, response, options?), post(…), put(…), delete(…) | A mock for that method and url. Returns a handle. options is { times?, match?, name? }: times is how many requests it answers, match adds to what must match (method, headers, body; see below), and name is a label. |
any(url, response, options?) | A mock for any method. |
mock(definition) | A mock with every option: see below. |
allowPassthrough(allow) | With true, a request no mock matches goes to the real fetch instead of throwing. |
pending() | The mocks that can still answer, including every mock without times. |
isDone(), assertDone() | Whether every mock with times was used up. assertDone() throws Unused HTTP mocks: and one line per mock left, like - GET /customers/C-2. Mocks without times never make it fail. |
clear() | Removes this interceptor's mocks. |
restore() | Removes this interceptor, and puts the real fetch back once none is left. (Before 1.8.7 fetch stayed replaced, and every request threw No HTTP mock found, the next test's mcp connecting included.) |
response, for get() and the others, is a response (below), or a plain object to send as the JSON body. An object is taken for a response only when its keys are all among status, statusText, headers, body and delay, and its status, if it has one, is a whole number from 200 to 599. Any other object is the body, so { id: "T-1", status: "open" } is sent as it is. (Before 1.8.7 an object with a status key was a response whatever its value, and a body like that had to be wrapped in { body }.)
interceptor.mock(definition)
| Field | Type | Description |
|---|---|---|
match.url | string, RegExp or (url) => boolean | Required. A string matches a URL that equals it or contains it, so "/customers/C-1" also matches …/customers/C-10. |
match.method | string or string[] | "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD" or "OPTIONS". Without it, any method. |
match.headers | Record<string, string | RegExp> | Headers the request must have, by name in any case, with that value or matching that pattern. |
match.body | object, string, RegExp or (body) => boolean | An object matches when every key in it equals the request body's (parsed as JSON), recursively. A string matches a body that equals or contains it. |
response | response, or (request) => response | What to answer. A function receives the request and can return a promise. |
times | number | How many requests it answers. Default: unlimited. |
name | string | A label for the mock, for debugging. |
A response is { status?, statusText?, headers?, body?, delay? }: status 200 and statusText "OK" by default; an object body is sent as JSON with content-type: application/json, and a string as text/plain, unless headers says otherwise; delay waits that many milliseconds.
The newest interceptor is asked first. Within one, mocks are tried in the order they were added, and the first that matches answers. When none does, the request goes to the real fetch if an interceptor allows passthrough, and otherwise fetch throws Error: No HTTP mock found for GET <url>, with a hint on the next line.
HTTP mock handles
| Member | Description |
|---|---|
callCount() | How many requests it answered. |
calls() | Those requests: { url, method, headers, body, rawBody }. Header names are in lower case; body is parsed as JSON when it is. |
waitForCalls(count, timeoutMs?) | Resolves to the first count requests once there are that many. Rejects with Timeout waiting for 3 calls, got 1 after timeoutMs, default 5000. |
remove() | Removes the mock. |
httpResponse
| Helper | Response |
|---|---|
json(data, status?), text(data, status?), html(data, status?) | That body, with its content-type. |
error(status, message?) | status, with message as the status text and { error: message } as the body. |
notFound(message?), unauthorized(message?), forbidden(message?), serverError(message?) | 404, 401, 403 or 500, the same way. |
networkError(message?) | No response: fetch rejects with TypeError: fetch failed: <message>. |
delayed(data, ms, status?) | data as JSON, after ms. |
Caveats
- A
Requestis matched by its own method, headers and body, asfetch's second argument is. (Before 1.8.7fetch(new Request(…))was matched as aGETwith no headers or body.) - Register the most specific mock first. A string URL also matches longer URLs, and the first mock that matches answers.
Usage
Recording what the client sends
A request interceptor that returns passthrough sees every request, and changes nothing:
import { test, expect } from "@frontmcp/testing";
test.use({ server: "./src/main.ts" });
test("the client sends the arguments as given", async ({ mcp }) => {
const sent: unknown[] = [];
mcp.intercept.request(({ request }) => {
if (request.method === "tools/call") sent.push(request.params);
return { action: "passthrough" };
});
await mcp.tools.call("search_tickets", { query: "log" });
expect(sent).toEqual([{ name: "search_tickets", arguments: { query: "log" } }]);
});mcp.trace.all() has the same requests with their answers, without an interceptor: see Fixtures.
Answering a call without the server
mcp.mock.tool() answers in the client. The handle counts the calls, and the server's own count shows it never ran:
test("a mocked call never reaches the server", async ({ mcp }) => {
const count = async () => (await mcp.tools.call("search_count")).json().searches;
const before = await count();
const search = mcp.mock.tool("search_tickets", { tickets: [{ id: "T-9", title: "From the mock" }] });
const result = await mcp.tools.call("search_tickets", { query: "log" });
expect(result.json()).toEqual({ tickets: [{ id: "T-9", title: "From the mock" }] });
expect(search.callCount()).toBe(1);
expect(search.calls()[0].params).toEqual({ name: "search_tickets", arguments: { query: "log" } });
expect(await count()).toBe(before);
});
Failing once, then recovering
With times: 1, a mock answers the first matching request, and the next one goes to the server. With functions for params and response, one mock can decide per request:
Failures
Example 1 of 3
Fail the first call
test("the first search fails, the second reaches the server", async ({ mcp }) => {
mcp.mock.toolError("search_tickets", -32603, "Search is down", { times: 1 });
const first = await mcp.tools.call("search_tickets", { query: "log" });
expect(first).toBeError(-32603);
expect(first.error?.message).toBe("Search is down");
const second = await mcp.tools.call("search_tickets", { query: "log" });
expect(second).toBeSuccessful();
});A request an interceptor fails rejects, whichever method it is, so check it with rejects.toThrow(). A failure the server answers with, like a tool that throws, is a result: check isError and error. Before 1.8.7 an interceptor's failure resolved to a failed result for tools.call(), resources.read() and prompts.get().
Slowing requests down
test("every request waits 200 ms", async ({ mcp }) => {
mcp.intercept.delay(200);
const result = await mcp.tools.call("search_tickets", { query: "log" });
expect(result.durationMs).toBeGreaterThanOrEqual(200);
});
The delay happens before the request is sent, so it doesn't trip mcp.setTimeout(). To test a slow tool against the client's timeout, the tool itself has to be slow.
Changing an answer
A response interceptor can hand the client something other than what the server sent, such as a list without one tool:
test("a tool that isn't in tools/list", async ({ mcp }) => {
mcp.intercept.response(({ request, response }) => {
if (request.method !== "tools/list") return { action: "passthrough" };
const tools = (response.result as { tools: { name: string }[] }).tools;
return { action: "modify", response: { ...response, result: { tools: tools.filter((t) => t.name !== "add_note") } } };
});
const tools = await mcp.tools.list();
expect(tools).toContainTool("get_customer");
expect(tools).not.toContainTool("add_note");
});
Mocking an API for a server in another process
Here the tools read the CRM's address from CRM_URL:
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const CRM_URL = process.env.CRM_URL ?? "https://crm.example.com";
@Tool({ name: "get_customer", description: "Look up a customer in the CRM", inputSchema: { id: z.string() } })
export class GetCustomer extends ToolContext {
async execute({ id }: { id: string }) {
const response = await fetch(`${CRM_URL}/customers/${id}`, { headers: { authorization: `Bearer ${process.env.CRM_KEY}` } });
if (response.status === 404) this.fail(new PublicMcpError(`There's no customer ${id}.`));
if (!response.ok) this.fail(new PublicMcpError(`The CRM answered ${response.status}.`));
const customer = await response.json();
return { id, name: customer.name, plan: customer.plan };
}
}
@Tool({ name: "add_note", description: "Add a note to a customer in the CRM", inputSchema: { id: z.string(), text: z.string() } })
export class AddNote extends ToolContext {
async execute({ id, text }: { id: string; text: string }) {
const response = await fetch(`${CRM_URL}/customers/${id}/notes`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
return { saved: response.ok };
}
}
@App({ name: "crm", tools: [GetCustomer, AddNote] })
export class Crm {}The test starts a MockAPIServer on a fixed port in beforeAll, which runs before the server under test starts, and gives the server its URL. A handler records what the tool posted:
import { test, expect, MockAPIServer, type MockRequest } from "@frontmcp/testing";
const notes: MockRequest[] = [];
const crm = new MockAPIServer({
port: 50910,
openApiSpec: {},
routes: [
{ method: "GET", path: "/customers/C-1", response: { body: { name: "Acme", plan: "pro" } } },
{ method: "GET", path: "/customers/C-500", response: { status: 500, body: { error: "database down" } } },
{
method: "POST",
path: "/customers/:id/notes",
handler: (req, res) => {
notes.push(req);
res.json({ ok: true }, 201);
},
},
],
});
test.use({ server: "./src/main.ts", env: { CRM_URL: "http://localhost:50910", CRM_KEY: "test-key" } });
test.beforeAll(() => crm.start());
test.afterAll(() => crm.stop());
test("get_customer reads the CRM", async ({ mcp }) => {
const result = await mcp.tools.call("get_customer", { id: "C-1" });
expect(result.json()).toEqual({ id: "C-1", name: "Acme", plan: "pro" });
});
test("an unknown customer is a tool error", async ({ mcp }) => {
// No route matches, so the mock answers 404
const result = await mcp.tools.call("get_customer", { id: "C-7" });
expect(result).toBeError();
expect(result.text()).toBe("There's no customer C-7.");
});
test("a CRM failure is reported", async ({ mcp }) => {
const result = await mcp.tools.call("get_customer", { id: "C-500" });
expect(result.text()).toBe("The CRM answered 500.");
});
test("add_note posts the note", async ({ mcp }) => {
expect((await mcp.tools.call("add_note", { id: "C-1", text: "Called back" })).json()).toEqual({ saved: true });
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ method: "POST", params: { id: "C-1" }, body: { text: "Called back" } });
expect(notes[0].headers["content-type"]).toBe("application/json");
});The tools use PublicMcpError so that result.text() is the message alone: a plain Error in a development server adds its stack.
Running the server in the test process
To mock a tool's fetch() per test with httpMock, run the server in the test's own process: build its HTTP handler with FrontMcpInstance.createHandler(), serve it with node:http, and point the client at it with baseUrl. allowPassthrough(true) lets the test client's requests to localhost through, while the mocks answer the tool's:
import { createServer, type Server } from "node:http";
import { FrontMcpInstance, LogLevel } from "@frontmcp/sdk";
import { test, expect, httpMock, httpResponse } from "@frontmcp/testing";
import { Crm } from "../src/crm.app";
let listener: Server;
test.use({ baseUrl: "http://localhost:50920" });
test.beforeAll(async () => {
const handler = await FrontMcpInstance.createHandler({
info: { name: "help-desk", version: "1.0.0" },
apps: [Crm],
logging: { level: LogLevel.Warn },
});
listener = createServer(handler as Parameters<typeof createServer>[1]).listen(50920);
});
test.afterAll(
() =>
new Promise<void>((resolve) => {
listener.closeAllConnections();
listener.close(() => resolve());
}),
);
test.afterEach(() => httpMock.disable());
test("get_customer calls the CRM once, with the key", async ({ mcp }) => {
const crm = httpMock.interceptor();
crm.allowPassthrough(true); // the test client's own requests to localhost
const customer = crm.get("https://crm.example.com/customers/C-1", { body: { name: "Acme", plan: "pro" } });
const result = await mcp.tools.call("get_customer", { id: "C-1" });
expect(result.json()).toEqual({ id: "C-1", name: "Acme", plan: "pro" });
expect(customer.callCount()).toBe(1);
expect(customer.calls()[0].headers.authorization).toMatch(/^Bearer /);
});
test("the CRM failing, then recovering", async ({ mcp }) => {
const crm = httpMock.interceptor();
crm.allowPassthrough(true);
crm.mock({ match: { url: "/customers/C-1", method: "GET" }, response: httpResponse.serverError(), times: 1 });
crm.get("/customers/C-1", { body: { name: "Acme", plan: "pro" } });
expect((await mcp.tools.call("get_customer", { id: "C-1" })).text()).toBe("The CRM answered 500.");
expect(await mcp.tools.call("get_customer", { id: "C-1" })).toBeSuccessful();
crm.assertDone();
});
test("the network failing", async ({ mcp }) => {
const crm = httpMock.interceptor();
crm.allowPassthrough(true);
crm.get("/customers/C-1", httpResponse.networkError("ECONNRESET"));
const result = await mcp.tools.call("get_customer", { id: "C-1" });
expect(result).toBeError();
expect(result.text()).toContain("fetch failed: ECONNRESET");
});Run it like any other file. Since 1.9.0 FrontMCP loads inside Jest without Node's VM modules, on Jest 30 and Jest 29: FrontMCP 1.8.7 needed NODE_OPTIONS=--experimental-vm-modules here, and with it Jest 30. Use createHandler() and a server you close, rather than FrontMcpInstance.bootstrap(): a server started that way can't be stopped, and Jest waits for it with "Jest did not exit one second after the test run has completed."
Troubleshooting
Every mcp call fails with No HTTP mock found for POST http://localhost:51000/
httpMock is on, and it intercepts the test client's own requests to the server. Call allowPassthrough(true) on the interceptor. If the server was started by test.use({ server }), httpMock can't mock its tools' requests anyway: see the pitfall.
Failed to initialize MCP connection … No HTTP mock found for POST … in the test after a mocked one
The earlier test made an interceptor and never restored it, so fetch is still replaced. Call httpMock.disable() in afterEach, or restore() the interceptor.
The tool calls the real API, though httpMock has a mock for it
The tool runs in the server's process, which httpMock doesn't reach. The call fails with fetch failed, or reaches the real API. Point the tool at a MockAPIServer, or run the server in the test process.
assertDone() passes though a mock was never used
Only mocks with times count. Give the mock { times: 1 }: get(url, response, { times: 1 }).
A dynamic import callback was invoked without --experimental-vm-modules
FrontMCP 1.8.7 or earlier was loaded inside Jest, by a test that runs the server in its process. Upgrade to 1.9.0 or later, which needs no flag, or run with NODE_OPTIONS=--experimental-vm-modules on Jest 30.
Must use import to load ES Module: …/node_modules/<package>/…
An ES-only package was loaded as CommonJS, because Jest skips node_modules when it transforms code. frontmcp test's generated configuration transforms jose, @noble/hashes and @noble/ciphers, which @frontmcp/sdk and CodeCall load, so a file that imports a CodeCall app needs nothing more. For another ES-only package, list it in frontmcp.config.ts: test: { esmPackages: ["<package>"] }. A project with a jest.config.* of its own has to transform all of them itself. (Before 1.8.7 only jose was on the list, and a file that imported a CodeCall app stopped on @noble/hashes unless the project listed @noble/hashes and @noble/ciphers.)
With NODE_OPTIONS=--experimental-vm-modules on Jest 29, jose fails this way in every file that imports @frontmcp/sdk. FrontMCP 1.9 doesn't need the flag: drop it, or move to Jest 30, which frontmcp create installs.
A MockAPIServer route never answers
An earlier route matches the same requests: routes are tried in order, and addRoute() adds to the end. Put specific paths, like /customers/C-1, before patterns, like /customers/:id.
A mock answers a request it wasn't meant for
A string URL matches every URL that contains it, and the first mock that matches answers: "/customers/C-1" answers /customers/C-10 too. Register the specific mock first, or use a RegExp like /\/customers\/C-1$/.