@hidechae/eztab-mcp 0.1.1 → 0.2.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 +53 -38
  2. package/dist/server.js +527 -370
  3. package/package.json +3 -4
package/README.md CHANGED
@@ -1,28 +1,29 @@
1
- # EZTab MCP server (live)
1
+ # EZTab MCP server
2
2
 
3
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.
4
+ Claude) build and edit guitar/bass tablature using EZTab's data model.
10
5
 
11
6
  ## How it works
12
7
 
13
8
  ```
14
- Claude ──stdio──> MCP server ──┐
15
- (hub, ws) ├── authoritative document
16
- EZTab app ──websocket────────┘
9
+ Claude ──stdio──> MCP server ──> in-memory document
10
+ │ EZTAB_TOKEN (MCP 連携トークン)
11
+ ▼
12
+ eztab-sync ── GET /tabs, GET /tabs/:id (list_tabs / open_tab)
13
+ └─ POST /inbox (send_to_eztab)
14
+ ▲ ポーリング
15
+ EZTab アプリ: 画面上部のバナーで挿入 / 新規タブ / 修正版の反映
17
16
  ```
18
17
 
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).
18
+ エージェントのツール呼び出しがメモリ上のドキュメントを書き換え、`get_tab` が
19
+ ASCII レンダリングを返します。`send_to_eztab` で結果をアプリの inbox に送ると、
20
+ アプリ側で「カーソルの後に挿入」「新規タブとして作成」「(open_tab で開いたタブの)
21
+ 修正版を反映」を選べます。挿入・反映は未保存の編集として入り、Save するまで確定
22
+ しません。MCP からライブラリを直接書き換える経路はありません。
24
23
 
25
- Default port `47823` (override with `EZTAB_LIVE_PORT`).
24
+ 連携トークンはアプリの Settings → MCP 連携 で発行します(1ユーザー1本、再発行で
25
+ 旧トークンは失効)。トークンで使えるのはタブ一覧・取得と inbox への投函だけです。
26
+ トークン無しでも、ライブラリ連携以外のツールは今まで通り使えます。
26
27
 
27
28
  ## Install (published package)
28
29
 
@@ -31,21 +32,22 @@ Published to npm as **`@hidechae/eztab-mcp`**. Add it to any MCP client:
31
32
  ```json
32
33
  {
33
34
  "mcpServers": {
34
- "eztab": { "command": "npx", "args": ["-y", "@hidechae/eztab-mcp"] }
35
+ "eztab": {
36
+ "command": "npx",
37
+ "args": ["-y", "@hidechae/eztab-mcp"],
38
+ "env": { "EZTAB_TOKEN": "eztab_…" }
39
+ }
35
40
  }
36
41
  }
37
42
  ```
38
43
 
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
44
  ### Releasing a new version
43
45
 
44
46
  npm versions are immutable, so publishing is deliberate (tag-driven):
45
47
 
46
48
  ```sh
47
49
  # bump mcp/package.json "version", commit, then:
48
- git tag mcp-v0.1.1 && git push --tags
50
+ git tag mcp-v0.1.3 && git push --tags
49
51
  ```
50
52
 
51
53
  The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
@@ -55,7 +57,7 @@ The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
55
57
 
56
58
  Dependencies are in the repo's `package.json` (`npm install`). This repo ships a
57
59
  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.
60
+ server automatically (approve it when asked).
59
61
 
60
62
  For Claude Desktop or elsewhere:
61
63
 
@@ -75,32 +77,45 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
75
77
 
76
78
  ## Tools
77
79
 
78
- These act on the live shared document (no file path needed):
79
-
80
80
  | Tool | What it does |
81
81
  |------|--------------|
82
- | `get_tab` | Readable ASCII rendering of the open tab (says so if the app is closed / on the library screen) |
83
- | `list_tabs` | List the library's tabs (id, title, artist, measures, tempo) — answered by the app |
84
- | `list_playlists` | List playlists (id, name, song count) — answered by the app |
85
- | `get_playlist` | A playlist's songs in order, by `id` or `name` — answered by the app |
86
- | `new_tab` | Replace the document with a fresh one (title, artist, tuning/preset, measures, tempo) |
87
- | `open_in_app` | Ask the connected app to open a tab by path (the app reads/saves it — the server never touches the filesystem) |
82
+ | `list_tabs` | ライブラリの一覧(アプリで編集中のタブは端末名つき)。要 `EZTAB_TOKEN` |
83
+ | `open_tab` | ライブラリのタブをこのセッションに読み込む(修正用)。要 `EZTAB_TOKEN` |
84
+ | `send_to_eztab` | 今のドキュメントをアプリの inbox に送る。`open_tab` で開いたものは修正版として届く(`as_new` で新規扱い)。要 `EZTAB_TOKEN` |
85
+ | `get_tab` | Readable ASCII rendering of the document under edit (`from`/`to` で小節範囲を絞れる) |
86
+ | `new_tab` | Start a fresh document (title, artist, tuning/preset, measures, tempo) |
88
87
  | `set_title` | Set the document title |
89
88
  | `set_artist` | Set the document artist |
90
- | `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) |
91
- | `set_chords` | Set chord labels per beat |
89
+ | `write_measures` | 小節単位の一括入力。1拍を1文字列で書き(`"6:5 6:~7"` など)、列番号への割り付けはサーバ側で行う。譜面・画像からの書き起こし向け |
90
+ | `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); optional `pm` palm-mutes it |
91
+ | `set_chords` | Set chord labels per column |
92
92
  | `set_key` / `remove_key` | Set/add or remove a key (key change) at a measure |
93
93
  | `set_section` | Set/remove a section marker (e.g. "Verse", "Chorus") |
94
94
  | `set_tempo` | Set playback BPM |
95
- | `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets) |
95
+ | `set_time_signature` | Set a measure's time signature |
96
+ | `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets/quintuplets) |
96
97
  | `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
97
98
  | `set_tuning` | Replace tuning / string count |
98
99
 
99
100
  These cover the editor's current model: variable string count, per-beat chords,
100
- keys with mid-piece changes, section markers, per-measure resolution, tempo, and
101
- title/artist metadata. (Playback, the on-screen keypad, undo/redo and the local
102
- library/archive are app-only UI concerns and intentionally have no MCP tools.)
101
+ keys with mid-piece changes, section markers, per-measure resolution, time
102
+ signature, tempo, and title/artist metadata. (Playback, the on-screen keypad,
103
+ undo/redo and the library/archive are app-only UI concerns and intentionally
104
+ have no MCP tools.)
103
105
 
104
106
  Indices are 0-based. `string` 0 is the top line; tuning is ordered high→low.
105
- If no app is connected, edits still apply to the in-memory document and sync
106
- when the app connects.
107
+ 例外として `write_measures` の弦番号はギターの呼び方どおり 1 始まり(1 = TAB の一番上の線)
108
+ で、`get_tab` の行ラベルと一致する。
109
+
110
+ ## Prompts
111
+
112
+ | Prompt | What it does |
113
+ |--------|--------------|
114
+ | `import_tab_from_image` | 添付した TAB 譜画像(通常 TAB・コードフォーム図・YouTube の TAB・バンドスコア1ページ)を書き起こす手順。引数 `part`(例: `Guitar II`)でバンドスコアのパートを指定、`notes` で補足 |
115
+
116
+ Claude Desktop では入力欄の「+」→ eztab → `import_tab_from_image`、Claude Code
117
+ では `/mcp__eztab__import_tab_from_image` で呼び出し、画像を添付して送る。
118
+
119
+ EZTab のモデルに無い記譜(スライド・ハンマリング・プリング → タイ/スラー、
120
+ ハーモニクス・ビブラート・装飾音 → 主音のみ等)の寄せ方は手順書
121
+ ([prompts.ts](prompts.ts))に書いてあり、書き起こし後に簡略化した箇所を報告させる。
package/dist/server.js CHANGED
@@ -4,16 +4,13 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
- import { WebSocket, WebSocketServer } from "ws";
8
7
 
9
8
  // ../src/types/tab.ts
10
9
  var TAB_FILE_VERSION = 1;
11
10
  var DEFAULT_TEMPO = 120;
12
11
  var DEFAULT_MIX = { melody: 1, backing: 0.4 };
13
12
  function shiftSectionsForInsert(sections, index) {
14
- return sections.map(
15
- (s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s
16
- );
13
+ return sections.map((s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s);
17
14
  }
18
15
  function shiftSectionsForDelete(sections, index) {
19
16
  return sections.filter((s) => s.measure !== index).map((s) => s.measure > index ? { ...s, measure: s.measure - 1 } : s);
@@ -27,9 +24,7 @@ function activeKeyAt(keys, measure) {
27
24
  return result;
28
25
  }
29
26
  function shiftKeysForInsert(keys, index) {
30
- const shifted = keys.map(
31
- (k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k
32
- );
27
+ const shifted = keys.map((k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k);
33
28
  if (!shifted.some((k) => k.measure === 0)) {
34
29
  shifted.unshift({ measure: 0, tonic: keys[0].tonic, mode: keys[0].mode });
35
30
  }
@@ -42,40 +37,146 @@ function shiftKeysForDelete(keys, index) {
42
37
  }
43
38
  return next;
44
39
  }
40
+ function cellPalmMute(c) {
41
+ return typeof c === "object" && c !== null && !!c.pm;
42
+ }
43
+ var CHORD_REST = "rest";
44
+ var CHORD_REST_LABEL = "N.C.";
45
+ function chordDisplay(label) {
46
+ if (!label) return "";
47
+ return label === CHORD_REST ? CHORD_REST_LABEL : label;
48
+ }
49
+ function parseChordInput(raw) {
50
+ if (raw === "") return "";
51
+ if (/^(r|n\.?c?\.?)$/i.test(raw)) return CHORD_REST;
52
+ const v = raw.charAt(0).toUpperCase() + raw.slice(1);
53
+ return /^[A-G]/.test(v) ? v : null;
54
+ }
45
55
  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(
56
+ const subdivisions = Array.from(
51
57
  { length: beats },
52
- (_, b) => measure.chords?.[b] ?? null
58
+ (_, b) => measure.subdivisions[Math.min(b, measure.subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
53
59
  );
54
- return { ...measure, beats, beatUnit, cells, chords };
60
+ const cols = subdivisions.reduce((a, b) => a + b, 0);
61
+ const cells = measure.cells.map((row) => Array.from({ length: cols }, (_, c) => row[c] ?? null));
62
+ const chords = Array.from({ length: cols }, (_, c) => measure.chords?.[c] ?? null);
63
+ return { ...measure, beats, beatUnit, subdivisions, cells, chords };
55
64
  }
56
65
  function columnCount(measure) {
57
- return measure.beats * measure.subdivisions;
66
+ let sum = 0;
67
+ for (const s of measure.subdivisions) sum += s;
68
+ return sum;
58
69
  }
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
- };
70
+ function uniformSubdivision(measure) {
71
+ const first = measure.subdivisions[0];
72
+ return measure.subdivisions.every((s) => s === first) ? first : null;
73
+ }
74
+ function normalizeChords(raw, subdivisions) {
75
+ const beats = subdivisions.length;
76
+ const cols = subdivisions.reduce((a, b) => a + b, 0);
77
+ const src = Array.isArray(raw) ? raw : [];
78
+ const out = Array.from({ length: cols }, () => null);
79
+ if (src.length > beats) {
80
+ for (let c = 0; c < Math.min(cols, src.length); c++) {
81
+ out[c] = src[c] || null;
82
+ }
83
+ } else {
84
+ let col = 0;
85
+ for (let b = 0; b < beats; b++) {
86
+ if (b < src.length) out[col] = src[b] || null;
87
+ col += subdivisions[b];
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ function normalizeDocument(data) {
93
+ if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
94
+ throw new Error("Not a valid EZTab document");
95
+ }
96
+ data.measures = data.measures.map((m) => {
97
+ let beats = m.beats;
98
+ let rawSubs = m.subdivisions;
99
+ const subsValid = typeof rawSubs === "number" || Array.isArray(rawSubs) && rawSubs.length > 0;
100
+ if (!Number.isInteger(beats) || beats < 1 || beats > MAX_BEATS || !subsValid) {
101
+ const cols2 = m.cells?.[0]?.length ?? m.columns ?? 8;
102
+ const sub = cols2 % 4 === 0 ? cols2 / 4 : 2;
103
+ beats = cols2 % 4 === 0 ? 4 : Math.max(1, Math.round(cols2 / sub));
104
+ rawSubs = sub;
105
+ }
106
+ const rawUnit = m.beatUnit;
107
+ const beatUnit = BEAT_UNITS.includes(rawUnit) ? rawUnit : DEFAULT_BEAT_UNIT;
108
+ const subdivisions = Array.from({ length: beats }, (_, b) => {
109
+ const v = Array.isArray(rawSubs) ? rawSubs[Math.min(b, rawSubs.length - 1)] : rawSubs;
110
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
111
+ });
112
+ const cols = subdivisions.reduce((a, b) => a + b, 0);
113
+ const cells = (m.cells ?? []).map(
114
+ (row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
115
+ );
116
+ const rawChords = Array.isArray(m.chords) ? m.chords : typeof m.chord === "string" && m.chord ? [m.chord] : [];
117
+ const chords = normalizeChords(rawChords, subdivisions);
118
+ return { beats, beatUnit, subdivisions, cells, chords };
119
+ });
120
+ if (!Array.isArray(data.keys) || data.keys.length === 0) {
121
+ data.keys = [{ measure: 0, tonic: "C", mode: "major" }];
122
+ }
123
+ if (typeof data.artist !== "string") data.artist = "";
124
+ if (!Array.isArray(data.sections)) data.sections = [];
125
+ if (typeof data.tempo !== "number") data.tempo = DEFAULT_TEMPO;
126
+ if (!data.mix || typeof data.mix.melody !== "number") {
127
+ data.mix = { ...DEFAULT_MIX };
128
+ }
129
+ data.version = TAB_FILE_VERSION;
130
+ return data;
131
+ }
132
+ var MAX_SUBDIVISION = 12;
133
+ var PRESET_TUNING_GROUPS = [
134
+ {
135
+ group: "Standard",
136
+ items: [
137
+ { name: "Guitar (6)", tuning: ["E4", "B3", "G3", "D3", "A2", "E2"] },
138
+ { name: "Guitar (7)", tuning: ["E4", "B3", "G3", "D3", "A2", "E2", "B1"] },
139
+ { name: "Bass (4)", tuning: ["G2", "D2", "A1", "E1"] },
140
+ { name: "Bass (5)", tuning: ["G2", "D2", "A1", "E1", "B0"] },
141
+ { name: "Bass (6)", tuning: ["C3", "G2", "D2", "A1", "E1", "B0"] },
142
+ { name: "Ukulele (4)", tuning: ["A4", "E4", "C4", "G4"] }
143
+ ]
144
+ },
145
+ {
146
+ group: "Drop",
147
+ items: [{ name: "Drop D", tuning: ["E4", "B3", "G3", "D3", "A2", "D2"] }]
148
+ },
149
+ {
150
+ group: "Open / Modal",
151
+ items: [
152
+ { name: "DADGAD", tuning: ["D4", "A3", "G3", "D3", "A2", "D2"] },
153
+ { name: "Open G", tuning: ["D4", "B3", "G3", "D3", "G2", "D2"] },
154
+ { name: "Open D", tuning: ["D4", "A3", "F#3", "D3", "A2", "D2"] },
155
+ { name: "Open E", tuning: ["E4", "B3", "G#3", "E3", "B2", "E2"] }
156
+ ]
157
+ }
158
+ ];
159
+ var PRESET_TUNINGS = Object.fromEntries(
160
+ PRESET_TUNING_GROUPS.flatMap((g) => g.items.map((i) => [i.name, i.tuning]))
161
+ );
65
162
  var DEFAULT_BEATS = 4;
66
163
  var DEFAULT_BEAT_UNIT = 4;
67
164
  var DEFAULT_SUBDIVISIONS = 2;
165
+ var MAX_FRET = 24;
166
+ var MAX_BEATS = 32;
167
+ var BEAT_UNITS = [1, 2, 4, 8, 16, 32];
68
168
  function createMeasure(stringCount, beats = DEFAULT_BEATS, subdivisions = DEFAULT_SUBDIVISIONS, beatUnit = DEFAULT_BEAT_UNIT) {
69
- const columns = beats * subdivisions;
169
+ const subs = Array.from(
170
+ { length: beats },
171
+ (_, b) => typeof subdivisions === "number" ? subdivisions : subdivisions[Math.min(b, subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
172
+ );
173
+ const columns = subs.reduce((a, b) => a + b, 0);
70
174
  return {
71
175
  beats,
72
176
  beatUnit,
73
- subdivisions,
74
- cells: Array.from(
75
- { length: stringCount },
76
- () => Array.from({ length: columns }, () => null)
77
- ),
78
- chords: Array.from({ length: beats }, () => null)
177
+ subdivisions: subs,
178
+ cells: Array.from({ length: stringCount }, () => Array.from({ length: columns }, () => null)),
179
+ chords: Array.from({ length: columns }, () => null)
79
180
  };
80
181
  }
81
182
  function newTabId() {
@@ -91,10 +192,7 @@ function createDocument(tuning = PRESET_TUNINGS["Guitar (6)"], measureCount = 4)
91
192
  title: "Untitled",
92
193
  artist: "",
93
194
  tuning: [...tuning],
94
- measures: Array.from(
95
- { length: measureCount },
96
- () => createMeasure(tuning.length)
97
- ),
195
+ measures: Array.from({ length: measureCount }, () => createMeasure(tuning.length)),
98
196
  keys: [{ measure: 0, tonic: "C", mode: "major" }],
99
197
  sections: [],
100
198
  tempo: DEFAULT_TEMPO,
@@ -109,54 +207,101 @@ function resizeMeasureStrings(measure, stringCount) {
109
207
  );
110
208
  return { ...measure, cells };
111
209
  }
112
- function setMeasureSubdivisions(measure, subdivisions) {
113
- const oldCols = columnCount(measure);
114
- const newCols = measure.beats * subdivisions;
210
+ function remapToSubdivisions(measure, subs) {
211
+ const newCols = subs.reduce((a, b) => a + b, 0);
212
+ const map = [];
213
+ let newStart = 0;
214
+ for (let b = 0; b < measure.subdivisions.length; b++) {
215
+ const os = measure.subdivisions[b];
216
+ const ns = subs[b];
217
+ for (let s = 0; s < os; s++) {
218
+ map.push(newStart + Math.min(ns - 1, Math.round(s * ns / os)));
219
+ }
220
+ newStart += ns;
221
+ }
115
222
  const cells = measure.cells.map((row) => {
116
223
  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
- }
224
+ row.forEach((cell, c) => {
225
+ if (cell != null && map[c] != null) next[map[c]] = cell;
226
+ });
122
227
  return next;
123
228
  });
124
- return { ...measure, subdivisions, cells };
229
+ let chords = measure.chords;
230
+ if (chords) {
231
+ const next = Array.from({ length: newCols }, () => null);
232
+ chords.forEach((label, c) => {
233
+ if (label && map[c] != null) next[map[c]] = label;
234
+ });
235
+ chords = next;
236
+ }
237
+ return { ...measure, subdivisions: subs, cells, chords };
238
+ }
239
+ function setMeasureSubdivisions(measure, subdivisions) {
240
+ return remapToSubdivisions(
241
+ measure,
242
+ Array.from({ length: measure.beats }, () => subdivisions)
243
+ );
244
+ }
245
+ function setBeatSubdivision(measure, beat, subdivision) {
246
+ const subs = [...measure.subdivisions];
247
+ subs[beat] = subdivision;
248
+ return remapToSubdivisions(measure, subs);
125
249
  }
126
250
 
127
- // eztab.ts
128
- function normalize(data) {
129
- if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
130
- throw new Error("Not a valid EZTab document");
131
- }
132
- data.measures = data.measures.map((m) => {
133
- let beats = m.beats;
134
- let subdivisions = m.subdivisions;
135
- if (typeof beats !== "number" || typeof subdivisions !== "number") {
136
- const cols2 = m.cells?.[0]?.length ?? m.columns ?? 8;
137
- subdivisions = cols2 % 4 === 0 ? cols2 / 4 : 2;
138
- beats = cols2 % 4 === 0 ? 4 : Math.max(1, Math.round(cols2 / subdivisions));
139
- }
140
- const beatUnit = typeof m.beatUnit === "number" ? m.beatUnit : 4;
141
- const cols = beats * subdivisions;
142
- const cells = (m.cells ?? []).map(
143
- (row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
251
+ // ../src/lib/config.ts
252
+ var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
253
+
254
+ // cloud.ts
255
+ var CloudError = class extends Error {
256
+ };
257
+ var BASE_URL = process.env.EZTAB_API_URL ?? CLOUD_BASE_URL;
258
+ function token() {
259
+ const t = process.env.EZTAB_TOKEN?.trim();
260
+ if (!t) {
261
+ throw new CloudError(
262
+ `EZTAB_TOKEN is not set. Issue an MCP link token in the EZTab app (Settings \u2192 MCP \u9023\u643A), then add it to this MCP server's config: "env": { "EZTAB_TOKEN": "eztab_\u2026" }.`
144
263
  );
145
- let chords = Array.isArray(m.chords) ? m.chords.slice(0, beats) : [];
146
- while (chords.length < beats) chords.push(null);
147
- return { beats, beatUnit, subdivisions, cells, chords };
264
+ }
265
+ return t;
266
+ }
267
+ async function req(path, init = {}) {
268
+ const res = await fetch(`${BASE_URL}${path}`, {
269
+ ...init,
270
+ headers: { ...init.headers ?? {}, authorization: `Bearer ${token()}` }
148
271
  });
149
- if (!Array.isArray(data.keys) || data.keys.length === 0) {
150
- data.keys = [{ measure: 0, tonic: "C", mode: "major" }];
272
+ if (res.status === 401) {
273
+ throw new CloudError(
274
+ "EZTab rejected the MCP link token (revoked or mistyped). Issue a new one in the app's settings."
275
+ );
151
276
  }
152
- if (typeof data.artist !== "string") data.artist = "";
153
- if (!Array.isArray(data.sections)) data.sections = [];
154
- if (typeof data.tempo !== "number") data.tempo = 120;
155
- if (!data.mix || typeof data.mix.melody !== "number") {
156
- data.mix = { melody: 1, backing: 0.4 };
277
+ if (!res.ok) {
278
+ const body = await res.json().catch(() => null);
279
+ throw new CloudError(
280
+ `EZTab request ${path} failed (${res.status})${body?.error ? `: ${body.error}` : ""}`
281
+ );
157
282
  }
158
- data.version = 1;
159
- return data;
283
+ return res;
284
+ }
285
+ var cloud = {
286
+ async list() {
287
+ const { tabs } = await (await req("/tabs")).json();
288
+ return tabs;
289
+ },
290
+ async get(id) {
291
+ return (await req(`/tabs/${encodeURIComponent(id)}`)).json();
292
+ },
293
+ async sendToInbox(payload) {
294
+ await req("/inbox", {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify(payload)
298
+ });
299
+ }
300
+ };
301
+
302
+ // eztab.ts
303
+ function normalize(data) {
304
+ return normalizeDocument(data);
160
305
  }
161
306
  function cellText(cell) {
162
307
  if (cell === null) return "--";
@@ -170,271 +315,201 @@ function cellText(cell) {
170
315
  }
171
316
  return String(cell).padStart(2, "-");
172
317
  }
173
- function render(doc) {
318
+ function render(doc, from = 0, to = doc.measures.length - 1) {
174
319
  const lines = [];
175
- lines.push(
176
- `Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`
177
- );
178
- lines.push(
179
- `Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`
180
- );
320
+ 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}`);
181
322
  const ann = [];
182
323
  doc.measures.forEach((_, m) => {
183
324
  const k = doc.keys.find((x) => x.measure === m);
184
325
  const s = doc.sections.find((x) => x.measure === m);
185
- if (k) ann.push(`m${m + 1}:key=${k.tonic} ${k.mode}`);
186
- if (s && s.label) ann.push(`m${m + 1}:section="${s.label}"`);
326
+ if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
327
+ if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
187
328
  });
188
329
  if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
189
330
  doc.measures.forEach((m, mi) => {
331
+ if (mi < from || mi > to) return;
190
332
  const key = activeKeyAt(doc.keys, mi);
333
+ const sub = uniformSubdivision(m);
334
+ const subText = sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
191
335
  lines.push("");
192
336
  lines.push(
193
- `--- Measure ${mi + 1} (${m.beats}/${m.beatUnit}, ${m.subdivisions}/beat, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
337
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
194
338
  );
195
- const chordRow = (m.chords ?? []).map((c, b) => `b${b + 1}:${c ?? "-"}`).join(" ");
196
- lines.push(`chords: ${chordRow}`);
197
- const ruler = Array.from(
198
- { length: columnCount(m) },
199
- (_, c) => String(c).padStart(2, " ")
200
- ).join(" ");
201
- lines.push(`col: ${ruler}`);
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}`);
202
345
  m.cells.forEach((row, s) => {
203
- const label = (doc.tuning[s] ?? "?").padEnd(3, " ");
346
+ const label = `${s + 1} ${doc.tuning[s] ?? "?"}`.padEnd(6, " ");
204
347
  const cells = row.map(cellText).join(" ");
205
348
  lines.push(`${label}| ${cells}`);
206
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(" ")}`);
207
355
  });
208
356
  return lines.join("\n");
209
357
  }
210
358
 
211
- // server.ts
212
- var PORT = Number(process.env.EZTAB_LIVE_PORT ?? 47823);
213
- var state = {
214
- doc: null,
215
- version: 0
216
- };
217
- var mode = "host";
218
- var clients = /* @__PURE__ */ new Set();
219
- var upstream = null;
220
- var appView = null;
221
- var appEditable = null;
222
- var pending = /* @__PURE__ */ new Map();
223
- var reqSeq = 0;
224
- var reqTag = Math.random().toString(36).slice(2, 8);
225
- function findAppClient() {
226
- for (const c of clients) {
227
- if (c._isApp && c.readyState === WebSocket.OPEN) return c;
359
+ // notation.ts
360
+ var BEAT_UNITS2 = [2, 4, 8, 16];
361
+ var BENDS = { b: 0.5, B: 1, "^": 1.5 };
362
+ function parseTime(text2) {
363
+ const m = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text2);
364
+ const beats = m ? Number(m[1]) : NaN;
365
+ const beatUnit = m ? Number(m[2]) : NaN;
366
+ if (!(beats >= 1 && beats <= 32) || !BEAT_UNITS2.includes(beatUnit)) {
367
+ throw new Error(`bad time signature "${text2}" (e.g. "4/4", "6/8")`);
228
368
  }
229
- return void 0;
230
- }
231
- function requestFromApp(action, params = {}, timeoutMs = 6e3) {
232
- const reqId = `${reqTag}-${++reqSeq}`;
233
- return new Promise((resolve, reject) => {
234
- const out = JSON.stringify({ type: "request", reqId, action, params });
235
- if (mode === "host") {
236
- const app = findAppClient();
237
- if (!app) {
238
- reject(
239
- new Error(
240
- "No EZTab app is connected. Ask the user to open the app and enable Live (toolbar)."
241
- )
242
- );
243
- return;
244
- }
245
- const timer = setTimeout(() => {
246
- pending.delete(reqId);
247
- reject(new Error("The EZTab app didn't respond in time."));
248
- }, timeoutMs);
249
- pending.set(reqId, { resolve, reject, timer });
250
- app.send(out);
251
- } else if (upstream && upstream.readyState === WebSocket.OPEN) {
252
- const timer = setTimeout(() => {
253
- pending.delete(reqId);
254
- reject(new Error("The EZTab app didn't respond in time."));
255
- }, timeoutMs);
256
- pending.set(reqId, { resolve, reject, timer });
257
- upstream.send(out);
258
- } else {
259
- reject(new Error("Not connected to the hub."));
260
- }
261
- });
369
+ return { beats, beatUnit };
262
370
  }
263
- function settleResponse(reqId, data) {
264
- const p = pending.get(reqId);
265
- if (p) {
266
- clearTimeout(p.timer);
267
- pending.delete(reqId);
268
- p.resolve(data);
269
- }
371
+ function parseValue(raw, where) {
372
+ if (raw === "x") return "mute";
373
+ if (raw === "r") return "rest";
374
+ const m = /^(~?)(\d+)([bB^p]*)$/.exec(raw);
375
+ if (!m) throw new Error(`${where}: bad value "${raw}"`);
376
+ const fret = Number(m[2]);
377
+ if (fret > MAX_FRET) throw new Error(`${where}: fret ${fret} is above ${MAX_FRET}`);
378
+ const mods = m[3];
379
+ const bendMarks = [...mods].filter((c) => c in BENDS);
380
+ if (bendMarks.length > 1) throw new Error(`${where}: more than one choke mark in "${raw}"`);
381
+ const note = { fret };
382
+ if (bendMarks.length) note.bend = BENDS[bendMarks[0]];
383
+ if (m[1]) note.tie = true;
384
+ if (mods.includes("p")) note.pm = true;
385
+ return Object.keys(note).length === 1 ? fret : note;
270
386
  }
271
- function pushState() {
272
- if (mode === "host") {
273
- const msg = JSON.stringify({
274
- type: "state",
275
- doc: state.doc,
276
- version: state.version
277
- });
278
- for (const c of clients) {
279
- if (c.readyState === WebSocket.OPEN) c.send(msg);
280
- }
281
- } else if (upstream && upstream.readyState === WebSocket.OPEN) {
282
- upstream.send(JSON.stringify({ type: "set", doc: state.doc }));
387
+ function parseBeat(text2, stringCount, where) {
388
+ const slots = text2.trim().split(/\s+/);
389
+ if (slots.length === 1 && slots[0] === "") {
390
+ throw new Error(`${where}: empty beat (write "-" for a beat with nothing on it)`);
283
391
  }
284
- }
285
- function broadcastExcept(except) {
286
- const msg = JSON.stringify({
287
- type: "state",
288
- doc: state.doc,
289
- version: state.version
290
- });
291
- for (const c of clients) {
292
- if (c !== except && c.readyState === WebSocket.OPEN) c.send(msg);
392
+ if (slots.length > MAX_SUBDIVISION) {
393
+ throw new Error(`${where}: ${slots.length} slots exceeds the max of ${MAX_SUBDIVISION}`);
293
394
  }
294
- }
295
- var wss = new WebSocketServer({ port: PORT });
296
- wss.on("connection", (ws) => {
297
- clients.add(ws);
298
- ws.send(
299
- JSON.stringify({ type: "state", doc: state.doc, version: state.version })
300
- );
301
- ws.on("message", (data) => {
302
- let msg;
303
- try {
304
- msg = JSON.parse(data.toString());
305
- } catch {
306
- return;
307
- }
308
- if (msg.type === "set" && msg.doc) {
309
- state.doc = normalize(msg.doc);
310
- state.version += 1;
311
- broadcastExcept(ws);
312
- } else if (msg.type === "seed" && msg.doc) {
313
- if (!state.doc) {
314
- state.doc = normalize(msg.doc);
315
- state.version += 1;
316
- broadcastExcept(ws);
317
- }
318
- } else if (msg.type === "get") {
319
- ws.send(
320
- JSON.stringify({
321
- type: "state",
322
- doc: state.doc,
323
- version: state.version
324
- })
325
- );
326
- } else if (msg.type === "appstate") {
327
- appView = msg.view === "editor" || msg.view === "library" ? msg.view : null;
328
- appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
329
- ws._isApp = true;
330
- const out = JSON.stringify({ type: "appstate", view: appView, editable: appEditable });
331
- for (const c of clients) {
332
- if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
395
+ return slots.map((slot, i) => {
396
+ const at = `${where} slot ${i + 1}`;
397
+ const cells = /* @__PURE__ */ new Map();
398
+ let chord = null;
399
+ if (slot === "-") return { cells, chord };
400
+ for (const item of slot.split("+")) {
401
+ if (item.startsWith("@")) {
402
+ const parsed = parseChordInput(item.slice(1));
403
+ if (!parsed) throw new Error(`${at}: bad chord "${item}"`);
404
+ chord = parsed;
405
+ continue;
333
406
  }
334
- } else if (msg.type === "request" && msg.reqId) {
335
- const app = findAppClient();
336
- if (app) app.send(JSON.stringify(msg));
337
- } else if (msg.type === "response" && msg.reqId) {
338
- settleResponse(msg.reqId, msg.data);
339
- const out = JSON.stringify(msg);
340
- for (const c of clients) {
341
- if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
407
+ const m = /^(\d+):(.+)$/.exec(item);
408
+ if (!m) throw new Error(`${at}: bad item "${item}" (expected string:value or @Chord)`);
409
+ const string = Number(m[1]);
410
+ if (string < 1 || string > stringCount) {
411
+ throw new Error(`${at}: string ${string} out of range (1..${stringCount})`);
342
412
  }
413
+ if (cells.has(string - 1)) throw new Error(`${at}: string ${string} written twice`);
414
+ cells.set(string - 1, parseValue(m[2], at));
343
415
  }
416
+ return { cells, chord };
344
417
  });
345
- const onGone = () => {
346
- clients.delete(ws);
347
- if (ws._isApp) {
348
- appView = null;
349
- appEditable = null;
350
- const out = JSON.stringify({ type: "appstate", view: null, editable: null });
351
- for (const c of clients) {
352
- if (c.readyState === WebSocket.OPEN) c.send(out);
353
- }
418
+ }
419
+ function buildMeasure(spec, stringCount, fallback, where = "measure") {
420
+ const time = spec.time ? parseTime(spec.time) : fallback;
421
+ if (spec.beats.length !== time.beats) {
422
+ throw new Error(
423
+ `${where}: ${spec.beats.length} beats given but the time signature ${time.beats}/${time.beatUnit} has ${time.beats}`
424
+ );
425
+ }
426
+ const beats = spec.beats.map((b, i) => parseBeat(b, stringCount, `${where} beat ${i + 1}`));
427
+ const measure = createMeasure(
428
+ stringCount,
429
+ time.beats,
430
+ beats.map((slots) => slots.length),
431
+ time.beatUnit
432
+ );
433
+ const chords = measure.chords;
434
+ let col = 0;
435
+ for (const slots of beats) {
436
+ for (const slot of slots) {
437
+ for (const [s, cell] of slot.cells) measure.cells[s][col] = cell;
438
+ if (slot.chord) chords[col] = slot.chord;
439
+ col++;
354
440
  }
355
- };
356
- ws.on("close", onGone);
357
- ws.on("error", onGone);
358
- });
359
- wss.on("error", (e) => {
360
- if (e.code === "EADDRINUSE") {
361
- enterClientMode();
362
- } else {
363
- console.error("hub error:", e);
364
441
  }
365
- });
366
- function enterClientMode() {
367
- mode = "client";
368
- console.error(`port ${PORT} in use; proxying to existing hub as a client`);
369
- const connect = () => {
370
- const ws = new WebSocket(`ws://127.0.0.1:${PORT}`);
371
- upstream = ws;
372
- ws.on("open", () => ws.send(JSON.stringify({ type: "get" })));
373
- ws.on("message", (data) => {
374
- try {
375
- const msg = JSON.parse(data.toString());
376
- if (msg.type === "state" && msg.doc) {
377
- state.doc = normalize(msg.doc);
378
- state.version = msg.version;
379
- } else if (msg.type === "appstate") {
380
- appView = msg.view === "editor" || msg.view === "library" ? msg.view : null;
381
- appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
382
- } else if (msg.type === "response" && msg.reqId) {
383
- settleResponse(msg.reqId, msg.data);
384
- }
385
- } catch {
386
- }
387
- });
388
- ws.on("close", () => {
389
- upstream = null;
390
- setTimeout(connect, 2e3);
391
- });
392
- ws.on("error", () => ws.close());
393
- };
394
- connect();
442
+ return measure;
395
443
  }
396
- var server = new McpServer({ name: "eztab", version: "0.2.0" });
444
+
445
+ // prompts.ts
446
+ var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an EZTab document with the eztab tools.
447
+
448
+ ## 1. Survey before writing
449
+ - 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
+ - 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
+ - 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.
452
+ - 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
+
454
+ ## 2. Write bar by bar with write_measures
455
+ Read each bar's rhythm first (beams, flags, dots, tuplet brackets, rests), then the fret numbers, then write it. Send a few bars per call, in order. In a fresh document the first bar goes to measure 0 (replacing the empty measure new_tab made); after that append at the current measure count.
456
+ - Subdivision per beat = the fewest slots that put every onset of the beat on a slot: quarter \u2192 1, eighths \u2192 2, 16ths or dotted-8th + 16th \u2192 4, triplet \u2192 3. Only onsets are stored; a held note is just empty slots ("-") after it.
457
+ - Use the TAB numbers for frets, and the staff notation (when present) to settle rhythm and ambiguous digits. String 1 is the TOP line of the TAB.
458
+ - Chord names printed above the staff go on the chord row with @Name at the slot where the chord changes (usually beat 1).
459
+ - Rests: write S:r on the string that was ringing only when the line clearly stops (a rest symbol); otherwise leave "-".
460
+
461
+ ## 3. Map notation EZTab cannot store exactly
462
+ - Hammer-on (H), pull-off (P), slide into a note (S, gliss line from a fretted note): mark the destination note tied, S:~F.
463
+ - A tie arc to the same fret, or a parenthesised fret "(6)" that continues a note: S:~F with the same fret.
464
+ - Choke / bend (C, Cho., "full", "1/2", up arrow): S:FB for full, S:Fb for \xBD, S:F^ for 1\xBD. A release / down choke: write the returning note tied.
465
+ - Bridge mute (M, P.M. bracket in Japanese band scores = \u30DF\u30E5\u30FC\u30C8): add p to every note under the bracket.
466
+ - Dead notes / brushing (x): S:x.
467
+ - Natural/artificial harmonics (o, Harm., <7>): plain fret. Vibrato, slide-in from nowhere (/6), slide-out, arpeggio wavy line, grace notes: write the main note only.
468
+ - Chord diagrams / chord-form boxes drawn inside the TAB staff (no fret numbers): write the chord name on the chord row. Write the voicing as notes only if the user asks or the diagram is unambiguous.
469
+ - Repeats, D.S., Coda, 1st/2nd endings: transcribe the bars as printed and tell the user; do not expand them unless asked.
470
+
471
+ ## 4. Check, then report
472
+ - After writing, call get_tab with from/to for the bars you wrote and compare them bar by bar against the image: beat count, slot positions, string lines and fret numbers. Fix anything off with write_measures (rewrite the bar).
473
+ - Then call send_to_eztab with a label naming the part and bars (e.g. "Guitar II, bars 35\u201343"); the user applies it in the app. If it fails because no token is configured, tell the user how to set it up (the error says how) \u2014 the document stays in this session.
474
+ - Finish with a short report: what was transcribed (part, bars), the notation you had to simplify (from section 3), and any digits or rhythms you were unsure of, by measure index.`;
475
+
476
+ // server.ts
477
+ var state = {
478
+ doc: null,
479
+ version: 0,
480
+ source: null
481
+ };
482
+ var server = new McpServer(
483
+ { name: "eztab", version: "0.2.0" },
484
+ {
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."
486
+ }
487
+ );
397
488
  function text(s) {
398
489
  return { content: [{ type: "text", text: s }] };
399
490
  }
400
491
  function requireDoc() {
401
492
  if (!state.doc) {
402
- throw new Error(
403
- "No document is open. Open the EZTab app (it will connect and share its tab), or call new_tab first."
404
- );
493
+ throw new Error("No document is open. Call new_tab first.");
405
494
  }
406
495
  return state.doc;
407
496
  }
408
- function assertEditable() {
409
- if (appView === "library") {
410
- throw new Error(
411
- "The EZTab app is on the library screen \u2014 no tab is open to edit. Ask the user to open a tab in the app and switch it to Edit mode, then retry."
412
- );
413
- }
414
- if (appEditable === false) {
415
- throw new Error(
416
- "The open tab is in View mode, so it can't be changed. Ask the user to switch the tab to Edit mode in the app, then retry. (Reading is always allowed.)"
417
- );
418
- }
419
- }
420
497
  function applyEdit(mutate) {
421
- assertEditable();
422
498
  const doc = requireDoc();
423
499
  mutate(doc);
424
500
  state.version += 1;
425
- pushState();
426
- const connected = mode === "host" ? clients.size > 0 : upstream != null;
427
- return text(
428
- `${connected ? "Pushed to app. " : "No app connected (will sync when it opens). "}
429
-
430
- ${render(doc)}`
431
- );
501
+ return text(render(doc));
502
+ }
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));
432
509
  }
433
510
  function setDoc(doc) {
434
- assertEditable();
435
511
  state.doc = doc;
436
512
  state.version += 1;
437
- pushState();
438
513
  return text(render(doc));
439
514
  }
440
515
  function assertMeasure(doc, m) {
@@ -444,67 +519,19 @@ function assertMeasure(doc, m) {
444
519
  }
445
520
  server.tool(
446
521
  "get_tab",
447
- "Return a readable rendering of the tab the EZTab app currently has open. If the app isn't connected, or is on the library screen (no tab open), this says so instead \u2014 relay that to the user (ask them to open the app / open a tab) rather than guessing.",
448
- {},
449
- async () => {
450
- if (appView === "library") {
451
- return text(
452
- "The EZTab app is open on the library screen \u2014 no tab is selected, so there's nothing to show. Ask the user to open a tab in the app."
453
- );
454
- }
455
- if (appView === "editor" && state.doc) {
456
- return text(render(state.doc));
457
- }
458
- if (state.doc) {
459
- return text(render(state.doc));
460
- }
461
- return text(
462
- "No tab is open. Ask the user to open the EZTab app and open a tab (it will be shared here automatically), or call new_tab to start a fresh one."
463
- );
464
- }
465
- );
466
- server.tool(
467
- "list_tabs",
468
- "List the tabs in the user's EZTab library (id, title, artist, measures, tempo). Read-only. Requires the app to be open with Live enabled.",
469
- {},
470
- async () => {
471
- try {
472
- const tabs = await requestFromApp("list_tabs");
473
- return text(JSON.stringify(tabs, null, 2));
474
- } catch (e) {
475
- return text(String(e?.message ?? e));
476
- }
477
- }
478
- );
479
- server.tool(
480
- "list_playlists",
481
- "List the user's playlists (id, name, song count). Read-only. Requires the app to be open with Live enabled.",
482
- {},
483
- async () => {
484
- try {
485
- const pls = await requestFromApp("list_playlists");
486
- return text(JSON.stringify(pls, null, 2));
487
- } catch (e) {
488
- return text(String(e?.message ?? e));
489
- }
490
- }
491
- );
492
- server.tool(
493
- "get_playlist",
494
- "Show a playlist's songs in order (resolved to titles). Provide its `id` or `name` (use list_playlists first). Read-only. Requires the app open with Live enabled.",
495
- { id: z.string().optional(), name: z.string().optional() },
522
+ "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
+ {
524
+ from: z.number().int().min(0).optional(),
525
+ to: z.number().int().min(0).optional()
526
+ },
496
527
  async (a) => {
497
- try {
498
- const pl = await requestFromApp("get_playlist", { id: a.id, name: a.name });
499
- return text(JSON.stringify(pl, null, 2));
500
- } catch (e) {
501
- return text(String(e?.message ?? e));
502
- }
528
+ if (state.doc) return text(render(state.doc, a.from, a.to));
529
+ return text("No document is open in this session. Call new_tab to start one.");
503
530
  }
504
531
  );
505
532
  server.tool(
506
533
  "new_tab",
507
- "Replace the live document with a fresh one and push it to the app. Tuning is top (highest) to bottom (lowest).",
534
+ "Start a fresh document, replacing any current one. Tuning is top (highest) to bottom (lowest).",
508
535
  {
509
536
  title: z.string().optional(),
510
537
  artist: z.string().optional(),
@@ -519,9 +546,70 @@ server.tool(
519
546
  if (a.title) doc.title = a.title;
520
547
  if (a.artist) doc.artist = a.artist;
521
548
  if (a.tempo) doc.tempo = a.tempo;
549
+ state.source = null;
522
550
  return setDoc(doc);
523
551
  }
524
552
  );
553
+ function formatTime(ms) {
554
+ return ms ? new Date(ms).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }) : "?";
555
+ }
556
+ async function withCloud(fn) {
557
+ try {
558
+ return await fn();
559
+ } catch (e) {
560
+ if (e instanceof CloudError) throw new Error(e.message);
561
+ throw e;
562
+ }
563
+ }
564
+ server.tool(
565
+ "list_tabs",
566
+ "List the tabs in the user's EZTab library (id, title, artist, measures, last update). Tabs open for editing in the app are marked with the device. Use the id with open_tab.",
567
+ { query: z.string().optional().describe("Filter by title/artist (case-insensitive).") },
568
+ async (a) => withCloud(async () => {
569
+ const q = a.query?.toLowerCase() ?? "";
570
+ const tabs = (await cloud.list()).filter((t) => !q || `${t.title} ${t.artist}`.toLowerCase().includes(q)).sort((x, y) => y.updatedAt - x.updatedAt);
571
+ if (!tabs.length) return text(q ? `No tabs match "${a.query}".` : "The library is empty.");
572
+ return text(
573
+ tabs.map(
574
+ (t) => `${t.id} ${t.title || "(untitled)"}${t.artist ? ` \u2014 ${t.artist}` : ""} [${t.measures} measures, updated ${formatTime(t.updatedAt)}]` + (t.lock ? ` \u2190 editing on ${t.lock.deviceName || "another device"}` : "")
575
+ ).join("\n")
576
+ );
577
+ })
578
+ );
579
+ server.tool(
580
+ "open_tab",
581
+ "Load a tab from the user's EZTab library into this session (replacing the current document) so it can be read and edited with the other tools. send_to_eztab then delivers it back to the app as a revision of that tab.",
582
+ { id: z.string().describe("Tab id from list_tabs.") },
583
+ async (a) => withCloud(async () => {
584
+ const meta = (await cloud.list()).find((t) => t.id === a.id);
585
+ if (!meta) throw new Error(`No tab with id ${a.id}. Use list_tabs to find it.`);
586
+ const doc = normalize(await cloud.get(a.id));
587
+ state.source = { tabId: a.id, title: meta.title, baseUpdatedAt: meta.updatedAt };
588
+ return setDoc(doc);
589
+ })
590
+ );
591
+ server.tool(
592
+ "send_to_eztab",
593
+ "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
+ {
595
+ label: z.string().optional().describe("Short description shown in the app, e.g. 'Guitar II, bars 1\u20138'."),
596
+ as_new: z.boolean().optional().describe(
597
+ "For a document opened with open_tab: send it as new material instead of a revision of that tab."
598
+ )
599
+ },
600
+ async (a) => withCloud(async () => {
601
+ const doc = requireDoc();
602
+ const target = state.source && !a.as_new ? state.source : null;
603
+ await cloud.sendToInbox({
604
+ doc,
605
+ label: a.label ?? "",
606
+ target: target ? { tabId: target.tabId, baseUpdatedAt: target.baseUpdatedAt } : null
607
+ });
608
+ return text(
609
+ target ? `Sent to the EZTab inbox as a revision of "${target.title}". The user applies it in the app and saves.` : `Sent ${doc.measures.length} measure(s) to the EZTab inbox. The user can insert them at the cursor or open them as a new tab.`
610
+ );
611
+ })
612
+ );
525
613
  server.tool(
526
614
  "set_title",
527
615
  "Set the document title.",
@@ -540,19 +628,17 @@ server.tool(
540
628
  );
541
629
  server.tool(
542
630
  "set_cells",
543
- "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.",
631
+ "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. Optional `pm` (boolean) palm-mutes (bridge-mutes) a fret-number note. measure/string/column are all 0-based (measure 0 = the first measure, string 0 = the top line).",
544
632
  {
545
633
  edits: z.array(
546
634
  z.object({
547
635
  measure: z.number().int(),
548
636
  string: z.number().int(),
549
637
  column: z.number().int(),
550
- value: z.union([
551
- z.number().int().min(0).max(24),
552
- z.enum(["rest", "mute", "clear"])
553
- ]),
638
+ value: z.union([z.number().int().min(0).max(24), z.enum(["rest", "mute", "clear"])]),
554
639
  bend: z.union([z.literal(0.5), z.literal(1), z.literal(1.5)]).optional(),
555
- tie: z.boolean().optional()
640
+ tie: z.boolean().optional(),
641
+ pm: z.boolean().optional()
556
642
  })
557
643
  )
558
644
  },
@@ -561,9 +647,7 @@ server.tool(
561
647
  assertMeasure(doc, e.measure);
562
648
  const m = doc.measures[e.measure];
563
649
  if (e.string < 0 || e.string >= doc.tuning.length) {
564
- throw new Error(
565
- `string ${e.string} out of range (0..${doc.tuning.length - 1})`
566
- );
650
+ throw new Error(`string ${e.string} out of range (0..${doc.tuning.length - 1})`);
567
651
  }
568
652
  const cols = columnCount(m);
569
653
  if (e.column < 0 || e.column >= cols) {
@@ -572,25 +656,64 @@ server.tool(
572
656
  );
573
657
  }
574
658
  let value = e.value === "clear" ? null : e.value;
575
- if (typeof e.value === "number" && (e.bend != null || e.tie)) {
659
+ if (typeof e.value === "number" && (e.bend != null || e.tie || e.pm)) {
576
660
  value = {
577
661
  fret: e.value,
578
662
  ...e.bend != null ? { bend: e.bend } : {},
579
- ...e.tie ? { tie: true } : {}
663
+ ...e.tie ? { tie: true } : {},
664
+ ...e.pm ? { pm: true } : {}
580
665
  };
581
666
  }
582
667
  m.cells[e.string][e.column] = value;
583
668
  }
584
669
  })
585
670
  );
671
+ server.tool(
672
+ "write_measures",
673
+ 'Write whole measures, one text string per beat \u2014 the easiest way to transcribe a score. Each listed measure is replaced entirely (notes, chords, per-beat subdivisions). `measure` is the 0-based index; using the current measure count appends, so a transcription can be written in order without add_measures. `time` (e.g. "4/4", "6/8") is optional: it defaults to the measure being replaced, or the previous measure when appending. `beats` has exactly one string per beat of the time signature.\n\nA beat string is space-separated slots; the slot count is that beat\'s subdivision (1 = quarter, 2 = eighths, 3 = eighth triplet, 4 = sixteenths, 6 = sixteenth triplet \u2026). Pick the smallest count that puts every onset of the beat on a slot (a dotted eighth + sixteenth = 4 slots: note - - note). A slot is "-" (nothing starts there; the previous note rings on) or items joined by "+":\n S:F string S (1 = top line of the TAB, as printed) at fret F\n S:~F tied / slurred from the previous note on that string (hammer-on, pull-off, slide into F, or a tie when F is the same fret)\n S:Fb / S:FB / S:F^ choke (bend) \xBD / 1 / 1\xBD steps\n S:Fp palm (bridge) mute; combines, e.g. S:~5p, S:7Bp\n S:x muted dead-note stroke (brushing)\n S:r rest: stops the note ringing on that string\n @Name chord name onset for the chord row (e.g. @Am7, @N.C.)\nExamples: ["6:5 6:7", "5:5 5:7", "4:5 - 4:7", "@F+4:3+3:2+2:1+1:1"].',
674
+ {
675
+ measures: z.array(
676
+ z.object({
677
+ measure: z.number().int().min(0),
678
+ time: z.string().optional(),
679
+ beats: z.array(z.string()).min(1)
680
+ })
681
+ ).min(1)
682
+ },
683
+ async (a) => applyEditRange((doc) => {
684
+ let lo = Infinity;
685
+ let hi = -1;
686
+ for (const spec of a.measures) {
687
+ const i = spec.measure;
688
+ if (i > doc.measures.length) {
689
+ throw new Error(
690
+ `measure ${i} would leave a gap: there are ${doc.measures.length} measures, so append at ${doc.measures.length}`
691
+ );
692
+ }
693
+ const ref = doc.measures[i] ?? doc.measures[i - 1];
694
+ const fallback = { beats: ref?.beats ?? 4, beatUnit: ref?.beatUnit ?? 4 };
695
+ const built = buildMeasure(
696
+ spec,
697
+ doc.tuning.length,
698
+ fallback,
699
+ `measure ${i}`
700
+ );
701
+ if (i === doc.measures.length) doc.measures.push(built);
702
+ else doc.measures[i] = built;
703
+ lo = Math.min(lo, i);
704
+ hi = Math.max(hi, i);
705
+ }
706
+ return [lo, hi];
707
+ })
708
+ );
586
709
  server.tool(
587
710
  "set_chords",
588
- "Set chord labels per beat (0-based). An empty label clears that beat's chord.",
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.',
589
712
  {
590
713
  edits: z.array(
591
714
  z.object({
592
715
  measure: z.number().int(),
593
- beat: z.number().int(),
716
+ column: z.number().int(),
594
717
  label: z.string()
595
718
  })
596
719
  )
@@ -599,13 +722,15 @@ server.tool(
599
722
  for (const e of a.edits) {
600
723
  assertMeasure(doc, e.measure);
601
724
  const m = doc.measures[e.measure];
602
- if (e.beat < 0 || e.beat >= m.beats) {
725
+ const cols = columnCount(m);
726
+ if (e.column < 0 || e.column >= cols) {
603
727
  throw new Error(
604
- `beat ${e.beat} out of range for measure ${e.measure} (0..${m.beats - 1})`
728
+ `column ${e.column} out of range for measure ${e.measure} (0..${cols - 1})`
605
729
  );
606
730
  }
607
- if (!m.chords) m.chords = Array.from({ length: m.beats }, () => null);
608
- m.chords[e.beat] = e.label.trim() || null;
731
+ if (!m.chords) m.chords = Array.from({ length: cols }, () => null);
732
+ const label = e.label.trim();
733
+ m.chords[e.column] = /^(rest|n\.?c\.?)$/i.test(label) ? CHORD_REST : label || null;
609
734
  }
610
735
  })
611
736
  );
@@ -681,14 +806,25 @@ server.tool(
681
806
  );
682
807
  server.tool(
683
808
  "set_resolution",
684
- "Set a measure's subdivisions per beat (1=quarter, 2=eighth, 4=sixteenth, 3=eighth triplet, 6=sixteenth triplet). Existing notes are remapped.",
685
- { measure: z.number().int(), subdivisions: z.number().int().min(1).max(12) },
809
+ "Set subdivisions per beat (1=quarter, 2=eighth, 4=sixteenth, 3=eighth triplet, 6=sixteenth triplet, 5=sixteenth quintuplet, 10=32nd quintuplet). Without `beat`, applies to every beat of the measure; with `beat` (0-based) only that beat changes, so straight and tuplet beats can mix in one measure. Existing notes are remapped.",
810
+ {
811
+ measure: z.number().int(),
812
+ subdivisions: z.number().int().min(1).max(12),
813
+ beat: z.number().int().min(0).optional()
814
+ },
686
815
  async (a) => applyEdit((doc) => {
687
816
  assertMeasure(doc, a.measure);
688
- doc.measures[a.measure] = setMeasureSubdivisions(
689
- doc.measures[a.measure],
690
- a.subdivisions
691
- );
817
+ const m = doc.measures[a.measure];
818
+ if (a.beat != null) {
819
+ if (a.beat >= m.beats) {
820
+ throw new Error(
821
+ `beat ${a.beat} out of range for measure ${a.measure} (0..${m.beats - 1})`
822
+ );
823
+ }
824
+ doc.measures[a.measure] = setBeatSubdivision(m, a.beat, a.subdivisions);
825
+ } else {
826
+ doc.measures[a.measure] = setMeasureSubdivisions(m, a.subdivisions);
827
+ }
692
828
  })
693
829
  );
694
830
  server.tool(
@@ -726,11 +862,32 @@ server.tool(
726
862
  { tuning: z.array(z.string()).min(1) },
727
863
  async (a) => applyEdit((doc) => {
728
864
  doc.tuning = [...a.tuning];
729
- doc.measures = doc.measures.map(
730
- (m) => resizeMeasureStrings(m, a.tuning.length)
731
- );
865
+ doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
866
+ })
867
+ );
868
+ server.prompt(
869
+ "import_tab_from_image",
870
+ "Transcribe a TAB score from attached image(s) into an EZTab document.",
871
+ {
872
+ part: z.string().optional().describe("Which part to take from a band score, e.g. 'Guitar II' or 'Bass'."),
873
+ notes: z.string().optional().describe("Anything else to know (tuning, which bars, \u2026).")
874
+ },
875
+ (a) => ({
876
+ messages: [
877
+ {
878
+ role: "user",
879
+ content: {
880
+ type: "text",
881
+ text: IMPORT_TAB_FROM_IMAGE + (a.part ? `
882
+
883
+ Part to transcribe: ${a.part}` : "") + (a.notes ? `
884
+
885
+ Notes from the user: ${a.notes}` : "")
886
+ }
887
+ }
888
+ ]
732
889
  })
733
890
  );
734
891
  var transport = new StdioServerTransport();
735
892
  await server.connect(transport);
736
- console.error(`eztab MCP server running (stdio) + live hub on ws://127.0.0.1:${PORT}`);
893
+ console.error("eztab MCP server running (stdio)");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.1.1",
4
- "description": "MCP server for EZTab — lets an AI agent edit the tab the EZTab app has open, live.",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for EZTab \u2014 lets an AI agent build and edit guitar/bass tablature.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "eztab-mcp": "dist/server.js"
@@ -31,11 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "^1.29.0",
34
- "ws": "^8.21.0",
35
34
  "zod": "^4.4.3"
36
35
  },
37
36
  "devDependencies": {
38
37
  "tsup": "^8.3.0",
39
- "typescript": "^5.8.3"
38
+ "typescript": "^7.0.0"
40
39
  }
41
40
  }