mr-magic-mcp-server 0.3.5 → 0.3.6

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.5",
3
+ "version": "0.3.6",
4
4
  "description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -43,7 +43,9 @@ function rankRecord(record) {
43
43
  async function tryProviders(track, { syncedOnly = false, providerNames = [] } = {}) {
44
44
  const matches = [];
45
45
  let bestSynced = null;
46
- let bestOverall = null;
46
+ // Only track candidates that have actual lyric text — metadata-only stubs
47
+ // (e.g. Melon returning a record with plainLyrics: null) never become best.
48
+ let bestWithContent = null;
47
49
  const chosenProviders =
48
50
  providerNames.length > 0
49
51
  ? providers.filter((provider) => providerNames.includes(provider.name))
@@ -56,15 +58,23 @@ async function tryProviders(track, { syncedOnly = false, providerNames = [] } =
56
58
  const scored = { provider: provider.name, result: candidate, score: rankRecord(candidate) };
57
59
  matches.push(scored);
58
60
 
59
- if (!bestOverall || scored.score > bestOverall.score) {
60
- bestOverall = scored;
61
+ // Only promote as best when the candidate actually has lyric content.
62
+ if (lyricContentScore(candidate) > 0) {
63
+ if (!bestWithContent || scored.score > bestWithContent.score) {
64
+ bestWithContent = scored;
65
+ }
61
66
  }
62
- if (candidate.synced && (!bestSynced || scored.score > bestSynced.score)) {
67
+ if (
68
+ candidate.synced &&
69
+ lyricContentScore(candidate) > 0 &&
70
+ (!bestSynced || scored.score > bestSynced.score)
71
+ ) {
63
72
  bestSynced = scored;
64
73
  }
65
74
  }
66
75
 
67
- const best = syncedOnly ? (bestSynced ?? null) : (bestSynced ?? bestOverall ?? null);
76
+ // best is null when no provider returned actual lyric content for this track.
77
+ const best = syncedOnly ? (bestSynced ?? null) : (bestSynced ?? bestWithContent ?? null);
68
78
  return {
69
79
  matches,
70
80
  best
@@ -259,6 +259,41 @@ function testAutoPickSyncedWithContentBeatsSyncedEmpty() {
259
259
  console.log('autoPick: synced+content beats synced+empty even with lower confidence: ok');
260
260
  }
261
261
 
262
+ function testEmptyRecordNeverBecomesBest() {
263
+ // Simulate the tryProviders bestWithContent guard directly.
264
+ // When all candidates are metadata-only (no lyric text), bestWithContent must stay null.
265
+ const emptyRecords = [
266
+ { plainLyrics: null, syncedLyrics: null, synced: false, confidence: 0.5 }, // Melon stub
267
+ { plainLyrics: '', syncedLyrics: null, synced: false, confidence: 0.3 } // empty string
268
+ ];
269
+ let bestWithContent = null;
270
+ for (const candidate of emptyRecords) {
271
+ if (lyricContentScore(candidate) > 0) {
272
+ bestWithContent = candidate;
273
+ }
274
+ }
275
+ assert.ok(
276
+ bestWithContent === null,
277
+ 'metadata-only records must not be promoted as best — bestWithContent should remain null'
278
+ );
279
+
280
+ // Contrast: a record with actual text IS selected
281
+ const withLyrics = { plainLyrics: 'line one\nline two', syncedLyrics: null, confidence: 0.1 };
282
+ let bestWithContentFromMixed = null;
283
+ for (const candidate of [...emptyRecords, withLyrics]) {
284
+ if (lyricContentScore(candidate) > 0) {
285
+ bestWithContentFromMixed = candidate;
286
+ }
287
+ }
288
+ assert.ok(
289
+ bestWithContentFromMixed === withLyrics,
290
+ 'when a content-bearing record is present, it should be promoted over empty ones'
291
+ );
292
+
293
+ divider();
294
+ console.log('empty records never become best — content guard works: ok');
295
+ }
296
+
262
297
  async function testBuildPayloadFromResultReturnsCacheKey() {
263
298
  // build a minimal find result with plain lyrics — no network call needed
264
299
  const best = {
@@ -273,7 +308,10 @@ async function testBuildPayloadFromResultReturnsCacheKey() {
273
308
  const context = buildActionContext({});
274
309
  const payload = await buildPayloadFromResult(result, context);
275
310
 
276
- assert.ok(payload.lyricsCacheKey, 'buildPayloadFromResult should include lyricsCacheKey when best has plain lyrics');
311
+ assert.ok(
312
+ payload.lyricsCacheKey,
313
+ 'buildPayloadFromResult should include lyricsCacheKey when best has plain lyrics'
314
+ );
277
315
  assert.equal(typeof payload.lyricsCacheKey, 'string', 'lyricsCacheKey should be a string');
278
316
 
279
317
  // Verify the cache was actually populated
@@ -283,7 +321,11 @@ async function testBuildPayloadFromResultReturnsCacheKey() {
283
321
 
284
322
  // Verify key stability — same artist/title always yields the same key
285
323
  const expectedKey = catalogCacheKey({ artist: 'Dylan Cotrone', title: 'Cigarette' });
286
- assert.equal(payload.lyricsCacheKey, expectedKey, 'lyricsCacheKey should match catalogCacheKey for the track');
324
+ assert.equal(
325
+ payload.lyricsCacheKey,
326
+ expectedKey,
327
+ 'lyricsCacheKey should match catalogCacheKey for the track'
328
+ );
287
329
 
288
330
  divider();
289
331
  console.log('buildPayloadFromResult returns lyricsCacheKey and populates cache: ok');
@@ -302,7 +344,10 @@ async function testBuildPayloadFromResultNoCacheKeyWhenNoLyrics() {
302
344
  const context = buildActionContext({});
303
345
  const payload = await buildPayloadFromResult(result, context);
304
346
 
305
- assert.ok(!payload.lyricsCacheKey, 'buildPayloadFromResult should NOT include lyricsCacheKey when best has no lyrics');
347
+ assert.ok(
348
+ !payload.lyricsCacheKey,
349
+ 'buildPayloadFromResult should NOT include lyricsCacheKey when best has no lyrics'
350
+ );
306
351
 
307
352
  divider();
308
353
  console.log('buildPayloadFromResult omits lyricsCacheKey when best has no lyrics: ok');
@@ -320,6 +365,7 @@ async function run() {
320
365
  testAutoPickPrefersContentOverEmpty();
321
366
  testAutoPickRicherContentWins();
322
367
  testAutoPickSyncedWithContentBeatsSyncedEmpty();
368
+ testEmptyRecordNeverBecomesBest();
323
369
  await testBuildPayloadFromResultReturnsCacheKey();
324
370
  await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
325
371
  const toolNames = mcpToolDefinitions.map((tool) => tool.name);