mr-magic-mcp-server 0.3.2 → 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 +3 -3
- package/src/core/find-service.js +38 -4
- package/src/index.js +25 -4
- package/src/provider-result-schema.js +41 -0
- package/src/tests/mcp-search-find-verify.mjs +43 -0
- package/src/tests/run-tests.js +173 -1
- package/src/tools/cli.js +58 -15
- package/src/transport/mcp-tools.js +7 -1
- package/src/utils/config.js +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-magic-mcp-server",
|
|
3
|
-
"version": "0.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",
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"node": ">=20"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
+
"@dotenvx/dotenvx": "^1.55.1",
|
|
62
63
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
63
64
|
"axios": "^1.13.6",
|
|
64
65
|
"cheerio": "^1.2.0",
|
|
65
|
-
"commander": "^14.0.3"
|
|
66
|
-
"dotenv": "^17.3.1"
|
|
66
|
+
"commander": "^14.0.3"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"eslint": "^10.0.3",
|
package/src/core/find-service.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
37
|
+
const contentScore = lyricContentScore(record) * 10; // 0 or 5..10
|
|
24
38
|
const syncedBonus = record?.synced ? 0.5 : 0;
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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.');
|
package/src/tests/run-tests.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import dotenvx from '@dotenvx/dotenvx';
|
|
5
5
|
|
|
6
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
7
|
const __dirname = path.dirname(__filename);
|
|
@@ -9,7 +9,11 @@ const projectRoot = path.resolve(__dirname, '..', '..');
|
|
|
9
9
|
const resolvedRoot = process.env.MR_MAGIC_ROOT || projectRoot;
|
|
10
10
|
const resolvedEnvPath = process.env.MR_MAGIC_ENV_PATH || path.join(resolvedRoot, '.env');
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Only load .env on local instances — skip on any known server environment
|
|
13
|
+
// (Render sets RENDER automatically; NODE_ENV=production covers other platforms).
|
|
14
|
+
if (!process.env.RENDER && process.env.NODE_ENV !== 'production') {
|
|
15
|
+
dotenvx.config({ path: resolvedEnvPath });
|
|
16
|
+
}
|
|
13
17
|
|
|
14
18
|
export function getProjectRoot() {
|
|
15
19
|
return resolvedRoot;
|