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:
| SDK | Protocol | Pin |
|---|---|---|
rust (mcptest-runner) | v2 | sdks/rust/mcptest-runner/src/coprocess.rs |
python (mcptest) | v2 | sdks/python/mcptest/_coprocess.py |
typescript (@mcptest/sdk) | v2 | sdks/typescript/src/coprocess.ts |
go (sdks/go) | v2 | sdks/go/coprocess.go |
jvm (mcptest-junit5) | v2 | sdks/jvm/.../mcptest/Coprocess.java |
dotnet (Mcptest.Sdk) | v2 | sdks/dotnet/src/Mcptest.Sdk/Coprocess.cs |
Version history:
- v1: the original unversioned channel. No handshake; the SDK started sending
mcp.*requests immediately after spawn. - v2: adds the
coprocess/handshakeexchange and strict (unknown fields rejected) parsing of the envelopes and verdict shapes on both sides.
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"}}
protocol_version(integer, required): the version the SDK speaks.sdk(string, optional): SDK family, for examplerust,python,typescript,go,jvm,dotnet. Diagnostic only; it names the peer in mismatch messages.sdk_version(string, optional): SDK package version. Diagnostic only.
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:
- The binary tolerates its absence. A legacy (v1) SDK never sends one, and the session serves
mcp.*requests either way. The version is enforced only when a handshake actually arrives. The SDK fails fast. If the reply reports a different version, or the binary answers
-32601(method not found, the signature of a v1 binary), the SDK aborts the session before any test traffic with a message such as:mcptest binary speaks coprocess v1; this SDK needs v2. Upgrade the mcptest binary (or pin the SDK to the matching release).
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"}}}
jsonrpc: always the literal"2.0".id: number or string, echoed in the response.method: one ofcoprocess/handshake,mcp.initialize,mcp.call,mcp.listTools,mcp.listResources,mcp.readResource,mcp.callPrompt,mcp.reporter.setUpload,mcp.reporter.flush,mcp.cache.set_mode,mcp.cassette.set_mode,mcp.shutdown.params: method-specific object, omitted for lifecycle methods.
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
- The version is one integer. Any change to a frame shape on this page, including adding a field, bumps it on both sides in the same commit.
- New binary, old SDK: works. The binary never requires a handshake, so a v1 SDK keeps running against a v2 binary on the v1 surface.
- Old binary, new SDK: fails fast at spawn. The SDK translates the binary's
-32601oncoprocess/handshakeinto the upgrade message above. - Mixed versions where both sides know the handshake: whichever side detects the mismatch reports both versions and the upgrade direction. Nothing falls back silently; that silence is the failure mode this protocol version exists to remove.
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) | yes | the SDK |
resources: (mcp.readResource) | yes | the SDK |
prompts: (mcp.callPrompt) | yes | the SDK |
agents: (agent-loop evals) | no | mcptest run <suite> |
compliance: | no | mcptest compliance run --from-suite <suite> |
security: | no | mcptest run <suite> |
evals: (top-level rubric evals) | no | mcptest 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:
| SDK | Reports lanes as | Shared list / hint |
|---|---|---|
| rust | one ignored #[test] per lane (mcptest::lanes::<lane>) | mcptest_runner::{UNSUPPORTED_SDK_LANES, lane_cli_hint} |
| python | one skipped pytest item per lane | mcptest._collect.{UNSUPPORTED_LANES, lane_cli_hint} |
| go | t.Skip per lane | mcptest.{UnsupportedLanes, laneCliHint} |
| jvm | TestAbortedException per lane | CollectedTest.UNSUPPORTED_LANES, Loader.laneCliHint |
| dotnet | reported lane per lane | Loader.{UnsupportedLanes, LaneCliHint} |
| typescript | exports the shared contract for host collectors | UNSUPPORTED_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.