mr-magic-mcp-server 0.3.4 → 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 +1 -1
- package/src/index.js +15 -5
- package/src/services/lyrics-service.js +15 -0
- package/src/tests/mcp-search-find-verify.mjs +12 -0
- package/src/tests/run-tests.js +103 -0
- package/src/transport/mcp-tools.js +28 -6
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
@@ -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
|
}
|
|
@@ -19,6 +19,7 @@ matchSummary.forEach((m) => console.log(' ', JSON.stringify(m)));
|
|
|
19
19
|
console.log('best provider:', findResult.best?.provider ?? 'none');
|
|
20
20
|
console.log('best hasPlain:', Boolean(findResult.best?.plainLyrics?.trim()));
|
|
21
21
|
console.log('best hasSynced:', Boolean(findResult.best?.syncedLyrics?.trim()));
|
|
22
|
+
console.log('lyricsCacheKey:', findResult.lyricsCacheKey ?? '(none)');
|
|
22
23
|
|
|
23
24
|
const emptyMatches = matchSummary.filter((m) => !m.hasPlain && !m.hasSynced);
|
|
24
25
|
if (emptyMatches.length > 0) {
|
|
@@ -28,6 +29,17 @@ if (emptyMatches.length > 0) {
|
|
|
28
29
|
console.log('PASS: all matches have lyric content');
|
|
29
30
|
}
|
|
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
|
+
|
|
31
43
|
// ── search_lyrics ─────────────────────────────────────────────────────────────
|
|
32
44
|
console.log('');
|
|
33
45
|
console.log('=== search_lyrics (raw catalog stubs — no hydration by design) ===');
|
package/src/tests/run-tests.js
CHANGED
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
lyricContentScore,
|
|
10
10
|
countLyricLines
|
|
11
11
|
} from '../provider-result-schema.js';
|
|
12
|
+
import {
|
|
13
|
+
buildPayloadFromResult,
|
|
14
|
+
buildActionContext,
|
|
15
|
+
catalogCacheKey,
|
|
16
|
+
catalogCache
|
|
17
|
+
} from '../services/lyrics-service.js';
|
|
12
18
|
import { mcpToolDefinitions, handleMcpTool } from '../transport/mcp-tools.js';
|
|
13
19
|
|
|
14
20
|
const divider = () => console.log('\n---');
|
|
@@ -253,6 +259,100 @@ function testAutoPickSyncedWithContentBeatsSyncedEmpty() {
|
|
|
253
259
|
console.log('autoPick: synced+content beats synced+empty even with lower confidence: ok');
|
|
254
260
|
}
|
|
255
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
|
+
|
|
297
|
+
async function testBuildPayloadFromResultReturnsCacheKey() {
|
|
298
|
+
// build a minimal find result with plain lyrics — no network call needed
|
|
299
|
+
const best = {
|
|
300
|
+
provider: 'genius',
|
|
301
|
+
title: 'Cigarette',
|
|
302
|
+
artist: 'Dylan Cotrone',
|
|
303
|
+
plainLyrics: 'I was always a mean kid\nCould not hold my tongue',
|
|
304
|
+
syncedLyrics: null,
|
|
305
|
+
synced: false
|
|
306
|
+
};
|
|
307
|
+
const result = { matches: [{ provider: 'genius', result: best }], best };
|
|
308
|
+
const context = buildActionContext({});
|
|
309
|
+
const payload = await buildPayloadFromResult(result, context);
|
|
310
|
+
|
|
311
|
+
assert.ok(
|
|
312
|
+
payload.lyricsCacheKey,
|
|
313
|
+
'buildPayloadFromResult should include lyricsCacheKey when best has plain lyrics'
|
|
314
|
+
);
|
|
315
|
+
assert.equal(typeof payload.lyricsCacheKey, 'string', 'lyricsCacheKey should be a string');
|
|
316
|
+
|
|
317
|
+
// Verify the cache was actually populated
|
|
318
|
+
const cached = catalogCache.get(payload.lyricsCacheKey);
|
|
319
|
+
assert.ok(cached, 'catalog cache should have an entry for the returned key');
|
|
320
|
+
assert.ok(cached.plainLyrics, 'cached entry should include plain lyrics');
|
|
321
|
+
|
|
322
|
+
// Verify key stability — same artist/title always yields the same key
|
|
323
|
+
const expectedKey = catalogCacheKey({ artist: 'Dylan Cotrone', title: 'Cigarette' });
|
|
324
|
+
assert.equal(
|
|
325
|
+
payload.lyricsCacheKey,
|
|
326
|
+
expectedKey,
|
|
327
|
+
'lyricsCacheKey should match catalogCacheKey for the track'
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
divider();
|
|
331
|
+
console.log('buildPayloadFromResult returns lyricsCacheKey and populates cache: ok');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function testBuildPayloadFromResultNoCacheKeyWhenNoLyrics() {
|
|
335
|
+
const best = {
|
|
336
|
+
provider: 'melon',
|
|
337
|
+
title: 'Cigarette',
|
|
338
|
+
artist: 'Dylan Cotrone',
|
|
339
|
+
plainLyrics: null,
|
|
340
|
+
syncedLyrics: null,
|
|
341
|
+
synced: false
|
|
342
|
+
};
|
|
343
|
+
const result = { matches: [], best };
|
|
344
|
+
const context = buildActionContext({});
|
|
345
|
+
const payload = await buildPayloadFromResult(result, context);
|
|
346
|
+
|
|
347
|
+
assert.ok(
|
|
348
|
+
!payload.lyricsCacheKey,
|
|
349
|
+
'buildPayloadFromResult should NOT include lyricsCacheKey when best has no lyrics'
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
divider();
|
|
353
|
+
console.log('buildPayloadFromResult omits lyricsCacheKey when best has no lyrics: ok');
|
|
354
|
+
}
|
|
355
|
+
|
|
256
356
|
async function run() {
|
|
257
357
|
testAutoPickPrefersSynced();
|
|
258
358
|
testAutoPickFallbackWhenNoSynced();
|
|
@@ -265,6 +365,9 @@ async function run() {
|
|
|
265
365
|
testAutoPickPrefersContentOverEmpty();
|
|
266
366
|
testAutoPickRicherContentWins();
|
|
267
367
|
testAutoPickSyncedWithContentBeatsSyncedEmpty();
|
|
368
|
+
testEmptyRecordNeverBecomesBest();
|
|
369
|
+
await testBuildPayloadFromResultReturnsCacheKey();
|
|
370
|
+
await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
|
|
268
371
|
const toolNames = mcpToolDefinitions.map((tool) => tool.name);
|
|
269
372
|
console.log('MCP tooling available:', toolNames.join(', '));
|
|
270
373
|
console.log('All sanity checks passed');
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
buildCatalogPayload,
|
|
9
9
|
exportBestResult,
|
|
10
10
|
formatRecord,
|
|
11
|
-
catalogCache
|
|
11
|
+
catalogCache,
|
|
12
|
+
catalogCacheKey
|
|
12
13
|
} from '../services/lyrics-service.js';
|
|
13
14
|
import { pushCatalogToAirtable } from '../services/airtable-writer.js';
|
|
14
15
|
import { getProviderStatus } from '../index.js';
|
|
@@ -171,7 +172,10 @@ const matchSchema = {
|
|
|
171
172
|
export const mcpToolDefinitions = [
|
|
172
173
|
{
|
|
173
174
|
name: 'find_lyrics',
|
|
174
|
-
description:
|
|
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.',
|
|
175
179
|
inputSchema: {
|
|
176
180
|
type: 'object',
|
|
177
181
|
description: 'Provide a track description (and optional hints) to look up lyrics.',
|
|
@@ -202,7 +206,9 @@ export const mcpToolDefinitions = [
|
|
|
202
206
|
},
|
|
203
207
|
{
|
|
204
208
|
name: 'find_synced_lyrics',
|
|
205
|
-
description:
|
|
209
|
+
description:
|
|
210
|
+
'Find lyrics but reject any candidates that lack timestamps. ' +
|
|
211
|
+
'Returns lyricsCacheKey when synced lyrics are resolved.',
|
|
206
212
|
inputSchema: {
|
|
207
213
|
type: 'object',
|
|
208
214
|
description:
|
|
@@ -253,7 +259,9 @@ export const mcpToolDefinitions = [
|
|
|
253
259
|
},
|
|
254
260
|
{
|
|
255
261
|
name: 'export_lyrics',
|
|
256
|
-
description:
|
|
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.',
|
|
257
265
|
inputSchema: {
|
|
258
266
|
type: 'object',
|
|
259
267
|
description: 'Provide the track and export options to write files to disk.',
|
|
@@ -266,7 +274,9 @@ export const mcpToolDefinitions = [
|
|
|
266
274
|
},
|
|
267
275
|
{
|
|
268
276
|
name: 'format_lyrics',
|
|
269
|
-
description:
|
|
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.',
|
|
270
280
|
inputSchema: {
|
|
271
281
|
type: 'object',
|
|
272
282
|
description: 'Provide the track and formatting options for in-memory rendering.',
|
|
@@ -407,7 +417,19 @@ export async function handleMcpTool(name, args = {}) {
|
|
|
407
417
|
includeRomanization: !options?.noRomanize,
|
|
408
418
|
includeSynced: options?.includeSynced ?? true
|
|
409
419
|
});
|
|
410
|
-
|
|
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 };
|
|
411
433
|
}
|
|
412
434
|
|
|
413
435
|
if (name === 'build_catalog_payload') {
|