vibo-mcp 2.3.0 → 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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +50 -1
- package/dist/auth.js +7 -3
- package/dist/bundle.js +693 -86
- package/dist/client.js +13 -1
- package/dist/gql.js +50 -0
- package/dist/index.js +2 -0
- package/dist/reorder.js +27 -0
- package/dist/tools/collaboration.js +19 -7
- 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/version.js +1 -1
- package/dist/view.js +13 -0
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/vibo-mcp/SKILL.md +17 -5
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) =>
|
|
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
|
],
|
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
|
+
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { minifiedResult, confirmTokenParam, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
|
+
import { minifiedResult, confirmTokenParam, resolveView, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
3
|
import { LIST_EVENT_USERS, INVITE_USERS, CHANGE_USER_ROLE, REMOVE_USER } from '../gql.js';
|
|
4
4
|
import { limitSchema, skipSchema, pagination, confirmWrite, CONFIRM_NOTE } from './shared.js';
|
|
5
|
-
import {
|
|
5
|
+
import { eventUsersViewArg, viewResponse, VIBO_VIEWS } from '../view.js';
|
|
6
|
+
function membersForView(view, users) {
|
|
7
|
+
if (resolveView(view, VIBO_VIEWS) !== 'compact')
|
|
8
|
+
return users;
|
|
9
|
+
return users.map((u) => {
|
|
10
|
+
const { _id, firstName, lastName, role } = (u ?? {});
|
|
11
|
+
return { _id, firstName, lastName, role };
|
|
12
|
+
});
|
|
13
|
+
}
|
|
6
14
|
export function registerCollaborationTools(server, client) {
|
|
7
15
|
server.registerTool('vibo_list_event_users', {
|
|
8
|
-
description: "List the hosts and guests on an event. With no usersType, returns both groups merged ({hosts, guests, hostsCount, guestsCount}) and `limit`/`skip` apply per group; with usersType, returns that one group's page.",
|
|
16
|
+
description: "List the hosts and guests on an event. With no usersType, returns both groups merged ({hosts, guests, hostsCount, guestsCount}) and `limit`/`skip` apply per group; with usersType, returns that one group's page. Default (compact) view returns each member's id, name and role only; pass view:'full' for email addresses and avatars.",
|
|
9
17
|
annotations: toolAnnotations({ title: 'List Vibo event users', readOnly: true }),
|
|
10
18
|
inputSchema: z.object({
|
|
11
19
|
eventId: z.string().describe('Event id.'),
|
|
12
20
|
usersType: z.enum(['host', 'guest']).optional().describe('Filter to only hosts or only guests.'),
|
|
13
|
-
view:
|
|
21
|
+
view: eventUsersViewArg(),
|
|
14
22
|
limit: limitSchema.describe('Max items to return (default 20). Applies per group when usersType is omitted.'),
|
|
15
23
|
skip: skipSchema,
|
|
16
24
|
}),
|
|
@@ -22,7 +30,11 @@ export function registerCollaborationTools(server, client) {
|
|
|
22
30
|
usersType,
|
|
23
31
|
pagination: page,
|
|
24
32
|
});
|
|
25
|
-
return viewResponse(view, {
|
|
33
|
+
return viewResponse(view, {
|
|
34
|
+
...data.eventUsers,
|
|
35
|
+
users: membersForView(view, data.eventUsers.users),
|
|
36
|
+
usersType,
|
|
37
|
+
});
|
|
26
38
|
}
|
|
27
39
|
// The API returns nothing unless usersType is set, so fetch both groups
|
|
28
40
|
// and merge for the intuitive "everyone on the event" listing.
|
|
@@ -34,8 +46,8 @@ export function registerCollaborationTools(server, client) {
|
|
|
34
46
|
// when `usersType` is omitted — the default call — so honouring `view` on
|
|
35
47
|
// only the filtered branch would leave the common path paying full price.
|
|
36
48
|
return viewResponse(view, {
|
|
37
|
-
hosts: hosts.eventUsers.users,
|
|
38
|
-
guests: guests.eventUsers.users,
|
|
49
|
+
hosts: membersForView(view, hosts.eventUsers.users),
|
|
50
|
+
guests: membersForView(view, guests.eventUsers.users),
|
|
39
51
|
hostsCount: hosts.eventUsers.totalCount,
|
|
40
52
|
guestsCount: guests.eventUsers.totalCount,
|
|
41
53
|
});
|
|
@@ -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
|
+
}
|
package/dist/tools/session.js
CHANGED
|
@@ -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
|
|
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();
|
package/dist/tools/shared.js
CHANGED
|
@@ -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 = {
|
|
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
|
}));
|