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.
@@ -0,0 +1,288 @@
1
+ import { z } from 'zod';
2
+ import { McpToolError, minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { CREATE_SECTION, REMOVE_SECTION, REORDER_SECTIONS, UPDATE_SECTION } from '../gql.js';
4
+ import { planMoves, missingIds } from '../reorder.js';
5
+ import { confirmWrite, CONFIRM_NOTE } from './shared.js';
6
+ import { fetchSections, fetchEventPermissions, findSection, isHost, } from './lookups.js';
7
+ /**
8
+ * The web app's section-name limit (`nameMustBeCharactersOrLess`, count 45).
9
+ * Vibo's API stores longer names (60 was accepted live), so this keeps names
10
+ * the web UI can still edit.
11
+ */
12
+ export const SECTION_NAME_MAX = 45;
13
+ /**
14
+ * The web app's "Add section" defaults (its create-form initial values), so a
15
+ * section made here behaves like one made in the UI.
16
+ */
17
+ const DEFAULT_SECTION_SETTINGS = {
18
+ timeEnabled: true,
19
+ songsEnabled: true,
20
+ notesEnabled: true,
21
+ canHostsOrderSongs: true,
22
+ canHostDeleteSection: true,
23
+ notesVisibleForHosts: true,
24
+ canHostChangeSectionName: true,
25
+ canHostChangeSectionTime: true,
26
+ };
27
+ /** The UI's "Who will see this section" choices → settings, verbatim from its bundle. */
28
+ const VISIBILITY_SETTINGS = {
29
+ host: { visibleForGuests: false, visibleForHosts: true }, // "Me and DJ"
30
+ public: { visibleForGuests: true, visibleForHosts: true }, // "Guests"
31
+ };
32
+ /** Section types a delete must be forced for: the DJ's built-in list and timeline dividers. */
33
+ const PROTECTED_TYPES = new Set(['dontPlay', 'headline']);
34
+ const TIME_PATTERN = /^(0?[1-9]|1[0-2]):([0-5]\d)\s*([ap])\.?m\.?$/i;
35
+ /** "5:00 PM" / "05:00pm" → "05:00 pm" (the form Vibo stores); undefined when not a 12-hour time. */
36
+ export function normalizeTime(time) {
37
+ const m = TIME_PATTERN.exec(time.trim());
38
+ if (!m)
39
+ return undefined;
40
+ return `${m[1].padStart(2, '0')}:${m[2]} ${m[3].toLowerCase()}m`;
41
+ }
42
+ function assertCanWriteTimeline(perms, flag, what) {
43
+ if (!isHost(perms))
44
+ return;
45
+ if (perms.isLocked) {
46
+ throw new McpToolError(`This event is locked, so hosts can't ${what}. Nothing was sent.`, {
47
+ hint: 'The DJ locks an event close to the date; ask them to unlock it or make the change.',
48
+ });
49
+ }
50
+ if (perms.settings?.[flag] === false) {
51
+ throw new McpToolError(`The DJ has turned off "${flag}" for this event, so hosts can't ${what}. Nothing was sent.`, {
52
+ hint: "It's an event setting only the DJ can change — ask them to enable it or make the change.",
53
+ });
54
+ }
55
+ }
56
+ const sectionLabel = (s) => ({ _id: s._id, name: s.name });
57
+ export function registerSectionManageTools(server, client) {
58
+ server.registerTool('vibo_create_section', {
59
+ description: `Create a new timeline section. Name is required, max ${SECTION_NAME_MAX} characters (Vibo's limit, checked ` +
60
+ 'before anything is sent). Place it with afterSectionId (directly after that section) or position ' +
61
+ '(0-based index; 0 = first); with neither it goes at the end of the timeline, like the web app. ' +
62
+ 'visibility "host" (default) = "Me and DJ", "public" = visible to guests too. Vibo gives a section a host ' +
63
+ "creates \"hosts can order songs\" = off, which only the DJ can turn on. Returns the new section _id. " +
64
+ CONFIRM_NOTE,
65
+ annotations: toolAnnotations({ title: 'Create Vibo section', readOnly: false, destructive: false }),
66
+ inputSchema: z.object({
67
+ eventId: z.string().describe('Event id.'),
68
+ name: z.string().describe(`Section name, 1–${SECTION_NAME_MAX} characters.`),
69
+ visibility: z
70
+ .enum(['host', 'public'])
71
+ .optional()
72
+ .describe('"host" (default) = Me and DJ only; "public" = guests can see it too.'),
73
+ time: z.string().optional().describe('Scheduled time as "hh:mm am/pm", e.g. "05:30 pm".'),
74
+ note: z.string().optional().describe('Note to the DJ (set right after creating, via updateSection).'),
75
+ afterSectionId: z.string().optional().describe('Put the new section directly after this section _id.'),
76
+ position: z
77
+ .number()
78
+ .int()
79
+ .min(0)
80
+ .optional()
81
+ .describe('0-based timeline index for the new section; past the end appends. Use instead of afterSectionId.'),
82
+ confirmToken: confirmTokenParam,
83
+ }),
84
+ }, async ({ eventId, name, visibility, time, note, afterSectionId, position, confirmToken }, ctx) => {
85
+ const trimmed = name.trim();
86
+ if (!trimmed) {
87
+ throw new McpToolError('Section name is empty.', { hint: 'Pass a name of 1–45 characters.' });
88
+ }
89
+ if (trimmed.length > SECTION_NAME_MAX) {
90
+ throw new McpToolError(`Section name is ${trimmed.length} characters; Vibo allows at most ${SECTION_NAME_MAX}. Nothing was sent.`, { hint: `Shorten the name to ${SECTION_NAME_MAX} characters or fewer.` });
91
+ }
92
+ let normalizedTime;
93
+ if (time !== undefined && time.trim() !== '') {
94
+ normalizedTime = normalizeTime(time);
95
+ if (!normalizedTime) {
96
+ throw new McpToolError(`"${time}" is not a 12-hour time.`, { hint: 'Use "hh:mm am/pm", e.g. "05:30 pm".' });
97
+ }
98
+ }
99
+ if (afterSectionId !== undefined && position !== undefined) {
100
+ throw new McpToolError('Pass afterSectionId or position, not both.', {
101
+ hint: 'afterSectionId places it after a named section; position places it at a 0-based index.',
102
+ });
103
+ }
104
+ const [perms, sections] = await Promise.all([fetchEventPermissions(client, eventId), fetchSections(client, eventId)]);
105
+ assertCanWriteTimeline(perms, 'canHostCreateSections', 'add sections');
106
+ // Resolve placement to the one knob createSection has: insertBeforeSectionId.
107
+ let insertBefore;
108
+ let placedAfter; // null = start of the timeline
109
+ if (afterSectionId !== undefined) {
110
+ const anchor = findSection(sections, afterSectionId);
111
+ insertBefore = sections[sections.indexOf(anchor) + 1];
112
+ placedAfter = anchor;
113
+ }
114
+ else if (position !== undefined) {
115
+ insertBefore = sections[position];
116
+ placedAfter = position === 0 ? null : (sections[Math.min(position, sections.length) - 1] ?? null);
117
+ }
118
+ else {
119
+ placedAfter = sections[sections.length - 1] ?? null;
120
+ }
121
+ const payload = {
122
+ type: 'simple',
123
+ name: trimmed,
124
+ settings: {
125
+ ...DEFAULT_SECTION_SETTINGS,
126
+ ...(perms.settings?.sectionSongsLimit != null ? { songsLimit: perms.settings.sectionSongsLimit } : {}),
127
+ ...(perms.settings?.sectionMustPlayLimit != null ? { mustPlayLimit: perms.settings.sectionMustPlayLimit } : {}),
128
+ ...VISIBILITY_SETTINGS[visibility ?? 'host'],
129
+ },
130
+ };
131
+ if (normalizedTime)
132
+ payload.time = normalizedTime;
133
+ if (insertBefore)
134
+ payload.insertBeforeSectionId = insertBefore._id;
135
+ const willSend = { eventId, payload };
136
+ if (note !== undefined)
137
+ willSend.thenUpdateSection = { payload: { note } };
138
+ const gate = await confirmWrite(ctx, {
139
+ tool: 'vibo_create_section',
140
+ mutation: note !== undefined ? 'createSection, then updateSection (note)' : 'createSection',
141
+ message: `Review and confirm creating section "${trimmed}":`,
142
+ confirmToken,
143
+ target: eventId,
144
+ willSend,
145
+ context: { placedAfter: placedAfter ? sectionLabel(placedAfter) : 'start of timeline' },
146
+ });
147
+ if (gate)
148
+ return gate;
149
+ const created = (await client.gql(CREATE_SECTION, {
150
+ eventId,
151
+ payload,
152
+ })).createSection;
153
+ let noteResult;
154
+ if (note !== undefined) {
155
+ noteResult = (await client.gql(UPDATE_SECTION, {
156
+ eventId,
157
+ sectionId: created._id,
158
+ payload: { note },
159
+ })).updateSection?.note;
160
+ }
161
+ // Belt and braces: if Vibo ever ignores insertBeforeSectionId (it honoured
162
+ // it live), move the section into place. A stale re-read that doesn't show
163
+ // the new section yet is reported, not guessed at.
164
+ let placement = 'unverified';
165
+ const after = await fetchSections(client, eventId);
166
+ const order = after.map((s) => s._id);
167
+ if (order.includes(created._id) && (!placedAfter || order.includes(placedAfter._id))) {
168
+ const { moves } = planMoves(order, [created._id], placedAfter?._id ?? null);
169
+ for (const m of moves) {
170
+ await client.gql(REORDER_SECTIONS, { eventId, sourceSectionId: m.source, targetSectionId: m.target });
171
+ }
172
+ placement = moves.length ? 'fixed-by-reorder' : 'as-requested';
173
+ }
174
+ return minifiedResult({
175
+ ...created,
176
+ ...(note !== undefined ? { note: noteResult ?? note } : {}),
177
+ placedAfter: placedAfter ? sectionLabel(placedAfter) : null,
178
+ placement,
179
+ });
180
+ });
181
+ server.registerTool('vibo_delete_section', {
182
+ description: 'Delete a timeline section, with every song, answer and comment in it — this cannot be undone. The preview ' +
183
+ 'names the section and how many songs and answered questions it holds. Sections of type "dontPlay" (the ' +
184
+ "DJ's do-not-play list) and \"headline\" (timeline dividers) are refused unless force:true. " +
185
+ CONFIRM_NOTE,
186
+ annotations: toolAnnotations({ title: 'Delete Vibo section', readOnly: false, destructive: true }),
187
+ inputSchema: z.object({
188
+ eventId: z.string().describe('Event id.'),
189
+ sectionId: z.string().describe('Section _id from vibo_list_sections.'),
190
+ force: z.boolean().optional().describe('Required to delete a "dontPlay" or "headline" section.'),
191
+ confirmToken: confirmTokenParam,
192
+ }),
193
+ }, async ({ eventId, sectionId, force, confirmToken }, ctx) => {
194
+ const section = findSection(await fetchSections(client, eventId), sectionId);
195
+ if (PROTECTED_TYPES.has(section.type) && !force) {
196
+ throw new McpToolError(`"${section.name}" is a ${section.type} section${section.type === 'dontPlay' ? " (the DJ's do-not-play list)" : ' (a timeline divider)'}; refusing to delete it. Nothing was sent.`, { hint: 'Pass force:true only if the user explicitly wants this section gone.' });
197
+ }
198
+ if (section.canRemove === false) {
199
+ throw new McpToolError(`Vibo reports you can't delete "${section.name}". Nothing was sent.`, {
200
+ hint: 'The DJ controls who may delete each section ("Who can delete section"); ask them to allow it or delete it.',
201
+ });
202
+ }
203
+ const summary = {
204
+ name: section.name,
205
+ type: section.type,
206
+ songsCount: section.songsCount,
207
+ questionsAnswered: `${section.answeredCount}/${section.questionsCount}`,
208
+ };
209
+ const vars = { eventId, sectionId };
210
+ const gate = await confirmWrite(ctx, {
211
+ tool: 'vibo_delete_section',
212
+ mutation: 'removeSection',
213
+ message: `Delete section "${section.name}"? It holds ${section.songsCount} song(s) and ` +
214
+ `${section.answeredCount} of ${section.questionsCount} answered question(s). This cannot be undone.`,
215
+ confirmToken,
216
+ target: sectionId,
217
+ willSend: vars,
218
+ context: summary,
219
+ });
220
+ if (gate)
221
+ return gate;
222
+ const data = await client.gql(REMOVE_SECTION, vars);
223
+ if (data.removeSection !== true) {
224
+ throw new McpToolError(`Vibo did not confirm deleting "${section.name}" (removeSection returned ${String(data.removeSection)}).`, {
225
+ hint: 'Re-check with vibo_list_sections before retrying.',
226
+ });
227
+ }
228
+ return minifiedResult({ deleted: true, section: { _id: sectionId, ...summary } });
229
+ });
230
+ server.registerTool('vibo_reorder_sections', {
231
+ description: 'Move one or more timeline sections to directly after targetSectionId (omit it to move them to the start), ' +
232
+ 'keeping the given order. Mirrors vibo_reorder_songs. Sends one reorderSections call per section that ' +
233
+ 'actually moves. ' +
234
+ CONFIRM_NOTE,
235
+ annotations: toolAnnotations({ title: 'Reorder Vibo sections', readOnly: false, destructive: false }),
236
+ inputSchema: z.object({
237
+ eventId: z.string().describe('Event id.'),
238
+ sourceSectionIds: z.array(z.string()).min(1).describe('Section _ids to move, in the order they should end up.'),
239
+ targetSectionId: z.string().optional().describe('Place the moved sections right after this section _id; omit for the start.'),
240
+ confirmToken: confirmTokenParam,
241
+ }),
242
+ }, async ({ eventId, sourceSectionIds, targetSectionId, confirmToken }, ctx) => {
243
+ if (new Set(sourceSectionIds).size !== sourceSectionIds.length) {
244
+ throw new McpToolError('sourceSectionIds contains a duplicate.', { hint: 'List each section once.' });
245
+ }
246
+ if (targetSectionId !== undefined && sourceSectionIds.includes(targetSectionId)) {
247
+ throw new McpToolError('targetSectionId is also one of the sections being moved.', {
248
+ hint: 'Pick a target outside sourceSectionIds (the moved sections keep their given order).',
249
+ });
250
+ }
251
+ const [perms, sections] = await Promise.all([fetchEventPermissions(client, eventId), fetchSections(client, eventId)]);
252
+ const order = sections.map((s) => s._id);
253
+ const missing = missingIds([...sourceSectionIds, ...(targetSectionId ? [targetSectionId] : [])], order);
254
+ if (missing.length) {
255
+ throw new McpToolError(`Not in this event's timeline: ${missing.join(', ')}. Nothing was sent.`, {
256
+ hint: 'Get section ids from vibo_list_sections for the same eventId.',
257
+ });
258
+ }
259
+ assertCanWriteTimeline(perms, 'canHostReorderSections', 'reorder sections');
260
+ const byId = new Map(sections.map((s) => [s._id, s]));
261
+ const { moves, finalOrder } = planMoves(order, sourceSectionIds, targetSectionId ?? null);
262
+ const after = targetSectionId ? sectionLabel(byId.get(targetSectionId)) : 'start of timeline';
263
+ if (moves.length === 0) {
264
+ return minifiedResult({ changed: false, message: 'Those sections are already in that order.', after });
265
+ }
266
+ const calls = moves.map((m) => ({ sourceSectionId: m.source, targetSectionId: m.target }));
267
+ const gate = await confirmWrite(ctx, {
268
+ tool: 'vibo_reorder_sections',
269
+ mutation: 'reorderSections',
270
+ message: `Review and confirm moving ${sourceSectionIds.length} section(s):`,
271
+ confirmToken,
272
+ target: eventId,
273
+ willSend: { eventId, calls },
274
+ context: { moving: sourceSectionIds.map((id) => byId.get(id).name), after },
275
+ });
276
+ if (gate)
277
+ return gate;
278
+ for (const c of calls) {
279
+ await client.gql(REORDER_SECTIONS, { eventId, ...c });
280
+ }
281
+ return minifiedResult({
282
+ changed: true,
283
+ callsSent: calls.length,
284
+ after,
285
+ newPositions: sourceSectionIds.map((id) => ({ ...sectionLabel(byId.get(id)), index: finalOrder.indexOf(id) })),
286
+ });
287
+ });
288
+ }
@@ -7,7 +7,7 @@ import { GET_ME } from '../gql.js';
7
7
  // exist — a deployment without them should not wire it in.
8
8
  export function registerSessionTools(server, client) {
9
9
  server.registerTool('vibo_capture_session', {
10
- description: "Capture your Vibo login from a signed-in web.vibodj.com browser tab via the fetchproxy bridge — for accounts that sign in with Apple/Google/Facebook (no password). Requires the fetchproxy browser extension installed and you signed into https://web.vibodj.com; approve the pair code shown on first use. The token is saved locally and reused on future calls.",
10
+ description: "Capture your Vibo login from a signed-in web.vibodj.com browser tab via ContextMint Bridge — for accounts that sign in with Apple/Google/Facebook (no password). Requires the ContextMint Bridge browser extension installed and you signed into https://web.vibodj.com; approve the pair code shown on first use. The token is saved locally and reused on future calls.",
11
11
  annotations: toolAnnotations({ title: 'Capture Vibo session (SSO)', readOnly: false, destructive: false }),
12
12
  }, async () => {
13
13
  const { accessToken, refreshToken } = await captureViboSession();
@@ -37,10 +37,15 @@ export const CONFIRM_NOTE = 'Asks the user to confirm first: a confirmation prom
37
37
  * Gate a write behind a confirmation: an elicitation prompt where the client
38
38
  * can show one, else the two-phase confirm-token flow (MCP_CONFIRM_MODE).
39
39
  * `undefined` means proceed; anything else is the result to return unchanged.
40
- * Nothing here makes a network call, so phase 1 never writes.
40
+ * Nothing here makes a network call, so phase 1 never writes (a tool may READ
41
+ * before calling this, to validate ids or fill `context`).
41
42
  */
42
43
  export function confirmWrite(ctx, options) {
43
- const preview = { action: options.mutation, willSend: options.willSend };
44
+ const preview = {
45
+ action: options.mutation,
46
+ willSend: options.willSend,
47
+ ...(options.context ? { context: options.context } : {}),
48
+ };
44
49
  return requireConfirmationWithFallback(ctx, confirmationFromEnv({
45
50
  action: options.tool.replace(/^vibo_/, 'vibo.'),
46
51
  message: options.message,
@@ -49,7 +54,7 @@ export function confirmWrite(ctx, options) {
49
54
  confirmToken: options.confirmToken,
50
55
  subject: () => ({
51
56
  target: options.target,
52
- payload: options.payload ?? options.willSend,
57
+ payload: options.payload ?? (options.context ? { willSend: options.willSend, context: options.context } : options.willSend),
53
58
  preview,
54
59
  }),
55
60
  }));
@@ -1,10 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { REMOVE_SECTION_SONGS, UPDATE_SECTION_SONGS, MOVE_SECTION_SONGS, REORDER_SONGS } from '../gql.js';
4
+ import { planMoves } from '../reorder.js';
4
5
  import { confirmWrite, CONFIRM_NOTE } from './shared.js';
6
+ import { fetchSections, fetchEventPermissions, fetchSectionSongs, findSection, assertSongsInSection, isHost, } from './lookups.js';
7
+ /**
8
+ * Vibo's song-comment limit, measured live: 90 is stored, 91 is refused with
9
+ * "Comment should be less then 90 characters". Counted in UTF-16 units (an
10
+ * emoji is 2), which is what String#length gives.
11
+ */
12
+ export const SONG_COMMENT_MAX = 90;
5
13
  export function registerSongManagementTools(server, client) {
6
14
  server.registerTool('vibo_remove_song_from_section', {
7
- description: 'Remove one or more songs from a section. ' + CONFIRM_NOTE,
15
+ description: 'Remove one or more songs from a section. Every id is checked against the section first; if any is not ' +
16
+ 'there, nothing is sent and the missing ids are listed. ' + CONFIRM_NOTE,
8
17
  annotations: toolAnnotations({ title: 'Remove songs from Vibo section', readOnly: false, destructive: true }),
9
18
  inputSchema: z.object({
10
19
  eventId: z.string().describe('Event id.'),
@@ -16,6 +25,10 @@ export function registerSongManagementTools(server, client) {
16
25
  confirmToken: confirmTokenParam,
17
26
  }),
18
27
  }, async ({ eventId, sectionId, songIds, confirmToken }, ctx) => {
28
+ // Vibo answers success:true for ids that aren't in the section and removes
29
+ // nothing, so check them ourselves before asking the user to confirm.
30
+ const songs = await fetchSectionSongs(client, eventId, sectionId);
31
+ assertSongsInSection(songIds, songs);
19
32
  const vars = { eventId, sectionId, songIds };
20
33
  const gate = await confirmWrite(ctx, {
21
34
  tool: 'vibo_remove_song_from_section',
@@ -24,6 +37,12 @@ export function registerSongManagementTools(server, client) {
24
37
  confirmToken,
25
38
  target: sectionId,
26
39
  willSend: vars,
40
+ context: {
41
+ songs: songIds.map((id) => {
42
+ const s = songs.find((x) => x._id === id);
43
+ return `${s.title ?? '?'} — ${s.artist ?? '?'}`;
44
+ }),
45
+ },
27
46
  });
28
47
  if (gate)
29
48
  return gate;
@@ -31,7 +50,9 @@ export function registerSongManagementTools(server, client) {
31
50
  return minifiedResult(data.removeSectionSongsV2);
32
51
  });
33
52
  server.registerTool('vibo_update_song', {
34
- description: 'Update songs in a section: mark must-play, flag as do-not-play, and/or set a comment. Provide at least one field. ' + CONFIRM_NOTE,
53
+ description: 'Update songs in a section: mark must-play, flag as do-not-play, and/or set a comment. Provide at least one field. ' +
54
+ `A comment can be at most ${SONG_COMMENT_MAX} characters (Vibo's limit; emoji count double; checked before anything is sent). ` +
55
+ CONFIRM_NOTE,
35
56
  annotations: toolAnnotations({ title: 'Update Vibo section songs', readOnly: false, destructive: false }),
36
57
  inputSchema: z.object({
37
58
  eventId: z.string().describe('Event id.'),
@@ -42,7 +63,7 @@ export function registerSongManagementTools(server, client) {
42
63
  .describe('Song _ids from vibo_get_section_songs.'),
43
64
  isMustPlay: z.boolean().optional(),
44
65
  isFlagged: z.boolean().optional().describe('mark do-not-play / flagged'),
45
- comment: z.string().optional(),
66
+ comment: z.string().optional().describe(`Comment for the DJ, at most ${SONG_COMMENT_MAX} characters.`),
46
67
  confirmToken: confirmTokenParam,
47
68
  }),
48
69
  }, async ({ eventId, sectionId, songIds, isMustPlay, isFlagged, comment, confirmToken }, ctx) => {
@@ -58,6 +79,9 @@ export function registerSongManagementTools(server, client) {
58
79
  hint: 'Pass isMustPlay, isFlagged, and/or comment to update the songs.',
59
80
  });
60
81
  }
82
+ if (comment !== undefined && comment.length > SONG_COMMENT_MAX) {
83
+ throw new McpToolError(`Comment is ${comment.length} characters; Vibo allows at most ${SONG_COMMENT_MAX}. Nothing was sent.`, { hint: `Shorten it to ${SONG_COMMENT_MAX} characters or fewer, or put the longer text in a section note.` });
84
+ }
61
85
  const vars = { eventId, sectionId, songIds, payload };
62
86
  const gate = await confirmWrite(ctx, {
63
87
  tool: 'vibo_update_song',
@@ -101,7 +125,12 @@ export function registerSongManagementTools(server, client) {
101
125
  return minifiedResult(data.moveSectionSongsV2);
102
126
  });
103
127
  server.registerTool('vibo_reorder_songs', {
104
- description: 'Reorder songs within a section. ' + CONFIRM_NOTE,
128
+ description: 'Reorder songs within a section: move sourceSongIds (in the given order) to directly after targetSongId, ' +
129
+ 'or to the top when it is omitted. Checks the ids against the section and, for a host, that the DJ allows ' +
130
+ "hosts to order this section's songs. That setting is OFF for every section a host creates and only the " +
131
+ 'DJ can turn it on, so a host usually cannot reorder songs in sections they added. Sends one ' +
132
+ 'reorderSongsBatch call per song that actually moves. ' +
133
+ CONFIRM_NOTE,
105
134
  annotations: toolAnnotations({ title: 'Reorder Vibo section songs', readOnly: false, destructive: false }),
106
135
  inputSchema: z.object({
107
136
  eventId: z.string().describe('Event id.'),
@@ -109,26 +138,57 @@ export function registerSongManagementTools(server, client) {
109
138
  sourceSongIds: z
110
139
  .array(z.string())
111
140
  .min(1)
112
- .describe('Song _ids from vibo_get_section_songs.'),
141
+ .describe('Song _ids from vibo_get_section_songs, in the order they should end up.'),
113
142
  targetSongId: z
114
143
  .string()
115
144
  .optional()
116
- .describe('place the moved songs after this song _id; omit for start'),
145
+ .describe('place the moved songs directly after this song _id; omit for the top'),
117
146
  confirmToken: confirmTokenParam,
118
147
  }),
119
148
  }, async ({ eventId, sectionId, sourceSongIds, targetSongId, confirmToken }, ctx) => {
120
- const vars = { eventId, sectionId, sourceSongIds, targetSongId: targetSongId ?? null };
149
+ if (new Set(sourceSongIds).size !== sourceSongIds.length) {
150
+ throw new McpToolError('sourceSongIds contains a duplicate.', { hint: 'List each song once.' });
151
+ }
152
+ if (targetSongId !== undefined && sourceSongIds.includes(targetSongId)) {
153
+ throw new McpToolError('targetSongId is also one of the songs being moved.', {
154
+ hint: 'Pick a target outside sourceSongIds.',
155
+ });
156
+ }
157
+ const [perms, sections, songs] = await Promise.all([
158
+ fetchEventPermissions(client, eventId),
159
+ fetchSections(client, eventId),
160
+ fetchSectionSongs(client, eventId, sectionId),
161
+ ]);
162
+ const section = findSection(sections, sectionId);
163
+ // Vibo refuses a host's reorder with "Action is not allowed for user" when
164
+ // the section's "hosts can order songs" is off — which it is for every
165
+ // section a host creates (measured live; a host's attempt to turn it on
166
+ // is accepted and ignored). The web app hides drag handles there too.
167
+ if (isHost(perms) && section.settings?.canHostsOrderSongs === false) {
168
+ throw new McpToolError(`The DJ has turned off host song ordering for "${section.name}", so Vibo refuses reorders from hosts there. Nothing was sent.`, {
169
+ hint: "It's the section's canHostsOrderSongs setting. Vibo turns it off for sections a host creates, and only the DJ " +
170
+ 'can turn it on (a host update is ignored). Ask the DJ, or leave a comment on the song instead.',
171
+ });
172
+ }
173
+ assertSongsInSection([...sourceSongIds, ...(targetSongId ? [targetSongId] : [])], songs);
174
+ const { moves } = planMoves(songs.map((s) => s._id), sourceSongIds, targetSongId ?? null);
175
+ if (moves.length === 0) {
176
+ return minifiedResult({ changed: false, message: 'Those songs are already in that order.' });
177
+ }
178
+ const calls = moves.map((m) => ({ sourceSongIds: [m.source], targetSongId: m.target }));
121
179
  const gate = await confirmWrite(ctx, {
122
180
  tool: 'vibo_reorder_songs',
123
181
  mutation: 'reorderSongsBatch',
124
182
  message: 'Review and confirm this reorder:',
125
183
  confirmToken,
126
184
  target: sectionId,
127
- willSend: vars,
185
+ willSend: { eventId, sectionId, calls },
128
186
  });
129
187
  if (gate)
130
188
  return gate;
131
- const data = await client.gql(REORDER_SONGS, vars);
132
- return minifiedResult(data.reorderSongsBatch);
189
+ for (const c of calls) {
190
+ await client.gql(REORDER_SONGS, { eventId, sectionId, ...c });
191
+ }
192
+ return minifiedResult({ changed: true, callsSent: calls.length });
133
193
  });
134
194
  }
@@ -1,9 +1,17 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { viewArg, viewResponse } from '../view.js';
4
4
  import { GET_SECTION_SONGS, SEARCH_SONGS, ADD_SONG_TO_SECTION, TOGGLE_LIKE } from '../gql.js';
5
5
  import { annotateSearchResults } from '../song-search.js';
6
6
  import { limitSchema, skipSchema, pagination, confirmWrite, CONFIRM_NOTE } from './shared.js';
7
+ import { fetchSectionSongs } from './lookups.js';
8
+ /**
9
+ * Waits before each post-add re-read. Vibo's re-reads can be briefly stale
10
+ * (CLAUDE.md, "Write-verification re-reads can be cached"), so one miss is not
11
+ * proof; three spread over ~2s is. Exported so tests can zero them.
12
+ */
13
+ export const addVerifyDelaysMs = [0, 700, 1500];
14
+ const sleep = (ms) => (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve());
7
15
  export function registerSongTools(server, client) {
8
16
  server.registerTool('vibo_get_section_songs', {
9
17
  description: "List the songs requested in a section, with who added each, like counts, must-play / do-not-play flags, comments, and streaming links. Sort by likesCount, createdAt, or title.",
@@ -95,7 +103,9 @@ export function registerSongTools(server, client) {
95
103
  'include viboSongId/title/artist when known). Before adding, check that result\'s ' +
96
104
  '`quality.confidence`: adding a `likely-not-original` result puts a cover, karaoke ' +
97
105
  'track or junk-metadata re-upload in front of a live DJ. If nothing looks original, ' +
98
- 'report the closest matches back rather than adding a best guess. ' + CONFIRM_NOTE,
106
+ 'report the closest matches back rather than adding a best guess. After adding, re-reads the section and ' +
107
+ 'reports an error if the song is not actually there (Vibo has answered added:true without adding). ' +
108
+ CONFIRM_NOTE,
99
109
  annotations: toolAnnotations({ title: 'Add song to Vibo section', readOnly: false, destructive: false }),
100
110
  inputSchema: z.object({
101
111
  eventId: z.string().describe('Event id.'),
@@ -130,7 +140,38 @@ export function registerSongTools(server, client) {
130
140
  sectionId,
131
141
  payload,
132
142
  });
133
- return minifiedResult(data.addSongToSection);
143
+ const res = data.addSongToSection ?? {};
144
+ if (res.added === false) {
145
+ throw new McpToolError('Vibo did not add the song (added: false).', {
146
+ hint: 'It may already be in this section, be on the do-not-play list, or the section may be full. ' +
147
+ 'Check with vibo_get_section_songs.',
148
+ });
149
+ }
150
+ // Don't trust added:true alone — confirm the song is really in the section.
151
+ // With no id to match on, say so rather than claim a failure: a false
152
+ // "NOT added" invites a retry that would add the song twice.
153
+ if (!res.songId && !viboSongId) {
154
+ return minifiedResult({
155
+ ...res,
156
+ verified: false,
157
+ note: 'Vibo returned no songId and no viboSongId was passed, so the add could not be checked by re-reading. Check with vibo_get_section_songs before retrying.',
158
+ });
159
+ }
160
+ const isOurs = (s) => (!!res.songId && s._id === res.songId) || (!!viboSongId && s.viboSongId === viboSongId);
161
+ let found;
162
+ for (const delay of addVerifyDelaysMs) {
163
+ await sleep(delay);
164
+ found = (await fetchSectionSongs(client, eventId, sectionId)).find(isOurs);
165
+ if (found)
166
+ break;
167
+ }
168
+ if (!found) {
169
+ throw new McpToolError('Vibo answered added:true, but the song is not in the section when re-read, so it was NOT added.', {
170
+ hint: 'Retry once; if it still fails, add it in the Vibo app. Details: ' +
171
+ JSON.stringify({ songId: res.songId ?? null, viboSongId: viboSongId ?? null, totalCount: res.totalCount }),
172
+ });
173
+ }
174
+ return minifiedResult({ ...res, verified: true, song: found });
134
175
  });
135
176
  server.registerTool('vibo_toggle_song_like', {
136
177
  description: 'Like or unlike a song in a section. ' + CONFIRM_NOTE,