mr-magic-mcp-server 0.3.3 → 0.3.4

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.4",
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,
@@ -0,0 +1,43 @@
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
+
23
+ const emptyMatches = matchSummary.filter((m) => !m.hasPlain && !m.hasSynced);
24
+ if (emptyMatches.length > 0) {
25
+ console.error('FAIL: find_lyrics returned empty-content matches:', emptyMatches);
26
+ process.exitCode = 1;
27
+ } else {
28
+ console.log('PASS: all matches have lyric content');
29
+ }
30
+
31
+ // ── search_lyrics ─────────────────────────────────────────────────────────────
32
+ console.log('');
33
+ console.log('=== search_lyrics (raw catalog stubs — no hydration by design) ===');
34
+ const searchResult = await handleMcpTool('search_lyrics', { track });
35
+ for (const entry of searchResult) {
36
+ const withContent = entry.results.filter(
37
+ (r) => (r.plainLyrics || '').trim() || (r.syncedLyrics || '').trim()
38
+ ).length;
39
+ console.log(
40
+ ` ${entry.provider}: ${entry.results.length} results, ${withContent} already have lyric text`
41
+ );
42
+ }
43
+ console.log('NOTE: search_lyrics returns raw stubs intentionally — hydration is not applied here.');
@@ -3,7 +3,12 @@ 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';
7
12
  import { mcpToolDefinitions, handleMcpTool } from '../transport/mcp-tools.js';
8
13
 
9
14
  const divider = () => console.log('\n---');
@@ -88,11 +93,178 @@ function testNormalizationDetectsSyncedState() {
88
93
  console.log('normalize/detect synced state: ok');
89
94
  }
90
95
 
96
+ function testLyricContentScoreEmpty() {
97
+ const empty = { plainLyrics: null, syncedLyrics: null };
98
+ assert.equal(lyricContentScore(empty), 0, 'empty record should score 0');
99
+ assert.equal(lyricContentScore(null), 0, 'null record should score 0');
100
+ const blankString = { plainLyrics: ' ', syncedLyrics: '' };
101
+ assert.equal(lyricContentScore(blankString), 0, 'whitespace-only record should score 0');
102
+ divider();
103
+ console.log('lyricContentScore empty records → 0: ok');
104
+ }
105
+
106
+ function testLyricContentScoreWithContent() {
107
+ const plain = { plainLyrics: 'line one\nline two\nline three', syncedLyrics: null };
108
+ const score = lyricContentScore(plain);
109
+ assert.ok(score > 0, 'record with plain lyrics should score > 0');
110
+ assert.ok(score >= 0.5, 'base score should be at least 0.5');
111
+ divider();
112
+ console.log('lyricContentScore with content > 0: ok');
113
+ }
114
+
115
+ function testLyricContentScoreRichness() {
116
+ const short = { plainLyrics: 'line one', syncedLyrics: null };
117
+ const longLines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
118
+ const long = { plainLyrics: longLines, syncedLyrics: null };
119
+ assert.ok(
120
+ lyricContentScore(long) > lyricContentScore(short),
121
+ 'longer lyrics should score higher'
122
+ );
123
+ divider();
124
+ console.log('lyricContentScore richness (longer > shorter): ok');
125
+ }
126
+
127
+ function testCountLyricLines() {
128
+ assert.equal(countLyricLines(null), 0, 'null input → 0');
129
+ assert.equal(countLyricLines(''), 0, 'empty string → 0');
130
+ assert.equal(countLyricLines('line one\nline two\nline three'), 3, 'three lines');
131
+ // LRC timestamps should be stripped before counting
132
+ assert.equal(
133
+ countLyricLines('[00:01.00] first line\n[00:02.00] second line'),
134
+ 2,
135
+ 'LRC lines strip timestamps'
136
+ );
137
+ divider();
138
+ console.log('countLyricLines: ok');
139
+ }
140
+
141
+ function testAutoPickPrefersContentOverEmpty() {
142
+ // melon-like entry: found in search but lyrics not yet fetched (empty)
143
+ const emptyMelon = {
144
+ provider: 'melon',
145
+ result: {
146
+ provider: 'melon',
147
+ synced: false,
148
+ confidence: 0.5,
149
+ title: 'Song',
150
+ artist: 'Artist',
151
+ plainLyrics: null,
152
+ syncedLyrics: null
153
+ }
154
+ };
155
+ // genius-like entry: has actual plain lyrics
156
+ const geniusWithLyrics = {
157
+ provider: 'genius',
158
+ result: {
159
+ provider: 'genius',
160
+ synced: false,
161
+ confidence: 0.3,
162
+ title: 'Song',
163
+ artist: 'Artist',
164
+ plainLyrics: 'I, I was always a mean kid\nCouldnt hold my tongue',
165
+ syncedLyrics: null
166
+ }
167
+ };
168
+
169
+ // Melon comes first in the list (simulating provider priority order) but is empty
170
+ const chooser = buildChooserEntries([emptyMelon, geniusWithLyrics]);
171
+ const picked = autoPick(chooser, true);
172
+ assert.equal(
173
+ picked.provider,
174
+ 'genius',
175
+ 'content beats empty records even when empty provider is listed first'
176
+ );
177
+ divider();
178
+ console.log('autoPick: content beats empty (Melon-like empty loses to Genius with lyrics): ok');
179
+ }
180
+
181
+ function testAutoPickRicherContentWins() {
182
+ const shortLyrics = {
183
+ provider: 'providerA',
184
+ result: {
185
+ provider: 'providerA',
186
+ synced: false,
187
+ confidence: 0.9,
188
+ title: 'Song',
189
+ artist: 'Artist',
190
+ plainLyrics: 'one line',
191
+ syncedLyrics: null
192
+ }
193
+ };
194
+ const richLyrics = {
195
+ provider: 'providerB',
196
+ result: {
197
+ provider: 'providerB',
198
+ synced: false,
199
+ confidence: 0.1,
200
+ title: 'Song',
201
+ artist: 'Artist',
202
+ plainLyrics: Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'),
203
+ syncedLyrics: null
204
+ }
205
+ };
206
+
207
+ // providerA has higher confidence but fewer lines; providerB has more content
208
+ const chooser = buildChooserEntries([shortLyrics, richLyrics]);
209
+ const picked = autoPick(chooser, false);
210
+ assert.equal(
211
+ picked.provider,
212
+ 'providerB',
213
+ 'richer lyric content wins even when confidence score is lower'
214
+ );
215
+ divider();
216
+ console.log('autoPick: richer content wins over higher-confidence sparse result: ok');
217
+ }
218
+
219
+ function testAutoPickSyncedWithContentBeatsSyncedEmpty() {
220
+ const syncedEmpty = {
221
+ provider: 'syncedEmpty',
222
+ result: {
223
+ provider: 'syncedEmpty',
224
+ synced: true,
225
+ confidence: 0.8,
226
+ title: 'Song',
227
+ artist: 'Artist',
228
+ plainLyrics: null,
229
+ syncedLyrics: null // synced flag set but no actual lyrics string
230
+ }
231
+ };
232
+ const syncedWithContent = {
233
+ provider: 'syncedFull',
234
+ result: {
235
+ provider: 'syncedFull',
236
+ synced: true,
237
+ confidence: 0.5,
238
+ title: 'Song',
239
+ artist: 'Artist',
240
+ plainLyrics: null,
241
+ syncedLyrics: '[00:00.00] real line\n[00:02.00] another line\n[00:04.00] more'
242
+ }
243
+ };
244
+
245
+ const chooser = buildChooserEntries([syncedEmpty, syncedWithContent]);
246
+ const picked = autoPick(chooser, true);
247
+ assert.equal(
248
+ picked.provider,
249
+ 'syncedFull',
250
+ 'synced result with actual lyrics beats synced result with no lyrics'
251
+ );
252
+ divider();
253
+ console.log('autoPick: synced+content beats synced+empty even with lower confidence: ok');
254
+ }
255
+
91
256
  async function run() {
92
257
  testAutoPickPrefersSynced();
93
258
  testAutoPickFallbackWhenNoSynced();
94
259
  testSelectMatchRespectsProviderAndSynced();
95
260
  testNormalizationDetectsSyncedState();
261
+ testLyricContentScoreEmpty();
262
+ testLyricContentScoreWithContent();
263
+ testLyricContentScoreRichness();
264
+ testCountLyricLines();
265
+ testAutoPickPrefersContentOverEmpty();
266
+ testAutoPickRicherContentWins();
267
+ testAutoPickSyncedWithContentBeatsSyncedEmpty();
96
268
  const toolNames = mcpToolDefinitions.map((tool) => tool.name);
97
269
  console.log('MCP tooling available:', toolNames.join(', '));
98
270
  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,6 +1,7 @@
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,
@@ -422,7 +423,12 @@ export async function handleMcpTool(name, args = {}) {
422
423
  return { error: 'No matches provided' };
423
424
  }
424
425
  const { provider, requireSynced, index } = args.criteria || {};
425
- let filtered = matches;
426
+ // Strip entries with no actual lyric content (e.g. unhydrated search stubs)
427
+ // so the caller never accidentally selects an empty result.
428
+ let filtered = matches.filter((entry) => lyricContentScore(entry.result) > 0);
429
+ if (filtered.length === 0) {
430
+ return { error: 'No matches with lyric content found' };
431
+ }
426
432
  if (provider) {
427
433
  filtered = filtered.filter((entry) => entry.provider === provider);
428
434
  }