Subprocess plugin protocol
The polyglot escape hatch for users who want to extend mcptest from a language other than Rust. The host spawns a plugin binary, exchanges newline-delimited JSON over stdin and stdout, and keeps the process alive across calls within one run. The contract is intentionally close to JSON-RPC 2.0 so plugin authors with prior JSON-RPC experience can reuse their helpers.
Wire types live in crates/mcptest-core/src/plugins/protocol.rs. The host-side client lives in crates/mcptest-core/src/plugins/host.rs. A working reference plugin lives in examples/plugins/echo/.
Tracker: . Out of scope: WASM plugins, embedded Lua, the CLI plumbing that wires --plugin <path> and imports.subprocess_matchers: into the runner (follow-up tickets).
Wire format
One JSON object per line in each direction. UTF-8 encoding. No multi-line objects, no Content-Length framing.
Request (host to plugin)
{"method": "<name>", "params": <any JSON or absent>, "id": <scalar>}
| Field | Required | Type | Notes |
|---|---|---|---|
method | yes | string | Plugin-defined. The host treats it as opaque. |
params | no | any JSON value | Omit when the method takes none. |
id | yes | string or number | Unique within the plugin's lifetime. The host mints these; plugins echo them back. |
Response (plugin to host)
{"id": <same scalar>, "result": <any JSON>}
or
{"id": <same scalar>, "error": {"code": <int>, "message": <string>, "data": <any or absent>}}
Exactly one of result and error is present. Sending both, or neither, is a protocol violation.
The host emits these error codes when a response is missing or malformed. Plugins should mirror them for protocol-level failures and use any other integer for application-defined errors.
| Code | Meaning |
|---|---|
-32700 | Parse error. The plugin sent a line that was not valid JSON. |
-32600 | Invalid response. The line parsed but did not match the schema. |
-32601 | Method not found. The plugin does not handle that method. |
-32602 | Invalid params. The method exists but the params are wrong. |
-32603 | Internal error. The plugin hit an unexpected failure. |
Lifecycle
The host owns the process lifetime end to end. Plugins do not need to implement any handshake or shutdown method beyond reading stdin until EOF.
- Spawn on first use. The host does not launch the plugin until the first request. A configured-but-unused plugin costs nothing.
- Keep alive across calls. Once spawned, the process stays running for the rest of the run. The host serializes requests through one stdin pipe and reads responses off one stdout pipe.
- Graceful shutdown. When the run finishes, the host drops the stdin pipe. The plugin sees EOF on stdin and should flush any buffers and exit. The host then waits up to the configured
call_timeoutfor the child to exit. - Forced shutdown. A plugin that ignores EOF is killed. tokio's
kill_on_dropmakes this automatic if the host is dropped without an explicitshutdown()call.
Error semantics
The host distinguishes four protocol-level failures:
- Spawn failure. The binary is missing, not executable, or refuses to start. The host raises
HostError::SpawnFailedon the first call. The plugin process never existed; no shutdown is needed. - Crash. The plugin exits before responding, or its stdout pipe closes mid-response. The host raises
HostError::PluginCrashedand reaps the child. Subsequent calls re-spawn? No. v1 surfaces every follow-up call asPluginCrashedtoo; callers decide whether to re-create the host. (Auto-respawn is a future-ticket policy choice.) - Timeout. The plugin does not respond within
call_timeout. The host raisesHostError::Timeoutand leaves the child running on the assumption that it might still be alive. Operators see the timeout and decide whether to kill the run. - Malformed response. The plugin sends a line that is not valid JSON, or is JSON that does not match the response schema. The host raises
HostError::InvalidJsonorHostError::InvalidResponsewith the raw line attached for diagnosis. The child is left running.
Application-level failures (the plugin returns {"error": ...} for a known method) are not host errors. The host returns the PluginResponse as-is and the caller decides how to react.
Plugin author checklist
- Read one JSON object per line on stdin.
- Write one JSON object per line on stdout, with the same
idechoed back. - Flush stdout after every response.
- Use stderr for free-form logging. The host ignores stderr; the wire stream stays clean.
- Exit cleanly when stdin reaches EOF.
- For protocol violations, use the JSON-RPC reserved codes above. For application errors, use any other integer.
- Keep per-call work under ~50 ms when you can. The protocol overhead is small but real (~10 ms steady state), so a plugin call is roughly one order of magnitude slower than an in-process matcher.
Reference plugin
examples/plugins/echo/echo.py handles two methods (echo and ping) in ~40 lines of Python. The host-side integration tests under crates/mcptest-core/tests/plugin_host.rs spawn it directly. Use it as a starting template when building a plugin in Go, Node, Ruby, or any other language with stdin and stdout.
Versioning
The protocol version is v1. The host exposes the constant as mcptest_core::plugins::PROTOCOL_VERSION. Additive changes (new optional request fields, new error codes) land under v1. Breaking changes (type changes, removed fields) roll a v2 and surface in the host so plugins can refuse an incompatible peer.