@hidechae/eztab-mcp 0.1.0 → 0.1.1

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 +5 -2
  2. package/dist/server.js +127 -69
  3. 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 current document |
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
- | `load_from_file` / `save_to_file` | Seed from / persist to an `.eztab` file |
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 === "editstate") {
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: "editstate", editable: appEditable });
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 === "command") {
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: "editstate", editable: null });
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 === "editstate") {
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
  }
@@ -371,15 +400,20 @@ function text(s) {
371
400
  function requireDoc() {
372
401
  if (!state.doc) {
373
402
  throw new Error(
374
- "No document is open. Open the EZTab app (it will connect), or call new_tab / load_from_file first."
403
+ "No document is open. Open the EZTab app (it will connect and share its tab), or call new_tab first."
375
404
  );
376
405
  }
377
406
  return state.doc;
378
407
  }
379
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
+ }
380
414
  if (appEditable === false) {
381
415
  throw new Error(
382
- "The EZTab app has this tab open in View mode, so it can't be changed. Switch the tab to Edit mode in the app, then retry. (Reading is always allowed.)"
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.)"
383
417
  );
384
418
  }
385
419
  }
@@ -410,9 +444,63 @@ function assertMeasure(doc, m) {
410
444
  }
411
445
  server.tool(
412
446
  "get_tab",
413
- "Return a readable rendering of the document currently open/shared with the app.",
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.",
414
482
  {},
415
- async () => text(render(requireDoc()))
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() },
496
+ 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
+ }
503
+ }
416
504
  );
417
505
  server.tool(
418
506
  "new_tab",
@@ -450,36 +538,6 @@ server.tool(
450
538
  doc.artist = a.artist;
451
539
  })
452
540
  );
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
541
  server.tool(
484
542
  "set_cells",
485
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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for EZTab — lets an AI agent edit the tab the EZTab app has open, live.",
5
5
  "type": "module",
6
6
  "bin": {