crosspad-mcp-server 8.1.2 → 9.1.0

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/.mcp.json +9 -0
  4. package/README.md +95 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +8 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +381 -57
  10. package/dist/index.js.map +1 -1
  11. package/dist/tools/idf-flash.js +2 -2
  12. package/dist/tools/idf-flash.js.map +1 -1
  13. package/dist/tools/idf-monitor.d.ts +3 -1
  14. package/dist/tools/idf-monitor.js +19 -3
  15. package/dist/tools/idf-monitor.js.map +1 -1
  16. package/dist/tools/midi.js +20 -16
  17. package/dist/tools/midi.js.map +1 -1
  18. package/dist/tools/symbols.d.ts +3 -1
  19. package/dist/tools/symbols.js +31 -1
  20. package/dist/tools/symbols.js.map +1 -1
  21. package/dist/tools/trace-buffer.d.ts +40 -0
  22. package/dist/tools/trace-buffer.js +74 -0
  23. package/dist/tools/trace-buffer.js.map +1 -0
  24. package/dist/tools/trace-device.d.ts +10 -0
  25. package/dist/tools/trace-device.js +26 -0
  26. package/dist/tools/trace-device.js.map +1 -0
  27. package/dist/tools/trace-doctor.d.ts +43 -0
  28. package/dist/tools/trace-doctor.js +150 -0
  29. package/dist/tools/trace-doctor.js.map +1 -0
  30. package/dist/tools/trace-export.d.ts +4 -0
  31. package/dist/tools/trace-export.js +14 -0
  32. package/dist/tools/trace-export.js.map +1 -0
  33. package/dist/tools/trace-session.d.ts +118 -0
  34. package/dist/tools/trace-session.js +346 -0
  35. package/dist/tools/trace-session.js.map +1 -0
  36. package/dist/tools/trace-symbols.d.ts +24 -0
  37. package/dist/tools/trace-symbols.js +44 -0
  38. package/dist/tools/trace-symbols.js.map +1 -0
  39. package/dist/tools/trace-webui.d.ts +53 -0
  40. package/dist/tools/trace-webui.js +222 -0
  41. package/dist/tools/trace-webui.js.map +1 -0
  42. package/dist/utils/device.d.ts +5 -0
  43. package/dist/utils/device.js +43 -15
  44. package/dist/utils/device.js.map +1 -1
  45. package/dist/utils/exec.js +26 -0
  46. package/dist/utils/exec.js.map +1 -1
  47. package/dist/utils/userConfig.d.ts +13 -0
  48. package/dist/utils/userConfig.js +43 -0
  49. package/dist/utils/userConfig.js.map +1 -0
  50. package/package.json +12 -4
  51. package/skills/crosspad/SKILL.md +58 -0
  52. package/skills/crosspad/reference/faq.md +40 -0
  53. package/skills/crosspad/reference/install.md +84 -0
  54. package/skills/crosspad/reference/repos.md +29 -0
  55. package/skills/crosspad/reference/role-contributor.md +64 -0
  56. package/skills/crosspad/reference/role-fw-dev.md +44 -0
  57. package/skills/crosspad/reference/role-user.md +49 -0
  58. package/skills/crosspad/reference/tools.md +68 -0
  59. package/skills/crosspad/scripts/doctor.sh +65 -0
  60. package/skills/crosspad/scripts/setup.sh +53 -0
  61. package/skills/swd-tracer/SKILL.md +135 -0
  62. package/skills/swd-tracer/reference/signals.md +42 -0
  63. package/skills/swd-tracer/scripts/detect-env.sh +61 -0
  64. package/skills/swd-tracer/scripts/install-udev-rules.sh +25 -0
  65. package/skills/swd-tracer/scripts/setup-venv.sh +26 -0
  66. package/tracer/PROTOCOL.md +260 -0
  67. package/tracer/README.md +327 -0
  68. package/tracer/swd_tracer.py +1083 -0
  69. package/tracer/ui/index.html +834 -0
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ const require = createRequire(import.meta.url);
7
7
  const { version } = require("../package.json");
8
8
  import { crosspadBuild, crosspadRun, crosspadKill } from "./tools/build.js";
9
9
  import { crosspadBuildCheck } from "./tools/build-check.js";
10
+ import { BIN_EXE as _BIN_EXE } from "./config.js";
10
11
  import { crosspadLog } from "./tools/log.js";
11
12
  import { crosspadIdfBuild } from "./tools/idf-build.js";
12
13
  import { crosspadIdfFlash, crosspadIdfOta } from "./tools/idf-flash.js";
@@ -24,6 +25,13 @@ import { crosspadStats } from "./tools/stats.js";
24
25
  import { crosspadSettingsGet, crosspadSettingsSet } from "./tools/settings.js";
25
26
  import { crosspadMidiSend } from "./tools/midi.js";
26
27
  import { crosspadAppList, crosspadAppInstall, crosspadAppRemove, crosspadAppUpdate, crosspadAppSync, } from "./tools/app-manager.js";
28
+ import { runDoctor, realProbe } from "./tools/trace-doctor.js";
29
+ import { setConfigValue } from "./utils/userConfig.js";
30
+ import { listSymbols } from "./tools/trace-symbols.js";
31
+ import { getDeviceState } from "./tools/trace-device.js";
32
+ import { TraceSession, getActiveSession, setActiveSession } from "./tools/trace-session.js";
33
+ import { getDashboard, openInBrowser, buildUiUrl } from "./tools/trace-webui.js";
34
+ import { writeCsv } from "./tools/trace-export.js";
27
35
  // Server instructions — MCP clients prepend these to the LLM system prompt.
28
36
  // This is the *primary* mechanism by which a Claude session "knows" to pick
29
37
  // crosspad_* tools when working inside any CrossPad repo. CLAUDE.md and memory
@@ -32,6 +40,10 @@ import { crosspadAppList, crosspadAppInstall, crosspadAppRemove, crosspadAppUpda
32
40
  const SERVER_INSTRUCTIONS = `
33
41
  You have access to the CrossPad MCP server, which exposes purpose-built tools for the CrossPad embedded music controller monorepo (repos: crosspad-pc, platform-idf, ESP32-S3, crosspad-core, crosspad-gui, plus app submodules).
34
42
 
43
+ NEW TO A CROSSPAD REPO OR SETTING UP? Use the \`crosspad\` skill first — it maps the ecosystem (repos, MCP tools, roles), walks install/config, and routes to per-role guides + an FAQ. Run \`bash scripts/doctor.sh\` from that skill to check your environment.
44
+
45
+ TOOL TAGS — a tool description starting with a bracket tag has a hardware/platform precondition: \`[PC sim]\` needs the host simulator built & running (crosspad_build platform=pc → crosspad_run); \`[ESP HW]\` needs a connected ESP32-S3 device; \`[STM HW]\` needs an ST-Link + STM32 board. Untagged tools (code search, repo/git, apps registry) need no hardware.
46
+
35
47
  WHEN TO USE THESE TOOLS — in any conversation that touches a CrossPad repo, prefer the crosspad_* tools over raw shell equivalents:
36
48
 
37
49
  - Inspecting code → crosspad_search_symbols (NOT \`grep -r\`); crosspad_list_interfaces; crosspad_interface_implementations.
@@ -42,6 +54,7 @@ WHEN TO USE THESE TOOLS — in any conversation that touches a CrossPad repo, pr
42
54
  - Sim interaction → crosspad_screenshot, crosspad_input, crosspad_midi, crosspad_stats, crosspad_settings_get/set.
43
55
  - Apps (registry) → crosspad_apps_list / install / remove / update / sync (NOT manual submodule git ops).
44
56
  - Commits → crosspad_commit (NOT raw \`git commit\`) — handles multi-repo paths and refuses on merge conflicts.
57
+ - SWD tracing → crosspad_trace (STM32 firmware variable RT trace over ST-Link). Run action=doctor first; resolve issues; then action=symbols → start → read.
45
58
 
46
59
  WHY: these tools resolve repos dynamically from env vars, parse build output into structured errors[], stream progress, and refuse unsafe operations. Manual shell equivalents will work but lose this scaffolding and frequently break across the 5 repos.
47
60
 
@@ -226,13 +239,34 @@ const O_Devices = {
226
239
  devices: z.array(z.object({
227
240
  port: z.string(),
228
241
  description: z.string().optional(),
229
- vid: z.string().optional(),
230
- pid: z.string().optional(),
242
+ vid: z.number().int().optional(),
243
+ pid: z.number().int().optional(),
231
244
  is_crosspad: z.boolean(),
245
+ kind: z.enum(["esp-native", "stm-bridge"]).nullable().optional(),
232
246
  }).passthrough()),
233
247
  crosspad_count: z.number().int().optional(),
234
248
  ...ErrorField,
235
249
  };
250
+ const O_Trace = {
251
+ success: z.boolean(),
252
+ action: z.string().optional(),
253
+ ok: z.boolean().optional(),
254
+ issues: z.array(z.record(z.string(), z.unknown())).optional(),
255
+ symbols: z.array(z.record(z.string(), z.unknown())).optional(),
256
+ device_state: z.string().optional(),
257
+ actual_fs: z.number().optional(),
258
+ sample_count: z.number().int().optional(),
259
+ signals: z.array(z.string()).optional(),
260
+ series: z.record(z.string(), z.unknown()).optional(),
261
+ stats: z.record(z.string(), z.unknown()).optional(),
262
+ file_path: z.string().optional(),
263
+ ui_url: z.string().optional(),
264
+ key: z.string().optional(),
265
+ // §11.6: last few daemon stderr lines, surfaced when device_state is an
266
+ // error / probe_lost / exited so the caller sees *why* without a re-run.
267
+ stderr_tail: z.string().optional(),
268
+ ...ErrorField,
269
+ };
236
270
  const O_Test = {
237
271
  success: z.boolean(),
238
272
  tests_found: z.boolean(),
@@ -355,15 +389,23 @@ const O_AppAction = {
355
389
  const BuildPlatform = z.enum(["pc", "idf"]).describe("Target platform: 'pc' = host simulator, 'idf' = ESP32-S3 firmware.");
356
390
  const PlatformPcOnly = z.enum(["pc"]).default("pc").describe("Platform — currently only 'pc' is supported here.");
357
391
  server.registerTool("crosspad_build", {
358
- description: "Build CrossPad for the given platform. platform=pc → CMake + Ninja host simulator (PREFER THIS over `cmake --build build` — picks the right MSVC env on Windows, parses errors/warnings, streams progress). platform=idf → idf.py build for ESP32-S3 firmware (PREFER THIS over `idf.py build` — sources IDF env, auto-fullcleans when new apps detected, parses errors/warnings).",
392
+ description: "[PC + ESP] Build CrossPad for the given platform.\n" +
393
+ " • platform='pc' → CMake + Ninja host simulator. PREFER THIS over `cmake --build build` (picks right MSVC env on Windows, parses errors/warnings, streams progress).\n" +
394
+ " • platform='idf' → idf.py build for ESP32-S3 firmware. PREFER THIS over raw `idf.py build` (sources IDF env, auto-fullcleans when new apps detected, parses errors/warnings).\n" +
395
+ "Mode×platform compatibility:\n" +
396
+ " • incremental → both (default)\n" +
397
+ " • clean → both (wipes build dir, then builds)\n" +
398
+ " • reconfigure → PC only (re-runs cmake without wiping cache)\n" +
399
+ " • fullclean → IDF only (runs idf.py fullclean, then builds)",
359
400
  inputSchema: {
360
401
  platform: BuildPlatform,
361
402
  mode: z.enum(["incremental", "clean", "fullclean", "reconfigure"])
362
403
  .default("incremental")
363
- .describe("incremental: rebuild only what changed (default). clean: wipe build dir then build. reconfigure: re-run cmake without wiping (PC only). fullclean: idf.py fullclean then build (IDF only)."),
404
+ .describe("Build mode. Compatibility: incremental & clean = both platforms; reconfigure = PC only; fullclean = IDF only. " +
405
+ "Pick incremental for normal iteration; clean if you suspect stale artifacts; fullclean (IDF) after adding new apps; reconfigure (PC) after editing CMakeLists."),
364
406
  build_type: z.enum(["Debug", "Release", "RelWithDebInfo"])
365
407
  .default("Debug")
366
- .describe("CMake build type — PC only. Only honored on clean/reconfigure (incremental keeps existing cache)."),
408
+ .describe("CMake build type — PC ONLY (ignored for IDF; ESP32 build type comes from sdkconfig). Only honored on mode=clean|reconfigure (incremental keeps existing cache)."),
367
409
  },
368
410
  outputSchema: O_Build,
369
411
  annotations: ANN_DESTRUCTIVE,
@@ -382,7 +424,7 @@ server.registerTool("crosspad_build", {
382
424
  return jsonResponse(await crosspadIdfBuild(idfMode, onLine, extra.signal));
383
425
  });
384
426
  server.registerTool("crosspad_run", {
385
- description: "Launch the built simulator binary in the background. Returns pid + exe_path. Refuses to spawn a duplicate if one is already responding on the TCP control port (use force=true to override). Fails if binary not built — call crosspad_build first. Currently PC-only (IDF firmware doesn't run on the host).",
427
+ description: "[PC sim] Launch the built simulator binary in the background. Returns pid + exe_path. Refuses to spawn a duplicate if one is already responding on the TCP control port (use force=true to override). Fails if binary not built — call crosspad_build first. Currently PC-only (IDF firmware doesn't run on the host).",
386
428
  inputSchema: {
387
429
  platform: PlatformPcOnly,
388
430
  force: z.boolean().default(false)
@@ -404,7 +446,7 @@ server.registerTool("crosspad_run", {
404
446
  return ok({ pid: result.pid, exe_path: result.exe_path, responsive: result.responsive });
405
447
  });
406
448
  server.registerTool("crosspad_kill", {
407
- description: "Stop the running PC simulator. Identifies the process by /proc/<pid>/exe match against the built binary (Linux) or pgrep -x basename (macOS/Windows), sends SIGTERM, waits up to 3s, then SIGKILL stragglers. Returns killed PIDs and whether anything still answers on the TCP control port. Currently PC-only.",
449
+ description: "[PC sim] Stop the running PC simulator. Identifies the process by /proc/<pid>/exe match against the built binary (Linux) or pgrep -x basename (macOS/Windows), sends SIGTERM, waits up to 3s, then SIGKILL stragglers. Returns killed PIDs and whether anything still answers on the TCP control port. Currently PC-only.",
408
450
  inputSchema: {
409
451
  platform: PlatformPcOnly,
410
452
  },
@@ -412,18 +454,18 @@ server.registerTool("crosspad_kill", {
412
454
  annotations: ANN_DESTRUCTIVE,
413
455
  }, async () => jsonResponse(await crosspadKill()));
414
456
  server.registerTool("crosspad_check", {
415
- description: "Health check for a build — detects stale exe, new sources missing from build system, dirty submodules. Use before crosspad_build to decide if rebuild needed. Currently PC-only.",
457
+ description: "[PC sim] Health check for a build — detects stale exe, new sources missing from build system, dirty submodules. Use before crosspad_build to decide if rebuild needed. Currently PC-only.",
416
458
  inputSchema: {
417
459
  platform: PlatformPcOnly,
418
460
  },
419
461
  outputSchema: O_BuildCheck,
420
462
  annotations: ANN_READ_ONLY,
421
- }, async () => jsonResponse(crosspadBuildCheck()));
463
+ }, async () => jsonResponse({ success: true, exe_path: _BIN_EXE, ...crosspadBuildCheck() }));
422
464
  // ═══════════════════════════════════════════════════════════════════════
423
465
  // FLASH — unified UART/OTA into one tool with `transport` axis
424
466
  // ═══════════════════════════════════════════════════════════════════════
425
467
  server.registerTool("crosspad_flash", {
426
- description: "Flash firmware to a connected CrossPad device. transport='uart' uses idf.py flash (device must be in bootloader mode). transport='ota' uses platform-idf/tools/ota_flash.py over USB CDC (no bootloader mode required). Requires prior crosspad_build platform=idf.",
468
+ description: "[ESP HW] Flash ESP firmware to a connected CrossPad device. transport='uart' uses idf.py flash (device must be in bootloader mode). transport='ota' uses platform-idf/tools/ota_flash.py over USB CDC (no bootloader mode required). Requires prior crosspad_build platform=idf. Works on both CrossPad generations: rev <2.0 (ESP native USB) and rev 2.0 (port is the STM32 CDC bridge — STM emulates the esptool DTR/RTS auto-reset and forwards the flash to the ESP over LPUART2; rev-2.0 STM must be in passthrough mode, i.e. NOT booted with pad-4 held).",
427
469
  inputSchema: {
428
470
  transport: z.enum(["uart", "ota"]).describe("'uart' = bootloader-mode flash via idf.py; 'ota' = USB-CDC OTA flash via ota_flash.py."),
429
471
  port: Port.optional(),
@@ -444,18 +486,24 @@ server.registerTool("crosspad_flash", {
444
486
  return jsonResponse(await crosspadIdfOta(port, firmware_path, onLine, extra.signal));
445
487
  });
446
488
  server.registerTool("crosspad_log", {
447
- description: "Capture logs from PC simulator (target='pc': spawn binary, capture stdout/stderr, kill) or connected ESP32-S3 device (target='idf': read serial via pyserial, no TTY needed). Consolidated tool — replaces crosspad_log_pc and crosspad_log_idf in v6.",
489
+ description: "[PC sim | ESP HW] Capture logs (consolidated; replaces crosspad_log_pc and crosspad_log_idf in v6).\n" +
490
+ " • target='pc' → spawn the built sim binary, capture stdout/stderr, then kill it. " +
491
+ "Fields used: timeout_seconds (default 5), max_lines (default 200). `port` and `filter` MUST be omitted.\n" +
492
+ " • target='idf' → read serial from a connected ESP32-S3 via pyserial (no TTY needed). " +
493
+ "Fields used: port (auto-detected if omitted), timeout_seconds (default 10), max_lines (default 500), filter (substring, case-insensitive).",
448
494
  inputSchema: {
449
- target: z.enum(["pc", "idf"]).describe("'pc' = run + capture sim binary; 'idf' = read serial from connected device."),
450
- port: Port.optional().describe("Serial port (idf only). Auto-detected if omitted; required when multiple devices connected."),
451
- timeout_seconds: TimeoutSec.optional().describe("Capture duration. Defaults: 5s for pc, 10s for idf."),
452
- max_lines: MaxLines.optional().describe("Max output lines. Defaults: 200 for pc, 500 for idf."),
495
+ target: z.enum(["pc", "idf"]).describe("'pc' = run+capture sim binary (uses timeout_seconds?,max_lines? — port/filter MUST be omitted); 'idf' = read serial from connected ESP device (uses port?,timeout_seconds?,max_lines?,filter?,reset_to_boot?)."),
496
+ port: Port.optional().describe("idf only. Serial port path. Auto-detected if omitted; required when multiple devices connected. MUST be omitted for target=pc."),
497
+ timeout_seconds: TimeoutSec.optional().describe("Capture duration in seconds. Defaults: 5 (pc), 10 (idf)."),
498
+ max_lines: MaxLines.optional().describe("Max output lines. Defaults: 200 (pc), 500 (idf)."),
453
499
  filter: z.string().optional()
454
- .describe("Case-insensitive substring filter (idf only). Only lines containing this string are returned."),
500
+ .describe("idf only. Case-insensitive substring filter only matching lines returned. MUST be omitted for target=pc."),
501
+ reset_to_boot: z.boolean().optional()
502
+ .describe("idf only. Pulse the device reset (esptool DTR/RTS sequence, works through the STM bridge) before capturing, so the log starts at boot t=0. Use for boot-time profiling. Default false (passive read of the running device)."),
455
503
  },
456
504
  outputSchema: O_Log,
457
505
  annotations: ANN_READ_ONLY,
458
- }, async ({ target, port, timeout_seconds, max_lines, filter }, extra) => {
506
+ }, async ({ target, port, timeout_seconds, max_lines, filter, reset_to_boot }, extra) => {
459
507
  if (target === "pc") {
460
508
  if (port)
461
509
  return err("Field 'port' is not used when target='pc'.");
@@ -469,25 +517,234 @@ server.registerTool("crosspad_log", {
469
517
  // target === "idf"
470
518
  const onLine = makeProgressLogger("log-idf", extra);
471
519
  return jsonResponse({
472
- ...(await crosspadIdfMonitor(port, timeout_seconds ?? 10, max_lines ?? 500, filter, onLine, extra.signal)),
520
+ ...(await crosspadIdfMonitor(port, timeout_seconds ?? 10, max_lines ?? 500, filter, onLine, extra.signal, reset_to_boot ?? false)),
473
521
  });
474
522
  });
475
523
  server.registerTool("crosspad_devices", {
476
- description: "List all connected USB serial devices. Identifies CrossPad devices (Espressif VID 0x303a, PID 0x3456) separately from other ports.",
524
+ description: "[ESP HW] List all connected USB serial devices. Identifies CrossPad devices separately and tags each with `kind`: 'esp-native' (rev <2.0, ESP32-S3 native USB, VID 0x303a/PID 0x3456) or 'stm-bridge' (rev 2.0, STM32 composite CDC+MIDI bridge, VID 0x0483/PID 0x5740 — STM programs the ESP over LPUART2).",
477
525
  inputSchema: {},
478
526
  outputSchema: O_Devices,
479
527
  annotations: ANN_READ_ONLY,
480
528
  }, async () => jsonResponse(listDevices()));
481
529
  // ═══════════════════════════════════════════════════════════════════════
530
+ // SWD TRACER
531
+ // ═══════════════════════════════════════════════════════════════════════
532
+ // §12.4 injectable browser opener — defaults to the real platform opener but is
533
+ // overridable (setTraceBrowserOpener) so a unit test can assert auto-open is
534
+ // called/skipped without launching a real browser. Returns true if it spawned.
535
+ let traceBrowserOpener = openInBrowser;
536
+ export function setTraceBrowserOpener(fn) { traceBrowserOpener = fn; }
537
+ const TraceAction = z.enum([
538
+ "doctor", "config_set", "symbols", "start", "stop",
539
+ "add", "remove", "status", "read", "save", "device_state", "ui",
540
+ ]);
541
+ server.registerTool("crosspad_trace", {
542
+ description: "[STM HW] Real-time SWD tracer for the STM32G0B1 firmware (ST-Link). Non-halting RAM polling of firmware variables resolved from the Debug ELF (like ST-Studio/CubeMonitor). Pick an `action`:\n" +
543
+ " • doctor → environment precheck → issues[] (run this FIRST; resolve issues, then config_set).\n" +
544
+ " • config_set → persist a resolved path/serial to ~/.config/crosspad-mcp/config.json (key,value).\n" +
545
+ " • symbols → list/search traceable variables from the ELF (query optional).\n" +
546
+ " • start → begin a background trace (signals[], rate_hz).\n" +
547
+ " • stop → end the active trace.\n" +
548
+ " • add/remove → mutate the live poll set of the active trace (signals[]); returns the current signal set.\n" +
549
+ " • status → device_state (running/stop_suspected/exited), sample_count, actual_fs, signals.\n" +
550
+ " • read → recent samples downsampled + per-signal stats (cheap; safe for the LLM).\n" +
551
+ " • save → export the in-memory buffer to CSV (returns file_path).\n" +
552
+ " • device_state → deep low-power/STOP register dump.\n" +
553
+ " • ui → returns the localhost dashboard URL.\n" +
554
+ "Signal names accept array indexing, e.g. 's_inputs[0]', 's_adc_raw[3]'.",
555
+ inputSchema: {
556
+ action: TraceAction.describe("Required params per action — doctor/stop/status/device_state/ui: (none); " +
557
+ "config_set: key,value; symbols: query?; start: signals[],rate_hz?; " +
558
+ "add/remove: signals[]; read: window_from?,window_to?,max_points?; save: format?."),
559
+ signals: z.array(z.string()).optional().describe("start: variable names from `symbols` (e.g. ['s_vbat_mv','s_inputs[0]'])."),
560
+ rate_hz: z.number().int().min(0).max(2000).optional().describe("start: target sample rate (0 = as fast as the probe allows). Actual Fs is reported."),
561
+ swo: z.array(z.string()).optional().describe("start (EXPERIMENTAL): map ITM stimulus ports to signal names, e.g. ['0:phase','1:isr_us']. Requires firmware that emits ITM on the SWO pin (NOT present in current CrossPad firmware — UNTESTED against real ITM). Omit for plain RAM polling. Fails soft: if SWV init fails, polling continues normally."),
562
+ query: z.string().optional().describe("symbols: case-insensitive substring filter."),
563
+ key: z.string().optional().describe("config_set: one of stm_elf_path|pyocd_python|probe_serial|trace_dir."),
564
+ value: z.string().optional().describe("config_set: the value to persist."),
565
+ window_from: z.number().optional().describe("read: start time (s) of the window."),
566
+ window_to: z.number().optional().describe("read: end time (s) of the window."),
567
+ max_points: z.number().int().min(1).max(5000).optional().describe("read: max points per signal (default 200)."),
568
+ format: z.enum(["csv"]).optional().describe("save: export format (csv)."),
569
+ },
570
+ outputSchema: O_Trace,
571
+ annotations: ANN_SIDE_EFFECT,
572
+ }, async ({ action, signals, rate_hz, swo, query, key, value, window_from, window_to, max_points, format }, extra) => {
573
+ switch (action) {
574
+ case "doctor": {
575
+ const r = await runDoctor(realProbe());
576
+ return ok({ action, ok: r.ok, issues: r.issues, device_state: r.probe ? "connected" : "no_probe" });
577
+ }
578
+ case "config_set": {
579
+ const allowed = ["stm_elf_path", "pyocd_python", "probe_serial", "trace_dir"];
580
+ if (!key || !allowed.includes(key))
581
+ return err(`config_set requires key in ${allowed.join("|")}`);
582
+ if (value === undefined)
583
+ return err("config_set requires `value`.");
584
+ setConfigValue(key, value);
585
+ return ok({ action, key, file_path: "~/.config/crosspad-mcp/config.json" });
586
+ }
587
+ case "symbols": {
588
+ const r = await listSymbols(query, undefined, extra.signal);
589
+ if (!r.success)
590
+ return err(r.error ?? "symbol resolution failed", { action });
591
+ return ok({ action, symbols: r.symbols });
592
+ }
593
+ case "start": {
594
+ if (!signals || signals.length === 0)
595
+ return err("start requires non-empty signals[].");
596
+ if (getActiveSession()?.isRunning())
597
+ return err("A trace is already running — stop it first.");
598
+ const doc = await runDoctor(realProbe());
599
+ if (!doc.ok) {
600
+ // §11.7: a vanished probe gets a distinct, actionable refusal.
601
+ if (doc.issues.some((i) => i.id === "no_probe_detected")) {
602
+ return err("No ST-Link detected on USB — replug the probe and retry (verify with `pyocd list` / `lsusb`).", { action, issues: doc.issues, device_state: "no_probe" });
603
+ }
604
+ return err("Doctor reported blocking issues — resolve them first.", { action, issues: doc.issues });
605
+ }
606
+ const sess = new TraceSession({ signals, rateHz: rate_hz ?? 0, swo });
607
+ sess.start();
608
+ setActiveSession(sess);
609
+ // §12.1/§12.4: ensure the PERSISTENT dashboard server is up (idempotent —
610
+ // reuses it across traces), auto-open the browser ONLY if no client is
611
+ // already connected (covers an external browser AND a VS Code Simple
612
+ // Browser tab opened on a previous trace), then bind this session so its
613
+ // frames broadcast to the UI (also emits trace_start).
614
+ const dashboard = getDashboard();
615
+ let uiUrl;
616
+ try {
617
+ uiUrl = await dashboard.ensureStarted();
618
+ if (!dashboard.hasClients())
619
+ traceBrowserOpener(uiUrl);
620
+ dashboard.bind(sess);
621
+ }
622
+ catch { /* UI optional — never block a trace on the dashboard */ }
623
+ // §11.6: don't lie. Wait for the first real frame and report what the
624
+ // daemon actually did (connect can fail fast → error/exit) instead of an
625
+ // optimistic "running" that masks a dead connect.
626
+ const first = await sess.waitForFirstFrame(3000);
627
+ if (first?.type === "error") {
628
+ // Connect failed — the daemon already exited. Clear the active session
629
+ // and surface the daemon's error + stderr tail. Unbind the dashboard
630
+ // (server stays up) so the next trace starts from a clean idle state.
631
+ sess.stop();
632
+ dashboard.unbind();
633
+ setActiveSession(null);
634
+ return err(`Trace connect failed: ${first.error}`, { action, device_state: "error: " + first.error, stderr_tail: sess.stderrTail(5) || undefined });
635
+ }
636
+ if (first && (first.type === "signals" || first.type === "sample")) {
637
+ return ok({ action, device_state: "running", signals, file_path: sess.filePath ?? undefined, ui_url: uiUrl });
638
+ }
639
+ // No frame within the window. If the proc already died, it's a failure;
640
+ // otherwise it's still connecting (honest) — caller can poll status.
641
+ if (!sess.isRunning()) {
642
+ dashboard.unbind();
643
+ setActiveSession(null);
644
+ return err(`Trace daemon exited before producing data (${sess.deviceState}).`, { action, device_state: sess.deviceState, stderr_tail: sess.stderrTail(5) || undefined });
645
+ }
646
+ return ok({ action, device_state: "connecting", signals, file_path: sess.filePath ?? undefined, ui_url: uiUrl });
647
+ }
648
+ case "stop": {
649
+ const s = getActiveSession();
650
+ if (!s)
651
+ return err("No active trace.");
652
+ const count = s.buffer.count();
653
+ // §11.5/§12.1: initiate teardown, but defer unbinding the dashboard and
654
+ // clearing the active session until the daemon has REALLY exited (the
655
+ // stop→SIGTERM→SIGKILL escalation can take ~4.5s). Clearing early would
656
+ // let a racing `start` spawn a second daemon onto the still-busy probe
657
+ // and make `status` report idle mid-teardown. onStopped fires
658
+ // synchronously if the process is already gone.
659
+ const d = getDashboard();
660
+ s.onStopped(() => {
661
+ d.unbind();
662
+ if (getActiveSession() === s)
663
+ setActiveSession(null);
664
+ });
665
+ s.stop();
666
+ return ok({ action, sample_count: count, file_path: s.filePath ?? undefined });
667
+ }
668
+ case "status": {
669
+ const s = getActiveSession();
670
+ if (!s)
671
+ return ok({ action, device_state: "idle", sample_count: 0 });
672
+ const n = s.buffer.count();
673
+ const elapsed = (performance.now() - s.startedAt) / 1000;
674
+ // §11.6: when the daemon is unhealthy, fold in the last stderr lines so
675
+ // the caller sees *why* without re-running.
676
+ const ds = s.deviceState;
677
+ const unhealthy = ds.startsWith("error") || ds === "probe_lost" || ds === "exited" || ds.startsWith("spawn_failed");
678
+ const tail = unhealthy ? (s.stderrTail(5) || undefined) : undefined;
679
+ return ok({ action, device_state: ds, sample_count: n, actual_fs: elapsed > 0 ? n / elapsed : 0, signals: s.buffer.signalNames(), stderr_tail: tail });
680
+ }
681
+ case "read": {
682
+ const s = getActiveSession();
683
+ if (!s)
684
+ return err("No active trace.");
685
+ const mp = max_points ?? 200;
686
+ const win = (window_from !== undefined || window_to !== undefined) ? { fromT: window_from, toT: window_to } : undefined;
687
+ const series = {};
688
+ const stats = {};
689
+ for (const sig of s.buffer.signalNames()) {
690
+ series[sig] = s.buffer.downsample(sig, mp, win);
691
+ stats[sig] = s.buffer.stats(sig);
692
+ }
693
+ return ok({ action, series, stats, device_state: s.deviceState, sample_count: s.buffer.count() });
694
+ }
695
+ case "save": {
696
+ const s = getActiveSession();
697
+ if (!s)
698
+ return err("No active trace.");
699
+ // `format` is constrained to "csv" by the input schema — no runtime branch needed.
700
+ const csvPath = (s.filePath ?? "/tmp/trace").replace(/\.cptrace$/, "") + ".csv";
701
+ writeCsv(csvPath, s.buffer, s.buffer.signalNames());
702
+ return ok({ action, file_path: csvPath });
703
+ }
704
+ case "device_state": {
705
+ const r = await getDeviceState(extra.signal);
706
+ if (!r.success)
707
+ return err(r.error ?? "device_state read failed", { action });
708
+ // Pack regs/decoded into `stats` (a schema field) so they survive
709
+ // outputSchema validation — O_Trace has no top-level regs/decoded keys.
710
+ return ok({
711
+ action,
712
+ device_state: r.accessible ? "accessible" : "inaccessible",
713
+ stats: { regs: r.regs, decoded: r.decoded, accessible: r.accessible },
714
+ });
715
+ }
716
+ case "ui": {
717
+ // §12.1: the dashboard is persistent and independent of any trace — just
718
+ // ensure the server is up and hand back the url. Works even when idle
719
+ // (no active trace): the UI shows a "waiting for trace…" state and
720
+ // /symbols falls back to the default ELF for autocomplete.
721
+ const url = await getDashboard().ensureStarted();
722
+ return ok({ action, ui_url: url });
723
+ }
724
+ case "add":
725
+ case "remove": {
726
+ const s = getActiveSession();
727
+ if (!s || !s.isRunning())
728
+ return err("No active trace — start one first.", { action });
729
+ if (!signals || signals.length === 0)
730
+ return err(`${action} requires non-empty signals[].`, { action });
731
+ // §4/§6: await the post-reconcile set (so the response reflects array
732
+ // expansion and dropped `unresolved` specs, not the pre-reconcile guess).
733
+ const reconciled = action === "add" ? await s.addSignals(signals) : await s.removeSignals(signals);
734
+ return ok({ action, signals: reconciled });
735
+ }
736
+ }
737
+ });
738
+ // ═══════════════════════════════════════════════════════════════════════
482
739
  // TEST
483
740
  // ═══════════════════════════════════════════════════════════════════════
484
741
  server.registerTool("crosspad_test_run", {
485
- description: "Build and run the Catch2 test suite for crosspad-pc. PREFER THIS over invoking the test binary directly — configures cmake with BUILD_TESTING=ON, parses Catch2 output into passed/failed counts and errors, supports filter and list_only.",
742
+ description: "[PC] Build and run the Catch2 test suite for crosspad-pc. PREFER THIS over invoking the test binary directly — configures cmake with BUILD_TESTING=ON, parses Catch2 output into passed/failed counts and errors, supports filter and list_only.",
486
743
  inputSchema: {
487
744
  filter: z.string().default("")
488
- .describe("Catch2 test filter (e.g. '[core]', 'PadManager*'). Empty = run all."),
745
+ .describe("Catch2 test filter (e.g. '[core]', 'PadManager*'). Default '' (empty) runs ALL tests — there is no opt-out for 'no tests'."),
489
746
  list_only: z.boolean().default(false)
490
- .describe("List discovered tests without running them."),
747
+ .describe("If true, list discovered tests matching `filter` without running them. Default false."),
491
748
  },
492
749
  outputSchema: O_Test,
493
750
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -499,12 +756,14 @@ server.registerTool("crosspad_test_run", {
499
756
  // SIM — screenshot
500
757
  // ═══════════════════════════════════════════════════════════════════════
501
758
  server.registerTool("crosspad_screenshot", {
502
- description: "Capture a PNG screenshot from the running PC simulator. By default saves to disk and returns the file_path. Set return_inline=true for inline image content (consumes more tokens).",
759
+ description: "[PC sim] Capture a PNG screenshot from the running PC simulator. " +
760
+ "Default behavior (return_inline=false): saves to <crosspad-pc>/screenshots/ and returns metadata + file_path (cheap, no token cost). " +
761
+ "Set return_inline=true ONLY when the LLM needs to actually see the image — that returns base64 inline and burns ~50-150k tokens.",
503
762
  inputSchema: {
504
763
  filename: z.string().optional()
505
- .describe("Custom filename (saved under <crosspad-pc>/screenshots/). Default: screenshot_<timestamp>.png"),
764
+ .describe("Custom filename (saved under <crosspad-pc>/screenshots/). Default: screenshot_<timestamp>.png. Ignored when return_inline=true."),
506
765
  return_inline: z.boolean().default(false)
507
- .describe("If true, returns inline base64 image content instead of file_path. Use only when the image is needed in-conversation."),
766
+ .describe("false (default) = save to disk, return file_path (token-cheap). true = return base64 image content for the LLM to view (token-expensive — only when the image must be analyzed)."),
508
767
  },
509
768
  outputSchema: O_Screenshot,
510
769
  annotations: ANN_SIDE_EFFECT,
@@ -541,19 +800,29 @@ server.registerTool("crosspad_screenshot", {
541
800
  // SIM — input events
542
801
  // ═══════════════════════════════════════════════════════════════════════
543
802
  server.registerTool("crosspad_input", {
544
- description: "Send a single input event to the running PC simulator (consolidated tool — replaces 7 separate tools in v6). Required fields depend on `action`: pad_press={pad,velocity?} · pad_release={pad} · encoder_rotate={delta} · encoder_press / encoder_release={} · click={x,y} · key={keycode}. The simulator validates and rejects bad combinations.",
803
+ description: "[PC sim] Send one input event to the running PC simulator (consolidated; replaces 7 v5 tools). " +
804
+ "Pick an `action`, then supply ONLY the fields it needs — extras are ignored. " +
805
+ "Required fields per action:\n" +
806
+ " • pad_press → pad (velocity optional, default 127)\n" +
807
+ " • pad_release → pad\n" +
808
+ " • encoder_rotate → delta (positive=CW, negative=CCW)\n" +
809
+ " • encoder_press → (none)\n" +
810
+ " • encoder_release → (none)\n" +
811
+ " • click → x, y\n" +
812
+ " • key → keycode (SDL keycode int)\n" +
813
+ "Requires the simulator to be running (crosspad_run first).",
545
814
  inputSchema: {
546
815
  action: z.enum([
547
816
  "pad_press", "pad_release",
548
817
  "encoder_rotate", "encoder_press", "encoder_release",
549
818
  "click", "key",
550
- ]).describe("Which input event to dispatch."),
551
- pad: PadIndex.optional().describe("Pad index (pad_press / pad_release)."),
552
- velocity: Velocity.optional().describe("Pad velocity (pad_press, default 127)."),
553
- delta: z.number().int().optional().describe("Encoder rotation delta. Positive=CW, negative=CCW. Typical -10..10."),
554
- x: z.number().int().min(0).optional().describe("X pixel coordinate (click)."),
555
- y: z.number().int().min(0).optional().describe("Y pixel coordinate (click)."),
556
- keycode: z.number().int().optional().describe("SDL keycode (key). E.g. 27=ESC, 32=SPACE, 13=RETURN."),
819
+ ]).describe("Which input event to dispatch. Required params — pad_press: pad (velocity?); pad_release: pad; encoder_rotate: delta; encoder_press/encoder_release: (none); click: x,y; key: keycode."),
820
+ pad: PadIndex.optional().describe("Required for action=pad_press|pad_release. Pad index 0-15."),
821
+ velocity: Velocity.optional().describe("Optional for action=pad_press (default 127). Ignored for other actions."),
822
+ delta: z.number().int().optional().describe("Required for action=encoder_rotate. Positive=CW, negative=CCW. Typical range -10..10."),
823
+ x: z.number().int().min(0).optional().describe("Required for action=click. X pixel coordinate (0 = left)."),
824
+ y: z.number().int().min(0).optional().describe("Required for action=click. Y pixel coordinate (0 = top)."),
825
+ keycode: z.number().int().optional().describe("Required for action=key. SDL keycode (e.g. 27=ESC, 32=SPACE, 13=RETURN)."),
557
826
  },
558
827
  outputSchema: O_Input,
559
828
  annotations: ANN_SIDE_EFFECT,
@@ -598,16 +867,23 @@ server.registerTool("crosspad_input", {
598
867
  // SIM — MIDI
599
868
  // ═══════════════════════════════════════════════════════════════════════
600
869
  server.registerTool("crosspad_midi", {
601
- description: "Send a single MIDI event to the running simulator (consolidated tool — replaces 4 separate tools in v6). Required fields depend on `type`: note_on/note_off={note,velocity?} · cc={cc_num,value} · program_change={program}.",
870
+ description: "[PC sim] Send one MIDI event to the running PC simulator (consolidated; replaces 4 v5 tools). " +
871
+ "Pick a `type`, then supply ONLY the fields it needs — extras are ignored. " +
872
+ "Required fields per type:\n" +
873
+ " • note_on → note (velocity optional, default 127)\n" +
874
+ " • note_off → note (velocity optional, default 0)\n" +
875
+ " • cc → cc_num, value ⚠️ NOT YET SUPPORTED BY PC SIM (no midi_cc handler — call fails fast)\n" +
876
+ " • program_change → program ⚠️ NOT YET SUPPORTED BY PC SIM (no midi_program_change handler — call fails fast)\n" +
877
+ "`channel` (0-15) defaults to 0 for every type. Only note_on/note_off actually reach the sim today.",
602
878
  inputSchema: {
603
879
  type: z.enum(["note_on", "note_off", "cc", "program_change"])
604
- .describe("MIDI event type."),
880
+ .describe("MIDI event type. Required params — note_on: note (velocity?); note_off: note (velocity?); cc: cc_num,value; program_change: program. channel? defaults 0 for all."),
605
881
  channel: Channel,
606
- note: Note.optional().describe("MIDI note number (note_on, note_off)."),
607
- velocity: Velocity.optional().describe("Velocity (note_on default 127, note_off default 0)."),
608
- cc_num: Cc.optional().describe("Controller number (cc)."),
609
- value: Cc7.optional().describe("Controller value (cc)."),
610
- program: Program.optional().describe("Program number (program_change)."),
882
+ note: Note.optional().describe("Required for type=note_on|note_off. MIDI note 0-127 (60 = middle C)."),
883
+ velocity: Velocity.optional().describe("Optional for type=note_on (default 127) and note_off (default 0). Ignored for cc/program_change."),
884
+ cc_num: Cc.optional().describe("Required for type=cc. MIDI controller number 0-127."),
885
+ value: Cc7.optional().describe("Required for type=cc. Controller value 0-127."),
886
+ program: Program.optional().describe("Required for type=program_change. Program number 0-127."),
611
887
  },
612
888
  outputSchema: O_Midi,
613
889
  annotations: ANN_SIDE_EFFECT,
@@ -644,13 +920,13 @@ server.registerTool("crosspad_midi", {
644
920
  // SIM — runtime state
645
921
  // ═══════════════════════════════════════════════════════════════════════
646
922
  server.registerTool("crosspad_stats", {
647
- description: "Read runtime statistics from the running PC simulator: pad state, capabilities, heap, registered apps, active pad logic.",
923
+ description: "[PC sim] Read runtime statistics from the running PC simulator: pad state, capabilities, heap, registered apps, active pad logic.",
648
924
  inputSchema: {},
649
925
  outputSchema: O_Stats,
650
926
  annotations: ANN_READ_ONLY,
651
927
  }, async () => jsonResponse((await crosspadStats())));
652
928
  server.registerTool("crosspad_settings_get", {
653
- description: "Read settings from the running simulator.",
929
+ description: "[PC sim] Read settings from the running simulator.",
654
930
  inputSchema: {
655
931
  category: z.enum(["all", "display", "keypad", "vibration", "wireless", "audio", "system"])
656
932
  .default("all")
@@ -660,12 +936,12 @@ server.registerTool("crosspad_settings_get", {
660
936
  annotations: ANN_READ_ONLY,
661
937
  }, async ({ category }) => jsonResponse((await crosspadSettingsGet(category))));
662
938
  server.registerTool("crosspad_settings_set", {
663
- description: "Write a single setting on the running simulator.",
939
+ description: "[PC sim] Write a single setting on the running simulator.",
664
940
  inputSchema: {
665
941
  key: z.string().min(1)
666
- .describe("Setting key (e.g. 'lcd_brightness', 'keypad.eco_mode', 'vibration.enable')"),
942
+ .describe("Setting key. Either a flat name ('lcd_brightness') or dotted category.field ('keypad.eco_mode', 'vibration.enable'). Use crosspad_settings_get to discover valid keys."),
667
943
  value: z.number()
668
- .describe("Numeric value. Booleans: 0=false, 1=true."),
944
+ .describe("Numeric value. Booleans must be encoded as 0=false, 1=true (no native bool support over the wire)."),
669
945
  },
670
946
  outputSchema: O_SettingsSet,
671
947
  annotations: ANN_DESTRUCTIVE,
@@ -707,9 +983,9 @@ server.registerTool("crosspad_commit", {
707
983
  description: "Commit staged changes in a specific CrossPad repo. PREFER THIS over raw `git commit` — handles repo aliases (idf/pc/arduino/core/gui), refuses on merge conflicts, uses 0600 tempfiles for messages (no shell-quoting issues with quotes/newlines/backticks), and never pushes. Stages files[] first if supplied.",
708
984
  inputSchema: {
709
985
  repo: RepoAlias,
710
- message: z.string().min(1).describe("Commit message"),
986
+ message: z.string().min(1).describe("Commit message. Newlines/quotes/backticks are safe — passed via 0600 tempfile, not shell-quoted."),
711
987
  files: z.array(z.string()).optional()
712
- .describe("Specific files to stage+commit. Omit to commit currently-staged changes."),
988
+ .describe("If supplied: stage exactly these files (repo-relative paths) then commit. If omitted: commit whatever is currently staged in the repo (no auto-stage)."),
713
989
  },
714
990
  outputSchema: O_Commit,
715
991
  annotations: ANN_DESTRUCTIVE,
@@ -718,19 +994,21 @@ server.registerTool("crosspad_commit", {
718
994
  // CODE — search and analysis
719
995
  // ═══════════════════════════════════════════════════════════════════════
720
996
  server.registerTool("crosspad_search_symbols", {
721
- description: "Search for symbol DEFINITIONS (classes, functions, macros, enums, typedefs) across CrossPad repos via git grep. PREFER THIS over raw `grep -r` or `git grep` — it filters to definitions only (skips call sites/declarations), classifies kind, and aggregates across all 5 repos automatically. Substring match: 'Foo' matches FooBar, MyFoo.",
997
+ description: "Search for symbol DEFINITIONS (classes, functions, macros, enums, typedefs) across CrossPad repos via git grep. PREFER THIS over raw `grep -r` or `git grep` — it filters to definitions only (skips call sites/declarations), classifies kind, and aggregates across all repos automatically. Substring match: 'Foo' matches FooBar, MyFoo. Vendored/generated trees (lvgl, managed_components, thorvg, TFT_eSPI, STM Drivers/Middlewares/CMSIS, build, …) are skipped by default — pass include_vendored=true to scan them.",
722
998
  inputSchema: {
723
999
  query: z.string().min(1).describe("Symbol name (substring match, case-insensitive on filter)"),
724
1000
  kind: z.enum(["class", "function", "macro", "enum", "typedef", "all"]).default("all"),
725
1001
  repos: z.array(z.string()).default(["all"])
726
- .describe("Repo names to scan, or ['all']. Names: crosspad-core, crosspad-gui, crosspad-pc, platform-idf, ESP32-S3."),
1002
+ .describe("Repo names to scan, or ['all']. Names: crosspad-core, crosspad-gui, crosspad-pc, platform-idf, ESP32-S3, stm32-r20."),
727
1003
  max_results: z.number().int().min(1).max(500).default(50),
728
1004
  context_lines: z.number().int().min(0).max(10).default(0)
729
1005
  .describe("Surrounding lines per match (like grep -C). 0 = no context."),
1006
+ include_vendored: z.boolean().default(false)
1007
+ .describe("Scan vendored/generated trees too (lvgl, managed_components, STM Drivers/Middlewares, build, …). Default false — these are almost always noise."),
730
1008
  },
731
1009
  outputSchema: O_SearchSymbols,
732
1010
  annotations: ANN_READ_ONLY,
733
- }, async ({ query, kind, repos, max_results, context_lines }) => jsonResponse({ success: true, ...crosspadSearchSymbols(query, kind, repos, max_results, context_lines) }));
1011
+ }, async ({ query, kind, repos, max_results, context_lines, include_vendored }) => jsonResponse({ success: true, ...crosspadSearchSymbols(query, kind, repos, max_results, context_lines, include_vendored) }));
734
1012
  server.registerTool("crosspad_list_interfaces", {
735
1013
  description: "List all crosspad-core interfaces (I*-prefixed classes in crosspad-core/include/crosspad/).",
736
1014
  inputSchema: {},
@@ -738,9 +1016,11 @@ server.registerTool("crosspad_list_interfaces", {
738
1016
  annotations: ANN_READ_ONLY,
739
1017
  }, async () => jsonResponse({ success: true, ...crosspadInterfaces("list") }));
740
1018
  server.registerTool("crosspad_interface_implementations", {
741
- description: "Find all classes implementing a given interface across CrossPad repos. Returns className, file path, platform.",
1019
+ description: "Find all classes implementing a given interface across CrossPad repos. Returns className, file path, platform. Use crosspad_list_interfaces first if you don't know exact names.",
742
1020
  inputSchema: {
743
- interface_name: z.string().min(1).describe("Interface name (e.g. 'IDisplay', 'IPadLogicHandler')"),
1021
+ interface_name: z.string().min(1)
1022
+ .regex(/^I[A-Z][A-Za-z0-9_]*$/, "Interface name must start with 'I' followed by an uppercase letter (e.g. 'IDisplay').")
1023
+ .describe("Interface name — MUST start with 'I' and use the exact crosspad-core casing (e.g. 'IDisplay', 'IPadLogicHandler', 'IKeyValueStore'). Not 'Display', not 'iDisplay'."),
744
1024
  },
745
1025
  outputSchema: O_Architecture,
746
1026
  annotations: ANN_READ_ONLY,
@@ -763,7 +1043,7 @@ server.registerTool("crosspad_list_apps_source", {
763
1043
  // APPS — package manager (crosspad-apps registry)
764
1044
  // ═══════════════════════════════════════════════════════════════════════
765
1045
  server.registerTool("crosspad_apps_list", {
766
- description: "List apps from the crosspad-apps registry, aggregating installation status across all detected platform repos. Reads JSON; no Python required.",
1046
+ description: "List apps from the crosspad-apps registry, aggregating installation status across all detected platform repos. Reads JSON; no Python required. Different from crosspad_list_apps_source (which scans REGISTER_APP() in source code).",
767
1047
  inputSchema: {
768
1048
  show_all: z.boolean().default(false)
769
1049
  .describe("Include apps incompatible with detected platforms."),
@@ -798,15 +1078,23 @@ server.registerTool("crosspad_apps_remove", {
798
1078
  return jsonResponse((await crosspadAppRemove(app_name, platform, onLine, extra.signal)));
799
1079
  });
800
1080
  server.registerTool("crosspad_apps_update", {
801
- description: "Update one or all installed apps on a platform. Specify app_name OR set update_all=true.",
1081
+ description: "Update one or all installed apps on a platform. EXACTLY ONE of these must be supplied: " +
1082
+ "set `app_name` to update a single app, OR set `update_all=true` to update every installed app on the platform. " +
1083
+ "Supplying both, or neither, is an error.",
802
1084
  inputSchema: {
803
1085
  platform: Platform,
804
- app_name: AppName.optional().describe("App ID to update. Required unless update_all=true."),
805
- update_all: z.boolean().default(false),
1086
+ app_name: AppName.optional().describe("App ID (e.g. 'metronome') to update one app. Mutually exclusive with update_all=true."),
1087
+ update_all: z.boolean().default(false).describe("If true, update all installed apps on `platform`. Mutually exclusive with app_name."),
806
1088
  },
807
1089
  outputSchema: O_AppAction,
808
1090
  annotations: ANN_DESTRUCTIVE_OPEN,
809
1091
  }, async ({ platform, app_name, update_all }, extra) => {
1092
+ if (!app_name && !update_all) {
1093
+ return err("Specify `app_name` to update a single app, OR set `update_all=true` to update every installed app.");
1094
+ }
1095
+ if (app_name && update_all) {
1096
+ return err("`app_name` and `update_all=true` are mutually exclusive — pick one.");
1097
+ }
810
1098
  const onLine = makeProgressLogger("apps-update", extra);
811
1099
  return jsonResponse((await crosspadAppUpdate(platform, app_name, update_all, onLine, extra.signal)));
812
1100
  });
@@ -868,6 +1156,42 @@ server.resource("crosspad-workspace", "crosspad://workspace", {
868
1156
  };
869
1157
  });
870
1158
  // ═══════════════════════════════════════════════════════════════════════
1159
+ // crosspad://trace — live SWD trace session status. Cheap snapshot the LLM
1160
+ // can read without a tool call to learn whether a trace is running, the
1161
+ // device state, achieved Fs, signals, and the dashboard URL.
1162
+ // ═══════════════════════════════════════════════════════════════════════
1163
+ server.resource("crosspad-trace", "crosspad://trace", {
1164
+ description: "Live SWD trace session status: active flag, device_state, sample_count, achieved Fs, traced signals, and the web UI URL. Returns {active:false} when idle.",
1165
+ mimeType: "application/json",
1166
+ }, async () => {
1167
+ const s = getActiveSession();
1168
+ const payload = s
1169
+ ? {
1170
+ active: true,
1171
+ device_state: s.deviceState,
1172
+ sample_count: s.buffer.count(),
1173
+ actual_fs: (() => {
1174
+ const elapsed = (performance.now() - s.startedAt) / 1000;
1175
+ return elapsed > 0 ? s.buffer.count() / elapsed : 0;
1176
+ })(),
1177
+ signals: s.buffer.signalNames(),
1178
+ file_path: s.filePath ?? null,
1179
+ // §12.1: the dashboard URL is owned by the persistent singleton; it's a
1180
+ // stable loopback URL once any trace/ui action has started the server.
1181
+ ui_url: getDashboard().port ? buildUiUrl(getDashboard().port) : null,
1182
+ }
1183
+ : { active: false };
1184
+ return {
1185
+ contents: [
1186
+ {
1187
+ uri: "crosspad://trace",
1188
+ mimeType: "application/json",
1189
+ text: JSON.stringify(payload, null, 2),
1190
+ },
1191
+ ],
1192
+ };
1193
+ });
1194
+ // ═══════════════════════════════════════════════════════════════════════
871
1195
  // RESOURCES — apps registry & installed manifest per platform
872
1196
  // One static resource per file-per-detected-platform. LLM/clients can
873
1197
  // inspect raw JSON without spending a tool call. Resource set updates only
@@ -929,7 +1253,7 @@ import path from "path";
929
1253
  // clients must construct concrete URIs.
930
1254
  // ═══════════════════════════════════════════════════════════════════════
931
1255
  server.registerResource("crosspad-symbol", new ResourceTemplate("crosspad://symbols/{repo}/{symbol}", { list: undefined }), {
932
- description: "Resolve a single symbol by repo+name. URI: crosspad://symbols/<repo>/<symbol>. <repo> is one of: crosspad-core, crosspad-gui, crosspad-pc, platform-idf, ESP32-S3, or 'all'. Returns JSON with matching definition(s) (class/function/macro/enum/typedef). For substring/wildcard search, use the crosspad_search_symbols tool.",
1256
+ description: "Resolve a single symbol by repo+name. URI: crosspad://symbols/<repo>/<symbol>. <repo> is one of: crosspad-core, crosspad-gui, crosspad-pc, platform-idf, ESP32-S3, stm32-r20, or 'all'. Returns JSON with matching definition(s) (class/function/macro/enum/typedef). For substring/wildcard search, use the crosspad_search_symbols tool.",
933
1257
  mimeType: "application/json",
934
1258
  }, async (uri, variables) => {
935
1259
  const repo = decodeURIComponent(String(Array.isArray(variables.repo) ? variables.repo[0] : variables.repo ?? "")).trim();