vibo-mcp 2.3.1 → 2.4.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.
package/dist/client.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { dirname, join } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { loadDotenvSafely, readEnvVar, McpToolError, SessionNotAuthenticatedError, truncateErrorMessage, withAmbientCancellation, } from '@chrischall/mcp-utils';
4
+ import { loadDotenvSafely, readEnvVar, McpToolError, SessionNotAuthenticatedError, detectEdgeBlock, EdgeBlockedError, truncateErrorMessage, withAmbientCancellation, } from '@chrischall/mcp-utils';
5
+ import { createGraphqlClient } from '@chrischall/mcp-utils/graphql';
5
6
  import { loadSession, saveSession } from './session-store.js';
6
7
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
7
8
  // mcpb bundle, which externalizes dotenv). `override: false` means a
@@ -46,6 +47,13 @@ const REFRESH = `
46
47
  const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED']);
47
48
  // Codes for "you are signed in, but not allowed to do this".
48
49
  const PERMISSION_ERROR_CODES = new Set(['FORBIDDEN']);
50
+ // Vibo's permission denial for a host acting where the DJ's settings forbid it
51
+ // ("Action is not allowed for user", e.g. reordering songs in a section with
52
+ // host ordering off — verified live) arrives without a FORBIDDEN code. Anchored
53
+ // on Vibo's exact wording so a validation error that merely says "not allowed"
54
+ // keeps its own message. Matched only when the
55
+ // error carries no auth code, so an expired session is never mistaken for it.
56
+ const PERMISSION_MESSAGE_PATTERN = /\bnot allowed for user\b/i;
49
57
  // Message fallback, consulted ONLY for an error that carries no code. Vibo's
50
58
  // expired-session text is "Not authorized. Try to log in"; the patterns are
51
59
  // anchored on session/token wording so an unrelated message that merely
@@ -60,43 +68,60 @@ const AUTH_MESSAGE_PATTERN = /not authoriz|unauthoriz|unauthenticated|invalid to
60
68
  * meters it on. Measured on that fleet: claude.ai sent 101 cancellations in
61
69
  * the week to 2026-09-20.
62
70
  *
63
- * ONE definition for both request paths, which is not tidiness: the two are
64
- * the multipart upload and the plain query, they had the same seven lines
65
- * copied between them, and the next person to add a third path is the one
66
- * this saves. The `TimeoutError` checks at both call sites still name a real
67
- * timeout — an abort from the caller arrives as `AbortError` and falls
68
- * through to 'failed'.
71
+ * Used by the multipart UPLOAD path only. The plain JSON query path goes
72
+ * through mcp-utils' GraphQL client (`this.graphql`), which applies the same
73
+ * 30 s timeout and the caller's cancellation itself. The `TimeoutError` check
74
+ * in `uploadTransportError` names a real timeout — an abort from the caller
75
+ * arrives as `AbortError` and falls through to 'failed'.
69
76
  */
70
77
  function requestSignal() {
71
78
  return withAmbientCancellation(AbortSignal.timeout(REQUEST_TIMEOUT_MS));
72
79
  }
73
- /** Whether a GraphQL document is a mutation (a write with side effects). */
74
- function isMutation(query) {
75
- return /^\s*(?:#[^\n]*\n\s*)*mutation\b/.test(query);
76
- }
77
80
  /**
78
- * The error for a request that never produced a response (timeout, dropped
79
- * connection, caller abort). For a READ that is safely retryable. For a WRITE
80
- * the outcome is unknown — Vibo may already have committed it — and a blind
81
- * retry repeats the side effect (a second round of invitation emails, a
82
- * second exported playlist, a duplicate comment or import); the confirmation
83
- * gate cannot stop that, because a fresh preview earns a fresh approval and
84
- * token. So a write says so, and asks for a state check first.
81
+ * The hint for a WRITE that never produced a response (timeout, dropped
82
+ * connection): its outcome is unknown — Vibo may already have committed it —
83
+ * and a blind retry repeats the side effect (a second round of invitation
84
+ * emails, a second exported playlist, a duplicate comment or import); the
85
+ * confirmation gate cannot stop that, because a fresh preview earns a fresh
86
+ * approval and token. So a write says so, and asks for a state check first.
87
+ */
88
+ const WRITE_OUTCOME_HINT = 'Do not repeat this write blindly — check the current state before retrying: e.g. ' +
89
+ 'vibo_list_event_users after inviting, vibo_get_section_songs after adding, importing or commenting ' +
90
+ 'on songs, the Spotify/Apple Music account after an export. Retry only if the change is not there.';
91
+ /**
92
+ * The transport error for the multipart UPLOAD path (always a write) — the one
93
+ * request mcp-utils' GraphQL client does not send, since it speaks JSON only.
94
+ * Same wording as that client's write error.
85
95
  */
86
- function transportError(what, err, isWrite) {
96
+ function uploadTransportError(err) {
87
97
  const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'failed';
88
- if (isWrite) {
89
- return new McpToolError(`${what} ${SERVICE} ${reason} — the change may already have been applied (outcome is unknown).`, {
90
- hint: 'Do not repeat this write blindly — check the current state before retrying: e.g. ' +
91
- 'vibo_list_event_users after inviting, vibo_get_section_songs after adding, importing or commenting ' +
92
- 'on songs, the Spotify/Apple Music account after an export. Retry only if the change is not there.',
93
- cause: err,
94
- });
98
+ return new McpToolError(`Upload to ${SERVICE} ${reason} — the change may already have been applied (outcome is unknown).`, { hint: WRITE_OUTCOME_HINT, cause: err });
99
+ }
100
+ /**
101
+ * Read a multipart-upload GraphQL response body. An error status is read as text first so a
102
+ * CDN/WAF refusal page (CloudFront, Cloudflare, Akamai, Imperva) is named as
103
+ * an {@link EdgeBlockedError} — the request never reached Vibo, so neither
104
+ * the permission-denial copy (403) nor a sign-in prompt applies.
105
+ */
106
+ async function readGraphQLBody(response) {
107
+ if (response.status >= 400) {
108
+ const text = await response.text().catch(() => '');
109
+ const edge = detectEdgeBlock({ body: text, headers: response.headers, status: response.status });
110
+ if (edge !== null)
111
+ throw new EdgeBlockedError(response.status, edge.vendor, { service: SERVICE });
112
+ try {
113
+ return JSON.parse(text);
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ }
119
+ try {
120
+ return (await response.json());
121
+ }
122
+ catch {
123
+ return {};
95
124
  }
96
- return new McpToolError(`${what} ${SERVICE} ${reason}.`, {
97
- hint: 'The Vibo API may be unreachable — check your connection and retry.',
98
- cause: err,
99
- });
100
125
  }
101
126
  /** One-way fingerprint of a configured token, so session.json never holds a
102
127
  * second copy of the pasted secret just to record where a session came from. */
@@ -125,8 +150,21 @@ export class ViboClient {
125
150
  // refreshes against each other (à la mcp-utils' TokenManager).
126
151
  loginInFlight = null;
127
152
  reauthInFlight = null;
153
+ // The JSON request path: mcp-utils' GraphQL transport (POST, errors[] at any
154
+ // status, CDN/WAF detection, timeout + the caller's cancellation, 429 retry,
155
+ // and a write-aware transport error judged by a real operation-kind lexer).
156
+ // Auth (x-token, single-flight refresh/login + one replay) and the
157
+ // permission/expiry classification below stay here: they are Vibo's rules.
158
+ // Pure to build — safe at Worker global scope.
159
+ graphql;
128
160
  constructor(opts = {}) {
129
161
  this.apiUrl = opts.apiUrl ?? readEnvVar('VIBO_API_URL') ?? DEFAULT_API_URL;
162
+ this.graphql = createGraphqlClient({
163
+ endpoint: this.apiUrl,
164
+ serviceName: SERVICE,
165
+ timeout: REQUEST_TIMEOUT_MS,
166
+ writeOutcomeHint: WRITE_OUTCOME_HINT,
167
+ });
130
168
  this.email = opts.email ?? readEnvVar('VIBO_EMAIL') ?? null;
131
169
  this.password = opts.password ?? readEnvVar('VIBO_PASSWORD') ?? null;
132
170
  this.accessToken = opts.accessToken ?? readEnvVar('VIBO_ACCESS_TOKEN') ?? null;
@@ -259,15 +297,9 @@ export class ViboClient {
259
297
  }
260
298
  catch (err) {
261
299
  // An upload is always a write (the multipart path only carries mutations).
262
- throw transportError('Upload to', err, true);
263
- }
264
- let body;
265
- try {
266
- body = (await response.json());
267
- }
268
- catch {
269
- body = {};
300
+ throw uploadTransportError(err);
270
301
  }
302
+ const body = await readGraphQLBody(response);
271
303
  return { status: response.status, body };
272
304
  }
273
305
  /** Returns the current access token, performing a first login if we only have email/password. */
@@ -324,8 +356,13 @@ export class ViboClient {
324
356
  }
325
357
  }
326
358
  }
327
- catch {
328
- // fall through to a full login
359
+ catch (err) {
360
+ // A CDN/WAF block is not a dead refresh token: say so, rather than
361
+ // falling through to a login that meets the same block or to a
362
+ // "sign in again" that would not help. The stored tokens are kept.
363
+ if (err instanceof EdgeBlockedError)
364
+ throw err;
365
+ // otherwise fall through to a full login
329
366
  }
330
367
  }
331
368
  if (this.email && this.password) {
@@ -339,31 +376,19 @@ export class ViboClient {
339
376
  return this.reauthInFlight;
340
377
  }
341
378
  async post(query, variables, token) {
342
- const headers = { 'content-type': 'application/json' };
343
- if (token)
344
- headers['x-token'] = token;
345
- let response;
346
- try {
347
- response = await fetch(this.apiUrl, {
348
- method: 'POST',
349
- headers,
350
- body: JSON.stringify({ query, variables }),
351
- signal: requestSignal(),
352
- });
353
- }
354
- catch (err) {
379
+ const result = await this.graphql.execute({
380
+ query,
381
+ variables,
382
+ ...(token ? { headers: { 'x-token': token } } : {}),
355
383
  // signIn / refreshToken are mutations too, but repeating them is harmless.
356
- const isWrite = query !== SIGN_IN && query !== REFRESH && isMutation(query);
357
- throw transportError('Request to', err, isWrite);
358
- }
359
- let body;
360
- try {
361
- body = (await response.json());
362
- }
363
- catch {
364
- body = {};
365
- }
366
- return { status: response.status, body };
384
+ ...(query === SIGN_IN || query === REFRESH ? { idempotent: true } : {}),
385
+ });
386
+ const body = {};
387
+ if (result.data !== undefined)
388
+ body.data = result.data;
389
+ if (result.errors !== undefined)
390
+ body.errors = result.errors;
391
+ return { status: result.status, body };
367
392
  }
368
393
  /** An expired / missing session — the only case a refresh + replay can fix. */
369
394
  isAuthError(status, errors) {
@@ -382,7 +407,12 @@ export class ViboClient {
382
407
  isPermissionError(status, errors) {
383
408
  if (status === 403)
384
409
  return true;
385
- return (errors ?? []).some((e) => PERMISSION_ERROR_CODES.has(e.code ?? e.extensions?.code ?? ''));
410
+ return (errors ?? []).some((e) => {
411
+ const code = e.code ?? e.extensions?.code ?? '';
412
+ if (PERMISSION_ERROR_CODES.has(code))
413
+ return true;
414
+ return !AUTH_ERROR_CODES.has(code) && PERMISSION_MESSAGE_PATTERN.test(e.message ?? '');
415
+ });
386
416
  }
387
417
  unwrap(status, body) {
388
418
  if (this.isPermissionError(status, body.errors)) {
package/dist/gql.js CHANGED
@@ -86,6 +86,45 @@ export const LIST_SECTIONS = `
86
86
  }
87
87
  }
88
88
  `;
89
+ // Section management (create / delete / reorder). Captured from the web app's
90
+ // own documents in the web.vibodj.com bundle (Timeline "+" -> Add section,
91
+ // the section "..." -> Delete, and the timeline drag handler) and checked
92
+ // against live introspection. Read-before-write lookups use the lean
93
+ // SECTIONS_FOR_WRITE / EVENT_PERMISSIONS documents so vibo_list_sections'
94
+ // output stays unchanged.
95
+ export const SECTIONS_FOR_WRITE = `
96
+ query sectionsForWrite($eventId: ID!) {
97
+ sections(eventId: $eventId) {
98
+ _id name type songsCount questionsCount answeredCount canRemove visibility
99
+ settings { canHostsOrderSongs canHostDeleteSection }
100
+ }
101
+ }
102
+ `;
103
+ export const EVENT_PERMISSIONS = `
104
+ query eventPermissions($eventId: ID!) {
105
+ event(eventId: $eventId) {
106
+ _id role isLocked
107
+ settings { canHostCreateSections canHostReorderSections sectionSongsLimit sectionMustPlayLimit }
108
+ }
109
+ }
110
+ `;
111
+ export const CREATE_SECTION = `
112
+ mutation createSection($eventId: ID!, $payload: CreateSectionInput!) {
113
+ createSection(eventId: $eventId, payload: $payload) {
114
+ _id name type time description visibility
115
+ }
116
+ }
117
+ `;
118
+ export const REMOVE_SECTION = `
119
+ mutation removeSection($eventId: ID!, $sectionId: ID!) {
120
+ removeSection(eventId: $eventId, sectionId: $sectionId)
121
+ }
122
+ `;
123
+ export const REORDER_SECTIONS = `
124
+ mutation reorderSections($eventId: ID!, $sourceSectionId: ID!, $targetSectionId: ID) {
125
+ reorderSections(eventId: $eventId, sourceSectionId: $sourceSectionId, targetSectionId: $targetSectionId)
126
+ }
127
+ `;
89
128
  // ---- Songs ------------------------------------------------------------------
90
129
  export const GET_SECTION_SONGS = `
91
130
  query getSectionSongs($eventId: ID!, $sectionId: ID!, $filter: SectionSongsFilter, $pagination: PaginationInput, $sort: SongsSortInput) {
@@ -102,6 +141,17 @@ export const GET_SECTION_SONGS = `
102
141
  }
103
142
  }
104
143
  `;
144
+ // The section's songs in their manual (unsorted) order, ids only — used to
145
+ // validate ids before a remove/reorder and to confirm an add landed.
146
+ export const SECTION_SONG_IDS = `
147
+ query sectionSongIds($eventId: ID!, $sectionId: ID!, $pagination: PaginationInput) {
148
+ getSectionSongs(eventId: $eventId, sectionId: $sectionId, pagination: $pagination) {
149
+ songs { _id viboSongId artist title }
150
+ next { skip limit }
151
+ totalCount
152
+ }
153
+ }
154
+ `;
105
155
  export const SEARCH_SONGS = `
106
156
  query getSongs($eventId: ID!, $sectionId: ID!, $filter: SongsFilter!, $limit: Int!) {
107
157
  getSongs(eventId: $eventId, sectionId: $sectionId, filter: $filter, limit: $limit) {
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { registerIdeasTools } from './tools/ideas.js';
15
15
  import { registerImportTools } from './tools/imports.js';
16
16
  import { registerCollaborationTools } from './tools/collaboration.js';
17
17
  import { registerSectionEditTools } from './tools/section-edit.js';
18
+ import { registerSectionManageTools } from './tools/section-manage.js';
18
19
  import { registerUploadTools } from './tools/uploads.js';
19
20
  import { registerSessionTools } from './tools/session.js';
20
21
  // The ViboClient is a module-level singleton (constructed in client.ts and
@@ -44,6 +45,7 @@ await runMcp({
44
45
  registerImportTools,
45
46
  registerCollaborationTools,
46
47
  registerSectionEditTools,
48
+ registerSectionManageTools,
47
49
  registerUploadTools,
48
50
  registerSessionTools,
49
51
  ],
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `order` is the current list; `sources` are moved (in the given order) to sit
3
+ * directly after `after`, or at the start when `after` is null. Callers
4
+ * validate ids first: every source and `after` must be in `order`, and
5
+ * `after` must not be a source.
6
+ */
7
+ export function planMoves(order, sources, after) {
8
+ let current = [...order];
9
+ let anchor = after;
10
+ const moves = [];
11
+ for (const source of sources) {
12
+ const from = current.indexOf(source);
13
+ const rest = current.filter((id) => id !== source);
14
+ const to = anchor === null ? 0 : rest.indexOf(anchor) + 1;
15
+ if (to !== from) {
16
+ moves.push({ source, target: anchor });
17
+ current = [...rest.slice(0, to), source, ...rest.slice(to)];
18
+ }
19
+ anchor = source;
20
+ }
21
+ return { moves, finalOrder: current };
22
+ }
23
+ /** Ids from `wanted` that are not in `have`, preserving order. */
24
+ export function missingIds(wanted, have) {
25
+ const set = new Set(have);
26
+ return wanted.filter((id) => !set.has(id));
27
+ }
@@ -0,0 +1,59 @@
1
+ import { McpToolError } from '@chrischall/mcp-utils';
2
+ import { SECTIONS_FOR_WRITE, EVENT_PERMISSIONS, SECTION_SONG_IDS } from '../gql.js';
3
+ /** The event's timeline, in order. */
4
+ export async function fetchSections(client, eventId) {
5
+ const data = await client.gql(SECTIONS_FOR_WRITE, { eventId });
6
+ return data.sections ?? [];
7
+ }
8
+ export async function fetchEventPermissions(client, eventId) {
9
+ const data = await client.gql(EVENT_PERMISSIONS, { eventId });
10
+ return data.event;
11
+ }
12
+ export function findSection(sections, sectionId) {
13
+ const section = sections.find((s) => s._id === sectionId);
14
+ if (!section) {
15
+ throw new McpToolError(`Section ${sectionId} is not in this event's timeline.`, {
16
+ hint: 'Get section ids from vibo_list_sections for the same eventId.',
17
+ });
18
+ }
19
+ return section;
20
+ }
21
+ /** Every song in a section, in its manual order (no sort applied). */
22
+ export async function fetchSectionSongs(client, eventId, sectionId) {
23
+ const songs = [];
24
+ const limit = 100;
25
+ // Bounded: 50 pages × 100 is far beyond Vibo's per-section song limit (999).
26
+ for (let page = 0; page < 50; page++) {
27
+ const data = await client.gql(SECTION_SONG_IDS, { eventId, sectionId, pagination: { skip: songs.length, limit } });
28
+ const batch = data.getSectionSongs?.songs ?? [];
29
+ songs.push(...batch);
30
+ const total = data.getSectionSongs?.totalCount;
31
+ if (batch.length < limit || (typeof total === 'number' && songs.length >= total))
32
+ break;
33
+ }
34
+ return songs;
35
+ }
36
+ /**
37
+ * Refuse, before the confirmation step, song ids that aren't in the section.
38
+ * Names a passed viboSongId's section-song _id, the usual mix-up.
39
+ */
40
+ export function assertSongsInSection(songIds, songs) {
41
+ const have = new Set(songs.map((s) => s._id));
42
+ const missing = songIds.filter((id) => !have.has(id));
43
+ if (missing.length === 0)
44
+ return;
45
+ const hints = missing
46
+ .map((id) => {
47
+ const byVibo = songs.find((s) => s.viboSongId === id);
48
+ return byVibo ? `${id} is a viboSongId — its section-song _id is ${byVibo._id}` : null;
49
+ })
50
+ .filter(Boolean);
51
+ throw new McpToolError(`${missing.length} of ${songIds.length} song id(s) are not in this section: ${missing.join(', ')}. Nothing was sent.`, {
52
+ hint: (hints.length ? hints.join('; ') + '. ' : '') +
53
+ 'Use the song _id values from vibo_get_section_songs for this same section.',
54
+ });
55
+ }
56
+ /** True when the caller is a host (not the DJ) — the role Vibo's host-permission toggles apply to. */
57
+ export function isHost(perms) {
58
+ return perms.role === 'host';
59
+ }