@hidechae/eztab-mcp 0.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 (3) hide show
  1. package/README.md +103 -0
  2. package/dist/server.js +678 -0
  3. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # EZTab MCP server (live)
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (e.g.
4
+ Claude) edit the **same document the desktop app has open**, live.
5
+
6
+ The server holds the authoritative document and runs a localhost WebSocket
7
+ **hub**. The desktop app connects to it (toggle **Live** in the toolbar) and
8
+ syncs both ways: the agent's tool calls appear in the app instantly, and your
9
+ edits in the app are visible to the agent.
10
+
11
+ ## How it works
12
+
13
+ ```
14
+ Claude ──stdio──> MCP server ──┐
15
+ (hub, ws) ├── authoritative document
16
+ EZTab app ──websocket────────┘
17
+ ```
18
+
19
+ - The app sends its document when you enable **Live** (seeds the hub if empty),
20
+ then pushes every local edit.
21
+ - Each agent tool call mutates the shared document and broadcasts it to the app.
22
+ - Last write wins on conflict (intended for turn-taking, not simultaneous typing
23
+ in the same cell).
24
+
25
+ Default port `47823` (override with `EZTAB_LIVE_PORT`).
26
+
27
+ ## Install (published package)
28
+
29
+ Published to npm as **`@hidechae/eztab-mcp`**. Add it to any MCP client:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "eztab": { "command": "npx", "args": ["-y", "@hidechae/eztab-mcp"] }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Requires the EZTab desktop app (toggle **Live** in its toolbar) on the same
40
+ machine — the server runs a localhost WebSocket hub the app connects to.
41
+
42
+ ### Releasing a new version
43
+
44
+ npm versions are immutable, so publishing is deliberate (tag-driven):
45
+
46
+ ```sh
47
+ # bump mcp/package.json "version", commit, then:
48
+ git tag mcp-v0.1.1 && git push --tags
49
+ ```
50
+
51
+ The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
52
+ (needs the `NPM_TOKEN` repo secret).
53
+
54
+ ## Setup (local dev)
55
+
56
+ Dependencies are in the repo's `package.json` (`npm install`). This repo ships a
57
+ project-scoped `.mcp.json`, so Claude Code running here offers the `eztab`
58
+ server automatically (approve it when asked). Then click **Live** in the app.
59
+
60
+ For Claude Desktop or elsewhere:
61
+
62
+ ```json
63
+ {
64
+ "mcpServers": {
65
+ "eztab": {
66
+ "command": "npx",
67
+ "args": ["tsx", "mcp/server.ts"],
68
+ "cwd": "/absolute/path/to/eztab"
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
75
+
76
+ ## Tools
77
+
78
+ These act on the live shared document (no file path needed):
79
+
80
+ | Tool | What it does |
81
+ |------|--------------|
82
+ | `get_tab` | Readable ASCII rendering of the current document |
83
+ | `new_tab` | Replace the document with a fresh one (title, artist, tuning/preset, measures, tempo) |
84
+ | `load_from_file` / `save_to_file` | Seed from / persist to an `.eztab` file |
85
+ | `set_title` | Set the document title |
86
+ | `set_artist` | Set the document artist |
87
+ | `set_cells` | Set tab cells: a fret number, `rest`, `mute` (cutting "x"), or `clear` (empty = sustain). Optional `bend` (0.5/1/1.5) chokes a fret note; optional `tie` ties/slurs it to the previous note (same pitch = just sustain) |
88
+ | `set_chords` | Set chord labels per beat |
89
+ | `set_key` / `remove_key` | Set/add or remove a key (key change) at a measure |
90
+ | `set_section` | Set/remove a section marker (e.g. "Verse", "Chorus") |
91
+ | `set_tempo` | Set playback BPM |
92
+ | `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets) |
93
+ | `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
94
+ | `set_tuning` | Replace tuning / string count |
95
+
96
+ These cover the editor's current model: variable string count, per-beat chords,
97
+ keys with mid-piece changes, section markers, per-measure resolution, tempo, and
98
+ title/artist metadata. (Playback, the on-screen keypad, undo/redo and the local
99
+ library/archive are app-only UI concerns and intentionally have no MCP tools.)
100
+
101
+ Indices are 0-based. `string` 0 is the top line; tuning is ordered high→low.
102
+ If no app is connected, edits still apply to the in-memory document and sync
103
+ when the app connects.
package/dist/server.js ADDED
@@ -0,0 +1,678 @@
1
+ #!/usr/bin/env node
2
+
3
+ // server.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { WebSocket, WebSocketServer } from "ws";
8
+
9
+ // ../src/types/tab.ts
10
+ var TAB_FILE_VERSION = 1;
11
+ var DEFAULT_TEMPO = 120;
12
+ var DEFAULT_MIX = { melody: 1, backing: 0.4 };
13
+ function shiftSectionsForInsert(sections, index) {
14
+ return sections.map(
15
+ (s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s
16
+ );
17
+ }
18
+ function shiftSectionsForDelete(sections, index) {
19
+ return sections.filter((s) => s.measure !== index).map((s) => s.measure > index ? { ...s, measure: s.measure - 1 } : s);
20
+ }
21
+ function activeKeyAt(keys, measure) {
22
+ let result = keys[0];
23
+ for (const k of keys) {
24
+ if (k.measure <= measure) result = k;
25
+ else break;
26
+ }
27
+ return result;
28
+ }
29
+ function shiftKeysForInsert(keys, index) {
30
+ const shifted = keys.map(
31
+ (k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k
32
+ );
33
+ if (!shifted.some((k) => k.measure === 0)) {
34
+ shifted.unshift({ measure: 0, tonic: keys[0].tonic, mode: keys[0].mode });
35
+ }
36
+ return shifted;
37
+ }
38
+ function shiftKeysForDelete(keys, index) {
39
+ const next = keys.filter((k) => k.measure !== index).map((k) => k.measure > index ? { ...k, measure: k.measure - 1 } : k);
40
+ if (!next.some((k) => k.measure === 0)) {
41
+ next.unshift({ measure: 0, tonic: keys[0].tonic, mode: keys[0].mode });
42
+ }
43
+ return next;
44
+ }
45
+ function setMeasureTimeSignature(measure, beats, beatUnit) {
46
+ const cols = beats * measure.subdivisions;
47
+ const cells = measure.cells.map(
48
+ (row) => Array.from({ length: cols }, (_, c) => row[c] ?? null)
49
+ );
50
+ const chords = Array.from(
51
+ { length: beats },
52
+ (_, b) => measure.chords?.[b] ?? null
53
+ );
54
+ return { ...measure, beats, beatUnit, cells, chords };
55
+ }
56
+ function columnCount(measure) {
57
+ return measure.beats * measure.subdivisions;
58
+ }
59
+ var PRESET_TUNINGS = {
60
+ "Guitar (6)": ["E4", "B3", "G3", "D3", "A2", "E2"],
61
+ "Bass (4)": ["G2", "D2", "A1", "E1"],
62
+ "Bass (5)": ["G2", "D2", "A1", "E1", "B0"],
63
+ "Ukulele (4)": ["A4", "E4", "C4", "G4"]
64
+ };
65
+ var DEFAULT_BEATS = 4;
66
+ var DEFAULT_BEAT_UNIT = 4;
67
+ var DEFAULT_SUBDIVISIONS = 2;
68
+ function createMeasure(stringCount, beats = DEFAULT_BEATS, subdivisions = DEFAULT_SUBDIVISIONS, beatUnit = DEFAULT_BEAT_UNIT) {
69
+ const columns = beats * subdivisions;
70
+ return {
71
+ beats,
72
+ beatUnit,
73
+ subdivisions,
74
+ cells: Array.from(
75
+ { length: stringCount },
76
+ () => Array.from({ length: columns }, () => null)
77
+ ),
78
+ chords: Array.from({ length: beats }, () => null)
79
+ };
80
+ }
81
+ function newTabId() {
82
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
83
+ return crypto.randomUUID();
84
+ }
85
+ return `id-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
86
+ }
87
+ function createDocument(tuning = PRESET_TUNINGS["Guitar (6)"], measureCount = 4) {
88
+ return {
89
+ version: TAB_FILE_VERSION,
90
+ id: newTabId(),
91
+ title: "Untitled",
92
+ artist: "",
93
+ tuning: [...tuning],
94
+ measures: Array.from(
95
+ { length: measureCount },
96
+ () => createMeasure(tuning.length)
97
+ ),
98
+ keys: [{ measure: 0, tonic: "C", mode: "major" }],
99
+ sections: [],
100
+ tempo: DEFAULT_TEMPO,
101
+ mix: { ...DEFAULT_MIX }
102
+ };
103
+ }
104
+ function resizeMeasureStrings(measure, stringCount) {
105
+ const cols = columnCount(measure);
106
+ const cells = Array.from(
107
+ { length: stringCount },
108
+ (_, s) => Array.from({ length: cols }, (_2, c) => measure.cells[s]?.[c] ?? null)
109
+ );
110
+ return { ...measure, cells };
111
+ }
112
+ function setMeasureSubdivisions(measure, subdivisions) {
113
+ const oldCols = columnCount(measure);
114
+ const newCols = measure.beats * subdivisions;
115
+ const cells = measure.cells.map((row) => {
116
+ const next = Array.from({ length: newCols }, () => null);
117
+ for (let c = 0; c < oldCols; c++) {
118
+ if (row[c] == null) continue;
119
+ const ni = Math.round(c * subdivisions / measure.subdivisions);
120
+ if (ni >= 0 && ni < newCols) next[ni] = row[c];
121
+ }
122
+ return next;
123
+ });
124
+ return { ...measure, subdivisions, cells };
125
+ }
126
+
127
+ // eztab.ts
128
+ import { readFile, writeFile } from "fs/promises";
129
+ import path from "path";
130
+ function resolvePath(p) {
131
+ return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
132
+ }
133
+ function normalize(data) {
134
+ if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
135
+ throw new Error("Not a valid EZTab document");
136
+ }
137
+ data.measures = data.measures.map((m) => {
138
+ let beats = m.beats;
139
+ let subdivisions = m.subdivisions;
140
+ if (typeof beats !== "number" || typeof subdivisions !== "number") {
141
+ const cols2 = m.cells?.[0]?.length ?? m.columns ?? 8;
142
+ subdivisions = cols2 % 4 === 0 ? cols2 / 4 : 2;
143
+ beats = cols2 % 4 === 0 ? 4 : Math.max(1, Math.round(cols2 / subdivisions));
144
+ }
145
+ const beatUnit = typeof m.beatUnit === "number" ? m.beatUnit : 4;
146
+ const cols = beats * subdivisions;
147
+ const cells = (m.cells ?? []).map(
148
+ (row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
149
+ );
150
+ let chords = Array.isArray(m.chords) ? m.chords.slice(0, beats) : [];
151
+ while (chords.length < beats) chords.push(null);
152
+ return { beats, beatUnit, subdivisions, cells, chords };
153
+ });
154
+ if (!Array.isArray(data.keys) || data.keys.length === 0) {
155
+ data.keys = [{ measure: 0, tonic: "C", mode: "major" }];
156
+ }
157
+ if (typeof data.artist !== "string") data.artist = "";
158
+ if (!Array.isArray(data.sections)) data.sections = [];
159
+ if (typeof data.tempo !== "number") data.tempo = 120;
160
+ if (!data.mix || typeof data.mix.melody !== "number") {
161
+ data.mix = { melody: 1, backing: 0.4 };
162
+ }
163
+ data.version = 1;
164
+ return data;
165
+ }
166
+ async function loadTab(p) {
167
+ const text2 = await readFile(resolvePath(p), "utf8");
168
+ return normalize(JSON.parse(text2));
169
+ }
170
+ async function saveTab(p, doc) {
171
+ await writeFile(resolvePath(p), JSON.stringify(doc, null, 2), "utf8");
172
+ }
173
+ function cellText(cell) {
174
+ if (cell === null) return "--";
175
+ if (cell === "rest") return "-R";
176
+ if (cell === "mute") return "-x";
177
+ if (typeof cell === "object") {
178
+ const bend = cell.bend ?? 0;
179
+ if (cell.tie) return `~${cell.fret}`.padStart(2, "-").slice(-2);
180
+ const mark = bend === 0.5 ? "b" : bend === 1.5 ? "^" : bend === 1 ? "B" : "";
181
+ return `${cell.fret}${mark}`.padStart(2, "-").slice(-2);
182
+ }
183
+ return String(cell).padStart(2, "-");
184
+ }
185
+ function render(doc) {
186
+ const lines = [];
187
+ lines.push(
188
+ `Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`
189
+ );
190
+ lines.push(
191
+ `Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`
192
+ );
193
+ const ann = [];
194
+ doc.measures.forEach((_, m) => {
195
+ const k = doc.keys.find((x) => x.measure === m);
196
+ const s = doc.sections.find((x) => x.measure === m);
197
+ if (k) ann.push(`m${m + 1}:key=${k.tonic} ${k.mode}`);
198
+ if (s && s.label) ann.push(`m${m + 1}:section="${s.label}"`);
199
+ });
200
+ if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
201
+ doc.measures.forEach((m, mi) => {
202
+ const key = activeKeyAt(doc.keys, mi);
203
+ lines.push("");
204
+ lines.push(
205
+ `--- Measure ${mi + 1} (${m.beats}/${m.beatUnit}, ${m.subdivisions}/beat, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
206
+ );
207
+ const chordRow = (m.chords ?? []).map((c, b) => `b${b + 1}:${c ?? "-"}`).join(" ");
208
+ lines.push(`chords: ${chordRow}`);
209
+ const ruler = Array.from(
210
+ { length: columnCount(m) },
211
+ (_, c) => String(c).padStart(2, " ")
212
+ ).join(" ");
213
+ lines.push(`col: ${ruler}`);
214
+ m.cells.forEach((row, s) => {
215
+ const label = (doc.tuning[s] ?? "?").padEnd(3, " ");
216
+ const cells = row.map(cellText).join(" ");
217
+ lines.push(`${label}| ${cells}`);
218
+ });
219
+ });
220
+ return lines.join("\n");
221
+ }
222
+
223
+ // server.ts
224
+ var PORT = Number(process.env.EZTAB_LIVE_PORT ?? 47823);
225
+ var state = {
226
+ doc: null,
227
+ version: 0
228
+ };
229
+ var mode = "host";
230
+ var clients = /* @__PURE__ */ new Set();
231
+ var upstream = null;
232
+ var appEditable = null;
233
+ function pushState() {
234
+ if (mode === "host") {
235
+ const msg = JSON.stringify({
236
+ type: "state",
237
+ doc: state.doc,
238
+ version: state.version
239
+ });
240
+ for (const c of clients) {
241
+ if (c.readyState === WebSocket.OPEN) c.send(msg);
242
+ }
243
+ } else if (upstream && upstream.readyState === WebSocket.OPEN) {
244
+ upstream.send(JSON.stringify({ type: "set", doc: state.doc }));
245
+ }
246
+ }
247
+ function sendCommand(cmd) {
248
+ const out = JSON.stringify(cmd);
249
+ if (mode === "host") {
250
+ let sent = false;
251
+ for (const c of clients) {
252
+ if (c.readyState === WebSocket.OPEN) {
253
+ c.send(out);
254
+ sent = true;
255
+ }
256
+ }
257
+ return sent;
258
+ }
259
+ if (upstream && upstream.readyState === WebSocket.OPEN) {
260
+ upstream.send(out);
261
+ return true;
262
+ }
263
+ return false;
264
+ }
265
+ function broadcastExcept(except) {
266
+ const msg = JSON.stringify({
267
+ type: "state",
268
+ doc: state.doc,
269
+ version: state.version
270
+ });
271
+ for (const c of clients) {
272
+ if (c !== except && c.readyState === WebSocket.OPEN) c.send(msg);
273
+ }
274
+ }
275
+ var wss = new WebSocketServer({ port: PORT });
276
+ wss.on("connection", (ws) => {
277
+ clients.add(ws);
278
+ ws.send(
279
+ JSON.stringify({ type: "state", doc: state.doc, version: state.version })
280
+ );
281
+ ws.on("message", (data) => {
282
+ let msg;
283
+ try {
284
+ msg = JSON.parse(data.toString());
285
+ } catch {
286
+ return;
287
+ }
288
+ if (msg.type === "set" && msg.doc) {
289
+ state.doc = normalize(msg.doc);
290
+ state.version += 1;
291
+ broadcastExcept(ws);
292
+ } else if (msg.type === "seed" && msg.doc) {
293
+ if (!state.doc) {
294
+ state.doc = normalize(msg.doc);
295
+ state.version += 1;
296
+ broadcastExcept(ws);
297
+ }
298
+ } else if (msg.type === "get") {
299
+ ws.send(
300
+ JSON.stringify({
301
+ type: "state",
302
+ doc: state.doc,
303
+ version: state.version
304
+ })
305
+ );
306
+ } else if (msg.type === "editstate") {
307
+ appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
308
+ ws._isApp = true;
309
+ const out = JSON.stringify({ type: "editstate", editable: appEditable });
310
+ for (const c of clients) {
311
+ if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
312
+ }
313
+ } else if (msg.type === "command") {
314
+ const out = JSON.stringify(msg);
315
+ for (const c of clients) {
316
+ if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
317
+ }
318
+ }
319
+ });
320
+ const onGone = () => {
321
+ clients.delete(ws);
322
+ if (ws._isApp) {
323
+ appEditable = null;
324
+ const out = JSON.stringify({ type: "editstate", editable: null });
325
+ for (const c of clients) {
326
+ if (c.readyState === WebSocket.OPEN) c.send(out);
327
+ }
328
+ }
329
+ };
330
+ ws.on("close", onGone);
331
+ ws.on("error", onGone);
332
+ });
333
+ wss.on("error", (e) => {
334
+ if (e.code === "EADDRINUSE") {
335
+ enterClientMode();
336
+ } else {
337
+ console.error("hub error:", e);
338
+ }
339
+ });
340
+ function enterClientMode() {
341
+ mode = "client";
342
+ console.error(`port ${PORT} in use; proxying to existing hub as a client`);
343
+ const connect = () => {
344
+ const ws = new WebSocket(`ws://127.0.0.1:${PORT}`);
345
+ upstream = ws;
346
+ ws.on("open", () => ws.send(JSON.stringify({ type: "get" })));
347
+ ws.on("message", (data) => {
348
+ try {
349
+ const msg = JSON.parse(data.toString());
350
+ if (msg.type === "state" && msg.doc) {
351
+ state.doc = normalize(msg.doc);
352
+ state.version = msg.version;
353
+ } else if (msg.type === "editstate") {
354
+ appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
355
+ }
356
+ } catch {
357
+ }
358
+ });
359
+ ws.on("close", () => {
360
+ upstream = null;
361
+ setTimeout(connect, 2e3);
362
+ });
363
+ ws.on("error", () => ws.close());
364
+ };
365
+ connect();
366
+ }
367
+ var server = new McpServer({ name: "eztab", version: "0.2.0" });
368
+ function text(s) {
369
+ return { content: [{ type: "text", text: s }] };
370
+ }
371
+ function requireDoc() {
372
+ if (!state.doc) {
373
+ throw new Error(
374
+ "No document is open. Open the EZTab app (it will connect), or call new_tab / load_from_file first."
375
+ );
376
+ }
377
+ return state.doc;
378
+ }
379
+ function assertEditable() {
380
+ if (appEditable === false) {
381
+ throw new Error(
382
+ "The EZTab app has this tab open in View mode, so it can't be changed. Switch the tab to Edit mode in the app, then retry. (Reading is always allowed.)"
383
+ );
384
+ }
385
+ }
386
+ function applyEdit(mutate) {
387
+ assertEditable();
388
+ const doc = requireDoc();
389
+ mutate(doc);
390
+ state.version += 1;
391
+ pushState();
392
+ const connected = mode === "host" ? clients.size > 0 : upstream != null;
393
+ return text(
394
+ `${connected ? "Pushed to app. " : "No app connected (will sync when it opens). "}
395
+
396
+ ${render(doc)}`
397
+ );
398
+ }
399
+ function setDoc(doc) {
400
+ assertEditable();
401
+ state.doc = doc;
402
+ state.version += 1;
403
+ pushState();
404
+ return text(render(doc));
405
+ }
406
+ function assertMeasure(doc, m) {
407
+ if (!Number.isInteger(m) || m < 0 || m >= doc.measures.length) {
408
+ throw new Error(`measure ${m} out of range (0..${doc.measures.length - 1})`);
409
+ }
410
+ }
411
+ server.tool(
412
+ "get_tab",
413
+ "Return a readable rendering of the document currently open/shared with the app.",
414
+ {},
415
+ async () => text(render(requireDoc()))
416
+ );
417
+ server.tool(
418
+ "new_tab",
419
+ "Replace the live document with a fresh one and push it to the app. Tuning is top (highest) to bottom (lowest).",
420
+ {
421
+ title: z.string().optional(),
422
+ artist: z.string().optional(),
423
+ preset: z.enum(["Guitar (6)", "Bass (4)", "Bass (5)", "Ukulele (4)"]).optional(),
424
+ tuning: z.array(z.string()).optional(),
425
+ measures: z.number().int().min(1).max(512).optional(),
426
+ tempo: z.number().int().min(20).max(400).optional()
427
+ },
428
+ async (a) => {
429
+ const tuning = a.tuning ?? (a.preset ? PRESET_TUNINGS[a.preset] : void 0);
430
+ const doc = createDocument(tuning, a.measures ?? 4);
431
+ if (a.title) doc.title = a.title;
432
+ if (a.artist) doc.artist = a.artist;
433
+ if (a.tempo) doc.tempo = a.tempo;
434
+ return setDoc(doc);
435
+ }
436
+ );
437
+ server.tool(
438
+ "set_title",
439
+ "Set the document title.",
440
+ { title: z.string() },
441
+ async (a) => applyEdit((doc) => {
442
+ doc.title = a.title;
443
+ })
444
+ );
445
+ server.tool(
446
+ "set_artist",
447
+ "Set the document artist (may be empty).",
448
+ { artist: z.string() },
449
+ async (a) => applyEdit((doc) => {
450
+ doc.artist = a.artist;
451
+ })
452
+ );
453
+ server.tool(
454
+ "load_from_file",
455
+ "Load an .eztab file into the live document and push it to the app.",
456
+ { path: z.string() },
457
+ async (a) => setDoc(await loadTab(a.path))
458
+ );
459
+ server.tool(
460
+ "save_to_file",
461
+ "Save the current live document to an .eztab file.",
462
+ { path: z.string() },
463
+ async (a) => {
464
+ await saveTab(a.path, requireDoc());
465
+ return text(`Saved ${resolvePath(a.path)}`);
466
+ }
467
+ );
468
+ server.tool(
469
+ "open_in_app",
470
+ "Ask the connected EZTab app to open the tab file at `path` (the app must have Live enabled). The app loads it into its editor and shares it back.",
471
+ { path: z.string() },
472
+ async (a) => {
473
+ const sent = sendCommand({
474
+ type: "command",
475
+ action: "open",
476
+ path: resolvePath(a.path)
477
+ });
478
+ return text(
479
+ sent ? `Asked the app to open ${resolvePath(a.path)}` : "No app is connected. Enable Live in the EZTab app first."
480
+ );
481
+ }
482
+ );
483
+ server.tool(
484
+ "set_cells",
485
+ "Set tab cells. value: a fret number (0-24), 'rest' (stops a sustaining note), 'mute' (a muted/dead 'x' stroke for cutting), or 'clear' (empty = previous note sustains through). Optional `bend` (0.5, 1, or 1.5 semitones) chokes a fret-number note. Optional `tie` (boolean) ties/slurs a fret-number note to the previous note on the same string \u2014 a matching pitch just sustains (no re-pick), a different pitch is a slur. string/column are 0-based; string 0 is the top line.",
486
+ {
487
+ edits: z.array(
488
+ z.object({
489
+ measure: z.number().int(),
490
+ string: z.number().int(),
491
+ column: z.number().int(),
492
+ value: z.union([
493
+ z.number().int().min(0).max(24),
494
+ z.enum(["rest", "mute", "clear"])
495
+ ]),
496
+ bend: z.union([z.literal(0.5), z.literal(1), z.literal(1.5)]).optional(),
497
+ tie: z.boolean().optional()
498
+ })
499
+ )
500
+ },
501
+ async (a) => applyEdit((doc) => {
502
+ for (const e of a.edits) {
503
+ assertMeasure(doc, e.measure);
504
+ const m = doc.measures[e.measure];
505
+ if (e.string < 0 || e.string >= doc.tuning.length) {
506
+ throw new Error(
507
+ `string ${e.string} out of range (0..${doc.tuning.length - 1})`
508
+ );
509
+ }
510
+ const cols = columnCount(m);
511
+ if (e.column < 0 || e.column >= cols) {
512
+ throw new Error(
513
+ `column ${e.column} out of range for measure ${e.measure} (0..${cols - 1})`
514
+ );
515
+ }
516
+ let value = e.value === "clear" ? null : e.value;
517
+ if (typeof e.value === "number" && (e.bend != null || e.tie)) {
518
+ value = {
519
+ fret: e.value,
520
+ ...e.bend != null ? { bend: e.bend } : {},
521
+ ...e.tie ? { tie: true } : {}
522
+ };
523
+ }
524
+ m.cells[e.string][e.column] = value;
525
+ }
526
+ })
527
+ );
528
+ server.tool(
529
+ "set_chords",
530
+ "Set chord labels per beat (0-based). An empty label clears that beat's chord.",
531
+ {
532
+ edits: z.array(
533
+ z.object({
534
+ measure: z.number().int(),
535
+ beat: z.number().int(),
536
+ label: z.string()
537
+ })
538
+ )
539
+ },
540
+ async (a) => applyEdit((doc) => {
541
+ for (const e of a.edits) {
542
+ assertMeasure(doc, e.measure);
543
+ const m = doc.measures[e.measure];
544
+ if (e.beat < 0 || e.beat >= m.beats) {
545
+ throw new Error(
546
+ `beat ${e.beat} out of range for measure ${e.measure} (0..${m.beats - 1})`
547
+ );
548
+ }
549
+ if (!m.chords) m.chords = Array.from({ length: m.beats }, () => null);
550
+ m.chords[e.beat] = e.label.trim() || null;
551
+ }
552
+ })
553
+ );
554
+ server.tool(
555
+ "set_key",
556
+ "Set or add a key (key change) starting at `measure`. measure 0 is the initial key.",
557
+ {
558
+ measure: z.number().int(),
559
+ tonic: z.string(),
560
+ mode: z.enum(["major", "minor"])
561
+ },
562
+ async (a) => applyEdit((doc) => {
563
+ assertMeasure(doc, a.measure);
564
+ const existing = doc.keys.find((k) => k.measure === a.measure);
565
+ if (existing) {
566
+ existing.tonic = a.tonic;
567
+ existing.mode = a.mode;
568
+ } else {
569
+ doc.keys.push({ measure: a.measure, tonic: a.tonic, mode: a.mode });
570
+ doc.keys.sort((x, y) => x.measure - y.measure);
571
+ }
572
+ })
573
+ );
574
+ server.tool(
575
+ "remove_key",
576
+ "Remove the key change at `measure` (measure 0 cannot be removed).",
577
+ { measure: z.number().int() },
578
+ async (a) => applyEdit((doc) => {
579
+ if (a.measure === 0) throw new Error("cannot remove the initial key");
580
+ doc.keys = doc.keys.filter((k) => k.measure !== a.measure);
581
+ })
582
+ );
583
+ server.tool(
584
+ "set_section",
585
+ "Set a section marker (e.g. 'Intro', 'Verse', 'Chorus') at `measure`. Empty label removes it.",
586
+ { measure: z.number().int(), label: z.string() },
587
+ async (a) => applyEdit((doc) => {
588
+ assertMeasure(doc, a.measure);
589
+ const label = a.label.trim();
590
+ doc.sections = doc.sections.filter((s) => s.measure !== a.measure);
591
+ if (label) {
592
+ doc.sections.push({ measure: a.measure, label });
593
+ doc.sections.sort((x, y) => x.measure - y.measure);
594
+ }
595
+ })
596
+ );
597
+ server.tool(
598
+ "set_tempo",
599
+ "Set the playback tempo (BPM).",
600
+ { bpm: z.number().int().min(20).max(400) },
601
+ async (a) => applyEdit((doc) => {
602
+ doc.tempo = a.bpm;
603
+ })
604
+ );
605
+ server.tool(
606
+ "set_time_signature",
607
+ "Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8. Resizes the measure, preserving cells/chords by index.",
608
+ {
609
+ measure: z.number().int(),
610
+ beats: z.number().int().min(1).max(32),
611
+ beatUnit: z.number().int().refine((v) => [2, 4, 8, 16].includes(v), {
612
+ message: "beatUnit must be 2, 4, 8, or 16"
613
+ })
614
+ },
615
+ async (a) => applyEdit((doc) => {
616
+ assertMeasure(doc, a.measure);
617
+ doc.measures[a.measure] = setMeasureTimeSignature(
618
+ doc.measures[a.measure],
619
+ a.beats,
620
+ a.beatUnit
621
+ );
622
+ })
623
+ );
624
+ server.tool(
625
+ "set_resolution",
626
+ "Set a measure's subdivisions per beat (1=quarter, 2=eighth, 4=sixteenth, 3=eighth triplet, 6=sixteenth triplet). Existing notes are remapped.",
627
+ { measure: z.number().int(), subdivisions: z.number().int().min(1).max(12) },
628
+ async (a) => applyEdit((doc) => {
629
+ assertMeasure(doc, a.measure);
630
+ doc.measures[a.measure] = setMeasureSubdivisions(
631
+ doc.measures[a.measure],
632
+ a.subdivisions
633
+ );
634
+ })
635
+ );
636
+ server.tool(
637
+ "add_measures",
638
+ "Insert `count` empty measures. Without `at`, appends; otherwise inserts before index `at`.",
639
+ {
640
+ count: z.number().int().min(1).max(256).optional(),
641
+ at: z.number().int().optional()
642
+ },
643
+ async (a) => applyEdit((doc) => {
644
+ const count = a.count ?? 1;
645
+ for (let i = 0; i < count; i++) {
646
+ const at = a.at == null ? doc.measures.length : a.at + i;
647
+ doc.measures.splice(at, 0, createMeasure(doc.tuning.length));
648
+ doc.keys = shiftKeysForInsert(doc.keys, at);
649
+ doc.sections = shiftSectionsForInsert(doc.sections, at);
650
+ }
651
+ })
652
+ );
653
+ server.tool(
654
+ "remove_measure",
655
+ "Remove the measure at index `measure` (at least one is kept).",
656
+ { measure: z.number().int() },
657
+ async (a) => applyEdit((doc) => {
658
+ assertMeasure(doc, a.measure);
659
+ if (doc.measures.length <= 1) throw new Error("cannot remove last measure");
660
+ doc.measures.splice(a.measure, 1);
661
+ doc.keys = shiftKeysForDelete(doc.keys, a.measure);
662
+ doc.sections = shiftSectionsForDelete(doc.sections, a.measure);
663
+ })
664
+ );
665
+ server.tool(
666
+ "set_tuning",
667
+ "Replace the tuning / string count. Existing frets are preserved where strings overlap. Order is top (highest) to bottom (lowest).",
668
+ { tuning: z.array(z.string()).min(1) },
669
+ async (a) => applyEdit((doc) => {
670
+ doc.tuning = [...a.tuning];
671
+ doc.measures = doc.measures.map(
672
+ (m) => resizeMeasureStrings(m, a.tuning.length)
673
+ );
674
+ })
675
+ );
676
+ var transport = new StdioServerTransport();
677
+ await server.connect(transport);
678
+ console.error(`eztab MCP server running (stdio) + live hub on ws://127.0.0.1:${PORT}`);
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@hidechae/eztab-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for EZTab — lets an AI agent edit the tab the EZTab app has open, live.",
5
+ "type": "module",
6
+ "bin": {
7
+ "eztab-mcp": "dist/server.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "eztab",
19
+ "guitar",
20
+ "tablature"
21
+ ],
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/hidechae/eztab.git",
26
+ "directory": "mcp"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.29.0",
34
+ "ws": "^8.21.0",
35
+ "zod": "^4.4.3"
36
+ },
37
+ "devDependencies": {
38
+ "tsup": "^8.3.0",
39
+ "typescript": "^5.8.3"
40
+ }
41
+ }