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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +14 -0
- package/.mcp.json +9 -0
- package/README.md +95 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +381 -57
- package/dist/index.js.map +1 -1
- package/dist/tools/idf-flash.js +2 -2
- package/dist/tools/idf-flash.js.map +1 -1
- package/dist/tools/idf-monitor.d.ts +3 -1
- package/dist/tools/idf-monitor.js +19 -3
- package/dist/tools/idf-monitor.js.map +1 -1
- package/dist/tools/midi.js +20 -16
- package/dist/tools/midi.js.map +1 -1
- package/dist/tools/symbols.d.ts +3 -1
- package/dist/tools/symbols.js +31 -1
- package/dist/tools/symbols.js.map +1 -1
- package/dist/tools/trace-buffer.d.ts +40 -0
- package/dist/tools/trace-buffer.js +74 -0
- package/dist/tools/trace-buffer.js.map +1 -0
- package/dist/tools/trace-device.d.ts +10 -0
- package/dist/tools/trace-device.js +26 -0
- package/dist/tools/trace-device.js.map +1 -0
- package/dist/tools/trace-doctor.d.ts +43 -0
- package/dist/tools/trace-doctor.js +150 -0
- package/dist/tools/trace-doctor.js.map +1 -0
- package/dist/tools/trace-export.d.ts +4 -0
- package/dist/tools/trace-export.js +14 -0
- package/dist/tools/trace-export.js.map +1 -0
- package/dist/tools/trace-session.d.ts +118 -0
- package/dist/tools/trace-session.js +346 -0
- package/dist/tools/trace-session.js.map +1 -0
- package/dist/tools/trace-symbols.d.ts +24 -0
- package/dist/tools/trace-symbols.js +44 -0
- package/dist/tools/trace-symbols.js.map +1 -0
- package/dist/tools/trace-webui.d.ts +53 -0
- package/dist/tools/trace-webui.js +222 -0
- package/dist/tools/trace-webui.js.map +1 -0
- package/dist/utils/device.d.ts +5 -0
- package/dist/utils/device.js +43 -15
- package/dist/utils/device.js.map +1 -1
- package/dist/utils/exec.js +26 -0
- package/dist/utils/exec.js.map +1 -1
- package/dist/utils/userConfig.d.ts +13 -0
- package/dist/utils/userConfig.js +43 -0
- package/dist/utils/userConfig.js.map +1 -0
- package/package.json +12 -4
- package/skills/crosspad/SKILL.md +58 -0
- package/skills/crosspad/reference/faq.md +40 -0
- package/skills/crosspad/reference/install.md +84 -0
- package/skills/crosspad/reference/repos.md +29 -0
- package/skills/crosspad/reference/role-contributor.md +64 -0
- package/skills/crosspad/reference/role-fw-dev.md +44 -0
- package/skills/crosspad/reference/role-user.md +49 -0
- package/skills/crosspad/reference/tools.md +68 -0
- package/skills/crosspad/scripts/doctor.sh +65 -0
- package/skills/crosspad/scripts/setup.sh +53 -0
- package/skills/swd-tracer/SKILL.md +135 -0
- package/skills/swd-tracer/reference/signals.md +42 -0
- package/skills/swd-tracer/scripts/detect-env.sh +61 -0
- package/skills/swd-tracer/scripts/install-udev-rules.sh +25 -0
- package/skills/swd-tracer/scripts/setup-venv.sh +26 -0
- package/tracer/PROTOCOL.md +260 -0
- package/tracer/README.md +327 -0
- package/tracer/swd_tracer.py +1083 -0
- 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.
|
|
230
|
-
pid: z.
|
|
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
|
|
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("
|
|
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
|
|
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: "
|
|
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
|
|
450
|
-
port: Port.optional().describe("Serial port
|
|
451
|
-
timeout_seconds: TimeoutSec.optional().describe("Capture duration. Defaults:
|
|
452
|
-
max_lines: MaxLines.optional().describe("Max output lines. Defaults: 200
|
|
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
|
|
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 (
|
|
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*').
|
|
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("
|
|
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.
|
|
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("
|
|
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
|
|
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("
|
|
552
|
-
velocity: Velocity.optional().describe("
|
|
553
|
-
delta: z.number().int().optional().describe("
|
|
554
|
-
x: z.number().int().min(0).optional().describe("X pixel coordinate (
|
|
555
|
-
y: z.number().int().min(0).optional().describe("Y pixel coordinate (
|
|
556
|
-
keycode: z.number().int().optional().describe("SDL keycode (
|
|
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
|
|
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
|
|
607
|
-
velocity: Velocity.optional().describe("
|
|
608
|
-
cc_num: Cc.optional().describe("
|
|
609
|
-
value: Cc7.optional().describe("Controller value
|
|
610
|
-
program: Program.optional().describe("Program number
|
|
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 (
|
|
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
|
|
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("
|
|
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
|
|
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)
|
|
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.
|
|
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.
|
|
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();
|