Files
firmware/mcp-server/tests/tool_coverage.py
Ben Meadors de23e5199d Add USB camera and uhubctl support for new test suite. Also included some bug fixes (#10204)
* Add USB camera and uhubctl support for new test suite. Also added some bug fixes

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor test messages for clarity and consistency in regex tests

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-19 06:51:41 -05:00

153 lines
6.7 KiB
Python

"""Tool-surface coverage: track which MCP tools the test suite actually exercises.
This is NOT line coverage (that's `coverage.py`). This measures which of the
38 public MCP tools in `meshtastic_mcp.server` got invoked during a pytest
run — a quick signal for "where are the test-coverage gaps".
Approach: introspect `meshtastic_mcp.server.app` for registered tools, find
the underlying handler functions in their source modules, and wrap each with
a counting shim. At session end, emit `tool_coverage.json` mapping each tool
name to its call count. Tools never called show `count=0`.
"""
from __future__ import annotations
import json
import pathlib
from typing import Any
_counts: dict[str, int] = {}
_installed = False
def _bump(name: str) -> None:
_counts[name] = _counts.get(name, 0) + 1
def _wrap(module: Any, attr: str, tool_name: str) -> None:
original = getattr(module, attr, None)
if original is None or not callable(original):
return
def wrapper(*args: Any, **kwargs: Any) -> Any:
_bump(tool_name)
return original(*args, **kwargs)
wrapper.__wrapped__ = original # type: ignore[attr-defined]
wrapper.__name__ = attr
wrapper.__doc__ = original.__doc__
setattr(module, attr, wrapper)
# Mapping: MCP tool name → (module, function name). Mirrors the wiring in
# `meshtastic_mcp.server`. Keep synchronized manually — adding a tool without
# updating this map means it shows as count=0 in reports even if exercised.
_TOOL_MAP: dict[str, tuple[str, str]] = {
# Discovery & metadata
"list_devices": ("meshtastic_mcp.devices", "list_devices"),
"list_boards": ("meshtastic_mcp.boards", "list_boards"),
"get_board": ("meshtastic_mcp.boards", "get_board"),
# Build & flash
"build": ("meshtastic_mcp.flash", "build"),
"clean": ("meshtastic_mcp.flash", "clean"),
"pio_flash": ("meshtastic_mcp.flash", "flash"),
"erase_and_flash": ("meshtastic_mcp.flash", "erase_and_flash"),
"update_flash": ("meshtastic_mcp.flash", "update_flash"),
"touch_1200bps": ("meshtastic_mcp.flash", "touch_1200bps"),
# Serial log sessions — module-level functions on serial_session
"serial_open": ("meshtastic_mcp.serial_session", "open_session"),
"serial_read": ("meshtastic_mcp.serial_session", "read_session"),
"serial_list": ("meshtastic_mcp.registry", "all_sessions"),
"serial_close": ("meshtastic_mcp.serial_session", "close_session"),
# Device reads
"device_info": ("meshtastic_mcp.info", "device_info"),
"list_nodes": ("meshtastic_mcp.info", "list_nodes"),
# Device writes
"set_owner": ("meshtastic_mcp.admin", "set_owner"),
"get_config": ("meshtastic_mcp.admin", "get_config"),
"set_config": ("meshtastic_mcp.admin", "set_config"),
"get_channel_url": ("meshtastic_mcp.admin", "get_channel_url"),
"set_channel_url": ("meshtastic_mcp.admin", "set_channel_url"),
"set_debug_log_api": ("meshtastic_mcp.admin", "set_debug_log_api"),
"send_text": ("meshtastic_mcp.admin", "send_text"),
"reboot": ("meshtastic_mcp.admin", "reboot"),
"shutdown": ("meshtastic_mcp.admin", "shutdown"),
"factory_reset": ("meshtastic_mcp.admin", "factory_reset"),
"send_input_event": ("meshtastic_mcp.admin", "send_input_event"),
# `capture_screen` in server.py calls camera.get_camera — instrument that.
"capture_screen": ("meshtastic_mcp.camera", "get_camera"),
# USB power control via uhubctl.
"uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"),
"uhubctl_power": ("meshtastic_mcp.uhubctl", "power_on"),
"uhubctl_cycle": ("meshtastic_mcp.uhubctl", "cycle"),
# USERPREFS
"userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"),
"userprefs_get": ("meshtastic_mcp.userprefs", "read_state"),
"userprefs_set": ("meshtastic_mcp.userprefs", "merge_active"),
"userprefs_reset": ("meshtastic_mcp.userprefs", "reset"),
"userprefs_testing_profile": ("meshtastic_mcp.userprefs", "build_testing_profile"),
# Vendor hardware tools
"esptool_chip_info": ("meshtastic_mcp.hw_tools", "esptool_chip_info"),
"esptool_erase_flash": ("meshtastic_mcp.hw_tools", "esptool_erase_flash"),
"esptool_raw": ("meshtastic_mcp.hw_tools", "esptool_raw"),
"nrfutil_dfu": ("meshtastic_mcp.hw_tools", "nrfutil_dfu"),
"nrfutil_raw": ("meshtastic_mcp.hw_tools", "nrfutil_raw"),
"picotool_info": ("meshtastic_mcp.hw_tools", "picotool_info"),
"picotool_load": ("meshtastic_mcp.hw_tools", "picotool_load"),
"picotool_raw": ("meshtastic_mcp.hw_tools", "picotool_raw"),
}
def install() -> None:
"""Wrap every mapped tool function with the counting shim. Idempotent."""
global _installed
if _installed:
return
import importlib
# Whitelist the exact module paths this function is ever allowed to
# import. `module_path` below is iterated from `_TOOL_MAP` — a file-
# local, hardcoded dict literal — but a static whitelist makes the
# "no untrusted input here" invariant legible to reviewers and to
# the Semgrep `non-literal-import` audit rule.
_allowed_modules = frozenset(path for path, _attr in _TOOL_MAP.values())
for tool_name, (module_path, attr) in _TOOL_MAP.items():
# Defense in depth: if someone mutates `_TOOL_MAP` at runtime
# (shouldn't happen; it's module-level) the whitelist catches it.
# `module_path` is a key from the hardcoded `_TOOL_MAP` dict and
# is gated above by membership in `_allowed_modules` (itself
# derived from the same literal values). There is no path for
# untrusted input to reach the `import_module` call below; the
# Semgrep suppression must sit on the line immediately preceding
# the call (multi-line comment blocks between comment and call
# break the rule's scope detection).
if module_path not in _allowed_modules:
continue
try:
# nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
mod = importlib.import_module(module_path)
except ImportError:
continue
_wrap(mod, attr, tool_name)
_counts.setdefault(tool_name, 0)
_installed = True
def write_report(path: pathlib.Path) -> None:
"""Emit `tool_coverage.json` with call counts for every mapped tool."""
exercised = sum(1 for c in _counts.values() if c > 0)
total = len(_counts)
payload = {
"total_tools": total,
"exercised": exercised,
"coverage_pct": round(100.0 * exercised / total, 1) if total else 0.0,
"counts": dict(sorted(_counts.items())),
"unexercised": sorted(k for k, v in _counts.items() if v == 0),
}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def snapshot() -> dict[str, int]:
return dict(_counts)