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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-magic-mcp-server",
3
- "version": "0.3.4",
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
@@ -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) ===');
@@ -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: '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.',
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: '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.',
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: '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.',
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: '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.',
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
- 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 };
411
433
  }
412
434
 
413
435
  if (name === 'build_catalog_payload') {