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,
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-test-servers-88d2a379b74e ,按照其中的说明把「test-servers」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
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.
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.
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.mjs
starts one in the smoke process so it can read back the headers the CLI sent.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 (InspectorClient over
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:cli and smoke:tui write it into a --catalog as
{ type: "stdio", command: node, args: [<built entry>] } — which is still
this shape, and still the build.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:
scripts/smoke-web-elicitation.mjs spawns it directly;
scripts/lib/mcp-app-flow.mjs (startMcpAppServer) does it for
smoke:web:app, smoke:web:tabs and pack:verify. These are automated and
config-driven, and the whole of this skill applies to them. ⚠️ Not every
smoke is here — smoke:cli and smoke:tui use the two shapes above and
pick no config at all.Run one by hand
below.⚠️ 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.
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:
createTestServerHttp /
createTestServerStdio build the server; the create*Tool,
create*Resource and create*Prompt fixtures in
test-servers/src/test-server-fixtures.ts populate it;
createTestServerInfo fills in serverInfo. Prefer an existing fixture
factory to hand-writing a ToolDefinition — that is what makes the fixture a
shared one.start() then stop(), and stop() in an afterEach. The server binds a
real port, so a test that throws before stopping leaks it into the rest of the
file.started.url. createTestServerHttp resolves through
findAvailablePort(), which walks upward when the port is taken, so an
assumed port is the same bug the two-process path has.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.
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:
start() /
stop() pair — disconnecting the client is what reaps the child, so the
afterEach that matters is client.disconnect().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.
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.
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:
console.error in
server-composable.ts). Watching stdout alone looks like a server that never
started.createTestServerHttp
resolves through findAvailablePort(), which walks upward when the configured
port is taken — so read the announced URL rather than assuming.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.
| 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) |
test-servers/src/preset-registry.ts; configs in
test-servers/configs/*.json.docs/test-servers.md saying what to do
and what the broken build did — the "what it looked like broken" half is what
makes the fixture reproducible later.outputSchema override 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".