crosspad-mcp-server 8.1.2 → 9.0.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 +369 -49
  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 +1066 -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,8 @@ 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
+
35
45
  WHEN TO USE THESE TOOLS — in any conversation that touches a CrossPad repo, prefer the crosspad_* tools over raw shell equivalents:
36
46
 
37
47
  - Inspecting code → crosspad_search_symbols (NOT \`grep -r\`); crosspad_list_interfaces; crosspad_interface_implementations.
@@ -42,6 +52,7 @@ WHEN TO USE THESE TOOLS — in any conversation that touches a CrossPad repo, pr
42
52
  - Sim interaction → crosspad_screenshot, crosspad_input, crosspad_midi, crosspad_stats, crosspad_settings_get/set.
43
53
  - Apps (registry) → crosspad_apps_list / install / remove / update / sync (NOT manual submodule git ops).
44
54
  - Commits → crosspad_commit (NOT raw \`git commit\`) — handles multi-repo paths and refuses on merge conflicts.
55
+ - SWD tracing → crosspad_trace (STM32 firmware variable RT trace over ST-Link). Run action=doctor first; resolve issues; then action=symbols → start → read.
45
56
 
46
57
  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
58
 
@@ -226,13 +237,34 @@ const O_Devices = {
226
237
  devices: z.array(z.object({
227
238
  port: z.string(),
228
239
  description: z.string().optional(),
229
- vid: z.string().optional(),
230
- pid: z.string().optional(),
240
+ vid: z.number().int().optional(),
241
+ pid: z.number().int().optional(),
231
242
  is_crosspad: z.boolean(),
243
+ kind: z.enum(["esp-native", "stm-bridge"]).nullable().optional(),
232
244
  }).passthrough()),
233
245
  crosspad_count: z.number().int().optional(),
234
246
  ...ErrorField,
235
247
  };
248
+ const O_Trace = {
249
+ success: z.boolean(),
250
+ action: z.string().optional(),
251
+ ok: z.boolean().optional(),
252
+ issues: z.array(z.record(z.string(), z.unknown())).optional(),
253
+ symbols: z.array(z.record(z.string(), z.unknown())).optional(),
254
+ device_state: z.string().optional(),
255
+ actual_fs: z.number().optional(),
256
+ sample_count: z.number().int().optional(),
257
+ signals: z.array(z.string()).optional(),
258
+ series: z.record(z.string(), z.unknown()).optional(),
259
+ stats: z.record(z.string(), z.unknown()).optional(),
260
+ file_path: z.string().optional(),
261
+ ui_url: z.string().optional(),
262
+ key: z.string().optional(),
263
+ // §11.6: last few daemon stderr lines, surfaced when device_state is an
264
+ // error / probe_lost / exited so the caller sees *why* without a re-run.
265
+ stderr_tail: z.string().optional(),
266
+ ...ErrorField,
267
+ };
236
268
  const O_Test = {
237
269
  success: z.boolean(),
238
270
  tests_found: z.boolean(),
@@ -355,15 +387,23 @@ const O_AppAction = {
355
387
  const BuildPlatform = z.enum(["pc", "idf"]).describe("Target platform: 'pc' = host simulator, 'idf' = ESP32-S3 firmware.");
356
388
  const PlatformPcOnly = z.enum(["pc"]).default("pc").describe("Platform — currently only 'pc' is supported here.");
357
389
  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).",
390
+ description: "Build CrossPad for the given platform.\n" +
391
+ " • platform='pc' → CMake + Ninja host simulator. PREFER THIS over `cmake --build build` (picks right MSVC env on Windows, parses errors/warnings, streams progress).\n" +
392
+ " • 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" +
393
+ "Mode×platform compatibility:\n" +
394
+ " • incremental → both (default)\n" +
395
+ " • clean → both (wipes build dir, then builds)\n" +
396
+ " • reconfigure → PC only (re-runs cmake without wiping cache)\n" +
397
+ " • fullclean → IDF only (runs idf.py fullclean, then builds)",
359
398
  inputSchema: {
360
399
  platform: BuildPlatform,
361
400
  mode: z.enum(["incremental", "clean", "fullclean", "reconfigure"])
362
401
  .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)."),
402
+ .describe("Build mode. Compatibility: incremental & clean = both platforms; reconfigure = PC only; fullclean = IDF only. " +
403
+ "Pick incremental for normal iteration; clean if you suspect stale artifacts; fullclean (IDF) after adding new apps; reconfigure (PC) after editing CMakeLists."),
364
404
  build_type: z.enum(["Debug", "Release", "RelWithDebInfo"])
365
405
  .default("Debug")
366
- .describe("CMake build type — PC only. Only honored on clean/reconfigure (incremental keeps existing cache)."),
406
+ .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
407
  },
368
408
  outputSchema: O_Build,
369
409
  annotations: ANN_DESTRUCTIVE,
@@ -418,12 +458,12 @@ server.registerTool("crosspad_check", {
418
458
  },
419
459
  outputSchema: O_BuildCheck,
420
460
  annotations: ANN_READ_ONLY,
421
- }, async () => jsonResponse(crosspadBuildCheck()));
461
+ }, async () => jsonResponse({ success: true, exe_path: _BIN_EXE, ...crosspadBuildCheck() }));
422
462
  // ═══════════════════════════════════════════════════════════════════════
423
463
  // FLASH — unified UART/OTA into one tool with `transport` axis
424
464
  // ═══════════════════════════════════════════════════════════════════════
425
465
  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.",
466
+ description: "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
467
  inputSchema: {
428
468
  transport: z.enum(["uart", "ota"]).describe("'uart' = bootloader-mode flash via idf.py; 'ota' = USB-CDC OTA flash via ota_flash.py."),
429
469
  port: Port.optional(),
@@ -444,18 +484,24 @@ server.registerTool("crosspad_flash", {
444
484
  return jsonResponse(await crosspadIdfOta(port, firmware_path, onLine, extra.signal));
445
485
  });
446
486
  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.",
487
+ description: "Capture logs (consolidated; replaces crosspad_log_pc and crosspad_log_idf in v6).\n" +
488
+ " • target='pc' → spawn the built sim binary, capture stdout/stderr, then kill it. " +
489
+ "Fields used: timeout_seconds (default 5), max_lines (default 200). `port` and `filter` MUST be omitted.\n" +
490
+ " • target='idf' → read serial from a connected ESP32-S3 via pyserial (no TTY needed). " +
491
+ "Fields used: port (auto-detected if omitted), timeout_seconds (default 10), max_lines (default 500), filter (substring, case-insensitive).",
448
492
  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."),
493
+ target: z.enum(["pc", "idf"]).describe("'pc' = run+capture sim binary; 'idf' = read serial from connected device. Other fields are conditional — see description."),
494
+ port: Port.optional().describe("idf only. Serial port path. Auto-detected if omitted; required when multiple devices connected. MUST be omitted for target=pc."),
495
+ timeout_seconds: TimeoutSec.optional().describe("Capture duration in seconds. Defaults: 5 (pc), 10 (idf)."),
496
+ max_lines: MaxLines.optional().describe("Max output lines. Defaults: 200 (pc), 500 (idf)."),
453
497
  filter: z.string().optional()
454
- .describe("Case-insensitive substring filter (idf only). Only lines containing this string are returned."),
498
+ .describe("idf only. Case-insensitive substring filter only matching lines returned. MUST be omitted for target=pc."),
499
+ reset_to_boot: z.boolean().optional()
500
+ .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
501
  },
456
502
  outputSchema: O_Log,
457
503
  annotations: ANN_READ_ONLY,
458
- }, async ({ target, port, timeout_seconds, max_lines, filter }, extra) => {
504
+ }, async ({ target, port, timeout_seconds, max_lines, filter, reset_to_boot }, extra) => {
459
505
  if (target === "pc") {
460
506
  if (port)
461
507
  return err("Field 'port' is not used when target='pc'.");
@@ -469,25 +515,232 @@ server.registerTool("crosspad_log", {
469
515
  // target === "idf"
470
516
  const onLine = makeProgressLogger("log-idf", extra);
471
517
  return jsonResponse({
472
- ...(await crosspadIdfMonitor(port, timeout_seconds ?? 10, max_lines ?? 500, filter, onLine, extra.signal)),
518
+ ...(await crosspadIdfMonitor(port, timeout_seconds ?? 10, max_lines ?? 500, filter, onLine, extra.signal, reset_to_boot ?? false)),
473
519
  });
474
520
  });
475
521
  server.registerTool("crosspad_devices", {
476
- description: "List all connected USB serial devices. Identifies CrossPad devices (Espressif VID 0x303a, PID 0x3456) separately from other ports.",
522
+ description: "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
523
  inputSchema: {},
478
524
  outputSchema: O_Devices,
479
525
  annotations: ANN_READ_ONLY,
480
526
  }, async () => jsonResponse(listDevices()));
481
527
  // ═══════════════════════════════════════════════════════════════════════
528
+ // SWD TRACER
529
+ // ═══════════════════════════════════════════════════════════════════════
530
+ // §12.4 injectable browser opener — defaults to the real platform opener but is
531
+ // overridable (setTraceBrowserOpener) so a unit test can assert auto-open is
532
+ // called/skipped without launching a real browser. Returns true if it spawned.
533
+ let traceBrowserOpener = openInBrowser;
534
+ export function setTraceBrowserOpener(fn) { traceBrowserOpener = fn; }
535
+ const TraceAction = z.enum([
536
+ "doctor", "config_set", "symbols", "start", "stop",
537
+ "add", "remove", "status", "read", "save", "device_state", "ui",
538
+ ]);
539
+ server.registerTool("crosspad_trace", {
540
+ description: "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" +
541
+ " • doctor → environment precheck → issues[] (run this FIRST; resolve issues, then config_set).\n" +
542
+ " • config_set → persist a resolved path/serial to ~/.config/crosspad-mcp/config.json (key,value).\n" +
543
+ " • symbols → list/search traceable variables from the ELF (query optional).\n" +
544
+ " • start → begin a background trace (signals[], rate_hz).\n" +
545
+ " • stop → end the active trace.\n" +
546
+ " • add/remove → mutate the live poll set of the active trace (signals[]); returns the current signal set.\n" +
547
+ " • status → device_state (running/stop_suspected/exited), sample_count, actual_fs, signals.\n" +
548
+ " • read → recent samples downsampled + per-signal stats (cheap; safe for the LLM).\n" +
549
+ " • save → export the in-memory buffer to CSV (returns file_path).\n" +
550
+ " • device_state → deep low-power/STOP register dump.\n" +
551
+ " • ui → returns the localhost dashboard URL.\n" +
552
+ "Signal names accept array indexing, e.g. 's_inputs[0]', 's_adc_raw[3]'.",
553
+ inputSchema: {
554
+ action: TraceAction,
555
+ signals: z.array(z.string()).optional().describe("start: variable names from `symbols` (e.g. ['s_vbat_mv','s_inputs[0]'])."),
556
+ 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."),
557
+ 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."),
558
+ query: z.string().optional().describe("symbols: case-insensitive substring filter."),
559
+ key: z.string().optional().describe("config_set: one of stm_elf_path|pyocd_python|probe_serial|trace_dir."),
560
+ value: z.string().optional().describe("config_set: the value to persist."),
561
+ window_from: z.number().optional().describe("read: start time (s) of the window."),
562
+ window_to: z.number().optional().describe("read: end time (s) of the window."),
563
+ max_points: z.number().int().min(1).max(5000).optional().describe("read: max points per signal (default 200)."),
564
+ format: z.enum(["csv"]).optional().describe("save: export format (csv)."),
565
+ },
566
+ outputSchema: O_Trace,
567
+ annotations: ANN_SIDE_EFFECT,
568
+ }, async ({ action, signals, rate_hz, swo, query, key, value, window_from, window_to, max_points, format }, extra) => {
569
+ switch (action) {
570
+ case "doctor": {
571
+ const r = await runDoctor(realProbe());
572
+ return ok({ action, ok: r.ok, issues: r.issues, device_state: r.probe ? "connected" : "no_probe" });
573
+ }
574
+ case "config_set": {
575
+ const allowed = ["stm_elf_path", "pyocd_python", "probe_serial", "trace_dir"];
576
+ if (!key || !allowed.includes(key))
577
+ return err(`config_set requires key in ${allowed.join("|")}`);
578
+ if (value === undefined)
579
+ return err("config_set requires `value`.");
580
+ setConfigValue(key, value);
581
+ return ok({ action, key, file_path: "~/.config/crosspad-mcp/config.json" });
582
+ }
583
+ case "symbols": {
584
+ const r = await listSymbols(query, undefined, extra.signal);
585
+ if (!r.success)
586
+ return err(r.error ?? "symbol resolution failed", { action });
587
+ return ok({ action, symbols: r.symbols });
588
+ }
589
+ case "start": {
590
+ if (!signals || signals.length === 0)
591
+ return err("start requires non-empty signals[].");
592
+ if (getActiveSession()?.isRunning())
593
+ return err("A trace is already running — stop it first.");
594
+ const doc = await runDoctor(realProbe());
595
+ if (!doc.ok) {
596
+ // §11.7: a vanished probe gets a distinct, actionable refusal.
597
+ if (doc.issues.some((i) => i.id === "no_probe_detected")) {
598
+ 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" });
599
+ }
600
+ return err("Doctor reported blocking issues — resolve them first.", { action, issues: doc.issues });
601
+ }
602
+ const sess = new TraceSession({ signals, rateHz: rate_hz ?? 0, swo });
603
+ sess.start();
604
+ setActiveSession(sess);
605
+ // §12.1/§12.4: ensure the PERSISTENT dashboard server is up (idempotent —
606
+ // reuses it across traces), auto-open the browser ONLY if no client is
607
+ // already connected (covers an external browser AND a VS Code Simple
608
+ // Browser tab opened on a previous trace), then bind this session so its
609
+ // frames broadcast to the UI (also emits trace_start).
610
+ const dashboard = getDashboard();
611
+ let uiUrl;
612
+ try {
613
+ uiUrl = await dashboard.ensureStarted();
614
+ if (!dashboard.hasClients())
615
+ traceBrowserOpener(uiUrl);
616
+ dashboard.bind(sess);
617
+ }
618
+ catch { /* UI optional — never block a trace on the dashboard */ }
619
+ // §11.6: don't lie. Wait for the first real frame and report what the
620
+ // daemon actually did (connect can fail fast → error/exit) instead of an
621
+ // optimistic "running" that masks a dead connect.
622
+ const first = await sess.waitForFirstFrame(3000);
623
+ if (first?.type === "error") {
624
+ // Connect failed — the daemon already exited. Clear the active session
625
+ // and surface the daemon's error + stderr tail. Unbind the dashboard
626
+ // (server stays up) so the next trace starts from a clean idle state.
627
+ sess.stop();
628
+ dashboard.unbind();
629
+ setActiveSession(null);
630
+ return err(`Trace connect failed: ${first.error}`, { action, device_state: "error: " + first.error, stderr_tail: sess.stderrTail(5) || undefined });
631
+ }
632
+ if (first && (first.type === "signals" || first.type === "sample")) {
633
+ return ok({ action, device_state: "running", signals, file_path: sess.filePath ?? undefined, ui_url: uiUrl });
634
+ }
635
+ // No frame within the window. If the proc already died, it's a failure;
636
+ // otherwise it's still connecting (honest) — caller can poll status.
637
+ if (!sess.isRunning()) {
638
+ dashboard.unbind();
639
+ setActiveSession(null);
640
+ return err(`Trace daemon exited before producing data (${sess.deviceState}).`, { action, device_state: sess.deviceState, stderr_tail: sess.stderrTail(5) || undefined });
641
+ }
642
+ return ok({ action, device_state: "connecting", signals, file_path: sess.filePath ?? undefined, ui_url: uiUrl });
643
+ }
644
+ case "stop": {
645
+ const s = getActiveSession();
646
+ if (!s)
647
+ return err("No active trace.");
648
+ const count = s.buffer.count();
649
+ // §11.5/§12.1: initiate teardown, but defer unbinding the dashboard and
650
+ // clearing the active session until the daemon has REALLY exited (the
651
+ // stop→SIGTERM→SIGKILL escalation can take ~4.5s). Clearing early would
652
+ // let a racing `start` spawn a second daemon onto the still-busy probe
653
+ // and make `status` report idle mid-teardown. onStopped fires
654
+ // synchronously if the process is already gone.
655
+ const d = getDashboard();
656
+ s.onStopped(() => {
657
+ d.unbind();
658
+ if (getActiveSession() === s)
659
+ setActiveSession(null);
660
+ });
661
+ s.stop();
662
+ return ok({ action, sample_count: count, file_path: s.filePath ?? undefined });
663
+ }
664
+ case "status": {
665
+ const s = getActiveSession();
666
+ if (!s)
667
+ return ok({ action, device_state: "idle", sample_count: 0 });
668
+ const n = s.buffer.count();
669
+ const elapsed = (performance.now() - s.startedAt) / 1000;
670
+ // §11.6: when the daemon is unhealthy, fold in the last stderr lines so
671
+ // the caller sees *why* without re-running.
672
+ const ds = s.deviceState;
673
+ const unhealthy = ds.startsWith("error") || ds === "probe_lost" || ds === "exited" || ds.startsWith("spawn_failed");
674
+ const tail = unhealthy ? (s.stderrTail(5) || undefined) : undefined;
675
+ return ok({ action, device_state: ds, sample_count: n, actual_fs: elapsed > 0 ? n / elapsed : 0, signals: s.buffer.signalNames(), stderr_tail: tail });
676
+ }
677
+ case "read": {
678
+ const s = getActiveSession();
679
+ if (!s)
680
+ return err("No active trace.");
681
+ const mp = max_points ?? 200;
682
+ const win = (window_from !== undefined || window_to !== undefined) ? { fromT: window_from, toT: window_to } : undefined;
683
+ const series = {};
684
+ const stats = {};
685
+ for (const sig of s.buffer.signalNames()) {
686
+ series[sig] = s.buffer.downsample(sig, mp, win);
687
+ stats[sig] = s.buffer.stats(sig);
688
+ }
689
+ return ok({ action, series, stats, device_state: s.deviceState, sample_count: s.buffer.count() });
690
+ }
691
+ case "save": {
692
+ const s = getActiveSession();
693
+ if (!s)
694
+ return err("No active trace.");
695
+ // `format` is constrained to "csv" by the input schema — no runtime branch needed.
696
+ const csvPath = (s.filePath ?? "/tmp/trace").replace(/\.cptrace$/, "") + ".csv";
697
+ writeCsv(csvPath, s.buffer, s.buffer.signalNames());
698
+ return ok({ action, file_path: csvPath });
699
+ }
700
+ case "device_state": {
701
+ const r = await getDeviceState(extra.signal);
702
+ if (!r.success)
703
+ return err(r.error ?? "device_state read failed", { action });
704
+ // Pack regs/decoded into `stats` (a schema field) so they survive
705
+ // outputSchema validation — O_Trace has no top-level regs/decoded keys.
706
+ return ok({
707
+ action,
708
+ device_state: r.accessible ? "accessible" : "inaccessible",
709
+ stats: { regs: r.regs, decoded: r.decoded, accessible: r.accessible },
710
+ });
711
+ }
712
+ case "ui": {
713
+ // §12.1: the dashboard is persistent and independent of any trace — just
714
+ // ensure the server is up and hand back the url. Works even when idle
715
+ // (no active trace): the UI shows a "waiting for trace…" state and
716
+ // /symbols falls back to the default ELF for autocomplete.
717
+ const url = await getDashboard().ensureStarted();
718
+ return ok({ action, ui_url: url });
719
+ }
720
+ case "add":
721
+ case "remove": {
722
+ const s = getActiveSession();
723
+ if (!s || !s.isRunning())
724
+ return err("No active trace — start one first.", { action });
725
+ if (!signals || signals.length === 0)
726
+ return err(`${action} requires non-empty signals[].`, { action });
727
+ // §4/§6: await the post-reconcile set (so the response reflects array
728
+ // expansion and dropped `unresolved` specs, not the pre-reconcile guess).
729
+ const reconciled = action === "add" ? await s.addSignals(signals) : await s.removeSignals(signals);
730
+ return ok({ action, signals: reconciled });
731
+ }
732
+ }
733
+ });
734
+ // ═══════════════════════════════════════════════════════════════════════
482
735
  // TEST
483
736
  // ═══════════════════════════════════════════════════════════════════════
484
737
  server.registerTool("crosspad_test_run", {
485
738
  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.",
486
739
  inputSchema: {
487
740
  filter: z.string().default("")
488
- .describe("Catch2 test filter (e.g. '[core]', 'PadManager*'). Empty = run all."),
741
+ .describe("Catch2 test filter (e.g. '[core]', 'PadManager*'). Default '' (empty) runs ALL tests — there is no opt-out for 'no tests'."),
489
742
  list_only: z.boolean().default(false)
490
- .describe("List discovered tests without running them."),
743
+ .describe("If true, list discovered tests matching `filter` without running them. Default false."),
491
744
  },
492
745
  outputSchema: O_Test,
493
746
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -499,12 +752,14 @@ server.registerTool("crosspad_test_run", {
499
752
  // SIM — screenshot
500
753
  // ═══════════════════════════════════════════════════════════════════════
501
754
  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).",
755
+ description: "Capture a PNG screenshot from the running PC simulator. " +
756
+ "Default behavior (return_inline=false): saves to <crosspad-pc>/screenshots/ and returns metadata + file_path (cheap, no token cost). " +
757
+ "Set return_inline=true ONLY when the LLM needs to actually see the image — that returns base64 inline and burns ~50-150k tokens.",
503
758
  inputSchema: {
504
759
  filename: z.string().optional()
505
- .describe("Custom filename (saved under <crosspad-pc>/screenshots/). Default: screenshot_<timestamp>.png"),
760
+ .describe("Custom filename (saved under <crosspad-pc>/screenshots/). Default: screenshot_<timestamp>.png. Ignored when return_inline=true."),
506
761
  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."),
762
+ .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
763
  },
509
764
  outputSchema: O_Screenshot,
510
765
  annotations: ANN_SIDE_EFFECT,
@@ -541,19 +796,29 @@ server.registerTool("crosspad_screenshot", {
541
796
  // SIM — input events
542
797
  // ═══════════════════════════════════════════════════════════════════════
543
798
  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.",
799
+ description: "Send one input event to the running PC simulator (consolidated; replaces 7 v5 tools). " +
800
+ "Pick an `action`, then supply ONLY the fields it needs — extras are ignored. " +
801
+ "Required fields per action:\n" +
802
+ " • pad_press → pad (velocity optional, default 127)\n" +
803
+ " • pad_release → pad\n" +
804
+ " • encoder_rotate → delta (positive=CW, negative=CCW)\n" +
805
+ " • encoder_press → (none)\n" +
806
+ " • encoder_release → (none)\n" +
807
+ " • click → x, y\n" +
808
+ " • key → keycode (SDL keycode int)\n" +
809
+ "Requires the simulator to be running (crosspad_run first).",
545
810
  inputSchema: {
546
811
  action: z.enum([
547
812
  "pad_press", "pad_release",
548
813
  "encoder_rotate", "encoder_press", "encoder_release",
549
814
  "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."),
815
+ ]).describe("Which input event to dispatch — see description for required fields per action."),
816
+ pad: PadIndex.optional().describe("Required for action=pad_press|pad_release. Pad index 0-15."),
817
+ velocity: Velocity.optional().describe("Optional for action=pad_press (default 127). Ignored for other actions."),
818
+ delta: z.number().int().optional().describe("Required for action=encoder_rotate. Positive=CW, negative=CCW. Typical range -10..10."),
819
+ x: z.number().int().min(0).optional().describe("Required for action=click. X pixel coordinate (0 = left)."),
820
+ y: z.number().int().min(0).optional().describe("Required for action=click. Y pixel coordinate (0 = top)."),
821
+ keycode: z.number().int().optional().describe("Required for action=key. SDL keycode (e.g. 27=ESC, 32=SPACE, 13=RETURN)."),
557
822
  },
558
823
  outputSchema: O_Input,
559
824
  annotations: ANN_SIDE_EFFECT,
@@ -598,16 +863,23 @@ server.registerTool("crosspad_input", {
598
863
  // SIM — MIDI
599
864
  // ═══════════════════════════════════════════════════════════════════════
600
865
  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}.",
866
+ description: "Send one MIDI event to the running PC simulator (consolidated; replaces 4 v5 tools). " +
867
+ "Pick a `type`, then supply ONLY the fields it needs — extras are ignored. " +
868
+ "Required fields per type:\n" +
869
+ " • note_on → note (velocity optional, default 127)\n" +
870
+ " • note_off → note (velocity optional, default 0)\n" +
871
+ " • cc → cc_num, value ⚠️ NOT YET SUPPORTED BY PC SIM (no midi_cc handler — call fails fast)\n" +
872
+ " • program_change → program ⚠️ NOT YET SUPPORTED BY PC SIM (no midi_program_change handler — call fails fast)\n" +
873
+ "`channel` (0-15) defaults to 0 for every type. Only note_on/note_off actually reach the sim today.",
602
874
  inputSchema: {
603
875
  type: z.enum(["note_on", "note_off", "cc", "program_change"])
604
- .describe("MIDI event type."),
876
+ .describe("MIDI event type — see description for required fields per type."),
605
877
  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)."),
878
+ note: Note.optional().describe("Required for type=note_on|note_off. MIDI note 0-127 (60 = middle C)."),
879
+ velocity: Velocity.optional().describe("Optional for type=note_on (default 127) and note_off (default 0). Ignored for cc/program_change."),
880
+ cc_num: Cc.optional().describe("Required for type=cc. MIDI controller number 0-127."),
881
+ value: Cc7.optional().describe("Required for type=cc. Controller value 0-127."),
882
+ program: Program.optional().describe("Required for type=program_change. Program number 0-127."),
611
883
  },
612
884
  outputSchema: O_Midi,
613
885
  annotations: ANN_SIDE_EFFECT,
@@ -663,9 +935,9 @@ server.registerTool("crosspad_settings_set", {
663
935
  description: "Write a single setting on the running simulator.",
664
936
  inputSchema: {
665
937
  key: z.string().min(1)
666
- .describe("Setting key (e.g. 'lcd_brightness', 'keypad.eco_mode', 'vibration.enable')"),
938
+ .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
939
  value: z.number()
668
- .describe("Numeric value. Booleans: 0=false, 1=true."),
940
+ .describe("Numeric value. Booleans must be encoded as 0=false, 1=true (no native bool support over the wire)."),
669
941
  },
670
942
  outputSchema: O_SettingsSet,
671
943
  annotations: ANN_DESTRUCTIVE,
@@ -707,9 +979,9 @@ server.registerTool("crosspad_commit", {
707
979
  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
980
  inputSchema: {
709
981
  repo: RepoAlias,
710
- message: z.string().min(1).describe("Commit message"),
982
+ message: z.string().min(1).describe("Commit message. Newlines/quotes/backticks are safe — passed via 0600 tempfile, not shell-quoted."),
711
983
  files: z.array(z.string()).optional()
712
- .describe("Specific files to stage+commit. Omit to commit currently-staged changes."),
984
+ .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
985
  },
714
986
  outputSchema: O_Commit,
715
987
  annotations: ANN_DESTRUCTIVE,
@@ -718,19 +990,21 @@ server.registerTool("crosspad_commit", {
718
990
  // CODE — search and analysis
719
991
  // ═══════════════════════════════════════════════════════════════════════
720
992
  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.",
993
+ 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
994
  inputSchema: {
723
995
  query: z.string().min(1).describe("Symbol name (substring match, case-insensitive on filter)"),
724
996
  kind: z.enum(["class", "function", "macro", "enum", "typedef", "all"]).default("all"),
725
997
  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."),
998
+ .describe("Repo names to scan, or ['all']. Names: crosspad-core, crosspad-gui, crosspad-pc, platform-idf, ESP32-S3, stm32-r20."),
727
999
  max_results: z.number().int().min(1).max(500).default(50),
728
1000
  context_lines: z.number().int().min(0).max(10).default(0)
729
1001
  .describe("Surrounding lines per match (like grep -C). 0 = no context."),
1002
+ include_vendored: z.boolean().default(false)
1003
+ .describe("Scan vendored/generated trees too (lvgl, managed_components, STM Drivers/Middlewares, build, …). Default false — these are almost always noise."),
730
1004
  },
731
1005
  outputSchema: O_SearchSymbols,
732
1006
  annotations: ANN_READ_ONLY,
733
- }, async ({ query, kind, repos, max_results, context_lines }) => jsonResponse({ success: true, ...crosspadSearchSymbols(query, kind, repos, max_results, context_lines) }));
1007
+ }, async ({ query, kind, repos, max_results, context_lines, include_vendored }) => jsonResponse({ success: true, ...crosspadSearchSymbols(query, kind, repos, max_results, context_lines, include_vendored) }));
734
1008
  server.registerTool("crosspad_list_interfaces", {
735
1009
  description: "List all crosspad-core interfaces (I*-prefixed classes in crosspad-core/include/crosspad/).",
736
1010
  inputSchema: {},
@@ -738,9 +1012,11 @@ server.registerTool("crosspad_list_interfaces", {
738
1012
  annotations: ANN_READ_ONLY,
739
1013
  }, async () => jsonResponse({ success: true, ...crosspadInterfaces("list") }));
740
1014
  server.registerTool("crosspad_interface_implementations", {
741
- description: "Find all classes implementing a given interface across CrossPad repos. Returns className, file path, platform.",
1015
+ 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
1016
  inputSchema: {
743
- interface_name: z.string().min(1).describe("Interface name (e.g. 'IDisplay', 'IPadLogicHandler')"),
1017
+ interface_name: z.string().min(1)
1018
+ .regex(/^I[A-Z][A-Za-z0-9_]*$/, "Interface name must start with 'I' followed by an uppercase letter (e.g. 'IDisplay').")
1019
+ .describe("Interface name — MUST start with 'I' and use the exact crosspad-core casing (e.g. 'IDisplay', 'IPadLogicHandler', 'IKeyValueStore'). Not 'Display', not 'iDisplay'."),
744
1020
  },
745
1021
  outputSchema: O_Architecture,
746
1022
  annotations: ANN_READ_ONLY,
@@ -798,15 +1074,23 @@ server.registerTool("crosspad_apps_remove", {
798
1074
  return jsonResponse((await crosspadAppRemove(app_name, platform, onLine, extra.signal)));
799
1075
  });
800
1076
  server.registerTool("crosspad_apps_update", {
801
- description: "Update one or all installed apps on a platform. Specify app_name OR set update_all=true.",
1077
+ description: "Update one or all installed apps on a platform. EXACTLY ONE of these must be supplied: " +
1078
+ "set `app_name` to update a single app, OR set `update_all=true` to update every installed app on the platform. " +
1079
+ "Supplying both, or neither, is an error.",
802
1080
  inputSchema: {
803
1081
  platform: Platform,
804
- app_name: AppName.optional().describe("App ID to update. Required unless update_all=true."),
805
- update_all: z.boolean().default(false),
1082
+ app_name: AppName.optional().describe("App ID (e.g. 'metronome') to update one app. Mutually exclusive with update_all=true."),
1083
+ update_all: z.boolean().default(false).describe("If true, update all installed apps on `platform`. Mutually exclusive with app_name."),
806
1084
  },
807
1085
  outputSchema: O_AppAction,
808
1086
  annotations: ANN_DESTRUCTIVE_OPEN,
809
1087
  }, async ({ platform, app_name, update_all }, extra) => {
1088
+ if (!app_name && !update_all) {
1089
+ return err("Specify `app_name` to update a single app, OR set `update_all=true` to update every installed app.");
1090
+ }
1091
+ if (app_name && update_all) {
1092
+ return err("`app_name` and `update_all=true` are mutually exclusive — pick one.");
1093
+ }
810
1094
  const onLine = makeProgressLogger("apps-update", extra);
811
1095
  return jsonResponse((await crosspadAppUpdate(platform, app_name, update_all, onLine, extra.signal)));
812
1096
  });
@@ -868,6 +1152,42 @@ server.resource("crosspad-workspace", "crosspad://workspace", {
868
1152
  };
869
1153
  });
870
1154
  // ═══════════════════════════════════════════════════════════════════════
1155
+ // crosspad://trace — live SWD trace session status. Cheap snapshot the LLM
1156
+ // can read without a tool call to learn whether a trace is running, the
1157
+ // device state, achieved Fs, signals, and the dashboard URL.
1158
+ // ═══════════════════════════════════════════════════════════════════════
1159
+ server.resource("crosspad-trace", "crosspad://trace", {
1160
+ 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.",
1161
+ mimeType: "application/json",
1162
+ }, async () => {
1163
+ const s = getActiveSession();
1164
+ const payload = s
1165
+ ? {
1166
+ active: true,
1167
+ device_state: s.deviceState,
1168
+ sample_count: s.buffer.count(),
1169
+ actual_fs: (() => {
1170
+ const elapsed = (performance.now() - s.startedAt) / 1000;
1171
+ return elapsed > 0 ? s.buffer.count() / elapsed : 0;
1172
+ })(),
1173
+ signals: s.buffer.signalNames(),
1174
+ file_path: s.filePath ?? null,
1175
+ // §12.1: the dashboard URL is owned by the persistent singleton; it's a
1176
+ // stable loopback URL once any trace/ui action has started the server.
1177
+ ui_url: getDashboard().port ? buildUiUrl(getDashboard().port) : null,
1178
+ }
1179
+ : { active: false };
1180
+ return {
1181
+ contents: [
1182
+ {
1183
+ uri: "crosspad://trace",
1184
+ mimeType: "application/json",
1185
+ text: JSON.stringify(payload, null, 2),
1186
+ },
1187
+ ],
1188
+ };
1189
+ });
1190
+ // ═══════════════════════════════════════════════════════════════════════
871
1191
  // RESOURCES — apps registry & installed manifest per platform
872
1192
  // One static resource per file-per-detected-platform. LLM/clients can
873
1193
  // inspect raw JSON without spending a tool call. Resource set updates only
@@ -929,7 +1249,7 @@ import path from "path";
929
1249
  // clients must construct concrete URIs.
930
1250
  // ═══════════════════════════════════════════════════════════════════════
931
1251
  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.",
1252
+ 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
1253
  mimeType: "application/json",
934
1254
  }, async (uri, variables) => {
935
1255
  const repo = decodeURIComponent(String(Array.isArray(variables.repo) ? variables.repo[0] : variables.repo ?? "")).trim();