testing · modelcontextprotocol — скилл для Claude Code: тесты MCP-серверов
Скилл объясняет, как писать и запускать тесты для серверов MCP: где лежит файл, какой раннер и как пройти порог покрытия. Пригодится, когда тест молча пропускается или проходит на устаревшей сборке.
npx skills add modelcontextprotocol/servers --skill testing -a claude-codeЧто делает testing
Скилл описывает тестовую обвязку каждого сервера в репозитории modelcontextprotocol/servers: где лежит файл теста, какой раннер его подхватывает и какую команду запускать для одного набора или отчёта о покрытии. Правила вынесены в AGENTS.md — новый код идёт с тестами, каждый файл TypeScript обязан пройти порог покрытия, а ожидаемый вывод об ошибке не должен попадать в консоль.
Тесты гоняются в одном процессе с сервером. Четыре сервера на TypeScript связывают настоящий Client с сервером через InMemoryTransport, три на Python — ClientSession с реальным serve() через потоки в памяти. Для каждого сервера есть свой харнесс в __tests__/, и подключаться нужно через него: он делает настройку и очистку сессии, которые ручное соединение пропускает. У everything это ещё и идентификатор сессии, иначе состояние утечёт в следующий тест.
Выбор харнесса зависит от того, что тест должен доказать. Логику — проверку пути, операцию над графом, перевод часового пояса — проверяют прямым вызовом функции. То, что видит клиент, гоняют через клиент: прямой вызов обработчика обходит валидацию входа, схемы вывода и маппинг ошибок, а именно там и ловились баги уровня протокола. Файл теста вне __tests__/ не запустится вообще, и vitest о нём ничего не сообщит — это повод проверить путь, если тест вдруг не находится.
Какие задачи решает
- написать протокольный тест для сервера на TypeScript или Python
- выбрать между вызовом функции напрямую и прогоном через MCP-клиент
- подобрать команду для одного набора тестов или отчёта о покрытии
- разобраться, почему тест пропускается или проходит на устаревшей сборке
- пройти порог покрытия по каждому файлу в CI
Примеры запросов агенту
- Напиши протокольный тест для инструмента echo в сервере everything
- Почему мой тест в src/filesystem/tests/ не запускается?
- Какой командой прогнать покрытие по одному файлу?
Как установить testing
1Через Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill testing -a claude-code2Вручную: положить файлы
~/.claude/skills/testing/SKILL.mdПапка скилла целиком; для одного проекта — .claude/skills/testing/
1Через Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill testing -a codex2Вручную: положить файлы
.agents/skills/testing/SKILL.mdДля всех проектов — ~/.agents/skills/
1Через Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill testing -a cursor2Вручную: положить файлы
.cursor/skills/testing/SKILL.mdCursor читает и .agents/skills/, и .claude/skills/
1GitHub CLI
gh skill install modelcontextprotocol/servers testing2Вручную: положить файлы
.github/skills/testing/SKILL.md1Через Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill testing -a gemini-cli2Командой платформы
gemini skills install https://github.com/modelcontextprotocol/servers --path .claude/skills/testingВручную: положить файлы
.devin/skills/testing/SKILL.mdСтарый путь .windsurf/skills/ тоже читается
Через Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill testing -a opencodeClaude.ai
Упакуйте папку в zip и загрузите в Settings → Capabilities → Skills
По шагам, с командами для каждой платформы: Как установить скилл в Claude Code: пять способов
Из того же набора7
Другие версии testing
Похожие
Рядом в теме «Тесты и QA»
Всё по теме «Тесты и QA» →Вопросы о testing
Как установить testing в Claude?
Выполните npx skills add modelcontextprotocol/servers --skill testing -a claude-code. Файл скилла окажется по пути ~/.claude/skills/testing/SKILL.md.
Что делает скилл testing?
Показывает, где размещать тесты для серверов MCP, какой раннер их подхватывает и какую команду запускать для набора тестов или отчёта о покрытии. Отдельно разбирает выбор между прямым вызовом функции и прогоном через клиент.
Чем это отличается от ручной проверки сервера?
Ручной прогон через Inspector или LLM-клиент — это /client-smoke, доказательство для PR, а не замена тесту. Скилл testing описывает именно тесты.
Исходный текст
SKILL.md · Apache-2.0
---
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, 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/client";
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).
- **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).
- **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
reason: `/* v8 ignore next -- <reason> */`, or `/* v8 ignore start -- <reason> */`
… `/* v8 ignore stop */` around a block. "Hard to test" is not a reason.
- Never lower a threshold, drop `perFile`, or add a file to the coverage
`exclude` to get green.
**The Python servers' coverage is a gate.** `npm run coverage:py -- <server>`
runs, in `src/<server>`:
```sh
uv run --frozen pytest --cov --cov-report=term-missing --cov-report=json
```
which prints coverage.py's report (the missing lines and partial branches)
and writes `coverage.json` (ignored by git). `scripts/lib/py-coverage.mjs` then
checks **every file** in it: at least 90% of its lines and 90% of its branches,
each file on its own. What is measured is the server's `[tool.coverage.run]`
(`branch = true`, `source` set to its package). `local:gate` and CI's
**Coverage \<server\>** job both run it. It is kept out of the plain
`uv run pytest` loop, which stays fast and uninstrumented.
A line or branch that genuinely cannot run is marked
`# pragma: no cover # <reason>`, with the reason on the same line; never
lower the gate. Diagnosing a red run is `/pre-push-gate`.
⚠️ **A spawned server is invisible to the report.** v8 coverage measures the
vitest process, not a child it starts, so code reached only through a spawn
smoke reads as uncovered. That is why behavior is tested in-process: a file
whose only test spawns it cannot clear the gate.
## Error output
A test that expects an error keeps it off the console. Spy on the logger for
the duration of the test (`vi.spyOn(console, "error").mockImplementation(() =>
{})`, restored afterwards), and pipe a spawned server's stderr rather than
inheriting it.
© modelcontextprotocol · Apache-2.0 · modelcontextprotocol/servers