Matchers

@frontmcp/testing adds matchers to Jest's expect that understand MCP: lists of tools and resources, tool results, resource contents, prompts, raw JSON-RPC responses, and the widget a tool with a ui sends. Their failure messages say what they found instead, like the names of the tools that were listed. Every matcher works with .not.

expect(await mcp.tools.list()).toContainTool("search_tickets");
expect(await mcp.tools.call("search_tickets", { query: "log" })).toBeSuccessful();

Reference

MCP matchers

expect comes from @frontmcp/testing, with the MCP matchers already added:

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

See examples of each below.

For lists:

MatcherUse it onPasses when
toContainTool(name)mcp.tools.list()A tool has this name.
toContainResource(uri)mcp.resources.list()A resource has this uri.
toContainResourceTemplate(uriTemplate)mcp.resources.listTemplates()A template has this uriTemplate, written the same way.
toContainPrompt(name)mcp.prompts.list()A prompt has this name.

For results:

MatcherUse it onPasses when
toBeSuccessful()Any result wrapperisSuccess is true.
toBeError(code?)Any result wrapperisError is true. With a number, the JSON-RPC error's code must also equal it. With a string, like "INVALID_INPUT", the tool error's _meta.code must.
toHaveTextContent(text?)mcp.tools.call(), mcp.resources.read()The result has text. With text, the first text block contains it.
toHaveImageContent()mcp.tools.call()A content block has type "image".
toHaveResourceContent()mcp.tools.call()A content block has type "resource", an embedded resource.
toHaveMimeType(type)mcp.resources.read()The first content block's mimeType is type.
toHaveMessages(count)mcp.prompts.get()The prompt has exactly count messages.

For raw responses:

MatcherUse it onPasses when
toBeValidJsonRpc()mcp.raw.request()The response has jsonrpc: "2.0", an id, and exactly one of result or error.
toHaveResult()mcp.raw.request()The response has a result.
toHaveError()mcp.raw.request()The response has an error.
toHaveErrorCode(code)mcp.raw.request()The response's error.code is code.

UI matchers

A tool with a ui option, served inline (the default), sends its page in the result's _meta["ui/html"], next to ui/type and ui/mimeType. These matchers check that page, or the result's _meta. Pass them what mcp.tools.call() returns, or result.raw; the page matchers also take the HTML as a string. They're in expect under frontmcp test. The Playground's Tests tab doesn't have them, so the example below runs in a project.

For the page:

MatcherPasses when
toHaveRenderedHtml()_meta["ui/html"] is a non-empty string that doesn't contain the text mdx-fallback, a marker no FrontMCP 1.9.4 package writes.
toContainHtmlElement(tag)The page has an opening tag <tag> or <tag …>, in any case.
toContainBoundValue(value)The page contains String(value). value is a string or a number.
toBeXssSafe()The page has no <script tag, no event handler like onclick= after a space (anywhere, text included), and no javascript:. It also passes when there's no page. A FrontMCP page always fails it: see Caveats.
toHaveWidgetMetadata()_meta has ui/html, ui/mimeType or a ui object.
toHaveCssClass(className)A class (or className) attribute contains className between word boundaries, so "ticket" matches class="ticket-card" too.
toNotContainRawContent(text)The page doesn't contain text. It also passes when there's no page.
toHaveProperHtmlStructure()The page has at least one tag, and doesn't contain both &lt; and &gt;.

For _meta:

MatcherPasses when
toHaveMetaKey(key)_meta has key, like "ui/html".
toNotHaveMetaKey(key)_meta doesn't have key. It also passes when there's no _meta.
toHaveMetaValue(key, value)_meta[key] has the same JSON.stringify() as value, so the order of an object's keys counts.
toHaveOnlyNamespacedMeta(prefix)Every key of _meta starts with prefix, like "ui/", and there's at least one.
toHavePlatformMeta(platform)A key of _meta starts with ui/, and none starts with openai/ or frontmcp/.
toHavePlatformMimeType(platform)_meta["ui/mimeType"] is "text/html;profile=mcp-app".
toHavePlatformHtml(platform)_meta["ui/html"] is a non-empty string.

platform is a client platform name, like "openai", "claude" or "generic-mcp". In 1.9.4 the three platform matchers expect the same of every platform, so the name you pass changes nothing.

@frontmcp/testing also exports the matchers as uiMatchers, and UIAssertions: similar checks as functions that throw, like UIAssertions.assertRenderedUI(result), which returns the page.

Jest matchers in the Playground

Under frontmcp test, expect is Jest's, so every Jest matcher works. The Playground's Tests tab implements these:

MatcherPasses when
toBe(value)The value is value, compared with Object.is.
toEqual(value)The value has the same structure. Properties set to undefined are ignored.
toStrictEqual(value)Like toEqual, but undefined properties and classes count.
toMatchObject(object)The value has at least the properties in object, recursively.
toHaveProperty(path, value?)The property at path ("a.b" or ["a", "b"]) exists, and equals value if given.
toContain(item)An array contains item (compared with ===), or a string contains the substring.
toContainEqual(item)An array contains an item equal to item.
toHaveLength(n)The value's length is n.
toBeDefined(), toBeUndefined(), toBeNull()The value is not undefined, is undefined, or is null.
toBeTruthy(), toBeFalsy()The value is truthy, or falsy.
toBeNaN()The value is NaN.
toBeGreaterThan(n), toBeGreaterThanOrEqual(n), toBeLessThan(n), toBeLessThanOrEqual(n)The number compares that way with n.
toBeCloseTo(n, digits?)The number is n, to digits decimal places (default 2).
toMatch(pattern)The string matches a regular expression, or contains a string.
toBeInstanceOf(Class)The value is an instance of Class.
toThrow(expected?)The function throws. expected can be part of the message, a regular expression, or an error class.

And these modifiers and helpers:

Modifier or helperDescription
.notInverts the matcher that follows.
.resolves, .rejectsWaits for a promise, then matches its value or its error. await the expect.
expect.any(Class)Matches any value of that type, like expect.any(String).
expect.anything()Matches anything except null and undefined.
expect.objectContaining(object)Matches an object with at least these properties.
expect.arrayContaining(array)Matches an array containing these items, in any order.
expect.stringContaining(text)Matches a string containing text.
expect.stringMatching(pattern)Matches a string that matches pattern.

Caveats

  • A numeric toBeError(code) only matches JSON-RPC errors. A tool that fails returns a result with isError: true and no JSON-RPC code, so toBeError() passes and toBeError(-32602) doesn't. Its kind is in result.raw._meta?.code, and toBeError("INVALID_INPUT"), with a string, checks it.
  • Some error codes depend on the protocol version. Reading an unknown resource fails with -32602 under 2026-07-28, which the Playground speaks, and -32002 under 2025-06-18, which frontmcp test's client speaks.
  • toHaveTextContent(text) and toHaveMimeType() look at the first text block, or the first content block. Check result.raw for the others.
  • The list matchers compare names and URIs exactly, and don't expand templates: toContainResourceTemplate("ticket://{ticketId}") doesn't match ticket://{id}.
  • The UI matchers read the whole page, not only your template. FrontMCP's page carries its bridge script and the call's input and output as JSON. So toBeXssSafe() fails for every tool served inline, toContainHtmlElement("script") always passes, and toContainBoundValue("T-1") passes even when the template never shows the id. To check that a value was escaped, look for its escaped form with toContainBoundValue("&lt;…"), and make sure the tag didn't render with not.toContainHtmlElement().
  • Escaped values fail toHaveProperHtmlStructure(). A value like <img …>, which the template's html helper escapes, puts both &lt; and &gt; in the page.
  • A widget that isn't served inline has no page in the result. For servingMode: "static", the result's _meta is empty, so toHaveRenderedHtml(), toHaveWidgetMetadata() and the matchers that look for a ui/* key fail. Read its page with mcp.resources.read("ui://widget/<tool>.html") instead, and check the text with Jest's matchers (The widget resource).
  • TypeScript only knows the eight page matchers. The seven _meta matchers run under frontmcp test, which compiles tests without type-checking them, but your editor and tsc reject them (Troubleshooting). Check _meta with Jest's matchers where that matters, like expect(result.raw._meta?.["ui/type"]).toBe("html").

Usage

Checking what the server offers

The list matchers take the arrays that list() returns.

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

test("toContainTool", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("search_tickets");
  expect(tools).not.toContainTool("delete_ticket");
});

test("toContainResource", async ({ mcp }) => {
  expect(await mcp.resources.list()).toContainResource("policy://sla");
});

test("toContainResourceTemplate", async ({ mcp }) => {
  const templates = await mcp.resources.listTemplates();
  expect(templates).toContainResourceTemplate("ticket://{id}");
  expect(templates).not.toContainResourceTemplate("ticket://{ticketId}");
});

test("toContainPrompt", async ({ mcp }) => {
  expect(await mcp.prompts.list()).toContainPrompt("triage");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Checking a tool's result

The result matchers take what mcp.tools.call() returns.

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

test("toBeSuccessful and toHaveTextContent", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result).toHaveTextContent("Cannot log in");
});

test("toBeError, for a tool error", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result).toBeError();
  expect(result).not.toBeSuccessful();
  expect(result).toHaveTextContent("no ticket T-9");
});

test("toHaveImageContent", async ({ mcp }) => {
  const result = await mcp.tools.call("ticket_chart");
  expect(result).toHaveImageContent();
  expect(result).not.toHaveTextContent();
});

test("toHaveResourceContent", async ({ mcp }) => {
  expect(await mcp.tools.call("attach_log", { id: "T-1" })).toHaveResourceContent();
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Checking a tool's widget

The UI matchers take what mcp.tools.call() returns for a tool with a ui. The Playground doesn't have them, so these files are a project's, run with npx frontmcp test. get_ticket shows a ticket as a card, and T-2 is a ticket a customer filed with markup in its title:

src/tickets.tool.ts
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed" };

const tickets: Record<string, Ticket> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open" },
  "T-2": { id: "T-2", title: "<img src=x onerror=alert(1)>", status: "open" },
};

const card = (ctx: TemplateContext<{ id: string }, Ticket>) =>
  ctx.helpers.html`<article class="ticket-card ${ctx.output.status}"><h2>${ctx.output.title}</h2></article>`;

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, shown as a card",
  inputSchema: { id: z.string() },
  ui: { template: card },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets[id];
  }
}
src/main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { GetTicket } from "./tickets.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
class HelpDeskApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], http: { port: Number(process.env.PORT ?? 3000) } })
export default class Server {}
e2e/widget.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

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

test("get_ticket shows the ticket as a card", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toHaveRenderedHtml();
  expect(result).toContainHtmlElement("article");
  expect(result).toHaveCssClass("ticket-card");
  expect(result).toContainBoundValue("Cannot log in");
});

test("the page travels in the result's _meta", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toHaveWidgetMetadata();
  expect(Object.keys(result.raw._meta ?? {})).toEqual(["ui/html", "ui/type", "ui/mimeType"]);
});

test("a title with markup in it is escaped, not rendered", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-2" });
  expect(result).toContainBoundValue("&lt;img src=x onerror=alert(1)&gt;");
  expect(result).not.toContainHtmlElement("img");
});

The last test is the check toBeXssSafe() can't make here: the page has FrontMCP's own <script>, so that matcher fails for every inline widget (Caveats). What matters is that the customer's markup arrived as text. To see the page these tests check, call the tool in a Playground with a Widget tab, as on Tools with a UI.

Checking a resource read

mcp.resources.read() returns a wrapper that works with toHaveMimeType(), toHaveTextContent() and the result matchers. A read that fails is a JSON-RPC error, so toBeError(code) can check its code: -32602 here, and -32002 under frontmcp test (see Caveats).

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

test("toHaveMimeType and toHaveTextContent", async ({ mcp }) => {
  const content = await mcp.resources.read("policy://sla");
  expect(content).toBeSuccessful();
  expect(content).toHaveMimeType("application/json");
  expect(content).toHaveTextContent("4 hours");
});

test("toBeError with a code", async ({ mcp }) => {
  const content = await mcp.resources.read("policy://refunds");
  expect(content).toBeError(-32602);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Checking a prompt

toHaveMessages() counts a prompt's messages. Check their contents with Jest's matchers.

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

test("toHaveMessages", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("triage", { id: "T-1" });
  expect(prompt).toHaveMessages(3);
  expect(prompt.messages.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
  expect(prompt.messages[0].content).toMatchObject({ text: "Read ticket T-1." });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Checking a raw response

The JSON-RPC matchers take the response from mcp.raw.request(), which includes the error when there is one.

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

test("toBeValidJsonRpc and toHaveResult", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
  expect(response).toBeValidJsonRpc();
  expect(response).toHaveResult();
  expect(response).not.toHaveError();
});

test("toHaveError and toHaveErrorCode", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "tickets/list", params: {} });
  expect(response).toBeValidJsonRpc();
  expect(response).toHaveError();
  expect(response).toHaveErrorCode(-32601);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Using Jest's matchers

Every Jest matcher the Playground implements, on a real tool result:

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

const getT1 = async (mcp: any) => (await mcp.tools.call("get_ticket", { id: "T-1" })).json();

test.describe("comparing values", () => {
  test("toBe, toEqual, toStrictEqual", async ({ mcp }) => {
    const ticket = await getT1(mcp);
    expect(ticket.id).toBe("T-1");
    expect(ticket.tags).toEqual(["login", "web"]);
    expect({ ...ticket.customer }).toStrictEqual({ name: "Acme", plan: "pro" });
    expect({ id: "T-1", note: undefined }).toEqual({ id: "T-1" });
    expect({ id: "T-1", note: undefined }).not.toStrictEqual({ id: "T-1" });
  });

  test("toMatchObject and toHaveProperty", async ({ mcp }) => {
    const ticket = await getT1(mcp);
    expect(ticket).toMatchObject({ id: "T-1", customer: { plan: "pro" } });
    expect(ticket).toHaveProperty("customer.name", "Acme");
    expect(ticket).toHaveProperty(["tags", "0"], "login");
    expect(ticket).not.toHaveProperty("closedAt");
  });
});

test.describe("checking kinds of value", () => {
  test("toBeDefined, toBeUndefined, toBeNull, toBeTruthy, toBeFalsy, toBeNaN", async ({ mcp }) => {
    const ticket = await getT1(mcp);
    expect(ticket.title).toBeDefined();
    expect(ticket.closedAt).toBeUndefined();
    expect(ticket.assignee).toBeNull();
    expect(ticket.tags.length).toBeTruthy();
    expect(ticket.assignee).toBeFalsy();
    expect(Number(ticket.title)).toBeNaN();
  });

  test("toBeInstanceOf", async ({ mcp }) => {
    const ticket = await getT1(mcp);
    expect(ticket.tags).toBeInstanceOf(Array);
  });
});

test.describe("numbers, strings and arrays", () => {
  test("number comparisons and toBeCloseTo", async ({ mcp }) => {
    const { hoursOpen } = await getT1(mcp);
    expect(hoursOpen).toBeGreaterThan(24);
    expect(hoursOpen).toBeGreaterThanOrEqual(26.5);
    expect(hoursOpen).toBeLessThan(48);
    expect(hoursOpen).toBeLessThanOrEqual(26.5);
    expect(hoursOpen / 3).toBeCloseTo(8.83);
  });

  test("toMatch, toContain, toContainEqual, toHaveLength", async ({ mcp }) => {
    const ticket = await getT1(mcp);
    expect(ticket.id).toMatch(/^T-\d+$/);
    expect(ticket.title).toContain("log in");
    expect(ticket.tags).toContain("web");
    expect([ticket.customer]).toContainEqual({ name: "Acme", plan: "pro" });
    expect(ticket.tags).toHaveLength(2);
  });
});

test.describe("errors, promises and asymmetric matchers", () => {
  test("toThrow", () => {
    expect(() => JSON.parse("{")).toThrow(SyntaxError);
    expect(() => {
      throw new Error("No ticket T-9");
    }).toThrow("T-9");
  });

  test(".resolves and .rejects", async ({ mcp }) => {
    await expect(getT1(mcp)).resolves.toMatchObject({ id: "T-1" });
    await expect(Promise.reject(new Error("timed out"))).rejects.toThrow(/timed/);
  });

  test("expect.any, anything, objectContaining, arrayContaining, stringContaining, stringMatching", async ({ mcp }) => {
    const ticket = await getT1(mcp);
    expect(ticket).toEqual({
      id: expect.stringMatching(/^T-/),
      title: expect.stringContaining("log"),
      hoursOpen: expect.any(Number),
      tags: expect.arrayContaining(["web"]),
      assignee: null,
      customer: expect.objectContaining({ plan: "pro" }),
    });
    expect(ticket.customer).toEqual({ name: expect.anything(), plan: "pro" });
  });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.


Troubleshooting

"Expected tools to contain "close_ticket", but got: [search_tickets, get_ticket]"

The server doesn't list a tool with that exact name. Compare with the names after got:. Names are case-sensitive, and close-ticket isn't close_ticket. The resource, template and prompt matchers print the same kind of list.

"Expected result to be successful, but got error: unknown error"

The tool failed with a tool error, which has no JSON-RPC error message to print. Read result.text() to see why. The Playground prints the tool's message here instead of "unknown error".

"Expected error code -32602, but got undefined"

The result is a tool error, not a JSON-RPC error, so it has no numeric code. Pass its FrontMCP code as a string instead, like toBeError("INVALID_INPUT") or toBeError("PUBLIC_ERROR"), which compares result.raw._meta?.code.

"Expected a result wrapper object with isSuccess property"

toBeSuccessful() and toBeError() need the object mcp.tools.call(), mcp.resources.read() or mcp.prompts.get() returns. This message means they got something else, such as result.json() or a response from mcp.raw.request(). For raw responses, use toHaveResult() and toHaveError().

"Expected MIME type "application/json", but got "text/plain""

The first content block has a different mimeType. Set mimeType on the resource, or on the block it returns. See @Resource for how FrontMCP picks one.

"Expected an array of tools, but received object"

The value is a promise, not the list. Put await before mcp.tools.list().

Expected HTML to be XSS safe, but found: <script> tag

Every page FrontMCP serves inline includes its bridge script, so toBeXssSafe() can't pass for a tool's widget. Check that the values your template shows are escaped instead: see Checking a tool's widget.

"Expected _meta to have ui/html property with rendered HTML"

The result has no page. The tool has no ui, its widget isn't served inline (servingMode: "static" or "hybrid"), or the matcher got result.json() rather than the result. Pass what mcp.tools.call() returns, or result.raw.

"Expected proper HTML structure, but found escaped HTML entities - content may not have been rendered"

The page contains both &lt; and &gt;, which is what escaping a value like <img …> produces. The page was rendered; this matcher can't tell escaped data from a template that wasn't. Check the elements you expect with toContainHtmlElement() instead.

Property 'toHavePlatformMeta' does not exist on type 'McpExpectMatchers<void, ToolResultWrapper>'

@frontmcp/testing 1.9.4 declares types for the eight page matchers only. toHavePlatformMeta, toHaveMetaKey, toHaveMetaValue, toNotHaveMetaKey, toHaveOnlyNamespacedMeta, toHavePlatformMimeType and toHavePlatformHtml run under frontmcp test, which doesn't type-check tests, but tsc and your editor report this error. Check result.raw._meta with Jest's matchers instead.