Skip to content

Commit 8f6b4ac

Browse files
committed
feat(error-shim): degraded-mode MCP server for setup/bootstrap failures
1 parent b582636 commit 8f6b4ac

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

src/codegraphagent/error_shim.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Degraded-mode MCP server.
2+
3+
When bootstrap or layout setup fails, the main shim hands control here. This
4+
server still satisfies the MCP `initialize` + `tools/list` + `tools/call`
5+
handshake — but the only tool it offers returns the underlying error so the
6+
agent (and the user reading the log) can see exactly what went wrong.
7+
8+
Implemented in pure stdlib to keep it functional even if fastmcp's import
9+
itself is what failed.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import sys
16+
from typing import BinaryIO
17+
18+
from codegraphagent.errors import (
19+
BootstrapError,
20+
CodeGraphAgentError,
21+
LayoutConflictError,
22+
)
23+
24+
25+
def _tool_for(exc: CodeGraphAgentError) -> dict:
26+
"""Return the synthetic tool descriptor matching the given error type."""
27+
if isinstance(exc, BootstrapError):
28+
return {
29+
"name": "codegraphagent_bootstrap_error",
30+
"description": (
31+
"CodeGraphAgent failed to download or verify the engine bundle. "
32+
"Call this tool to see the underlying error."
33+
),
34+
"inputSchema": {"type": "object", "properties": {}, "required": []},
35+
}
36+
if isinstance(exc, LayoutConflictError):
37+
return {
38+
"name": "codegraphagent_setup_error",
39+
"description": (
40+
"CodeGraphAgent could not set up the .biorouter/codegraph "
41+
"symlink. Call this tool to see the remediation steps."
42+
),
43+
"inputSchema": {"type": "object", "properties": {}, "required": []},
44+
}
45+
return {
46+
"name": "codegraphagent_error",
47+
"description": "CodeGraphAgent encountered an internal error.",
48+
"inputSchema": {"type": "object", "properties": {}, "required": []},
49+
}
50+
51+
52+
def _error_text(exc: CodeGraphAgentError) -> str:
53+
parts = [f"CodeGraphAgent error: {exc}"]
54+
if isinstance(exc, BootstrapError):
55+
if exc.url:
56+
parts.append(f"URL: {exc.url}")
57+
if exc.expected_sha and exc.observed_sha:
58+
parts.append(f"Expected SHA-256: {exc.expected_sha}")
59+
parts.append(f"Observed SHA-256: {exc.observed_sha}")
60+
parts.append(
61+
"Recovery: retry; or set CODEGRAPH_ENGINE_PATH to a pre-downloaded "
62+
"bundle; or pin CODEGRAPH_ENGINE_VERSION to a different release."
63+
)
64+
elif isinstance(exc, LayoutConflictError):
65+
parts.append(f"Path: {exc.path}")
66+
parts.append(
67+
"Recovery: rename or remove the conflicting .codegraph directory, "
68+
"then restart the CodeGraphAgent extension."
69+
)
70+
return "\n".join(parts)
71+
72+
73+
def serve(
74+
exc: CodeGraphAgentError,
75+
*,
76+
stdin: BinaryIO | None = None,
77+
stdout: BinaryIO | None = None,
78+
) -> None:
79+
"""Serve MCP requests in degraded mode until `shutdown` is received."""
80+
inp = stdin or sys.stdin.buffer
81+
out = stdout or sys.stdout.buffer
82+
tool = _tool_for(exc)
83+
84+
while True:
85+
line = inp.readline()
86+
if not line:
87+
break
88+
try:
89+
req = json.loads(line)
90+
except json.JSONDecodeError:
91+
continue
92+
93+
method = req.get("method")
94+
req_id = req.get("id")
95+
96+
if method == "initialize":
97+
resp = {
98+
"jsonrpc": "2.0",
99+
"id": req_id,
100+
"result": {
101+
"protocolVersion": req.get("params", {}).get(
102+
"protocolVersion", "2024-11-05"
103+
),
104+
"capabilities": {"tools": {}},
105+
"serverInfo": {"name": "codegraphagent (degraded)", "version": "0.1.0"},
106+
},
107+
}
108+
elif method == "tools/list":
109+
resp = {
110+
"jsonrpc": "2.0",
111+
"id": req_id,
112+
"result": {"tools": [tool]},
113+
}
114+
elif method == "tools/call":
115+
resp = {
116+
"jsonrpc": "2.0",
117+
"id": req_id,
118+
"result": {
119+
"content": [{"type": "text", "text": _error_text(exc)}],
120+
"isError": True,
121+
},
122+
}
123+
elif method == "shutdown":
124+
resp = {"jsonrpc": "2.0", "id": req_id, "result": None}
125+
out.write((json.dumps(resp) + "\n").encode())
126+
out.flush()
127+
break
128+
else:
129+
resp = {
130+
"jsonrpc": "2.0",
131+
"id": req_id,
132+
"error": {"code": -32601, "message": f"Method not found: {method}"},
133+
}
134+
out.write((json.dumps(resp) + "\n").encode())
135+
out.flush()

tests/test_error_shim.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""error_shim — minimal MCP server that surfaces a single error tool when the
2+
shim couldn't reach the engine."""
3+
4+
from __future__ import annotations
5+
6+
import io
7+
import json
8+
import threading
9+
10+
from codegraphagent import error_shim
11+
from codegraphagent.errors import BootstrapError, LayoutConflictError
12+
13+
14+
def _send(stream: io.BytesIO, frame: dict) -> None:
15+
stream.write((json.dumps(frame) + "\n").encode())
16+
17+
18+
def test_error_shim_lists_bootstrap_error_tool():
19+
parent_in = io.BytesIO()
20+
_send(parent_in, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
21+
_send(parent_in, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
22+
_send(parent_in, {"jsonrpc": "2.0", "id": 3, "method": "shutdown"})
23+
parent_in.seek(0)
24+
parent_out = io.BytesIO()
25+
26+
exc = BootstrapError(
27+
"boom",
28+
url="https://example.com/x.tar.gz",
29+
expected_sha="abc",
30+
observed_sha="def",
31+
)
32+
error_shim.serve(exc, stdin=parent_in, stdout=parent_out)
33+
34+
frames = [json.loads(l) for l in parent_out.getvalue().decode().splitlines() if l]
35+
# frames: initialize response, tools/list response, shutdown response
36+
assert len(frames) == 3
37+
tools_list = frames[1]["result"]["tools"]
38+
assert any(t["name"] == "codegraphagent_bootstrap_error" for t in tools_list)
39+
40+
41+
def test_error_shim_call_returns_error_details():
42+
parent_in = io.BytesIO()
43+
_send(parent_in, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
44+
_send(parent_in, {
45+
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
46+
"params": {"name": "codegraphagent_bootstrap_error", "arguments": {}},
47+
})
48+
_send(parent_in, {"jsonrpc": "2.0", "id": 3, "method": "shutdown"})
49+
parent_in.seek(0)
50+
parent_out = io.BytesIO()
51+
52+
exc = BootstrapError(
53+
"download failed",
54+
url="https://example.com/x.tar.gz",
55+
)
56+
error_shim.serve(exc, stdin=parent_in, stdout=parent_out)
57+
58+
frames = [json.loads(l) for l in parent_out.getvalue().decode().splitlines() if l]
59+
call_result = frames[1]["result"]
60+
text = call_result["content"][0]["text"]
61+
assert "download failed" in text
62+
assert "https://example.com/x.tar.gz" in text
63+
64+
65+
def test_error_shim_layout_conflict():
66+
parent_in = io.BytesIO()
67+
_send(parent_in, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
68+
_send(parent_in, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
69+
_send(parent_in, {"jsonrpc": "2.0", "id": 3, "method": "shutdown"})
70+
parent_in.seek(0)
71+
parent_out = io.BytesIO()
72+
73+
exc = LayoutConflictError("path exists", path="/tmp/foo/.codegraph")
74+
error_shim.serve(exc, stdin=parent_in, stdout=parent_out)
75+
76+
frames = [json.loads(l) for l in parent_out.getvalue().decode().splitlines() if l]
77+
tools_list = frames[1]["result"]["tools"]
78+
assert any(t["name"] == "codegraphagent_setup_error" for t in tools_list)

0 commit comments

Comments
 (0)