Fixtures

A test in @frontmcp/testing is a function that receives three fixtures: mcp, a client connected to your server; server, the running server, with a way to make more clients; and auth, a factory for signed test tokens. test.use() says how to start the server. A server starts when the first test that needs it runs, and the tests with the same test.use() options share it, by default the whole file. Each test gets a new mcp client, so tests share the server's state but not the client's. The Playground's Tests tab only has mcp, so most examples on this page are code blocks, run with frontmcp test and Jest 30 on FrontMCP 1.9.1. @frontmcp/testing 1.9.2 changes only how the test server stops (it waits for the process to exit), and transport: "sse" was run again on 1.9.2. 1.9.3 changes only logLevel, which now reaches the server; that was run with Jest.

test.use({ server, baseUrl?, port?, project?, entryPath?, transport?, publicMode?, env?, startupTimeout?, logLevel? });

test("name", async ({ mcp, server, auth }) => { /* … */ });

Reference

test.use(options)

Say which server the tests use, at the top of the file for all of them, or inside a test.describe for the tests in that block:

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

test.use({ server: "./src/main.ts", env: { CRM_URL: "http://localhost:50910" } });

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

The server must listen on the port in process.env.PORT, which the library sets for it:

src/main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
})
export default class Server {}

See more examples below.

Options

OptionTypeDefaultDescription
serverstringThe entry file, run with npx tsx <file> from the directory you run frontmcp test in, so paths are relative to it, not to the test file. A value with a space is run as a shell command, like "node dist/main.js". Required, unless you set baseUrl.
baseUrlstringTest a server that's already running at this URL, instead of starting one. With server too, the server is started and the clients connect to baseUrl, as through a proxy.
portnumberthe first free port from 51000The port to start the server on. If it's taken, the library warns and picks another. 0 means the same as leaving it out. The library locks each port it hands out, in a file in the system's temp folder, so Jest workers running files in parallel never get the same one.
projectstringA name for port allocation. FrontMCP's own E2E projects have their own ranges; any other name gets ports from 51000.
entryPathstring"/"Where the server serves MCP, if it sets http.entryPath. Every client connects there, and auth's tokens are made for it.
transport"streamable-http" | "sse""streamable-http""sse" connects with the older HTTP+SSE transport: GET /sse, then each request as a POST to the endpoint the stream names. The server must serve it, which a FrontMCP server does unless its transport.protocol turns legacy off; otherwise connecting fails with SSE connection to http://localhost:51000/sse failed: HTTP 404 Not Found and a hint. Before 1.9.2 it connected only with publicMode: true: with the anonymous token, initialize got HTTP 404 session expired. Before 1.9.0, "sse" threw "SSE transport not yet implemented".
publicModebooleanfalseThe mcp client doesn't ask the server for an anonymous token, so it sends no Authorization header. A client you give a token still sends it: see mcp.
envRecord<string, string>Environment variables for the server, added to the test process's own. They're read when the server starts, after beforeAll, so a value a beforeAll hook puts in the object, like a mock's URL, reaches the server. A JWT_SECRET here also makes auth sign its tokens with it. (Before 1.9.0 they were read when test.use() ran.)
startupTimeoutnumber30000How long to wait for the server to answer, in milliseconds. It's ready when GET /health answers with any 2xx or 404.
logLevel"debug" | "info" | "warn" | "error""debug" prints the command, then everything the server prints, as [SERVER] …. DEBUG_SERVER=1 or DEBUG=1 in the environment does the same. Every value is also the server's log level, passed to it as FRONTMCP_LOG_LEVEL, unless its logging.level is set: with "warn", its info() lines aren't written. (Changed in 1.9.3: before, the values other than "debug" did nothing.)
auth{ mode?, type? }Says what the server's auth is, and doesn't change it: set that in the server's own @FrontMcp, and use auth for tokens. mode: "public" keeps mcp anonymous, as publicMode does. mode and type also reach the server's environment as FRONTMCP_TEST_AUTH_MODE and FRONTMCP_TEST_AUTH_TYPE, which only your own entry file reads.

How calls combine

  • A call applies to its block. test.use() at the top of the file configures every test in it. Inside a test.describe, it configures that block's tests, on top of the file's options: the last value of each option wins, and env entries are added. Calling it again in the same place merges the new options in.
  • Each configuration has its own server, and the block that configured it stops it. Two blocks with different options start two servers, and a block's server stops when the block ends. A block with no test.use() of its own uses the server of the block around it. See Two configurations of a server.

Before 1.8.7, every test.use() in a file merged into one configuration, the last call won for every test, and a test.use() inside a test.describe stopped the server after that block.

Lifetimes

FixtureCreatedShared byEnds
serverWhen the first test that needs it runs, not when the file loads. beforeAll hooks run first.Every test in the file, or in the describe block that configured itAfter the last test of the file, or of that block, when the library stops the server's process.
authWith the serverEvery test the server is shared byWith the server
mcpBefore each test, connectedOne testDisconnected after the test
Clients from server.createClient()When you call itWhoever holds itWhen the test ends: the library disconnects them. Calling disconnect() yourself is fine. (Before 1.8.7 it didn't close them for you.)

So everything a tool keeps in memory carries over from one test to the next, and everything you set on a client (mocks, interceptors, an elicitation handler, collected notifications) doesn't. When a test fails, the library prints the server's last 50 log entries under [TestFixture] === Server Logs (test failed) ===.

mcp

A new McpTestClient for each test, already connected. It speaks MCP 2025-06-18: it sends initialize, then uses the session the server gives it.

  • Credentials. Without publicMode, the client first asks the server's /oauth/token for an anonymous token (grant_type: "anonymous"), and sends it as Authorization: Bearer … if it gets one. A public server gives one, so there this.auth.user.sub is an anon: id. With publicMode: true, the client sends no Authorization header.
  • On a server that needs a token, connecting fails with 401. The fixture prints a warning, [TestFixture] MCP client could not connect anonymously — the server requires authentication, with the reason, and gives the test a client that never initialized: mcp.isConnected() is true, but every request fails with -32600, Session not initialized — send `initialize` first. Tests for such a server sign mcp in with await mcp.authenticate(token), or use server.createClient({ token }). A 401 or 403 is tried once, so a refused connection doesn't wait. (Before 1.8.7 it was tried three times, half a second apart.)
  • mcp.authenticate(token) signs the client in. It opens a new session as the token's user, and keeps the token, so mcp.reconnect() uses it too. When the server refuses the token, it rejects, as createClient() does, and the client keeps its old session and identity. See Testing authentication. Before 1.8.7 it changed only the header: the session still belonged to the old token, the next request failed with HTTP 404: Not Found (invalid session id), and reconnect() dropped the token.

Besides the methods for tools, resources and prompts, each client keeps a record of its own, for debugging:

MemberWhat it holds
mcp.logs.all(), filter(level), search(text), last(), clear()The client's own log, like Connecting to http://localhost:51000... and Connected to help-desk. Not the server's: that's server.getLogs().
mcp.trace.all(), last(), clear()Every request the client sent and its answer: { request: { method, params, id }, response: { result?, error? }, durationMs, timestamp }.
mcp.transport_info.lastRequestHeadersThe headers of the last request: Content-Type, Accept, User-Agent, Authorization and mcp-session-id.

server

MemberDescription
info{ baseUrl, port, pid }, like { baseUrl: "http://localhost:51000", port: 51000, pid: 68493 }. pid is the process listening on port, which the library finds with lsof; where that finds nothing, such as on Windows, it's the shell that runs the command. info is always current: after restart() it has the new pid. (Before 1.8.7 pid was always the shell's.)
createClient(options?)Another client, connected: see below.
createClientBuilder()A builder for a client with any settings: see below.
restart()Stops the server and starts it again on the same port. Everything it kept in memory is gone, sessions included. mcp and the clients from createClient() reconnect, each with a new session: see Restarting the server.
getLogs()Everything the server printed since it started, as a string[] of chunks as they arrived (a chunk can hold several lines, or part of one). Lines from standard error start with [ERROR] , even FrontMCP's warnings. It holds FrontMCP's own log lines, at INFO by default, and whatever your code prints.
clearLogs()Empties getLogs().

With only baseUrl, there's no process: info has no pid, getLogs() is always empty, and restart() only waits for the URL to answer, then reconnects the clients.

server.createClient(options?)

OptionTypeDescription
tokenstringSent as Authorization: Bearer <token>, with publicMode: true too.
clientInfo{ name, version }The client's name in initialize, and its User-Agent, as name/version. The default is @frontmcp/testing 0.4.0.
entryPathstringOverrides test.use()'s entryPath for this client.
transport"streamable-http" | "sse"As in test.use().

It resolves to a connected McpTestClient, the same kind as mcp, and rejects when the server refuses to initialize, with a message like Failed to initialize MCP connection to http://localhost:51000/ after 1 attempt: HTTP 401: Unauthorized - {"error":"Unauthorized"}. The library disconnects the client when the test ends.

server.createClientBuilder()

Returns a builder for the server's URL, with test.use()'s entryPath and publicMode. Each method returns the builder; build() returns an unconnected client, and buildAndConnect() a connected one.

MethodDescription
withToken(token)Sends Authorization: Bearer <token>.
withHeaders(headers)Adds headers to every request, like an API key header of your own.
withPublicMode(enabled?)Turns publicMode on (the default) or off for this client.
withClientInfo({ name, version })As clientInfo above.
withProtocolVersion(version)The version to ask for in initialize. "2026-07-28" throws: the client can't speak it.
withTimeout(ms)The timeout for each request. Default 30000.
withQueryParams(params)Adds query parameters to the MCP URL.
withCapabilities(capabilities), withPlatform(platform)The capabilities the client declares, or those of a known client platform with its clientInfo.
withDebug(enabled?)Prints the client's log as it goes.

auth

Makes signed JWTs for your tests. With no JWT_SECRET in test.use({ env }), it signs with an RSA key of its own, which your server doesn't know; with one, it signs the way a public, local or remote server does, so that server accepts them. Testing authentication covers each auth mode.

MemberDescription
createToken({ sub, scopes?, email?, name?, claims?, expiresIn? })A token for that user. expiresIn is in seconds, default 3600: the token lasts at least that long.
createExpiredToken({ sub })A token that expired an hour ago.
createInvalidToken({ sub })A token whose signature is wrong.
usersadmin, user and readOnly, ready to pass to createToken().
getIssuer(), getAudience(), getJwks()The iss and aud its tokens carry, and its public key.

Hooks

test.beforeEach(fn) and test.afterEach(fn) with a function that takes a parameter run as part of each test, and receive its fixtures: the same mcp, server and auth the test gets. beforeEach runs after the client connects and before the test; afterEach runs after the test, even when it failed, and before the clients are disconnected. Hooks in an outer test.describe run before an inner block's beforeEach, and after its afterEach.

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

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

test.beforeEach(async ({ mcp }) => {
  await mcp.tools.call("reset_desk");
});

test("closes a ticket", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeSuccessful();
});

A hook function without a parameter, and every test.beforeAll and test.afterAll, is Jest's own, and gets no fixtures: use them for what isn't the server, like a mock API or a mock identity provider. beforeAll runs before the server starts, so a mock you start there is up when the server connects to it, and a URL it puts in test.use()'s env object reaches the server.

Before 1.9.0 every hook was Jest's, so a hook that took a parameter waited for a done callback until the test timed out. Jest's done style no longer works for test.beforeEach and test.afterEach.

Caveats

  • test.beforeAll and test.afterAll with a parameter wait for done. Jest treats a hook whose function takes an argument as a callback hook, so test.beforeAll(async ({ mcp }) => …) never finishes, and fails after the test timeout, 60 seconds under frontmcp test. See Troubleshooting.
  • test.each and test.describe.each pass each row's values to the function: see Running a test for each input.
  • Tests run one at a time within a file, in the order they're written, and each file starts its own servers.
  • Files can run in parallel. Without --runInBand, Jest runs files in parallel workers, and each file's servers get different ports. --runInBand runs one file at a time, which is the quieter choice for tests that each start a server.

Usage

Sharing the server between tests

Each test gets a new client, but the server is the same one, so what one test changes, the next one sees. The Playground runs its tests the same way: one server per run, a new mcp for each test.

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

test("opens the first ticket", async ({ mcp }) => {
  const result = await mcp.tools.call("open_ticket", { title: "Cannot log in" });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in" });
});

test("the next test sees the same server", async ({ mcp }) => {
  // T-1 was opened by the test above, on another client.
  const result = await mcp.tools.call("open_ticket", { title: "Invoice total is wrong" });
  expect(result.json()).toEqual({ id: "T-2", title: "Invoice total is wrong" });
});

test("a test that doesn't depend on the others", async ({ mcp }) => {
  const before = (await mcp.tools.call("list_tickets")).json().tickets.length;
  await mcp.tools.call("open_ticket", { title: "Login link expired" });
  const after = (await mcp.tools.call("list_tickets")).json().tickets.length;
  expect(after).toBe(before + 1);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The second test passes only because the first one ran before it. The third checks the change it made, so it passes whatever ran before. Under frontmcp test, the same file, named e2e/tickets.e2e.spec.ts, needs test.use({ server: "./src/main.ts" }) at the top; the Playground accepts test.use() and ignores it.

Two configurations of a server

test.use() in a test.describe gives that block its own options, and its own server. Here the two blocks start a server each, with a different DESK_NAME, and each server stops when its block ends:

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

test.describe("north", () => {
  test.use({ server: "./src/main.ts", env: { DESK_NAME: "north" } });

  test("north desk", async ({ mcp }) => {
    expect((await mcp.tools.call("desk_name")).json()).toMatchObject({ name: "north" });
  });
});

test.describe("south", () => {
  test.use({ server: "./src/main.ts", env: { DESK_NAME: "south" } });

  test("south desk", async ({ mcp }) => {
    expect((await mcp.tools.call("desk_name")).json()).toMatchObject({ name: "south" });
  });
});

Options you set at the top of the file apply to every block, so a file whose blocks differ in one option can set server once at the top and only env in each block. Before 1.8.7, both blocks above started a server with DESK_NAME=south, and tests needing two configurations of a server went in two files.

Calling as several clients

server.createClient() connects another client to the same server, with its own session, token or client name. The library disconnects it when the test ends, so make it in the test that uses it.

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

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

test("each client has its own session", async ({ mcp, server }) => {
  const cli = await server.createClient({ clientInfo: { name: "desk-cli", version: "2.0.0" } });
  expect(cli.sessionId).not.toBe(mcp.sessionId);
  expect(cli.transport_info.lastRequestHeaders["User-Agent"]).toBe("desk-cli/2.0.0");
  expect(await cli.tools.call("search_tickets", { query: "log" })).toBeSuccessful();
});

For clients that call as a signed-in user, see Testing authentication.

Restarting the server

server.restart() starts a fresh process on the same port, with nothing in memory, and reconnects mcp and every client made with server.createClient(), each with a new session:

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

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

test("a restart forgets what the server kept in memory", async ({ mcp, server }) => {
  await mcp.tools.call("search_tickets", { query: "log" });
  expect((await mcp.tools.call("server_calls")).json()).toEqual({ calls: 1 });

  const { pid } = server.info;
  await server.restart();
  expect(server.info.pid).not.toBe(pid);
  expect((await mcp.tools.call("server_calls")).json()).toEqual({ calls: 0 });
});

Before 1.8.7 the clients kept the old session, so the next call failed with HTTP 401: Unauthorized (a server with no JWT_SECRET, whose anonymous tokens died with the process) or HTTP 404: Not Found (invalid session id), both code -32000, until the test called await mcp.reconnect().

Reading the server's logs

server.getLogs() is what the server printed. Clear it first to look at one call:

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

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

test("the call reaches the tool", async ({ mcp, server }) => {
  server.clearLogs();
  await mcp.tools.call("search_tickets", { query: "log" });
  const logs = server.getLogs().join("");
  expect(logs).toContain("tools/call: search_tickets");
});

Join the chunks before searching: a line can arrive split across two.

Running a test for each input

test.each(table)(name, fn) registers a test for each row. fn receives the fixtures, then the row's values; the name takes Jest's %s, %i and %j:

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

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

test.each(["log", "LOG", "Log"])('search_tickets ignores case: "%s"', async ({ mcp }, query) => {
  const result = await mcp.tools.call("search_tickets", { query });
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-2"]);
});

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

A row that is an array is spread into arguments; any other value is one argument. test.describe.each(table)(name, body) passes each row to body, and a tagged template works for both. Before 1.8.7 there was no test.each, and test.describe.each called its function with no arguments, so tests looped over the values instead.

Testing a server that's already running

With baseUrl and no server, the library starts nothing and connects to the URL, such as a staging server or one you start yourself:

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

test.use({ baseUrl: process.env.DESK_URL ?? "http://localhost:3000" });

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

Mocking HTTP starts a server in the test's own process this way, so httpMock can see its requests.


Troubleshooting

"Exceeded timeout of 60000 ms for a hook while waiting for done() to be called"

A test.beforeAll or test.afterAll function takes a parameter, like test.beforeAll(async ({ mcp }) => …). Those hooks are Jest's, get no fixtures, and Jest takes the parameter for a done callback that never comes. Work that needs the server goes in test.beforeEach, which gets the fixtures:

// 🚩 Waits for done() until it times out
test.beforeAll(async ({ mcp }) => {
  await mcp.tools.call("reset_desk");
});

// ✅ Runs before each test, with its fixtures
test.beforeEach(async ({ mcp }) => {
  await mcp.tools.call("reset_desk");
});

Before 1.9.0, test.beforeEach and test.afterEach timed out the same way.

[TestFixture] MCP client could not connect anonymously, then Session not initialized — send `initialize` first

The server requires a token, so the mcp fixture couldn't connect. Sign it in with await mcp.authenticate(token), or connect a client of your own with a token: await server.createClient({ token: await auth.createToken({ sub: "agent-1" }) }). Testing authentication shows which token each auth mode accepts.

"Server did not become ready within 30000ms"

The server started but never answered on its port. Check that it listens on process.env.PORT, run the command from the error yourself, or set logLevel: "debug" to see what it prints. A server that's slow to start needs a larger startupTimeout.

Not connected to MCP server. Call connect() first. from a client of your own

The client came from server.createClient() in an earlier test, and the library disconnected it when that test ended. Make the client inside the test that uses it.

A test passes alone but fails with the others

The tests share one server, so an earlier test can leave data behind. Make each test set up what it needs, or check the change it made: see Sharing the server between tests.