mcptest docs GitHub

Coprocess protocol

The native test-framework SDKs (the Rust mcptest-runner crate today, with Python, TypeScript, Go, JVM, and .NET siblings) do not reimplement MCP. Each SDK spawns the mcptest binary as a coprocess:

mcptest exec --connection-server --server-command "<argv of the server under test>"

and exchanges newline-delimited JSON-RPC 2.0 over the child's stdin and stdout. One request line in, one response line out, strictly serialized. This page is the wire contract for that channel.

Protocol version

The coprocess protocol carries a single integer version:

COPROCESS_PROTOCOL_VERSION = 2

It is pinned on the binary side in crates/mcptest-core/src/exec/protocol.rs and mirrored once per SDK. None of the SDKs can import the core crate (they stay dependency-light, and four of them are not Rust), so each pin is a literal kept honest by the handshake itself plus the per-SDK handshake test suites; the Rust pin is additionally cross-checked by crates/mcptest-core/tests/exec_ipc.rs. Bump every pin together, in the same commit, whenever any frame shape on this page changes.

SDK support matrix:

SDKProtocolPin
rust (mcptest-runner)v2sdks/rust/mcptest-runner/src/coprocess.rs
python (mcptest)v2sdks/python/mcptest/_coprocess.py
typescript (@mcptest/sdk)v2sdks/typescript/src/coprocess.ts
go (sdks/go)v2sdks/go/coprocess.go
jvm (mcptest-junit5)v2sdks/jvm/.../mcptest/Coprocess.java
dotnet (Mcptest.Sdk)v2sdks/dotnet/src/Mcptest.Sdk/Coprocess.cs

Version history:

Handshake

A v2 SDK sends exactly one handshake as its first request after spawn:

{"jsonrpc": "2.0", "id": 1, "method": "coprocess/handshake",
 "params": {"protocol_version": 2, "sdk": "rust", "sdk_version": "1.0.1"}}

A matching binary replies:

{"jsonrpc": "2.0", "id": 1,
 "result": {"protocol_version": 2, "binary_version": "1.0.1"}}

On a version mismatch the binary replies with a JSON-RPC error, code -32602 (invalid params), whose message names both versions and which side to upgrade:

{"jsonrpc": "2.0", "id": 1,
 "error": {"code": -32602,
           "message": "coprocess protocol version mismatch: this mcptest binary speaks v2, the rust SDK sent v3. Upgrade the mcptest binary (or pin the SDK to the matching release)."}}

How each side treats the handshake:

Frame shapes

All frames are single lines of JSON terminated by \n.

Request (SDK to binary):

{"jsonrpc": "2.0", "id": 7, "method": "mcp.call",
 "params": {"tool": "get_weather", "arguments": {"city": "Paris"}}}

The envelope is a closed shape: as of v2 the binary rejects requests that carry any other top-level field, and the SDK rejects responses that do.

Response (binary to SDK), exactly one of result or error:

{"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "sunny"}], "isError": false}}
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32000, "message": "tool refused", "data": {"tool": "get_weather"}}}

Error codes follow JSON-RPC (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error) plus the application range: -32000 upstream MCP server error, -32001 upstream MCP connection dropped.

Verdict shape (the result of mcp.call as the SDKs type it):

{"success": true, "data": null, "text": "sunny", "error": null, "duration_ms": 3}

As of v2 the SDKs parse this shape strictly: an undeclared field is contract drift and fails the call loudly instead of misparsing into defaults. The binary may also forward the upstream MCP tools/call result verbatim (content, structuredContent, isError); SDKs recognize that passthrough shape explicitly and derive success from isError.

Compatibility rule

Lanes not over IPC

The methods above forward a single MCP call or a metadata query. The richer suite lanes the mcptest run CLI executes are not yet drivable over the coprocess; there is no wire verb for an agent loop, compliance scoring, a security probe, or a rubric eval. Lane parity is tracked under WOR-1464 and will land per-lane (each adds a method here, a schemas/ipc/v1.json entry, and the per-SDK surface).

Until then an SDK must not silently drop a lane a suite declares. The contract is: report the lane and name the CLI command that runs it.

Lane (YAML key)Drivable over IPC?Runs via
tools: (mcp.call + matchers)yesthe SDK
resources: (mcp.readResource)yesthe SDK
prompts: (mcp.callPrompt)yesthe SDK
agents: (agent-loop evals)nomcptest run <suite>
compliance:nomcptest compliance run --from-suite <suite>
security:nomcptest run <suite>
evals: (top-level rubric evals)nomcptest eval <suite>

Every collecting SDK implements the reporting half today, so a mixed suite reports each unsupported lane (as a skipped or ignored test naming the CLI command) rather than dropping it:

SDKReports lanes asShared list / hint
rustone ignored #[test] per lane (mcptest::lanes::<lane>)mcptest_runner::{UNSUPPORTED_SDK_LANES, lane_cli_hint}
pythonone skipped pytest item per lanemcptest._collect.{UNSUPPORTED_LANES, lane_cli_hint}
got.Skip per lanemcptest.{UnsupportedLanes, laneCliHint}
jvmTestAbortedException per laneCollectedTest.UNSUPPORTED_LANES, Loader.laneCliHint
dotnetreported lane per laneLoader.{UnsupportedLanes, LaneCliHint}
typescriptexports the shared contract for host collectorsUNSUPPORTED_SDK_LANES, laneCliHint

For the Rust SDK, cargo test lists each lane as mcptest::lanes::<lane> ... ignored, <reason> (and compliance rules as mcptest::compliance::<rule> ... ignored, <reason>); run with cargo test -- --ignored --nocapture to print the exact CLI command. The per-SDK skip messages share the same wording so the lane name and command do not drift across languages.