flashgate 0.4.2__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flashgate-0.4.2 → flashgate-0.5.0}/PKG-INFO +2 -2
- {flashgate-0.4.2 → flashgate-0.5.0}/README.md +1 -1
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/cli.py +3 -1
- flashgate-0.5.0/flashgate/mcp_server.py +351 -0
- flashgate-0.5.0/flashgate/results.py +89 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/PKG-INFO +2 -2
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/SOURCES.txt +2 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/pyproject.toml +1 -1
- {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_cli.py +11 -0
- flashgate-0.5.0/tests/test_mcp.py +197 -0
- flashgate-0.4.2/flashgate/mcp_server.py +0 -221
- {flashgate-0.4.2 → flashgate-0.5.0}/LICENSE +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/__init__.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/__main__.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/board.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/flasher.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/gatestate.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/probes.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/serialmon.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/sttools.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/swdsig.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/dependency_links.txt +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/entry_points.txt +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/requires.txt +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/top_level.txt +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/setup.cfg +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_board.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_gatestate.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_probes.py +0 -0
- {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_swdsig.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -133,7 +133,7 @@ profile the gate stays idle and says so.
|
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
board_info, doctor, build, flash, verify, probe, console_send,
|
|
136
|
-
console_read. Any MCP-capable agent can drive the board directly. mcp 1.x
|
|
136
|
+
console_read. Any MCP-capable agent can drive the board directly. Every tool returns a structured result envelope (status, stable code, summary, CLI exit_code, log) — never a bare log to parse. mcp 1.x
|
|
137
137
|
and 2.x supported.
|
|
138
138
|
|
|
139
139
|
## Demos
|
|
@@ -117,7 +117,7 @@ profile the gate stays idle and says so.
|
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
board_info, doctor, build, flash, verify, probe, console_send,
|
|
120
|
-
console_read. Any MCP-capable agent can drive the board directly. mcp 1.x
|
|
120
|
+
console_read. Any MCP-capable agent can drive the board directly. Every tool returns a structured result envelope (status, stable code, summary, CLI exit_code, log) — never a bare log to parse. mcp 1.x
|
|
121
121
|
and 2.x supported.
|
|
122
122
|
|
|
123
123
|
## Demos
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Exit-code contract (the M3 Stop hook enforces these):
|
|
4
4
|
0 verified | 1 build failed | 2 flash failed | 3 no banner (timeout)
|
|
5
|
-
4 boot error string | 5 git sha
|
|
5
|
+
4 boot error string | 5 identity mismatch (git sha or board name)
|
|
6
|
+
6 environment error (incl. probes required but console unavailable)
|
|
7
|
+
7 functional probe failed
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""flashgate MCP server: expose the hardware gate to any MCP-capable agent.
|
|
2
|
+
|
|
3
|
+
Run via `flashgate-mcp` (stdio transport). Requires the optional extra:
|
|
4
|
+
|
|
5
|
+
pip install "flashgate[mcp]"
|
|
6
|
+
|
|
7
|
+
Wiring (.mcp.json, Claude Code compatible):
|
|
8
|
+
|
|
9
|
+
{"mcpServers": {"flashgate": {
|
|
10
|
+
"command": "flashgate-mcp",
|
|
11
|
+
"args": ["--board", "/path/to/boards/apollo-h743.yaml"]}}}
|
|
12
|
+
|
|
13
|
+
Tools: board_info, doctor, build, flash, verify, probe, console_send,
|
|
14
|
+
console_read.
|
|
15
|
+
|
|
16
|
+
Every tool returns a `flashgate.results.Result` envelope (schema_version,
|
|
17
|
+
status, stable code, summary, CLI exit_code, data, warnings, policy) —
|
|
18
|
+
never a bare log the model has to parse. With mcp 2.x the envelope is
|
|
19
|
+
emitted as MCP structuredContent (registered via structured_output=True)
|
|
20
|
+
plus a JSON text block; on the mcp 1.x fallback the JSON text is still
|
|
21
|
+
produced. The text log of the underlying CLI run travels in data["log"].
|
|
22
|
+
Native ToolAnnotations (readOnly/destructive hints) are attached when the
|
|
23
|
+
installed SDK supports them.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import functools
|
|
29
|
+
import inspect
|
|
30
|
+
import io
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
from contextlib import redirect_stdout
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from mcp.server.mcpserver import MCPServer as _Server # mcp 2.x
|
|
39
|
+
except ImportError:
|
|
40
|
+
try:
|
|
41
|
+
from mcp.server.fastmcp import FastMCP as _Server # mcp 1.x
|
|
42
|
+
except ImportError as exc: # pragma: no cover - friendly extra hint
|
|
43
|
+
raise SystemExit(
|
|
44
|
+
"flashgate MCP server needs the optional dependency: "
|
|
45
|
+
'pip install "flashgate[mcp]"'
|
|
46
|
+
) from exc
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from mcp.types import ToolAnnotations
|
|
50
|
+
except ImportError: # pre-1.9 SDK
|
|
51
|
+
ToolAnnotations = None # type: ignore[assignment]
|
|
52
|
+
|
|
53
|
+
from . import __version__, flasher, probes as probe_mod, serialmon
|
|
54
|
+
from . import results
|
|
55
|
+
from .board import Board, BoardError, default_board_path, load_board
|
|
56
|
+
from . import cli as cli_mod
|
|
57
|
+
|
|
58
|
+
mcp = _Server(f"flashgate {__version__}")
|
|
59
|
+
|
|
60
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
61
|
+
_BOARD_ARG: list[str] = [] # set from argv by main()
|
|
62
|
+
|
|
63
|
+
# Policy metadata (design doc §5.5 risk ladder), carried in every envelope.
|
|
64
|
+
P_READ = {"risk": "R0"} # read-only, no device state change
|
|
65
|
+
P_PROBE = {"risk": "R1"} # drives device state, recoverable
|
|
66
|
+
P_BUILD = {"risk": "R0"} # host-side only
|
|
67
|
+
P_FLASH = {"risk": "R2"} # modifies the device
|
|
68
|
+
P_VERIFY = {"risk": "R2"} # includes a flash
|
|
69
|
+
P_CONSOLE_SEND = {"risk": "R1"} # open-world line into the firmware
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _board(board: str | None = None) -> Board:
|
|
73
|
+
"""Resolve the board profile: tool arg > server --board arg > default."""
|
|
74
|
+
source = board or (_BOARD_ARG[0] if _BOARD_ARG else None)
|
|
75
|
+
path = Path(source) if source else default_board_path()
|
|
76
|
+
if path is None or not Path(path).is_file():
|
|
77
|
+
raise BoardError(f"board profile not found: {source or 'boards/*.yaml'}")
|
|
78
|
+
return load_board(Path(path))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _capture(fn, *args) -> tuple[int, str]:
|
|
82
|
+
"""Run a CLI command function, return (exit code, printed output ANSI-stripped)."""
|
|
83
|
+
buf = io.StringIO()
|
|
84
|
+
with redirect_stdout(buf):
|
|
85
|
+
rc = fn(*args)
|
|
86
|
+
return rc, _ANSI.sub("", buf.getvalue()).strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cli_result(label: str, rc: int, log: str, policy: dict) -> results.Result:
|
|
90
|
+
"""Envelope from a CLI run: the log rides in data['log'], the summary is
|
|
91
|
+
the log's first meaningful line so a model can triage without reading it."""
|
|
92
|
+
first = next((ln for ln in log.splitlines() if ln.strip()), "")
|
|
93
|
+
summary = f"{label}: {first[:140]}" if first else f"{label}: exit {rc}"
|
|
94
|
+
return results.from_exit(rc, summary, log=log, policy=policy)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _serial_error(exc: Exception, port: str | None, policy: dict) -> results.Result:
|
|
98
|
+
return results.failure(
|
|
99
|
+
f"cannot open {port}: {exc}" if port else str(exc),
|
|
100
|
+
code=results.TRANSPORT_ERROR, status="incomplete", policy=policy)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _register(annotations=None, structured: bool = False):
|
|
104
|
+
"""@tool decorator tolerant of mcp 2.x and 1.x: forwards annotations /
|
|
105
|
+
structured_output only when the installed SDK's decorator accepts them."""
|
|
106
|
+
def deco(fn):
|
|
107
|
+
params = inspect.signature(mcp.tool).parameters
|
|
108
|
+
kwargs = {}
|
|
109
|
+
if annotations is not None and "annotations" in params:
|
|
110
|
+
kwargs["annotations"] = annotations
|
|
111
|
+
if structured and "structured_output" in params:
|
|
112
|
+
kwargs["structured_output"] = True
|
|
113
|
+
return mcp.tool(**kwargs)(fn)
|
|
114
|
+
return deco
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ann(**kwargs):
|
|
118
|
+
return ToolAnnotations(**kwargs) if ToolAnnotations is not None else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _safe(policy: dict):
|
|
122
|
+
"""Last-resort guard: an exception escaping a tool must surface as an
|
|
123
|
+
INTERNAL_ERROR envelope, never as an MCP protocol error — the
|
|
124
|
+
envelope contract (design doc §5.1) has no room for bare crashes."""
|
|
125
|
+
def deco(fn):
|
|
126
|
+
@functools.wraps(fn)
|
|
127
|
+
def wrapper(*args, **kwargs):
|
|
128
|
+
try:
|
|
129
|
+
return fn(*args, **kwargs)
|
|
130
|
+
except Exception as exc: # noqa: BLE001 - by design
|
|
131
|
+
return results.failure(
|
|
132
|
+
f"{type(exc).__name__}: {exc}",
|
|
133
|
+
code=results.INTERNAL_ERROR, policy=policy)
|
|
134
|
+
return wrapper
|
|
135
|
+
return deco
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@_register(annotations=_ann(readOnlyHint=True), structured=True)
|
|
139
|
+
@_safe(P_READ)
|
|
140
|
+
def board_info(board: str | None = None) -> results.Result:
|
|
141
|
+
"""Show the active board profile: firmware dir, artifact, watch globs,
|
|
142
|
+
banner contract, and the functional probes it defines."""
|
|
143
|
+
try:
|
|
144
|
+
b = _board(board)
|
|
145
|
+
except BoardError as exc:
|
|
146
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
147
|
+
policy=P_READ)
|
|
148
|
+
probes: list[str] = []
|
|
149
|
+
warnings: list[str] = []
|
|
150
|
+
try:
|
|
151
|
+
probes = list(probe_mod.load_probes(b.yaml_path))
|
|
152
|
+
except (OSError, ValueError) as exc:
|
|
153
|
+
warnings.append(f"probes unloadable: {exc}")
|
|
154
|
+
r = results.ok(
|
|
155
|
+
f"{b.name} ({b.mcu}) — {len(probes)} probe(s) defined",
|
|
156
|
+
data={
|
|
157
|
+
"board": b.name,
|
|
158
|
+
"mcu": b.mcu,
|
|
159
|
+
"description": b.description,
|
|
160
|
+
"firmware_dir": str(b.firmware_dir),
|
|
161
|
+
"artifact": str(b.artifact),
|
|
162
|
+
"flash": f"{b.flash_connect} @ {b.flash_address}",
|
|
163
|
+
"banner": b.banner_regex,
|
|
164
|
+
"gate_watch": list(b.watch_globs),
|
|
165
|
+
"probes": probes,
|
|
166
|
+
},
|
|
167
|
+
policy=P_READ)
|
|
168
|
+
r.warnings.extend(warnings)
|
|
169
|
+
return r
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@_register(annotations=_ann(readOnlyHint=True), structured=True)
|
|
173
|
+
@_safe(P_READ)
|
|
174
|
+
def doctor(board: str | None = None) -> results.Result:
|
|
175
|
+
"""Check hardware prerequisites: ST-Link probe, console serial port,
|
|
176
|
+
toolchain. Run this first when anything else fails (exit code 6)."""
|
|
177
|
+
try:
|
|
178
|
+
rc, log = _capture(cli_mod.cmd_doctor, _board(board))
|
|
179
|
+
except BoardError as exc:
|
|
180
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
181
|
+
policy=P_READ)
|
|
182
|
+
return _cli_result("doctor", rc, log, P_READ)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@_register(annotations=_ann(idempotentHint=True), structured=True)
|
|
186
|
+
@_safe(P_BUILD)
|
|
187
|
+
def build(board: str | None = None) -> results.Result:
|
|
188
|
+
"""Build the firmware (incremental). Fails with code BUILD_FAILED."""
|
|
189
|
+
try:
|
|
190
|
+
rc, log = _capture(cli_mod.cmd_build, _board(board))
|
|
191
|
+
except BoardError as exc:
|
|
192
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
193
|
+
policy=P_BUILD)
|
|
194
|
+
return _cli_result("build", rc, log, P_BUILD)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@_register(annotations=_ann(destructiveHint=True), structured=True)
|
|
198
|
+
@_safe(P_FLASH)
|
|
199
|
+
def flash(board: str | None = None) -> results.Result:
|
|
200
|
+
"""Flash + verify + start via ST-Link (with auto-retry). Destructive:
|
|
201
|
+
replaces the firmware running on the board."""
|
|
202
|
+
try:
|
|
203
|
+
rc, log = _capture(cli_mod.cmd_flash, _board(board))
|
|
204
|
+
except BoardError as exc:
|
|
205
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
206
|
+
policy=P_FLASH)
|
|
207
|
+
return _cli_result("flash", rc, log, P_FLASH)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@_register(annotations=_ann(destructiveHint=True), structured=True)
|
|
211
|
+
@_safe(P_VERIFY)
|
|
212
|
+
def verify(board: str | None = None) -> results.Result:
|
|
213
|
+
"""The full gate: build -> flash -> boot evidence -> identity check ->
|
|
214
|
+
all functional probes. status=succeeded means the BOARD ITSELF confirms
|
|
215
|
+
the firmware works. Failure codes: BUILD_FAILED, FLASH_FAILED,
|
|
216
|
+
BOOT_EVIDENCE_TIMEOUT, BOOT_ERROR, IDENTITY_MISMATCH,
|
|
217
|
+
CAPABILITY_UNAVAILABLE (console needed by probes but unavailable),
|
|
218
|
+
PROBE_FAILED."""
|
|
219
|
+
try:
|
|
220
|
+
rc, log = _capture(cli_mod.cmd_verify, _board(board), ["all"])
|
|
221
|
+
except BoardError as exc:
|
|
222
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
223
|
+
policy=P_VERIFY)
|
|
224
|
+
return _cli_result("verify", rc, log, P_VERIFY)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@_register(annotations=_ann(readOnlyHint=False), structured=True)
|
|
228
|
+
@_safe(P_PROBE)
|
|
229
|
+
def probe(names: list[str] | None = None, board: str | None = None) -> results.Result:
|
|
230
|
+
"""Run functional probes against the ALREADY RUNNING firmware (no
|
|
231
|
+
rebuild/reflash). names=None or [] runs every probe (same semantics as
|
|
232
|
+
the CLI). The probe stage needs the console UART; without it the result
|
|
233
|
+
is incomplete / CAPABILITY_UNAVAILABLE — never a pass."""
|
|
234
|
+
try:
|
|
235
|
+
b = _board(board)
|
|
236
|
+
except BoardError as exc:
|
|
237
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
238
|
+
policy=P_PROBE)
|
|
239
|
+
try:
|
|
240
|
+
available = list(probe_mod.load_probes(b.yaml_path))
|
|
241
|
+
except (OSError, ValueError, probe_mod.ProbeError) as exc:
|
|
242
|
+
return results.failure(f"cannot load probes: {exc}",
|
|
243
|
+
code=results.CAPABILITY_UNAVAILABLE,
|
|
244
|
+
status="incomplete", policy=P_PROBE)
|
|
245
|
+
requested = names or None # [] means "all", like the CLI
|
|
246
|
+
if requested is not None:
|
|
247
|
+
unknown = [n for n in requested if n not in available]
|
|
248
|
+
if unknown:
|
|
249
|
+
return results.failure(
|
|
250
|
+
f"unknown probe {unknown[0]!r}; available: {available}",
|
|
251
|
+
code=results.INVALID_ARGUMENT, policy=P_PROBE)
|
|
252
|
+
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
253
|
+
if port is None:
|
|
254
|
+
return results.failure(
|
|
255
|
+
f"console serial unresolved — {why}",
|
|
256
|
+
code=results.CAPABILITY_UNAVAILABLE, status="incomplete",
|
|
257
|
+
policy=P_PROBE)
|
|
258
|
+
try:
|
|
259
|
+
conn = serialmon.open_flush(port, b.baudrate)
|
|
260
|
+
except Exception as exc: # pyserial SerialException
|
|
261
|
+
return _serial_error(exc, port, P_PROBE)
|
|
262
|
+
try:
|
|
263
|
+
rc, log = _capture(cli_mod._run_probes, b, requested, conn)
|
|
264
|
+
finally:
|
|
265
|
+
conn.close()
|
|
266
|
+
return _cli_result("probe", rc, log, P_PROBE)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@_register(annotations=_ann(readOnlyHint=False, openWorldHint=True), structured=True)
|
|
270
|
+
@_safe(P_CONSOLE_SEND)
|
|
271
|
+
def console_send(line: str, wait_s: float = 1.0, board: str | None = None) -> results.Result:
|
|
272
|
+
"""Send ONE line to the firmware console (e.g. 'led0?' or 'selftest')
|
|
273
|
+
and return the response lines received within wait_s seconds."""
|
|
274
|
+
try:
|
|
275
|
+
b = _board(board)
|
|
276
|
+
except BoardError as exc:
|
|
277
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
278
|
+
policy=P_CONSOLE_SEND)
|
|
279
|
+
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
280
|
+
if port is None:
|
|
281
|
+
return results.failure(
|
|
282
|
+
f"console serial unresolved — {why}",
|
|
283
|
+
code=results.CAPABILITY_UNAVAILABLE, status="incomplete",
|
|
284
|
+
policy=P_CONSOLE_SEND)
|
|
285
|
+
try:
|
|
286
|
+
conn = serialmon.open_flush(port, b.baudrate)
|
|
287
|
+
except Exception as exc: # pyserial SerialException
|
|
288
|
+
return _serial_error(exc, port, P_CONSOLE_SEND)
|
|
289
|
+
try:
|
|
290
|
+
conn.write((line + "\r\n").encode())
|
|
291
|
+
deadline = time.monotonic() + max(0.1, wait_s)
|
|
292
|
+
out = ""
|
|
293
|
+
while time.monotonic() < deadline:
|
|
294
|
+
out += conn.read(256).decode("utf-8", errors="replace")
|
|
295
|
+
finally:
|
|
296
|
+
conn.close()
|
|
297
|
+
r = results.ok(f"sent {line!r}" + (" (no response)" if not out.strip() else ""),
|
|
298
|
+
data={"sent": line, "wait_s": wait_s,
|
|
299
|
+
"response": out.strip()},
|
|
300
|
+
policy=P_CONSOLE_SEND)
|
|
301
|
+
if not out.strip():
|
|
302
|
+
r.warnings.append("no response within wait_s")
|
|
303
|
+
return r
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@_register(annotations=_ann(readOnlyHint=True), structured=True)
|
|
307
|
+
@_safe(P_READ)
|
|
308
|
+
def console_read(seconds: float = 2.0, board: str | None = None) -> results.Result:
|
|
309
|
+
"""Read whatever the firmware prints on the console for N seconds
|
|
310
|
+
(banner, self-test output, fault dumps)."""
|
|
311
|
+
try:
|
|
312
|
+
b = _board(board)
|
|
313
|
+
except BoardError as exc:
|
|
314
|
+
return results.failure(str(exc), code=results.PROFILE_NOT_FOUND,
|
|
315
|
+
policy=P_READ)
|
|
316
|
+
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
317
|
+
if port is None:
|
|
318
|
+
return results.failure(
|
|
319
|
+
f"console serial unresolved — {why}",
|
|
320
|
+
code=results.CAPABILITY_UNAVAILABLE, status="incomplete",
|
|
321
|
+
policy=P_READ)
|
|
322
|
+
try:
|
|
323
|
+
conn = serialmon.open_flush(port, b.baudrate)
|
|
324
|
+
except Exception as exc: # pyserial SerialException
|
|
325
|
+
return _serial_error(exc, port, P_READ)
|
|
326
|
+
try:
|
|
327
|
+
deadline = time.monotonic() + max(0.1, seconds)
|
|
328
|
+
out = ""
|
|
329
|
+
while time.monotonic() < deadline:
|
|
330
|
+
out += conn.read(256).decode("utf-8", errors="replace")
|
|
331
|
+
finally:
|
|
332
|
+
conn.close()
|
|
333
|
+
r = results.ok(f"read {seconds:.1f}s of console output"
|
|
334
|
+
+ (" (console silent)" if not out.strip() else ""),
|
|
335
|
+
data={"text": out.strip()}, policy=P_READ)
|
|
336
|
+
if not out.strip():
|
|
337
|
+
r.warnings.append("console silent for the whole window")
|
|
338
|
+
return r
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main() -> None:
|
|
342
|
+
args = sys.argv[1:]
|
|
343
|
+
if "--board" in args:
|
|
344
|
+
i = args.index("--board")
|
|
345
|
+
if i + 1 < len(args):
|
|
346
|
+
_BOARD_ARG.append(args[i + 1])
|
|
347
|
+
mcp.run()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Unified result envelope for flashgate MCP tools (design doc §7).
|
|
2
|
+
|
|
3
|
+
Every MCP tool returns a Result: machine-decidable status, a stable code,
|
|
4
|
+
a human summary, the CLI exit code as a compatibility layer, and the
|
|
5
|
+
payload. "Command succeeded" and "hardware verified" are different things;
|
|
6
|
+
`status` speaks only in evidence terms.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = "1.0"
|
|
16
|
+
|
|
17
|
+
Status = Literal["succeeded", "failed", "incomplete", "cancelled", "timed_out"]
|
|
18
|
+
|
|
19
|
+
# Stable machine codes (design doc §7.2). Extensible; never repurpose one.
|
|
20
|
+
OK = "OK"
|
|
21
|
+
BUILD_FAILED = "BUILD_FAILED"
|
|
22
|
+
FLASH_FAILED = "FLASH_FAILED"
|
|
23
|
+
RESET_FAILED = "RESET_FAILED"
|
|
24
|
+
BOOT_EVIDENCE_TIMEOUT = "BOOT_EVIDENCE_TIMEOUT"
|
|
25
|
+
BOOT_ERROR = "BOOT_ERROR"
|
|
26
|
+
IDENTITY_MISMATCH = "IDENTITY_MISMATCH"
|
|
27
|
+
PROBE_FAILED = "PROBE_FAILED"
|
|
28
|
+
CAPABILITY_UNAVAILABLE = "CAPABILITY_UNAVAILABLE"
|
|
29
|
+
TRANSPORT_ERROR = "TRANSPORT_ERROR"
|
|
30
|
+
ADAPTER_ERROR = "ADAPTER_ERROR"
|
|
31
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
32
|
+
PROFILE_NOT_FOUND = "PROFILE_NOT_FOUND" # board yaml missing/unloadable
|
|
33
|
+
INVALID_ARGUMENT = "INVALID_ARGUMENT"
|
|
34
|
+
|
|
35
|
+
# CLI exit-code contract (board yaml comment) -> envelope semantics.
|
|
36
|
+
# 6 = env/prereq: a required step COULD NOT RUN -> "incomplete", never "passed".
|
|
37
|
+
_EXIT_STATUS: dict[int, Status] = {
|
|
38
|
+
0: "succeeded", 1: "failed", 2: "failed", 3: "timed_out",
|
|
39
|
+
4: "failed", 5: "failed", 6: "incomplete", 7: "failed",
|
|
40
|
+
}
|
|
41
|
+
_EXIT_CODE: dict[int, str] = {
|
|
42
|
+
0: OK, 1: BUILD_FAILED, 2: FLASH_FAILED, 3: BOOT_EVIDENCE_TIMEOUT,
|
|
43
|
+
4: BOOT_ERROR, 5: IDENTITY_MISMATCH, 6: CAPABILITY_UNAVAILABLE,
|
|
44
|
+
7: PROBE_FAILED,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Result(BaseModel):
|
|
49
|
+
"""The envelope every flashgate MCP tool returns."""
|
|
50
|
+
|
|
51
|
+
schema_version: str = SCHEMA_VERSION
|
|
52
|
+
status: Status
|
|
53
|
+
code: str
|
|
54
|
+
summary: str
|
|
55
|
+
exit_code: int | None = None
|
|
56
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
57
|
+
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
|
58
|
+
warnings: list[str] = Field(default_factory=list)
|
|
59
|
+
policy: dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ok(summary: str, *, data: dict | None = None,
|
|
63
|
+
policy: dict | None = None) -> Result:
|
|
64
|
+
return Result(status="succeeded", code=OK, summary=summary,
|
|
65
|
+
data=data or {}, policy=policy or {})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def failure(summary: str, *, code: str = INTERNAL_ERROR,
|
|
69
|
+
status: Status = "failed", data: dict | None = None,
|
|
70
|
+
policy: dict | None = None) -> Result:
|
|
71
|
+
return Result(status=status, code=code, summary=summary,
|
|
72
|
+
data=data or {}, policy=policy or {})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def from_exit(rc: int, summary: str, *, log: str | None = None,
|
|
76
|
+
data: dict | None = None,
|
|
77
|
+
policy: dict | None = None) -> Result:
|
|
78
|
+
"""Envelope from the CLI exit-code contract (the compat layer)."""
|
|
79
|
+
payload = dict(data or {})
|
|
80
|
+
if log:
|
|
81
|
+
payload["log"] = log
|
|
82
|
+
return Result(
|
|
83
|
+
status=_EXIT_STATUS.get(rc, "failed"),
|
|
84
|
+
code=_EXIT_CODE.get(rc, INTERNAL_ERROR),
|
|
85
|
+
summary=summary,
|
|
86
|
+
exit_code=rc,
|
|
87
|
+
data=payload,
|
|
88
|
+
policy=policy or {},
|
|
89
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -133,7 +133,7 @@ profile the gate stays idle and says so.
|
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
board_info, doctor, build, flash, verify, probe, console_send,
|
|
136
|
-
console_read. Any MCP-capable agent can drive the board directly. mcp 1.x
|
|
136
|
+
console_read. Any MCP-capable agent can drive the board directly. Every tool returns a structured result envelope (status, stable code, summary, CLI exit_code, log) — never a bare log to parse. mcp 1.x
|
|
137
137
|
and 2.x supported.
|
|
138
138
|
|
|
139
139
|
## Demos
|
|
@@ -9,6 +9,7 @@ flashgate/flasher.py
|
|
|
9
9
|
flashgate/gatestate.py
|
|
10
10
|
flashgate/mcp_server.py
|
|
11
11
|
flashgate/probes.py
|
|
12
|
+
flashgate/results.py
|
|
12
13
|
flashgate/serialmon.py
|
|
13
14
|
flashgate/sttools.py
|
|
14
15
|
flashgate/swdsig.py
|
|
@@ -21,5 +22,6 @@ flashgate.egg-info/top_level.txt
|
|
|
21
22
|
tests/test_board.py
|
|
22
23
|
tests/test_cli.py
|
|
23
24
|
tests/test_gatestate.py
|
|
25
|
+
tests/test_mcp.py
|
|
24
26
|
tests/test_probes.py
|
|
25
27
|
tests/test_swdsig.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flashgate"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -146,3 +146,14 @@ class TestAutoRouting:
|
|
|
146
146
|
monkeypatch.setattr(cli, "_verify_uart", fake_uart)
|
|
147
147
|
assert cli.cmd_verify(board, None, None) == cli.EXIT_OK
|
|
148
148
|
assert seen["mode"] == "uart"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestEnvelopeCoupling:
|
|
152
|
+
def test_exit_env_maps_to_incomplete_never_succeeded(self):
|
|
153
|
+
# Belt-and-suspenders (mutation finding M5): the constant every
|
|
154
|
+
# fail-closed guard asserts must map to "incomplete" in the MCP
|
|
155
|
+
# envelope. If the mapping ever flips to "succeeded", a check that
|
|
156
|
+
# could not run would count as a pass.
|
|
157
|
+
from flashgate import results
|
|
158
|
+
assert results.from_exit(cli.EXIT_ENV, "x").status == "incomplete"
|
|
159
|
+
assert results.from_exit(cli.EXIT_PROBE_FAIL, "x").status == "failed"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""MCP contract tests: every tool returns the unified Result envelope,
|
|
2
|
+
stable codes map from the CLI exit contract, and structuredContent carries
|
|
3
|
+
the envelope on mcp 2.x. Programmatic call_tool — no transport needed."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
pytest.importorskip("mcp")
|
|
12
|
+
|
|
13
|
+
from flashgate import mcp_server as srv
|
|
14
|
+
from flashgate import results
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestExitMapping:
|
|
18
|
+
def test_all_eight_exit_codes_map(self):
|
|
19
|
+
expect = {
|
|
20
|
+
0: ("succeeded", results.OK),
|
|
21
|
+
1: ("failed", results.BUILD_FAILED),
|
|
22
|
+
2: ("failed", results.FLASH_FAILED),
|
|
23
|
+
3: ("timed_out", results.BOOT_EVIDENCE_TIMEOUT),
|
|
24
|
+
4: ("failed", results.BOOT_ERROR),
|
|
25
|
+
5: ("failed", results.IDENTITY_MISMATCH),
|
|
26
|
+
6: ("incomplete", results.CAPABILITY_UNAVAILABLE),
|
|
27
|
+
7: ("failed", results.PROBE_FAILED),
|
|
28
|
+
}
|
|
29
|
+
for rc, (status, code) in expect.items():
|
|
30
|
+
r = results.from_exit(rc, "s")
|
|
31
|
+
assert r.status == status and r.code == code and r.exit_code == rc
|
|
32
|
+
|
|
33
|
+
def test_unknown_exit_code_is_internal_error(self):
|
|
34
|
+
r = results.from_exit(99, "s")
|
|
35
|
+
assert r.status == "failed" and r.code == results.INTERNAL_ERROR
|
|
36
|
+
|
|
37
|
+
def test_log_rides_in_data(self):
|
|
38
|
+
r = results.from_exit(0, "s", log="line1\nline2")
|
|
39
|
+
assert r.data["log"] == "line1\nline2"
|
|
40
|
+
|
|
41
|
+
def test_json_round_trip(self):
|
|
42
|
+
r = results.from_exit(7, "probe broke", log="x")
|
|
43
|
+
parsed = json.loads(r.model_dump_json())
|
|
44
|
+
assert parsed["code"] == results.PROBE_FAILED
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestRegisteredTools:
|
|
48
|
+
TOOLS = {"board_info", "doctor", "build", "flash", "verify",
|
|
49
|
+
"probe", "console_send", "console_read"}
|
|
50
|
+
|
|
51
|
+
def test_all_tools_registered(self):
|
|
52
|
+
async def go():
|
|
53
|
+
return {t.name for t in await srv.mcp.list_tools()}
|
|
54
|
+
assert asyncio.run(go()) == self.TOOLS
|
|
55
|
+
|
|
56
|
+
def test_annotations_attached_when_supported(self):
|
|
57
|
+
async def go():
|
|
58
|
+
return {t.name: t.annotations for t in await srv.mcp.list_tools()}
|
|
59
|
+
anns = asyncio.run(go())
|
|
60
|
+
if srv.ToolAnnotations is None: # pre-1.9 SDK
|
|
61
|
+
pytest.skip("SDK lacks ToolAnnotations")
|
|
62
|
+
assert anns["board_info"].read_only_hint is True
|
|
63
|
+
assert anns["flash"].destructive_hint is True
|
|
64
|
+
assert anns["verify"].destructive_hint is True
|
|
65
|
+
assert anns["console_read"].read_only_hint is True
|
|
66
|
+
|
|
67
|
+
def test_every_tool_is_envelope_typed(self):
|
|
68
|
+
async def go():
|
|
69
|
+
out = {}
|
|
70
|
+
for t in await srv.mcp.list_tools():
|
|
71
|
+
out[t.name] = (t.output_schema or {}).get("properties", {})
|
|
72
|
+
return out
|
|
73
|
+
props = asyncio.run(go())
|
|
74
|
+
for name in self.TOOLS:
|
|
75
|
+
assert {"status", "code", "summary", "schema_version"} <= set(props[name]), name
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestBoardInfoTool:
|
|
79
|
+
def test_default_profile_returns_envelope(self):
|
|
80
|
+
async def go():
|
|
81
|
+
return await srv.mcp.call_tool("board_info", {})
|
|
82
|
+
res = asyncio.run(go())
|
|
83
|
+
sc = res.structured_content
|
|
84
|
+
assert res.is_error is False
|
|
85
|
+
assert sc["schema_version"] == results.SCHEMA_VERSION
|
|
86
|
+
assert sc["status"] == "succeeded"
|
|
87
|
+
assert sc["code"] == results.OK
|
|
88
|
+
assert sc["policy"] == {"risk": "R0"}
|
|
89
|
+
assert isinstance(sc["data"]["probes"], list)
|
|
90
|
+
|
|
91
|
+
def test_missing_profile_is_structured_failure(self):
|
|
92
|
+
async def go():
|
|
93
|
+
return await srv.mcp.call_tool(
|
|
94
|
+
"board_info", {"board": "Z:/definitely/missing.yaml"})
|
|
95
|
+
res = asyncio.run(go())
|
|
96
|
+
sc = res.structured_content
|
|
97
|
+
assert sc["status"] == "failed"
|
|
98
|
+
assert sc["code"] == results.PROFILE_NOT_FOUND
|
|
99
|
+
assert res.is_error is False # domain errors are envelopes, not MCP errors
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestProbeTool:
|
|
103
|
+
def test_unresolved_serial_is_incomplete_never_pass(self, monkeypatch):
|
|
104
|
+
monkeypatch.setattr(
|
|
105
|
+
srv.serialmon, "resolve_console_port",
|
|
106
|
+
lambda serial_port, vid, pids: (None, "no serial found"))
|
|
107
|
+
async def go():
|
|
108
|
+
return await srv.mcp.call_tool("probe", {})
|
|
109
|
+
res = asyncio.run(go())
|
|
110
|
+
sc = res.structured_content
|
|
111
|
+
assert sc["status"] == "incomplete"
|
|
112
|
+
assert sc["code"] == results.CAPABILITY_UNAVAILABLE
|
|
113
|
+
|
|
114
|
+
def test_unopenable_serial_is_incomplete(self, tmp_path, monkeypatch):
|
|
115
|
+
import serial as serial_mod
|
|
116
|
+
|
|
117
|
+
board_yaml = tmp_path / "board.yaml"
|
|
118
|
+
board_yaml.write_text(
|
|
119
|
+
"board: t\nmcu: m\ndescription: d\nfirmware:\n dir: fw\n"
|
|
120
|
+
" build: ninja -C build\n artifact: build/fw.bin\nserial:\n"
|
|
121
|
+
" baudrate: 9600\n banner: 'BOOT {git}'\n", encoding="utf-8")
|
|
122
|
+
(tmp_path / "fw").mkdir()
|
|
123
|
+
monkeypatch.setattr(
|
|
124
|
+
srv.serialmon, "resolve_console_port",
|
|
125
|
+
lambda serial_port, vid, pids: ("COMX", "hint"))
|
|
126
|
+
|
|
127
|
+
def boom(*a, **k):
|
|
128
|
+
raise serial_mod.SerialException("access denied")
|
|
129
|
+
monkeypatch.setattr(srv.serialmon, "open_flush", boom)
|
|
130
|
+
async def go():
|
|
131
|
+
return await srv.mcp.call_tool("probe", {"board": str(board_yaml)})
|
|
132
|
+
res = asyncio.run(go())
|
|
133
|
+
sc = res.structured_content
|
|
134
|
+
assert sc["status"] == "incomplete"
|
|
135
|
+
assert sc["code"] == results.TRANSPORT_ERROR
|
|
136
|
+
assert "COMX" in sc["summary"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestProbeEdgeCases:
|
|
140
|
+
"""Regressions from the adversarial review of 0.5.0."""
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _board_yaml(tmp_path):
|
|
144
|
+
yaml = tmp_path / "board.yaml"
|
|
145
|
+
yaml.write_text(
|
|
146
|
+
"board: t\nmcu: m\ndescription: d\nfirmware:\n dir: fw\n"
|
|
147
|
+
" build: ninja -C build\n artifact: build/fw.bin\nserial:\n"
|
|
148
|
+
" baudrate: 9600\n banner: 'BOOT {git}'\n", encoding="utf-8")
|
|
149
|
+
(tmp_path / "fw").mkdir()
|
|
150
|
+
return yaml
|
|
151
|
+
|
|
152
|
+
def test_empty_names_means_all_like_cli(self, tmp_path, monkeypatch):
|
|
153
|
+
# F1 regression: probe(names=[]) used to run ZERO probes and return
|
|
154
|
+
# succeeded — a false pass at the heart of the gate's invariant.
|
|
155
|
+
# [] must behave exactly like None ("all"), as the CLI does.
|
|
156
|
+
yaml = self._board_yaml(tmp_path)
|
|
157
|
+
monkeypatch.setattr(srv.serialmon, "resolve_console_port",
|
|
158
|
+
lambda sp, vid, pids: ("COMX", "hint"))
|
|
159
|
+
monkeypatch.setattr(srv.serialmon, "open_flush",
|
|
160
|
+
lambda *a, **k: SimpleNamespace(close=lambda: None))
|
|
161
|
+
called = {}
|
|
162
|
+
def fake_run(b, names, conn):
|
|
163
|
+
called["names"] = names
|
|
164
|
+
return 0
|
|
165
|
+
monkeypatch.setattr(srv.cli_mod, "_run_probes", fake_run)
|
|
166
|
+
res = asyncio.run(srv.mcp.call_tool(
|
|
167
|
+
"probe", {"board": str(yaml), "names": []}))
|
|
168
|
+
assert res.structured_content["status"] == "succeeded"
|
|
169
|
+
assert called["names"] is None # expanded to "all"
|
|
170
|
+
|
|
171
|
+
def test_unknown_probe_name_is_invalid_argument(self, tmp_path, monkeypatch):
|
|
172
|
+
# F3: a typo in probe names is an argument error, not a missing
|
|
173
|
+
# capability — the agent should fix the name, not hunt the serial.
|
|
174
|
+
yaml = self._board_yaml(tmp_path)
|
|
175
|
+
monkeypatch.setattr(srv.serialmon, "resolve_console_port",
|
|
176
|
+
lambda sp, vid, pids: (None, "n/a"))
|
|
177
|
+
res = asyncio.run(srv.mcp.call_tool(
|
|
178
|
+
"probe", {"board": str(yaml), "names": ["typo"]}))
|
|
179
|
+
sc = res.structured_content
|
|
180
|
+
assert sc["status"] == "failed"
|
|
181
|
+
assert sc["code"] == results.INVALID_ARGUMENT
|
|
182
|
+
assert "typo" in sc["summary"]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestCatchAllGuard:
|
|
186
|
+
def test_internal_exception_becomes_envelope(self, monkeypatch):
|
|
187
|
+
# F2: no exception may escape a tool into an MCP protocol error;
|
|
188
|
+
# the envelope contract survives even a crash inside the CLI layer.
|
|
189
|
+
def boom(b):
|
|
190
|
+
raise RuntimeError("kaboom")
|
|
191
|
+
monkeypatch.setattr(srv.cli_mod, "cmd_doctor", boom)
|
|
192
|
+
res = asyncio.run(srv.mcp.call_tool("doctor", {}))
|
|
193
|
+
sc = res.structured_content
|
|
194
|
+
assert res.is_error is False
|
|
195
|
+
assert sc["status"] == "failed"
|
|
196
|
+
assert sc["code"] == results.INTERNAL_ERROR
|
|
197
|
+
assert "kaboom" in sc["summary"]
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
"""flashgate MCP server: expose the hardware gate to any MCP-capable agent.
|
|
2
|
-
|
|
3
|
-
Run via `flashgate-mcp` (stdio transport). Requires the optional extra:
|
|
4
|
-
|
|
5
|
-
pip install "flashgate[mcp]"
|
|
6
|
-
|
|
7
|
-
Wiring (.mcp.json, Claude Code compatible):
|
|
8
|
-
|
|
9
|
-
{"mcpServers": {"flashgate": {
|
|
10
|
-
"command": "flashgate-mcp",
|
|
11
|
-
"args": ["--board", "/path/to/boards/apollo-h743.yaml"]}}}
|
|
12
|
-
|
|
13
|
-
Tools: board_info, doctor, build, flash, verify, probe, console_send,
|
|
14
|
-
console_read. Every tool returns plain text (ANSI stripped) — the same
|
|
15
|
-
output the CLI prints, plus the flashgate exit-code contract.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import io
|
|
21
|
-
import re
|
|
22
|
-
import sys
|
|
23
|
-
import time
|
|
24
|
-
from contextlib import redirect_stdout
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
|
|
27
|
-
try:
|
|
28
|
-
from mcp.server.mcpserver import MCPServer as _Server # mcp 2.x
|
|
29
|
-
except ImportError:
|
|
30
|
-
try:
|
|
31
|
-
from mcp.server.fastmcp import FastMCP as _Server # mcp 1.x
|
|
32
|
-
except ImportError as exc: # pragma: no cover - friendly extra hint
|
|
33
|
-
raise SystemExit(
|
|
34
|
-
"flashgate MCP server needs the optional dependency: "
|
|
35
|
-
'pip install "flashgate[mcp]"'
|
|
36
|
-
) from exc
|
|
37
|
-
|
|
38
|
-
from . import __version__, flasher, probes as probe_mod, serialmon
|
|
39
|
-
from .board import Board, BoardError, default_board_path, load_board
|
|
40
|
-
from . import cli as cli_mod
|
|
41
|
-
|
|
42
|
-
mcp = _Server(f"flashgate {__version__}")
|
|
43
|
-
|
|
44
|
-
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
45
|
-
_BOARD_ARG: list[str] = [] # set from argv by main()
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _board(board: str | None = None) -> Board:
|
|
49
|
-
"""Resolve the board profile: tool arg > server --board arg > default."""
|
|
50
|
-
source = board or (_BOARD_ARG[0] if _BOARD_ARG else None)
|
|
51
|
-
path = Path(source) if source else default_board_path()
|
|
52
|
-
if path is None or not Path(path).is_file():
|
|
53
|
-
raise BoardError(f"board profile not found: {source or 'boards/*.yaml'}")
|
|
54
|
-
return load_board(Path(path))
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _capture(fn, *args) -> str:
|
|
58
|
-
"""Run a CLI command function, return its printed output, ANSI-stripped."""
|
|
59
|
-
buf = io.StringIO()
|
|
60
|
-
with redirect_stdout(buf):
|
|
61
|
-
rc = fn(*args)
|
|
62
|
-
text = _ANSI.sub("", buf.getvalue()).strip()
|
|
63
|
-
return f"exit code: {rc}\n{text}"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _err(exc: Exception) -> str:
|
|
67
|
-
return f"error: {exc}"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@mcp.tool()
|
|
71
|
-
def board_info(board: str | None = None) -> str:
|
|
72
|
-
"""Show the active board profile: firmware dir, artifact, watch globs,
|
|
73
|
-
banner contract, and the functional probes it defines."""
|
|
74
|
-
try:
|
|
75
|
-
b = _board(board)
|
|
76
|
-
except BoardError as exc:
|
|
77
|
-
return _err(exc)
|
|
78
|
-
lines = [
|
|
79
|
-
f"board : {b.name} ({b.mcu})",
|
|
80
|
-
f"description : {b.description}",
|
|
81
|
-
f"firmware : {b.firmware_dir}",
|
|
82
|
-
f"artifact : {b.artifact}",
|
|
83
|
-
f"flash : {b.flash_connect} @ {b.flash_address}",
|
|
84
|
-
f"banner : {b.banner_regex}",
|
|
85
|
-
f"gate.watch : {', '.join(b.watch_globs)}",
|
|
86
|
-
]
|
|
87
|
-
try:
|
|
88
|
-
names = list(probe_mod.load_probes(b.yaml_path))
|
|
89
|
-
lines.append(f"probes : {', '.join(names) if names else '(none)'}")
|
|
90
|
-
except (OSError, ValueError) as exc:
|
|
91
|
-
lines.append(f"probes : (unloadable: {exc})")
|
|
92
|
-
return "\n".join(lines)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
@mcp.tool()
|
|
96
|
-
def doctor(board: str | None = None) -> str:
|
|
97
|
-
"""Check hardware prerequisites: ST-Link probe, console serial port,
|
|
98
|
-
toolchain. Run this first when anything else fails (exit code 6)."""
|
|
99
|
-
try:
|
|
100
|
-
return _capture(cli_mod.cmd_doctor, _board(board))
|
|
101
|
-
except BoardError as exc:
|
|
102
|
-
return _err(exc)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@mcp.tool()
|
|
106
|
-
def build(board: str | None = None) -> str:
|
|
107
|
-
"""Build the firmware (incremental). Exit codes: 0 ok, 1 build failed."""
|
|
108
|
-
try:
|
|
109
|
-
return _capture(cli_mod.cmd_build, _board(board))
|
|
110
|
-
except BoardError as exc:
|
|
111
|
-
return _err(exc)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
@mcp.tool()
|
|
115
|
-
def flash(board: str | None = None) -> str:
|
|
116
|
-
"""Flash + verify + start via ST-Link (with auto-retry). Exit codes:
|
|
117
|
-
0 ok, 2 flash failed, 6 environment error."""
|
|
118
|
-
try:
|
|
119
|
-
return _capture(cli_mod.cmd_flash, _board(board))
|
|
120
|
-
except BoardError as exc:
|
|
121
|
-
return _err(exc)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
@mcp.tool()
|
|
125
|
-
def verify(board: str | None = None) -> str:
|
|
126
|
-
"""The full gate: build -> flash -> boot banner -> sha check -> all
|
|
127
|
-
functional probes. Exit 0 means the BOARD ITSELF confirms the firmware
|
|
128
|
-
works. 1 build, 2 flash, 3 no banner, 4 boot error, 5 sha mismatch,
|
|
129
|
-
6 env, 7 probe failure."""
|
|
130
|
-
try:
|
|
131
|
-
return _capture(cli_mod.cmd_verify, _board(board), ["all"])
|
|
132
|
-
except BoardError as exc:
|
|
133
|
-
return _err(exc)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@mcp.tool()
|
|
137
|
-
def probe(names: list[str] | None = None, board: str | None = None) -> str:
|
|
138
|
-
"""Run functional probes against the ALREADY RUNNING firmware (no
|
|
139
|
-
rebuild/reflash). names=None runs every probe. Exit 7 = probe failed."""
|
|
140
|
-
try:
|
|
141
|
-
b = _board(board)
|
|
142
|
-
except BoardError as exc:
|
|
143
|
-
return _err(exc)
|
|
144
|
-
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
145
|
-
if port is None:
|
|
146
|
-
return f"error: console serial unresolved — {why}"
|
|
147
|
-
try:
|
|
148
|
-
conn = serialmon.open_flush(port, b.baudrate)
|
|
149
|
-
except Exception as exc: # pyserial SerialException
|
|
150
|
-
return f"error: cannot open {port}: {exc}"
|
|
151
|
-
try:
|
|
152
|
-
buf = io.StringIO()
|
|
153
|
-
with redirect_stdout(buf):
|
|
154
|
-
rc = cli_mod._run_probes(b, names, conn)
|
|
155
|
-
return f"exit code: {rc}\n{_ANSI.sub('', buf.getvalue()).strip()}"
|
|
156
|
-
finally:
|
|
157
|
-
conn.close()
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
@mcp.tool()
|
|
161
|
-
def console_send(line: str, wait_s: float = 1.0, board: str | None = None) -> str:
|
|
162
|
-
"""Send ONE line to the firmware console (e.g. 'led0?' or 'selftest')
|
|
163
|
-
and return the response lines received within wait_s seconds."""
|
|
164
|
-
try:
|
|
165
|
-
b = _board(board)
|
|
166
|
-
except BoardError as exc:
|
|
167
|
-
return _err(exc)
|
|
168
|
-
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
169
|
-
if port is None:
|
|
170
|
-
return f"error: console serial unresolved — {why}"
|
|
171
|
-
try:
|
|
172
|
-
conn = serialmon.open_flush(port, b.baudrate)
|
|
173
|
-
except Exception as exc:
|
|
174
|
-
return f"error: cannot open {port}: {exc}"
|
|
175
|
-
try:
|
|
176
|
-
conn.write((line + "\r\n").encode())
|
|
177
|
-
deadline = time.monotonic() + max(0.1, wait_s)
|
|
178
|
-
out = ""
|
|
179
|
-
while time.monotonic() < deadline:
|
|
180
|
-
out += conn.read(256).decode("utf-8", errors="replace")
|
|
181
|
-
return out.strip() or "(no response)"
|
|
182
|
-
finally:
|
|
183
|
-
conn.close()
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
@mcp.tool()
|
|
187
|
-
def console_read(seconds: float = 2.0, board: str | None = None) -> str:
|
|
188
|
-
"""Read whatever the firmware prints on the console for N seconds
|
|
189
|
-
(banner, self-test output, fault dumps)."""
|
|
190
|
-
try:
|
|
191
|
-
b = _board(board)
|
|
192
|
-
except BoardError as exc:
|
|
193
|
-
return _err(exc)
|
|
194
|
-
port, why = serialmon.resolve_console_port(b.serial_port, b.usb_vid, b.usb_pids)
|
|
195
|
-
if port is None:
|
|
196
|
-
return f"error: console serial unresolved — {why}"
|
|
197
|
-
try:
|
|
198
|
-
conn = serialmon.open_flush(port, b.baudrate)
|
|
199
|
-
except Exception as exc:
|
|
200
|
-
return f"error: cannot open {port}: {exc}"
|
|
201
|
-
try:
|
|
202
|
-
deadline = time.monotonic() + max(0.1, seconds)
|
|
203
|
-
out = ""
|
|
204
|
-
while time.monotonic() < deadline:
|
|
205
|
-
out += conn.read(256).decode("utf-8", errors="replace")
|
|
206
|
-
return out.strip() or "(silence)"
|
|
207
|
-
finally:
|
|
208
|
-
conn.close()
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def main() -> None:
|
|
212
|
-
args = sys.argv[1:]
|
|
213
|
-
if "--board" in args:
|
|
214
|
-
i = args.index("--board")
|
|
215
|
-
if i + 1 < len(args):
|
|
216
|
-
_BOARD_ARG.append(args[i + 1])
|
|
217
|
-
mcp.run()
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if __name__ == "__main__":
|
|
221
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|