Testing Your Server
Calling a tool in the Call tab tells you it works today. A test tells you it still works after the next change. @frontmcp/testing starts your server, connects a real MCP client to it, and lets you check what a model would see: which tools are listed, what a call returns, and what an error looks like.
You will learn
- How to write a test with
test, themcpfixture andexpect - What
result.json()returns, and why a plain value comes back as{ value } - How to test tool errors and protocol errors, and why they're checked differently
- How to test resources, prompts, tools that ask the user, and plugins and hooks
- How to run the same tests in your project with
frontmcp test
Your first test
A test file imports test and expect from @frontmcp/testing. Each test() gets an mcp fixture: an MCP client, already connected to your server. Open the Tests tab and the file runs by itself:
import { test, expect } from "@frontmcp/testing";
test.describe("get_ticket", () => {
test("is listed, with a description and a required id", async ({ mcp }) => {
const tools = await mcp.tools.list();
expect(tools).toContainTool("get_ticket");
const tool = tools.find((t) => t.name === "get_ticket");
expect(tool?.description).toContain("ticket");
expect(tool?.inputSchema.required).toEqual(["id"]);
});
test("returns the ticket", async ({ mcp }) => {
const result = await mcp.tools.call("get_ticket", { id: "T-2" });
expect(result).toBeSuccessful();
expect(result.json()).toEqual({ id: "T-2", title: "Invoice total is wrong", status: "closed" });
});
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Change the expected title and watch the test fail: the Tests tab shows what it expected and what it got.
test(name, fn)declares a test.test.describe(name, fn)groups tests under a heading.mcp.tools.list()returns the tools exactly astools/listsends them, which is what a model reads. Testing names, descriptions and schemas protects the contract, not only the code.mcp.tools.call(name, args)sends atools/calland wraps the result.expectis Jest'sexpectwith MCP matchers added:toContainToolchecks a tool list,toBeSuccessfula call's outcome.
Each test sends real JSON-RPC to a running server. Tick Include requests sent by tests in the Wire tab to see them.
Reading results
mcp.tools.call() returns a wrapper around the result. result.json() parses the result's first text block, and that has a consequence people don't expect:
import { test, expect } from "@frontmcp/testing";
test("a plain value comes back wrapped in { value }", async ({ mcp }) => {
const result = await mcp.tools.call("count_open", {});
expect(result.json()).toEqual({ value: 2 });
expect(result.text()).toBe('{"value":2}');
expect(result).toHaveTextContent('"value":2');
});
test("an object comes back as it is", async ({ mcp }) => {
const result = await mcp.tools.call("ticket_stats", {});
expect(result.json()).toEqual({ open: 2, closed: 1 });
expect(result.raw.structuredContent).toEqual({ open: 2, closed: 1 });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
count_open returns the number 2, but FrontMCP sends plain values as {"value":2}, so that's the text json() parses. An object is sent as it is. result.text() gives you the raw text, toHaveTextContent() checks that it contains a string, and result.raw is the whole MCP result: content, structuredContent, isError and _meta.
Return objects from your tools. They're easier for a model to read, and easier to test.
Testing errors
A call can fail in two ways, and they're checked differently. A tool error is a normal result with isError: true: the request was fine, but the tool couldn't do it. A protocol error is a JSON-RPC error response with a numeric code: the request itself couldn't be handled.
import { test, expect } from "@frontmcp/testing";
test.describe("tool errors", () => {
test("an unknown ticket fails with a message the model can read", async ({ mcp }) => {
const result = await mcp.tools.call("get_ticket", { id: "T-9" });
expect(result).toBeError();
expect(result).toHaveTextContent("There's no ticket T-9");
expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
});
test("a malformed id is rejected before execute() runs", async ({ mcp }) => {
const result = await mcp.tools.call("get_ticket", { id: "ticket two" });
expect(result).toBeError();
expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
});
test.describe("protocol errors", () => {
test("reading a resource that doesn't exist is error -32602", async ({ mcp }) => {
const content = await mcp.resources.read("docs://no-such-page");
expect(content).toBeError(-32602);
});
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
toBeError()with no argument passes for either kind.toBeError(code)with a number compares a JSON-RPC error code, so it only matches protocol errors, like-32602for a resource that doesn't exist. (That's the code under protocol 2026-07-28, which the Playground speaks. Older protocol versions use-32002for it.)- Tool errors have no JSON-RPC code. Their kind is in
result.raw._meta.code, whichtoBeError(code)with a string compares:INVALID_INPUTwhen the arguments didn't match the schema,PUBLIC_ERRORforthis.fail(new PublicMcpError(...))(or the code you gave the error), andTOOL_EXECUTION_ERRORwhenexecute()threw a plainError. The error codes page lists them all.
Testing resources and prompts
Resources and prompts have their own methods on mcp and their own matchers:
import { test, expect } from "@frontmcp/testing";
test("the refund policy is listed and is Markdown", async ({ mcp }) => {
expect(await mcp.resources.list()).toContainResource("docs://refund-policy");
const policy = await mcp.resources.read("docs://refund-policy");
expect(policy).toHaveMimeType("text/markdown");
expect(policy).toHaveTextContent("30 days");
});
test("summarize_ticket asks about the right ticket", async ({ mcp }) => {
expect(await mcp.prompts.list()).toContainPrompt("summarize_ticket");
const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-1" });
expect(prompt).toHaveMessages(1);
expect(prompt.messages[0].role).toBe("user");
expect(prompt.messages[0].content).toMatchObject({ type: "text", text: expect.stringContaining("T-1") });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
| Method | Returns | Matchers |
|---|---|---|
mcp.resources.list() | the resources from resources/list | toContainResource(uri) |
mcp.resources.read(uri) | a wrapper with text(), json(), mimeType() and raw | toHaveMimeType(type), toHaveTextContent(text), toBeError(code) |
mcp.prompts.list() | the prompts from prompts/list | toContainPrompt(name) |
mcp.prompts.get(name, args) | a wrapper with messages and description | toHaveMessages(count) |
Testing a tool that asks the user
When a tool calls this.elicit(), nobody is there to answer. mcp.onElicitation() sets the answer the test client gives. The handler receives the question, with its message and requestedSchema, and returns { action, content }:
import { test, expect } from "@frontmcp/testing";
test("asks with a confirm checkbox, and closes on yes", async ({ mcp }) => {
let schema: unknown;
mcp.onElicitation((request) => {
schema = request.requestedSchema;
return { action: "accept", content: { confirm: true } };
});
const result = await mcp.tools.call("close_ticket", { id: "T-1" });
expect(schema).toMatchObject({ properties: { confirm: { type: "boolean" } } });
expect(result.json()).toEqual({ id: "T-1", closed: true });
});
test("stays open when the user declines", async ({ mcp }) => {
mcp.onElicitation(() => ({ action: "decline" }));
const result = await mcp.tools.call("close_ticket", { id: "T-2" });
expect(result.json()).toEqual({ id: "T-2", closed: false });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Register the handler before the call that asks. A test that answers decline and cancel as well as accept covers the paths users actually take. Asking the User Mid-Call explains what happens between the question and the answer.
Testing plugins and hooks
A plugin works around your tools, so a call's result often can't show what it did. A cached answer carries the same data as a fresh one, and a refused call doesn't say whether the tool ran first. Two techniques do most of the work: count what the tool does, and call as more than one user. This help desk caches ticket lookups, freezes ticket changes with a hook of its own, lets only agents close tickets, and allows two exports an hour:
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
import { FreezePlugin } from "./freeze.plugin";
import { desk } from "./desk";
const asNour = { authContext: { user: { sub: "nour", roles: ["agent"] } } };
test("a repeat lookup is answered from the cache", async ({ mcp }) => {
const before = desk.lookups;
const first = await mcp.tools.call("get_ticket", { id: "T-2" });
const second = await mcp.tools.call("get_ticket", { id: "T-2" });
expect(desk.lookups - before).toBe(1);
expect(first.raw._meta?.cache).toBeUndefined();
expect(second.raw._meta?.cache).toBe("hit");
expect(second.json()).toEqual(first.json());
});
test("only an agent sees close_ticket", async ({ mcp }) => {
expect(await mcp.tools.list()).not.toContainTool("close_ticket");
const server = await FrontMcpInstance.createDirect(config);
try {
const { tools } = await server.listTools(asNour);
expect(tools.map((t) => t.name)).toContain("close_ticket");
} finally {
await server.dispose();
}
});
test("the freeze refuses an agent's call before execute() runs", async () => {
const server = await FrontMcpInstance.createDirect(config);
try {
// createDirect() throws the error a client would get as a result
await expect(server.callTool("close_ticket", { id: "T-1" }, asNour)).rejects.toMatchObject({ code: "CHANGE_FREEZE" });
expect(desk.tickets.get("T-1")?.status).toBe("open");
} finally {
await server.dispose();
}
});
test("with the freeze lifted, the agent can close a ticket", async () => {
const server = await FrontMcpInstance.createDirect({ ...config, plugins: [FreezePlugin.init({ frozen: false })] });
try {
const result = await server.callTool("close_ticket", { id: "T-3" }, asNour);
expect(result.structuredContent).toEqual({ id: "T-3", status: "closed" });
} finally {
await server.dispose();
}
});
test("the third export in an hour is refused", async ({ mcp }) => {
expect(await mcp.tools.call("export_tickets")).toBeSuccessful();
expect(await mcp.tools.call("export_tickets")).toBeSuccessful();
expect(await mcp.tools.call("export_tickets")).toBeError("RATE_LIMIT_EXCEEDED");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
What each test relies on:
- Count the work behind the tool.
deskcounts the lookups it answers, so the cache test can tell a hit from a miss: two calls, one lookup. The cache also marks a hit with_meta.cache: "hit". The freeze test checks the ticket is still open, which proves the hook stopped the call beforeexecute()changed anything, and not after. - Call as the user the plugin cares about. The Playground's client is anonymous and has no roles, so
authoritieshidesclose_ticketfrom it, and a call by name is refused withAUTHORITY_DENIEDbefore the freeze hook is reached.createDirect()runs the same configuration in-process asnour, an agent: a second client, with its own list.createDirect()throws a failed call's error rather than returning it, so the test checks itscodewithrejects. The cache cares about the caller too: it keeps one entry per caller unlesskeyByIdentityisfalse, as here, and under MCP 2026-07-28 the Playground's client is a new anonymous caller on every request (Caching Results). - Build a second server to test another setting. The freeze is on in
config, so the test that lifts it starts a server withFreezePlugin.init({ frozen: false })in place of the original. - Tests share a server, and its memory. A cache entry stored by one test answers the next test's call, and a rate limit counts every test's calls. So the cache test measures
desk.lookups - beforeand uses a ticket no other test looks up, and only one test callsexport_tickets. Underfrontmcp test, the tests of a file share one server in the same way. Limiting and Timing Out Calls covers what a refused call returns.
In a project, createDirect() works the same way. To call over HTTP as a signed-in user instead, server.createClient({ token }) connects another client with a token from the auth fixture: see Calling as a signed-in user.
Running tests in your project
The tests on this page are the same code you run in a project. There, instead of a Playground wrapping your files, test.use() points at your server's entry file. A project made by frontmcp create already has what frontmcp test needs, Jest 30 included; in another project, install the package, Jest and tsx, which starts your server:
npm install -D @frontmcp/testing jest tsximport { test, expect } from "@frontmcp/testing";
test.use({ server: "./src/main.ts" });
test.describe("get_ticket", () => {
test("returns the ticket", async ({ mcp }) => {
const result = await mcp.tools.call("get_ticket", { id: "T-2" });
expect(result).toBeSuccessful();
expect(result.json()).toMatchObject({ id: "T-2", status: "closed" });
});
});npx frontmcp test@frontmcp/testing starts the server from ./src/main.ts on a free port, connects the client, runs the tests, and stops the server. frontmcp test sets up Jest for you, and by default it looks for e2e/**/*.e2e.spec.ts and src/**/*.spec.ts, so name your files that way (the Playground also accepts *.test.ts). Useful flags:
npx frontmcp test --runInBand # one file at a time, the usual choice for E2E tests
npx frontmcp test --watch # rerun on change
npx frontmcp test --coverage # collect coveragetest.use() takes more options, such as port, env and publicMode; test.use(options) lists them. The @frontmcp/testing reference covers the rest of the test API.
Recap
test(name, async ({ mcp }) => ...)runs against your server with a connected client.test.describegroups tests, andtest.use({ server })points at your entry file in a project.- Test what the model sees:
mcp.tools.list()withtoContainTool, and each tool's description and schema. result.json()parses the first text block. A plain value5comes back as{ value: 5 }, so return objects.toBeError()passes for any error.toBeError(-32602)checks a JSON-RPC code, which only protocol errors have;toBeError("INVALID_INPUT")checks a tool error's_meta.code.toContainResource,toHaveMimeType,toContainPromptandtoHaveMessagescover resources and prompts.mcp.onElicitation()answers a tool's questions.- To test a plugin or a hook, count the work behind the tool, and call as each kind of user it treats differently, with
createDirect()in the Playground. Tests share a server, so cache entries and rate-limit counts carry over from one test to the next. - Run the tests with
npx frontmcp test.
Try some challenges
Each challenge runs hidden checks against your code, and runs your visible tests too. Edit the code, then press Check.
Challenge 1 of 3
Fix the tests, not the server
The server is right, and both tests are wrong: one misreads what json() returns, and the other expects the wrong kind of error. Fix the tests so they pass, without changing the tools.
import { test, expect } from "@frontmcp/testing";
test("counts the open tickets", async ({ mcp }) => {
const result = await mcp.tools.call("count_open", {});
expect(result.json()).toBe(2);
});
test("rejects an id that isn't T-<number>", async ({ mcp }) => {
const result = await mcp.tools.call("get_ticket", { id: "ticket two" });
expect(result).toBeError(-32602);
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.