3"""Shared plumbing for the MCP server: subprocess capture, file reads, app discovery.
5Everything here is deliberately dependency-free -- the server ships with zero
6third-party packages so it runs against a bare ``python3`` -- and everything
7that reaches the outside world is bounded: :data:`MAX_OUTPUT_CHARS` caps what a
8runaway build log can hand back to a client, and :func:`run_command` always
12from __future__
import annotations
16from pathlib
import Path
17from shutil
import which
as _shutil_which
20REPO_ROOT = Path(__file__).resolve().parents[3]
21sys.path.insert(0, str(REPO_ROOT /
"scripts" /
"dev"))
23import ra8_apps
as _ra8_apps
27MAX_OUTPUT_CHARS = 12000
33def log(message: str) ->
None:
34 """Write a diagnostic line to stderr (never stdout, which is the wire)."""
35 print(f
"[ra8d2-mcp] {message}", file=sys.stderr, flush=
True)
38def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
39 """Clamp ``text`` to ``limit`` characters, keeping the tail (most recent)."""
40 if len(text) <= limit:
42 head =
"[... output truncated, showing the last bytes ...]\n"
43 return head + text[-(limit - len(head)) :]
46def run_command(argv: list[str], timeout: int) -> str:
47 """Run ``argv`` from the repo root and return a formatted result block.
49 The combined stdout+stderr is captured and truncated. The returned string
50 leads with the exact command and its exit status so the assistant always
51 sees whether the step actually succeeded.
53 pretty =
" ".join(argv)
55 proc = subprocess.run(
58 stdout=subprocess.PIPE,
59 stderr=subprocess.STDOUT,
64 except FileNotFoundError
as exc:
65 return f
"$ {pretty}\n[not run] executable not found: {exc}"
66 except subprocess.TimeoutExpired:
67 return f
"$ {pretty}\n[timeout] exceeded {timeout}s and was killed"
68 status =
"ok" if proc.returncode == 0
else f
"FAILED (exit {proc.returncode})"
69 body = truncate(proc.stdout
or "")
70 return f
"$ {pretty}\n[status] {status}\n\n{body}".rstrip() +
"\n"
73def read_text(path: Path, max_lines: int = 0) -> str:
74 """Read a UTF-8 text file, optionally clamped to the first ``max_lines``."""
75 text = path.read_text(encoding=
"utf-8", errors=
"replace")
77 lines = text.splitlines()
78 if len(lines) > max_lines:
79 kept =
"\n".join(lines[:max_lines])
80 return f
"{kept}\n[... {len(lines) - max_lines} more lines ...]\n"
87def _mcp_app(app: dict[str, str]) -> dict[str, str]:
88 """Adapt one authoritative app record to the MCP field names."""
90 "id": _ra8_apps.app_id(app),
92 "group": app[
"group"],
93 "dir": app[
"rel_dir"],
94 "description": app[
"desc"],
95 "toolchain": app[
"toolchain"],
99def discover_apps() -> list[dict[str, str]]:
100 """Return every app from the authoritative catalogue without basename loss."""
101 return [_mcp_app(app)
for app
in _ra8_apps.get_apps()]
104def require_app(name: str) -> dict[str, str]:
105 """Resolve a firmware app name or raise ``ValueError`` if it is unknown.
107 Validating against the discovered set keeps an arbitrary string from ever
108 reaching ``just`` as a recipe argument, so a tool call cannot inject a
109 foreign application name.
111 resolved = _ra8_apps.find_app(name)
112 app = _mcp_app(resolved)
if resolved
is not None else None
114 sample =
", ".join(candidate[
"id"]
for candidate
in discover_apps()[:12])
115 msg = f
"unknown app '{name}'. Use the list_apps tool. Examples: {sample} ..."
116 raise ValueError(msg)
120def which(name: str) -> bool:
121 """Return True if ``name`` resolves on PATH."""
122 return _shutil_which(name)
is not None