ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcp_util.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Shared plumbing for the MCP server: subprocess capture, file reads, app discovery.
4
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
9carries a timeout.
10"""
11
12from __future__ import annotations
13
14import subprocess
15import sys
16from pathlib import Path
17from shutil import which as _shutil_which
18
19# Repo root = three parents up from this file (tools/mcp/src/<this>).
20REPO_ROOT = Path(__file__).resolve().parents[3]
21sys.path.insert(0, str(REPO_ROOT / "scripts" / "dev"))
22
23import ra8_apps as _ra8_apps # noqa: E402 # authoritative app catalogue lives in scripts/dev
24
25# Upper bound on captured subprocess output returned to the client, so a
26# runaway build log can never blow up the assistant's context window.
27MAX_OUTPUT_CHARS = 12000
28
29
30# ---------------------------------------------------------------------------
31# Small shared helpers
32# ---------------------------------------------------------------------------
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)
36
37
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:
41 return text
42 head = "[... output truncated, showing the last bytes ...]\n"
43 return head + text[-(limit - len(head)) :]
44
45
46def run_command(argv: list[str], timeout: int) -> str:
47 """Run ``argv`` from the repo root and return a formatted result block.
48
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.
52 """
53 pretty = " ".join(argv)
54 try:
55 proc = subprocess.run( # noqa: S603 # trusted: fixed just/tool argv from internal registry
56 argv,
57 cwd=str(REPO_ROOT),
58 stdout=subprocess.PIPE,
59 stderr=subprocess.STDOUT,
60 text=True,
61 timeout=timeout,
62 check=False,
63 )
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"
71
72
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")
76 if max_lines > 0:
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"
81 return text
82
83
84# ---------------------------------------------------------------------------
85# Firmware app catalogue (adapts the authoritative scripts/dev/ra8_apps.py data)
86# ---------------------------------------------------------------------------
87def _mcp_app(app: dict[str, str]) -> dict[str, str]:
88 """Adapt one authoritative app record to the MCP field names."""
89 return {
90 "id": _ra8_apps.app_id(app),
91 "name": app["name"],
92 "group": app["group"],
93 "dir": app["rel_dir"],
94 "description": app["desc"],
95 "toolchain": app["toolchain"],
96 }
97
98
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()]
102
103
104def require_app(name: str) -> dict[str, str]:
105 """Resolve a firmware app name or raise ``ValueError`` if it is unknown.
106
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.
110 """
111 resolved = _ra8_apps.find_app(name)
112 app = _mcp_app(resolved) if resolved is not None else None
113 if app is 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)
117 return app
118
119
120def which(name: str) -> bool:
121 """Return True if ``name`` resolves on PATH."""
122 return _shutil_which(name) is not None