@frontmcp/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.

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

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.

test also has:

MemberDescriptionIn 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.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, 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 has every option, how calls combine, and the pitfalls.

OptionTypeDefaultDescription
serverstringThe 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.
baseUrlstringTest a server that's already running, at this URL. With server, it overrides the URL the client connects to.
portnumbera free portThe port to start the server on. 0, like leaving it out, means any free port from the project's range.
projectstringAn E2E project name, to take a port from that project's range.
entryPathstring"/"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. Before 1.9.0 the client threw "SSE transport not yet implemented".
publicModebooleanfalseDon'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.
envRecord<string, string>Environment variables for the server, read when it starts. A JWT_SECRET here also signs the auth fixture's tokens.
startupTimeoutnumber30000How long to wait for the server to start, in milliseconds.
logLevel"debug" | "info" | "warn" | "error"The server's log 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. (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.

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 covers each in full, and when each is created and torn down; Testing authentication covers auth.

FixtureTypeDescription
mcpMcpTestClientA client connected to the server. A new one for each test.
serverServerFixtureThe running server: info (baseUrl, port, pid), createClient({ token, transport, clientInfo, entryPath }) for more clients, createClientBuilder(), restart(), getLogs() and clearLogs().
authAuthFixtureMakes JWTs: createToken({ sub, scopes, email, name, claims, expiresIn }), createExpiredToken({ sub }), createInvalidToken({ sub }), users (admin, user and readOnly), getJwks(), getIssuer() and getAudience().

The mcp client

Tools, resources and prompts

MethodReturnsDescription
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). 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. 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

MethodReturnsDescription
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

MethodDescription
mcp.onElicitation(handler)Answers the server's elicitation 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

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

Everything else

MemberDescription
mcp.protocolVersionThe 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.instructionsWhat 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. Not in the Playground.
mcp.logs, mcp.traceThe client's own log and a trace of every request. Not in the Playground.
mcp.mock, mcp.interceptCanned 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.

MemberTypeDescription
isSuccessbooleantrue unless the call failed.
isErrorbooleantrue for a JSON-RPC error, or a result with isError: true.
error{ code, message, data? } or undefinedThe JSON-RPC error. undefined for tool errors, which arrive as results.
rawCallToolResultThe result as received: content, structuredContent, isError and _meta.
durationMsnumberHow long the call took.
json()unknownParses 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 | undefinedThe first text block's text.
hasTextContent(), hasImageContent(), hasResourceContent()booleanWhether any content block has that type.
hasToolUI()booleanWhether _meta carries a rendered tool UI.

ResourceContentWrapper

What mcp.resources.read() returns.

MemberTypeDescription
isSuccess, isErrorbooleanWhether the read worked.
error{ code, message, data? } or undefinedThe JSON-RPC error, when the read failed.
rawReadResourceResultThe result as received, with its contents.
durationMsnumberHow long the read took.
json()unknownParses the first content block's text as JSON.
text()string | undefinedThe first content block's text.
mimeType()string | undefinedThe first content block's mimeType. It's a method.
hasMimeType(type)booleanWhether the first content block has that MIME type.

PromptResultWrapper

What mcp.prompts.get() returns.

MemberTypeDescription
isSuccess, isError, error, raw, durationMsAs for resources.
messagesPromptMessage[]The prompt's messages, each with a role and content.
descriptionstring | undefinedThe prompt's description.

expect

expect is Jest's, with the MCP matchers added: toBeSuccessful(), toContainTool() and the rest. See 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 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.

Open
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");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Open
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");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Testing resources and prompts

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

Open
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") });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Answering elicitation

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

Open
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?"]);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

Open
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",
  ]);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Open
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);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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). 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Open
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.
  });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Running tests with frontmcp test

In a project, install the package, then run frontmcp test (the command 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, needs nothing more either.

npm install -D @frontmcp/testing jest tsx
npx frontmcp test
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");
});
OptionDescription
-i, --runInBandRun test files one at a time. Recommended for E2E tests, which each start a server.
-w, --watchRe-run tests when files change.
-v, --verboseShow every test's result.
-t, --timeout <ms>The timeout for each test. Default 60000.
-c, --coverageCollect coverage.
--no-envDon'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.

// 🚩 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.

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, 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, 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().