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.
Files changed (30) hide show
  1. {flashgate-0.4.2 → flashgate-0.5.0}/PKG-INFO +2 -2
  2. {flashgate-0.4.2 → flashgate-0.5.0}/README.md +1 -1
  3. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/cli.py +3 -1
  4. flashgate-0.5.0/flashgate/mcp_server.py +351 -0
  5. flashgate-0.5.0/flashgate/results.py +89 -0
  6. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/PKG-INFO +2 -2
  7. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/SOURCES.txt +2 -0
  8. {flashgate-0.4.2 → flashgate-0.5.0}/pyproject.toml +1 -1
  9. {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_cli.py +11 -0
  10. flashgate-0.5.0/tests/test_mcp.py +197 -0
  11. flashgate-0.4.2/flashgate/mcp_server.py +0 -221
  12. {flashgate-0.4.2 → flashgate-0.5.0}/LICENSE +0 -0
  13. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/__init__.py +0 -0
  14. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/__main__.py +0 -0
  15. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/board.py +0 -0
  16. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/flasher.py +0 -0
  17. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/gatestate.py +0 -0
  18. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/probes.py +0 -0
  19. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/serialmon.py +0 -0
  20. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/sttools.py +0 -0
  21. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate/swdsig.py +0 -0
  22. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/dependency_links.txt +0 -0
  23. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/entry_points.txt +0 -0
  24. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/requires.txt +0 -0
  25. {flashgate-0.4.2 → flashgate-0.5.0}/flashgate.egg-info/top_level.txt +0 -0
  26. {flashgate-0.4.2 → flashgate-0.5.0}/setup.cfg +0 -0
  27. {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_board.py +0 -0
  28. {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_gatestate.py +0 -0
  29. {flashgate-0.4.2 → flashgate-0.5.0}/tests/test_probes.py +0 -0
  30. {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.4.2
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 mismatch | 6 environment error
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.4.2
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.4.2"
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