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…UseWorks with a server from test.use({ server })
What the client sends, or how your test code copes with an answer or a failuremcp.intercept, mcp.mockYes: they work in the client, and the server never sees a mocked request.
A tool that calls an HTTP API, against canned answersMockAPIServerYes, 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()httpMockNo. 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().

MemberAnswers
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)

FieldTypeDescription
methodstringThe JSON-RPC method, like "tools/call". Required.
paramsobject, or (params) => booleanWhich 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.
responseJSON-RPC response, or (request) => responseWhat to answer. A function receives the whole request and can return a promise. The answer's id is replaced with the request's.
timesnumberHow many requests it answers. After that it stops matching, and requests go to the next mock that matches, or to the server. Default: unlimited.
delaynumberMilliseconds to wait before answering.

Mocks are tried in the order they were added, and the first that matches answers.

Mock handles

MemberDescription
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.

HelperReturns
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.

MemberDescription
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:

ResultWhat 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

  1. Mocks. The first that matches answers, and the request interceptors don't run.
  2. Request interceptors, in the order they were added.
  3. The server, if nothing answered or failed the request.
  4. 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():

HelperKindWhat it does
logger(log?)requestCalls log("[MCP Request] <method>", params). Default console.log.
delay(ms)requestWaits ms.
failWhen(condition, error)requestRejects the requests condition(ctx) is true for, with error (an Error or a message).
modifyMethod(method, modify)requestSends modify(request) instead, for requests of method.
responseLogger(log?)responseCalls log("[MCP Response] <method> OK (12ms)", response), or ERROR.
modifyResponse(method, modify)responseHands the client modify(response), for requests of method.

Caveats

  • Mocks and interceptors belong to one client. The next test's mcp starts with none, and a client from server.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 of delay(), interceptors and mocks. A request that does time out fails with -32000 and Request timeout after 200ms, whatever the number of milliseconds is. (Before 1.8.7 it failed with -32603 Unknown 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" } } }],
});
OptionTypeDefaultDescription
portnumbera random free portThe 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).
openApiSpecanyRequired. Served at /openapi.json and /openapi.yaml, for tests of the OpenAPI adapter. Pass {} if you don't need it.
routesMockRoute[][]What to answer: see below.
maxBodyBytes, bodyTimeoutMsnumber1048576, 10000Limits on reading a request's body.
debugbooleanfalseLogs each request.
MemberDescription
start()Starts listening. Resolves to { baseUrl, port, specUrl }, like { baseUrl: "http://localhost:50910", port: 50910, specUrl: "http://localhost:50910/openapi.json" }.
stop()Stops it.
infoWhat 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'.

  • path is compared with the request's path, without its query string: exactly, or with :name segments that match any one segment, like /customers/:id.
  • response is { status?, headers?, body }. status defaults to 200. The body is sent as JSON.
  • handler(req, res) answers for itself. req has url (the path and query), method, headers (names in lower case), params (the :name segments), query and body (parsed as JSON when it is, for POST, PUT and PATCH). Answer with res.json(body, status?) or res.send(body, status?, headers?); both send JSON. A handler can also record req, 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.

MemberDescription
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.

MemberDescription
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)

FieldTypeDescription
match.urlstring, RegExp or (url) => booleanRequired. A string matches a URL that equals it or contains it, so "/customers/C-1" also matches …/customers/C-10.
match.methodstring or string[]"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD" or "OPTIONS". Without it, any method.
match.headersRecord<string, string | RegExp>Headers the request must have, by name in any case, with that value or matching that pattern.
match.bodyobject, string, RegExp or (body) => booleanAn 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.
responseresponse, or (request) => responseWhat to answer. A function receives the request and can return a promise.
timesnumberHow many requests it answers. Default: unlimited.
namestringA 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

MemberDescription
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

HelperResponse
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 Request is matched by its own method, headers and body, as fetch's second argument is. (Before 1.8.7 fetch(new Request(…)) was matched as a GET with 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:

e2e/client.e2e.spec.ts
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:

src/crm.app.ts
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:

e2e/crm.e2e.spec.ts
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:

e2e/in-process.e2e.spec.ts
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$/.