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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<slug>/
|
||||
# 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"]
|
||||
@@ -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/<slug>/).
|
||||
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:<port>/<path>/`). 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))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user