114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
"""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"]
|