"""Drive the stdio MCP server in-process, over its own request handler. The transport MCP specifies is newline-delimited JSON-RPC over stdin and stdout, but a test that spawned a subprocess to ask `tools/list` would be measuring the pipe as much as the server, and would need a timeout to avoid hanging a suite on a handshake that never completes. So this helper speaks to `handle()` directly: the same dictionaries the loop would have decoded off the wire, minus the wire. The one thing that buys is that a protocol error is an assertion failure in the test that caused it, not a hang. What that deliberately does not cover is the loop itself -- the framing, the flush, the tolerance for a blank line. `tests/test_mcp_jobbsok_tools.py` covers those separately, by driving the real launcher as a subprocess and reading its stdout, which is the only place they are observable. Style note: this file follows tests/helpers/golden.py. """ PROTOCOL_VERSION = "2024-11-05" class RpcError(AssertionError): """A JSON-RPC error response, raised where the test can see the code.""" def __init__(self, method, error): self.method = method self.error = error super().__init__( "%s returned error %s: %s" % (method, error.get("code"), error.get("message")) ) class Client(object): """A minimal MCP client bound to one server module. Ids are assigned here rather than by the caller, because an id the test chose tells the test nothing: what matters is that the response carries back the id the request went out with, and that is asserted on every call. """ def __init__(self, server): self.server = server self._next_id = 0 def request(self, method, params=None): self._next_id += 1 req_id = self._next_id message = {"jsonrpc": "2.0", "id": req_id, "method": method} if params is not None: message["params"] = params response = self.server.handle(message) assert response is not None, "%s got no response; it is not a notification" % method assert response.get("jsonrpc") == "2.0", "response is not JSON-RPC 2.0: %r" % response assert response.get("id") == req_id, ( "response id %r does not match request id %r" % (response.get("id"), req_id) ) if "error" in response: raise RpcError(method, response["error"]) return response["result"] def notify(self, method, params=None): """Send a notification and assert the server stays silent.""" message = {"jsonrpc": "2.0", "method": method} if params is not None: message["params"] = params assert self.server.handle(message) is None, ( "%s is a notification; the server must not reply to it" % method ) def initialize(self): result = self.request( "initialize", { "protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "jobbsok-tests", "version": "0"}, }, ) self.notify("notifications/initialized") return result def tools(self): return self.request("tools/list")["tools"] def call(self, name, arguments): """Call a tool and return the raw result, errors included.""" self._next_id += 1 req_id = self._next_id response = self.server.handle( { "jsonrpc": "2.0", "id": req_id, "method": "tools/call", "params": {"name": name, "arguments": arguments}, } ) assert response.get("id") == req_id return response def call_text(self, name, arguments): """Call a tool that is expected to succeed and return its text.""" response = self.call(name, arguments) if "error" in response: raise RpcError("tools/call:%s" % name, response["error"]) result = response["result"] assert result.get("isError") is False, ( "%s reported a tool error: %r" % (name, result) ) blocks = result["content"] assert len(blocks) == 1 and blocks[0]["type"] == "text", ( "%s returned %r; one text block was expected" % (name, blocks) ) return blocks[0]["text"]