crosspad-mcp-server 5.1.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +109 -44
  2. package/dist/config.d.ts +2 -2
  3. package/dist/config.js +42 -8
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.js +953 -219
  6. package/dist/index.js.map +1 -1
  7. package/dist/tools/app-manager.d.ts +13 -5
  8. package/dist/tools/app-manager.js +94 -28
  9. package/dist/tools/app-manager.js.map +1 -1
  10. package/dist/tools/architecture.js +13 -4
  11. package/dist/tools/architecture.js.map +1 -1
  12. package/dist/tools/build-check.d.ts +1 -1
  13. package/dist/tools/build-check.js +19 -9
  14. package/dist/tools/build-check.js.map +1 -1
  15. package/dist/tools/build.d.ts +43 -4
  16. package/dist/tools/build.js +147 -15
  17. package/dist/tools/build.js.map +1 -1
  18. package/dist/tools/diff-core.d.ts +5 -1
  19. package/dist/tools/diff-core.js +11 -6
  20. package/dist/tools/diff-core.js.map +1 -1
  21. package/dist/tools/idf-build.d.ts +1 -1
  22. package/dist/tools/idf-build.js +5 -5
  23. package/dist/tools/idf-build.js.map +1 -1
  24. package/dist/tools/idf-flash.d.ts +20 -0
  25. package/dist/tools/idf-flash.js +185 -0
  26. package/dist/tools/idf-flash.js.map +1 -0
  27. package/dist/tools/idf-monitor.d.ts +29 -0
  28. package/dist/tools/idf-monitor.js +222 -0
  29. package/dist/tools/idf-monitor.js.map +1 -0
  30. package/dist/tools/log.d.ts +2 -2
  31. package/dist/tools/log.js +6 -5
  32. package/dist/tools/log.js.map +1 -1
  33. package/dist/tools/midi.d.ts +27 -0
  34. package/dist/tools/midi.js +112 -0
  35. package/dist/tools/midi.js.map +1 -0
  36. package/dist/tools/repo-actions.d.ts +46 -0
  37. package/dist/tools/repo-actions.js +286 -0
  38. package/dist/tools/repo-actions.js.map +1 -0
  39. package/dist/tools/repos.js +14 -10
  40. package/dist/tools/repos.js.map +1 -1
  41. package/dist/tools/screenshot.js +42 -7
  42. package/dist/tools/screenshot.js.map +1 -1
  43. package/dist/tools/settings.js +11 -1
  44. package/dist/tools/settings.js.map +1 -1
  45. package/dist/tools/symbols.d.ts +2 -1
  46. package/dist/tools/symbols.js +121 -53
  47. package/dist/tools/symbols.js.map +1 -1
  48. package/dist/tools/test.d.ts +1 -9
  49. package/dist/tools/test.js +11 -92
  50. package/dist/tools/test.js.map +1 -1
  51. package/dist/utils/device.d.ts +38 -0
  52. package/dist/utils/device.js +287 -0
  53. package/dist/utils/device.js.map +1 -0
  54. package/dist/utils/exec.d.ts +17 -5
  55. package/dist/utils/exec.js +73 -27
  56. package/dist/utils/exec.js.map +1 -1
  57. package/dist/utils/git.d.ts +9 -0
  58. package/dist/utils/git.js +61 -1
  59. package/dist/utils/git.js.map +1 -1
  60. package/dist/utils/remote-client.d.ts +1 -0
  61. package/dist/utils/remote-client.js +56 -15
  62. package/dist/utils/remote-client.js.map +1 -1
  63. package/package.json +4 -1
  64. package/dist/tools/scaffold.d.ts +0 -15
  65. package/dist/tools/scaffold.js +0 -192
  66. package/dist/tools/scaffold.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,24 +1,53 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- // Tool implementations
6
- import { crosspadBuild, crosspadRun } from "./tools/build.js";
5
+ import { createRequire } from "module";
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require("../package.json");
8
+ import { crosspadBuild, crosspadRun, crosspadKill } from "./tools/build.js";
7
9
  import { crosspadBuildCheck } from "./tools/build-check.js";
8
10
  import { crosspadLog } from "./tools/log.js";
9
11
  import { crosspadIdfBuild } from "./tools/idf-build.js";
10
- import { crosspadTest, crosspadTestScaffold } from "./tools/test.js";
12
+ import { crosspadIdfFlash, crosspadIdfOta } from "./tools/idf-flash.js";
13
+ import { crosspadIdfMonitor } from "./tools/idf-monitor.js";
14
+ import { listDevices } from "./utils/device.js";
15
+ import { crosspadTest } from "./tools/test.js";
11
16
  import { crosspadReposStatus } from "./tools/repos.js";
12
17
  import { crosspadDiffCore } from "./tools/diff-core.js";
18
+ import { crosspadSubmoduleUpdate, crosspadCommit } from "./tools/repo-actions.js";
13
19
  import { crosspadSearchSymbols } from "./tools/symbols.js";
14
20
  import { crosspadInterfaces, crosspadApps } from "./tools/architecture.js";
15
- import { crosspadScaffoldApp } from "./tools/scaffold.js";
16
21
  import { crosspadScreenshot } from "./tools/screenshot.js";
17
22
  import { crosspadInput } from "./tools/input.js";
18
23
  import { crosspadStats } from "./tools/stats.js";
19
24
  import { crosspadSettingsGet, crosspadSettingsSet } from "./tools/settings.js";
25
+ import { crosspadMidiSend } from "./tools/midi.js";
20
26
  import { crosspadAppList, crosspadAppInstall, crosspadAppRemove, crosspadAppUpdate, crosspadAppSync, } from "./tools/app-manager.js";
21
- const server = new McpServer({ name: "crosspad", version: "5.0.0" }, { capabilities: { logging: {} } });
27
+ // Server instructions MCP clients prepend these to the LLM system prompt.
28
+ // This is the *primary* mechanism by which a Claude session "knows" to pick
29
+ // crosspad_* tools when working inside any CrossPad repo. CLAUDE.md and memory
30
+ // alone proved insufficient; these instructions are loaded by the protocol
31
+ // itself before the user's first message and survive context compaction.
32
+ const SERVER_INSTRUCTIONS = `
33
+ 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
+
35
+ WHEN TO USE THESE TOOLS — in any conversation that touches a CrossPad repo, prefer the crosspad_* tools over raw shell equivalents:
36
+
37
+ - Inspecting code → crosspad_search_symbols (NOT \`grep -r\`); crosspad_list_interfaces; crosspad_interface_implementations.
38
+ - Repo state → crosspad_repo_status (NOT \`git status\` across N repos); crosspad_repo_diff for submodule drift.
39
+ - Building PC sim → crosspad_check platform=pc → crosspad_build platform=pc (NOT raw cmake/ninja). Then crosspad_run; crosspad_kill when done.
40
+ - Building firmware→ crosspad_build platform=idf (NOT raw \`idf.py build\`); crosspad_flash transport=uart|ota.
41
+ - Tests → crosspad_test_run (NOT raw catch2 binary).
42
+ - Sim interaction → crosspad_screenshot, crosspad_input, crosspad_midi, crosspad_stats, crosspad_settings_get/set.
43
+ - Apps (registry) → crosspad_apps_list / install / remove / update / sync (NOT manual submodule git ops).
44
+ - Commits → crosspad_commit (NOT raw \`git commit\`) — handles multi-repo paths and refuses on merge conflicts.
45
+
46
+ 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
+
48
+ DISCOVERY: if unsure whether a repo is detected, check the \`crosspad://workspace\` resource — it lists detected repos, current branches, dirty counts, and sim status.
49
+ `.trim();
50
+ const server = new McpServer({ name: "crosspad", version }, { capabilities: { logging: {}, resources: {} }, instructions: SERVER_INSTRUCTIONS });
22
51
  function makeStreamLogger(logger) {
23
52
  return (stream, line) => {
24
53
  if (!line.trim())
@@ -27,247 +56,952 @@ function makeStreamLogger(logger) {
27
56
  server.server.sendLoggingMessage({ level, logger, data: line }).catch(() => { });
28
57
  };
29
58
  }
59
+ /**
60
+ * Compose a stream logger that ALSO emits notifications/progress when the
61
+ * client supplied a progress token. Build/test/flash callers see a moving
62
+ * counter (lines processed) and the latest log line as the message.
63
+ *
64
+ * Lines remain on the logging channel for diagnostics; progress is the
65
+ * spec-compliant signal for "still working."
66
+ */
67
+ function makeProgressLogger(logger, extra) {
68
+ const stream = makeStreamLogger(logger);
69
+ const token = extra?._meta?.progressToken;
70
+ if (token === undefined || token === null)
71
+ return stream;
72
+ let counter = 0;
73
+ return (s, line) => {
74
+ stream(s, line);
75
+ counter++;
76
+ extra
77
+ .sendNotification({
78
+ method: "notifications/progress",
79
+ params: { progressToken: token, progress: counter, message: line.slice(0, 200) },
80
+ })
81
+ .catch(() => { });
82
+ };
83
+ }
84
+ // ═══════════════════════════════════════════════════════════════════════
85
+ // RESPONSE HELPERS — uniform { success, ...data, error? } envelope
86
+ // MCP spec: tool-level errors must set `isError: true` on the result so the
87
+ // client/LLM can distinguish them from successful tool calls.
88
+ // ═══════════════════════════════════════════════════════════════════════
30
89
  function jsonResponse(data) {
31
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
90
+ // Emit structuredContent in addition to text content.
91
+ // - Clients with outputSchema validate structuredContent.
92
+ // - Clients without it ignore the field per spec.
93
+ // - LLM still sees the same JSON in `content` for backwards compat.
94
+ const dataAsRecord = data;
95
+ const result = {
96
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
97
+ structuredContent: dataAsRecord,
98
+ };
99
+ if (dataAsRecord.success === false)
100
+ result.isError = true;
101
+ return result;
102
+ }
103
+ function ok(data = {}) {
104
+ return jsonResponse({ success: true, ...data });
105
+ }
106
+ function err(message, extra = {}) {
107
+ return jsonResponse({ success: false, error: message, ...extra });
32
108
  }
33
109
  // ═══════════════════════════════════════════════════════════════════════
34
- // TOOL 1: crosspad_build
35
- // ═══════════════════════════════════════════════════════════════════════
36
- server.tool("crosspad_build", "Build, run, or check the CrossPad PC simulator or ESP-IDF firmware.", {
37
- action: z.enum(["pc", "pc_run", "pc_check", "pc_log", "idf"])
38
- .describe("pc: build simulator. pc_run: launch exe. pc_check: build health check. pc_log: capture stdout. idf: build ESP-IDF firmware."),
39
- mode: z.enum(["incremental", "clean", "reconfigure", "fullclean"]).default("incremental")
40
- .describe("Build mode (pc: incremental/clean/reconfigure, idf: build/fullclean/clean)").optional(),
41
- timeout_seconds: z.number().default(5).optional()
42
- .describe("pc_log: capture duration in seconds"),
43
- max_lines: z.number().default(200).optional()
44
- .describe("pc_log: max output lines"),
45
- }, async ({ action, mode, timeout_seconds, max_lines }) => {
46
- const onLine = makeStreamLogger("build");
47
- switch (action) {
48
- case "pc": {
49
- const m = (mode === "fullclean" ? "clean" : mode ?? "incremental");
50
- return jsonResponse(await crosspadBuild(m, onLine));
51
- }
52
- case "pc_run":
53
- return jsonResponse(crosspadRun());
54
- case "pc_check":
55
- return jsonResponse(crosspadBuildCheck());
56
- case "pc_log":
57
- return jsonResponse(await crosspadLog(timeout_seconds ?? 5, max_lines ?? 200, onLine));
58
- case "idf": {
59
- const m = (mode === "reconfigure" ? "build" : mode ?? "build");
60
- return jsonResponse(await crosspadIdfBuild(m, onLine));
61
- }
110
+ // SHARED ZOD SCHEMAS
111
+ // ═══════════════════════════════════════════════════════════════════════
112
+ const Velocity = z.number().int().min(0).max(127).describe("MIDI velocity 0-127");
113
+ const Note = z.number().int().min(0).max(127).describe("MIDI note number 0-127 (60 = middle C)");
114
+ const Channel = z.number().int().min(0).max(15).default(0).describe("MIDI channel 0-15");
115
+ const PadIndex = z.number().int().min(0).max(15).describe("Pad index 0-15 (4x4 grid)");
116
+ const Cc = z.number().int().min(0).max(127).describe("MIDI CC number 0-127");
117
+ const Cc7 = z.number().int().min(0).max(127).describe("MIDI value 0-127");
118
+ const Program = z.number().int().min(0).max(127).describe("MIDI program number 0-127");
119
+ // Port allow-list — must match Linux/macOS device paths or Windows COM ports.
120
+ // Prevents shell-injection via crafted port strings flowing into command lines.
121
+ const Port = z.string()
122
+ .regex(/^(?:\/dev\/(?:tty(?:ACM|USB)\d+|cu\.usb[A-Za-z0-9._-]+|cu\.usbmodem[A-Za-z0-9._-]+|cu\.usbserial[A-Za-z0-9._-]+)|COM\d+)$/, "Port must be /dev/ttyACM*, /dev/ttyUSB*, /dev/cu.usb*, or COM*")
123
+ .describe("Serial port path (e.g. /dev/ttyACM0, COM3). Auto-detected if omitted; required when multiple devices connected.");
124
+ const TimeoutSec = z.number().int().min(1).max(600).describe("Capture duration in seconds");
125
+ const MaxLines = z.number().int().min(1).max(10000).describe("Max output lines to return");
126
+ const RepoAlias = z.enum(["idf", "pc", "arduino", "core", "gui", "platform-idf", "crosspad-pc", "ESP32-S3", "crosspad-core", "crosspad-gui"])
127
+ .describe("Repo to target. Aliases: idf=platform-idf, pc=crosspad-pc, arduino=ESP32-S3, core=crosspad-core, gui=crosspad-gui.");
128
+ const Submodule = z.enum(["crosspad-core", "crosspad-gui", "crosspad-instructions", "crosspad-sampler"])
129
+ .describe("Which submodule to operate on");
130
+ const Platform = z.enum(["idf", "pc", "arduino"]).describe("Platform repo (idf=platform-idf, pc=crosspad-pc, arduino=ESP32-S3)");
131
+ // Git refs (branch / tag / commit SHA) — restricted to safe characters so they
132
+ // can flow into shell-invoked git commands without injection risk. Matches
133
+ // git's own ref-name rules (see git-check-ref-format) loosely.
134
+ const GitRef = z.string()
135
+ .min(1)
136
+ .max(200)
137
+ .regex(/^[A-Za-z0-9._/-]+$/, "Invalid git ref — letters/digits/._/- only")
138
+ .refine((s) => !s.startsWith("-"), "Ref cannot start with '-'")
139
+ .refine((s) => !s.includes(".."), "Ref cannot contain '..'");
140
+ // App / submodule names also flow into shell args — keep them strict.
141
+ const AppName = z.string()
142
+ .min(1)
143
+ .max(100)
144
+ .regex(/^[A-Za-z0-9_-]+$/, "App name must be alphanumeric (with _ or -)");
145
+ // ═══════════════════════════════════════════════════════════════════════
146
+ // TOOL ANNOTATIONS — hints for MCP clients (used for confirmation gating).
147
+ // Per spec these are *hints*, not guarantees — clients trust at their own risk.
148
+ // ═══════════════════════════════════════════════════════════════════════
149
+ const ANN_READ_ONLY = { readOnlyHint: true };
150
+ const ANN_DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
151
+ const ANN_DESTRUCTIVE_OPEN = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
152
+ const ANN_READ_OPEN = { readOnlyHint: true, openWorldHint: true };
153
+ const ANN_SIDE_EFFECT = { readOnlyHint: false, destructiveHint: false };
154
+ // ═══════════════════════════════════════════════════════════════════════
155
+ // OUTPUT SCHEMAS — typed result shapes per tool, exposed as `outputSchema`
156
+ // so clients can validate `structuredContent`. Loose by design (most fields
157
+ // optional, no .strict()) — implementations are free to return additional
158
+ // keys; the schema documents the *expected* shape, not a tight contract.
159
+ // ═══════════════════════════════════════════════════════════════════════
160
+ const ErrorField = { error: z.string().optional() };
161
+ const O_Build = {
162
+ success: z.boolean(),
163
+ duration_seconds: z.number(),
164
+ errors: z.array(z.string()),
165
+ warnings_count: z.number().int(),
166
+ output_path: z.string(),
167
+ ...ErrorField,
168
+ };
169
+ const O_Run = {
170
+ success: z.boolean(),
171
+ pid: z.number().int().nullable().optional(),
172
+ exe_path: z.string(),
173
+ already_running: z.boolean().optional(),
174
+ responsive: z.boolean().optional(),
175
+ ...ErrorField,
176
+ };
177
+ const O_Kill = {
178
+ success: z.boolean(),
179
+ killed_pids: z.array(z.number().int()),
180
+ was_running: z.boolean(),
181
+ ...ErrorField,
182
+ };
183
+ const O_BuildCheck = {
184
+ success: z.boolean(),
185
+ needs_rebuild: z.boolean(),
186
+ reasons: z.array(z.string()),
187
+ exe_exists: z.boolean(),
188
+ exe_path: z.string(),
189
+ ...ErrorField,
190
+ };
191
+ const O_IdfBuild = {
192
+ success: z.boolean(),
193
+ duration_seconds: z.number(),
194
+ errors: z.array(z.string()),
195
+ warnings: z.array(z.string()),
196
+ tail: z.array(z.string()),
197
+ auto_reconfigured: z.boolean().optional(),
198
+ ...ErrorField,
199
+ };
200
+ const O_Flash = {
201
+ success: z.boolean(),
202
+ method: z.enum(["uart", "ota"]),
203
+ port: z.string(),
204
+ duration_seconds: z.number(),
205
+ output_tail: z.array(z.string()),
206
+ ...ErrorField,
207
+ };
208
+ // Log result is target-dependent; keep it permissive.
209
+ const O_Log = {
210
+ success: z.boolean(),
211
+ // pc fields
212
+ exe_path: z.string().optional(),
213
+ stdout: z.string().optional(),
214
+ stderr: z.string().optional(),
215
+ exit_code: z.number().int().nullable().optional(),
216
+ duration_seconds: z.number().optional(),
217
+ truncated: z.boolean().optional(),
218
+ // idf fields
219
+ port: z.string().optional(),
220
+ lines: z.array(z.string()).optional(),
221
+ line_count: z.number().int().optional(),
222
+ ...ErrorField,
223
+ };
224
+ const O_Devices = {
225
+ success: z.boolean(),
226
+ devices: z.array(z.object({
227
+ port: z.string(),
228
+ description: z.string().optional(),
229
+ vid: z.string().optional(),
230
+ pid: z.string().optional(),
231
+ is_crosspad: z.boolean(),
232
+ }).passthrough()),
233
+ crosspad_count: z.number().int().optional(),
234
+ ...ErrorField,
235
+ };
236
+ const O_Test = {
237
+ success: z.boolean(),
238
+ tests_found: z.boolean(),
239
+ build_output: z.string(),
240
+ test_output: z.string(),
241
+ passed: z.number().int(),
242
+ failed: z.number().int(),
243
+ errors: z.array(z.string()),
244
+ duration_seconds: z.number(),
245
+ ...ErrorField,
246
+ };
247
+ const O_Screenshot = {
248
+ success: z.boolean(),
249
+ width: z.number().int().optional(),
250
+ height: z.number().int().optional(),
251
+ format: z.string().optional(),
252
+ file_path: z.string().optional(),
253
+ size: z.number().int().optional(),
254
+ ...ErrorField,
255
+ };
256
+ const O_Input = {
257
+ success: z.boolean(),
258
+ ...ErrorField,
259
+ };
260
+ const O_Midi = {
261
+ success: z.boolean(),
262
+ type: z.enum(["note_on", "note_off", "cc", "program_change"]).optional(),
263
+ channel: z.number().int().optional(),
264
+ details: z.record(z.string(), z.number()).optional(),
265
+ ...ErrorField,
266
+ };
267
+ const O_Stats = {
268
+ success: z.boolean(),
269
+ stats: z.record(z.string(), z.unknown()).optional(),
270
+ ...ErrorField,
271
+ };
272
+ const O_SettingsGet = {
273
+ success: z.boolean(),
274
+ settings: z.record(z.string(), z.unknown()).optional(),
275
+ ...ErrorField,
276
+ };
277
+ const O_SettingsSet = {
278
+ success: z.boolean(),
279
+ key: z.string().optional(),
280
+ value: z.number().optional(),
281
+ ...ErrorField,
282
+ };
283
+ // Repo-status & repo-diff are loose aggregate structures — only the top
284
+ // `success` is guaranteed; everything else passes through.
285
+ const O_RepoStatus = {
286
+ success: z.boolean(),
287
+ repos: z.array(z.record(z.string(), z.unknown())).optional(),
288
+ ...ErrorField,
289
+ };
290
+ const O_RepoDiff = {
291
+ success: z.boolean(),
292
+ parent: z.string().optional(),
293
+ submodules: z.array(z.record(z.string(), z.unknown())).optional(),
294
+ ...ErrorField,
295
+ };
296
+ const O_SubmoduleUpdate = {
297
+ success: z.boolean(),
298
+ submodule: z.string(),
299
+ repo: z.string(),
300
+ old_sha: z.string().nullable(),
301
+ new_sha: z.string().nullable(),
302
+ commits_pulled: z.number().int(),
303
+ changed_files: z.array(z.string()),
304
+ staged: z.boolean(),
305
+ ...ErrorField,
306
+ };
307
+ const O_Commit = {
308
+ success: z.boolean(),
309
+ repo: z.string(),
310
+ commit_hash: z.string().nullable(),
311
+ message: z.string(),
312
+ files_committed: z.array(z.string()),
313
+ ...ErrorField,
314
+ };
315
+ const O_SearchSymbols = {
316
+ success: z.boolean(),
317
+ matches: z.array(z.record(z.string(), z.unknown())).optional(),
318
+ total: z.number().int().optional(),
319
+ truncated: z.boolean().optional(),
320
+ ...ErrorField,
321
+ };
322
+ const O_Architecture = {
323
+ success: z.boolean(),
324
+ // any of: interfaces[], implementations[], capabilities, etc.
325
+ interfaces: z.array(z.unknown()).optional(),
326
+ implementations: z.array(z.unknown()).optional(),
327
+ capabilities: z.array(z.unknown()).optional(),
328
+ platforms: z.record(z.string(), z.unknown()).optional(),
329
+ ...ErrorField,
330
+ };
331
+ const O_AppsSource = {
332
+ success: z.boolean(),
333
+ apps: z.array(z.record(z.string(), z.unknown())),
334
+ ...ErrorField,
335
+ };
336
+ const O_AppsList = {
337
+ success: z.boolean(),
338
+ apps: z.array(z.record(z.string(), z.unknown())),
339
+ installed_count: z.number().int(),
340
+ total_count: z.number().int(),
341
+ ...ErrorField,
342
+ };
343
+ const O_AppAction = {
344
+ success: z.boolean(),
345
+ action: z.string(),
346
+ platform: z.string(),
347
+ app_name: z.string().optional(),
348
+ output: z.string(),
349
+ ...ErrorField,
350
+ };
351
+ // ═══════════════════════════════════════════════════════════════════════
352
+ // BUILD — unified across platforms (pc, idf)
353
+ // `platform` arg disambiguates. Modes are validated per-platform at runtime.
354
+ // ═══════════════════════════════════════════════════════════════════════
355
+ const BuildPlatform = z.enum(["pc", "idf"]).describe("Target platform: 'pc' = host simulator, 'idf' = ESP32-S3 firmware.");
356
+ const PlatformPcOnly = z.enum(["pc"]).default("pc").describe("Platform — currently only 'pc' is supported here.");
357
+ 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).",
359
+ inputSchema: {
360
+ platform: BuildPlatform,
361
+ mode: z.enum(["incremental", "clean", "fullclean", "reconfigure"])
362
+ .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)."),
364
+ build_type: z.enum(["Debug", "Release", "RelWithDebInfo"])
365
+ .default("Debug")
366
+ .describe("CMake build type — PC only. Only honored on clean/reconfigure (incremental keeps existing cache)."),
367
+ },
368
+ outputSchema: O_Build,
369
+ annotations: ANN_DESTRUCTIVE,
370
+ }, async ({ platform, mode, build_type }, extra) => {
371
+ if (platform === "pc") {
372
+ if (mode === "fullclean")
373
+ return err("mode='fullclean' is IDF-only. PC supports: incremental, clean, reconfigure.");
374
+ const onLine = makeProgressLogger("build-pc", extra);
375
+ return jsonResponse(await crosspadBuild(mode, onLine, build_type, extra.signal));
62
376
  }
377
+ // idf
378
+ if (mode === "reconfigure")
379
+ return err("mode='reconfigure' is PC-only. IDF supports: incremental, clean, fullclean.");
380
+ const idfMode = mode === "incremental" ? "build" : mode;
381
+ const onLine = makeProgressLogger("build-idf", extra);
382
+ return jsonResponse(await crosspadIdfBuild(idfMode, onLine, extra.signal));
63
383
  });
384
+ 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).",
386
+ inputSchema: {
387
+ platform: PlatformPcOnly,
388
+ force: z.boolean().default(false)
389
+ .describe("Spawn another instance even if one is already running. Default: false."),
390
+ },
391
+ outputSchema: O_Run,
392
+ annotations: ANN_SIDE_EFFECT,
393
+ }, async ({ force }) => {
394
+ const result = await crosspadRun(force);
395
+ if (result.already_running) {
396
+ return err(result.error ?? "Simulator already running.", { exe_path: result.exe_path, already_running: true });
397
+ }
398
+ if (result.pid === null) {
399
+ return err(`Binary not found: ${result.exe_path}. Run crosspad_build first.`, { exe_path: result.exe_path });
400
+ }
401
+ if (result.responsive === false) {
402
+ return err(`Simulator process started (pid=${result.pid}) but TCP control port did not respond within 3s. Process may have crashed during startup.`, { pid: result.pid, exe_path: result.exe_path, responsive: false });
403
+ }
404
+ return ok({ pid: result.pid, exe_path: result.exe_path, responsive: result.responsive });
405
+ });
406
+ server.registerTool("crosspad_kill", {
407
+ description: "Stop the running PC simulator. Sends SIGTERM to processes named 'CrossPad'. Returns killed PIDs and whether the TCP control port stopped responding. Currently PC-only.",
408
+ inputSchema: {
409
+ platform: PlatformPcOnly,
410
+ },
411
+ outputSchema: O_Kill,
412
+ annotations: ANN_DESTRUCTIVE,
413
+ }, async () => jsonResponse(await crosspadKill()));
414
+ 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.",
416
+ inputSchema: {
417
+ platform: PlatformPcOnly,
418
+ },
419
+ outputSchema: O_BuildCheck,
420
+ annotations: ANN_READ_ONLY,
421
+ }, async () => jsonResponse(crosspadBuildCheck()));
422
+ // ═══════════════════════════════════════════════════════════════════════
423
+ // FLASH — unified UART/OTA into one tool with `transport` axis
64
424
  // ═══════════════════════════════════════════════════════════════════════
65
- // TOOL 2: crosspad_test
66
- // ═══════════════════════════════════════════════════════════════════════
67
- server.tool("crosspad_test", "Run Catch2 tests or scaffold test infrastructure for crosspad-pc.", {
68
- action: z.enum(["run", "scaffold"])
69
- .describe("run: build + run tests. scaffold: generate test boilerplate."),
70
- filter: z.string().default("").optional()
71
- .describe("Catch2 test name filter (e.g. '[core]')"),
72
- list_only: z.boolean().default(false).optional()
73
- .describe("List tests without running"),
74
- }, async ({ action, filter, list_only }) => {
75
- if (action === "scaffold") {
76
- return jsonResponse(crosspadTestScaffold());
425
+ 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.",
427
+ inputSchema: {
428
+ transport: z.enum(["uart", "ota"]).describe("'uart' = bootloader-mode flash via idf.py; 'ota' = USB-CDC OTA flash via ota_flash.py."),
429
+ port: Port.optional(),
430
+ firmware_path: z.string().optional()
431
+ .describe("Custom firmware binary path (OTA only). Defaults to <idf-root>/build/CrossPad.bin."),
432
+ },
433
+ outputSchema: O_Flash,
434
+ annotations: ANN_DESTRUCTIVE,
435
+ }, async ({ transport, port, firmware_path }, extra) => {
436
+ if (transport === "uart") {
437
+ if (firmware_path)
438
+ return err("Field 'firmware_path' is OTA-only — UART flash always uses the active build dir.");
439
+ const onLine = makeProgressLogger("flash-uart", extra);
440
+ return jsonResponse(await crosspadIdfFlash(port, onLine, extra.signal));
441
+ }
442
+ // ota
443
+ const onLine = makeProgressLogger("flash-ota", extra);
444
+ return jsonResponse(await crosspadIdfOta(port, firmware_path, onLine, extra.signal));
445
+ });
446
+ 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.",
448
+ 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."),
453
+ filter: z.string().optional()
454
+ .describe("Case-insensitive substring filter (idf only). Only lines containing this string are returned."),
455
+ },
456
+ outputSchema: O_Log,
457
+ annotations: ANN_READ_ONLY,
458
+ }, async ({ target, port, timeout_seconds, max_lines, filter }, extra) => {
459
+ if (target === "pc") {
460
+ if (port)
461
+ return err("Field 'port' is not used when target='pc'.");
462
+ if (filter)
463
+ return err("Field 'filter' is not used when target='pc'.");
464
+ const onLine = makeProgressLogger("log-pc", extra);
465
+ return jsonResponse({
466
+ ...(await crosspadLog(timeout_seconds ?? 5, max_lines ?? 200, onLine, extra.signal)),
467
+ });
77
468
  }
78
- const onLine = makeStreamLogger("test");
79
- return jsonResponse(await crosspadTest(filter ?? "", list_only ?? false, onLine));
469
+ // target === "idf"
470
+ const onLine = makeProgressLogger("log-idf", extra);
471
+ return jsonResponse({
472
+ ...(await crosspadIdfMonitor(port, timeout_seconds ?? 10, max_lines ?? 500, filter, onLine, extra.signal)),
473
+ });
80
474
  });
475
+ server.registerTool("crosspad_devices", {
476
+ description: "List all connected USB serial devices. Identifies CrossPad devices (Espressif VID 0x303a, PID 0x3456) separately from other ports.",
477
+ inputSchema: {},
478
+ outputSchema: O_Devices,
479
+ annotations: ANN_READ_ONLY,
480
+ }, async () => jsonResponse(listDevices()));
81
481
  // ═══════════════════════════════════════════════════════════════════════
82
- // TOOL 3: crosspad_sim
83
- // ═══════════════════════════════════════════════════════════════════════
84
- server.tool("crosspad_sim", "Interact with the running simulator: screenshots, input, stats, settings.", {
85
- action: z.enum(["screenshot", "input", "stats", "settings_get", "settings_set"])
86
- .describe("screenshot: capture PNG. input: send event. stats: runtime diagnostics. settings_get/set: read/write settings."),
87
- // screenshot params
88
- region: z.enum(["full", "lcd"]).default("full").optional()
89
- .describe("screenshot: full window or LCD only"),
90
- filename: z.string().optional()
91
- .describe("screenshot: custom filename"),
92
- save_to_file: z.boolean().default(true).optional()
93
- .describe("screenshot: save to disk (default) or return base64"),
94
- // input params
95
- input_action: z.enum(["click", "pad_press", "pad_release", "encoder_rotate", "encoder_press", "encoder_release", "key"]).optional()
96
- .describe("input: event type"),
97
- x: z.number().optional().describe("input click: X coordinate"),
98
- y: z.number().optional().describe("input click: Y coordinate"),
99
- pad: z.number().optional().describe("input pad: index 0-15"),
100
- velocity: z.number().optional().describe("input pad_press: velocity 0-127"),
101
- delta: z.number().optional().describe("input encoder_rotate: rotation delta"),
102
- keycode: z.number().optional().describe("input key: SDL keycode"),
103
- // settings params
104
- category: z.string().default("all").optional()
105
- .describe("settings_get: all/display/keypad/vibration/wireless/audio/system"),
106
- key: z.string().optional()
107
- .describe("settings_set: dotted key name (e.g. 'lcd_brightness')"),
108
- value: z.number().optional()
109
- .describe("settings_set: numeric value"),
110
- }, async ({ action, region, filename, save_to_file, input_action, x, y, pad, velocity, delta, keycode, category, key, value }) => {
111
- switch (action) {
112
- case "screenshot":
113
- return jsonResponse(await crosspadScreenshot(save_to_file ?? true, filename));
114
- case "input": {
115
- if (!input_action) {
116
- return jsonResponse({ success: false, error: "input_action is required for action=input" });
117
- }
118
- let input;
119
- switch (input_action) {
120
- case "click":
121
- input = { action: "click", x: x ?? 0, y: y ?? 0 };
122
- break;
123
- case "pad_press":
124
- input = { action: "pad_press", pad: pad ?? 0, velocity: velocity ?? 127 };
125
- break;
126
- case "pad_release":
127
- input = { action: "pad_release", pad: pad ?? 0 };
128
- break;
129
- case "encoder_rotate":
130
- input = { action: "encoder_rotate", delta: delta ?? 1 };
131
- break;
132
- case "encoder_press":
133
- input = { action: "encoder_press" };
134
- break;
135
- case "encoder_release":
136
- input = { action: "encoder_release" };
137
- break;
138
- case "key":
139
- input = { action: "key", keycode: keycode ?? 0 };
140
- break;
141
- }
142
- return jsonResponse(await crosspadInput(input));
143
- }
144
- case "stats":
145
- return jsonResponse(await crosspadStats());
146
- case "settings_get":
147
- return jsonResponse(await crosspadSettingsGet(category ?? "all"));
148
- case "settings_set": {
149
- if (!key || value === undefined) {
150
- return jsonResponse({ success: false, error: "key and value required for settings_set" });
151
- }
152
- return jsonResponse(await crosspadSettingsSet(key, value));
482
+ // TEST
483
+ // ═══════════════════════════════════════════════════════════════════════
484
+ 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.",
486
+ inputSchema: {
487
+ filter: z.string().default("")
488
+ .describe("Catch2 test filter (e.g. '[core]', 'PadManager*'). Empty = run all."),
489
+ list_only: z.boolean().default(false)
490
+ .describe("List discovered tests without running them."),
491
+ },
492
+ outputSchema: O_Test,
493
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
494
+ }, async ({ filter, list_only }, extra) => {
495
+ const onLine = makeProgressLogger("test", extra);
496
+ return jsonResponse((await crosspadTest(filter, list_only, onLine, extra.signal)));
497
+ });
498
+ // ═══════════════════════════════════════════════════════════════════════
499
+ // SIM screenshot
500
+ // ═══════════════════════════════════════════════════════════════════════
501
+ 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).",
503
+ inputSchema: {
504
+ filename: z.string().optional()
505
+ .describe("Custom filename (saved under <crosspad-pc>/screenshots/). Default: screenshot_<timestamp>.png"),
506
+ 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."),
508
+ },
509
+ outputSchema: O_Screenshot,
510
+ annotations: ANN_SIDE_EFFECT,
511
+ }, async ({ filename, return_inline }) => {
512
+ const result = await crosspadScreenshot(!return_inline, filename);
513
+ if (!result.success)
514
+ return jsonResponse({ ...result });
515
+ if (return_inline) {
516
+ // Inline path simulator returned base64 directly. Include
517
+ // structuredContent so clients honoring outputSchema see metadata
518
+ // alongside the image part.
519
+ if (result.data_base64) {
520
+ const meta = { success: true, width: result.width, height: result.height, format: result.format };
521
+ return {
522
+ content: [
523
+ { type: "image", data: result.data_base64, mimeType: "image/png" },
524
+ { type: "text", text: JSON.stringify(meta, null, 2) },
525
+ ],
526
+ structuredContent: meta,
527
+ };
153
528
  }
154
529
  }
530
+ // File path — return metadata only, no base64 dump
531
+ return jsonResponse({
532
+ success: true,
533
+ width: result.width,
534
+ height: result.height,
535
+ format: result.format,
536
+ file_path: result.file_path,
537
+ size: result.size,
538
+ });
155
539
  });
156
540
  // ═══════════════════════════════════════════════════════════════════════
157
- // TOOL 4: crosspad_repo
541
+ // SIM input events
158
542
  // ═══════════════════════════════════════════════════════════════════════
159
- server.tool("crosspad_repo", "Git status and submodule diffs across all CrossPad repos.", {
160
- action: z.enum(["status", "diff"])
161
- .describe("status: git status all repos. diff: submodule drift analysis."),
162
- submodule: z.enum(["crosspad-core", "crosspad-gui", "both"]).default("both").optional()
163
- .describe("diff: which submodule to analyze"),
164
- }, async ({ action, submodule }) => {
543
+ 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.",
545
+ inputSchema: {
546
+ action: z.enum([
547
+ "pad_press", "pad_release",
548
+ "encoder_rotate", "encoder_press", "encoder_release",
549
+ "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."),
557
+ },
558
+ outputSchema: O_Input,
559
+ annotations: ANN_SIDE_EFFECT,
560
+ }, async ({ action, pad, velocity, delta, x, y, keycode }) => {
561
+ // Per-action required-field validation. Cleaner than letting the sim reject
562
+ // because the error here cites the missing field by name.
563
+ const need = (field, val) => val === undefined ? `Field '${field}' is required for action='${action}'.` : null;
564
+ let missing = null;
165
565
  switch (action) {
166
- case "status":
167
- return jsonResponse(crosspadReposStatus());
168
- case "diff":
169
- return jsonResponse(crosspadDiffCore(submodule ?? "both"));
566
+ case "pad_press":
567
+ missing = need("pad", pad);
568
+ break;
569
+ case "pad_release":
570
+ missing = need("pad", pad);
571
+ break;
572
+ case "encoder_rotate":
573
+ missing = need("delta", delta);
574
+ break;
575
+ case "click":
576
+ missing = need("x", x) ?? need("y", y);
577
+ break;
578
+ case "key":
579
+ missing = need("keycode", keycode);
580
+ break;
170
581
  }
582
+ if (missing)
583
+ return err(missing);
584
+ const params = action === "pad_press"
585
+ ? { action, pad: pad, velocity: velocity ?? 127 }
586
+ : action === "pad_release"
587
+ ? { action, pad: pad }
588
+ : action === "encoder_rotate"
589
+ ? { action, delta: delta }
590
+ : action === "click"
591
+ ? { action, x: x, y: y }
592
+ : action === "key"
593
+ ? { action, keycode: keycode }
594
+ : { action };
595
+ return jsonResponse((await crosspadInput(params)));
171
596
  });
172
597
  // ═══════════════════════════════════════════════════════════════════════
173
- // TOOL 5: crosspad_code
174
- // ═══════════════════════════════════════════════════════════════════════
175
- server.tool("crosspad_code", "Search symbols, query interfaces, list registered apps, or scaffold new apps across CrossPad repos.", {
176
- action: z.enum(["search", "interfaces", "apps", "scaffold"])
177
- .describe("search: find classes/functions/macros. interfaces: query crosspad-core interfaces. apps: list REGISTER_APP registrations. scaffold: generate app boilerplate."),
178
- // search params
179
- query: z.string().optional()
180
- .describe("search: symbol name. interfaces: 'list', 'implementations <Name>', or 'capabilities'."),
181
- kind: z.enum(["class", "function", "macro", "enum", "typedef", "all"]).default("all").optional()
182
- .describe("search: filter by symbol kind"),
183
- repos: z.array(z.string()).default(["all"]).optional()
184
- .describe("search: repo names to scan, or ['all']"),
185
- max_results: z.number().default(50).optional()
186
- .describe("search: result cap"),
187
- // apps params
188
- platform: z.enum(["pc", "idf", "arduino", "all"]).default("all").optional()
189
- .describe("apps: platform to scan"),
190
- // scaffold params
191
- name: z.string().optional()
192
- .describe("scaffold: PascalCase app name"),
193
- display_name: z.string().optional()
194
- .describe("scaffold: human-readable name"),
195
- has_pad_logic: z.boolean().default(false).optional()
196
- .describe("scaffold: generate IPadLogicHandler stub"),
197
- icon: z.string().default("CrossPad_Logo_110w.png").optional()
198
- .describe("scaffold: icon filename"),
199
- }, async ({ action, query, kind, repos, max_results, platform, name, display_name, has_pad_logic, icon }) => {
200
- switch (action) {
201
- case "search": {
202
- if (!query)
203
- return jsonResponse({ error: "query is required for action=search" });
204
- return jsonResponse(crosspadSearchSymbols(query, kind ?? "all", repos ?? ["all"], max_results ?? 50));
205
- }
206
- case "interfaces": {
207
- return jsonResponse(crosspadInterfaces(query ?? "list"));
208
- }
209
- case "apps":
210
- return jsonResponse(crosspadApps(platform ?? "all"));
211
- case "scaffold": {
212
- if (!name)
213
- return jsonResponse({ error: "name is required for action=scaffold" });
214
- return jsonResponse(crosspadScaffoldApp({ name, display_name, has_pad_logic: has_pad_logic ?? false, icon: icon ?? "CrossPad_Logo_110w.png" }));
215
- }
598
+ // SIM MIDI
599
+ // ═══════════════════════════════════════════════════════════════════════
600
+ 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}.",
602
+ inputSchema: {
603
+ type: z.enum(["note_on", "note_off", "cc", "program_change"])
604
+ .describe("MIDI event type."),
605
+ 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)."),
611
+ },
612
+ outputSchema: O_Midi,
613
+ annotations: ANN_SIDE_EFFECT,
614
+ }, async ({ type, channel, note, velocity, cc_num, value, program }) => {
615
+ const need = (field, val) => val === undefined ? `Field '${field}' is required for type='${type}'.` : null;
616
+ let missing = null;
617
+ switch (type) {
618
+ case "note_on":
619
+ case "note_off":
620
+ missing = need("note", note);
621
+ break;
622
+ case "cc":
623
+ missing = need("cc_num", cc_num) ?? need("value", value);
624
+ break;
625
+ case "program_change":
626
+ missing = need("program", program);
627
+ break;
216
628
  }
629
+ if (missing)
630
+ return err(missing);
631
+ return jsonResponse({
632
+ ...(await crosspadMidiSend({
633
+ type,
634
+ channel,
635
+ note,
636
+ velocity: velocity ?? (type === "note_off" ? 0 : type === "note_on" ? 127 : undefined),
637
+ cc_num,
638
+ value,
639
+ program,
640
+ })),
641
+ });
217
642
  });
218
643
  // ═══════════════════════════════════════════════════════════════════════
219
- // TOOL 6: crosspad_apps
220
- // ═══════════════════════════════════════════════════════════════════════
221
- server.tool("crosspad_apps", "Manage CrossPad app packages: list, install, remove, update from the crosspad-apps registry. List aggregates status across all detected repos (idf, pc, arduino).", {
222
- action: z.enum(["list", "install", "remove", "update", "sync"])
223
- .describe("list: available apps + where installed. install: add app submodule. remove: remove app. update: update app(s). sync: sync manifest."),
224
- platform: z.enum(["idf", "pc", "arduino"]).optional()
225
- .describe("install/remove/update/sync: target platform repo (required for mutations)"),
226
- app_name: z.string().optional()
227
- .describe("install/remove/update: app ID from registry"),
228
- ref: z.string().default("main").optional()
229
- .describe("install: git ref (branch/tag/commit)"),
230
- force: z.boolean().default(false).optional()
231
- .describe("install: install even if incompatible"),
232
- update_all: z.boolean().default(false).optional()
233
- .describe("update: update all installed apps"),
234
- show_all: z.boolean().default(false).optional()
235
- .describe("list: include incompatible apps"),
236
- }, async ({ action, platform, app_name, ref, force, update_all, show_all }) => {
237
- const onLine = makeStreamLogger("app-manager");
238
- switch (action) {
239
- case "list":
240
- return jsonResponse(crosspadAppList(show_all ?? false));
241
- case "install": {
242
- if (!app_name)
243
- return jsonResponse({ success: false, error: "app_name required" });
244
- if (!platform)
245
- return jsonResponse({ success: false, error: "platform required for install (idf, pc, or arduino)" });
246
- return jsonResponse(await crosspadAppInstall(app_name, platform, ref ?? "main", force ?? false, onLine));
247
- }
248
- case "remove": {
249
- if (!app_name)
250
- return jsonResponse({ success: false, error: "app_name required" });
251
- if (!platform)
252
- return jsonResponse({ success: false, error: "platform required for remove (idf, pc, or arduino)" });
253
- return jsonResponse(await crosspadAppRemove(app_name, platform, onLine));
254
- }
255
- case "update": {
256
- if (!platform)
257
- return jsonResponse({ success: false, error: "platform required for update (idf, pc, or arduino)" });
258
- return jsonResponse(await crosspadAppUpdate(platform, app_name, update_all ?? false, onLine));
644
+ // SIM runtime state
645
+ // ═══════════════════════════════════════════════════════════════════════
646
+ server.registerTool("crosspad_stats", {
647
+ description: "Read runtime statistics from the running PC simulator: pad state, capabilities, heap, registered apps, active pad logic.",
648
+ inputSchema: {},
649
+ outputSchema: O_Stats,
650
+ annotations: ANN_READ_ONLY,
651
+ }, async () => jsonResponse((await crosspadStats())));
652
+ server.registerTool("crosspad_settings_get", {
653
+ description: "Read settings from the running simulator.",
654
+ inputSchema: {
655
+ category: z.enum(["all", "display", "keypad", "vibration", "wireless", "audio", "system"])
656
+ .default("all")
657
+ .describe("Settings category. Use 'all' to fetch everything."),
658
+ },
659
+ outputSchema: O_SettingsGet,
660
+ annotations: ANN_READ_ONLY,
661
+ }, async ({ category }) => jsonResponse((await crosspadSettingsGet(category))));
662
+ server.registerTool("crosspad_settings_set", {
663
+ description: "Write a single setting on the running simulator.",
664
+ inputSchema: {
665
+ key: z.string().min(1)
666
+ .describe("Setting key (e.g. 'lcd_brightness', 'keypad.eco_mode', 'vibration.enable')"),
667
+ value: z.number()
668
+ .describe("Numeric value. Booleans: 0=false, 1=true."),
669
+ },
670
+ outputSchema: O_SettingsSet,
671
+ annotations: ANN_DESTRUCTIVE,
672
+ }, async ({ key, value }) => jsonResponse((await crosspadSettingsSet(key, value))));
673
+ // ═══════════════════════════════════════════════════════════════════════
674
+ // REPO — read-only
675
+ // ═══════════════════════════════════════════════════════════════════════
676
+ server.registerTool("crosspad_repo_status", {
677
+ description: "Git status across ALL detected CrossPad repos in one call: branch, HEAD, dirty files, submodule sync state. PREFER THIS over running `git status` per repo — handles the 5-repo monorepo layout in one shot.",
678
+ inputSchema: {},
679
+ outputSchema: O_RepoStatus,
680
+ annotations: ANN_READ_ONLY,
681
+ }, async () => jsonResponse({ success: true, ...crosspadReposStatus() }));
682
+ server.registerTool("crosspad_repo_diff", {
683
+ description: "Show submodule drift in a parent repo (crosspad-pc or platform-idf): commits ahead/behind pinned, changed files, uncommitted work. Use to inspect dev-mode work before pinning.",
684
+ inputSchema: {
685
+ submodule: z.enum(["crosspad-core", "crosspad-gui", "both"]).default("both")
686
+ .describe("Which submodule to inspect."),
687
+ parent: z.enum(["crosspad-pc", "platform-idf"]).default("crosspad-pc")
688
+ .describe("Parent repo containing the submodule. Defaults to crosspad-pc."),
689
+ },
690
+ outputSchema: O_RepoDiff,
691
+ annotations: ANN_READ_ONLY,
692
+ }, async ({ submodule, parent }) => jsonResponse({ success: true, ...crosspadDiffCore(submodule, parent) }));
693
+ // ═══════════════════════════════════════════════════════════════════════
694
+ // REPO — mutations
695
+ // ═══════════════════════════════════════════════════════════════════════
696
+ server.registerTool("crosspad_submodule_update", {
697
+ description: "Update a submodule in a parent repo to the latest commit on a tracking branch (git fetch + checkout origin/<branch> + stage). Destructive: discards local commits in the submodule that aren't on the remote branch.",
698
+ inputSchema: {
699
+ submodule: Submodule,
700
+ repo: RepoAlias.describe("Parent repo containing the submodule (idf, pc, arduino, or full name)"),
701
+ branch: GitRef.default("main").describe("Remote branch to track (e.g. main, develop)"),
702
+ },
703
+ outputSchema: O_SubmoduleUpdate,
704
+ annotations: ANN_DESTRUCTIVE_OPEN,
705
+ }, async ({ submodule, repo, branch }) => jsonResponse(crosspadSubmoduleUpdate(submodule, repo, branch)));
706
+ server.registerTool("crosspad_commit", {
707
+ 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
+ inputSchema: {
709
+ repo: RepoAlias,
710
+ message: z.string().min(1).describe("Commit message"),
711
+ files: z.array(z.string()).optional()
712
+ .describe("Specific files to stage+commit. Omit to commit currently-staged changes."),
713
+ },
714
+ outputSchema: O_Commit,
715
+ annotations: ANN_DESTRUCTIVE,
716
+ }, async ({ repo, message, files }) => jsonResponse(crosspadCommit(repo, message, files)));
717
+ // ═══════════════════════════════════════════════════════════════════════
718
+ // CODE — search and analysis
719
+ // ═══════════════════════════════════════════════════════════════════════
720
+ 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.",
722
+ inputSchema: {
723
+ query: z.string().min(1).describe("Symbol name (substring match, case-insensitive on filter)"),
724
+ kind: z.enum(["class", "function", "macro", "enum", "typedef", "all"]).default("all"),
725
+ 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."),
727
+ max_results: z.number().int().min(1).max(500).default(50),
728
+ context_lines: z.number().int().min(0).max(10).default(0)
729
+ .describe("Surrounding lines per match (like grep -C). 0 = no context."),
730
+ },
731
+ outputSchema: O_SearchSymbols,
732
+ annotations: ANN_READ_ONLY,
733
+ }, async ({ query, kind, repos, max_results, context_lines }) => jsonResponse({ success: true, ...crosspadSearchSymbols(query, kind, repos, max_results, context_lines) }));
734
+ server.registerTool("crosspad_list_interfaces", {
735
+ description: "List all crosspad-core interfaces (I*-prefixed classes in crosspad-core/include/crosspad/).",
736
+ inputSchema: {},
737
+ outputSchema: O_Architecture,
738
+ annotations: ANN_READ_ONLY,
739
+ }, async () => jsonResponse({ success: true, ...crosspadInterfaces("list") }));
740
+ server.registerTool("crosspad_interface_implementations", {
741
+ description: "Find all classes implementing a given interface across CrossPad repos. Returns className, file path, platform.",
742
+ inputSchema: {
743
+ interface_name: z.string().min(1).describe("Interface name (e.g. 'IDisplay', 'IPadLogicHandler')"),
744
+ },
745
+ outputSchema: O_Architecture,
746
+ annotations: ANN_READ_ONLY,
747
+ }, async ({ interface_name }) => jsonResponse({ success: true, ...crosspadInterfaces(`implementations ${interface_name}`) }));
748
+ server.registerTool("crosspad_capabilities", {
749
+ description: "List platform capability flags (Capability enum) and which capabilities each platform sets.",
750
+ inputSchema: {},
751
+ outputSchema: O_Architecture,
752
+ annotations: ANN_READ_ONLY,
753
+ }, async () => jsonResponse({ success: true, ...crosspadInterfaces("capabilities") }));
754
+ server.registerTool("crosspad_list_apps_source", {
755
+ description: "List apps registered via REGISTER_APP() macro by scanning source files. Different from crosspad_apps_list (which reads the package registry).",
756
+ inputSchema: {
757
+ platform: z.enum(["pc", "idf", "arduino", "all"]).default("all"),
758
+ },
759
+ outputSchema: O_AppsSource,
760
+ annotations: ANN_READ_ONLY,
761
+ }, async ({ platform }) => jsonResponse({ success: true, apps: crosspadApps(platform) }));
762
+ // ═══════════════════════════════════════════════════════════════════════
763
+ // APPS — package manager (crosspad-apps registry)
764
+ // ═══════════════════════════════════════════════════════════════════════
765
+ 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.",
767
+ inputSchema: {
768
+ show_all: z.boolean().default(false)
769
+ .describe("Include apps incompatible with detected platforms."),
770
+ },
771
+ outputSchema: O_AppsList,
772
+ annotations: ANN_READ_OPEN,
773
+ }, async ({ show_all }) => jsonResponse(crosspadAppList(show_all)));
774
+ server.registerTool("crosspad_apps_install", {
775
+ description: "Install an app from the crosspad-apps registry as a git submodule. Requires gh CLI authenticated. Delegates to <repo>/{tools|scripts}/app_manager.py.",
776
+ inputSchema: {
777
+ platform: Platform,
778
+ app_name: AppName.describe("App ID from registry (e.g. 'metronome')"),
779
+ ref: GitRef.default("main").describe("Git ref (branch, tag, or commit SHA)"),
780
+ force: z.boolean().default(false).describe("Install even if marked incompatible."),
781
+ },
782
+ outputSchema: O_AppAction,
783
+ annotations: ANN_DESTRUCTIVE_OPEN,
784
+ }, async ({ platform, app_name, ref, force }, extra) => {
785
+ const onLine = makeProgressLogger("apps-install", extra);
786
+ return jsonResponse((await crosspadAppInstall(app_name, platform, ref, force, onLine, extra.signal)));
787
+ });
788
+ server.registerTool("crosspad_apps_remove", {
789
+ description: "Remove an installed app submodule from a platform repo. Delegates to app_manager.py.",
790
+ inputSchema: {
791
+ platform: Platform,
792
+ app_name: AppName,
793
+ },
794
+ outputSchema: O_AppAction,
795
+ annotations: ANN_DESTRUCTIVE,
796
+ }, async ({ platform, app_name }, extra) => {
797
+ const onLine = makeProgressLogger("apps-remove", extra);
798
+ return jsonResponse((await crosspadAppRemove(app_name, platform, onLine, extra.signal)));
799
+ });
800
+ server.registerTool("crosspad_apps_update", {
801
+ description: "Update one or all installed apps on a platform. Specify app_name OR set update_all=true.",
802
+ inputSchema: {
803
+ platform: Platform,
804
+ app_name: AppName.optional().describe("App ID to update. Required unless update_all=true."),
805
+ update_all: z.boolean().default(false),
806
+ },
807
+ outputSchema: O_AppAction,
808
+ annotations: ANN_DESTRUCTIVE_OPEN,
809
+ }, async ({ platform, app_name, update_all }, extra) => {
810
+ const onLine = makeProgressLogger("apps-update", extra);
811
+ return jsonResponse((await crosspadAppUpdate(platform, app_name, update_all, onLine, extra.signal)));
812
+ });
813
+ server.registerTool("crosspad_apps_sync", {
814
+ description: "Sync a platform's apps.json manifest with existing submodules (rebuild manifest from disk state).",
815
+ inputSchema: { platform: Platform },
816
+ outputSchema: O_AppAction,
817
+ annotations: ANN_DESTRUCTIVE,
818
+ }, async ({ platform }, extra) => {
819
+ const onLine = makeProgressLogger("apps-sync", extra);
820
+ return jsonResponse((await crosspadAppSync(platform, onLine, extra.signal)));
821
+ });
822
+ // ═══════════════════════════════════════════════════════════════════════
823
+ // RESOURCES
824
+ // crosspad://workspace — agregat (repos, branches, dirty, sim status).
825
+ // Eksponowane jako resource (nie tool) → klient może załadować raz na
826
+ // początek sesji bez tool call, dając LLM tani sygnał kontekstowy.
827
+ // ═══════════════════════════════════════════════════════════════════════
828
+ import { isSimulatorRunning as _isSimRunning } from "./utils/remote-client.js";
829
+ import { getRepos as _getRepos } from "./config.js";
830
+ import { getHead as _getHead } from "./utils/git.js";
831
+ server.resource("crosspad-workspace", "crosspad://workspace", {
832
+ description: "Detected CrossPad repos with branch, HEAD, dirty count, plus PC simulator running status. Cheap snapshot — load once per session for context.",
833
+ mimeType: "application/json",
834
+ }, async () => {
835
+ const repos = _getRepos();
836
+ const repoSummary = {};
837
+ for (const [name, root] of Object.entries(repos)) {
838
+ const head = _getHead(root);
839
+ // Quick branch + dirty count via single-shot porcelain
840
+ const { runCommand: _runCmd } = await import("./utils/exec.js");
841
+ const branch = _runCmd("git rev-parse --abbrev-ref HEAD", root, 5000);
842
+ const dirty = _runCmd("git status --porcelain", root, 5000);
843
+ const dirtyCount = dirty.success
844
+ ? dirty.stdout.split("\n").filter((l) => l.trim().length > 0).length
845
+ : 0;
846
+ repoSummary[name] = {
847
+ path: root,
848
+ head: head ?? null,
849
+ branch: branch.success ? branch.stdout.trim() : null,
850
+ dirty_count: dirtyCount,
851
+ };
852
+ }
853
+ const simRunning = await _isSimRunning();
854
+ const payload = {
855
+ detected_repos: Object.keys(repos),
856
+ repos: repoSummary,
857
+ pc_simulator: { running: simRunning },
858
+ hint: "If a repo you expected isn't detected, set its path env var (CROSSPAD_PC_ROOT, CROSSPAD_IDF_ROOT, etc.) and restart the MCP server.",
859
+ };
860
+ return {
861
+ contents: [
862
+ {
863
+ uri: "crosspad://workspace",
864
+ mimeType: "application/json",
865
+ text: JSON.stringify(payload, null, 2),
866
+ },
867
+ ],
868
+ };
869
+ });
870
+ // ═══════════════════════════════════════════════════════════════════════
871
+ // RESOURCES — apps registry & installed manifest per platform
872
+ // One static resource per file-per-detected-platform. LLM/clients can
873
+ // inspect raw JSON without spending a tool call. Resource set updates only
874
+ // at server start (registries don't appear/disappear mid-session).
875
+ // ═══════════════════════════════════════════════════════════════════════
876
+ import fs from "fs";
877
+ import path from "path";
878
+ (() => {
879
+ const repos = _getRepos();
880
+ // Map repo name -> platform label for stable URIs.
881
+ const platformByRepo = {
882
+ "platform-idf": "idf",
883
+ "crosspad-pc": "pc",
884
+ "ESP32-S3": "esp32-s3",
885
+ };
886
+ for (const [repoName, root] of Object.entries(repos)) {
887
+ const platform = platformByRepo[repoName];
888
+ if (!platform)
889
+ continue;
890
+ const registryPath = path.join(root, "app-registry.json");
891
+ if (fs.existsSync(registryPath)) {
892
+ const uri = `crosspad://apps/registry/${platform}`;
893
+ server.resource(`crosspad-apps-registry-${platform}`, uri, {
894
+ description: `Raw app-registry.json from ${repoName} — declared apps, versions, platforms, requires.`,
895
+ mimeType: "application/json",
896
+ }, async () => ({
897
+ contents: [
898
+ {
899
+ uri,
900
+ mimeType: "application/json",
901
+ text: fs.readFileSync(registryPath, "utf-8"),
902
+ },
903
+ ],
904
+ }));
259
905
  }
260
- case "sync": {
261
- if (!platform)
262
- return jsonResponse({ success: false, error: "platform required for sync (idf, pc, or arduino)" });
263
- return jsonResponse(await crosspadAppSync(platform, onLine));
906
+ const manifestPath = path.join(root, "apps.json");
907
+ if (fs.existsSync(manifestPath)) {
908
+ const uri = `crosspad://apps/installed/${platform}`;
909
+ server.resource(`crosspad-apps-installed-${platform}`, uri, {
910
+ description: `Raw apps.json (installed manifest) from ${repoName} — what's currently installed, ref, install/update timestamps.`,
911
+ mimeType: "application/json",
912
+ }, async () => ({
913
+ contents: [
914
+ {
915
+ uri,
916
+ mimeType: "application/json",
917
+ text: fs.readFileSync(manifestPath, "utf-8"),
918
+ },
919
+ ],
920
+ }));
264
921
  }
265
922
  }
923
+ })();
924
+ // ═══════════════════════════════════════════════════════════════════════
925
+ // RESOURCES — code navigation via URI templates (MCP-native)
926
+ // crosspad://symbols/{repo}/{symbol} — resolve a single symbol's definitions
927
+ // in a single repo without spending a tool call. Repo "all" searches every
928
+ // detected repo. listCallback is undefined (cannot enumerate every symbol);
929
+ // clients must construct concrete URIs.
930
+ // ═══════════════════════════════════════════════════════════════════════
931
+ 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.",
933
+ mimeType: "application/json",
934
+ }, async (uri, variables) => {
935
+ const repo = decodeURIComponent(String(Array.isArray(variables.repo) ? variables.repo[0] : variables.repo ?? "")).trim();
936
+ const symbol = decodeURIComponent(String(Array.isArray(variables.symbol) ? variables.symbol[0] : variables.symbol ?? "")).trim();
937
+ if (!repo || !symbol) {
938
+ return {
939
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: "URI must be crosspad://symbols/<repo>/<symbol>" }, null, 2) }],
940
+ };
941
+ }
942
+ const reposScope = repo === "all" ? ["all"] : [repo];
943
+ const result = crosspadSearchSymbols(symbol, "all", reposScope, 50, 0);
944
+ return {
945
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
946
+ };
266
947
  });
267
948
  // ═══════════════════════════════════════════════════════════════════════
268
- // START
949
+ // START — stdio (default) or HTTP (--http <port>)
950
+ // HTTP transport is opt-in via CLI flag for remote dev boxes / browsers.
951
+ // Stateful sessions: each initialize gets a session ID; subsequent requests
952
+ // must echo it. Single shared transport multiplexes sessions internally.
269
953
  // ═══════════════════════════════════════════════════════════════════════
954
+ function parseHttpPort(argv) {
955
+ for (let i = 0; i < argv.length; i++) {
956
+ const a = argv[i];
957
+ if (a === "--http") {
958
+ const next = argv[i + 1];
959
+ if (!next)
960
+ return 3000;
961
+ const n = parseInt(next, 10);
962
+ return Number.isFinite(n) && n > 0 && n < 65536 ? n : NaN;
963
+ }
964
+ if (a.startsWith("--http=")) {
965
+ const n = parseInt(a.slice("--http=".length), 10);
966
+ return Number.isFinite(n) && n > 0 && n < 65536 ? n : NaN;
967
+ }
968
+ }
969
+ return null;
970
+ }
270
971
  async function main() {
972
+ const httpPort = parseHttpPort(process.argv.slice(2));
973
+ if (httpPort !== null) {
974
+ if (Number.isNaN(httpPort)) {
975
+ console.error("Invalid --http port (must be 1..65535)");
976
+ process.exit(1);
977
+ }
978
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
979
+ const { createServer } = await import("http");
980
+ const { randomUUID } = await import("crypto");
981
+ const transport = new StreamableHTTPServerTransport({
982
+ sessionIdGenerator: () => randomUUID(),
983
+ });
984
+ await server.connect(transport);
985
+ const httpServer = createServer((req, res) => {
986
+ const pathname = (req.url ?? "/").split("?")[0];
987
+ if (pathname !== "/mcp") {
988
+ res.writeHead(404, { "Content-Type": "text/plain" });
989
+ res.end("Not Found — MCP endpoint is at /mcp");
990
+ return;
991
+ }
992
+ transport.handleRequest(req, res).catch((e) => {
993
+ console.error("MCP HTTP request failed:", e);
994
+ if (!res.headersSent) {
995
+ res.writeHead(500, { "Content-Type": "text/plain" });
996
+ res.end("Internal error");
997
+ }
998
+ });
999
+ });
1000
+ httpServer.listen(httpPort, () => {
1001
+ console.error(`crosspad-mcp HTTP transport listening on http://localhost:${httpPort}/mcp`);
1002
+ });
1003
+ return;
1004
+ }
271
1005
  const transport = new StdioServerTransport();
272
1006
  await server.connect(transport);
273
1007
  }