@hidechae/eztab-mcp 0.2.0 → 0.3.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 +23 -4
  2. package/dist/server.js +458 -54
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,11 +47,16 @@ npm versions are immutable, so publishing is deliberate (tag-driven):
47
47
 
48
48
  ```sh
49
49
  # bump mcp/package.json "version", commit, then:
50
- git tag mcp-v0.1.3 && git push --tags
50
+ git tag mcp-vX.Y.Z origin/main && git push origin mcp-vX.Y.Z
51
51
  ```
52
52
 
53
- The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
54
- (needs the `NPM_TOKEN` repo secret).
53
+ The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags.
54
+ 認証は npm の Trusted Publishing(GitHub Actions の OIDC)で、トークンの Secret は不要。
55
+ npmjs.com のパッケージ設定で `hidechae/eztab` の `publish-mcp.yml` を Trusted Publisher に
56
+ 登録してある前提。タグ名と `mcp/package.json` の version が一致しないと公開しない。
57
+ 公開に失敗したタグをやり直すときは、失敗した run の re-run ではなく(古いワークフローのまま
58
+ 再実行される)、`Publish MCP to npm` を workflow_dispatch し、`tag` にそのタグを指定する
59
+ (`gh workflow run publish-mcp.yml -f tag=mcp-vX.Y.Z`)。指定したタグの中身が公開される。
55
60
 
56
61
  ## Setup (local dev)
57
62
 
@@ -95,7 +100,21 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
95
100
  | `set_time_signature` | Set a measure's time signature |
96
101
  | `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets/quintuplets) |
97
102
  | `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
98
- | `set_tuning` | Replace tuning / string count |
103
+ | `set_tuning` | Replace tuning / string count (`track` で対象パートを指定) |
104
+ | `add_track` / `remove_track` | パート(ベース・2本目のギターなど)の追加・削除。メインを含めて最大4、track 0(メイン)は削除不可 |
105
+
106
+ ### 複数パート(トラック)
107
+
108
+ 1つのタブにメインを含めて最大4パートを持てる(docs/design/0001-multiple-tracks.md)。
109
+
110
+ - `set_cells` / `write_measures` / `set_resolution` / `set_tuning` は任意の `track`(0 始まり、既定 0 = メイン)を取る
111
+ - 小節と拍子は全パート共通。`add_measures` / `remove_measure` / `set_time_signature` は全パートに作用し、
112
+ `write_measures` で末尾に書き足すと全パートに小節が増える。分割(subdivisions)はパートごと
113
+ - コードは track 0 のもの(`set_chords` は track 0 の列で指定)。`write_measures(track > 0)` ではコード指定と、
114
+ 既存小節の拍子を変える `time` を拒否する
115
+ - `get_tab` は全パートを小節ごとに並べて表示する(`track` で1パートに絞れる)
116
+ - `send_to_eztab` は `x-eztab-format-revision: 2` を付けて送る。0.2.0 以前の MCP は複数パートのタブを
117
+ 送り返せない(サーバが 409 で拒否する)。新しい形式のタブ(リビジョン 3 以降)は読めるが編集・送信できない
99
118
 
100
119
  These cover the editor's current model: variable string count, per-beat chords,
101
120
  keys with mid-piece changes, section markers, per-measure resolution, time
package/dist/server.js CHANGED
@@ -7,6 +7,10 @@ import { z } from "zod";
7
7
 
8
8
  // ../src/types/tab.ts
9
9
  var TAB_FILE_VERSION = 1;
10
+ var SUPPORTED_FORMAT_REVISION = 2;
11
+ var MULTI_TRACK_FORMAT_REVISION = 2;
12
+ var MAX_TRACKS = 4;
13
+ var DEFAULT_MAIN_TRACK_NAME = "Guitar";
10
14
  var DEFAULT_TEMPO = 120;
11
15
  var DEFAULT_MIX = { melody: 1, backing: 0.4 };
12
16
  function shiftSectionsForInsert(sections, index) {
@@ -126,9 +130,59 @@ function normalizeDocument(data) {
126
130
  if (!data.mix || typeof data.mix.melody !== "number") {
127
131
  data.mix = { ...DEFAULT_MIX };
128
132
  }
133
+ normalizeTracks(data);
129
134
  data.version = TAB_FILE_VERSION;
130
135
  return data;
131
136
  }
137
+ function normalizeTrackMeta(raw, fallbackName) {
138
+ const volume = Number(raw?.volume);
139
+ return {
140
+ id: typeof raw?.id === "string" && raw.id ? raw.id : newTabId(),
141
+ name: typeof raw?.name === "string" && raw.name.trim() ? raw.name : fallbackName,
142
+ volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1,
143
+ muted: raw?.muted === true
144
+ };
145
+ }
146
+ function defaultMainTrack() {
147
+ return { id: newTabId(), name: DEFAULT_MAIN_TRACK_NAME, volume: 1, muted: false };
148
+ }
149
+ function normalizeTracks(data) {
150
+ if (data.trackMismatch !== true) delete data.trackMismatch;
151
+ delete data.workspace;
152
+ const rawTracks = Array.isArray(data.tracks) ? data.tracks : [];
153
+ if (data.mainTrack != null || rawTracks.length > 0) {
154
+ data.mainTrack = normalizeTrackMeta(data.mainTrack, DEFAULT_MAIN_TRACK_NAME);
155
+ }
156
+ if (rawTracks.length === 0) {
157
+ delete data.tracks;
158
+ return;
159
+ }
160
+ const measures = data.measures;
161
+ data.tracks = rawTracks.slice(0, MAX_TRACKS - 1).map((t, i) => {
162
+ const tuning = Array.isArray(t?.tuning) && t.tuning.length > 0 && t.tuning.every((n) => typeof n === "string") ? [...t.tuning] : [...PRESET_TUNINGS["Guitar (6)"]];
163
+ const rawMeasures = Array.isArray(t?.measures) ? t.measures : [];
164
+ if (rawMeasures.length !== measures.length) data.trackMismatch = true;
165
+ return {
166
+ ...normalizeTrackMeta(t, `Track ${i + 2}`),
167
+ tuning,
168
+ measures: measures.map((m, mi) => fitTrackMeasure(rawMeasures[mi], m, tuning.length))
169
+ };
170
+ });
171
+ }
172
+ function fitTrackMeasure(raw, main, stringCount) {
173
+ const rawSubs = Array.isArray(raw?.subdivisions) ? raw.subdivisions : [];
174
+ const subdivisions = Array.from({ length: main.beats }, (_, b) => {
175
+ const v = rawSubs[Math.min(b, rawSubs.length - 1)];
176
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
177
+ });
178
+ const cols = subdivisions.reduce((a, b) => a + b, 0);
179
+ const rows = Array.isArray(raw?.cells) ? raw.cells : [];
180
+ const cells = Array.from(
181
+ { length: stringCount },
182
+ (_, s) => Array.from({ length: cols }, (_2, c) => rows[s]?.[c] ?? null)
183
+ );
184
+ return { subdivisions, cells };
185
+ }
132
186
  var MAX_SUBDIVISION = 12;
133
187
  var PRESET_TUNING_GROUPS = [
134
188
  {
@@ -248,6 +302,202 @@ function setBeatSubdivision(measure, beat, subdivision) {
248
302
  return remapToSubdivisions(measure, subs);
249
303
  }
250
304
 
305
+ // ../src/lib/tracks.ts
306
+ function newUid() {
307
+ return newTabId();
308
+ }
309
+ function storedMeasure(m) {
310
+ const { uid: _uid, ...rest } = m;
311
+ return rest;
312
+ }
313
+ function openWorkspace(doc) {
314
+ if (doc.workspace) return doc;
315
+ const measures = doc.measures.map((m) => ({ ...m, uid: m.uid ?? newUid() }));
316
+ const extras = doc.tracks ?? [];
317
+ const metas = [
318
+ doc.mainTrack ?? defaultMainTrack(),
319
+ ...extras.map(({ tuning: _t, measures: _m, ...meta }) => meta)
320
+ ];
321
+ const parked = [
322
+ null,
323
+ ...extras.map(
324
+ (t) => ({
325
+ tuning: [...t.tuning],
326
+ measures: measures.map(
327
+ (main, i) => trackMeasureToMeasure(
328
+ main,
329
+ t.measures[i]?.subdivisions,
330
+ t.measures[i]?.cells,
331
+ t.tuning.length
332
+ )
333
+ )
334
+ })
335
+ )
336
+ ];
337
+ const { mainTrack: _mt, tracks: _tr, ...rest } = doc;
338
+ return { ...rest, measures, workspace: { active: 0, metas, parked } };
339
+ }
340
+ function trackMeasureToMeasure(main, subdivisions, cells, stringCount) {
341
+ const base = createMeasure(
342
+ stringCount,
343
+ main.beats,
344
+ subdivisions ?? main.subdivisions,
345
+ main.beatUnit
346
+ );
347
+ return {
348
+ ...base,
349
+ cells: base.cells.map((row, s) => row.map((_, c) => cells?.[s]?.[c] ?? null)),
350
+ uid: main.uid
351
+ };
352
+ }
353
+ function trackBodies(doc) {
354
+ const ws = doc.workspace;
355
+ if (!ws) return [{ tuning: doc.tuning, measures: doc.measures }];
356
+ return ws.parked.map(
357
+ (b, i) => i === ws.active ? { tuning: doc.tuning, measures: doc.measures } : b
358
+ );
359
+ }
360
+ function trackMetas(doc) {
361
+ return doc.workspace?.metas ?? [doc.mainTrack ?? defaultMainTrack()];
362
+ }
363
+ function mainMeasures(doc) {
364
+ return trackBodies(doc)[0].measures;
365
+ }
366
+ function closeWorkspace(doc) {
367
+ const ws = doc.workspace;
368
+ if (!ws) return doc;
369
+ const bodies = trackBodies(doc);
370
+ const main = bodies[0];
371
+ const {
372
+ workspace: _ws,
373
+ trackMismatch: _tm,
374
+ mainTrack: _mt,
375
+ tracks: _tr,
376
+ formatRevision,
377
+ ...rest
378
+ } = doc;
379
+ const out = {
380
+ ...rest,
381
+ tuning: [...main.tuning],
382
+ measures: main.measures.map(storedMeasure)
383
+ };
384
+ const extras = bodies.slice(1).map(
385
+ (b, i) => ({
386
+ ...ws.metas[i + 1],
387
+ tuning: [...b.tuning],
388
+ measures: b.measures.map((m) => ({ subdivisions: m.subdivisions, cells: m.cells }))
389
+ })
390
+ );
391
+ const mainMeta = ws.metas[0];
392
+ const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
393
+ if (extras.length > 0 || !mainIsDefault) out.mainTrack = mainMeta;
394
+ if (extras.length > 0) {
395
+ out.tracks = extras;
396
+ out.formatRevision = Math.max(formatRevision ?? 1, MULTI_TRACK_FORMAT_REVISION);
397
+ } else if (formatRevision != null && formatRevision > MULTI_TRACK_FORMAT_REVISION) {
398
+ out.formatRevision = formatRevision;
399
+ }
400
+ return out;
401
+ }
402
+ function reconcileTracks(prev, next) {
403
+ let doc = next.workspace ? next : openWorkspace(next);
404
+ const ws = doc.workspace;
405
+ const measures = assignUids(prev, doc.measures);
406
+ if (measures !== doc.measures) doc = { ...doc, measures };
407
+ let changed = false;
408
+ const parked = ws.parked.map((body) => {
409
+ if (!body) return body;
410
+ const fitted = fitBody(body, measures);
411
+ if (fitted !== body) changed = true;
412
+ return fitted;
413
+ });
414
+ if (changed) doc = { ...doc, workspace: { ...ws, parked } };
415
+ return doc;
416
+ }
417
+ function assignUids(prev, measures) {
418
+ const seen = /* @__PURE__ */ new Set();
419
+ let out = null;
420
+ const present = new Set(measures.map((m) => m.uid).filter(Boolean));
421
+ measures.forEach((m, i) => {
422
+ let uid = m.uid;
423
+ if (uid && seen.has(uid)) uid = void 0;
424
+ if (!uid) {
425
+ const old = prev?.measures[i]?.uid;
426
+ uid = old && !present.has(old) && !seen.has(old) ? old : newUid();
427
+ out ??= [...measures];
428
+ out[i] = { ...m, uid };
429
+ }
430
+ seen.add(uid);
431
+ });
432
+ return out ?? measures;
433
+ }
434
+ function fitBody(body, measures) {
435
+ const same = body.measures.length === measures.length && body.measures.every(
436
+ (m, i) => m.uid === measures[i].uid && m.beats === measures[i].beats && m.beatUnit === measures[i].beatUnit
437
+ );
438
+ if (same) return body;
439
+ const byUid = new Map(body.measures.map((m) => [m.uid, m]));
440
+ const stringCount = body.tuning.length;
441
+ const out = [];
442
+ measures.forEach((m) => {
443
+ let pm = byUid.get(m.uid);
444
+ if (!pm) {
445
+ const before = out[out.length - 1];
446
+ const subs = before && before.beats === m.beats ? before.subdivisions : void 0;
447
+ pm = createMeasure(stringCount, m.beats, subs, m.beatUnit);
448
+ } else if (pm.beats !== m.beats || pm.beatUnit !== m.beatUnit) {
449
+ pm = setMeasureTimeSignature(pm, m.beats, m.beatUnit);
450
+ }
451
+ out.push({ ...pm, uid: m.uid });
452
+ });
453
+ return { ...body, measures: out };
454
+ }
455
+ function switchTrack(doc, index) {
456
+ const ws = doc.workspace;
457
+ if (!ws || index === ws.active || index < 0 || index >= ws.metas.length) return doc;
458
+ const parked = [...ws.parked];
459
+ parked[ws.active] = { tuning: doc.tuning, measures: doc.measures };
460
+ const body = parked[index];
461
+ parked[index] = null;
462
+ return {
463
+ ...doc,
464
+ tuning: body.tuning,
465
+ measures: body.measures,
466
+ workspace: { ...ws, active: index, parked }
467
+ };
468
+ }
469
+ function addTrack(doc, name, tuning) {
470
+ const ws = doc.workspace;
471
+ if (!ws || ws.metas.length >= MAX_TRACKS) return doc;
472
+ const main = mainMeasures(doc);
473
+ const body = {
474
+ tuning: [...tuning],
475
+ measures: main.map((m) => ({
476
+ ...createMeasure(tuning.length, m.beats, m.subdivisions, m.beatUnit),
477
+ uid: m.uid
478
+ }))
479
+ };
480
+ const meta = { id: newTabId(), name, volume: 1, muted: false };
481
+ const added = {
482
+ ...doc,
483
+ workspace: { ...ws, metas: [...ws.metas, meta], parked: [...ws.parked, body] }
484
+ };
485
+ return switchTrack(added, ws.metas.length);
486
+ }
487
+ function removeTrack(doc, index) {
488
+ const ws = doc.workspace;
489
+ if (!ws || index <= 0 || index >= ws.metas.length) return doc;
490
+ const shown = ws.active === index ? switchTrack(doc, 0) : doc;
491
+ const w = shown.workspace;
492
+ const metas = w.metas.filter((_, i) => i !== index);
493
+ const parked = w.parked.filter((_, i) => i !== index);
494
+ const active = w.active > index ? w.active - 1 : w.active;
495
+ return { ...shown, workspace: { active, metas, parked } };
496
+ }
497
+ function storedSize(doc) {
498
+ return new TextEncoder().encode(JSON.stringify(closeWorkspace(doc))).length;
499
+ }
500
+
251
501
  // ../src/lib/config.ts
252
502
  var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
253
503
 
@@ -293,7 +543,12 @@ var cloud = {
293
543
  async sendToInbox(payload) {
294
544
  await req("/inbox", {
295
545
  method: "POST",
296
- headers: { "content-type": "application/json" },
546
+ headers: {
547
+ "content-type": "application/json",
548
+ // The newest tab format this build edits safely; the server refuses
549
+ // newer tabs from older builds (docs/design/0001-multiple-tracks.md).
550
+ "x-eztab-format-revision": String(SUPPORTED_FORMAT_REVISION)
551
+ },
297
552
  body: JSON.stringify(payload)
298
553
  });
299
554
  }
@@ -315,46 +570,79 @@ function cellText(cell) {
315
570
  }
316
571
  return String(cell).padStart(2, "-");
317
572
  }
318
- function render(doc, from = 0, to = doc.measures.length - 1) {
573
+ function render(doc, from = 0, to = doc.measures.length - 1, track) {
574
+ const bodies = trackBodies(doc);
575
+ const metas = trackMetas(doc);
576
+ const multi = bodies.length > 1;
577
+ const main = bodies[0];
578
+ const shown = bodies.map((_, i) => i).filter((i) => track == null || i === track);
319
579
  const lines = [];
320
580
  lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
321
- lines.push(`Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`);
581
+ if (multi) {
582
+ lines.push(`Tracks (track index: name, tuning top\u2192bottom) measures=${main.measures.length}`);
583
+ metas.forEach((t, i) => {
584
+ lines.push(
585
+ ` ${i}: ${t.name} ${bodies[i].tuning.join(" ")}${i === 0 ? " (main: carries the chords)" : ""}`
586
+ );
587
+ });
588
+ } else {
589
+ lines.push(`Tuning (top\u2192bottom): ${main.tuning.join(" ")} measures=${main.measures.length}`);
590
+ }
322
591
  const ann = [];
323
- doc.measures.forEach((_, m) => {
592
+ main.measures.forEach((_, m) => {
324
593
  const k = doc.keys.find((x) => x.measure === m);
325
594
  const s = doc.sections.find((x) => x.measure === m);
326
595
  if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
327
596
  if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
328
597
  });
329
598
  if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
330
- doc.measures.forEach((m, mi) => {
599
+ main.measures.forEach((m, mi) => {
331
600
  if (mi < from || mi > to) return;
332
601
  const key = activeKeyAt(doc.keys, mi);
333
- const sub = uniformSubdivision(m);
334
- const subText = sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
335
602
  lines.push("");
603
+ if (!multi) {
604
+ lines.push(
605
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText(m)}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
606
+ );
607
+ lines.push(chordLine(m));
608
+ measureRows(lines, m, main.tuning);
609
+ return;
610
+ }
336
611
  lines.push(
337
- `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
612
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
338
613
  );
339
- const chordRow = (m.chords ?? []).map((label, c) => label ? `c${c}:${chordDisplay(label)}` : null).filter(Boolean).join(" ");
340
- lines.push(`chords: ${chordRow || "(none)"}`);
341
- const ruler = Array.from({ length: columnCount(m) }, (_, c) => String(c).padStart(2, " ")).join(
342
- " "
343
- );
344
- lines.push(`col: ${ruler}`);
345
- m.cells.forEach((row, s) => {
346
- const label = `${s + 1} ${doc.tuning[s] ?? "?"}`.padEnd(6, " ");
347
- const cells = row.map(cellText).join(" ");
348
- lines.push(`${label}| ${cells}`);
349
- });
350
- const pm = Array.from(
351
- { length: columnCount(m) },
352
- (_, c) => m.cells.some((row) => cellPalmMute(row[c] ?? null)) ? "PM" : " "
353
- );
354
- if (pm.some((x) => x !== " ")) lines.push(`pm: ${pm.join(" ")}`);
614
+ lines.push(`${chordLine(m)} (columns of track 0)`);
615
+ for (const t of shown) {
616
+ const tm = bodies[t].measures[mi];
617
+ lines.push(`[track ${t}: ${metas[t].name}] ${subText(tm)}, ${columnCount(tm)} cols`);
618
+ measureRows(lines, tm, bodies[t].tuning);
619
+ }
355
620
  });
356
621
  return lines.join("\n");
357
622
  }
623
+ function subText(m) {
624
+ const sub = uniformSubdivision(m);
625
+ return sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
626
+ }
627
+ function chordLine(m) {
628
+ const row = (m.chords ?? []).map((label, c) => label ? `c${c}:${chordDisplay(label)}` : null).filter(Boolean).join(" ");
629
+ return `chords: ${row || "(none)"}`;
630
+ }
631
+ function measureRows(lines, m, tuning) {
632
+ const ruler = Array.from({ length: columnCount(m) }, (_, c) => String(c).padStart(2, " ")).join(
633
+ " "
634
+ );
635
+ lines.push(`col: ${ruler}`);
636
+ m.cells.forEach((row, s) => {
637
+ const label = `${s + 1} ${tuning[s] ?? "?"}`.padEnd(6, " ");
638
+ lines.push(`${label}| ${row.map(cellText).join(" ")}`);
639
+ });
640
+ const pm = Array.from(
641
+ { length: columnCount(m) },
642
+ (_, c) => m.cells.some((row) => cellPalmMute(row[c] ?? null)) ? "PM" : " "
643
+ );
644
+ if (pm.some((x) => x !== " ")) lines.push(`pm: ${pm.join(" ")}`);
645
+ }
358
646
 
359
647
  // notation.ts
360
648
  var BEAT_UNITS2 = [2, 4, 8, 16];
@@ -449,6 +737,7 @@ var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an
449
737
  - Identify which staff to take. A band score has several parts (Vocal, Guitar I/II, Keyboard, Bass, Drums). If a part was not named and more than one has TAB, list the parts you see and ask which one \u2014 do not guess.
450
738
  - From the image work out: instrument and string count (6 lines = guitar, 4 = bass), tuning if printed (assume standard otherwise), time signature, key signature, tempo mark, and how many bars you will transcribe. Printed bar numbers (e.g. 35, 36 \u2026) are just labels \u2014 the document starts at measure 0.
451
739
  - Start with new_tab (preset "Guitar (6)" / "Bass (4)" / \u2026, measures: 1, title if known). If the user is adding to a document already open in this session, append to it instead.
740
+ - To transcribe several parts into one tab (e.g. guitar + bass), write the first part on track 0, then add_track for each further part and pass its \`track\` index to write_measures. Measures and time signatures are shared by all tracks; chords (@Name) go on track 0 only.
452
741
  - Set the key with set_key when there is a key signature (1 flat \u2192 F major, or D minor if the harmony says so), and the tempo with set_tempo when one is printed.
453
742
 
454
743
  ## 2. Write bar by bar with write_measures
@@ -480,9 +769,9 @@ var state = {
480
769
  source: null
481
770
  };
482
771
  var server = new McpServer(
483
- { name: "eztab", version: "0.2.0" },
772
+ { name: "eztab", version: "0.3.0" },
484
773
  {
485
- instructions: "All indices are 0-based: measure 0 is the first measure, string 0 is the top (highest) string, and column 0 is the first column. Notes and chords share the same column grid. Subdivisions are per beat and can differ within a measure (e.g. [2,2,3,2] mixes a triplet beat into straight eighths), so columns per measure = the sum of its per-beat subdivisions; get_tab's ruler shows the column indices. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing. Exception: write_measures uses guitar string numbers (1 = top line), matching the labels get_tab prints. To write whole measures (e.g. transcribing a score or an image), prefer write_measures over set_cells \u2014 it takes one string per beat and does the column arithmetic for you. For transcribing from an image, follow the import_tab_from_image prompt. To deliver a result to the EZTab app, call send_to_eztab; to fix an existing tab, list_tabs \u2192 open_tab \u2192 edit \u2192 send_to_eztab."
774
+ instructions: "All indices are 0-based: measure 0 is the first measure, string 0 is the top (highest) string, and column 0 is the first column. Notes and chords share the same column grid. Subdivisions are per beat and can differ within a measure (e.g. [2,2,3,2] mixes a triplet beat into straight eighths), so columns per measure = the sum of its per-beat subdivisions; get_tab's ruler shows the column indices. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing. Exception: write_measures uses guitar string numbers (1 = top line), matching the labels get_tab prints. To write whole measures (e.g. transcribing a score or an image), prefer write_measures over set_cells \u2014 it takes one string per beat and does the column arithmetic for you. For transcribing from an image, follow the import_tab_from_image prompt. To deliver a result to the EZTab app, call send_to_eztab; to fix an existing tab, list_tabs \u2192 open_tab \u2192 edit \u2192 send_to_eztab. A tab can hold up to 4 tracks (parts, e.g. guitar + bass); track 0 is the main track. Tools that edit notes take an optional 0-based `track` (default 0). Measures and time signatures are shared by all tracks (adding/removing a measure affects every track); subdivisions are per track; chords belong to track 0 and use its columns."
486
775
  }
487
776
  );
488
777
  function text(s) {
@@ -494,24 +783,44 @@ function requireDoc() {
494
783
  }
495
784
  return state.doc;
496
785
  }
497
- function applyEdit(mutate) {
498
- const doc = requireDoc();
786
+ function onTrack(doc, track = 0) {
787
+ const n = trackMetas(doc).length;
788
+ if (!Number.isInteger(track) || track < 0 || track >= n) {
789
+ throw new Error(`track ${track} out of range (0..${n - 1}); get_tab lists the tracks`);
790
+ }
791
+ return switchTrack(doc, track);
792
+ }
793
+ function edit(track, mutate) {
794
+ const current = requireDoc();
795
+ if ((current.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
796
+ throw new Error(
797
+ "This tab was saved by a newer EZTab and can only be read here. Update the EZTab MCP server (npx -y @hidechae/eztab-mcp@latest)."
798
+ );
799
+ }
800
+ const before = onTrack(current, track);
801
+ const doc = structuredClone(before);
499
802
  mutate(doc);
803
+ state.doc = reconcileTracks(before, doc);
500
804
  state.version += 1;
501
- return text(render(doc));
805
+ return state.doc;
502
806
  }
503
- function applyEditRange(mutate) {
504
- const doc = structuredClone(requireDoc());
505
- const [from, to] = mutate(doc);
506
- state.doc = doc;
507
- state.version += 1;
508
- return text(render(doc, from, to));
807
+ function applyEdit(mutate, track) {
808
+ return text(render(edit(track, mutate)));
809
+ }
810
+ function applyEditRange(mutate, track) {
811
+ let range = [0, 0];
812
+ const doc = edit(track, (d) => {
813
+ range = mutate(d);
814
+ });
815
+ return text(render(doc, range[0], range[1], track));
509
816
  }
510
817
  function setDoc(doc) {
511
- state.doc = doc;
818
+ state.doc = openWorkspace(doc);
512
819
  state.version += 1;
513
- return text(render(doc));
820
+ return text(render(state.doc));
514
821
  }
822
+ var MISMATCH_NOTE = "Warning: this tab's extra tracks may be shifted against the main track \u2014 an older EZTab app added or removed measures without them. Check that the tracks line up (get_tab) and ask the user before sending it back.";
823
+ var trackParam = z.number().int().min(0).optional().describe("0-based track (part) index from get_tab; default 0, the main track.");
515
824
  function assertMeasure(doc, m) {
516
825
  if (!Number.isInteger(m) || m < 0 || m >= doc.measures.length) {
517
826
  throw new Error(`measure ${m} out of range (0..${doc.measures.length - 1})`);
@@ -522,10 +831,14 @@ server.tool(
522
831
  "Return a readable rendering of the document currently under edit in this session. `from`/`to` (0-based, inclusive) limit it to a range of measures. If nothing has been created yet, say so and offer new_tab.",
523
832
  {
524
833
  from: z.number().int().min(0).optional(),
525
- to: z.number().int().min(0).optional()
834
+ to: z.number().int().min(0).optional(),
835
+ track: trackParam.describe("Show only this track (default: all tracks).")
526
836
  },
527
837
  async (a) => {
528
- if (state.doc) return text(render(state.doc, a.from, a.to));
838
+ if (state.doc) {
839
+ if (a.track != null) onTrack(state.doc, a.track);
840
+ return text(render(state.doc, a.from, a.to, a.track));
841
+ }
529
842
  return text("No document is open in this session. Call new_tab to start one.");
530
843
  }
531
844
  );
@@ -585,7 +898,17 @@ server.tool(
585
898
  if (!meta) throw new Error(`No tab with id ${a.id}. Use list_tabs to find it.`);
586
899
  const doc = normalize(await cloud.get(a.id));
587
900
  state.source = { tabId: a.id, title: meta.title, baseUpdatedAt: meta.updatedAt };
588
- return setDoc(doc);
901
+ const shown = setDoc(doc);
902
+ if (doc.trackMismatch) {
903
+ shown.content.unshift({ type: "text", text: MISMATCH_NOTE });
904
+ }
905
+ if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
906
+ shown.content.unshift({
907
+ type: "text",
908
+ text: "Note: this tab was saved by a newer EZTab; it can be read but not edited here. Update the EZTab MCP server to edit it."
909
+ });
910
+ }
911
+ return shown;
589
912
  })
590
913
  );
591
914
  server.tool(
@@ -593,12 +916,30 @@ server.tool(
593
916
  "Send the current document to the EZTab app's inbox. The user then chooses in the app what to do with it \u2014 insert its measures at the cursor, create a new tab, or (for a document opened with open_tab) replace that tab \u2014 and it stays unsaved until they press Save. Call this once the document has been checked with get_tab.",
594
917
  {
595
918
  label: z.string().optional().describe("Short description shown in the app, e.g. 'Guitar II, bars 1\u20138'."),
919
+ tracks_checked: z.boolean().optional().describe(
920
+ "Set to true after checking with the user that the extra tracks line up, when open_tab warned that they may be shifted."
921
+ ),
596
922
  as_new: z.boolean().optional().describe(
597
923
  "For a document opened with open_tab: send it as new material instead of a revision of that tab."
598
924
  )
599
925
  },
600
926
  async (a) => withCloud(async () => {
601
- const doc = requireDoc();
927
+ if (requireDoc().trackMismatch && !a.tracks_checked) {
928
+ throw new Error(
929
+ `${MISMATCH_NOTE} Not sent: confirm the alignment, then call send_to_eztab again with tracks_checked: true.`
930
+ );
931
+ }
932
+ const doc = closeWorkspace(requireDoc());
933
+ if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
934
+ throw new Error(
935
+ "This tab was saved by a newer EZTab; update the EZTab MCP server before sending it."
936
+ );
937
+ }
938
+ if (storedSize(doc) > 4.5 * 1024 * 1024) {
939
+ throw new Error(
940
+ "The tab is too large to send (over 4.5 MB). Remove measures or tracks, or send part of it as new material."
941
+ );
942
+ }
602
943
  const target = state.source && !a.as_new ? state.source : null;
603
944
  await cloud.sendToInbox({
604
945
  doc,
@@ -640,7 +981,8 @@ server.tool(
640
981
  tie: z.boolean().optional(),
641
982
  pm: z.boolean().optional()
642
983
  })
643
- )
984
+ ),
985
+ track: trackParam
644
986
  },
645
987
  async (a) => applyEdit((doc) => {
646
988
  for (const e of a.edits) {
@@ -666,7 +1008,7 @@ server.tool(
666
1008
  }
667
1009
  m.cells[e.string][e.column] = value;
668
1010
  }
669
- })
1011
+ }, a.track)
670
1012
  );
671
1013
  server.tool(
672
1014
  "write_measures",
@@ -678,7 +1020,10 @@ server.tool(
678
1020
  time: z.string().optional(),
679
1021
  beats: z.array(z.string()).min(1)
680
1022
  })
681
- ).min(1)
1023
+ ).min(1),
1024
+ track: trackParam.describe(
1025
+ "0-based track (part) to write; default 0. On a track other than 0, chord items (@Name) are rejected (chords belong to track 0) and `time` may not change an existing measure's time signature (it is shared by all tracks \u2014 change it on track 0). Appending adds the measure to every track."
1026
+ )
682
1027
  },
683
1028
  async (a) => applyEditRange((doc) => {
684
1029
  let lo = Infinity;
@@ -698,17 +1043,30 @@ server.tool(
698
1043
  fallback,
699
1044
  `measure ${i}`
700
1045
  );
701
- if (i === doc.measures.length) doc.measures.push(built);
1046
+ const existing = doc.measures[i];
1047
+ if (a.track) {
1048
+ if (built.chords?.some((c) => c)) {
1049
+ throw new Error(
1050
+ `measure ${i}: chord items (@Name) can only be written on track 0, which carries the chords`
1051
+ );
1052
+ }
1053
+ if (existing && (existing.beats !== built.beats || existing.beatUnit !== built.beatUnit)) {
1054
+ throw new Error(
1055
+ `measure ${i}: the time signature is shared by all tracks; change it on track 0 (time "${existing.beats}/${existing.beatUnit}" here)`
1056
+ );
1057
+ }
1058
+ }
1059
+ if (!existing) doc.measures.push(built);
702
1060
  else doc.measures[i] = built;
703
1061
  lo = Math.min(lo, i);
704
1062
  hi = Math.max(hi, i);
705
1063
  }
706
1064
  return [lo, hi];
707
- })
1065
+ }, a.track)
708
1066
  );
709
1067
  server.tool(
710
1068
  "set_chords",
711
- 'Set chord-name onsets per column (the same time grid as tab cells, so chords can sit on any subdivision). `measure` and `column` are 0-based; column = the sum of earlier beats\' subdivisions + slot-within-beat (see get_tab\'s ruler). A label of "rest" (or "N.C.") places a chord rest that stops the previous chord (rendered as N.C.). An empty label clears that column\'s chord.',
1069
+ 'Set chord-name onsets per column of track 0 (chords belong to the main track; the same time grid as its tab cells, so chords can sit on any subdivision). `measure` and `column` are 0-based; column = the sum of earlier beats\' subdivisions + slot-within-beat (see get_tab\'s ruler). A label of "rest" (or "N.C.") places a chord rest that stops the previous chord (rendered as N.C.). An empty label clears that column\'s chord.',
712
1070
  {
713
1071
  edits: z.array(
714
1072
  z.object({
@@ -787,7 +1145,7 @@ server.tool(
787
1145
  );
788
1146
  server.tool(
789
1147
  "set_time_signature",
790
- "Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8. Resizes the measure, preserving cells/chords by index.",
1148
+ "Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8. Shared by all tracks: every track's measure is resized, preserving cells/chords by index.",
791
1149
  {
792
1150
  measure: z.number().int(),
793
1151
  beats: z.number().int().min(1).max(32),
@@ -810,7 +1168,10 @@ server.tool(
810
1168
  {
811
1169
  measure: z.number().int(),
812
1170
  subdivisions: z.number().int().min(1).max(12),
813
- beat: z.number().int().min(0).optional()
1171
+ beat: z.number().int().min(0).optional(),
1172
+ track: trackParam.describe(
1173
+ "0-based track whose grid to change; default 0. Each track has its own subdivisions."
1174
+ )
814
1175
  },
815
1176
  async (a) => applyEdit((doc) => {
816
1177
  assertMeasure(doc, a.measure);
@@ -825,11 +1186,11 @@ server.tool(
825
1186
  } else {
826
1187
  doc.measures[a.measure] = setMeasureSubdivisions(m, a.subdivisions);
827
1188
  }
828
- })
1189
+ }, a.track)
829
1190
  );
830
1191
  server.tool(
831
1192
  "add_measures",
832
- "Insert `count` empty measures. Without `at`, appends; otherwise inserts before index `at`.",
1193
+ "Insert `count` empty measures into every track. Without `at`, appends; otherwise inserts before index `at`.",
833
1194
  {
834
1195
  count: z.number().int().min(1).max(256).optional(),
835
1196
  at: z.number().int().optional()
@@ -846,7 +1207,7 @@ server.tool(
846
1207
  );
847
1208
  server.tool(
848
1209
  "remove_measure",
849
- "Remove the measure at index `measure` (at least one is kept).",
1210
+ "Remove the measure at index `measure` from every track (at least one is kept).",
850
1211
  { measure: z.number().int() },
851
1212
  async (a) => applyEdit((doc) => {
852
1213
  assertMeasure(doc, a.measure);
@@ -859,11 +1220,54 @@ server.tool(
859
1220
  server.tool(
860
1221
  "set_tuning",
861
1222
  "Replace the tuning / string count. Existing frets are preserved where strings overlap. Order is top (highest) to bottom (lowest).",
862
- { tuning: z.array(z.string()).min(1) },
1223
+ { tuning: z.array(z.string()).min(1), track: trackParam },
863
1224
  async (a) => applyEdit((doc) => {
864
1225
  doc.tuning = [...a.tuning];
865
1226
  doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
866
- })
1227
+ }, a.track)
1228
+ );
1229
+ var TRACK_PRESETS = [
1230
+ "Guitar (6)",
1231
+ "Guitar (7)",
1232
+ "Bass (4)",
1233
+ "Bass (5)",
1234
+ "Bass (6)",
1235
+ "Ukulele (4)"
1236
+ ];
1237
+ server.tool(
1238
+ "add_track",
1239
+ "Add a track (part) to the tab, e.g. a bass line or a second guitar \u2014 up to 4 tracks including the main one. It starts empty, on track 0's measures and grid; write it with write_measures / set_cells using the new `track` index.",
1240
+ {
1241
+ name: z.string().min(1).describe("Shown in the app, e.g. 'Bass' or 'Guitar 2'."),
1242
+ preset: z.enum(TRACK_PRESETS).optional(),
1243
+ tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset.")
1244
+ },
1245
+ async (a) => {
1246
+ const before = trackMetas(requireDoc()).length;
1247
+ const tuning = a.tuning ?? PRESET_TUNINGS[a.preset ?? "Bass (4)"];
1248
+ const doc = edit(0, () => {
1249
+ });
1250
+ const next = addTrack(doc, a.name, tuning);
1251
+ if (trackMetas(next).length === before) {
1252
+ throw new Error("a tab can hold at most 4 tracks");
1253
+ }
1254
+ state.doc = next;
1255
+ return text(`Added track ${before} "${a.name}".
1256
+
1257
+ ${render(next)}`);
1258
+ }
1259
+ );
1260
+ server.tool(
1261
+ "remove_track",
1262
+ "Remove a track (part) and its notes. Track 0 (the main track) cannot be removed.",
1263
+ { track: z.number().int().min(1) },
1264
+ async (a) => {
1265
+ const doc = edit(0, () => {
1266
+ });
1267
+ onTrack(doc, a.track);
1268
+ state.doc = removeTrack(doc, a.track);
1269
+ return text(render(state.doc));
1270
+ }
867
1271
  );
868
1272
  server.prompt(
869
1273
  "import_tab_from_image",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for EZTab \u2014 lets an AI agent build and edit guitar/bass tablature.",
5
5
  "type": "module",
6
6
  "bin": {