feat(m1): add jobbsok-tools stdio mcp server and launcher
This commit is contained in:
parent
28a950bcea
commit
89bcd1b038
6 changed files with 1057 additions and 0 deletions
114
tests/helpers/mcp_stdio.py
Normal file
114
tests/helpers/mcp_stdio.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue