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.
@@ -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,
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  // literal on the line carrying the release marker; every manifest and the MCP
3
3
  // server banner import VERSION from here, so there is exactly one place to keep
4
4
  // in sync (and one release-please extra-files entry).
5
- export const VERSION = '2.3.0'; // x-release-please-version
5
+ export const VERSION = '2.4.0'; // x-release-please-version
package/dist/view.js CHANGED
@@ -18,13 +18,26 @@ import { minifiedResult, resolveView, stripMediaUrls, viewParam } from '@chrisch
18
18
  * When a real payload can be captured, a field projection belongs here beside
19
19
  * this one and will save considerably more. Until then this is the honest
20
20
  * ceiling, and this docblock says so rather than implying a shape was checked.
21
+ *
22
+ * One exception: `vibo_list_event_users` projects its members to
23
+ * `{_id, firstName, lastName, role}` on compact, because its document's field
24
+ * list is fixed by the query and the dropped `email` is third-party PII
25
+ * (fleet-audit #1136). See `membersForView` in `tools/collaboration.ts`.
21
26
  */
22
27
  export const VIBO_VIEWS = ['compact', 'full'];
23
28
  const NOTE = 'compact strips image/avatar URLs from the response; "full" returns Vibo\'s payload untouched. ' +
24
29
  'No field projection: this server has no verified record of which Vibo fields matter, and inventing ' +
25
30
  'one would risk dropping a field a caller needs.';
31
+ const EVENT_USERS_NOTE = 'compact (default) returns each member as {_id, firstName, lastName, role} only — no email addresses or ' +
32
+ 'avatar URLs; "full" returns Vibo\'s payload untouched, emails included. This document\'s field list is ' +
33
+ 'fixed by its query, so the projection cannot drop an unknown field.';
26
34
  /** The `view` parameter every read tool in this server takes. */
27
35
  export const viewArg = () => viewParam(VIBO_VIEWS, { note: NOTE });
36
+ /**
37
+ * `vibo_list_event_users`' `view` parameter — the one exception to NOTE's
38
+ * "no field projection" (see the docblock above), so its schema says so.
39
+ */
40
+ export const eventUsersViewArg = () => viewParam(VIBO_VIEWS, { note: EVENT_USERS_NOTE });
28
41
  /**
29
42
  * Answer in the requested rung.
30
43
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibo-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "mcpName": "io.github.chrischall/vibo-mcp",
5
5
  "description": "Vibo (vibodj.com) MCP server for Claude — host/couple event music planning & management. Developed and maintained by AI (Claude Code).",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -46,8 +46,8 @@
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^2.6.0",
50
- "@fetchproxy/bootstrap": "^3.2.0",
49
+ "@chrischall/mcp-utils": "^2.8.0",
50
+ "@fetchproxy/bootstrap": "^3.4.1",
51
51
  "@modelcontextprotocol/server": "^2.0.0",
52
52
  "dotenv": "^18.0.1",
53
53
  "zod": "^4.6.5"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/vibo-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.3.0",
9
+ "version": "2.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "vibo-mcp",
14
- "version": "2.3.0",
14
+ "version": "2.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -40,7 +40,9 @@ Pick one:
40
40
  - **Captured token (for Apple/Google/Facebook accounts):** set
41
41
  `VIBO_ACCESS_TOKEN` (and `VIBO_REFRESH_TOKEN`) with values captured from a
42
42
  signed-in `web.vibodj.com` session — no password needed.
43
- - **Browser capture (SSO, automatic):** with the fetchproxy browser extension
43
+ - **Browser capture (SSO, automatic):** with the ContextMint Bridge browser extension
44
+ (https://github.com/nullnet-app/contextmint-bridge/releases — the fetchproxy
45
+ extension renamed, same maintainer, public source; Chrome only for now)
44
46
  installed and yourself signed into https://web.vibodj.com, run
45
47
  `vibo_capture_session` once — it grabs the token from your tab (approve the
46
48
  pair code), saves it to `~/.vibo-mcp/session.json`, and reuses it thereafter.
@@ -76,8 +78,10 @@ fresh preview and token to re-approve), and a reused one as `TOKEN_REUSED`.
76
78
  `MCP_CONFIRM_MODE` (`ask-user` default / `auto` / `refuse`) controls this flow.
77
79
 
78
80
  - `vibo_add_song_to_section` — add a searched song to a section.
79
- - `vibo_remove_song_from_section` / `vibo_move_song` / `vibo_reorder_songs`.
80
- - `vibo_update_song` — mark must-play / do-not-play, or set a comment.
81
+ - `vibo_remove_song_from_section` — every id is checked against the section first; unknown ids are listed and nothing is sent.
82
+ - `vibo_move_song` / `vibo_reorder_songs` — reorder places `sourceSongIds` directly after `targetSongId` (omit it for the top). A host needs the section's "hosts can order songs" setting on — Vibo turns it off for sections a host creates, and only the DJ can turn it on.
83
+ - `vibo_update_song` — mark must-play / do-not-play, or set a comment (at most 90 characters; an emoji counts as 2).
84
+ - `vibo_add_song_to_section` re-reads the section after adding and errors if the song isn't actually there.
81
85
  - `vibo_toggle_song_like` — like/unlike a song.
82
86
  - `vibo_comment_on_song` / `vibo_comment_on_section` (+ delete) — leave the DJ notes.
83
87
  - `vibo_import_playlist_to_section` — pull tracks from a connected Spotify/Apple playlist.
@@ -86,6 +90,9 @@ fresh preview and token to re-approve), and a reused one as `TOKEN_REUSED`.
86
90
  - `vibo_create_event_contact` — add a host/guest contact.
87
91
  - `vibo_invite_users` / `vibo_change_user_role` / `vibo_remove_user` — manage who's on the event.
88
92
  - `vibo_update_section` — edit a section's name, time, or note.
93
+ - `vibo_create_section` — add a section (name ≤ 45 characters; `visibility` host/public; optional time and note — Vibo drops a host's description) and place it with `afterSectionId` or `position`. Returns the new `_id`.
94
+ - `vibo_delete_section` — delete a section; the preview shows its name, song count and answered questions. `dontPlay`/`headline` sections need `force: true`.
95
+ - `vibo_reorder_sections` — move sections to directly after `targetSectionId` (omit for the start).
89
96
  - `vibo_answer_question` — answer a planning question (text / option ids / link / image+file uploads).
90
97
  - `vibo_set_profile_photo` — set your profile photo from a local image in the upload directory (`VIBO_UPLOAD_DIR`, default `~/Downloads/vibo-mcp`).
91
98
  - `vibo_capture_session` — capture your login from a signed-in browser tab (SSO accounts).
@@ -94,7 +101,7 @@ fresh preview and token to re-approve), and a reused one as `TOKEN_REUSED`.
94
101
 
95
102
  ## Response shape (`view`)
96
103
 
97
- **Six of this server's 39 tools take `view: "compact" | "full"`**, and on every
104
+ **Six of this server's 42 tools take `view: "compact" | "full"`**, and on every
98
105
  one of them **`compact` is the DEFAULT**. You get the slim rung without asking.
99
106
 
100
107
  They are exactly the six reads whose GraphQL document asks Vibo for media:
@@ -104,7 +111,7 @@ They are exactly the six reads whose GraphQL document asks Vibo for media:
104
111
  | `vibo_search_songs` | `thumbnails { s180x180 original }` per track |
105
112
  | `vibo_get_section_songs` | `thumbnails { s180x180 original }` per song |
106
113
  | `vibo_list_song_ideas_songs` | `thumbnails { s180x180 original }` per song |
107
- | `vibo_list_event_users` | `imageUrl` per person (both exits — merged and filtered) |
114
+ | `vibo_list_event_users` | `email` + `imageUrl` per person — projected to `_id`/`firstName`/`lastName`/`role` on both exits (merged and filtered); `view: "full"` for emails |
108
115
  | `vibo_list_notifications` | `imageUrl` per notification |
109
116
  | `vibo_get_me` | `imageUrl` |
110
117
 
@@ -117,6 +124,11 @@ writes no field list, because this repo holds no captured Vibo payload to
117
124
  derive one from honestly; instead it removes keys whose value is a picture,
118
125
  which is subtractive and so cannot drop a field nobody knew about.
119
126
 
127
+ The one exception is `vibo_list_event_users`: its query fixes the member field
128
+ list, so compact projects each member to `_id`/`firstName`/`lastName`/`role`
129
+ and keeps other people's email addresses out of the default answer (fleet-audit
130
+ #1136). Ask for `view: "full"` only when an email is actually needed.
131
+
120
132
  What compact does **not** touch is the point of these tools: `songUrl`, the
121
133
  `links` block (`spotify` / `youtube` / `appleMusic`) and the `quality` verdict
122
134
  all survive. A streaming link is this server's product, not decoration —