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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +139 -6
- package/dist/song-search.js +237 -0
- package/dist/tools/songs.js +34 -5
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -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.
|
|
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.
|
|
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
|
+
"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.
|
|
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:
|
|
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(
|
|
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:
|
|
32268
|
+
filter: { q: query, source: resolvedSource },
|
|
32138
32269
|
limit: limit ?? 20
|
|
32139
32270
|
});
|
|
32140
|
-
|
|
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
|
+
}
|
package/dist/tools/songs.js
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
72
|
+
filter: { q: query, source: resolvedSource },
|
|
53
73
|
limit: limit ?? 20,
|
|
54
74
|
});
|
|
55
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "vibo-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.5.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|