@hidechae/eztab-mcp 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/server.js +135 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,9 +79,12 @@ These act on the live shared document (no file path needed):
|
|
|
79
79
|
|
|
80
80
|
| Tool | What it does |
|
|
81
81
|
|------|--------------|
|
|
82
|
-
| `get_tab` | Readable ASCII rendering of the
|
|
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 |
|
|
83
86
|
| `new_tab` | Replace the document with a fresh one (title, artist, tuning/preset, measures, tempo) |
|
|
84
|
-
| `
|
|
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) |
|
|
85
88
|
| `set_title` | Set the document title |
|
|
86
89
|
| `set_artist` | Set the document artist |
|
|
87
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) |
|
package/dist/server.js
CHANGED
|
@@ -125,11 +125,6 @@ function setMeasureSubdivisions(measure, subdivisions) {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// eztab.ts
|
|
128
|
-
import { readFile, writeFile } from "fs/promises";
|
|
129
|
-
import path from "path";
|
|
130
|
-
function resolvePath(p) {
|
|
131
|
-
return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
|
132
|
-
}
|
|
133
128
|
function normalize(data) {
|
|
134
129
|
if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
|
|
135
130
|
throw new Error("Not a valid EZTab document");
|
|
@@ -163,13 +158,6 @@ function normalize(data) {
|
|
|
163
158
|
data.version = 1;
|
|
164
159
|
return data;
|
|
165
160
|
}
|
|
166
|
-
async function loadTab(p) {
|
|
167
|
-
const text2 = await readFile(resolvePath(p), "utf8");
|
|
168
|
-
return normalize(JSON.parse(text2));
|
|
169
|
-
}
|
|
170
|
-
async function saveTab(p, doc) {
|
|
171
|
-
await writeFile(resolvePath(p), JSON.stringify(doc, null, 2), "utf8");
|
|
172
|
-
}
|
|
173
161
|
function cellText(cell) {
|
|
174
162
|
if (cell === null) return "--";
|
|
175
163
|
if (cell === "rest") return "-R";
|
|
@@ -229,7 +217,57 @@ var state = {
|
|
|
229
217
|
var mode = "host";
|
|
230
218
|
var clients = /* @__PURE__ */ new Set();
|
|
231
219
|
var upstream = null;
|
|
220
|
+
var appView = null;
|
|
232
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;
|
|
228
|
+
}
|
|
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
|
+
});
|
|
262
|
+
}
|
|
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
|
+
}
|
|
270
|
+
}
|
|
233
271
|
function pushState() {
|
|
234
272
|
if (mode === "host") {
|
|
235
273
|
const msg = JSON.stringify({
|
|
@@ -244,24 +282,6 @@ function pushState() {
|
|
|
244
282
|
upstream.send(JSON.stringify({ type: "set", doc: state.doc }));
|
|
245
283
|
}
|
|
246
284
|
}
|
|
247
|
-
function sendCommand(cmd) {
|
|
248
|
-
const out = JSON.stringify(cmd);
|
|
249
|
-
if (mode === "host") {
|
|
250
|
-
let sent = false;
|
|
251
|
-
for (const c of clients) {
|
|
252
|
-
if (c.readyState === WebSocket.OPEN) {
|
|
253
|
-
c.send(out);
|
|
254
|
-
sent = true;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return sent;
|
|
258
|
-
}
|
|
259
|
-
if (upstream && upstream.readyState === WebSocket.OPEN) {
|
|
260
|
-
upstream.send(out);
|
|
261
|
-
return true;
|
|
262
|
-
}
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
285
|
function broadcastExcept(except) {
|
|
266
286
|
const msg = JSON.stringify({
|
|
267
287
|
type: "state",
|
|
@@ -303,14 +323,19 @@ wss.on("connection", (ws) => {
|
|
|
303
323
|
version: state.version
|
|
304
324
|
})
|
|
305
325
|
);
|
|
306
|
-
} else if (msg.type === "
|
|
326
|
+
} else if (msg.type === "appstate") {
|
|
327
|
+
appView = msg.view === "editor" || msg.view === "library" ? msg.view : null;
|
|
307
328
|
appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
|
|
308
329
|
ws._isApp = true;
|
|
309
|
-
const out = JSON.stringify({ type: "
|
|
330
|
+
const out = JSON.stringify({ type: "appstate", view: appView, editable: appEditable });
|
|
310
331
|
for (const c of clients) {
|
|
311
332
|
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
|
|
312
333
|
}
|
|
313
|
-
} else if (msg.type === "
|
|
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);
|
|
314
339
|
const out = JSON.stringify(msg);
|
|
315
340
|
for (const c of clients) {
|
|
316
341
|
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
|
|
@@ -320,8 +345,9 @@ wss.on("connection", (ws) => {
|
|
|
320
345
|
const onGone = () => {
|
|
321
346
|
clients.delete(ws);
|
|
322
347
|
if (ws._isApp) {
|
|
348
|
+
appView = null;
|
|
323
349
|
appEditable = null;
|
|
324
|
-
const out = JSON.stringify({ type: "
|
|
350
|
+
const out = JSON.stringify({ type: "appstate", view: null, editable: null });
|
|
325
351
|
for (const c of clients) {
|
|
326
352
|
if (c.readyState === WebSocket.OPEN) c.send(out);
|
|
327
353
|
}
|
|
@@ -350,8 +376,11 @@ function enterClientMode() {
|
|
|
350
376
|
if (msg.type === "state" && msg.doc) {
|
|
351
377
|
state.doc = normalize(msg.doc);
|
|
352
378
|
state.version = msg.version;
|
|
353
|
-
} else if (msg.type === "
|
|
379
|
+
} else if (msg.type === "appstate") {
|
|
380
|
+
appView = msg.view === "editor" || msg.view === "library" ? msg.view : null;
|
|
354
381
|
appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
|
|
382
|
+
} else if (msg.type === "response" && msg.reqId) {
|
|
383
|
+
settleResponse(msg.reqId, msg.data);
|
|
355
384
|
}
|
|
356
385
|
} catch {
|
|
357
386
|
}
|
|
@@ -364,22 +393,32 @@ function enterClientMode() {
|
|
|
364
393
|
};
|
|
365
394
|
connect();
|
|
366
395
|
}
|
|
367
|
-
var server = new McpServer(
|
|
396
|
+
var server = new McpServer(
|
|
397
|
+
{ name: "eztab", version: "0.2.0" },
|
|
398
|
+
{
|
|
399
|
+
instructions: "All indices are 0-based: measure 0 is the first measure, beat 0 is the first beat of a measure, string 0 is the top (highest) string, and column 0 is the first column. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing."
|
|
400
|
+
}
|
|
401
|
+
);
|
|
368
402
|
function text(s) {
|
|
369
403
|
return { content: [{ type: "text", text: s }] };
|
|
370
404
|
}
|
|
371
405
|
function requireDoc() {
|
|
372
406
|
if (!state.doc) {
|
|
373
407
|
throw new Error(
|
|
374
|
-
"No document is open. Open the EZTab app (it will connect), or call new_tab
|
|
408
|
+
"No document is open. Open the EZTab app (it will connect and share its tab), or call new_tab first."
|
|
375
409
|
);
|
|
376
410
|
}
|
|
377
411
|
return state.doc;
|
|
378
412
|
}
|
|
379
413
|
function assertEditable() {
|
|
414
|
+
if (appView === "library") {
|
|
415
|
+
throw new Error(
|
|
416
|
+
"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."
|
|
417
|
+
);
|
|
418
|
+
}
|
|
380
419
|
if (appEditable === false) {
|
|
381
420
|
throw new Error(
|
|
382
|
-
"The
|
|
421
|
+
"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.)"
|
|
383
422
|
);
|
|
384
423
|
}
|
|
385
424
|
}
|
|
@@ -410,9 +449,63 @@ function assertMeasure(doc, m) {
|
|
|
410
449
|
}
|
|
411
450
|
server.tool(
|
|
412
451
|
"get_tab",
|
|
413
|
-
"Return a readable rendering of the
|
|
452
|
+
"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.",
|
|
414
453
|
{},
|
|
415
|
-
async () =>
|
|
454
|
+
async () => {
|
|
455
|
+
if (appView === "library") {
|
|
456
|
+
return text(
|
|
457
|
+
"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."
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (appView === "editor" && state.doc) {
|
|
461
|
+
return text(render(state.doc));
|
|
462
|
+
}
|
|
463
|
+
if (state.doc) {
|
|
464
|
+
return text(render(state.doc));
|
|
465
|
+
}
|
|
466
|
+
return text(
|
|
467
|
+
"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."
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
);
|
|
471
|
+
server.tool(
|
|
472
|
+
"list_tabs",
|
|
473
|
+
"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.",
|
|
474
|
+
{},
|
|
475
|
+
async () => {
|
|
476
|
+
try {
|
|
477
|
+
const tabs = await requestFromApp("list_tabs");
|
|
478
|
+
return text(JSON.stringify(tabs, null, 2));
|
|
479
|
+
} catch (e) {
|
|
480
|
+
return text(String(e?.message ?? e));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
);
|
|
484
|
+
server.tool(
|
|
485
|
+
"list_playlists",
|
|
486
|
+
"List the user's playlists (id, name, song count). Read-only. Requires the app to be open with Live enabled.",
|
|
487
|
+
{},
|
|
488
|
+
async () => {
|
|
489
|
+
try {
|
|
490
|
+
const pls = await requestFromApp("list_playlists");
|
|
491
|
+
return text(JSON.stringify(pls, null, 2));
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return text(String(e?.message ?? e));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
);
|
|
497
|
+
server.tool(
|
|
498
|
+
"get_playlist",
|
|
499
|
+
"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.",
|
|
500
|
+
{ id: z.string().optional(), name: z.string().optional() },
|
|
501
|
+
async (a) => {
|
|
502
|
+
try {
|
|
503
|
+
const pl = await requestFromApp("get_playlist", { id: a.id, name: a.name });
|
|
504
|
+
return text(JSON.stringify(pl, null, 2));
|
|
505
|
+
} catch (e) {
|
|
506
|
+
return text(String(e?.message ?? e));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
416
509
|
);
|
|
417
510
|
server.tool(
|
|
418
511
|
"new_tab",
|
|
@@ -450,39 +543,9 @@ server.tool(
|
|
|
450
543
|
doc.artist = a.artist;
|
|
451
544
|
})
|
|
452
545
|
);
|
|
453
|
-
server.tool(
|
|
454
|
-
"load_from_file",
|
|
455
|
-
"Load an .eztab file into the live document and push it to the app.",
|
|
456
|
-
{ path: z.string() },
|
|
457
|
-
async (a) => setDoc(await loadTab(a.path))
|
|
458
|
-
);
|
|
459
|
-
server.tool(
|
|
460
|
-
"save_to_file",
|
|
461
|
-
"Save the current live document to an .eztab file.",
|
|
462
|
-
{ path: z.string() },
|
|
463
|
-
async (a) => {
|
|
464
|
-
await saveTab(a.path, requireDoc());
|
|
465
|
-
return text(`Saved ${resolvePath(a.path)}`);
|
|
466
|
-
}
|
|
467
|
-
);
|
|
468
|
-
server.tool(
|
|
469
|
-
"open_in_app",
|
|
470
|
-
"Ask the connected EZTab app to open the tab file at `path` (the app must have Live enabled). The app loads it into its editor and shares it back.",
|
|
471
|
-
{ path: z.string() },
|
|
472
|
-
async (a) => {
|
|
473
|
-
const sent = sendCommand({
|
|
474
|
-
type: "command",
|
|
475
|
-
action: "open",
|
|
476
|
-
path: resolvePath(a.path)
|
|
477
|
-
});
|
|
478
|
-
return text(
|
|
479
|
-
sent ? `Asked the app to open ${resolvePath(a.path)}` : "No app is connected. Enable Live in the EZTab app first."
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
);
|
|
483
546
|
server.tool(
|
|
484
547
|
"set_cells",
|
|
485
|
-
"Set tab cells. value: a fret number (0-24), 'rest' (stops a sustaining note), 'mute' (a muted/dead 'x' stroke for cutting), or 'clear' (empty = previous note sustains through). Optional `bend` (0.5, 1, or 1.5 semitones) chokes a fret-number note. Optional `tie` (boolean) ties/slurs a fret-number note to the previous note on the same string \u2014 a matching pitch just sustains (no re-pick), a different pitch is a slur. string/column are 0-based
|
|
548
|
+
"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. measure/string/column are all 0-based (measure 0 = the first measure, string 0 = the top line).",
|
|
486
549
|
{
|
|
487
550
|
edits: z.array(
|
|
488
551
|
z.object({
|
|
@@ -527,7 +590,7 @@ server.tool(
|
|
|
527
590
|
);
|
|
528
591
|
server.tool(
|
|
529
592
|
"set_chords",
|
|
530
|
-
"Set chord labels per beat
|
|
593
|
+
"Set chord labels per beat. `measure` and `beat` are 0-based (measure 0 = the first measure, beat 0 = the first beat). An empty label clears that beat's chord.",
|
|
531
594
|
{
|
|
532
595
|
edits: z.array(
|
|
533
596
|
z.object({
|