vibo-mcp 2.3.1 → 2.4.0

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
@@ -46,6 +46,13 @@ const REFRESH = `
46
46
  const AUTH_ERROR_CODES = new Set(['UNAUTHORIZED', 'UNAUTHENTICATED']);
47
47
  // Codes for "you are signed in, but not allowed to do this".
48
48
  const PERMISSION_ERROR_CODES = new Set(['FORBIDDEN']);
49
+ // Vibo's permission denial for a host acting where the DJ's settings forbid it
50
+ // ("Action is not allowed for user", e.g. reordering songs in a section with
51
+ // host ordering off — verified live) arrives without a FORBIDDEN code. Anchored
52
+ // on Vibo's exact wording so a validation error that merely says "not allowed"
53
+ // keeps its own message. Matched only when the
54
+ // error carries no auth code, so an expired session is never mistaken for it.
55
+ const PERMISSION_MESSAGE_PATTERN = /\bnot allowed for user\b/i;
49
56
  // Message fallback, consulted ONLY for an error that carries no code. Vibo's
50
57
  // expired-session text is "Not authorized. Try to log in"; the patterns are
51
58
  // anchored on session/token wording so an unrelated message that merely
@@ -382,7 +389,12 @@ export class ViboClient {
382
389
  isPermissionError(status, errors) {
383
390
  if (status === 403)
384
391
  return true;
385
- return (errors ?? []).some((e) => PERMISSION_ERROR_CODES.has(e.code ?? e.extensions?.code ?? ''));
392
+ return (errors ?? []).some((e) => {
393
+ const code = e.code ?? e.extensions?.code ?? '';
394
+ if (PERMISSION_ERROR_CODES.has(code))
395
+ return true;
396
+ return !AUTH_ERROR_CODES.has(code) && PERMISSION_MESSAGE_PATTERN.test(e.message ?? '');
397
+ });
386
398
  }
387
399
  unwrap(status, body) {
388
400
  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
+ }
@@ -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
  }));