ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcp_tools.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The tools this server exposes, and the JSON schema that advertises them.
4
5One function per tool, plus the :data:`TOOLS` table mapping the advertised
6name to its schema and handler. Implementation and schema live in the same
7file on purpose: a tool whose schema drifts from its handler is invisible to
8the server -- the client is told about a parameter nothing reads, or a
9parameter the handler requires is never sent -- and nothing else in the
10protocol layer can detect that.
11"""
12
13from __future__ import annotations
14
15import subprocess
16from typing import Any
17
18from mcp_util import (
19 REPO_ROOT,
20 discover_apps,
21 read_text,
22 require_app,
23 run_command,
24 which,
25)
26
27# MCP clients commonly start from a GUI with a reduced PATH. Enter Just through
28# the repository resolver, and use the platform shell's absolute path so the
29# resolver remains reachable even when PATH contains no command directories.
30_JUST_LAUNCHER = ("/bin/bash", "-p", "scripts/dev/run_just.sh")
31
32
33def _just_argv(*args: str) -> list[str]:
34 """Return a Just command routed through the repository-owned resolver."""
35 return [*_JUST_LAUNCHER, *args]
36
37
38# ---------------------------------------------------------------------------
39# Tool implementations -- each returns plain text shown to the assistant
40# ---------------------------------------------------------------------------
41def tool_list_apps(args: dict[str, Any]) -> str:
42 """List discovered firmware apps, optionally filtered by a substring."""
43 needle = str(args.get("filter", "")).lower()
44 apps = discover_apps()
45 rows = []
46 for app in apps:
47 app_identifier = app["id"]
48 hay = f"{app_identifier} {app['group']} {app['description']}".lower()
49 if needle and needle not in hay:
50 continue
51 rows.append(f" {app['group']:<28} {app_identifier:<60} {app['description']}")
52 header = f"{len(rows)} firmware app(s)" + (f" matching '{needle}'" if needle else "")
53 body = "\n".join(rows) if rows else " (none matched)"
54 return (
55 f"{header}\n"
56 " build: just apps::build <app> | flash: just apps::hardware::flash <app> | "
57 "emulate: just apps::emulator::run <app>\n\n"
58 f"{' TIER/GROUP':<30} {'APP IDENTIFIER':<60} DESCRIPTION\n{body}\n"
59 )
60
61
62def tool_app_info(args: dict[str, Any]) -> str:
63 """Show details for one firmware app: location, description, boot files, README."""
64 name = str(args.get("app", "")).strip()
65 app = require_app(name)
66 app_dir = REPO_ROOT / app["dir"]
67 boot_files = [
68 f
69 for f in (
70 "src/main.c",
71 "src/cpu1_main.c",
72 "src/ns_main.c",
73 "src/vector_table.c",
74 "src/system_init.c",
75 "src/secure_exception.c",
76 "src/trustzone_init.c",
77 "inc/trustzone_init.h",
78 "linker_script.ld",
79 "linker_script_cpu1.ld",
80 "ns_image.ld",
81 "ns_image_xip.ld",
82 "payload.ld",
83 "CMakeLists.txt",
84 "README.md",
85 )
86 if (app_dir / f).is_file()
87 ]
88 out = [
89 f"app: {app['id']}",
90 f"group/tier: {app['group']}",
91 f"directory: {app['dir']}",
92 f"description: {app['description'] or '(none)'}",
93 f"files: {', '.join(boot_files)}",
94 "",
95 "build: just apps::build " + app["id"],
96 "flash: just apps::hardware::flash " + app["id"] + " (local J-Link)",
97 "run on the emulator: just apps::emulator::run " + app["id"] + " (tools/ra8_emulator)",
98 ]
99 readme = app_dir / "README.md"
100 if readme.is_file():
101 out += ["", "--- README.md (first 60 lines) ---", read_text(readme, 60)]
102 return "\n".join(out)
103
104
105def tool_search_code(args: dict[str, Any]) -> str:
106 """Search first-party source with ripgrep (falls back to grep -r)."""
107 pattern = str(args.get("pattern", "")).strip()
108 if not pattern:
109 msg = "pattern is required"
110 raise ValueError(msg)
111 glob = str(args.get("glob", "")).strip()
112 max_results = int(args.get("max_results", 80))
113 if which("rg"):
114 argv = [
115 "rg",
116 "--line-number",
117 "--no-heading",
118 "--max-count",
119 "5",
120 "-g",
121 "!third_party",
122 "-g",
123 "!build",
124 ]
125 if glob:
126 argv += ["-g", glob]
127 argv += [
128 "--",
129 pattern,
130 "apps",
131 "libs",
132 "examples",
133 "tests",
134 "port",
135 "scripts",
136 "tools",
137 "docs",
138 ]
139 else:
140 argv = [
141 "grep",
142 "-rnI",
143 "--exclude-dir=third_party",
144 "--exclude-dir=build",
145 pattern,
146 "apps",
147 "libs",
148 "examples",
149 "tests",
150 "port",
151 "scripts",
152 "tools",
153 "docs",
154 ]
155 result = run_command(argv, timeout=30)
156 lines = result.splitlines()
157 if len(lines) > max_results + 3:
158 lines = [*lines[: max_results + 3], f"[... capped at {max_results} hits ...]"]
159 return "\n".join(lines)
160
161
162def tool_repo_overview(_args: dict[str, Any]) -> str:
163 """Summarise the target hardware, key commands, and repo layout."""
164 apps = discover_apps()
165 return (
166 "ra8-firmware -- bare-metal RA8D2 firmware (hand-written HAL, CMake + "
167 "arm-none-eabi-gcc).\n\n"
168 "TARGET: Renesas R7KA8D2KFLCAC -- Cortex-M85 @ 1 GHz (+ Helium) primary, "
169 "Cortex-M33 @ 250 MHz secondary; 1 MB MRAM, 2 MB SRAM (ECC); EK-RA8D2 board.\n\n"
170 f"APPS: {len(apps)} firmware apps under examples/ and apps/board/. "
171 "Use list_apps / app_info.\n\n"
172 "COMMON WORKFLOWS (also exposed as tools):\n"
173 " just apps::build <app> cross-compile one app\n"
174 " just apps::hardware::flash <app> build + flash via local J-Link\n"
175 " just apps::emulator::run <app> run the real .elf on ra8_emulator\n"
176 " just quality::gate::run unit-tests host unit tests (portable)\n"
177 " just quality::local::mcdc DO-178C Level B MC/DC coverage report\n"
178 " just quality::gate::run <name> run one registered quality gate (portable)\n"
179 " just hil::flash <app> flash the Pi-attached HIL board\n\n"
180 "AUTHORITATIVE DOCS are exposed as MCP resources (CLAUDE.md, STYLE_GUIDE, "
181 "RING_AND_WORLD, CONTRIBUTING, the HUM chapter map, the app catalogue)."
182 )
183
184
185def tool_hum_lookup(args: dict[str, Any]) -> str:
186 """Look up Hardware User's Manual chapter page ranges from the chapter map."""
187 query = str(args.get("query", "")).strip().lower()
188 chapter_map = REPO_ROOT / "docs" / "reference" / "CHAPTER_MAP.md"
189 if not chapter_map.is_file():
190 return "docs/reference/CHAPTER_MAP.md not found in this tree."
191 lines = read_text(chapter_map).splitlines()
192 if not query:
193 return read_text(chapter_map, 60)
194 hits = [ln for ln in lines if query in ln.lower()]
195 if not hits:
196 return f"No chapter-map entry matched '{query}'."
197 return f"CHAPTER_MAP.md entries matching '{query}':\n" + "\n".join(hits[:40])
198
199
200def tool_build_app(args: dict[str, Any]) -> str:
201 """Cross-compile one firmware app via Just and return the log tail."""
202 app = require_app(str(args.get("app", "")).strip())
203 return run_command(_just_argv("apps::build", app["id"]), timeout=900)
204
205
206def tool_run_tests(_args: dict[str, Any]) -> str:
207 """Compile and run unit tests in the host's supported CI environment."""
208 return run_command(_just_argv("quality::gate::run", "unit-tests"), timeout=900)
209
210
211_GATES: dict[str, str] = {
212 "format-check": "format",
213 "tidy": "tidy",
214 "ascii": "ascii",
215 "version": "since",
216 "cppcheck": "cppcheck",
217 "check-annotations": "annotations",
218 "mcdc": "mcdc",
219 "cite-check": "cite-check",
220 "ai-attribution": "no-ai-attribution",
221 "inclusive": "inclusive-terminology",
222}
223
224
225def tool_quality_gate(args: dict[str, Any]) -> str:
226 """Run one named quality gate (formatting, lint, ASCII, citations, ...)."""
227 gate = str(args.get("gate", "")).strip()
228 registered_gate = _GATES.get(gate)
229 if registered_gate is None:
230 msg = f"unknown gate '{gate}'. Choose one of: {', '.join(sorted(_GATES))}"
231 raise ValueError(msg)
232 timeout = 900 if gate in ("tidy", "cppcheck", "mcdc") else 300
233 return run_command(_just_argv("quality::gate::run", registered_gate), timeout=timeout)
234
235
236def tool_coverage(args: dict[str, Any]) -> str:
237 """Run the DO-178C Level B MC/DC coverage build and return its tail.
238
239 MC/DC (modified condition / decision coverage) is the certification bar this
240 tree targets; the tail carries the per-decision summary and any gaps.
241 """
242 del args # no arguments; signature kept uniform for the dispatcher
243 return run_command(_just_argv("quality::gate::run", "mcdc"), timeout=1200)
244
245
246def tool_emu_app(args: dict[str, Any]) -> str:
247 """Boot one app's real ``.elf`` on the ra8_emulator Unicorn emulator -- no hardware.
248
249 Runs ``scripts/emu/smoke.sh <app>`` headlessly: it builds the app + the
250 emulator, runs the firmware, and asserts it reaches its run budget without
251 faulting -- plus its real peripheral UART banner where known. Returns the
252 per-app verdict + log tail. The single way to exercise an app without a board.
253 """
254 app = require_app(str(args.get("app", "")).strip())
255 return run_command(["bash", "scripts/emu/smoke.sh", app["id"]], timeout=900)
256
257
258def _capture(argv: list[str], timeout: int = 20) -> str:
259 """Return the stripped stdout of ``argv`` (or a short error marker)."""
260 try:
261 proc = subprocess.run( # noqa: S603 # trusted: fixed git/gh argv from internal callers
262 argv,
263 cwd=str(REPO_ROOT),
264 stdout=subprocess.PIPE,
265 stderr=subprocess.STDOUT,
266 text=True,
267 timeout=timeout,
268 check=False,
269 )
270 except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
271 return f"[not run: {exc}]"
272 return (proc.stdout or "").strip() or "(no output)"
273
274
275def tool_git_status(args: dict[str, Any]) -> str:
276 """Read-only repo state: current branch, working-tree status, recent commits, open PRs."""
277 del args # no arguments
278 branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"])
279 status = _capture(["git", "status", "--short", "--branch"])
280 commits = _capture(["git", "log", "--oneline", "-8"])
281 prs = (
282 _capture(
283 [
284 "gh",
285 "pr",
286 "list",
287 "--state",
288 "open",
289 "--limit",
290 "10",
291 "--json",
292 "number,title,headRefName",
293 "--template",
294 "{{range .}}#{{.number}} {{.title}} ({{.headRefName}})\n{{end}}",
295 ]
296 )
297 if which("gh")
298 else "(gh not on PATH)"
299 )
300 return (
301 f"branch: {branch}\n\n"
302 f"--- working tree ---\n{status}\n\n"
303 f"--- recent commits ---\n{commits}\n\n"
304 f"--- open PRs ---\n{prs}\n"
305 )
306
307
308def tool_hum_citation(args: dict[str, Any]) -> str:
309 """Emit the HUM register-citation skeleton the project requires above every MMIO access."""
310 chapter = str(args.get("chapter", "")).strip()
311 section = str(args.get("section", "")).strip()
312 page = str(args.get("page", "")).strip()
313 if not chapter:
314 msg = "chapter is required, e.g. '38.2.3' (use hum_lookup to find it)"
315 raise ValueError(msg)
316 sect = f' "{section}"' if section else ' "<section name>"'
317 pg = f" p {page}" if page else " p <NNNN>"
318 return (
319 f"/* HUM Ch {chapter}{sect}{pg} */\n\n"
320 "Place this comment IMMEDIATELY above the register read/write (CLAUDE.md\n"
321 "External HUM Citations policy). Fill the section name + page range from\n"
322 "docs/reference/CHAPTER_MAP.md (the hum_lookup tool / chapter-map resource).\n"
323 "Cite the Hardware User's Manual, never an in-tree file:line."
324 )
325
326
327def tool_flash_app(args: dict[str, Any]) -> str:
328 """Build and flash an app to a locally attached EK-RA8D2 (hardware write).
329
330 Gated: with ``confirm`` false (the default) this only previews the exact
331 command. Pass ``confirm`` true to actually program the board.
332 """
333 app = require_app(str(args.get("app", "")).strip())
334 argv = _just_argv("apps::hardware::flash", app["id"])
335 if not bool(args.get("confirm", False)):
336 return (
337 "[dry run] hardware write withheld. This would program a locally "
338 "attached EK-RA8D2 via J-Link.\n"
339 f"Command: just apps::hardware::flash {app['id']}\n"
340 "Re-call flash_app with confirm=true to execute."
341 )
342 return run_command(argv, timeout=300)
343
344
345_HIL_ACTIONS: dict[str, str] = {
346 "flash": "hil::flash",
347 "recover": "hil::recover",
348 "flash-retry": "hil::flash_retry",
349 "reflash": "hil::reflash",
350 "erase": "hil::erase",
351 "probe": "hil::probe",
352 "dlm-reset": "hil::dlm_reset",
353}
354
355
356def tool_hil(args: dict[str, Any]) -> str:
357 """Drive the Pi-attached hardware-in-the-loop rig (hardware action).
358
359 Gated like flash_app: previews unless ``confirm`` is true. ``flash`` /
360 ``recover`` / ``flash-retry`` / ``reflash`` require an ``app``; ``probe`` /
361 ``erase`` / ``dlm-reset`` do not.
362 """
363 action = str(args.get("action", "")).strip()
364 recipe = _HIL_ACTIONS.get(action)
365 if recipe is None:
366 msg = f"unknown action '{action}'. Choose: {', '.join(sorted(_HIL_ACTIONS))}"
367 raise ValueError(msg)
368 recipe_args = [recipe]
369 if action in ("flash", "recover", "flash-retry", "reflash"):
370 app = require_app(str(args.get("app", "")).strip())
371 recipe_args.append(app["id"])
372 if not bool(args.get("confirm", False)):
373 return (
374 "[dry run] HIL hardware action withheld.\n"
375 f"Command: just {' '.join(recipe_args)}\n"
376 "Re-call hil with confirm=true to execute."
377 )
378 return run_command(_just_argv(*recipe_args), timeout=600)
379
380
381# ---------------------------------------------------------------------------
382# Tool registry -- name, JSON-Schema input, handler. Descriptions are what the
383# assistant reads to decide when to call each tool, so they carry real intent.
384# ---------------------------------------------------------------------------
385def _schema(props: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]:
386 return {"type": "object", "properties": props, "required": required or []}
387
388
389TOOLS: list[dict[str, Any]] = [
390 {
391 "name": "list_apps",
392 "description": "List discovered RA8D2 firmware apps (name, tier/group, "
393 "description). Optional 'filter' substring narrows the list.",
394 "inputSchema": _schema(
395 {"filter": {"type": "string", "description": "case-insensitive substring filter"}}
396 ),
397 "handler": tool_list_apps,
398 },
399 {
400 "name": "app_info",
401 "description": "Details for one firmware app: directory, description, which "
402 "boot files are present, build/flash/emulator commands, README head.",
403 "inputSchema": _schema({"app": {"type": "string", "description": "app name"}}, ["app"]),
404 "handler": tool_app_info,
405 },
406 {
407 "name": "repo_overview",
408 "description": "One-shot orientation: target hardware, app count, the common "
409 "build/test/flash/HIL workflows, and where the docs live.",
410 "inputSchema": _schema({}),
411 "handler": tool_repo_overview,
412 },
413 {
414 "name": "search_code",
415 "description": "Search first-party source (apps, libs, examples, tests, "
416 "port, scripts, tools, docs) for a regex pattern. Skips third_party and build.",
417 "inputSchema": _schema(
418 {
419 "pattern": {"type": "string", "description": "regex to search for"},
420 "glob": {"type": "string", "description": "optional file glob, e.g. *.c"},
421 "max_results": {"type": "integer", "description": "hit cap (default 80)"},
422 },
423 ["pattern"],
424 ),
425 "handler": tool_search_code,
426 },
427 {
428 "name": "hum_lookup",
429 "description": "Look up Hardware User's Manual chapter page ranges from "
430 "docs/reference/CHAPTER_MAP.md (useful for register citations).",
431 "inputSchema": _schema(
432 {"query": {"type": "string", "description": "chapter number or section keyword"}}
433 ),
434 "handler": tool_hum_lookup,
435 },
436 {
437 "name": "build_app",
438 "description": "Cross-compile one firmware app (just apps::build <app>) and return the "
439 "build log tail with the real exit status.",
440 "inputSchema": _schema({"app": {"type": "string", "description": "app name"}}, ["app"]),
441 "handler": tool_build_app,
442 },
443 {
444 "name": "run_tests",
445 "description": (
446 "Host-compile and run the unit-test suite (just quality::gate::run unit-tests)."
447 ),
448 "inputSchema": _schema({}),
449 "handler": tool_run_tests,
450 },
451 {
452 "name": "quality_gate",
453 "description": "Run one named quality gate: format-check, tidy, ascii, "
454 "version, cppcheck, check-annotations, mcdc, cite-check, "
455 "ai-attribution, inclusive.",
456 "inputSchema": _schema({"gate": {"type": "string", "description": "gate name"}}, ["gate"]),
457 "handler": tool_quality_gate,
458 },
459 {
460 "name": "flash_app",
461 "description": "Build and flash an app to a locally attached EK-RA8D2 via "
462 "J-Link. HARDWARE WRITE -- previews unless confirm=true.",
463 "inputSchema": _schema(
464 {
465 "app": {"type": "string", "description": "app name"},
466 "confirm": {"type": "boolean", "description": "true to actually flash"},
467 },
468 ["app"],
469 ),
470 "handler": tool_flash_app,
471 },
472 {
473 "name": "hil",
474 "description": "Drive the Pi-attached HIL rig: action in flash, recover, "
475 "flash-retry, reflash, erase, probe, dlm-reset. HARDWARE -- previews "
476 "unless confirm=true. flash/recover/flash-retry/reflash need an app.",
477 "inputSchema": _schema(
478 {
479 "action": {"type": "string", "description": "HIL action"},
480 "app": {"type": "string", "description": "app name (for flash actions)"},
481 "confirm": {"type": "boolean", "description": "true to actually run"},
482 },
483 ["action"],
484 ),
485 "handler": tool_hil,
486 },
487 {
488 "name": "emu_app",
489 "description": "Boot one app's real .elf on the ra8_emulator Unicorn emulator (no "
490 "hardware): build + run headless, assert it reaches its run budget "
491 "without faulting plus its peripheral UART banner. Returns the verdict.",
492 "inputSchema": _schema({"app": {"type": "string", "description": "app name"}}, ["app"]),
493 "handler": tool_emu_app,
494 },
495 {
496 "name": "coverage",
497 "description": "Run the DO-178C Level B MC/DC coverage gate and return "
498 "the per-decision summary tail.",
499 "inputSchema": _schema({}),
500 "handler": tool_coverage,
501 },
502 {
503 "name": "git_status",
504 "description": "Read-only repo state: current branch, working-tree status, the last "
505 "few commits, and open GitHub PRs.",
506 "inputSchema": _schema({}),
507 "handler": tool_git_status,
508 },
509 {
510 "name": "hum_citation",
511 "description": "Emit the HUM register-citation comment skeleton the project requires "
512 "immediately above every MMIO access. Args: chapter (e.g. 38.2.3), "
513 "optional section + page.",
514 "inputSchema": _schema(
515 {
516 "chapter": {"type": "string", "description": "HUM chapter, e.g. 38.2.3"},
517 "section": {"type": "string", "description": "section name (optional)"},
518 "page": {"type": "string", "description": "page or range, e.g. 2181 (optional)"},
519 },
520 ["chapter"],
521 ),
522 "handler": tool_hum_citation,
523 },
524]
525
526TOOL_INDEX: dict[str, dict[str, Any]] = {t["name"]: t for t in TOOLS}