# Interceptors and HTTP mocking

> Fake what the test client sends and receives with mcp.mock and mcp.intercept, and the APIs your tools call with MockAPIServer and httpMock: every option, and which process each one works in.

Source: https://frontmcp.dev/reference/testing/interceptors

`@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`.

```ts
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`](#mcpintercept), [`mcp.mock`](#mcpmock) | 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`](#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`](#httpmock) | No. Only when the server runs in the test's own process: see [Running the server in the test process](#running-the-server-in-the-test-process). |

### `mcp.mock`

Answers the client's requests itself, without sending them. Every member returns a [handle](#mock-handles), 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)`](https://frontmcp.dev/reference/testing/matchers#mcp-matchers) matches it. It's not a tool error, which a real failing tool returns: see [`toBeError`](https://frontmcp.dev/reference/testing#caveats). |
| `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

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()`:

| 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 `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`](#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.

```ts
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](https://frontmcp.dev/reference/adapters/openapi). 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'``.

- **`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.

| Member | Description |
| --- | --- |
| `httpMock.interceptor()` | A new [interceptor](#interceptors-1). 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()`. |

> **Pitfall: It only sees `fetch()` in the test's own process**
The server that `test.use({ server })` starts is a separate process, and its `fetch` isn't replaced: a tool there calls the real API whatever you mocked. And while `httpMock` is on, the test client's own requests to the server go through it too, so without `allowPassthrough(true)` every `mcp` call fails with `No HTTP mock found for POST http://localhost:51000/`. Use [`MockAPIServer`](#mockapiserver) for a separate server, or [run the server in the test process](#running-the-server-in-the-test-process). Code that makes requests without `fetch`, like `node:http` or a client built on it, isn't intercepted either.

#### Interceptors

What `httpMock.interceptor()` returns.

| Member | Description |
| --- | --- |
| `get(url, response, options?)`, `post(…)`, `put(…)`, `delete(…)` | A mock for that method and `url`. Returns a [handle](#http-mock-handles). `options` is `{ times?, match?, name? }`: `times` is how many requests it answers, `match` adds to what must match (`method`, `headers`, `body`; see [below](#interceptormockdefinition)), 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](#http-mock-handles) 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 `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:

```ts 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](https://frontmcp.dev/reference/testing/fixtures#mcp).

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

```ts
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:

<Examples title="Failures">

#### Example: Fail the first call
```ts
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();
});
```

#### Example: Answer from the request
```ts
import { test, expect, mockResponse } from "@frontmcp/testing";

test("answering from the request", async ({ mcp }) => {
  mcp.mock.add({
    method: "tools/call",
    params: (params) => params.name === "search_tickets" && (params.arguments as { query: string }).query.length < 3,
    response: (request) =>
      mockResponse.errors.invalidParams(`Query too short: ${(request.params?.arguments as { query: string }).query}`),
  });

  expect((await mcp.tools.call("search_tickets", { query: "lo" })).error?.message).toBe("Query too short: lo");
  expect(await mcp.tools.call("search_tickets", { query: "login" })).toBeSuccessful();
});
```

#### Example: Fail a method
```ts
test("reads fail while storage is down", async ({ mcp }) => {
  const restore = mcp.intercept.failMethod("resources/read", "Storage is down");

  await expect(mcp.resources.read("policy://sla")).rejects.toThrow("Storage is down");

  restore();
  expect(await mcp.resources.read("policy://sla")).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

```ts
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()`](#caveats). 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:

```ts
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`:

```ts 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:

```ts 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:

```ts 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](#httpmock).

### `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`](#mocking-an-api-for-a-server-in-another-process), or [run the server in the test process](#running-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$/`.
