How to Test an MCP Server in CI
Test your MCP server the way an AI app uses it: call its tools, check the results, and pin the tool names and input schemas. In Python, Client(server) from the official mcp SDK runs the server in the same process, so pytest starts no subprocess. In TypeScript, the test starts the server over stdio and calls it with node:test. A GitHub Actions workflow then runs the tests and MCP Inspector's CLI in both protocol eras, across a matrix of Python and Node versions.
What you'll have at the end
A test suite for the bookmarks server from Build an MCP server. It runs 16 Python tests and 18 TypeScript tests, checks the tool contract in both languages, and calls the server through MCP Inspector. Running bash test.sh ends like this:
16 passed in 0.20s
...
ℹ tests 18
ℹ pass 18
ℹ fail 0
...
ok: python tools/list (modern)
ok: typescript tools/list (modern)
ok: python tools/list (legacy)
ok: typescript tools/list (legacy)
...
ok: save and search through the Inspector
All checks passed
Each test type reaches the server a different way. The Python tests skip the process boundary, the TypeScript tests and the Inspector start the server as a child process.
Before you start
- Python: 3.10 or later, with uv. The test project pins
mcp[cli]==2.3.0andpytest==9.1.1. - Node.js: 22.19 or later. MCP Inspector 2.11.0 requires it. This guide ran on Node.js 24.19.0.
- A server to test. This guide tests the bookmarks server from Build an MCP server. The example folder points the tests at that server's code, so you can run everything without copying it.
1. Set up the test project
Keep the tests in their own folder, separate from the server's code. This is the layout the example uses:
test-mcp-server-in-ci/
├── contract/
│ └── tools.contract.json
├── python/
│ ├── pyproject.toml
│ └── tests/
│ ├── conftest.py
│ ├── test_bookmarks.py
│ └── test_contract.py
├── typescript/
│ ├── package.json
│ ├── tsconfig.json
│ └── test/
│ ├── support.ts
│ ├── bookmarks.test.ts
│ └── contract.test.ts
├── github-workflow-example.yml
└── test.sh
- Python
- TypeScript
uv add "mcp[cli]==2.3.0"
uv add --dev "pytest==9.1.1"
This writes both pins to pyproject.toml. The pytest section tells pytest where the tests live:
[project]
name = "bookmarks-tests"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]==2.3.0",
]
[dependency-groups]
dev = [
"pytest==9.1.1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
npm install @modelcontextprotocol/client@^2.3.1
npm install --save-dev typescript@^7.0.2 @types/node@^22.20.5
@modelcontextprotocol/client is the SDK's client package. The tests don't need the server package, because the server they test is already written. The test script compiles the tests, then runs them with Node's built-in runner:
{
"name": "test-mcp-server-in-ci",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"test": "tsc && node --test dist/bookmarks.test.js dist/contract.test.js"
},
"type": "module",
"dependencies": {
"@modelcontextprotocol/client": "^2.3.1"
},
"devDependencies": {
"@types/node": "^22.20.5",
"typescript": "^7.0.2"
},
"private": true
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"outDir": "./dist",
"rootDir": "./test",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["test/**/*"]
}
2. Test the Python server in process
The Python SDK's Client can connect straight to an MCPServer object in the same process. The test imports the server module, then passes its mcp object to Client. No process starts, and no port opens.
conftest.py holds the setup that every test shares. It loads the server by file path, gives each test its own bookmarks file, and runs each test once per protocol era:
import importlib.util
import os
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
import pytest
from mcp import Client
# The server under test is the build-mcp-server example. Load it from its file
# path so this suite tests that code instead of a copy of it.
BOOKMARKS_PATH = Path(__file__).resolve().parents[3] / "build-mcp-server" / "python" / "bookmarks.py"
# Protocol eras and the Client mode that selects each one.
# "legacy" is the 2025-era initialize handshake; "2026-07-28" is the modern revision.
ERA_MODES = {"legacy": "legacy", "modern": "2026-07-28"}
def selected_eras() -> list[str]:
raw = os.environ.get("MCP_PROTOCOL_ERA")
if raw is None:
return list(ERA_MODES)
if raw not in ERA_MODES:
raise pytest.UsageError(f"MCP_PROTOCOL_ERA must be one of {sorted(ERA_MODES)}, got {raw!r}")
return [raw]
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "protocol_era" in metafunc.fixturenames:
metafunc.parametrize("protocol_era", selected_eras())
@pytest.fixture(scope="session")
def bookmarks_module() -> ModuleType:
spec = importlib.util.spec_from_file_location("bookmarks_example", BOOKMARKS_PATH)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load {BOOKMARKS_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def bookmarks_file(bookmarks_module: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
# Every test gets its own empty bookmarks file, so tests don't depend on order.
path = tmp_path / "bookmarks.json"
monkeypatch.setattr(bookmarks_module, "BOOKMARKS_FILE", path)
return path
@pytest.fixture
def connect(bookmarks_module: ModuleType, protocol_era: str, bookmarks_file: Path) -> Callable[[], Client]:
# Client(server) connects to an in-process MCPServer. No process, no socket.
def _connect() -> Client:
return Client(bookmarks_module.mcp, mode=ERA_MODES[protocol_era])
return _connect
The mode argument picks the protocol era:
mode | Protocol era | What the in-process client does |
|---|---|---|
"legacy" | 2025-era | Runs the initialize handshake over in-memory streams. |
"2026-07-28" | Modern | Skips the handshake and sends each request straight to the server. |
The SDK's Client source documents both modes, along with "auto", which tries modern first and falls back to initialize. The tests use the two that pin a single era, so a failure names the era that broke.
Each test then calls the server through Client. Assertions use the result's content and is_error fields:
import json
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
import pytest
from mcp import Client
def test_load_bookmarks_returns_empty_list_when_file_is_missing(
bookmarks_module: ModuleType, bookmarks_file: Path
) -> None:
assert not bookmarks_file.exists()
assert bookmarks_module.load_bookmarks() == []
def test_save_bookmarks_round_trips_through_the_file(bookmarks_module: ModuleType, bookmarks_file: Path) -> None:
bookmarks_module.save_bookmarks([{"url": "https://example.com", "title": "Example", "tags": []}])
assert json.loads(bookmarks_file.read_text()) == [{"url": "https://example.com", "title": "Example", "tags": []}]
@pytest.mark.anyio
async def test_lists_both_tools(protocol_era: str, connect: Callable[[], Client]) -> None:
async with connect() as client:
listing = await client.list_tools()
assert sorted(tool.name for tool in listing.tools) == ["save_bookmark", "search_bookmarks"]
@pytest.mark.anyio
async def test_save_then_search_finds_the_bookmark(
protocol_era: str, connect: Callable[[], Client], bookmarks_file: Path
) -> None:
async with connect() as client:
saved = await client.call_tool(
"save_bookmark",
{"url": "https://modelcontextprotocol.io", "title": "MCP docs", "tags": ["mcp"]},
)
found = await client.call_tool("search_bookmarks", {"query": "mcp docs"})
assert saved.is_error is False
assert saved.content[0].text == "Saved 'MCP docs'. Total bookmarks: 1"
assert found.content[0].text == "- MCP docs: https://modelcontextprotocol.io"
assert json.loads(bookmarks_file.read_text())[0]["tags"] == ["mcp"]
@pytest.mark.anyio
async def test_saving_the_same_url_twice_stores_one_copy(
protocol_era: str, connect: Callable[[], Client], bookmarks_file: Path
) -> None:
arguments = {"url": "https://example.com", "title": "Example"}
async with connect() as client:
await client.call_tool("save_bookmark", arguments)
second = await client.call_tool("save_bookmark", arguments)
assert second.content[0].text == "Already saved: https://example.com"
assert len(json.loads(bookmarks_file.read_text())) == 1
@pytest.mark.anyio
async def test_search_with_no_match_says_so(protocol_era: str, connect: Callable[[], Client]) -> None:
async with connect() as client:
result = await client.call_tool("search_bookmarks", {"query": "nothing"})
assert result.is_error is False
assert result.content[0].text == "No bookmarks match 'nothing'."
@pytest.mark.anyio
async def test_missing_required_argument_is_a_tool_error(protocol_era: str, connect: Callable[[], Client]) -> None:
async with connect() as client:
result = await client.call_tool("save_bookmark", {"url": "https://example.com"})
assert result.is_error is True
assert "title" in result.content[0].text
@pytest.mark.anyio
async def test_unknown_tool_is_a_tool_error(protocol_era: str, connect: Callable[[], Client]) -> None:
async with connect() as client:
result = await client.call_tool("delete_everything", {})
assert result.is_error is True
A bad argument doesn't raise an exception. The Python SDK returns it as a result with is_error set to True, and the test_missing_required_argument_is_a_tool_error test checks that. The two plain tests at the top of the file call load_bookmarks and save_bookmarks directly, with no client, so a failure there points at the file handling rather than the protocol.
The @pytest.mark.anyio marker runs the async tests. It comes from the anyio package, which the mcp SDK already installs, so no extra pytest plugin is needed.
3. Pin the tool contract
Behavior tests check what a tool returns. A contract test checks what clients see before any call: the tool names, which parameters are required, each parameter's type, and each description. A change to any of these can break a client even when every tool still works. A contract test catches a renamed parameter that a behavior test would miss.
The contract lives in one JSON file, so the Python and TypeScript suites check the same surface:
{
"tools": [
{
"name": "save_bookmark",
"required": ["url", "title"],
"properties": {
"url": { "types": ["string"] },
"title": { "types": ["string"] },
"tags": { "types": ["array"] }
}
},
{
"name": "search_bookmarks",
"required": ["query"],
"properties": {
"query": { "types": ["string"] }
}
}
]
}
The Python SDK writes an optional parameter as anyOf rather than a single top-level type, so the check reads the types from each option. This is the tags parameter in the bookmarks server:
{"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "Optional lowercase tags, e.g. [\"recipes\", \"python\"]", "title": "Tags"}
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from mcp import Client
# The contract is shared with the TypeScript suite, so both languages check the same tool surface.
CONTRACT_PATH = Path(__file__).resolve().parents[2] / "contract" / "tools.contract.json"
CONTRACT = json.loads(CONTRACT_PATH.read_text())
def declared_types(schema: dict[str, Any]) -> set[str]:
"""JSON types a schema allows, reading a top-level `type` or the options of `anyOf`."""
if "type" in schema:
declared = schema["type"]
return set(declared) if isinstance(declared, list) else {declared}
allowed: set[str] = set()
for option in schema.get("anyOf", []):
allowed |= declared_types(option)
return allowed
@pytest.mark.anyio
async def test_tool_list_matches_contract(protocol_era: str, connect: Callable[[], Client]) -> None:
async with connect() as client:
listing = await client.list_tools()
tools = {tool.name: tool for tool in listing.tools}
expected_names = {tool["name"] for tool in CONTRACT["tools"]}
assert set(tools) == expected_names, "the server's tool names differ from the contract"
for expected in CONTRACT["tools"]:
tool = tools[expected["name"]]
schema = tool.input_schema
assert tool.description, f"{expected['name']} needs a description for the model to read"
assert schema["type"] == "object", f"{expected['name']} input schema must be an object"
assert set(schema.get("required", [])) == set(expected["required"]), f"{expected['name']} required fields"
assert set(schema["properties"]) == set(expected["properties"]), f"{expected['name']} parameter names"
for param, spec in expected["properties"].items():
prop = schema["properties"][param]
assert declared_types(prop) >= set(spec["types"]), f"{expected['name']}.{param} type"
assert prop.get("description"), f"{expected['name']}.{param} needs a description"
4. Test the TypeScript server over stdio
The TypeScript test starts the built server as a child process and talks to it over stdio. StdioClientTransport launches the process and reads and writes its pipes. support.ts builds the client for each protocol era:
import { fileURLToPath } from "node:url";
import {
Client,
type VersionNegotiationMode,
} from "@modelcontextprotocol/client";
import {
StdioClientTransport,
getDefaultEnvironment,
} from "@modelcontextprotocol/client/stdio";
// Compiled tests run from typescript/dist, so these paths climb out of dist/.
// The server under test is the build-mcp-server example, run as a child process.
export const SERVER_PATH = fileURLToPath(
new URL("../../../build-mcp-server/typescript/build/index.js", import.meta.url),
);
export const CONTRACT_PATH = fileURLToPath(
new URL("../../contract/tools.contract.json", import.meta.url),
);
export type ProtocolEra = "legacy" | "modern";
const ERA_MODE: Record<ProtocolEra, VersionNegotiationMode> = {
legacy: "legacy",
modern: { pin: "2026-07-28" },
};
export function selectedEras(): ProtocolEra[] {
const raw = process.env.MCP_PROTOCOL_ERA;
if (raw === undefined) return ["legacy", "modern"];
if (raw !== "legacy" && raw !== "modern") {
throw new Error(`MCP_PROTOCOL_ERA must be "legacy" or "modern", got "${raw}"`);
}
return [raw];
}
export async function connect(era: ProtocolEra, bookmarksFile: string): Promise<Client> {
const client = new Client(
{ name: "bookmarks-tests", version: "1.0.0" },
{ versionNegotiation: { mode: ERA_MODE[era] } },
);
const transport = new StdioClientTransport({
command: process.execPath,
args: [SERVER_PATH],
env: { ...getDefaultEnvironment(), BOOKMARKS_FILE: bookmarksFile },
});
await client.connect(transport);
return client;
}
The bookmarks example starts its stdio transport as soon as its module loads, and it doesn't export its server object. The TypeScript SDK's in-memory transport needs that object, so this test can't use it against the example. The test runs the built server as a process instead. If you own the server code, you can export the function that builds the McpServer and not the call that starts stdio. The SDK's InMemoryTransport.createLinkedPair() then connects a client and a server in the same process. The example doesn't show that setup.
The tests in bookmarks.test.ts cover the same behavior as the Python suite. They run once per era, in a fresh temporary folder:
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { ProtocolError, type CallToolResult, type Client } from "@modelcontextprotocol/client";
import { connect, selectedEras } from "./support.js";
function textOf(result: CallToolResult): string {
const [block] = result.content;
if (block?.type !== "text") {
throw new Error(`expected a text block, got ${block?.type}`);
}
return block.text;
}
for (const era of selectedEras()) {
describe(`bookmarks server over the ${era} protocol era`, () => {
let dir: string;
let bookmarksFile: string;
let client: Client;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "bookmarks-test-"));
bookmarksFile = join(dir, "bookmarks.json");
client = await connect(era, bookmarksFile);
});
afterEach(async () => {
await client.close();
rmSync(dir, { recursive: true, force: true });
});
it("lists save_bookmark and search_bookmarks", async () => {
const { tools } = await client.listTools();
assert.deepEqual(tools.map((tool) => tool.name).sort(), ["save_bookmark", "search_bookmarks"]);
});
it("saves a bookmark that search then finds", async () => {
const saved = await client.callTool({
name: "save_bookmark",
arguments: { url: "https://modelcontextprotocol.io", title: "MCP docs", tags: ["mcp"] },
});
const found = await client.callTool({
name: "search_bookmarks",
arguments: { query: "mcp docs" },
});
assert.notEqual(saved.isError, true);
assert.equal(textOf(saved), "Saved 'MCP docs'. Total bookmarks: 1");
assert.equal(textOf(found), "- MCP docs: https://modelcontextprotocol.io");
assert.deepEqual(JSON.parse(readFileSync(bookmarksFile, "utf8"))[0].tags, ["mcp"]);
});
it("stores a repeated URL once", async () => {
const arguments_ = { url: "https://example.com", title: "Example" };
await client.callTool({ name: "save_bookmark", arguments: arguments_ });
const second = await client.callTool({ name: "save_bookmark", arguments: arguments_ });
assert.equal(textOf(second), "Already saved: https://example.com");
assert.equal(JSON.parse(readFileSync(bookmarksFile, "utf8")).length, 1);
});
it("says so when nothing matches", async () => {
const result = await client.callTool({ name: "search_bookmarks", arguments: { query: "nothing" } });
assert.notEqual(result.isError, true);
assert.equal(textOf(result), "No bookmarks match 'nothing'.");
});
it("returns a tool error when a required argument is missing", async () => {
const result = await client.callTool({
name: "save_bookmark",
arguments: { url: "https://example.com" },
});
assert.equal(result.isError, true);
assert.match(textOf(result), /title/);
});
it("refuses a tool that does not exist with a protocol error", async () => {
await assert.rejects(
client.callTool({ name: "delete_everything", arguments: {} }),
(error: unknown) => error instanceof ProtocolError && error.code === -32602,
);
});
});
}
The SDKs disagree about one case: a tool name that doesn't exist. The table shows what the 2026-07-28 spec asks for and what each SDK does.
| Case | Spec (2026-07-28) | Python SDK 2.3.0 | TypeScript SDK 2.3.1 |
|---|---|---|---|
| Tool name doesn't exist | Protocol error, code -32602 | Tool result with isError: true | Protocol error, code -32602 |
The test for each SDK asserts what that SDK does. Your own client should handle both shapes, because a server built on either SDK can send either one.
The contract test in TypeScript reads the same JSON file and checks the same fields:
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import type { Client } from "@modelcontextprotocol/client";
import { CONTRACT_PATH, connect, selectedEras } from "./support.js";
// The contract is shared with the Python suite, so both languages check the same tool surface.
type Contract = {
tools: {
name: string;
required: string[];
properties: Record<string, { types: string[] }>;
}[];
};
type JsonSchema = Record<string, unknown>;
const contract: Contract = JSON.parse(readFileSync(CONTRACT_PATH, "utf8"));
// JSON types a schema allows, read from a top-level `type` or from the options of `anyOf`.
function declaredTypes(schema: JsonSchema): Set<string> {
const { type, anyOf } = schema;
if (typeof type === "string") return new Set([type]);
if (Array.isArray(type)) return new Set(type.filter((t): t is string => typeof t === "string"));
const allowed = new Set<string>();
if (Array.isArray(anyOf)) {
for (const option of anyOf) {
for (const t of declaredTypes(option as JsonSchema)) allowed.add(t);
}
}
return allowed;
}
for (const era of selectedEras()) {
describe(`tool contract over the ${era} protocol era`, () => {
let dir: string;
let client: Client;
before(async () => {
dir = mkdtempSync(join(tmpdir(), "bookmarks-contract-"));
client = await connect(era, join(dir, "bookmarks.json"));
});
after(async () => {
await client.close();
rmSync(dir, { recursive: true, force: true });
});
it("exposes exactly the tools in the contract", async () => {
const { tools } = await client.listTools();
assert.deepEqual(
tools.map((tool) => tool.name).sort(),
contract.tools.map((tool) => tool.name).sort(),
);
});
for (const expected of contract.tools) {
it(`${expected.name} has the contracted input schema`, async () => {
const { tools } = await client.listTools();
const tool = tools.find((candidate) => candidate.name === expected.name);
assert.ok(tool, `${expected.name} is missing`);
assert.ok(tool.description, `${expected.name} needs a description for the model to read`);
const schema = tool.inputSchema as JsonSchema;
assert.equal(schema.type, "object");
assert.deepEqual([...((schema.required as string[] | undefined) ?? [])].sort(), [...expected.required].sort());
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
assert.deepEqual(Object.keys(properties).sort(), Object.keys(expected.properties).sort());
for (const [param, spec] of Object.entries(expected.properties)) {
const prop = properties[param];
assert.ok(prop, `${expected.name}.${param} is missing`);
for (const wanted of spec.types) {
assert.ok(declaredTypes(prop).has(wanted), `${expected.name}.${param} should allow ${wanted}`);
}
assert.ok(
typeof prop.description === "string" && prop.description.length > 0,
`${expected.name}.${param} needs a description`,
);
}
});
}
});
}
5. Check the server with MCP Inspector in CI
MCP Inspector's CLI mode connects to a server, runs one method, prints the result and exits. Each CI run needs only a command and an exit code. Pin the version, because a new Inspector release can change its output. Pass --protocol-era on every run, so the step checks the era you mean.
- Python
- TypeScript
npx -y @modelcontextprotocol/inspector@2.11.0 --cli \
-e BOOKMARKS_FILE=./scratch-bookmarks.json \
uv run bookmarks.py --protocol-era modern --method tools/list
npx -y @modelcontextprotocol/inspector@2.11.0 --cli \
-e BOOKMARKS_FILE=./scratch-bookmarks.json \
node build/index.js --protocol-era modern --method tools/list
The -e flag sets an environment variable for the server, so the check reads a scratch file and leaves your real bookmarks alone. The output is the server's tool list. This sample comes from the TypeScript server, trimmed to its first tool:
{
"tools": [
{
"name": "save_bookmark",
"description": "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
"inputSchema": {
...
Check the output with grep -q for each tool name, so the step fails if a tool goes missing. The CLI also sets its exit code. These codes come from the Inspector's CLI docs:
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error or an unexpected error. |
3 | The server requires authentication. |
4 | The server can't be reached. |
5 | A tool returned isError: true, or the tool wasn't found. |
A tool call that returns isError: true still prints its result, but the exit code is 5. That makes a failed call stop a shell chain with &&, the same as any other failure.
6. Run the matrix in GitHub Actions
The example workflow runs the Python and TypeScript suites and both Inspector checks. It sits in the example folder, so it doesn't run in this repository. To use it in your own repository, copy it to .github/workflows/, then change the paths to match your layout.
# Example CI workflow. Copy it to .github/workflows/ in your repository and
# change the paths to match your layout. This copy lives in the guide's example
# folder, so it does not run in this repository.
name: test-mcp-server
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
protocol_era: [legacy, modern]
python: ["3.10", "3.14"]
node: ["22.19.0", "24.19.0"]
env:
# uv reads UV_PYTHON, so every uv command in this job uses the matrix Python.
UV_PYTHON: ${{ matrix.python }}
# Both test suites read this and run only the era in the matrix cell.
MCP_PROTOCOL_ERA: ${{ matrix.protocol_era }}
defaults:
run:
working-directory: docs/guides-examples/test-mcp-server-in-ci
steps:
- uses: actions/checkout@v7.0.1
with:
# The guides live in the docs submodule of the MCPBundles repository.
submodules: true
- uses: astral-sh/setup-uv@v10.3.0
- uses: actions/setup-node@v7.1.0
with:
node-version: ${{ matrix.node }}
- name: Python tests (in-process client)
working-directory: docs/guides-examples/test-mcp-server-in-ci/python
run: |
uv sync --locked
uv run --locked pytest -q
- name: Build the server under test
working-directory: docs/guides-examples/build-mcp-server/typescript
run: |
npm ci
npm run build
- name: TypeScript tests (stdio child process)
working-directory: docs/guides-examples/test-mcp-server-in-ci/typescript
run: |
npm ci
npm test
- name: MCP Inspector CLI (Python server)
run: |
npx --yes @modelcontextprotocol/inspector@2.11.0 --cli \
-e BOOKMARKS_FILE=/tmp/bookmarks-python.json \
uv run --project python python ../build-mcp-server/python/bookmarks.py \
--protocol-era ${{ matrix.protocol_era }} --method tools/list
- name: MCP Inspector CLI (TypeScript server)
run: |
npx --yes @modelcontextprotocol/inspector@2.11.0 --cli \
-e BOOKMARKS_FILE=/tmp/bookmarks-typescript.json \
node ../build-mcp-server/typescript/build/index.js \
--protocol-era ${{ matrix.protocol_era }} --method tools/list
The matrix runs each job for every combination of these values:
- Protocol era:
legacyandmodern. The job setsMCP_PROTOCOL_ERA, and both test suites run only that era. The Inspector steps pass the same value to--protocol-era. - Python:
3.10and3.14.UV_PYTHONtells uv which interpreter to use, so the job tests the floor and the current release. - Node.js:
22.19.0and24.19.0. The lower version is the one MCP Inspector requires, so the matrix tests it.
That's 2 eras × 2 Python versions × 2 Node versions, or 8 jobs. fail-fast: false keeps the other jobs running when one fails, so one broken era doesn't hide the rest. uv sync --locked fails the job when uv.lock doesn't match pyproject.toml, instead of quietly updating it.
The workflow pins three actions by version: actions/checkout@v7.0.1, actions/setup-node@v7.1.0 and astral-sh/setup-uv@v10.3.0.
7. Run every check locally with test.sh
test.sh runs the same checks as the workflow on one machine. It also calls save_bookmark and search_bookmarks through the Inspector, which the workflow doesn't do. It stops at the first failure.
#!/usr/bin/env bash
# Runs every check in this guide locally, in the same order as the CI workflow:
# 1. pytest against an in-process Python client (both protocol eras)
# 2. node:test against the TypeScript server over stdio (both protocol eras)
# 3. MCP Inspector CLI against both example servers (both protocol eras)
# Exits non-zero on the first failure.
set -euo pipefail
cd "$(dirname "$0")"
HERE="$PWD"
EXAMPLE="$(cd ../build-mcp-server && pwd)"
INSPECTOR="npx -y @modelcontextprotocol/inspector@2.11.0 --cli"
STORE="$(mktemp -t guide.test-ci.bookmarks.json.XXXXXX)"
trap 'rm -f "$STORE"' EXIT
node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' \
|| { echo "FAIL: Node.js 22.19 or later is required for MCP Inspector 2.11.0 (found $(node -v))"; exit 1; }
echo "== Python: pytest, in-process client, both eras"
(cd python && uv sync --locked --quiet && uv run --locked pytest -q)
echo "== TypeScript: node:test, stdio child process, both eras"
if [ ! -f "$EXAMPLE/typescript/build/index.js" ]; then
(cd "$EXAMPLE/typescript" && npm install --silent && npm run --silent build)
fi
(cd typescript && npm ci --silent && npm test --silent)
echo "== MCP Inspector CLI: tools/list in both eras, then a call"
for era in modern legacy; do
for server in python typescript; do
if [ "$server" = python ]; then
cmd=(uv run --project "$HERE/python" python "$EXAMPLE/python/bookmarks.py")
else
cmd=(node "$EXAMPLE/typescript/build/index.js")
fi
out="$($INSPECTOR -e BOOKMARKS_FILE="$STORE" "${cmd[@]}" --protocol-era "$era" --method tools/list)"
echo "$out" | grep -q '"save_bookmark"' || { echo "FAIL: $server $era tools/list"; echo "$out"; exit 1; }
echo "$out" | grep -q '"search_bookmarks"' || { echo "FAIL: $server $era tools/list"; echo "$out"; exit 1; }
echo "ok: $server tools/list ($era)"
done
done
rm -f "$STORE"
out="$($INSPECTOR -e BOOKMARKS_FILE="$STORE" uv run --project "$HERE/python" python "$EXAMPLE/python/bookmarks.py" \
--protocol-era modern --method tools/call \
--tool-name save_bookmark --tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs")"
echo "$out" | grep -q 'Saved' || { echo "FAIL: save_bookmark call"; echo "$out"; exit 1; }
out="$($INSPECTOR -e BOOKMARKS_FILE="$STORE" uv run --project "$HERE/python" python "$EXAMPLE/python/bookmarks.py" \
--protocol-era modern --method tools/call \
--tool-name search_bookmarks --tool-arg query=mcp)"
echo "$out"
echo "$out" | grep -q 'https://modelcontextprotocol.io' || { echo "FAIL: search_bookmarks call"; exit 1; }
echo "ok: save and search through the Inspector"
echo "All checks passed"
From the example folder, run:
bash test.sh
The script checks your Node version before it starts, because an older Node fails in confusing ways with the Inspector. Set UV_PYTHON to test another Python version, for example UV_PYTHON=3.10 bash test.sh. The first run installs packages, so it takes longer than the rest.
Common problems
Node.js 22.19 or later is required for MCP Inspector 2.11.0
Your shell is running an older Node. Check with node -v, then switch to 22.19 or later and run the script again. Some version managers keep a separate default for each shell, so check the shell that runs the script.
Error [ProtocolError]: Tool delete_everything not found in a TypeScript test
The test called a tool that doesn't exist and didn't expect the error. The SDK rejects the call with a protocol error, code -32602. Assert on that error, as the refuses a tool that does not exist test does, or call a real tool name.
Already saved: https://example.com in a test that should save for the first time
An earlier test left a bookmark in the same file. Each Python test gets its own file from the bookmarks_file fixture in conftest.py, and each TypeScript test creates a new temporary folder. A new test that writes to a shared path brings this back, so give it its own file.
MCP_PROTOCOL_ERA must be one of ['legacy', 'modern']
You set MCP_PROTOCOL_ERA to a value the Python suite doesn't know. Use legacy or modern, or unset the variable to run both eras.
FAQ
Does the test suite call an AI model?
No. The tests call the server's tools directly, so no model is involved. The example server reads and writes a local JSON file, so only the package installs need network access.
Why run both protocol eras?
The 2026-07-28 revision is the current one, but some AI apps still use the 2025-era initialize handshake. A server that passes in one era and fails in the other breaks for some users, so the matrix runs both.
Is a contract test the same as a unit test?
No. A contract test checks what clients see: tool names, required parameters, types and descriptions. A unit test checks what a tool returns. Keep both, because a contract test catches a renamed parameter even when the tool logic still works.
Can I use another test runner?
Yes. pytest and node:test are the standard runners for each language. Use the one your team already runs, since the test bodies stay the same.
- Build an MCP serverWrite the bookmarks server these tests cover.
- Test with MCP InspectorUse the Inspector web UI to debug a server by hand.
- Tool errorsReturn errors the model can recover from.
- Tool input schemasHow parameters, required fields and types become the input schema.
- Deploy a remote MCP serverHost the server over Streamable HTTP and test it there.