mcptest docs GitHub

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>}
FieldRequiredTypeNotes
methodyesstringPlugin-defined. The host treats it as opaque.
paramsnoany JSON valueOmit when the method takes none.
idyesstring or numberUnique 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.

CodeMeaning
-32700Parse error. The plugin sent a line that was not valid JSON.
-32600Invalid response. The line parsed but did not match the schema.
-32601Method not found. The plugin does not handle that method.
-32602Invalid params. The method exists but the params are wrong.
-32603Internal 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.

  1. Spawn on first use. The host does not launch the plugin until the first request. A configured-but-unused plugin costs nothing.
  2. 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.
  3. 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_timeout for the child to exit.
  4. Forced shutdown. A plugin that ignores EOF is killed. tokio's kill_on_drop makes this automatic if the host is dropped without an explicit shutdown() call.

Error semantics

The host distinguishes four protocol-level failures:

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

  1. Read one JSON object per line on stdin.
  2. Write one JSON object per line on stdout, with the same id echoed back.
  3. Flush stdout after every response.
  4. Use stderr for free-form logging. The host ignores stderr; the wire stream stays clean.
  5. Exit cleanly when stdin reaches EOF.
  6. For protocol violations, use the JSON-RPC reserved codes above. For application errors, use any other integer.
  7. 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.