- 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`](../../../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.mjs`
starts 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 (`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.
- **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.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.
- **You, in a terminal**, with the Inspector in another — `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.
### Automated, in-process HTTP: build the server from the API
```ts
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`](../../../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.)
Five mechanics of this path:
- **The factories come from one barrel.** `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.
- **Read `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.
- **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.
- **Anything a showcase config turns on is available in-process too.** The
constructor takes the `ServerConfig` a JSON config *resolves* to — concrete
definitions rather than the file's preset references, `serverType` rather
than its `transport` — so pass the resolved shape, not the raw JSON fields.
`maxPageSize: { tools: 4 }` with `createNumberedTools(12)` makes the tool list
paginate (`inspectorClient.test.ts`, "should paginate tools when maxPageSize
is set"). To reuse a showcase config wholesale, spread
`resolveConfig(loadConfig(path))` — which does that translation — into
`createTestServerHttp` with `port: undefined` so the harness picks the port —
[`empty-cursor.test.ts`](../../../clients/web/src/test/integration/mcp/empty-cursor.test.ts)
does exactly that with `empty-cursor-http.json`.
⚠️ **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
```ts
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 the
`afterEach` that matters is `client.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:
```sh
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:
```sh
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.
```sh
# 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
```
```sh
# 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.error` in
`server-composable.ts`). Watching stdout alone looks like a server that never
started.
- **The bound port is not necessarily the config's.** `createTestServerHttp`
resolves through `findAvailablePort()`, 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`](../../../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 in
`test-servers/configs/*.json`.
- A new showcase config gets a row in `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.
- ⚠️ **An `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".
View on GitHub