mr-magic-mcp-server 0.3.3 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-magic-mcp-server",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,4 +1,5 @@
1
1
  import { findLyrics, findSyncedLyrics, searchProvider, searchSources } from '../index.js';
2
+ import { lyricContentScore } from '../provider-result-schema.js';
2
3
 
3
4
  export function normalizeTrack(track = {}) {
4
5
  if (!track || typeof track !== 'object') {
@@ -54,13 +55,46 @@ export function pickIndex(entries, index) {
54
55
  return entries[parsedIndex - 1].result;
55
56
  }
56
57
 
58
+ /**
59
+ * Rank a chooser entry for auto-picking.
60
+ *
61
+ * Priority (highest first):
62
+ * 1. Has actual lyric content (score > 0) beats empty/unhydrated entries.
63
+ * 2. Synced beats plain when both have real content.
64
+ * 3. Richer content (more lyric lines) wins when both are non-empty.
65
+ * 4. Original list order is preserved as the final tie-breaker (lower index wins).
66
+ */
67
+ function entryRankScore(entry, index) {
68
+ const result = entry?.result;
69
+ const content = lyricContentScore(result) * 10; // 0 or 5..10
70
+ const syncedBonus = result?.synced ? 0.5 : 0;
71
+ // Use a small negative index term so earlier list positions win ties
72
+ const positionPenalty = index * 0.0001;
73
+ return content + syncedBonus - positionPenalty;
74
+ }
75
+
57
76
  export function autoPick(entries, preferSynced = true) {
58
77
  if (!entries.length) return null;
78
+
79
+ // Sort a shallow copy by rank, descending
80
+ const ranked = entries
81
+ .map((entry, idx) => ({ entry, rank: entryRankScore(entry, idx) }))
82
+ .sort((a, b) => b.rank - a.rank);
83
+
59
84
  if (preferSynced) {
60
- const synced = entries.find((entry) => entry.result?.synced);
61
- if (synced) {
62
- return synced.result;
85
+ // Prefer synced among results that actually have content
86
+ const syncedWithContent = ranked.find(
87
+ ({ entry }) => entry.result?.synced && lyricContentScore(entry.result) > 0
88
+ );
89
+ if (syncedWithContent) {
90
+ return syncedWithContent.entry.result;
91
+ }
92
+ // Fall back to synced without content check (legacy behaviour preserved)
93
+ const anySynced = ranked.find(({ entry }) => entry.result?.synced);
94
+ if (anySynced) {
95
+ return anySynced.entry.result;
63
96
  }
64
97
  }
65
- return entries[0].result;
98
+
99
+ return ranked[0].entry.result;
66
100
  }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from './providers/musixmatch.js';
8
8
  import { fetchFromMelon, searchMelon } from './providers/melon.js';
9
9
  import { getEnvValue } from './utils/config.js';
10
+ import { lyricContentScore } from './provider-result-schema.js';
10
11
 
11
12
  const providers = [
12
13
  { name: 'lrclib', fetch: fetchFromLrclib, search: searchLrclib },
@@ -19,10 +20,24 @@ const providerIndex = providers.reduce(
19
20
  new Map()
20
21
  );
21
22
 
23
+ /**
24
+ * Score a candidate result for ranking.
25
+ *
26
+ * Priority order (highest wins):
27
+ * 1. Results with actual lyric content beat empty / unhydrated ones.
28
+ * lyricContentScore returns 0 for empties, 0.5-1.0 for results that have text,
29
+ * with a continuous richness bonus proportional to the line count.
30
+ * 2. Among results that both have lyric text, synced content beats plain.
31
+ * 3. Provider confidence score is a secondary tie-breaker.
32
+ *
33
+ * Multiplying lyricContentScore by 10 ensures it always dominates the confidence
34
+ * score (which lives in the 0-1 range) and the synced bonus (0.5).
35
+ */
22
36
  function rankRecord(record) {
23
- const confidenceScore = record?.confidence ?? 0;
37
+ const contentScore = lyricContentScore(record) * 10; // 0 or 5..10
24
38
  const syncedBonus = record?.synced ? 0.5 : 0;
25
- return confidenceScore + syncedBonus;
39
+ const confidenceScore = record?.confidence ?? 0;
40
+ return contentScore + syncedBonus + confidenceScore;
26
41
  }
27
42
 
28
43
  async function tryProviders(track, { syncedOnly = false, providerNames = [] } = {}) {
@@ -59,7 +74,11 @@ async function tryProviders(track, { syncedOnly = false, providerNames = [] } =
59
74
  export async function findLyrics(track, options = {}) {
60
75
  const { matches, best } = await tryProviders(track, options);
61
76
  return {
62
- matches: matches.map(({ provider, result }) => ({ provider, result })),
77
+ // Filter out candidates with no actual lyric content (empty / unhydrated stubs).
78
+ // This keeps the matches list clean for all consumers: CLI, MCP tools, HTTP server.
79
+ matches: matches
80
+ .filter(({ result }) => lyricContentScore(result) > 0)
81
+ .map(({ provider, result }) => ({ provider, result })),
63
82
  best: best?.result ?? null
64
83
  };
65
84
  }
@@ -67,7 +86,9 @@ export async function findLyrics(track, options = {}) {
67
86
  export async function findSyncedLyrics(track, options = {}) {
68
87
  const { matches, best } = await tryProviders(track, { ...options, syncedOnly: true });
69
88
  return {
70
- matches: matches.map(({ provider, result }) => ({ provider, result })),
89
+ matches: matches
90
+ .filter(({ result }) => lyricContentScore(result) > 0)
91
+ .map(({ provider, result }) => ({ provider, result })),
71
92
  best: best?.result ?? null
72
93
  };
73
94
  }
@@ -15,6 +15,47 @@ export function detectSyncedState(syncedLyrics) {
15
15
  return { hasSynced: timestampCount > 1, timestampCount };
16
16
  }
17
17
 
18
+ /**
19
+ * Count non-empty lines in a lyric string, after stripping LRC timestamps.
20
+ * Used for ranking results by lyric richness.
21
+ */
22
+ export function countLyricLines(text) {
23
+ if (!text || typeof text !== 'string') return 0;
24
+ return text
25
+ .split('\n')
26
+ .map((line) => line.replace(/^\[\d{1,2}:\d{2}[.:]\d{1,3}\]/, '').trim())
27
+ .filter(Boolean).length;
28
+ }
29
+
30
+ /**
31
+ * Returns a numeric "content score" for a lyric record:
32
+ * - 0 → no lyric text at all (empty / unhydrated)
33
+ * - 0.5 → has some lyric text (plain or synced)
34
+ * - 0…1 continuous bonus added for richness (normalized line count, capped at 1)
35
+ *
36
+ * The function only inspects the actual lyric strings so that placeholder
37
+ * records that happen to have `plainLyrics: ""` still score 0.
38
+ */
39
+ export function lyricContentScore(record) {
40
+ if (!record) return 0;
41
+
42
+ const plainText = typeof record.plainLyrics === 'string' ? record.plainLyrics.trim() : '';
43
+ const syncedText = typeof record.syncedLyrics === 'string' ? record.syncedLyrics.trim() : '';
44
+
45
+ const hasAny = Boolean(plainText || syncedText);
46
+ if (!hasAny) return 0;
47
+
48
+ // Base score for having any content
49
+ let score = 0.5;
50
+
51
+ // Richness bonus: cap at 200 lines → bonus up to 0.5
52
+ const lines = Math.max(countLyricLines(plainText), countLyricLines(syncedText));
53
+ const richnessBonus = Math.min(lines / 200, 0.5);
54
+ score += richnessBonus;
55
+
56
+ return score;
57
+ }
58
+
18
59
  export function normalizeLyricRecord({
19
60
  provider,
20
61
  id,
@@ -114,6 +114,21 @@ export async function buildPayloadFromResult(result, context) {
114
114
  if (context.shouldExport) {
115
115
  payload.exports = await exportBestResult(result, context);
116
116
  }
117
+
118
+ // Populate the in-memory catalog cache so push_catalog_to_airtable can be
119
+ // used immediately after find_lyrics / find_synced_lyrics / export_lyrics —
120
+ // the agent does NOT have to call build_catalog_payload first.
121
+ const best = result.best;
122
+ const plainLyrics = payload.formatted?.plainLyrics || best.plainLyrics || '';
123
+ if (plainLyrics) {
124
+ const key = catalogCacheKey({ artist: best.artist, title: best.title });
125
+ catalogCache.set(key, {
126
+ plainLyrics,
127
+ romanizedPlainLyrics: payload.formatted?.romanizedPlain || null,
128
+ preferRomanized: context.includeRomanization
129
+ });
130
+ payload.lyricsCacheKey = key;
131
+ }
117
132
  }
118
133
  return payload;
119
134
  }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import '../utils/config.js';
3
+ import { handleMcpTool } from '../transport/mcp-tools.js';
4
+
5
+ const track = { artist: 'Dylan Cotrone', title: 'Cigarette' };
6
+
7
+ // ── find_lyrics ──────────────────────────────────────────────────────────────
8
+ console.log('=== find_lyrics ===');
9
+ const findResult = await handleMcpTool('find_lyrics', { track });
10
+ const matchSummary = (findResult.matches ?? []).map((m) => ({
11
+ provider: m.provider,
12
+ synced: Boolean(m.result?.synced),
13
+ hasPlain: Boolean(m.result?.plainLyrics?.trim()),
14
+ hasSynced: Boolean(m.result?.syncedLyrics?.trim()),
15
+ plainLines: (m.result?.plainLyrics || '').split('\n').filter(Boolean).length
16
+ }));
17
+ console.log(`matches (${matchSummary.length}):`);
18
+ matchSummary.forEach((m) => console.log(' ', JSON.stringify(m)));
19
+ console.log('best provider:', findResult.best?.provider ?? 'none');
20
+ console.log('best hasPlain:', Boolean(findResult.best?.plainLyrics?.trim()));
21
+ console.log('best hasSynced:', Boolean(findResult.best?.syncedLyrics?.trim()));
22
+ console.log('lyricsCacheKey:', findResult.lyricsCacheKey ?? '(none)');
23
+
24
+ const emptyMatches = matchSummary.filter((m) => !m.hasPlain && !m.hasSynced);
25
+ if (emptyMatches.length > 0) {
26
+ console.error('FAIL: find_lyrics returned empty-content matches:', emptyMatches);
27
+ process.exitCode = 1;
28
+ } else {
29
+ console.log('PASS: all matches have lyric content');
30
+ }
31
+
32
+ const hasBestLyrics =
33
+ Boolean(findResult.best?.plainLyrics?.trim()) || Boolean(findResult.best?.syncedLyrics?.trim());
34
+ if (hasBestLyrics && !findResult.lyricsCacheKey) {
35
+ console.error('FAIL: find_lyrics resolved lyrics but did not return lyricsCacheKey');
36
+ process.exitCode = 1;
37
+ } else if (hasBestLyrics) {
38
+ console.log('PASS: lyricsCacheKey present when lyrics resolved');
39
+ } else {
40
+ console.log('NOTE: no lyrics resolved, lyricsCacheKey expected to be absent');
41
+ }
42
+
43
+ // ── search_lyrics ─────────────────────────────────────────────────────────────
44
+ console.log('');
45
+ console.log('=== search_lyrics (raw catalog stubs — no hydration by design) ===');
46
+ const searchResult = await handleMcpTool('search_lyrics', { track });
47
+ for (const entry of searchResult) {
48
+ const withContent = entry.results.filter(
49
+ (r) => (r.plainLyrics || '').trim() || (r.syncedLyrics || '').trim()
50
+ ).length;
51
+ console.log(
52
+ ` ${entry.provider}: ${entry.results.length} results, ${withContent} already have lyric text`
53
+ );
54
+ }
55
+ console.log('NOTE: search_lyrics returns raw stubs intentionally — hydration is not applied here.');
@@ -3,7 +3,18 @@ import assert from 'node:assert/strict';
3
3
 
4
4
  import { selectMatch } from '../index.js';
5
5
  import { buildChooserEntries, autoPick } from '../core/find-service.js';
6
- import { normalizeLyricRecord, detectSyncedState } from '../provider-result-schema.js';
6
+ import {
7
+ normalizeLyricRecord,
8
+ detectSyncedState,
9
+ lyricContentScore,
10
+ countLyricLines
11
+ } from '../provider-result-schema.js';
12
+ import {
13
+ buildPayloadFromResult,
14
+ buildActionContext,
15
+ catalogCacheKey,
16
+ catalogCache
17
+ } from '../services/lyrics-service.js';
7
18
  import { mcpToolDefinitions, handleMcpTool } from '../transport/mcp-tools.js';
8
19
 
9
20
  const divider = () => console.log('\n---');
@@ -88,11 +99,229 @@ function testNormalizationDetectsSyncedState() {
88
99
  console.log('normalize/detect synced state: ok');
89
100
  }
90
101
 
102
+ function testLyricContentScoreEmpty() {
103
+ const empty = { plainLyrics: null, syncedLyrics: null };
104
+ assert.equal(lyricContentScore(empty), 0, 'empty record should score 0');
105
+ assert.equal(lyricContentScore(null), 0, 'null record should score 0');
106
+ const blankString = { plainLyrics: ' ', syncedLyrics: '' };
107
+ assert.equal(lyricContentScore(blankString), 0, 'whitespace-only record should score 0');
108
+ divider();
109
+ console.log('lyricContentScore empty records → 0: ok');
110
+ }
111
+
112
+ function testLyricContentScoreWithContent() {
113
+ const plain = { plainLyrics: 'line one\nline two\nline three', syncedLyrics: null };
114
+ const score = lyricContentScore(plain);
115
+ assert.ok(score > 0, 'record with plain lyrics should score > 0');
116
+ assert.ok(score >= 0.5, 'base score should be at least 0.5');
117
+ divider();
118
+ console.log('lyricContentScore with content > 0: ok');
119
+ }
120
+
121
+ function testLyricContentScoreRichness() {
122
+ const short = { plainLyrics: 'line one', syncedLyrics: null };
123
+ const longLines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
124
+ const long = { plainLyrics: longLines, syncedLyrics: null };
125
+ assert.ok(
126
+ lyricContentScore(long) > lyricContentScore(short),
127
+ 'longer lyrics should score higher'
128
+ );
129
+ divider();
130
+ console.log('lyricContentScore richness (longer > shorter): ok');
131
+ }
132
+
133
+ function testCountLyricLines() {
134
+ assert.equal(countLyricLines(null), 0, 'null input → 0');
135
+ assert.equal(countLyricLines(''), 0, 'empty string → 0');
136
+ assert.equal(countLyricLines('line one\nline two\nline three'), 3, 'three lines');
137
+ // LRC timestamps should be stripped before counting
138
+ assert.equal(
139
+ countLyricLines('[00:01.00] first line\n[00:02.00] second line'),
140
+ 2,
141
+ 'LRC lines strip timestamps'
142
+ );
143
+ divider();
144
+ console.log('countLyricLines: ok');
145
+ }
146
+
147
+ function testAutoPickPrefersContentOverEmpty() {
148
+ // melon-like entry: found in search but lyrics not yet fetched (empty)
149
+ const emptyMelon = {
150
+ provider: 'melon',
151
+ result: {
152
+ provider: 'melon',
153
+ synced: false,
154
+ confidence: 0.5,
155
+ title: 'Song',
156
+ artist: 'Artist',
157
+ plainLyrics: null,
158
+ syncedLyrics: null
159
+ }
160
+ };
161
+ // genius-like entry: has actual plain lyrics
162
+ const geniusWithLyrics = {
163
+ provider: 'genius',
164
+ result: {
165
+ provider: 'genius',
166
+ synced: false,
167
+ confidence: 0.3,
168
+ title: 'Song',
169
+ artist: 'Artist',
170
+ plainLyrics: 'I, I was always a mean kid\nCouldnt hold my tongue',
171
+ syncedLyrics: null
172
+ }
173
+ };
174
+
175
+ // Melon comes first in the list (simulating provider priority order) but is empty
176
+ const chooser = buildChooserEntries([emptyMelon, geniusWithLyrics]);
177
+ const picked = autoPick(chooser, true);
178
+ assert.equal(
179
+ picked.provider,
180
+ 'genius',
181
+ 'content beats empty records even when empty provider is listed first'
182
+ );
183
+ divider();
184
+ console.log('autoPick: content beats empty (Melon-like empty loses to Genius with lyrics): ok');
185
+ }
186
+
187
+ function testAutoPickRicherContentWins() {
188
+ const shortLyrics = {
189
+ provider: 'providerA',
190
+ result: {
191
+ provider: 'providerA',
192
+ synced: false,
193
+ confidence: 0.9,
194
+ title: 'Song',
195
+ artist: 'Artist',
196
+ plainLyrics: 'one line',
197
+ syncedLyrics: null
198
+ }
199
+ };
200
+ const richLyrics = {
201
+ provider: 'providerB',
202
+ result: {
203
+ provider: 'providerB',
204
+ synced: false,
205
+ confidence: 0.1,
206
+ title: 'Song',
207
+ artist: 'Artist',
208
+ plainLyrics: Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'),
209
+ syncedLyrics: null
210
+ }
211
+ };
212
+
213
+ // providerA has higher confidence but fewer lines; providerB has more content
214
+ const chooser = buildChooserEntries([shortLyrics, richLyrics]);
215
+ const picked = autoPick(chooser, false);
216
+ assert.equal(
217
+ picked.provider,
218
+ 'providerB',
219
+ 'richer lyric content wins even when confidence score is lower'
220
+ );
221
+ divider();
222
+ console.log('autoPick: richer content wins over higher-confidence sparse result: ok');
223
+ }
224
+
225
+ function testAutoPickSyncedWithContentBeatsSyncedEmpty() {
226
+ const syncedEmpty = {
227
+ provider: 'syncedEmpty',
228
+ result: {
229
+ provider: 'syncedEmpty',
230
+ synced: true,
231
+ confidence: 0.8,
232
+ title: 'Song',
233
+ artist: 'Artist',
234
+ plainLyrics: null,
235
+ syncedLyrics: null // synced flag set but no actual lyrics string
236
+ }
237
+ };
238
+ const syncedWithContent = {
239
+ provider: 'syncedFull',
240
+ result: {
241
+ provider: 'syncedFull',
242
+ synced: true,
243
+ confidence: 0.5,
244
+ title: 'Song',
245
+ artist: 'Artist',
246
+ plainLyrics: null,
247
+ syncedLyrics: '[00:00.00] real line\n[00:02.00] another line\n[00:04.00] more'
248
+ }
249
+ };
250
+
251
+ const chooser = buildChooserEntries([syncedEmpty, syncedWithContent]);
252
+ const picked = autoPick(chooser, true);
253
+ assert.equal(
254
+ picked.provider,
255
+ 'syncedFull',
256
+ 'synced result with actual lyrics beats synced result with no lyrics'
257
+ );
258
+ divider();
259
+ console.log('autoPick: synced+content beats synced+empty even with lower confidence: ok');
260
+ }
261
+
262
+ async function testBuildPayloadFromResultReturnsCacheKey() {
263
+ // build a minimal find result with plain lyrics — no network call needed
264
+ const best = {
265
+ provider: 'genius',
266
+ title: 'Cigarette',
267
+ artist: 'Dylan Cotrone',
268
+ plainLyrics: 'I was always a mean kid\nCould not hold my tongue',
269
+ syncedLyrics: null,
270
+ synced: false
271
+ };
272
+ const result = { matches: [{ provider: 'genius', result: best }], best };
273
+ const context = buildActionContext({});
274
+ const payload = await buildPayloadFromResult(result, context);
275
+
276
+ assert.ok(payload.lyricsCacheKey, 'buildPayloadFromResult should include lyricsCacheKey when best has plain lyrics');
277
+ assert.equal(typeof payload.lyricsCacheKey, 'string', 'lyricsCacheKey should be a string');
278
+
279
+ // Verify the cache was actually populated
280
+ const cached = catalogCache.get(payload.lyricsCacheKey);
281
+ assert.ok(cached, 'catalog cache should have an entry for the returned key');
282
+ assert.ok(cached.plainLyrics, 'cached entry should include plain lyrics');
283
+
284
+ // Verify key stability — same artist/title always yields the same key
285
+ const expectedKey = catalogCacheKey({ artist: 'Dylan Cotrone', title: 'Cigarette' });
286
+ assert.equal(payload.lyricsCacheKey, expectedKey, 'lyricsCacheKey should match catalogCacheKey for the track');
287
+
288
+ divider();
289
+ console.log('buildPayloadFromResult returns lyricsCacheKey and populates cache: ok');
290
+ }
291
+
292
+ async function testBuildPayloadFromResultNoCacheKeyWhenNoLyrics() {
293
+ const best = {
294
+ provider: 'melon',
295
+ title: 'Cigarette',
296
+ artist: 'Dylan Cotrone',
297
+ plainLyrics: null,
298
+ syncedLyrics: null,
299
+ synced: false
300
+ };
301
+ const result = { matches: [], best };
302
+ const context = buildActionContext({});
303
+ const payload = await buildPayloadFromResult(result, context);
304
+
305
+ assert.ok(!payload.lyricsCacheKey, 'buildPayloadFromResult should NOT include lyricsCacheKey when best has no lyrics');
306
+
307
+ divider();
308
+ console.log('buildPayloadFromResult omits lyricsCacheKey when best has no lyrics: ok');
309
+ }
310
+
91
311
  async function run() {
92
312
  testAutoPickPrefersSynced();
93
313
  testAutoPickFallbackWhenNoSynced();
94
314
  testSelectMatchRespectsProviderAndSynced();
95
315
  testNormalizationDetectsSyncedState();
316
+ testLyricContentScoreEmpty();
317
+ testLyricContentScoreWithContent();
318
+ testLyricContentScoreRichness();
319
+ testCountLyricLines();
320
+ testAutoPickPrefersContentOverEmpty();
321
+ testAutoPickRicherContentWins();
322
+ testAutoPickSyncedWithContentBeatsSyncedEmpty();
323
+ await testBuildPayloadFromResultReturnsCacheKey();
324
+ await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
96
325
  const toolNames = mcpToolDefinitions.map((tool) => tool.name);
97
326
  console.log('MCP tooling available:', toolNames.join(', '));
98
327
  console.log('All sanity checks passed');
package/src/tools/cli.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import { exportLyrics, deriveFormatSet } from '../core/export.js';
23
23
  import { fetchFromMelon } from '../providers/melon.js';
24
24
  import { fetchFromGenius } from '../providers/genius.js';
25
+ import { lyricContentScore } from '../provider-result-schema.js';
25
26
 
26
27
  async function hydrateSearchResult(provider, result, cache) {
27
28
  if (result.plainLyrics || result.syncedLyrics) {
@@ -389,20 +390,56 @@ program
389
390
  })
390
391
  );
391
392
 
392
- let globalIndex = 1;
393
- const table = enriched.flatMap((entry) =>
394
- entry.results.map((result) => ({
395
- index: globalIndex++,
396
- provider: entry.provider,
397
- synced: result.synced,
398
- syncedRaw: result.synced,
399
- title: result.title,
400
- artist: result.artist,
401
- plainPreview: extractPlainPreview(result),
402
- syncedPreview: extractSyncedPreview(result),
403
- rawResult: result
404
- }))
405
- );
393
+ // Build rows without final indices first so we can filter/sort before numbering.
394
+ // Filter out any result that has no lyric content at all (empty stubs from lazy providers
395
+ // like Melon or Genius that were not successfully hydrated should not appear in results).
396
+ const unsortedRows = enriched
397
+ .flatMap((entry) =>
398
+ entry.results.map((result) => ({
399
+ provider: entry.provider,
400
+ synced: result.synced,
401
+ syncedRaw: result.synced,
402
+ title: result.title,
403
+ artist: result.artist,
404
+ plainPreview: extractPlainPreview(result),
405
+ syncedPreview: extractSyncedPreview(result),
406
+ rawResult: result
407
+ }))
408
+ )
409
+ .filter((row) => lyricContentScore(row.rawResult) > 0);
410
+
411
+ /**
412
+ * Score how well a result matches the user's query (0–2).
413
+ * +1.0 for title match (substring, case-insensitive)
414
+ * +1.0 for artist match (substring, case-insensitive)
415
+ * This is the strongest factor so that relevant results always lead,
416
+ * even if an irrelevant result from the same search happens to have more lyric text.
417
+ */
418
+ function queryRelevance(row) {
419
+ const qTitle = (track.title || '').toLowerCase();
420
+ const qArtist = (track.artist || '').toLowerCase();
421
+ const rTitle = (row.title || '').toLowerCase();
422
+ const rArtist = (row.artist || '').toLowerCase();
423
+ let score = 0;
424
+ if (qTitle && (rTitle.includes(qTitle) || qTitle.includes(rTitle))) score += 1;
425
+ if (qArtist && (rArtist.includes(qArtist) || qArtist.includes(rArtist))) score += 1;
426
+ return score;
427
+ }
428
+
429
+ // Sort priority:
430
+ // 1. Relevance to query (weight ×100) — ensures on-target results always lead
431
+ // 2. Has actual lyric content (weight ×10) — content beats empty stubs
432
+ // 3. Synced bonus (0.5) + content richness — finer tie-breaking
433
+ // Stable JS sort preserves original provider order within equal-scoring rows.
434
+ unsortedRows.sort((a, b) => {
435
+ const relevanceA = queryRelevance(a) * 100;
436
+ const relevanceB = queryRelevance(b) * 100;
437
+ const contentA = lyricContentScore(a.rawResult) * 10 + (a.syncedRaw ? 0.5 : 0);
438
+ const contentB = lyricContentScore(b.rawResult) * 10 + (b.syncedRaw ? 0.5 : 0);
439
+ return relevanceB + contentB - (relevanceA + contentA);
440
+ });
441
+
442
+ const table = unsortedRows.map((row, idx) => ({ index: idx + 1, ...row }));
406
443
 
407
444
  if (table.length === 0) {
408
445
  console.log('No matches found');
@@ -594,7 +631,13 @@ program
594
631
  providerNames,
595
632
  syncedOnly: options.syncedOnly
596
633
  });
597
- chooserEntries = buildChooserEntries(Array.isArray(result.matches) ? result.matches : []);
634
+ // Filter out matches with no lyric content before building the chooser so that
635
+ // empty provider stubs (e.g. Melon returning a shell record with no lyrics) don't
636
+ // appear as selectable options.
637
+ const contentMatches = (Array.isArray(result.matches) ? result.matches : []).filter(
638
+ (entry) => lyricContentScore(entry.result) > 0
639
+ );
640
+ chooserEntries = buildChooserEntries(contentMatches);
598
641
  totalResults = chooserEntries.length;
599
642
 
600
643
  if ((options.choose || options.index) && totalResults === 0) {
@@ -1,13 +1,15 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
2
 
3
3
  import { runFind, runSearch, runProviderSearch } from '../core/find-service.js';
4
+ import { lyricContentScore } from '../provider-result-schema.js';
4
5
  import {
5
6
  buildActionContext,
6
7
  buildPayloadFromResult,
7
8
  buildCatalogPayload,
8
9
  exportBestResult,
9
10
  formatRecord,
10
- catalogCache
11
+ catalogCache,
12
+ catalogCacheKey
11
13
  } from '../services/lyrics-service.js';
12
14
  import { pushCatalogToAirtable } from '../services/airtable-writer.js';
13
15
  import { getProviderStatus } from '../index.js';
@@ -170,7 +172,10 @@ const matchSchema = {
170
172
  export const mcpToolDefinitions = [
171
173
  {
172
174
  name: 'find_lyrics',
173
- description: 'Find the best lyric match across providers (prefers synced when available).',
175
+ description:
176
+ 'Find the best lyric match across providers (prefers synced when available). ' +
177
+ 'Returns lyricsCacheKey when lyrics are resolved — pass this to push_catalog_to_airtable ' +
178
+ 'without calling build_catalog_payload first.',
174
179
  inputSchema: {
175
180
  type: 'object',
176
181
  description: 'Provide a track description (and optional hints) to look up lyrics.',
@@ -201,7 +206,9 @@ export const mcpToolDefinitions = [
201
206
  },
202
207
  {
203
208
  name: 'find_synced_lyrics',
204
- description: 'Find lyrics but reject any candidates that lack timestamps.',
209
+ description:
210
+ 'Find lyrics but reject any candidates that lack timestamps. ' +
211
+ 'Returns lyricsCacheKey when synced lyrics are resolved.',
205
212
  inputSchema: {
206
213
  type: 'object',
207
214
  description:
@@ -252,7 +259,9 @@ export const mcpToolDefinitions = [
252
259
  },
253
260
  {
254
261
  name: 'export_lyrics',
255
- description: 'Find lyrics and save plain/LRC/SRT plus romanized variants to disk.',
262
+ description:
263
+ 'Find lyrics and save plain/LRC/SRT plus romanized variants to disk. ' +
264
+ 'Also returns lyricsCacheKey so push_catalog_to_airtable can be called immediately.',
256
265
  inputSchema: {
257
266
  type: 'object',
258
267
  description: 'Provide the track and export options to write files to disk.',
@@ -265,7 +274,9 @@ export const mcpToolDefinitions = [
265
274
  },
266
275
  {
267
276
  name: 'format_lyrics',
268
- description: 'Find lyrics and return formatted text (with optional romanization) in-memory.',
277
+ description:
278
+ 'Find lyrics and return formatted text (with optional romanization) in-memory. ' +
279
+ 'Also returns lyricsCacheKey so push_catalog_to_airtable can be called immediately.',
269
280
  inputSchema: {
270
281
  type: 'object',
271
282
  description: 'Provide the track and formatting options for in-memory rendering.',
@@ -406,7 +417,19 @@ export async function handleMcpTool(name, args = {}) {
406
417
  includeRomanization: !options?.noRomanize,
407
418
  includeSynced: options?.includeSynced ?? true
408
419
  });
409
- return { formatted, best };
420
+ // Populate the catalog cache so push_catalog_to_airtable can be used
421
+ // immediately without a separate build_catalog_payload call.
422
+ let lyricsCacheKey = null;
423
+ const plainLyrics = formatted.plainLyrics || best.plainLyrics || '';
424
+ if (plainLyrics) {
425
+ lyricsCacheKey = catalogCacheKey({ artist: best.artist, title: best.title });
426
+ catalogCache.set(lyricsCacheKey, {
427
+ plainLyrics,
428
+ romanizedPlainLyrics: formatted.romanizedPlain || null,
429
+ preferRomanized: !options?.noRomanize
430
+ });
431
+ }
432
+ return { formatted, best, lyricsCacheKey };
410
433
  }
411
434
 
412
435
  if (name === 'build_catalog_payload') {
@@ -422,7 +445,12 @@ export async function handleMcpTool(name, args = {}) {
422
445
  return { error: 'No matches provided' };
423
446
  }
424
447
  const { provider, requireSynced, index } = args.criteria || {};
425
- let filtered = matches;
448
+ // Strip entries with no actual lyric content (e.g. unhydrated search stubs)
449
+ // so the caller never accidentally selects an empty result.
450
+ let filtered = matches.filter((entry) => lyricContentScore(entry.result) > 0);
451
+ if (filtered.length === 0) {
452
+ return { error: 'No matches with lyric content found' };
453
+ }
426
454
  if (provider) {
427
455
  filtered = filtered.filter((entry) => entry.provider === provider);
428
456
  }