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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +54 -2
- package/dist/auth.js +7 -3
- package/dist/bundle.js +2068 -332
- package/dist/client.js +95 -65
- package/dist/gql.js +50 -0
- package/dist/index.js +2 -0
- package/dist/reorder.js +27 -0
- package/dist/tools/lookups.js +59 -0
- package/dist/tools/section-manage.js +288 -0
- package/dist/tools/session.js +1 -1
- package/dist/tools/shared.js +8 -3
- package/dist/tools/song-management.js +70 -10
- package/dist/tools/songs.js +44 -3
- package/dist/upload-source.js +107 -44
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/server.json +2 -2
- package/skills/vibo-mcp/SKILL.md +11 -4
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
79
|
-
* connection
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
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
|
|
96
|
+
function uploadTransportError(err) {
|
|
87
97
|
const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'failed';
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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) =>
|
|
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
|
],
|
package/dist/reorder.js
ADDED
|
@@ -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
|
+
}
|