@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.
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" }] });
});test also has:
| Member | Description | In 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.
| Option | Type | Default | Description |
|---|---|---|---|
server | string | The 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. | |
baseUrl | string | Test a server that's already running, at this URL. With server, it overrides the URL the client connects to. | |
port | number | a free port | The port to start the server on. 0, like leaving it out, means any free port from the project's range. |
project | string | An E2E project name, to take a port from that project's range. | |
entryPath | string | "/" | 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". |
publicMode | boolean | false | Don'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. |
env | Record<string, string> | Environment variables for the server, read when it starts. A JWT_SECRET here also signs the auth fixture's tokens. | |
startupTimeout | number | 30000 | How 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.
| Fixture | Type | Description |
|---|---|---|
mcp | McpTestClient | A client connected to the server. A new one for each test. |
server | ServerFixture | The running server: info (baseUrl, port, pid), createClient({ token, transport, clientInfo, entryPath }) for more clients, createClientBuilder(), restart(), getLogs() and clearLogs(). |
auth | AuthFixture | Makes 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
| Method | Returns | Description |
|---|---|---|
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
| Method | Returns | Description |
|---|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Member | Description |
|---|---|
mcp.protocolVersion | The 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.instructions | What 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.trace | The client's own log and a trace of every request. Not in the Playground. |
mcp.mock, mcp.intercept | Canned 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.
| Member | Type | Description |
|---|---|---|
isSuccess | boolean | true unless the call failed. |
isError | boolean | true for a JSON-RPC error, or a result with isError: true. |
error | { code, message, data? } or undefined | The JSON-RPC error. undefined for tool errors, which arrive as results. |
raw | CallToolResult | The result as received: content, structuredContent, isError and _meta. |
durationMs | number | How long the call took. |
json() | unknown | Parses 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 | undefined | The first text block's text. |
hasTextContent(), hasImageContent(), hasResourceContent() | boolean | Whether any content block has that type. |
hasToolUI() | boolean | Whether _meta carries a rendered tool UI. |
ResourceContentWrapper
What mcp.resources.read() returns.
| Member | Type | Description |
|---|---|---|
isSuccess, isError | boolean | Whether the read worked. |
error | { code, message, data? } or undefined | The JSON-RPC error, when the read failed. |
raw | ReadResourceResult | The result as received, with its contents. |
durationMs | number | How long the read took. |
json() | unknown | Parses the first content block's text as JSON. |
text() | string | undefined | The first content block's text. |
mimeType() | string | undefined | The first content block's mimeType. It's a method. |
hasMimeType(type) | boolean | Whether the first content block has that MIME type. |
PromptResultWrapper
What mcp.prompts.get() returns.
| Member | Type | Description |
|---|---|---|
isSuccess, isError, error, raw, durationMs | As for resources. | |
messages | PromptMessage[] | The prompt's messages, each with a role and content. |
description | string | undefined | The 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,
isErroristrueanderrorisundefined, so a JSON-RPC code liketoBeError(-32602)can't match. Pass the FrontMCP code as a string instead:toBeError("INVALID_INPUT")checksresult.raw._meta?.code. json()parses the text FrontMCP sent. A tool that returns a plain value, like12, sends{"value":12}, sojson()returns{ value: 12 }.- The list helpers follow
nextCursor.tools.list(),resources.list(),resources.listTemplates()andprompts.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/testingreturned 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 withmcp.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 speaks2025-06-18, and the Playground2026-07-28. Reading an unknown resource fails with-32002in the first and-32602in the second. And an elicitation answer that doesn't match the schema reachesthis.elicit()unchecked in the first, while the second fails the call withINVALID_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.
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.
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.
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.
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):
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.
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:
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.
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 testimport { 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");
});| Option | Description |
|---|---|
-i, --runInBand | Run test files one at a time. Recommended for E2E tests, which each start a server. |
-w, --watch | Re-run tests when files change. |
-v, --verbose | Show every test's result. |
-t, --timeout <ms> | The timeout for each test. Default 60000. |
-c, --coverage | Collect coverage. |
--no-env | Don'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().