test-servers
GitHub指导手动运行可组合 MCP 测试服务器,用于验证功能、复现 Bug 或执行冒烟测试。支持选择预设配置和协议时代,涵盖进程内 HTTP、Spawned stdio 及 Spawned composable HTTP 三种启动方式,确保使用真实传输而非模拟数据。
Trigger Scenarios
Install
npx skills add modelcontextprotocol/inspector --skill test-servers -g -y
SKILL.md
Frontmatter
{
"name": "test-servers",
"description": "Run a composable MCP test server by hand — pick the showcase config for a feature or bug, build it, and connect with the right protocol era. Use when a change, a PR or a smoke test needs a real server to exercise it; when reproducing a reported bug by hand; when choosing which fixture or protocol era to run; when a fixture keeps serving stale code after an edit; or when the config or preset you need does not exist yet.",
"disable-model-invocation": false
}
Running a test server
test-servers/ provides composable MCP servers so tests and manual checks
exercise a real server over a real transport instead of mocks. A server is
assembled from presets (fixture factories in
test-servers/src/preset-registry.ts) and configured declaratively with a JSON
file under test-servers/configs/.
The full catalogue of showcase configs — one per feature, each with what to click
and what the broken build did — is
docs/test-servers.md. This skill is how to
run one.
Three ways to use a fixture — pick the right one first
A fixture is stood up in one of three shapes, and most of what follows is about the third. Establish which one you are in before reading further, because the showcase config file and the protocol-era table belong to that one alone.
⚠️ The cut is how the server is stood up, not who is driving. Automated and by-hand is the wrong axis: the composable-config shape has both kinds of consumer, and a smoke that spawns it needs every bit of the config and era guidance a person at two terminals does.
| Shape | Server runs | Config file | Consumers |
|---|---|---|---|
| In-process HTTP | inside the test process, built from the API | none — options are constructor args | integration tests, CLI tests, smoke:cli |
| Spawned stdio | a child process the transport (or the binary under test) starts | none — the stdio fixture runs its default config | integration tests, the CLI suites, smoke:cli, smoke:tui |
| Spawned composable HTTP | a child process started with --config |
yes — a showcase --config <name>.json |
the web smokes, pack:verify, and a person by hand |
⚠️ "A smoke" is not a shape — smoke:cli uses the first two and smoke:tui
the second, while only the config-driven web smokes use the third. Pick by the
row, never by the caller's category.
- In-process HTTP —
createTestServerHttp. The caller constructs the server and owns its lifecycle. No subprocess, no JSON config, no showcase config to pick. This is the shape for anything needing HTTP/SSE, a specific tool set, or the modern handler — and it is not test-only:scripts/smoke-cli.mjsstarts one in the smoke process so it can read back the headers the CLI sent. - Spawned stdio —
getTestMcpServerCommand(). The test hands the built fixture's{ command, args }to a stdio transport (or to the built CLI), and the transport spawns it. A subprocess is started, but still no config file: that entry point runs the stdio server's default config, so there is nothing to pick. Reach for it when stdio is the point (InspectorClientover stdio, the CLI's out-of-process E2E suite) and the default tool set is enough. A caller may also just name the built entry rather than connect to it —smoke:cliandsmoke:tuiwrite it into a--catalogas{ type: "stdio", command: node, args: [<built entry>] }— which is still this shape, and still the build. - Spawned composable HTTP —
server-composable.js --config <name>.json. Picking the showcase config and the protocol era applies to this shape, whoever starts it. Two consumers, and they differ only in who runs the second process:- A script.
scripts/smoke-web-elicitation.mjsspawns it directly;scripts/lib/mcp-app-flow.mjs(startMcpAppServer) does it forsmoke:web:app,smoke:web:tabsandpack:verify. These are automated and config-driven, and the whole of this skill applies to them. ⚠️ Not every smoke is here —smoke:cliandsmoke:tuiuse the two shapes above and pick no config at all. - You, in a terminal, with the Inspector in another —
Run one by handbelow.
- A script.
⚠️ The build applies to all three. Every shape resolves
test-servers/build/ — the two API-driven ones through the
@modelcontextprotocol/inspector-test-server alias, the composable one by
running the emitted .js directly — so Build first and its stale-build hazard
are not guidance for one path. Read that section whichever shape you are in.
Automated, in-process HTTP: build the server from the API
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createEchoTool,
} from "@modelcontextprotocol/inspector-test-server";
let server: TestServerHttp | null = null;
afterEach(async () => {
// Stop it even when the assertion threw, or the port leaks into the next test.
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
it("…", async () => {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("excluded-tools-test", "1.0.0"),
tools: [createEchoTool()],
// `modern: {}` opts the fixture into the 2026-07-28 handler; omit it for legacy.
});
await started.start();
server = started;
// `started.url` is the bound URL — read it, never reconstruct it from a port.
// …connect an InspectorClient to it and assert.
});
The reference test is
clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts
— read it before writing a new one; it is the shape fixture-backed integration
tests follow when the server is built in-process. (Stdio-backed ones follow the
next subsection instead.)
Four mechanics of this path:
- The factories come from one barrel.
createTestServerHttp/createTestServerStdiobuild the server; thecreate*Tool,create*Resourceandcreate*Promptfixtures intest-servers/src/test-server-fixtures.tspopulate it;createTestServerInfofills inserverInfo. Prefer an existing fixture factory to hand-writing aToolDefinition— that is what makes the fixture a shared one. start()thenstop(), andstop()in anafterEach. The server binds a real port, so a test that throws before stopping leaks it into the rest of the file.- Read
started.url.createTestServerHttpresolves throughfindAvailablePort(), which walks upward when the port is taken, so an assumed port is the same bug the two-process path has. - Era is a constructor option, not a config file.
modern: {}on the config object selects the modern handler; the client side picks its own negotiation (eraToVersionNegotiation). The showcase-config era table below does not apply.
⚠️ The barrel is an alias to the BUILD, not to the source —
vitest.shared.mts maps @modelcontextprotocol/inspector-test-server to
test-servers/build/index.js. So Build first applies to this path in full,
stale-build hazard included: an edit to test-servers/src that is not rebuilt
is invisible to an in-process test exactly as it is to a spawned one.
Automated, spawned stdio: hand over the command
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
const { command, args } = getTestMcpServerCommand();
const client = new InspectorClient(
{ type: "stdio", command, args },
{ environment: { transport: createTransportNode } },
);
await client.connect();
// … afterEach → client.disconnect(), which is what stops the child.
getTestMcpServerCommand() returns node <test-servers/build/test-server-stdio.js>.
Three consequences:
- You do not own the process, the transport does. There is no
start()/stop()pair — disconnecting the client is what reaps the child, so theafterEachthat matters isclient.disconnect(). - No config is selected and none can be. That entry point starts the stdio server on its default config, so the showcase-config table and the protocol-era guidance below do not apply. If the case needs a specific tool set or the modern handler, it is an in-process HTTP test, not this.
- It is still the build. The path comes from the module's own resolved
location under the alias, so it is
test-servers/build/, with the same staleness hazard.
The same command feeds the CLI's out-of-process E2E suite
(clients/cli/__tests__/e2e.test.ts), which spawns the built CLI and lets it
spawn the fixture. Reference tests for this shape:
clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts
and clients/cli/__tests__/methods.test.ts.
Build first
Every shape above resolves generated output — the in-process one imports the
barrel, which is aliased to test-servers/build/index.js, and the stdio and
composable-config ones run emitted .js as real subprocesses. So the build must
exist whichever one you are in:
cd clients/web && npm run test-servers:build # tsc -p test-servers → test-servers/build/
Scripts reach this through scripts/ensure-test-servers.mjs, which builds
unconditionally (once per process per repo root).
⚠️ Unconditional emit is not a clean. A deleted source file leaves its
stale .js behind, existence checks pass against it, and anything still
importing that module silently runs the old code — reported not as staleness but
as a product failure in whatever was being tested. So after deleting or renaming
a source file:
rm -rf test-servers/build
The .tsbuildinfo is pinned inside build/ so that clean actually invalidates
the cache.
Run one by hand (two processes)
This is the spawned composable HTTP shape from the section above, driven by you rather than by a smoke script — the config and era guidance is the same either way. Two processes: the test server, then the Inspector.
# 1. The server, from the repo root, with the config you picked:
node test-servers/build/server-composable.js --config test-servers/configs/<name>.json
# 2. The Inspector, in another terminal (needs a built launcher — `npm run build`):
node clients/launcher/build/index.js --web
Then add the server in the Inspector using the URL the first process announced.
Two mechanics that bite:
- The server announces its URL on stderr, not stdout (
console.errorinserver-composable.ts). Watching stdout alone looks like a server that never started. - The bound port is not necessarily the config's.
createTestServerHttpresolves throughfindAvailablePort(), which walks upward when the configured port is taken — so read the announced URL rather than assuming.
Pick the right protocol era
Each config in docs/test-servers.md says
which era to connect with. The default is legacy; configs setting
transport.modern need Protocol Era = Modern. Connecting with the wrong era
usually looks like a missing capability rather than an error.
Common starting points
| Want to see | Config |
|---|---|
| An MCP App in the Apps tab | mcp-app-http.json (legacy) |
| An App-rendered elicitation | app-elicitation-http.json (legacy) |
Mcp-* headers + the modern error taxonomy |
modern-network-http.json |
A tool result's structuredContent section |
structured-output-http.json (legacy) |
| RFC 6570 resource-template expansion | rfc6570-templates-http.json |
| OAuth token revocation on clear | oauth-revocation-http.json (legacy) |
| A token endpoint the SDK refuses | oauth-insecure-token-endpoint-http.json (legacy) |
| Cancelling a call mid-flight | cancellation-modern-http.json (modern) |
Adding a config or preset
- Presets live in
test-servers/src/preset-registry.ts; configs intest-servers/configs/*.json. - A new showcase config gets a row in
docs/test-servers.mdsaying what to do and what the broken build did — the "what it looked like broken" half is what makes the fixture reproducible later. - ⚠️ An
outputSchemaoverride must ride a tool that returns structured content. A conforming client validates the result against the advertised schema, so an override on a preset returning none makes every call fail with "declares an output schema but returned no structured content".
Version History
- 2e90a62 Current 2026-09-23 06:47


