vibo-mcp 1.4.2 → 1.5.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Vibo (vibodj.com) — plan & manage event music, song requests, ideas, guests, and playlists via natural language",
10
- "version": "1.4.2"
10
+ "version": "1.5.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Vibo",
16
16
  "source": "./",
17
17
  "description": "MCP server for Vibo — browse & manage events, timeline, songs, the DJ song ideas/questions, guests, and exports to Spotify/Apple Music",
18
- "version": "1.4.2",
18
+ "version": "1.5.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vibo-mcp",
3
3
  "displayName": "Vibo",
4
- "version": "1.4.2",
4
+ "version": "1.5.0",
5
5
  "description": "MCP server for Vibo (vibodj.com) — plan & manage event music, song requests, ideas, guests, and playlists via natural language",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -31126,7 +31126,7 @@ function toolAnnotations(opts = {}) {
31126
31126
  }
31127
31127
 
31128
31128
  // src/version.ts
31129
- var VERSION = "1.4.2";
31129
+ var VERSION = "1.5.0";
31130
31130
 
31131
31131
  // src/client.ts
31132
31132
  import { dirname as dirname2, join as join2 } from "path";
@@ -32082,6 +32082,134 @@ function registerSectionTools(server, client2) {
32082
32082
  );
32083
32083
  }
32084
32084
 
32085
+ // src/song-search.ts
32086
+ var NON_ORIGINAL_MARKERS = [
32087
+ "karaoke",
32088
+ "cover",
32089
+ "covers",
32090
+ "tribute",
32091
+ "instrumental",
32092
+ "backing track",
32093
+ "made famous by",
32094
+ "in the style of",
32095
+ "piano version",
32096
+ "acoustic version",
32097
+ "lyric video",
32098
+ "lyrics video",
32099
+ "sped up",
32100
+ "slowed",
32101
+ "nightcore",
32102
+ "8 bit"
32103
+ ];
32104
+ var VARIANT_MARKERS = [
32105
+ "remix",
32106
+ "demo",
32107
+ "live at",
32108
+ "live from",
32109
+ "performance",
32110
+ "radio edit",
32111
+ "extended mix",
32112
+ "reprise"
32113
+ ];
32114
+ var UPLOADER_MARKERS = ["karaoke", "cover", "covers", "tribute", "versions", "lyrics", "sing king", "topic"];
32115
+ var JUNK_ARTISTS = ["board", "unknown", "various", "various artists", "na", "none"];
32116
+ function normalizeName(value) {
32117
+ return value.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[&+]/g, " and ").replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
32118
+ }
32119
+ function containsPhrase(normalizedHaystack, phrase) {
32120
+ const needle = normalizeName(phrase);
32121
+ if (!needle) return false;
32122
+ return ` ${normalizedHaystack} `.includes(` ${needle} `);
32123
+ }
32124
+ function parseSearchQuery(query) {
32125
+ const raw = query.trim();
32126
+ const match = raw.match(/^(.+?)\s+[-–—]\s+(.+)$/);
32127
+ if (!match) return { raw, structured: false };
32128
+ const artist = match[1].trim();
32129
+ const title = match[2].trim();
32130
+ if (!artist || !title) return { raw, structured: false };
32131
+ return { raw, artist, title, structured: true };
32132
+ }
32133
+ function streamingLinkCount(links) {
32134
+ if (!links) return 0;
32135
+ return [links.spotify, links.appleMusic, links.tidal, links.deezer, links.soundcloud].filter(
32136
+ (l) => typeof l === "string" && l.length > 0
32137
+ ).length;
32138
+ }
32139
+ function soundcloudHandleMatchesArtist(url2, artist) {
32140
+ const path = url2.replace(/^https?:\/\/(www\.)?soundcloud\.com\//i, "").split("/")[0] ?? "";
32141
+ const handle = normalizeName(path).split(" ").join("");
32142
+ if (!handle) return false;
32143
+ const tokens = normalizeName(artist).split(" ").filter((t) => t.length >= 4);
32144
+ if (!tokens.length) return false;
32145
+ return tokens.some((t) => handle.includes(t));
32146
+ }
32147
+ function assessSong(song, intended) {
32148
+ const warnings = [];
32149
+ let hard = false;
32150
+ const title = (song.title ?? "").trim();
32151
+ const artist = (song.artist ?? "").trim();
32152
+ const normalizedArtist = normalizeName(artist);
32153
+ const isJunkArtist = JUNK_ARTISTS.includes(normalizedArtist);
32154
+ if (!artist) {
32155
+ warnings.push("Result has no artist field.");
32156
+ hard = true;
32157
+ } else if (isJunkArtist) {
32158
+ warnings.push(`Artist field is placeholder junk ("${artist}"), not a performer name.`);
32159
+ hard = true;
32160
+ } else {
32161
+ const uploader = UPLOADER_MARKERS.find((m) => containsPhrase(normalizedArtist, m));
32162
+ if (uploader) {
32163
+ warnings.push(`Artist field looks like an uploader/channel ("${artist}"), not the performer.`);
32164
+ hard = true;
32165
+ }
32166
+ }
32167
+ if (intended?.artist && artist && !isJunkArtist) {
32168
+ const wanted = normalizeName(intended.artist);
32169
+ if (wanted && !normalizedArtist.includes(wanted) && !wanted.includes(normalizedArtist)) {
32170
+ warnings.push(`Artist is "${artist}", but the query asked for "${intended.artist}".`);
32171
+ hard = true;
32172
+ }
32173
+ }
32174
+ const normalizedTitle = normalizeName(title);
32175
+ const nonOriginal = NON_ORIGINAL_MARKERS.find((m) => containsPhrase(normalizedTitle, m));
32176
+ if (nonOriginal) {
32177
+ warnings.push(`Title contains "${nonOriginal}" \u2014 not the original studio recording.`);
32178
+ hard = true;
32179
+ }
32180
+ const variant = VARIANT_MARKERS.find((m) => containsPhrase(normalizedTitle, m));
32181
+ if (variant) {
32182
+ warnings.push(`Title contains "${variant}" \u2014 an alternate version; confirm it is the one wanted.`);
32183
+ }
32184
+ if (streamingLinkCount(song.links) === 0) {
32185
+ warnings.push("No streaming-service links (YouTube only) \u2014 typical of a re-upload.");
32186
+ hard = true;
32187
+ }
32188
+ const soundcloud = song.links?.soundcloud;
32189
+ const artistForHandle = isJunkArtist ? intended?.artist : artist || intended?.artist;
32190
+ if (soundcloud && artistForHandle && !soundcloudHandleMatchesArtist(soundcloud, artistForHandle)) {
32191
+ warnings.push(`SoundCloud handle does not obviously belong to "${artistForHandle}".`);
32192
+ }
32193
+ const confidence = hard ? "likely-not-original" : warnings.length ? "uncertain" : "likely-original";
32194
+ return { confidence, warnings };
32195
+ }
32196
+ function annotateSearchResults(results, query, source = "searchField") {
32197
+ const parsed = parseSearchQuery(query);
32198
+ const annotated = results.map((song) => ({ ...song, quality: assessSong(song, parsed) }));
32199
+ const summary = {
32200
+ total: annotated.length,
32201
+ likelyOriginal: annotated.filter((s) => s.quality.confidence === "likely-original").length,
32202
+ flagged: annotated.filter((s) => s.quality.confidence === "likely-not-original").length
32203
+ };
32204
+ let hint;
32205
+ if (source === "searchField" && !parsed.structured) {
32206
+ hint = `Query "${parsed.raw}" is not in "<Artist> - <Title>" form. Vibo's text index ranks covers, karaoke and re-uploads above official recordings for unhyphenated queries. Retry as "<Artist> - <Title>" (space-hyphen-space) before trusting these results.`;
32207
+ } else if (source === "searchField" && summary.total > 0 && summary.likelyOriginal === 0) {
32208
+ hint = 'No result looks like an official recording. Try reversing to "<Title> - <Artist>", searching the title alone and filtering by artist, or source: "spotify".';
32209
+ }
32210
+ return { query: parsed, ...hint ? { hint } : {}, summary, results: annotated };
32211
+ }
32212
+
32085
32213
  // src/tools/songs.ts
32086
32214
  function registerSongTools(server, client2) {
32087
32215
  server.registerTool(
@@ -32120,30 +32248,35 @@ function registerSongTools(server, client2) {
32120
32248
  server.registerTool(
32121
32249
  "vibo_search_songs",
32122
32250
  {
32123
- description: "Search for songs to add to a section. source 'searchField' (default) searches Vibo's catalog; 'spotify' searches your connected Spotify. Returns songUrl/viboSongId/title/artist to pass to vibo_add_song_to_section.",
32251
+ description: `Search for songs to add to a section. ALWAYS query as "<Artist> - <Title>" with a space-hyphen-space separator (e.g. "Ed Sheeran - Thinking Out Loud"). Vibo's default 'searchField' index is a loose text match over a catalog full of YouTube covers, karaoke tracks and re-uploads: the hyphenated form resolves to the official recording, while the same words unhyphenated rank covers and re-uploads above it (measured live \u2014 "Chris Stapleton - Tennessee Whiskey" returned only the official master; without the hyphen, none of the nine results was the original). Each result carries a \`quality\` verdict (likely-original / uncertain / likely-not-original) plus warnings \u2014 check it before adding, and never add a \`likely-not-original\` result without saying so. source 'spotify' searches your connected Spotify (a structured catalog, so the hyphen matters less). Returns songUrl/viboSongId/title/artist for vibo_add_song_to_section.`,
32124
32252
  annotations: toolAnnotations({ title: "Search Vibo songs", readOnly: true }),
32125
32253
  inputSchema: {
32126
32254
  eventId: external_exports.string().describe("Event id (search is scoped to an event/section)."),
32127
32255
  sectionId: external_exports.string().describe("Section id the search is for."),
32128
- query: external_exports.string().describe("Song or artist to search for."),
32256
+ query: external_exports.string().describe(
32257
+ `Song to search for, as "<Artist> - <Title>" (space-hyphen-space). Use the artist's own stylization \u2014 the index does not fold variants together, and "Dan + Shay - Speechless" returns the official master while "Dan and Shay - Speechless" returns covers and live cuts without it. Artist alone returns only a short popularity-ranked subset of their catalog, so a specific track may be missing entirely. If the hyphenated query looks wrong, retry as "<Title> - <Artist>", then the title alone filtered by artist.`
32258
+ ),
32129
32259
  source: external_exports.enum(["searchField", "spotify"]).optional().describe("Search source (default 'searchField')."),
32130
32260
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max results (default 20).")
32131
32261
  }
32132
32262
  },
32133
32263
  async ({ eventId, sectionId, query, source, limit }) => {
32264
+ const resolvedSource = source ?? "searchField";
32134
32265
  const data = await client2.gql(SEARCH_SONGS, {
32135
32266
  eventId,
32136
32267
  sectionId,
32137
- filter: { q: query, source: source ?? "searchField" },
32268
+ filter: { q: query, source: resolvedSource },
32138
32269
  limit: limit ?? 20
32139
32270
  });
32140
- return textResult(data.getSongs);
32271
+ const songs = data.getSongs;
32272
+ if (!Array.isArray(songs)) return textResult(songs);
32273
+ return textResult(annotateSearchResults(songs, query, resolvedSource));
32141
32274
  }
32142
32275
  );
32143
32276
  server.registerTool(
32144
32277
  "vibo_add_song_to_section",
32145
32278
  {
32146
- description: "Add a song to a section. Pass a song from vibo_search_songs (songUrl is required; include viboSongId/title/artist when known). Confirm-gated.",
32279
+ description: "Add a song to a section. Pass a song from vibo_search_songs (songUrl is required; include viboSongId/title/artist when known). Before adding, check that result's `quality.confidence`: adding a `likely-not-original` result puts a cover, karaoke track or junk-metadata re-upload in front of a live DJ. If nothing looks original, report the closest matches back rather than adding a best guess. Confirm-gated.",
32147
32280
  annotations: toolAnnotations({ title: "Add song to Vibo section", readOnly: false }),
32148
32281
  inputSchema: {
32149
32282
  eventId: external_exports.string().describe("Event id."),
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Search-quality heuristics for Vibo's song catalog.
3
+ *
4
+ * Vibo's `searchField` source is a loose text index over a catalog that mixes
5
+ * official recordings with YouTube-sourced covers, karaoke tracks and
6
+ * re-uploads. Two consequences, both confirmed live against the production
7
+ * catalog rather than assumed:
8
+ *
9
+ * 1. Querying `"<Artist> - <Title>"` resolves to the official recording;
10
+ * dropping the hyphen falls back to loose matching that ranks re-uploads
11
+ * first. Observed: `"Chris Stapleton - Tennessee Whiskey"` returned exactly
12
+ * one result — the official master, six streaming links. The same words
13
+ * without the hyphen returned nine results, *none* of them the original:
14
+ * the top hit was a re-upload whose `artist` field read `board`, followed
15
+ * by a Sing King karaoke track, three violin/piano covers, an instrumental
16
+ * and a techno edit.
17
+ *
18
+ * 2. Bad matches are mechanically detectable. Official recordings carry links
19
+ * across several streaming services; re-uploads are typically YouTube-only,
20
+ * carry a version marker in the title, or put an uploader/channel name in
21
+ * the `artist` field.
22
+ *
23
+ * A third, subtler trap: the index does not fold artist stylizations together.
24
+ * `"Dan + Shay - Speechless"` returns the official master; `"Dan and Shay -
25
+ * Speechless"` returns an acoustic cut, a `- Topic` upload, a live awards
26
+ * performance and a cover — the official recording is absent entirely. Query
27
+ * using the artist's own stylization. {@link normalizeName} folds `+`/`&`/`and`
28
+ * only for *comparing* results, never for building the query.
29
+ *
30
+ * These helpers are pure so they can be unit-tested without a network call.
31
+ */
32
+ /**
33
+ * Title markers that mean "someone other than the credited artist performed
34
+ * this". Any hit downgrades the result to `likely-not-original`.
35
+ *
36
+ * Matched on whole words, so `demo` does not fire on "Demolition Man". A title
37
+ * that legitimately contains one of these words as part of its name (P!nk's
38
+ * "Cover Me in Sunshine") will still be flagged — that bias is deliberate:
39
+ * a false warning costs a glance, a missed karaoke track reaches the DJ.
40
+ */
41
+ const NON_ORIGINAL_MARKERS = [
42
+ 'karaoke',
43
+ 'cover',
44
+ 'covers',
45
+ 'tribute',
46
+ 'instrumental',
47
+ 'backing track',
48
+ 'made famous by',
49
+ 'in the style of',
50
+ 'piano version',
51
+ 'acoustic version',
52
+ 'lyric video',
53
+ 'lyrics video',
54
+ 'sped up',
55
+ 'slowed',
56
+ 'nightcore',
57
+ '8 bit',
58
+ ];
59
+ /**
60
+ * Title markers for a legitimate alternate version of the real artist's
61
+ * recording. These warn but do not downgrade: a remix or live cut may be
62
+ * exactly what was asked for, so the caller decides.
63
+ */
64
+ const VARIANT_MARKERS = [
65
+ 'remix',
66
+ 'demo',
67
+ 'live at',
68
+ 'live from',
69
+ 'performance',
70
+ 'radio edit',
71
+ 'extended mix',
72
+ 'reprise',
73
+ ];
74
+ /** Artist-field markers that name an uploader/channel rather than a performer. */
75
+ const UPLOADER_MARKERS = ['karaoke', 'cover', 'covers', 'tribute', 'versions', 'lyrics', 'sing king', 'topic'];
76
+ /**
77
+ * Artist values seen in the wild that are pure placeholder junk — no performer
78
+ * name at all. Matched exactly (normalized), never as a substring, so real
79
+ * artists containing these words are unaffected.
80
+ */
81
+ const JUNK_ARTISTS = ['board', 'unknown', 'various', 'various artists', 'na', 'none'];
82
+ /**
83
+ * Fold a name to a comparable form: lowercase, diacritics stripped, `&`/`+`
84
+ * spelled as `and`, punctuation dropped. So `"Dan + Shay"`, `"Dan & Shay"` and
85
+ * `"Dan and Shay"` all normalize alike, and `"Amélie"` matches `"Amelie"`.
86
+ */
87
+ export function normalizeName(value) {
88
+ return value
89
+ .normalize('NFD')
90
+ .replace(/[̀-ͯ]/g, '')
91
+ .toLowerCase()
92
+ .replace(/[&+]/g, ' and ')
93
+ .replace(/[^a-z0-9]+/g, ' ')
94
+ .trim()
95
+ .replace(/\s+/g, ' ');
96
+ }
97
+ /**
98
+ * Whole-word containment over normalized text. Padding both sides with spaces
99
+ * turns a substring test into a word-boundary test without a built regex.
100
+ */
101
+ function containsPhrase(normalizedHaystack, phrase) {
102
+ const needle = normalizeName(phrase);
103
+ if (!needle)
104
+ return false;
105
+ return ` ${normalizedHaystack} `.includes(` ${needle} `);
106
+ }
107
+ /**
108
+ * Split a search query on the first ` - ` separator (hyphen, en dash or em
109
+ * dash). Without a separator the query is returned unstructured, which is the
110
+ * signal to warn the caller that they are about to get loose-match results.
111
+ */
112
+ export function parseSearchQuery(query) {
113
+ const raw = query.trim();
114
+ const match = raw.match(/^(.+?)\s+[-–—]\s+(.+)$/);
115
+ if (!match)
116
+ return { raw, structured: false };
117
+ const artist = match[1].trim();
118
+ const title = match[2].trim();
119
+ if (!artist || !title)
120
+ return { raw, structured: false };
121
+ return { raw, artist, title, structured: true };
122
+ }
123
+ /** Count links to real streaming services. YouTube is excluded: re-uploads all have one. */
124
+ function streamingLinkCount(links) {
125
+ if (!links)
126
+ return 0;
127
+ return [links.spotify, links.appleMusic, links.tidal, links.deezer, links.soundcloud].filter((l) => typeof l === 'string' && l.length > 0).length;
128
+ }
129
+ /**
130
+ * True when a SoundCloud URL's handle plausibly belongs to `artist` — i.e. the
131
+ * handle contains one of the artist's substantial name tokens. Deliberately
132
+ * loose: `soundcloud.com/elvissonymusic/...` counts for Elvis Presley (a label
133
+ * account), while `soundcloud.com/jen-prince-602192391/...` does not.
134
+ */
135
+ function soundcloudHandleMatchesArtist(url, artist) {
136
+ const path = url.replace(/^https?:\/\/(www\.)?soundcloud\.com\//i, '').split('/')[0] ?? '';
137
+ const handle = normalizeName(path).split(' ').join('');
138
+ if (!handle)
139
+ return false;
140
+ const tokens = normalizeName(artist)
141
+ .split(' ')
142
+ .filter((t) => t.length >= 4);
143
+ if (!tokens.length)
144
+ return false;
145
+ return tokens.some((t) => handle.includes(t));
146
+ }
147
+ /**
148
+ * Judge one search result, optionally against what the caller asked for.
149
+ * Passing `intended` (from {@link parseSearchQuery}) enables the strongest
150
+ * check by far: whether the result's artist is actually the artist requested.
151
+ */
152
+ export function assessSong(song, intended) {
153
+ const warnings = [];
154
+ let hard = false;
155
+ const title = (song.title ?? '').trim();
156
+ const artist = (song.artist ?? '').trim();
157
+ const normalizedArtist = normalizeName(artist);
158
+ const isJunkArtist = JUNK_ARTISTS.includes(normalizedArtist);
159
+ if (!artist) {
160
+ warnings.push('Result has no artist field.');
161
+ hard = true;
162
+ }
163
+ else if (isJunkArtist) {
164
+ warnings.push(`Artist field is placeholder junk ("${artist}"), not a performer name.`);
165
+ hard = true;
166
+ }
167
+ else {
168
+ const uploader = UPLOADER_MARKERS.find((m) => containsPhrase(normalizedArtist, m));
169
+ if (uploader) {
170
+ warnings.push(`Artist field looks like an uploader/channel ("${artist}"), not the performer.`);
171
+ hard = true;
172
+ }
173
+ }
174
+ // The strongest available check: does the result credit the artist we asked
175
+ // for? Substring both ways so "Ed Sheeran" matches "Ed Sheeran & Beyoncé".
176
+ if (intended?.artist && artist && !isJunkArtist) {
177
+ const wanted = normalizeName(intended.artist);
178
+ if (wanted && !normalizedArtist.includes(wanted) && !wanted.includes(normalizedArtist)) {
179
+ warnings.push(`Artist is "${artist}", but the query asked for "${intended.artist}".`);
180
+ hard = true;
181
+ }
182
+ }
183
+ const normalizedTitle = normalizeName(title);
184
+ const nonOriginal = NON_ORIGINAL_MARKERS.find((m) => containsPhrase(normalizedTitle, m));
185
+ if (nonOriginal) {
186
+ warnings.push(`Title contains "${nonOriginal}" — not the original studio recording.`);
187
+ hard = true;
188
+ }
189
+ const variant = VARIANT_MARKERS.find((m) => containsPhrase(normalizedTitle, m));
190
+ if (variant) {
191
+ warnings.push(`Title contains "${variant}" — an alternate version; confirm it is the one wanted.`);
192
+ }
193
+ if (streamingLinkCount(song.links) === 0) {
194
+ warnings.push('No streaming-service links (YouTube only) — typical of a re-upload.');
195
+ hard = true;
196
+ }
197
+ // Soft signal: only checkable when a SoundCloud link is present.
198
+ const soundcloud = song.links?.soundcloud;
199
+ const artistForHandle = isJunkArtist ? intended?.artist : artist || intended?.artist;
200
+ if (soundcloud && artistForHandle && !soundcloudHandleMatchesArtist(soundcloud, artistForHandle)) {
201
+ warnings.push(`SoundCloud handle does not obviously belong to "${artistForHandle}".`);
202
+ }
203
+ const confidence = hard
204
+ ? 'likely-not-original'
205
+ : warnings.length
206
+ ? 'uncertain'
207
+ : 'likely-original';
208
+ return { confidence, warnings };
209
+ }
210
+ /**
211
+ * Attach a quality verdict to each search result and, when the query was not
212
+ * in `"<Artist> - <Title>"` form, tell the caller to retry in that form.
213
+ */
214
+ export function annotateSearchResults(results, query, source = 'searchField') {
215
+ const parsed = parseSearchQuery(query);
216
+ const annotated = results.map((song) => ({ ...song, quality: assessSong(song, parsed) }));
217
+ const summary = {
218
+ total: annotated.length,
219
+ likelyOriginal: annotated.filter((s) => s.quality.confidence === 'likely-original').length,
220
+ flagged: annotated.filter((s) => s.quality.confidence === 'likely-not-original').length,
221
+ };
222
+ // The hyphen rule is specific to the loose `searchField` text index; the
223
+ // Spotify source queries a structured catalog and does not need it.
224
+ let hint;
225
+ if (source === 'searchField' && !parsed.structured) {
226
+ hint =
227
+ `Query "${parsed.raw}" is not in "<Artist> - <Title>" form. Vibo's text index ranks ` +
228
+ 'covers, karaoke and re-uploads above official recordings for unhyphenated queries. ' +
229
+ 'Retry as "<Artist> - <Title>" (space-hyphen-space) before trusting these results.';
230
+ }
231
+ else if (source === 'searchField' && summary.total > 0 && summary.likelyOriginal === 0) {
232
+ hint =
233
+ 'No result looks like an official recording. Try reversing to "<Title> - <Artist>", ' +
234
+ 'searching the title alone and filtering by artist, or source: "spotify".';
235
+ }
236
+ return { query: parsed, ...(hint ? { hint } : {}), summary, results: annotated };
237
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
3
  import { GET_SECTION_SONGS, SEARCH_SONGS, ADD_SONG_TO_SECTION, TOGGLE_LIKE } from '../gql.js';
4
+ import { annotateSearchResults } from '../song-search.js';
4
5
  import { limitSchema, skipSchema, pagination, previewResult } from './shared.js';
5
6
  export function registerSongTools(server, client) {
6
7
  server.registerTool('vibo_get_section_songs', {
@@ -36,26 +37,54 @@ export function registerSongTools(server, client) {
36
37
  return textResult(data.getSectionSongs);
37
38
  });
38
39
  server.registerTool('vibo_search_songs', {
39
- description: "Search for songs to add to a section. source 'searchField' (default) searches Vibo's catalog; 'spotify' searches your connected Spotify. Returns songUrl/viboSongId/title/artist to pass to vibo_add_song_to_section.",
40
+ description: 'Search for songs to add to a section. ALWAYS query as "<Artist> - <Title>" with a ' +
41
+ 'space-hyphen-space separator (e.g. "Ed Sheeran - Thinking Out Loud"). Vibo\'s default ' +
42
+ "'searchField' index is a loose text match over a catalog full of YouTube covers, " +
43
+ 'karaoke tracks and re-uploads: the hyphenated form resolves to the official recording, ' +
44
+ 'while the same words unhyphenated rank covers and re-uploads above it (measured live — ' +
45
+ '"Chris Stapleton - Tennessee Whiskey" returned only the official master; without the ' +
46
+ 'hyphen, none of the nine results was the original). Each result carries a `quality` ' +
47
+ 'verdict (likely-original / uncertain / likely-not-original) plus warnings — check it ' +
48
+ 'before adding, and never add a `likely-not-original` result without saying so. ' +
49
+ "source 'spotify' searches your connected Spotify (a structured catalog, so the hyphen " +
50
+ 'matters less). Returns songUrl/viboSongId/title/artist for vibo_add_song_to_section.',
40
51
  annotations: toolAnnotations({ title: 'Search Vibo songs', readOnly: true }),
41
52
  inputSchema: {
42
53
  eventId: z.string().describe('Event id (search is scoped to an event/section).'),
43
54
  sectionId: z.string().describe('Section id the search is for.'),
44
- query: z.string().describe('Song or artist to search for.'),
55
+ query: z
56
+ .string()
57
+ .describe('Song to search for, as "<Artist> - <Title>" (space-hyphen-space). Use the artist\'s ' +
58
+ 'own stylization — the index does not fold variants together, and "Dan + Shay - ' +
59
+ 'Speechless" returns the official master while "Dan and Shay - Speechless" returns ' +
60
+ 'covers and live cuts without it. Artist alone returns only a short ' +
61
+ 'popularity-ranked subset of their catalog, so a specific track may be missing ' +
62
+ 'entirely. If the hyphenated query looks wrong, retry as "<Title> - <Artist>", ' +
63
+ 'then the title alone filtered by artist.'),
45
64
  source: z.enum(['searchField', 'spotify']).optional().describe("Search source (default 'searchField')."),
46
65
  limit: z.number().int().min(1).max(50).optional().describe('Max results (default 20).'),
47
66
  },
48
67
  }, async ({ eventId, sectionId, query, source, limit }) => {
68
+ const resolvedSource = source ?? 'searchField';
49
69
  const data = await client.gql(SEARCH_SONGS, {
50
70
  eventId,
51
71
  sectionId,
52
- filter: { q: query, source: source ?? 'searchField' },
72
+ filter: { q: query, source: resolvedSource },
53
73
  limit: limit ?? 20,
54
74
  });
55
- return textResult(data.getSongs);
75
+ const songs = data.getSongs;
76
+ // Vibo returns a bare array; if that ever changes, pass it through untouched
77
+ // rather than guessing at a shape.
78
+ if (!Array.isArray(songs))
79
+ return textResult(songs);
80
+ return textResult(annotateSearchResults(songs, query, resolvedSource));
56
81
  });
57
82
  server.registerTool('vibo_add_song_to_section', {
58
- description: 'Add a song to a section. Pass a song from vibo_search_songs (songUrl is required; include viboSongId/title/artist when known). Confirm-gated.',
83
+ description: 'Add a song to a section. Pass a song from vibo_search_songs (songUrl is required; ' +
84
+ 'include viboSongId/title/artist when known). Before adding, check that result\'s ' +
85
+ '`quality.confidence`: adding a `likely-not-original` result puts a cover, karaoke ' +
86
+ 'track or junk-metadata re-upload in front of a live DJ. If nothing looks original, ' +
87
+ 'report the closest matches back rather than adding a best guess. Confirm-gated.',
59
88
  annotations: toolAnnotations({ title: 'Add song to Vibo section', readOnly: false }),
60
89
  inputSchema: {
61
90
  eventId: z.string().describe('Event id.'),
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 = '1.4.2'; // x-release-please-version
5
+ export const VERSION = '1.5.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibo-mcp",
3
- "version": "1.4.2",
3
+ "version": "1.5.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>",
@@ -60,7 +60,7 @@
60
60
  "@cloudflare/workers-types": "^5.20260708.1",
61
61
  "@types/node": "^26.0.0",
62
62
  "@vitest/coverage-v8": "^4.1.2",
63
- "agents": "^0.17.3",
63
+ "agents": "^0.19.0",
64
64
  "esbuild": "^0.28.0",
65
65
  "typescript": "^7.0.2",
66
66
  "vitest": "^4.1.2",
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": "1.4.2",
9
+ "version": "1.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "vibo-mcp",
14
- "version": "1.4.2",
14
+ "version": "1.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },