From d5ba25387891366c58aa5d6315d344cc4c652333 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 04:01:40 -0400 Subject: [PATCH] feat(supervise): MCP sidecar HTTP server + Dockerfile (PRD 0013) Phase 2 of PRD 0013. Adds the in-container MCP server: - claude_bottle/supervise_server.py: minimal JSON-RPC over HTTP MCP server. Handles initialize / notifications/initialized / tools/list / tools/call. Each tools/call validates the proposed file syntactically, writes a Proposal to the host-mounted queue, blocks waiting for a Response, archives both files, returns the operator's {status, notes} wrapped in MCP content. - Three tool definitions with JSON Schema inputs: cred-proxy-block (routes.json), pipelock-block (allowlist), capability-block (Dockerfile). - Dockerfile.supervise mirroring the cred-proxy pattern: same pinned python:3.13-alpine, copies supervise.py + supervise_server.py into /app, exposes port 9100. Stdlib-only. Tests cover JSON-RPC parsing, per-tool validation, all three handlers, the queue round-trip via a background responder thread, and an end-to-end HTTP sanity check on a random port. Co-Authored-By: Claude Opus 4.7 --- Dockerfile.supervise | 32 ++ claude_bottle/supervise_server.py | 499 ++++++++++++++++++++++++++++ tests/unit/test_supervise_server.py | 378 +++++++++++++++++++++ 3 files changed, 909 insertions(+) create mode 100644 Dockerfile.supervise create mode 100644 claude_bottle/supervise_server.py create mode 100644 tests/unit/test_supervise_server.py diff --git a/Dockerfile.supervise b/Dockerfile.supervise new file mode 100644 index 0000000..94aa371 --- /dev/null +++ b/Dockerfile.supervise @@ -0,0 +1,32 @@ +# Per-bottle supervise sidecar image (PRD 0013). +# +# Exposes three MCP tools (cred-proxy-block, pipelock-block, +# capability-block) the agent calls to propose config changes when +# stuck. Each tool call writes a Proposal to a host-mounted queue +# dir and blocks waiting for the operator's Response. +# +# Stdlib-only Python. The bottle slug arrives via +# SUPERVISE_BOTTLE_SLUG; the host's ~/.claude-bottle/queue// +# is bind-mounted at /run/supervise/queue. + +# python:3.13-alpine, pinned by digest (same image cred-proxy uses, +# so docker pulls / caches once for both sidecars). +FROM python@sha256:420cd0bf0f3998275875e02ecd5808168cf0843cbb4d3c536432f729247b2acc + +# Both files ship as single files into /app; supervise_server.py +# imports supervise via same-directory resolution. +COPY claude_bottle/supervise.py /app/supervise.py +COPY claude_bottle/supervise_server.py /app/supervise_server.py + +# Pre-create the queue mount point so docker's bind-mount has a +# parent dir. Matches Dockerfile.cred-proxy's pattern. +RUN mkdir -p /run/supervise/queue + +EXPOSE 9100 + +# WORKDIR makes the in-app same-dir import deterministic regardless +# of how the container is launched. +WORKDIR /app + +# PID 1 is python for clean signal handling and exit codes. +ENTRYPOINT ["python3", "/app/supervise_server.py"] diff --git a/claude_bottle/supervise_server.py b/claude_bottle/supervise_server.py new file mode 100644 index 0000000..a384c08 --- /dev/null +++ b/claude_bottle/supervise_server.py @@ -0,0 +1,499 @@ +"""Supervise sidecar HTTP server (PRD 0013). + +Per-bottle MCP server exposing three tools — `cred-proxy-block`, +`pipelock-block`, `capability-block` — that the agent calls to +propose config changes when stuck. Each tool call: + + 1. Validates the proposed file syntactically. + 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from + the host's ~/.claude-bottle/queue//). + 3. Blocks polling for a matching Response file. + 4. Returns the operator's `{status, notes}` to the agent. + +The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at +container creation by the backend's start step). The queue dir comes +from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`). + +Speaks MCP over HTTP+JSON-RPC. Methods handled: + + * `initialize` — handshake; returns server info + caps. + * `notifications/initialized` — ack-only. + * `tools/list` — returns the three tool definitions. + * `tools/call` — validates, queues, blocks, returns. + +Everything else returns JSON-RPC error -32601 (method not found). + +Stdlib-only. The Dockerfile copies this file + claude_bottle/supervise.py +into the image; the server imports `supervise` for the queue / Proposal +plumbing. +""" + +from __future__ import annotations + +import http.server +import json +import os +import socketserver +import sys +import typing +from dataclasses import dataclass +from pathlib import Path + +# Same-directory import inside the container; `supervise.py` is COPYed +# alongside this file by Dockerfile.supervise. +import supervise as _sv + + +# --- JSON-RPC / MCP plumbing ---------------------------------------------- + + +MCP_PROTOCOL_VERSION = "2024-11-05" +SERVER_NAME = "claude-bottle-supervise" +SERVER_VERSION = "0.1.0" + +JSONRPC_VERSION = "2.0" + +# JSON-RPC 2.0 standard error codes. +ERR_PARSE = -32700 +ERR_INVALID_REQUEST = -32600 +ERR_METHOD_NOT_FOUND = -32601 +ERR_INVALID_PARAMS = -32602 +ERR_INTERNAL = -32603 + + +@dataclass(frozen=True) +class JsonRpcRequest: + method: str + params: dict[str, object] + id: object # None for notifications; int/str/null for requests + is_notification: bool + + +def parse_jsonrpc(body: bytes) -> JsonRpcRequest: + """Parse a single JSON-RPC 2.0 request body. Raises ValueError + with a JSON-RPC error code attached if the shape is wrong.""" + try: + raw = json.loads(body) + except json.JSONDecodeError as e: + raise _RpcError(ERR_PARSE, f"parse error: {e}") from e + if not isinstance(raw, dict): + raise _RpcError(ERR_INVALID_REQUEST, "request must be a JSON object") + if raw.get("jsonrpc") != JSONRPC_VERSION: + raise _RpcError(ERR_INVALID_REQUEST, "jsonrpc field must be '2.0'") + method = raw.get("method") + if not isinstance(method, str): + raise _RpcError(ERR_INVALID_REQUEST, "method must be a string") + params = raw.get("params", {}) + if params is None: + params = {} + if not isinstance(params, dict): + raise _RpcError(ERR_INVALID_PARAMS, "params must be an object") + rpc_id = raw.get("id", _NO_ID) + is_notification = rpc_id is _NO_ID + return JsonRpcRequest( + method=method, + params=params, + id=None if is_notification else rpc_id, + is_notification=is_notification, + ) + + +_NO_ID = object() + + +class _RpcError(Exception): + def __init__(self, code: int, message: str): + super().__init__(message) + self.code = code + self.message = message + + +def jsonrpc_result(request_id: object, result: object) -> bytes: + payload = {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result} + return (json.dumps(payload) + "\n").encode("utf-8") + + +def jsonrpc_error(request_id: object, code: int, message: str) -> bytes: + payload = { + "jsonrpc": JSONRPC_VERSION, + "id": request_id, + "error": {"code": code, "message": message}, + } + return (json.dumps(payload) + "\n").encode("utf-8") + + +# --- Tool definitions ------------------------------------------------------ + + +TOOL_DEFINITIONS: list[dict[str, object]] = [ + { + "name": _sv.TOOL_CRED_PROXY_BLOCK, + "description": ( + "Call when cred-proxy refused your HTTPS request — missing " + "route, expired token, wrong scope (typically a 403 or a " + "404 from `http://cred-proxy://`). Read the " + "current routes.json from " + "/etc/claude-bottle/current-config/routes.json, compose a " + "modified version with the route you need, and pass the " + "full new file plus a justification. The operator approves " + "or rejects in the supervise TUI. On approval the supervisor " + "writes the new routes.json on the host and SIGHUPs cred-proxy " + "(wired in PRD 0014; in the v1 supervise foundation the " + "approval is acknowledged but no config change runs)." + ), + "inputSchema": { + "type": "object", + "properties": { + "routes": { + "type": "string", + "description": "Full proposed routes.json file content (JSON text).", + }, + "justification": { + "type": "string", + "description": "Why this routes change is justified.", + }, + }, + "required": ["routes", "justification"], + }, + }, + { + "name": _sv.TOOL_PIPELOCK_BLOCK, + "description": ( + "Call when pipelock refused your outbound request — host " + "not in the allowlist, protocol blocked, connection " + "refused at the egress layer. Read the current allowlist " + "from /etc/claude-bottle/current-config/allowlist, compose " + "a modified version, and pass the full new file plus a " + "justification. On approval the supervisor writes the new " + "allowlist and restarts pipelock (wired in PRD 0015; v1 " + "acknowledges only)." + ), + "inputSchema": { + "type": "object", + "properties": { + "allowlist": { + "type": "string", + "description": "Full proposed pipelock allowlist (one hostname per line).", + }, + "justification": { + "type": "string", + "description": "Why the new host(s) should be allowed.", + }, + }, + "required": ["allowlist", "justification"], + }, + }, + { + "name": _sv.TOOL_CAPABILITY_BLOCK, + "description": ( + "Call when the bottle is missing a tool, skill, permission, " + "or env var you need — something that lives in the agent " + "Dockerfile rather than in routes or the pipelock allowlist. " + "Read the current Dockerfile from " + "/etc/claude-bottle/current-config/Dockerfile, compose a " + "modified version, and pass the full new file plus a " + "justification. On approval the supervisor rebuilds the " + "bottle from the new Dockerfile and starts a replacement on " + "the same branch (wired in PRD 0016; v1 acknowledges only)." + ), + "inputSchema": { + "type": "object", + "properties": { + "dockerfile": { + "type": "string", + "description": "Full proposed Dockerfile content.", + }, + "justification": { + "type": "string", + "description": "Why this capability is needed.", + }, + }, + "required": ["dockerfile", "justification"], + }, + }, +] + + +# Map each tool to the input field that carries the proposed file. +PROPOSED_FILE_FIELD: dict[str, str] = { + _sv.TOOL_CRED_PROXY_BLOCK: "routes", + _sv.TOOL_PIPELOCK_BLOCK: "allowlist", + _sv.TOOL_CAPABILITY_BLOCK: "dockerfile", +} + + +# --- Validation ------------------------------------------------------------ + + +def validate_proposed_file(tool: str, content: str) -> None: + """Syntactic validation. The operator is the real gate; this just + catches obvious paste-errors / wrong-tool selections before they + enter the queue.""" + if not content.strip(): + raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty") + if tool == _sv.TOOL_CRED_PROXY_BLOCK: + try: + parsed = json.loads(content) + except json.JSONDecodeError as e: + raise _RpcError( + ERR_INVALID_PARAMS, + f"{tool}: proposed routes.json is not valid JSON: {e}", + ) from e + if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list): + raise _RpcError( + ERR_INVALID_PARAMS, + f"{tool}: proposed routes.json must be an object with a 'routes' array", + ) + elif tool == _sv.TOOL_PIPELOCK_BLOCK: + for i, line in enumerate(content.splitlines()): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # Hostnames are conservative: letters/digits/dots/dashes only. + for ch in stripped: + if not (ch.isalnum() or ch in ".-_"): + raise _RpcError( + ERR_INVALID_PARAMS, + f"{tool}: allowlist line {i + 1} has invalid character {ch!r}", + ) + elif tool == _sv.TOOL_CAPABILITY_BLOCK: + # Dockerfiles are too varied to validate syntactically beyond + # non-empty. The operator reads the diff in the TUI. + pass + else: + raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}") + + +# --- MCP handlers ---------------------------------------------------------- + + +@dataclass(frozen=True) +class ServerConfig: + bottle_slug: str + queue_dir: Path + + +def handle_initialize(_params: dict[str, object]) -> dict[str, object]: + return { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + } + + +def handle_tools_list(_params: dict[str, object]) -> dict[str, object]: + return {"tools": TOOL_DEFINITIONS} + + +def handle_tools_call( + params: dict[str, object], + config: ServerConfig, +) -> dict[str, object]: + """Validates the proposal, writes it to the queue, blocks waiting + for a Response, returns the result wrapped in MCP `content`.""" + name = params.get("name") + if not isinstance(name, str): + raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'") + if name not in PROPOSED_FILE_FIELD: + raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}") + args_raw = params.get("arguments", {}) + if not isinstance(args_raw, dict): + raise _RpcError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object") + + file_field = PROPOSED_FILE_FIELD[name] + proposed_file = args_raw.get(file_field) + justification = args_raw.get("justification") + if not isinstance(proposed_file, str): + raise _RpcError( + ERR_INVALID_PARAMS, + f"{name}: '{file_field}' is required and must be a string", + ) + if not isinstance(justification, str) or not justification.strip(): + raise _RpcError( + ERR_INVALID_PARAMS, + f"{name}: 'justification' is required and must be a non-empty string", + ) + + validate_proposed_file(name, proposed_file) + + proposal = _sv.Proposal.new( + bottle_slug=config.bottle_slug, + tool=name, + proposed_file=proposed_file, + justification=justification, + current_file_hash=_sv.sha256_hex(proposed_file), + ) + _sv.write_proposal(config.queue_dir, proposal) + sys.stderr.write( + f"supervise: queued proposal {proposal.id} ({name}) " + f"for bottle {config.bottle_slug}; waiting for operator...\n" + ) + sys.stderr.flush() + response = _sv.wait_for_response(config.queue_dir, proposal.id) + _sv.archive_proposal(config.queue_dir, proposal.id) + + text = format_response_text(response) + return { + "content": [{"type": "text", "text": text}], + "isError": response.status == _sv.STATUS_REJECTED, + } + + +def format_response_text(response: "_sv.Response") -> str: + """Pretty-print a Response for the tool's text content. The agent + reads the text and decides whether to retry / give up / surface.""" + lines = [f"status: {response.status}"] + if response.notes: + lines.append(f"notes: {response.notes}") + if response.status == _sv.STATUS_MODIFIED and response.final_file is not None: + lines.append("the operator modified your proposal before approving; " + "the final config is now what's been applied") + return "\n".join(lines) + + +# --- HTTP transport -------------------------------------------------------- + + +# Max request body the server accepts. Generous because Dockerfile +# proposals can be a few KB; routes.json is small. 1 MB is well above +# any realistic config file. +MAX_BODY_BYTES = 1 * 1024 * 1024 + + +class MCPHandler(http.server.BaseHTTPRequestHandler): + """Per-request JSON-RPC handler. Each tools/call may block for + a long time; the ThreadingMixIn on the server class ensures + other requests can be processed concurrently.""" + + server_version = f"{SERVER_NAME}/{SERVER_VERSION}" + + def log_message(self, format: str, *args: typing.Any) -> None: + if os.environ.get("SUPERVISE_DEBUG"): + super().log_message(format, *args) + + def do_GET(self) -> None: + # /health for liveness; everything else 405. POST is the only + # method MCP needs. + if self.path == "/health": + self._write_text(200, "ok\n") + return + self._write_text(405, "use POST for MCP requests\n") + + def do_POST(self) -> None: + length_header = self.headers.get("Content-Length") + if length_header is None: + self._write_text(411, "Content-Length required\n") + return + try: + length = int(length_header) + except ValueError: + self._write_text(400, "invalid Content-Length\n") + return + if length < 0 or length > MAX_BODY_BYTES: + self._write_text(413, "request body too large\n") + return + body = self.rfile.read(length) + + try: + req = parse_jsonrpc(body) + except _RpcError as e: + self._write_jsonrpc(jsonrpc_error(None, e.code, e.message)) + return + + config = typing.cast("MCPServer", self.server).config + + try: + result = self._dispatch(req, config) + except _RpcError as e: + self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message)) + return + except Exception as e: # pragma: no cover — defensive + sys.stderr.write(f"supervise: internal error: {e}\n") + self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error")) + return + + if req.is_notification: + self._write_text(202, "") + return + self._write_jsonrpc(jsonrpc_result(req.id, result)) + + def _dispatch(self, req: JsonRpcRequest, config: ServerConfig) -> object: + method = req.method + if method == "initialize": + return handle_initialize(req.params) + if method == "notifications/initialized": + return None # ack-only + if method == "tools/list": + return handle_tools_list(req.params) + if method == "tools/call": + return handle_tools_call(req.params, config) + raise _RpcError(ERR_METHOD_NOT_FOUND, f"method not found: {method}") + + def _write_jsonrpc(self, body: bytes) -> None: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def _write_text(self, status: int, body: str) -> None: + encoded = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Connection", "close") + self.end_headers() + if encoded: + self.wfile.write(encoded) + + +class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + allow_reuse_address = True + daemon_threads = True + config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path()) + + +# --- Entry point ----------------------------------------------------------- + + +def serve( + *, + bottle_slug: str, + queue_dir: Path, + port: int = _sv.SUPERVISE_PORT, + bind: str = "0.0.0.0", +) -> typing.NoReturn: + queue_dir.mkdir(parents=True, exist_ok=True) + server = MCPServer((bind, port), MCPHandler) + server.config = ServerConfig(bottle_slug=bottle_slug, queue_dir=queue_dir) + sys.stderr.write( + f"supervise listening on {bind}:{port}; " + f"slug={bottle_slug!r}; queue={queue_dir}; " + f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type] + ) + sys.stderr.flush() + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + sys.exit(0) + + +def main(argv: list[str]) -> int: + del argv # config is env-only, matches cred_proxy_server pattern + bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") + if not bottle_slug: + sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n") + return 2 + queue_dir = Path(os.environ.get("SUPERVISE_QUEUE_DIR", _sv.QUEUE_DIR_IN_CONTAINER)) + port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT))) + bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0") + serve(bottle_slug=bottle_slug, queue_dir=queue_dir, port=port, bind=bind) + return 0 # serve() does not return + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py new file mode 100644 index 0000000..2b48db6 --- /dev/null +++ b/tests/unit/test_supervise_server.py @@ -0,0 +1,378 @@ +"""Unit: supervise sidecar MCP server (PRD 0013).""" + +import http.client +import json +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + + +# The server module loads `supervise` via same-directory import inside +# the container (Dockerfile.supervise WORKDIRs into /app). For tests +# we mirror that by injecting claude_bottle/ onto sys.path under the +# bare name `supervise`. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "claude_bottle")) +import supervise as _sv # noqa: E402 + +from claude_bottle import supervise_server # noqa: E402 +from claude_bottle.supervise_server import ( + ERR_INVALID_PARAMS, + ERR_INVALID_REQUEST, + ERR_METHOD_NOT_FOUND, + ERR_PARSE, + MCPHandler, + MCPServer, + PROPOSED_FILE_FIELD, + ServerConfig, + TOOL_DEFINITIONS, + _RpcError, + format_response_text, + handle_initialize, + handle_tools_call, + handle_tools_list, + jsonrpc_error, + jsonrpc_result, + parse_jsonrpc, + serve, + validate_proposed_file, +) + + +# --- Validation ------------------------------------------------------------ + + +class TestValidation(unittest.TestCase): + def test_cred_proxy_block_requires_valid_json(self): + with self.assertRaises(_RpcError) as cm: + validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, "{not json") + self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) + self.assertIn("not valid JSON", cm.exception.message) + + def test_cred_proxy_block_requires_routes_array(self): + with self.assertRaises(_RpcError): + validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, '{"other": []}') + + def test_cred_proxy_block_accepts_valid_routes(self): + validate_proposed_file( + _sv.TOOL_CRED_PROXY_BLOCK, + '{"routes": [{"path": "/x/", "upstream": "https://example.com"}]}', + ) + + def test_pipelock_block_accepts_clean_hostnames(self): + validate_proposed_file( + _sv.TOOL_PIPELOCK_BLOCK, + "api.example.com\n# comment\nfoo.bar.baz\n", + ) + + def test_pipelock_block_rejects_invalid_char(self): + with self.assertRaises(_RpcError): + validate_proposed_file(_sv.TOOL_PIPELOCK_BLOCK, "host with space.com\n") + + def test_capability_block_accepts_anything_nonempty(self): + validate_proposed_file( + _sv.TOOL_CAPABILITY_BLOCK, + "FROM python:3.13\nRUN apk add git\n", + ) + + def test_empty_proposed_file_rejected_for_all_tools(self): + for tool in _sv.TOOLS: + with self.subTest(tool=tool): + with self.assertRaises(_RpcError): + validate_proposed_file(tool, " \n\t") + + +# --- JSON-RPC parsing ------------------------------------------------------ + + +class TestParseJsonRpc(unittest.TestCase): + def test_parses_request_with_id(self): + req = parse_jsonrpc( + b'{"jsonrpc": "2.0", "id": 7, "method": "tools/list", "params": {}}' + ) + self.assertEqual("tools/list", req.method) + self.assertEqual(7, req.id) + self.assertFalse(req.is_notification) + + def test_parses_notification_no_id(self): + req = parse_jsonrpc( + b'{"jsonrpc": "2.0", "method": "notifications/initialized"}' + ) + self.assertTrue(req.is_notification) + self.assertIsNone(req.id) + + def test_rejects_bad_json(self): + with self.assertRaises(_RpcError) as cm: + parse_jsonrpc(b"{not json") + self.assertEqual(ERR_PARSE, cm.exception.code) + + def test_rejects_wrong_jsonrpc_version(self): + with self.assertRaises(_RpcError) as cm: + parse_jsonrpc(b'{"jsonrpc": "1.0", "method": "x"}') + self.assertEqual(ERR_INVALID_REQUEST, cm.exception.code) + + def test_rejects_missing_method(self): + with self.assertRaises(_RpcError): + parse_jsonrpc(b'{"jsonrpc": "2.0"}') + + def test_treats_null_id_as_request(self): + # JSON-RPC spec: id can be null for a request (just discouraged). + req = parse_jsonrpc(b'{"jsonrpc": "2.0", "id": null, "method": "x"}') + self.assertFalse(req.is_notification) + self.assertIsNone(req.id) + + +# --- JSON-RPC response framing -------------------------------------------- + + +class TestJsonRpcFraming(unittest.TestCase): + def test_result_envelope(self): + body = jsonrpc_result(1, {"ok": True}) + decoded = json.loads(body) + self.assertEqual({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}, decoded) + + def test_error_envelope(self): + body = jsonrpc_error(2, -32601, "method not found: foo") + decoded = json.loads(body) + self.assertEqual( + {"jsonrpc": "2.0", "id": 2, + "error": {"code": -32601, "message": "method not found: foo"}}, + decoded, + ) + + +# --- MCP handlers ---------------------------------------------------------- + + +class TestHandleInitialize(unittest.TestCase): + def test_returns_protocol_version_and_caps(self): + result = handle_initialize({}) + self.assertEqual("2024-11-05", result["protocolVersion"]) + self.assertIn("tools", result["capabilities"]) # type: ignore[index] + self.assertEqual( + "claude-bottle-supervise", + result["serverInfo"]["name"], # type: ignore[index] + ) + + +class TestHandleToolsList(unittest.TestCase): + def test_returns_three_tools(self): + result = handle_tools_list({}) + names = [t["name"] for t in result["tools"]] # type: ignore[index] + self.assertEqual( + sorted([ + _sv.TOOL_CRED_PROXY_BLOCK, + _sv.TOOL_PIPELOCK_BLOCK, + _sv.TOOL_CAPABILITY_BLOCK, + ]), + sorted(names), + ) + + def test_each_tool_has_inputSchema_with_two_required_fields(self): + for tool in TOOL_DEFINITIONS: + with self.subTest(name=tool["name"]): + schema = tool["inputSchema"] + self.assertEqual("object", schema["type"]) # type: ignore[index] + required = schema["required"] # type: ignore[index] + self.assertEqual(2, len(required)) + self.assertIn("justification", required) + self.assertIn(PROPOSED_FILE_FIELD[tool["name"]], required) # type: ignore[index] + + +class TestHandleToolsCall(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.") + self.queue_dir = Path(self._tmp.name) + self.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir) + + def tearDown(self): + self._tmp.cleanup() + + def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: + """Background thread: poll the queue for a fresh proposal, write a + matching response. Returns the thread so the test can join it.""" + def runner(): + for _ in range(200): + pending = _sv.list_pending_proposals(self.queue_dir) + if pending: + p = pending[0] + _sv.write_response(self.queue_dir, _sv.Response( + proposal_id=p.id, status=status, notes=notes, + )) + return + time.sleep(0.01) + + t = threading.Thread(target=runner) + t.start() + return t + + def test_call_round_trips_through_queue(self): + responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="lgtm") + try: + result = handle_tools_call( + { + "name": _sv.TOOL_CRED_PROXY_BLOCK, + "arguments": { + "routes": '{"routes": []}', + "justification": "need a route", + }, + }, + self.config, + ) + finally: + responder.join() + self.assertFalse(result["isError"]) # type: ignore[index] + text = result["content"][0]["text"] # type: ignore[index] + self.assertIn("status: approved", text) + self.assertIn("notes: lgtm", text) + + def test_rejected_response_sets_isError(self): + responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope") + try: + result = handle_tools_call( + { + "name": _sv.TOOL_PIPELOCK_BLOCK, + "arguments": { + "allowlist": "example.com\n", + "justification": "needed for tests", + }, + }, + self.config, + ) + finally: + responder.join() + self.assertTrue(result["isError"]) # type: ignore[index] + + def test_invalid_tool_name_raises(self): + with self.assertRaises(_RpcError) as cm: + handle_tools_call( + {"name": "not-a-tool", "arguments": {}}, + self.config, + ) + self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) + + def test_missing_justification_raises(self): + with self.assertRaises(_RpcError): + handle_tools_call( + { + "name": _sv.TOOL_CRED_PROXY_BLOCK, + "arguments": {"routes": '{"routes": []}'}, + }, + self.config, + ) + + def test_archives_proposal_after_response(self): + responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED) + try: + handle_tools_call( + { + "name": _sv.TOOL_CRED_PROXY_BLOCK, + "arguments": { + "routes": '{"routes": []}', + "justification": "x", + }, + }, + self.config, + ) + finally: + responder.join() + # No pending proposals left after archive. + self.assertEqual([], _sv.list_pending_proposals(self.queue_dir)) + # Both files moved to processed/. + processed = list((self.queue_dir / "processed").glob("*.json")) + self.assertEqual(2, len(processed)) + + +# --- Response text formatting --------------------------------------------- + + +class TestFormatResponseText(unittest.TestCase): + def test_approved_with_notes(self): + text = format_response_text(_sv.Response( + proposal_id="x", status=_sv.STATUS_APPROVED, notes="retry now", + )) + self.assertIn("status: approved", text) + self.assertIn("notes: retry now", text) + + def test_modified_includes_modified_hint(self): + text = format_response_text(_sv.Response( + proposal_id="x", status=_sv.STATUS_MODIFIED, notes="", + final_file="modified content", + )) + self.assertIn("status: modified", text) + self.assertIn("the operator modified", text.lower()) + + +# --- End-to-end HTTP sanity ------------------------------------------------ + + +class TestHttpEndToEnd(unittest.TestCase): + """Spin up the server on a random port and round-trip a tools/list + over real HTTP. Catches the JSON-RPC plumbing if it ever drifts + from the unit-level handlers.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.") + self.queue_dir = Path(self._tmp.name) + # Pick a random port by binding to :0 first. + import socket + s = socket.socket() + s.bind(("127.0.0.1", 0)) + self.port = s.getsockname()[1] + s.close() + self.server = MCPServer(("127.0.0.1", self.port), MCPHandler) + self.server.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir) + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True, + ) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + self._tmp.cleanup() + + def _post_jsonrpc(self, body: dict[str, object]) -> dict[str, object]: + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + try: + payload = json.dumps(body).encode("utf-8") + conn.request("POST", "/", body=payload, + headers={"Content-Type": "application/json", + "Content-Length": str(len(payload))}) + resp = conn.getresponse() + data = resp.read() + return json.loads(data) + finally: + conn.close() + + def test_tools_list_over_http(self): + result = self._post_jsonrpc( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + self.assertEqual("2.0", result["jsonrpc"]) + self.assertEqual(1, result["id"]) + names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index] + self.assertIn(_sv.TOOL_CRED_PROXY_BLOCK, names) + + def test_unknown_method_returns_jsonrpc_error(self): + result = self._post_jsonrpc( + {"jsonrpc": "2.0", "id": 2, "method": "does/not/exist"}, + ) + self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index] + + def test_health_endpoint(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + try: + conn.request("GET", "/health") + resp = conn.getresponse() + self.assertEqual(200, resp.status) + self.assertEqual(b"ok\n", resp.read()) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main()