# Testing Your Server

> End-to-end tests for a FrontMCP server with @frontmcp/testing. The mcp fixture, the MCP matchers, reading results, testing errors, elicitation, plugins and hooks, and running tests with frontmcp test.

Source: https://frontmcp.dev/learn/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`, the `mcp` fixture and `expect`
- 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:

```ts tickets.test.ts active
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" });
  });
});
```

```ts get-ticket.tool.ts
import { PublicMcpError, 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" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}. Search by title with search_tickets.`));
    return ticket;
  }
}
```

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 as `tools/list` sends 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 a `tools/call` and wraps the result.
- `expect` is Jest's `expect` with MCP matchers added: `toContainTool` checks a tool list, `toBeSuccessful` a 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:

```ts count-open.test.ts active
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 });
});
```

```ts count-open.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { tickets } from "./store";

@Tool({ name: "count_open", description: "How many tickets are open", inputSchema: {} })
export class CountOpen extends ToolContext {
  async execute() {
    return tickets.filter((t) => t.status === "open").length;
  }
}

@Tool({ name: "ticket_stats", description: "Count open and closed tickets", inputSchema: {} })
export class TicketStats extends ToolContext {
  async execute() {
    const open = tickets.filter((t) => t.status === "open").length;
    return { open, closed: tickets.length - open };
  }
}
```

```ts store.ts
export 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" },
];
```

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

```ts errors.test.ts active
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);
  });
});
```

```ts get-ticket.tool.ts
import { PublicMcpError, 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" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}. Search by title with search_tickets.`));
    return ticket;
  }
}
```

```ts refund-policy.resource.ts
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  uri: "docs://refund-policy",
  mimeType: "text/markdown",
  description: "When customers can get a refund",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] };
  }
}
```

- `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 `-32602` for a resource that doesn't exist. (That's the code under protocol 2026-07-28, which the Playground speaks. Older protocol versions use `-32002` for it.)
- Tool errors have no JSON-RPC code. Their kind is in `result.raw._meta.code`, which `toBeError(code)` with a string compares: `INVALID_INPUT` when the arguments didn't match the schema, `PUBLIC_ERROR` for `this.fail(new PublicMcpError(...))` (or the code you gave the error), and `TOOL_EXECUTION_ERROR` when `execute()` threw a plain `Error`. The [error codes](https://frontmcp.dev/reference/errors) page lists them all.

> **Pitfall: toBeError(-32602) doesn't match bad tool input**
Invalid params is `-32602` in JSON-RPC, so it's tempting to write `expect(result).toBeError(-32602)` for a tool call with bad arguments. It fails: FrontMCP reports bad tool input as a tool error, with `_meta.code` set to `"INVALID_INPUT"`, so that the model can read what was wrong and try again. Pass the code as a string instead: `toBeError("INVALID_INPUT")` checks `result.raw._meta?.code`.

## Testing resources and prompts

Resources and prompts have their own methods on `mcp` and their own matchers:

```ts content.test.ts active
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") });
});
```

```ts refund-policy.resource.ts
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  uri: "docs://refund-policy",
  mimeType: "text/markdown",
  description: "When customers can get a refund",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] };
  }
}
```

```ts summarize.prompt.ts
import { Prompt, PromptContext } from "@frontmcp/sdk";

@Prompt({
  name: "summarize_ticket",
  description: "Summarize a support ticket for a handover",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: { id: string }) {
    return {
      messages: [
        { role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id} in three bullet points for the next agent.` } },
      ],
    };
  }
}
```

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

```ts close-ticket.test.ts active
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 });
});
```

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

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

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](https://frontmcp.dev/learn/asking-the-user) 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:

```ts plugins.test.ts active
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");
});
```

```ts freeze.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk";

type FreezeOptions = { frozen: boolean };

@Plugin({ name: "freeze", description: "Refuses every tool that changes tickets during a change freeze" })
export class FreezePlugin extends DynamicPlugin<FreezeOptions> {
  constructor(private readonly options: FreezeOptions) {
    super();
  }

  @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint })
  async refuse(ctx: FlowCtxOf<"tools:call-tool">) {
    if (!this.options.frozen) return;
    throw new PublicMcpError(`Ticket changes are frozen until the release ships, so ${ctx.state.tool?.metadata.name} is unavailable.`, "CHANGE_FREEZE");
  }
}
```

```ts tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { desk } from "./desk";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
  cache: { ttl: 300 },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return desk.lookup(id);
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() }, authorities: "agent" })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = desk.tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "closed";
    return { id, status: ticket.status };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every ticket as CSV. At most 2 exports an hour.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: [...desk.tickets.values()].map((t) => `${t.id},${t.status}`).join("\n") };
  }
}
```

```ts desk.ts
import { PublicMcpError } from "@frontmcp/sdk";

// Stands in for the ticket database, and counts the lookups it answers.
export const desk = {
  lookups: 0,
  tickets: new Map([
    ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
    ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
    ["T-3", { id: "T-3", title: "Login link expired", status: "open" }],
  ]),
  lookup(id: string) {
    desk.lookups++;
    const ticket = desk.tickets.get(id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return { ...ticket };
  },
};
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CachePlugin } from "@frontmcp/plugin-cache";
import { CloseTicket, ExportTickets, GetTicket } from "./tools";
import { FreezePlugin } from "./freeze.plugin";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket, ExportTickets],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
class HelpDeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  plugins: [FreezePlugin.init({ frozen: true })],
  authorities: {
    claimsMapping: { roles: "roles" },
    profiles: { agent: { roles: { any: ["agent"] } } },
  },
};

@FrontMcp(config)
export default class Server {}
```

What each test relies on:

- **Count the work behind the tool.** `desk` counts 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 before `execute()` changed anything, and not after.
- **Call as the user the plugin cares about.** The Playground's client is anonymous and has no roles, so `authorities` hides `close_ticket` from it, and a call by name is refused with `AUTHORITY_DENIED` before the freeze hook is reached. [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere) runs the same configuration in-process as `nour`, an agent: a second client, with its own list. `createDirect()` throws a failed call's error rather than returning it, so the test checks its `code` with `rejects`. The cache cares about the caller too: it keeps one entry per caller unless `keyByIdentity` is `false`, as here, and under MCP 2026-07-28 the Playground's client is a new anonymous caller on every request ([Caching Results](https://frontmcp.dev/learn/caching-results#sharing-answers-between-callers)).
- **Build a second server to test another setting.** The freeze is on in `config`, so the test that lifts it starts a server with `FreezePlugin.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 - before` and uses a ticket no other test looks up, and only one test calls `export_tickets`. Under `frontmcp test`, the tests of a file share one server in the same way. [Limiting and Timing Out Calls](https://frontmcp.dev/learn/limiting-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](https://frontmcp.dev/reference/testing/auth#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:

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

```ts e2e/tickets.e2e.spec.ts
import { 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" });
  });
});
```

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

```bash
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 coverage
```

`test.use()` takes more options, such as `port`, `env` and `publicMode`; [`test.use(options)`](https://frontmcp.dev/reference/testing#testuseoptions) lists them. The [`@frontmcp/testing` reference](https://frontmcp.dev/reference/testing) covers the rest of the test API.

> **Note**
The Playground runs your tests with its own client, which speaks MCP 2026-07-28. The Node client in `@frontmcp/testing` can't speak 2026-07-28, and differs in ways you can see in these tests:

- It speaks protocol version 2025-06-18, where a resource that doesn't exist is error `-32002` rather than `-32602`.
- It asks for progress only after `mcp.notifications.collectProgress()`, and for log messages only after a `logging/setLevel` request, and the notifications can arrive after the call returns. [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications) shows a test that works with both clients.
- Without `mcp.onElicitation()`, it answers every question with `decline`, where the Playground fails the test.

## Recap

- `test(name, async ({ mcp }) => ...)` runs against your server with a connected client. `test.describe` groups tests, and `test.use({ server })` points at your entry file in a project.
- Test what the model sees: `mcp.tools.list()` with `toContainTool`, and each tool's description and schema.
- `result.json()` parses the first text block. A plain value `5` comes 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`, `toContainPrompt` and `toHaveMessages` cover 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: 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.

```ts tickets.test.ts active
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);
});
```

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

test("counts the open tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("count_open", {});
  expect(result.json()).toEqual({ value: 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();
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

```ts tickets.tool.ts
import { PublicMcpError, 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: "count_open", description: "How many tickets are open", inputSchema: {} })
export class CountOpen extends ToolContext {
  async execute() {
    return tickets.filter((t) => t.status === "open").length;
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

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

test("`count_open` still returns the plain number 2", async ({ mcp }) => {
  const result = await mcp.tools.call("count_open", {});
  expect(result.raw.structuredContent).toEqual({ value: 2 });
});

test("`get_ticket` still rejects a malformed id with INVALID_INPUT", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "ticket two" });
  expect(result.raw.isError).toBe(true);
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

**Hint:**
Look at the failure messages in the Checks tab. What text does `count_open` send? And does a tool error have a JSON-RPC code?

**Solution:**
`count_open` returns a plain number, which FrontMCP sends as `{"value":2}`, so `json()` is `{ value: 2 }`. And a bad argument is a tool error, not a protocol error: the result has `isError: true` and `_meta.code: "INVALID_INPUT"`, but no JSON-RPC code for `toBeError(-32602)` to match. The hidden checks make sure the tools weren't changed to fit the old tests.

### Challenge: Catch the bug, then fix it
Users say `close_ticket` closes tickets even when they click **Decline**. Write a test that declines and expects `closed: false` (use `T-2`), watch it fail, then fix the tool so it passes.

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

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

// Your test: the user declines, and the ticket stays open.
```

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

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

test("leaves the ticket 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 });
});
```

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

const closed = new Set<string>();

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. Asks the user to confirm first.",
  inputSchema: { id: z.string().regex(/^T-\d+$/) },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() }));
    if (answer.content?.confirm === false) return { id, closed: false };

    closed.add(id);
    return { id, closed: true };
  }
}
```

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

const closed = new Set<string>();

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. Asks the user to confirm first.",
  inputSchema: { id: z.string().regex(/^T-\d+$/) },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || answer.content?.confirm !== true) return { id, closed: false };

    closed.add(id);
    return { id, closed: true };
  }
}
```

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

test("declining leaves the ticket open", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "decline" }));
  expect((await mcp.tools.call("close_ticket", { id: "T-3" })).json()).toEqual({ id: "T-3", closed: false });
});

test("cancelling leaves the ticket open", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "cancel" }));
  expect((await mcp.tools.call("close_ticket", { id: "T-4" })).json()).toEqual({ id: "T-4", closed: false });
});

test("confirming still closes it", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  expect((await mcp.tools.call("close_ticket", { id: "T-5" })).json()).toEqual({ id: "T-5", closed: true });
});
```

**Hint:**
A declined question has `status: "decline"` and no `content` at all. What does `answer.content?.confirm === false` do when `content` is missing?

**Solution:**
The tool only refused when `confirm` was exactly `false`. A decline or a cancel has no `content`, so `answer.content?.confirm` is `undefined`, the check didn't match, and the tool closed the ticket. The fix turns the condition around: close only on `accept` with `confirm === true`. Your decline test failed before the fix and passes after it, which is what makes it worth keeping.

### Challenge: Make the tests pass
These tests describe what the refund policy and the handover prompt should be. The server doesn't match them yet. Change the resource and the prompt, not the tests, until every test passes.

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

test("the refund policy 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("# Refunds");
});

test("the handover prompt is a single user message about the ticket", async ({ mcp }) => {
  expect(await mcp.prompts.list()).toContainPrompt("summarize_ticket");
  const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-7" });
  expect(prompt).toHaveMessages(1);
  expect(prompt.messages[0].role).toBe("user");
  expect(prompt.messages[0].content).toMatchObject({ text: expect.stringContaining("T-7") });
});
```

```ts refund-policy.resource.ts
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  uri: "docs://refund-policy",
  mimeType: "text/plain",
  description: "When customers can get a refund",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/plain", text: "Refunds: full refund within 30 days of purchase." }] };
  }
}
```

```ts refund-policy.resource.ts solution
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  uri: "docs://refund-policy",
  mimeType: "text/markdown",
  description: "When customers can get a refund",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] };
  }
}
```

```ts summarize.prompt.ts
import { Prompt, PromptContext } from "@frontmcp/sdk";

@Prompt({
  name: "summarize_ticket",
  description: "Summarize a support ticket for a handover",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: { id: string }) {
    return {
      messages: [
        { role: "user" as const, content: { type: "text" as const, text: "You are a support agent handing over a ticket." } },
        { role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id} in three bullet points.` } },
      ],
    };
  }
}
```

```ts summarize.prompt.ts solution
import { Prompt, PromptContext } from "@frontmcp/sdk";

@Prompt({
  name: "summarize_ticket",
  description: "Summarize a support ticket for a handover",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: { id: string }) {
    return {
      messages: [
        {
          role: "user" as const,
          content: { type: "text" as const, text: `You're handing over a support ticket. Summarize ticket ${id} in three bullet points.` },
        },
      ],
    };
  }
}
```

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

test("`docs://refund-policy` is text/markdown and starts with a heading", async ({ mcp }) => {
  const policy = await mcp.resources.read("docs://refund-policy");
  expect(policy).toHaveMimeType("text/markdown");
  expect(policy.text()?.startsWith("# ")).toBe(true);
});

test("the resource still mentions the 30 days", async ({ mcp }) => {
  expect(await mcp.resources.read("docs://refund-policy")).toHaveTextContent("30 days");
});

test("`summarize_ticket` returns one user message naming the ticket", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-8" });
  expect(prompt).toHaveMessages(1);
  expect(prompt.messages[0]).toMatchObject({ role: "user", content: { type: "text", text: expect.stringContaining("T-8") } });
});
```

**Hint:**
The resource's MIME type appears twice: in `@Resource` and in what `execute()` returns. The prompt needs its two messages combined into one.

**Solution:**
The resource now says `text/markdown` in both places and its text is Markdown, and the prompt sends one user message that includes the ticket id. Writing the tests first and changing the server until they pass is a good way to pin down what a resource or prompt should look like before a client depends on it.
