Skip to main content

testing

Write, place and run tests for a server in this repo. Use when choosing between driving the server through an MCP client and calling its functions directly; when writing a protocol-level test for a TypeScript or Python server; when choosing the command that runs one suite or a coverage report; or when a test is skipped or passes against a stale build.

Source facts

Repository
modelcontextprotocol/servers
Last source activity
October 4, 2026 at 23:23
Detected SKILL.md language
English
Stars
91,295
Forks
11,788

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
testing
description
Write, place and run tests for a server in this repo. Use when choosing between driving the server through an MCP client and calling its functions directly; when writing a protocol-level test for a TypeScript or Python server; when choosing the command that runs one suite or a coverage report; or when a test is skipped or passes against a stale build.
disable-model-invocation
false
# Testing The **rules** are in [`AGENTS.md`](../../../AGENTS.md) under **Always test new or modified code**: new behavior comes with tests, every TypeScript file clears the per-file coverage gate, test at the protocol level where you can, and keep expected error output off the console. This skill explains the harness each server has, how to write a test with it, which command runs what, and how to clear the per-file coverage gate. Driving a running server by hand, with the Inspector or an LLM client, is `/client-smoke`. It is evidence for a PR, not a substitute for a test. **Every server is tested in-process, and every file is gated.** The four TypeScript servers link a real `Client` to the server over an in-memory transport (#4854); the three Python servers link a `ClientSession` to the real `serve()` over in-memory streams (#4855). Both languages hold each file to a per-file coverage floor, in CI and in `npm run local:gate`. ## Where a test goes | Server | Directory | File name | Runner | | --- | --- | --- | --- | | `everything`, `filesystem`, `memory`, `sequentialthinking` | `src/<server>/__tests__/` | `<subject>.test.ts` | vitest, `globals: true` | | `fetch`, `git`, `time` | `src/<server>/tests/` | `test_<subject>.py` | pytest, with `pytest-asyncio` (`asyncio_mode = "auto"`) | | Root tooling | `scripts/`, beside the script | `<name>.test.mjs` | `node --test` | Each vitest config includes only `**/__tests__/**/*.test.ts`, so a test file anywhere else in a workspace is never run and reports nothing. Shared test helpers go in `__tests__/` without the `.test` suffix, as each server's connection harness does (`__tests__/harness.ts` in `everything`, `__tests__/helpers.ts` in the other three). ## What each server's tests do | Server | How its tests reach the code | | --- | --- | | `everything`, `filesystem`, `memory`, `sequentialthinking` | An SDK `Client` connected to the server's `createServer()` over `InMemoryTransport`, in the vitest process, through the server's own harness, plus unit tests of helper modules such as `lib.ts`. `everything` drives its stdio, SSE and Streamable HTTP transports in-process too; the other three have one thin spawn smoke of the built `dist/index.js` | | `fetch`, `git`, `time` | A `ClientSession` linked in-process to the real `serve()` over in-memory streams (below), plus direct calls on the pure functions in `server.py` | No TypeScript test uses an in-memory transport yet. ## Choosing a harness for a new test Ask what the test must prove. - **Logic** (a path check, a graph operation, a timezone conversion): call the function directly. That is what `lib.ts` and the functions in `server.py` are for. - **What a client sees** (a tool's result shape, its advertised schema, an error result, a capability, a notification): drive the server through a client. A handler called directly skips the SDK's input validation, output schema validation and error mapping, which is where the wire-level bugs in this repo have been. Which client harness is available depends on the language. ### TypeScript: in-process, over an in-memory transport Each TypeScript server exports a `createServer(…)` factory and starts its transport only from a guarded `main()`, so importing it starts nothing and a test can link a real `Client` to a real server with no process and no build. **Each server's `__tests__/` has a harness that does the linking. Connect through it**, never by calling `createServer()` and `InMemoryTransport.createLinkedPair()` by hand: the harness also does the per-session setup and teardown the server needs. | Server | Factory | Harness | `connect(…)` takes | | --- | --- | --- | --- | | `everything` | `createServer()` in `server/index.ts`, returns `{ server, cleanup }` | `__tests__/harness.ts` | `{ capabilities, taskStore, setup, sessionId }`, all optional | | `filesystem` | `createServer(allowedDirectories)` in `server.ts` | `__tests__/helpers.ts` | the allowed directories, then `{ capabilities, listRoots }` | | `memory` | `createServer(memoryFilePath)` in `index.ts` | `__tests__/helpers.ts` | the graph file's path (`makeTempGraph()` makes one) | | `sequentialthinking` | `createServer()` in `index.ts` | `__tests__/helpers.ts` | `{ disableThoughtLogging }`, defaulting to `"true"` | Each `connect` returns the `client` and a `close()`; call `close()` in `afterEach`. The harnesses also export small result helpers (`call`, `textOf` and the like); read the one you are using before adding another. ```ts import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { connect, contentOf, textOf, type Session } from "./harness.js"; describe("echo, over the protocol", () => { let session: Session; let client: Client; beforeEach(async () => { session = await connect(); client = session.client; }); afterEach(async () => { await session.close(); }); it("returns the message", async () => { const result = await client.callTool({ name: "echo", arguments: { message: "hi" }, }); expect(textOf(contentOf(result)[0])).toBe("Echo: hi"); }); it("reports bad input as a tool error", async () => { const result = await client.callTool({ name: "echo", arguments: {} }); expect(result.isError).toBe(true); }); }); ``` What `everything`'s harness does that a hand-rolled connection would miss: - **A session id per connection.** Logging, subscriptions, roots and the toggle tools keep per-session state in module-level maps keyed by the transport's session id, and `InMemoryTransport` has none, so every hand-rolled session would share the `undefined` key and leak state into the next test. `connect()` sets a fresh `sessionId` on the server transport (`sessionId: null` keeps it undefined, as stdio does), and `close()` runs the server's `cleanup(sessionId)`, which also stops the timers `createServer()` starts (the roots sync, the task store). - **Capabilities.** A tool `everything` registers in `registerConditionalTools` appears only when the client declares the matching capability: pass `capabilities` (`ALL_CAPABILITIES` declares every one), with `setup` to install the client's handlers for the requests the server sends back (sampling, elicitation, roots) and `taskStore` for the task-augmented ones. - **Notifications.** `session.notifications` collects every notification no specific handler consumed; `ofMethod(notifications, method)` filters it. Assert on what the client receives (tool, resource and prompt lists, call results, errors, notifications), never on SDK internals or a handler's context object: the SDK v2 migration (#4856) must be able to update these tests with import and type changes alone. ### TypeScript: the spawn smoke of the built binary What only the published entry point can show (that `dist/index.js` starts as a bin and serves over stdio; in `memory` and `sequentialthinking`, also through a symlink, as npm's `.bin` entry runs it) is checked by one thin test per server that spawns it with `StdioClientTransport`: `filesystem`'s `bin-smoke.test.ts`, and `stdio-smoke.test.ts` in `memory` and `sequentialthinking`. `everything` has none; its transports are driven in-process. Everything else is tested in-process. Two hazards apply to these tests, and only to them: - ⚠️ **They run the last build, not your edit.** `npm test` does not build. Build first, or run `npm run validate -w src/<server>`, which builds before it tests. - ⚠️ **A missing `dist/` is handled two ways.** `filesystem` and `sequentialthinking` guard the smoke with `skipIf(!existsSync(distIndexPath))`, so with no build it is reported as **skipped** and the run stays green having tested nothing over stdio. `memory`'s has no guard and **fails**. A skipped test in this repo usually means "not built". Close the client in `finally` or `afterEach`: that is what stops the child process. Pipe its stderr (`stderr: "pipe"`), and pass environment variables in the transport's `env` option (`MEMORY_FILE_PATH`, say), since the child inherits only the SDK's default safelist (`PATH`, `HOME`, `USER` and the like). ### Python: `ClientSession` over in-memory streams, in-process Each Python server builds its `Server` inside `serve()` and serves it over the `stdio_server()` it imports into `server.py`. A test replaces that one name with a context manager that yields the server side of a pair of in-memory streams, runs the real `serve()` in a task group, and opens a `ClientSession` on the client side. No process, no build, and the SDK's input validation and error mapping run exactly as they do for a real client: ```python from contextlib import asynccontextmanager from typing import Any, AsyncIterator import anyio import pytest from mcp import ClientSession from mcp.shared.memory import create_client_server_memory_streams import mcp_server_time.server as server_module @asynccontextmanager async def connect(**serve_kwargs: Any) -> AsyncIterator[ClientSession]: async with create_client_server_memory_streams() as (client, server): @asynccontextmanager async def fake_stdio_server() -> AsyncIterator[Any]: yield server with pytest.MonkeyPatch.context() as mp: mp.setattr(server_module, "stdio_server", fake_stdio_server) async with anyio.create_task_group() as tg: tg.start_soon(lambda: server_module.serve(**serve_kwargs)) async with ClientSession(*client) as session: await session.initialize() yield session tg.cancel_scope.cancel() async def test_get_current_time() -> None: async with connect() as session: result = await session.call_tool("get_current_time", {"timezone": "UTC"}) assert result.isError is False ``` - **Patch the name `server.py` looks up** (`mcp_server_<name>.server.stdio_server`), not `mcp.server.stdio.stdio_server`: `server.py` imported the function, so the module attribute is what `serve()` calls. - **Cancel the task group after the session closes.** `serve()` runs until its streams end, so without the cancel the test never returns. - **Assert on the wire shape where you can**, for example `result.model_dump(by_alias=True, mode="json")`, rather than on Python attribute names: the SDK v2 port (#4851) renames them, and a wire-level assertion survives it. - **No network in a test.** `fetch` replaces `httpx.AsyncClient` with one on an `httpx.MockTransport`; `git` works against a temporary repository its fixture creates and closes before removing. `pytest-asyncio` with `asyncio_mode = "auto"` is a dev dependency of all three, so an `async def test_…` runs as it stands. A subprocess test over stdio (`StdioServerParameters(command=sys.executable, args=["-m", "mcp_server_<name>"])` with `mcp.client.stdio.stdio_client`) is kept to a thin smoke of the entry point. coverage.py does not measure a child process, so code reached only that way reads as uncovered. ## Tests that pin a known bug The suites are characterization tests: each asserts what the server does today, so a migration that changes behavior fails a test. Most of what they pin is intended. Some of it is a bug, pinned as it is so that the fix has to change the assertion visibly. Every test of that kind carries one marker, directly above the test (or above its `describe`/class when the whole block pins the same bug): ```ts // KNOWN BUG #<N>: pins current (wrong) behavior; the fix changes this assertion. ``` In Python the same text follows `#`. A bug with no issue yet reads `KNOWN BUG (no issue): <what is wrong>; …`, which also marks it for filing. - `git grep "KNOWN BUG"` lists every test that pins a bug. A test without the marker pins intended behavior, or guards a fixed bug against regression. - **Fixing the bug** means changing that test's assertion to the correct behavior and removing the marker, in the fix PR. - **Pinning a newly found bug**: write the test against what the code does, add the marker, and file the issue (`/issue-create`), then cite it. - A pin of current design that a feature request wants changed is not a bug and gets no marker. ## Running them | Scope | Command | | --- | --- | | One TypeScript server | `npm test -w src/<server>` | | One file, or one test | `npm test -w src/<server> -- __tests__/<file>.test.ts -t "<name>"` | | Every TypeScript server | `npm test` at the root | | One TypeScript server's coverage gate | `npm run coverage -w src/<server>` (see below) | | One TypeScript server's whole chain | `npm run validate -w src/<server>` (format check, lint, typecheck, build, test) | | One Python server | `uv run pytest` in `src/<server>` | | One Python test | `uv run pytest <path to the test file>::<name>` in `src/<server>` (for example `tests/test_server.py::test_git_checkout_existing_branch`) | | One Python server's whole chain | `npm run validate:py -- <server>` | | One Python server's coverage gate | `npm run coverage:py -- <server>` at the root | | Every Python server's coverage gate | `npm run coverage:py` at the root | | Root tooling | `npm run test:scripts` | `npm run typecheck -w src/<server>` is the only step that typechecks the test files in `filesystem`, `memory` and `sequentialthinking`: their build `tsconfig.json` excludes tests, and vitest does not typecheck. ## `test` versus `coverage` `test` is `vitest run`, the fast loop, and part of `validate`. `coverage` is `vitest run --coverage`: the same suite, instrumented, with a v8 report over the workspace's `**/*.ts` (tests and `dist/` excluded) written to the terminal and to the ignored `coverage/` directory (`coverage/index.html` has the line-by-line view). ```sh npm run coverage -w src/<server> # one server npm run coverage # all four ``` **`coverage` is a gate.** Each server's `vitest.config.ts` sets `coverage.thresholds` to 90 on lines, statements, functions and branches with `perFile: true`, so the command fails when any one file is below 90 on any one of them, even if the totals are above. CI runs it as its own job (`typescript.yml` → **Coverage \<server\>**) and `npm run local:gate` as its own stage. `test` and `validate` never measure coverage, so a change can pass them and still fail here: run `coverage` on the server you changed before you push. Clearing a red file: - The `Uncovered Line #s` column is the to-do list. A branch shortfall with no uncovered line is the untaken side of a condition, `??`, `?.` or default parameter: write the test that takes it. - Only code that **cannot** run gets an ignore, at the source and with its
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub