# @frontmcp/testing

> Test a FrontMCP server end to end, with a connected MCP client in every test and matchers that understand MCP results.

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

`@frontmcp/testing` runs end-to-end tests against your server. It starts the server, gives every test `mcp`, an MCP client connected to it, and adds matchers like `toBeSuccessful()` to `expect`. Tests run under Jest with `frontmcp test`. The Playground's **Tests** tab runs the same test files in your browser. For a guided introduction, see [Testing Your Server](https://frontmcp.dev/learn/testing-your-server).

```ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts" });

test("name", async ({ mcp }) => {
  expect(await mcp.tools.call("search_tickets", { query: "log" })).toBeSuccessful();
});
```

---

## Reference

### `test(name, fn)`

Registers a test. `fn` receives the [fixtures](#fixtures) and is usually `async`. Before each test, the library connects a new `mcp` client. The server starts when the first test that needs it runs, and the tests with the same `test.use()` options share it: by default, every test in the file.

```ts tickets.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts" });

test("finds tickets by title", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ tickets: [{ id: "T-1", title: "Cannot log in" }] });
});
```

[See more examples below.](#usage)

`test` also has:

| Member | Description | In the Playground |
| --- | --- | --- |
| `test.describe(name, body)` | Groups tests. Their names are shown under the group's. `test.describe.only` and `test.describe.skip` focus or skip the group. | Yes |
| `test.each(table)(name, fn)` | One test for each row of `table`. `fn` receives the fixtures, then the row's values: `test.each([["log", 2]])("%s finds %i", async ({ mcp }, query, count) => …)`. It takes a tagged template too. `test.describe.each(table)(name, body)` passes each row to `body`. | No |
| `test.only(name, fn)` | Runs only the focused tests in the file. | Yes |
| `test.skip(name, fn)` | Skips a test. | Yes |
| `test.skip(condition, reason?)` | When `condition` is true, skips every test registered after it in the same `describe` block. | No |
| `test.todo(name)` | A placeholder for a test you haven't written. | No |
| `test.use(options)` | Configures the server and client for the file, or for the `describe` block it's called in. See [below](#testuseoptions). | Accepted, and ignored |
| `test.beforeEach(fn)`, `test.afterEach(fn)` | Run `fn` before, or after, each test. A function that takes a parameter receives the test's [fixtures](https://frontmcp.dev/reference/testing/fixtures#hooks), the same `mcp` the test gets; one without is Jest's own hook. | No |
| `test.beforeAll(fn)`, `test.afterAll(fn)` | Jest's hooks, without fixtures. `beforeAll` runs before the server starts. | No |

### `test.use(options)`

Configures how the server is started and how clients connect. Call it at the top of the file to configure every test in it, or inside a `test.describe` to configure that block: the block's options are added to the file's, and a block with a configuration of its own starts a server of its own, which stops when the block ends. Calling it again in the same place merges the new options in. [Fixtures](https://frontmcp.dev/reference/testing/fixtures) has every option, how calls combine, and the pitfalls.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `server` | `string` | | The server's entry file, like `"./src/main.ts"`, run with `npx tsx`. A value with a space is run as a command. Required, unless you set `baseUrl`. |
| `baseUrl` | `string` | | Test a server that's already running, at this URL. With `server`, it overrides the URL the client connects to. |
| `port` | `number` | a free port | The port to start the server on. `0`, like leaving it out, means any free port from the project's range. |
| `project` | `string` | | An E2E project name, to take a port from that project's range. |
| `entryPath` | `string` | `"/"` | Where the server serves MCP, if it sets `http.entryPath`. |
| `transport` | `"streamable-http" \| "sse"` | `"streamable-http"` | The client's transport. `"sse"` is the older HTTP+SSE transport (`GET /sse`, then `POST /message`), for servers that still serve it: see [Fixtures](https://frontmcp.dev/reference/testing/fixtures#options). Before 1.9.0 the client threw "SSE transport not yet implemented". |
| `publicMode` | `boolean` | `false` | Don't ask the server for an anonymous token, so `mcp` sends no `Authorization` header, for servers with `auth: { mode: "public" }`. A client made with a token of its own still sends it. |
| `env` | `Record<string, string>` | | Environment variables for the server, read when it starts. A `JWT_SECRET` here also signs the `auth` fixture's tokens. |
| `startupTimeout` | `number` | `30000` | How long to wait for the server to start, in milliseconds. |
| `logLevel` | `"debug" \| "info" \| "warn" \| "error"` | | The server's [log level](https://frontmcp.dev/reference/server/logging#choosing-a-level), passed to it as `FRONTMCP_LOG_LEVEL`, unless its `logging.level` is set. `"debug"` also prints the server's command and startup. It doesn't set the level of the log messages the server sends the client; see [Notifications](#notifications). (Changed in 1.9.3: before, the values other than `"debug"` did nothing.) |
| `auth` | `{ mode?, type? }` | | Says what the server's auth is; it doesn't change it. `mode: "public"` keeps `mcp` anonymous, like `publicMode`. `mode` and `type` also reach the server as `FRONTMCP_TEST_AUTH_MODE` and `FRONTMCP_TEST_AUTH_TYPE`, which only code of your own reads. |

> **Note**
Before 1.8.7, `test.use()` in a `test.describe` changed the options for the whole file, `test.each` didn't exist, `test.describe.each` lost its rows, `port: 0` made the library wait on port 0, and `publicMode` dropped the tokens of clients you gave one. Each works as this page says now. `mcp.authenticate()`, `server.restart()` and interceptor errors changed too: [Fixtures](https://frontmcp.dev/reference/testing/fixtures) and [Interceptors and HTTP mocking](https://frontmcp.dev/reference/testing/interceptors) list each change.

> **Note**
A `test.beforeEach()` or `test.afterEach()` function that takes a parameter receives the fixtures; before 1.9.0 Jest took the parameter for a `done` callback and the hook timed out, and a `done`-style hook no longer works there. `transport: "sse"` works, `test.use({ env })` is read when the server starts, and `@frontmcp/testing` installs `@swc/jest` itself, so `frontmcp test` works in a project that didn't come from `frontmcp create`.

### Fixtures

The test function receives an object with three fixtures, and so does a `test.beforeEach()` or `test.afterEach()` hook that takes a parameter. [Fixtures](https://frontmcp.dev/reference/testing/fixtures) covers each in full, and when each is created and torn down; [Testing authentication](https://frontmcp.dev/reference/testing/auth) covers `auth`.

| Fixture | Type | Description |
| --- | --- | --- |
| `mcp` | `McpTestClient` | A client connected to the server. A new one for each test. |
| `server` | `ServerFixture` | The running server: `info` (`baseUrl`, `port`, `pid`), `createClient({ token, transport, clientInfo, entryPath })` for more clients, `createClientBuilder()`, `restart()`, `getLogs()` and `clearLogs()`. |
| `auth` | `AuthFixture` | Makes JWTs: `createToken({ sub, scopes, email, name, claims, expiresIn })`, `createExpiredToken({ sub })`, `createInvalidToken({ sub })`, `users` (`admin`, `user` and `readOnly`), `getJwks()`, `getIssuer()` and `getAudience()`. |

> **Note**
The Playground's Tests tab only has `mcp`, connected to the example's server in your browser. `server` and `auth` are `undefined` there.

### The `mcp` client

#### Tools, resources and prompts

| Method | Returns | Description |
| --- | --- | --- |
| `mcp.tools.list()` | `Promise<Tool[]>` | Every tool. It follows `nextCursor` through the pages of `tools/list`, which hold 40 tools by default ([Listing a server with many tools](#listing-a-server-with-many-tools)). Before 1.8.6, `@frontmcp/testing` returned only the first page. Throws if a request fails, if the server sends the same cursor twice, or if it doesn't finish within 1000 pages. |
| `mcp.tools.call(name, args?, options?)` | `Promise<ToolResultWrapper>` | Calls a tool. Doesn't throw when the call fails; check the [wrapper](#toolresultwrapper). `options.progressToken` is sent as `_meta.progressToken` (the Playground ignores `options`). |
| `mcp.resources.list()` | `Promise<Resource[]>` | The resources in `resources/list`, paged like `tools.list()`. Throws if a request fails. |
| `mcp.resources.listTemplates()` | `Promise<ResourceTemplate[]>` | The templates in `resources/templates/list`, paged like `tools.list()`. Throws if a request fails. |
| `mcp.resources.read(uri)` | `Promise<ResourceContentWrapper>` | Reads a resource. Doesn't throw when the read fails. |
| `mcp.prompts.list()` | `Promise<Prompt[]>` | The prompts in `prompts/list`, paged like `tools.list()`. Throws if a request fails. |
| `mcp.prompts.get(name, args?)` | `Promise<PromptResultWrapper>` | Gets a prompt. `args` values are strings. |

#### Raw requests

| Method | Returns | Description |
| --- | --- | --- |
| `mcp.raw.request(message)` | `Promise<JSONRPCResponse>` | Sends a JSON-RPC request as you wrote it (`jsonrpc`, `id`, `method`, `params`) and returns the response, `error` included. |
| `mcp.raw.notify(message)` | `Promise<void>` | Sends a notification. Not in the Playground. |
| `mcp.raw.sendRaw(data)` | `Promise<JSONRPCResponse>` | Sends a string as the request body, to test parse errors. Not in the Playground. |

#### Elicitation

| Method | Description |
| --- | --- |
| `mcp.onElicitation(handler)` | Answers the server's [elicitation](https://frontmcp.dev/reference/sdk/elicit) requests. `handler(request)` receives the `message` and `requestedSchema`, and returns `{ action, content? }` or a promise of one. `action` is `"accept"`, `"decline"` or `"cancel"`. |
| `mcp.clearElicitationHandler()` | Removes the handler. |

Without a handler, `frontmcp test`'s client answers every question with `decline`. The Playground's test client fails the test instead, and says which question was asked. If the handler throws, `frontmcp test`'s client answers `cancel`.

#### Notifications

| Method | Description |
| --- | --- |
| `mcp.notifications.collect()` | A collector with `received` (every notification the client has received, before and after the collector was made: `method`, `params`, `timestamp`), `has(method)` and `waitFor(method, timeoutMs)`. |
| `mcp.notifications.collectProgress()` | A collector with `all` (every progress notification: `progress`, `total`, `progressToken`, `timestamp`) and `waitForComplete(timeoutMs)`, which waits until `progress` reaches `total`. From then on, each `mcp.tools.call()` sends a progress token, so the server sends progress. The update's `message` is only in the notification's `params`, in `collect()`. |
| `mcp.notifications.send(method, params?)` | Sends a notification to the server. Not in the Playground. |

> **Pitfall: Ask for notifications before the call, and wait for them**
Under `frontmcp test`, the server sends progress only for a call that carries a progress token, and log messages only after the client sets a log level, which the client doesn't do by itself. They also reach the client on the session's notification stream, which can be after the call has returned. So, before the call, call `collectProgress()` and send `logging/setLevel` with `mcp.raw.request()`, and after it, wait with `waitForComplete()` or `waitFor()` before you check anything. [Checking notifications](#checking-notifications) shows a test that does.

The Playground's test client asks for progress and for `"debug"` logs on every `tools/call`, and has every notification by the time the call returns, so a test that skips these steps passes in the Playground and fails under `frontmcp test`.

#### Everything else

| Member | Description |
| --- | --- |
| `mcp.protocolVersion` | The protocol version in use. `frontmcp test`'s client asks for `2025-06-18`, and can't speak `2026-07-28`: building a client with `server.createClientBuilder().withProtocolVersion("2026-07-28")` throws. The Playground speaks `2026-07-28`. |
| `mcp.isConnected()` | Whether the client is connected. |
| `mcp.serverInfo`, `mcp.capabilities`, `mcp.hasCapability(name)`, `mcp.instructions` | What the server said about itself when the client connected. Not in the Playground. |
| `mcp.sessionId`, `mcp.session`, `mcp.auth`, `mcp.authenticate(token)`, `mcp.disconnect()`, `mcp.reconnect()` | The session and credentials. `authenticate(token)` opens a new session as that token's user, and rejects when the server refuses the token, leaving the old session as it was: see [Testing authentication](https://frontmcp.dev/reference/testing/auth#the-auth-fixture). Not in the Playground. |
| `mcp.logs`, `mcp.trace` | The client's own log and a trace of every request. Not in the Playground. |
| `mcp.mock`, `mcp.intercept` | Canned responses and request interceptors. Not in the Playground. |
| `mcp.setTimeout(ms)` | The request timeout. Default `30000`. Not in the Playground. |

### Result wrappers

#### `ToolResultWrapper`

What `mcp.tools.call()` returns.

| Member | Type | Description |
| --- | --- | --- |
| `isSuccess` | `boolean` | `true` unless the call failed. |
| `isError` | `boolean` | `true` for a JSON-RPC error, or a result with `isError: true`. |
| `error` | `{ code, message, data? }` or `undefined` | The JSON-RPC error. `undefined` for tool errors, which arrive as results. |
| `raw` | `CallToolResult` | The result as received: `content`, `structuredContent`, `isError` and `_meta`. |
| `durationMs` | `number` | How long the call took. |
| `json()` | `unknown` | Parses the first text block as JSON. For a result with a tool UI, returns `structuredContent`. Throws "No text content to parse as JSON" when there's no text block. |
| `text()` | `string \| undefined` | The first text block's text. |
| `hasTextContent()`, `hasImageContent()`, `hasResourceContent()` | `boolean` | Whether any content block has that type. |
| `hasToolUI()` | `boolean` | Whether `_meta` carries a rendered tool UI. |

#### `ResourceContentWrapper`

What `mcp.resources.read()` returns.

| Member | Type | Description |
| --- | --- | --- |
| `isSuccess`, `isError` | `boolean` | Whether the read worked. |
| `error` | `{ code, message, data? }` or `undefined` | The JSON-RPC error, when the read failed. |
| `raw` | `ReadResourceResult` | The result as received, with its `contents`. |
| `durationMs` | `number` | How long the read took. |
| `json()` | `unknown` | Parses the first content block's `text` as JSON. |
| `text()` | `string \| undefined` | The first content block's `text`. |
| `mimeType()` | `string \| undefined` | The first content block's `mimeType`. It's a method. |
| `hasMimeType(type)` | `boolean` | Whether the first content block has that MIME type. |

#### `PromptResultWrapper`

What `mcp.prompts.get()` returns.

| Member | Type | Description |
| --- | --- | --- |
| `isSuccess`, `isError`, `error`, `raw`, `durationMs` | | As for resources. |
| `messages` | `PromptMessage[]` | The prompt's messages, each with a `role` and `content`. |
| `description` | `string \| undefined` | The prompt's description. |

### `expect`

`expect` is Jest's, with the MCP matchers added: `toBeSuccessful()`, `toContainTool()` and the rest. See [Matchers](https://frontmcp.dev/reference/testing/matchers) for every one, and for the Jest matchers the Playground supports.

#### Caveats

- **Tool errors are results, not JSON-RPC errors.** For a tool that fails, `isError` is `true` and `error` is `undefined`, so a JSON-RPC code like `toBeError(-32602)` can't match. Pass the FrontMCP code as a string instead: `toBeError("INVALID_INPUT")` checks `result.raw._meta?.code`.
- **`json()` parses the text FrontMCP sent.** A tool that returns a plain value, like `12`, sends `{"value":12}`, so `json()` returns `{ value: 12 }`.
- **The list helpers follow `nextCursor`.** `tools.list()`, `resources.list()`, `resources.listTemplates()` and `prompts.list()` read every page, and throw if the server returns the same cursor twice or doesn't finish within 1000 pages. Before 1.8.6 `@frontmcp/testing` returned the first page only, so on a server with more than 40 tools a test that listed them missed the rest. [Listing a server with many tools](#listing-a-server-with-many-tools) shows how to read one page at a time with `mcp.raw.request()`.
- **Tests with the same `test.use()` options share one server.** By default that's every test in the file, so anything a tool keeps in memory carries over from test to test. Don't depend on the order tests run in.
- **The protocol version changes some answers.** `frontmcp test`'s client speaks `2025-06-18`, and the Playground `2026-07-28`. Reading an unknown resource fails with `-32002` in the first and `-32602` in the second. And an elicitation answer that doesn't match the schema reaches `this.elicit()` unchecked in the first, while the second fails the call with `INVALID_INPUT`.

---

## Usage

### Writing a test file

Name test files `*.test.ts` or `*.spec.ts` in a Playground, and `*.e2e.spec.ts` in a project. A visible test file adds a **Tests** tab, which runs the tests against a fresh copy of the example.

```ts search.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title",
  inputSchema: { query: z.string().min(3) },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}
```

```ts search.test.ts active
import { test, expect } from "@frontmcp/testing";

test("search_tickets is listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("search_tickets");
});

test("finds both login tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]);
});

test("rejects a query shorter than 3 characters", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "a" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

### Checking a tool's result

The wrapper gives you the parsed result, the raw one, and whether the call failed. A tool error sets `isError` but not `error`, because it arrives as a result.

```ts get-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one ticket by id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return { id, title: "Cannot log in", status: "open" };
  }
}

@Tool({ name: "count_open", description: "How many tickets are open", inputSchema: {} })
export class CountOpen extends ToolContext {
  async execute() {
    return 12;
  }
}
```

```ts results.test.ts active
import { test, expect } from "@frontmcp/testing";

test("json() parses the result", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.isSuccess).toBe(true);
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
  expect(result.raw.structuredContent).toEqual(result.json());
});

test("a plain value arrives wrapped", async ({ mcp }) => {
  const result = await mcp.tools.call("count_open");
  expect(result.json()).toEqual({ value: 12 });
});

test("a tool error is a result with isError", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result.isError).toBe(true);
  expect(result.error).toBeUndefined();
  expect(result.text()).toBe("There's no ticket T-9.");
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
});
```

### Testing resources and prompts

`resources.read()` and `prompts.get()` return wrappers too. Templates have their own list.

```ts help-desk.ts
import { Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

@Resource({ name: "sla", uri: "policy://sla", mimeType: "application/json", description: "Reply times by priority" })
export class SlaPolicy extends ResourceContext {
  async execute() {
    return { high: "4 hours", normal: "1 business day" };
  }
}

@ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json" })
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in" }) }] };
  }
}

@Prompt({ name: "triage", description: "Triage a ticket", arguments: [{ name: "id", required: true }] })
export class TriagePrompt extends PromptContext {
  async execute(args: Record<string, string>) {
    return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Read ticket://${args.id} and suggest a priority.` } }] };
  }
}
```

```ts help-desk.test.ts active
import { test, expect } from "@frontmcp/testing";

test("the SLA policy is listed and reads as JSON", async ({ mcp }) => {
  expect(await mcp.resources.list()).toContainResource("policy://sla");
  const content = await mcp.resources.read("policy://sla");
  expect(content.mimeType()).toBe("application/json");
  expect(content.json()).toEqual({ high: "4 hours", normal: "1 business day" });
});

test("tickets are read through the template", async ({ mcp }) => {
  expect(await mcp.resources.listTemplates()).toContainResourceTemplate("ticket://{id}");
  const content = await mcp.resources.read("ticket://T-1");
  expect(content.json()).toEqual({ id: "T-1", title: "Cannot log in" });
});

test("reading an unknown URI fails", async ({ mcp }) => {
  const content = await mcp.resources.read("policy://refunds");
  expect(content.isError).toBe(true);
  expect(content.error?.message).toContain("Resource not found");
});

test("the triage prompt names the ticket", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("triage", { id: "T-1" });
  expect(prompt.messages).toHaveLength(1);
  expect(prompt.messages[0].role).toBe("user");
  expect(prompt.messages[0].content).toMatchObject({ type: "text", text: expect.stringContaining("ticket://T-1") });
});
```

### Answering elicitation

Register a handler before calling a tool that asks the user something. It plays the user: return what they would choose.

```ts close-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a ticket after the user confirms", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { closed: answer.status === "accept" && answer.content?.confirm === true };
  }
}
```

```ts close-ticket.test.ts active
import { test, expect } from "@frontmcp/testing";

test("closes when the user confirms", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  expect((await mcp.tools.call("close_ticket", { id: "T-1" })).json()).toEqual({ closed: true });
});

test("stays open when the user declines", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "decline" }));
  expect((await mcp.tools.call("close_ticket", { id: "T-1" })).json()).toEqual({ closed: false });
});

test("the question names the ticket", async ({ mcp }) => {
  const questions: string[] = [];
  mcp.onElicitation((request) => {
    questions.push(request.message);
    return { action: "cancel" };
  });
  await mcp.tools.call("close_ticket", { id: "T-7" });
  expect(questions).toEqual(["Close ticket T-7?"]);
});
```

### Checking notifications

The collectors hold the log messages and progress updates the test client received. Set a log level and start collecting progress before the call, and wait for the last update before you check them, so the same test passes under `frontmcp test` (see the [pitfall above](#notifications)):

```ts bulk-close.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } })
export class BulkClose extends ToolContext {
  async execute({ ids }: { ids: string[] }) {
    await this.notify(`Closing ${ids.length} tickets`);
    for (const [i, id] of ids.entries()) await this.progress(i + 1, ids.length, `Closed ${id}`);
    return { closed: ids };
  }
}
```

```ts notifications.test.ts active
import { test, expect } from "@frontmcp/testing";

test("logs once and reports progress per ticket", async ({ mcp }) => {
  // Before the call: ask for log messages and progress.
  await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } });
  const progress = mcp.notifications.collectProgress();

  await mcp.tools.call("bulk_close", { ids: ["T-1", "T-2"] });
  await progress.waitForComplete(1000);

  expect(progress.all.map((p) => p.progress)).toEqual([1, 2]);

  const received = mcp.notifications.collect();
  expect(received.has("notifications/message")).toBe(true);
  expect(received.received.map((n) => n.method)).toEqual([
    "notifications/message",
    "notifications/progress",
    "notifications/progress",
  ]);
});
```

Protocol 2026-07-28 removed `logging/setLevel`, so in the Playground that request gets a `-32601` error, and the test client asks for logs with each call instead. Under `frontmcp test`, which speaks 2025-06-18, it's what makes the server send `this.notify()`'s messages.

### Sending raw requests

`mcp.raw.request()` sends any JSON-RPC request and returns the whole response, which is how you test protocol errors.

```ts ping.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Reply with pong", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { reply: "pong" };
  }
}
```

```ts raw.test.ts active
import { test, expect } from "@frontmcp/testing";

test("tools/list answers with the tools", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
  expect(response).toBeValidJsonRpc();
  expect(response.result.tools.map((t: { name: string }) => t.name)).toEqual(["ping"]);
});

test("an unknown method is a -32601 error", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "tickets/list", params: {} });
  expect(response).toHaveErrorCode(-32601);
});
```

### Listing a server with many tools

Past 40 tools, a server answers `tools/list` a page at a time, with a `nextCursor` for the next one ([`pagination`](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists)). `mcp.tools.list()` follows the cursors and returns every tool. To look at the pages themselves, ask for each one with `mcp.raw.request()`. Here a page holds two of the server's five tools:

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  pagination: { tools: { mode: "auto", autoThreshold: 4, pageSize: 2 } },
})
export default class Server {}
```

```ts help-desk.app.ts
import { App, tool } from "@frontmcp/sdk";

const names = ["search_tickets", "get_ticket", "create_ticket", "assign_ticket", "close_ticket"];
const tools = names.map((name) => tool({ name, description: name.replace("_", " "), inputSchema: {} })(async () => ({ ok: true })));

@App({ id: "help-desk", name: "Help Desk", tools })
export class HelpDeskApp {}
```

```ts pages.test.ts active
import { test, expect } from "@frontmcp/testing";

test("tools/list comes in pages, and following the cursor finds every tool", async ({ mcp }) => {
  const names: string[] = [];
  const pageSizes: number[] = [];
  let cursor: string | undefined;

  do {
    const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor ? { cursor } : {} });
    pageSizes.push(response.result.tools.length);
    names.push(...response.result.tools.map((tool: { name: string }) => tool.name));
    cursor = response.result.nextCursor;
  } while (cursor);

  expect(pageSizes).toEqual([2, 2, 1]);
  expect(names).toHaveLength(5);
  expect(names).toContain("search_tickets");
});
```

The loop stops when a page has no `nextCursor`. `await mcp.tools.list()` alone returns all five, and `toContainTool("search_tickets")` passes on it.

### Grouping and skipping tests

`test.describe` groups tests under a name, and `test.skip` keeps a test in the file without running it. Skipped tests show as skipped in the Tests tab.

```ts search.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const titles = ["Cannot log in", "Login link expired"];
    return { titles: titles.filter((t) => t.toLowerCase().includes(query.toLowerCase())) };
  }
}
```

```ts search.test.ts active
import { test, expect } from "@frontmcp/testing";

test.describe("search_tickets", () => {
  test("matches any part of the title", async ({ mcp }) => {
    const result = await mcp.tools.call("search_tickets", { query: "link" });
    expect(result.json()).toEqual({ titles: ["Login link expired"] });
  });

  test("ignores case", async ({ mcp }) => {
    const result = await mcp.tools.call("search_tickets", { query: "LOG" });
    expect(result.json().titles).toHaveLength(2);
  });

  test.skip("filters by customer", async ({ mcp }) => {
    // Not built yet.
  });
});
```

### Running tests with `frontmcp test`

In a project, install the package, then run `frontmcp test` ([the command](https://frontmcp.dev/reference/cli#frontmcp-test) lists its options and the ES modules it transpiles by default). It runs your spec files with Jest, using a configuration it generates: `src/**/*.spec.ts`, `__tests__/**/*.spec.ts` and `e2e/**/*.e2e.spec.ts` (and their `.tsx` versions). That configuration compiles with `@swc/jest`, which `@frontmcp/testing` installs since 1.9.0, and the library starts your server with `tsx`, so a project needs Jest and `tsx` besides the package. `frontmcp create` installs them all, with Jest 30; Jest 29 works too. A `jest.config.*` file in the project root replaces the generated configuration: `preset: "@frontmcp/testing"` gives it the same settings. The generated one transpiles `jose`, `@noble/hashes` and `@noble/ciphers`, the ES-only packages the SDK and CodeCall load, so a test file can import an app that uses CodeCall with nothing else set. A test that starts FrontMCP in its own process, like [a server in the test process](https://frontmcp.dev/reference/testing/interceptors#running-the-server-in-the-test-process), needs nothing more either.

```bash
npm install -D @frontmcp/testing jest tsx
npx frontmcp test
```

```ts e2e/tickets.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts" });

test("search_tickets is listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("search_tickets");
});
```

| Option | Description |
| --- | --- |
| `-i`, `--runInBand` | Run test files one at a time. Recommended for E2E tests, which each start a server. |
| `-w`, `--watch` | Re-run tests when files change. |
| `-v`, `--verbose` | Show every test's result. |
| `-t`, `--timeout <ms>` | The timeout for each test. Default `60000`. |
| `-c`, `--coverage` | Collect coverage. |
| `--no-env` | Don't load `.env` and `.env.local`, which `frontmcp test` loads by default. |

---

## Troubleshooting

### "test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option"

The file never called `test.use()`, or called it without `server` or `baseUrl`, so the library doesn't know what to test. Add `test.use({ server: "./src/main.ts" })` at the top of the file. `server` is a path, not the server class.

### "Failed to start test server."

The message lists the entry file and command, and the reason after `Error:`. "Server did not become ready within 30000ms" means the server never answered on its port: run the command yourself to see why, check that it reads the port from `PORT`, or raise `startupTimeout`.

### "frontmcp test needs @swc/jest, which comes with @frontmcp/testing."

`frontmcp test` found neither `@frontmcp/testing` nor `@swc/jest` in the project. Install the package and Jest: `npm install -D @frontmcp/testing jest`. Install `tsx` too, or `npx` downloads it each time a test starts the server.

### "Module @swc/jest in the transform option was not found"

A Jest configuration names `@swc/jest`, and Jest can't find it from the project's root. With a `jest.config.*` of your own, use `preset: "@frontmcp/testing"`, which finds the `@swc/jest` that `@frontmcp/testing` installs, or install `@swc/jest`, `@swc/core` and `@swc/helpers` yourself. Before 1.9.0, `@frontmcp/testing` didn't install `@swc/jest`, so `frontmcp test`'s own configuration failed this way in every project that wasn't made by `frontmcp create`.

### "No text content to parse as JSON"

`json()` found no text block. The call may have failed with a JSON-RPC error (check `result.error`), or the tool returned only images or resources.

### `toBeError(-32602)` fails with "Expected error code -32602, but got undefined"

The tool failed with a tool error, which arrives as a result with `isError: true` and no JSON-RPC code. Pass its FrontMCP code as a string: with a string, `toBeError` compares `result.raw._meta?.code`.

```ts
// 🚩 Tool errors have no JSON-RPC code
expect(result).toBeError(-32602);

// ✅ Check that it failed, and why
expect(result).toBeError("INVALID_INPUT");
```

### A notification test passes in the Playground but `collect()` or `collectProgress()` is empty under `frontmcp test`

The call didn't ask for the notifications, or the test checked before they arrived. Call `mcp.notifications.collectProgress()` before the call, send `logging/setLevel` for log messages, and wait with `waitForComplete()` or `waitFor()`. See [Checking notifications](#checking-notifications).

### A test passes alone but fails with the others

Tests in a file share one server, so an earlier test can leave data behind, like a closed ticket. Make each test set up what it needs, or check the change it made instead of the absolute state.

### An elicitation test gets `decline`, or fails with "The tool asked the user for input"

No handler was registered when the tool asked. Call `mcp.onElicitation()` before `mcp.tools.call()`. `frontmcp test`'s client answers `decline` by itself; the Playground fails the test and names the question.

### "Expected an array of tools, but received object"

`expect()` got a promise. Put `await` before `mcp.tools.list()`.

### `toContainTool()` fails for a tool the server has, on a server with many tools

Past 40 tools (by default), `tools/list` [comes in pages](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists), and `@frontmcp/testing` before 1.8.6 returned only the first page from `mcp.tools.list()`, so a tool on a later page was missing. Upgrade to 1.8.6 or later, or page with `mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { cursor } })`, as in [Listing a server with many tools](#listing-a-server-with-many-tools), or check the tool with `mcp.tools.call()`. `resources.list()`, `resources.listTemplates()` and `prompts.list()` had the same problem.

### `Failed to list tools: tools/list returned the cursor "…" twice`

The server sent a `nextCursor` it had already sent, and the helper stopped instead of looping. A server that never stops paging ends with `tools/list did not finish within 1000 pages`. Check what the server's `tools/list` returns with `mcp.raw.request()`.
