mr-magic-mcp-server 0.3.9 → 0.3.11

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.
@@ -1,17 +1,19 @@
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
+ import { extractPlainPreview, extractSyncedPreview } from '../core/preview.js';
5
+ import { buildProviderReferenceFingerprint, lyricContentScore } from '../provider-result-schema.js';
5
6
  import {
6
7
  buildActionContext,
7
8
  buildPayloadFromResult,
8
9
  buildCatalogPayload,
10
+ buildCatalogPayloadFromResult,
9
11
  formatRecord,
10
12
  catalogCache,
11
13
  catalogCacheKey
12
14
  } from '../services/lyrics-service.js';
13
15
  import { pushCatalogToAirtable } from '../services/airtable-writer.js';
14
- import { getProviderStatus } from '../index.js';
16
+ import { getProviderStatus, resolveProviderReference } from '../index.js';
15
17
 
16
18
  const trackSchema = {
17
19
  type: 'object',
@@ -120,22 +122,56 @@ const selectCriteriaSchema = {
120
122
  additionalProperties: false
121
123
  };
122
124
 
123
- const normalizedLyricRecordSchema = {
125
+ const providerReferenceSchema = {
124
126
  type: 'object',
125
- description: 'Normalized lyric record as returned by a provider search.',
127
+ description: 'Compact provider reference returned by MCP search tools for exact recall.',
126
128
  properties: {
127
129
  provider: { type: 'string', description: 'Provider slug for the result.' },
128
- providerId: { type: 'string', description: 'Provider-specific identifier if available.' },
129
- title: { type: 'string', description: 'Track title from the provider result.' },
130
- artist: { type: 'string', description: 'Artist name from the provider result.' },
131
- album: { type: 'string', description: 'Album name if provided.' },
130
+ providerId: {
131
+ type: ['string', 'null'],
132
+ description: 'Provider-specific identifier if available.'
133
+ },
134
+ ids: {
135
+ type: 'object',
136
+ description: 'Optional provider-specific identifiers such as Melon songId.',
137
+ additionalProperties: { type: 'string' }
138
+ },
139
+ title: { type: ['string', 'null'], description: 'Track title for provider recall.' },
140
+ artist: { type: ['string', 'null'], description: 'Artist name for provider recall.' },
141
+ album: { type: ['string', 'null'], description: 'Album name for provider recall.' },
132
142
  duration: {
133
- type: 'string',
143
+ type: ['number', 'string', 'null'],
144
+ description: 'Track duration hint used when replaying provider lookups.'
145
+ },
146
+ sourceUrl: {
147
+ type: ['string', 'null'],
148
+ description: 'Canonical source URL for exact-result recall when available.'
149
+ },
150
+ fingerprint: {
151
+ type: ['string', 'null'],
152
+ description: 'Stable fingerprint for recalling preview-only matches without provider IDs.'
153
+ }
154
+ },
155
+ additionalProperties: false
156
+ };
157
+
158
+ const compactSearchResultSchema = {
159
+ type: 'object',
160
+ description: 'Compact MCP search result preview. Search tools never return full lyrics.',
161
+ properties: {
162
+ provider: { type: 'string', description: 'Provider slug for the result.' },
163
+ providerId: {
164
+ type: ['string', 'null'],
165
+ description: 'Provider-specific identifier if available.'
166
+ },
167
+ title: { type: ['string', 'null'], description: 'Track title from the provider result.' },
168
+ artist: { type: ['string', 'null'], description: 'Artist name from the provider result.' },
169
+ album: { type: ['string', 'null'], description: 'Album name if provided.' },
170
+ duration: {
171
+ type: ['number', 'null'],
134
172
  description: 'Duration (seconds) if reported.'
135
173
  },
136
- plainLyrics: { type: 'string', description: 'Plain lyric text if hydrated.' },
137
- syncedLyrics: { type: 'string', description: 'Synced lyric text if hydrated.' },
138
- sourceUrl: { type: 'string', description: 'Canonical URL to view the lyrics.' },
174
+ sourceUrl: { type: ['string', 'null'], description: 'Canonical URL to view the lyrics.' },
139
175
  confidence: {
140
176
  type: 'number',
141
177
  description: 'Confidence score for the match (0-1 scale when available).'
@@ -150,10 +186,19 @@ const normalizedLyricRecordSchema = {
150
186
  description: 'Number of timestamped lines detected in synced lyrics.'
151
187
  },
152
188
  status: { type: ['string', 'null'], description: 'Provider-specific status for the record.' },
153
- rawRecord: {
154
- type: ['object', 'null'],
155
- description: 'Unmodified provider payload for debugging/reference.'
156
- }
189
+ hasLyrics: {
190
+ type: 'boolean',
191
+ description: 'True when the provider already returned lyric text.'
192
+ },
193
+ plainPreview: {
194
+ type: ['string', 'null'],
195
+ description: 'Short preview of plain lyrics when available.'
196
+ },
197
+ syncedPreview: {
198
+ type: ['string', 'null'],
199
+ description: 'Short preview of synced lyrics when available.'
200
+ },
201
+ reference: providerReferenceSchema
157
202
  },
158
203
  additionalProperties: false
159
204
  };
@@ -163,44 +208,214 @@ const matchSchema = {
163
208
  description: 'A single search result entry (provider + normalized lyric record).',
164
209
  properties: {
165
210
  provider: { type: 'string', description: 'Provider slug for the result.' },
166
- result: normalizedLyricRecordSchema
211
+ result: compactSearchResultSchema
167
212
  },
168
213
  additionalProperties: false
169
214
  };
170
215
 
216
+ const searchGroupSchema = {
217
+ type: 'object',
218
+ description: 'Provider bucket returned by search_lyrics with preview-only results.',
219
+ properties: {
220
+ provider: { type: 'string', description: 'Provider slug for this bucket.' },
221
+ results: {
222
+ type: 'array',
223
+ description: 'Compact preview-only matches for the provider.',
224
+ items: compactSearchResultSchema
225
+ }
226
+ },
227
+ additionalProperties: false
228
+ };
229
+
230
+ function compactStringMap(input = {}) {
231
+ const entries = Object.entries(input).filter(
232
+ ([, value]) => value !== null && value !== undefined && value !== ''
233
+ );
234
+ if (entries.length === 0) {
235
+ return undefined;
236
+ }
237
+ return Object.fromEntries(entries.map(([key, value]) => [key, value.toString()]));
238
+ }
239
+
240
+ function buildProviderReference(record = {}) {
241
+ const reference = {
242
+ provider: record.provider,
243
+ providerId: record.providerId ?? null,
244
+ title: record.title ?? null,
245
+ artist: record.artist ?? null,
246
+ album: record.album ?? null,
247
+ duration: record.duration ?? null,
248
+ sourceUrl: record.sourceUrl ?? null
249
+ };
250
+ const ids = compactStringMap(record.ids || {});
251
+ if (ids) {
252
+ reference.ids = ids;
253
+ }
254
+ reference.fingerprint = buildProviderReferenceFingerprint(record);
255
+ return reference;
256
+ }
257
+
258
+ function buildCompactSearchResult(record = {}) {
259
+ return {
260
+ provider: record.provider,
261
+ providerId: record.providerId ?? null,
262
+ title: record.title ?? null,
263
+ artist: record.artist ?? null,
264
+ album: record.album ?? null,
265
+ duration: record.duration ?? null,
266
+ sourceUrl: record.sourceUrl ?? null,
267
+ confidence: record.confidence ?? 0,
268
+ synced: Boolean(record.synced),
269
+ plainOnly: Boolean(record.plainOnly),
270
+ timestampCount: record.timestampCount ?? 0,
271
+ status: record.status ?? null,
272
+ hasLyrics: Boolean(record.plainLyrics || record.syncedLyrics),
273
+ plainPreview: extractPlainPreview(record) || null,
274
+ syncedPreview: extractSyncedPreview(record) || null,
275
+ reference: buildProviderReference(record)
276
+ };
277
+ }
278
+
279
+ function projectSearchGroups(groups = []) {
280
+ return groups.map((group) => ({
281
+ provider: group.provider,
282
+ results: Array.isArray(group.results)
283
+ ? group.results.map((record) => buildCompactSearchResult(record))
284
+ : []
285
+ }));
286
+ }
287
+
288
+ function normalizeMatchInput(match) {
289
+ if (!match || typeof match !== 'object') {
290
+ return null;
291
+ }
292
+
293
+ if (match.result && typeof match.result === 'object') {
294
+ return {
295
+ provider: match.provider || match.result.provider || match.result.reference?.provider || null,
296
+ result: match.result
297
+ };
298
+ }
299
+
300
+ if (match.reference || match.providerId || match.title || match.artist) {
301
+ return {
302
+ provider: match.provider || match.reference?.provider || null,
303
+ result: match
304
+ };
305
+ }
306
+
307
+ return null;
308
+ }
309
+
310
+ function flattenSelectableMatches(args = {}) {
311
+ const groupedItems = Array.isArray(args.items) ? args.items : [];
312
+ const groupedMatches = groupedItems.flatMap((item) =>
313
+ (item.results || []).map((result) => ({ provider: item.provider || result.provider, result }))
314
+ );
315
+ const directMatches = Array.isArray(args.matches)
316
+ ? args.matches.map((entry) => normalizeMatchInput(entry)).filter(Boolean)
317
+ : [];
318
+ return [...groupedMatches, ...directMatches];
319
+ }
320
+
321
+ function mergeTrackWithResult(track = {}, result = {}) {
322
+ return {
323
+ title: track.title || result.title || '',
324
+ artist: track.artist || result.artist || '',
325
+ album: track.album || result.album || null,
326
+ duration: track.duration ?? result.duration ?? null
327
+ };
328
+ }
329
+
330
+ function resolveLookupInputs(args = {}) {
331
+ const normalizedMatch = normalizeMatchInput(args.match);
332
+ const result = normalizedMatch?.result || {};
333
+ const reference = args.reference || result.reference || null;
334
+ const track = mergeTrackWithResult(args.track || {}, result);
335
+ return { track, reference };
336
+ }
337
+
338
+ function assertLookupInputs(track, reference) {
339
+ if (reference) {
340
+ return;
341
+ }
342
+
343
+ if (track?.title || track?.artist) {
344
+ return;
345
+ }
346
+
347
+ throw new McpError(
348
+ ErrorCode.InvalidParams,
349
+ 'Provide track metadata or a provider reference from search_lyrics/search_provider'
350
+ );
351
+ }
352
+
353
+ async function resolveFindResult(args, options = {}, { syncedOnly = false } = {}) {
354
+ const { track, reference } = resolveLookupInputs(args);
355
+ assertLookupInputs(track, reference);
356
+
357
+ if (reference) {
358
+ const resolved = await resolveProviderReference(reference, track);
359
+ return buildResolvedReferenceResult(resolved, { syncedOnly });
360
+ }
361
+
362
+ return runFind(track, { ...options, syncedOnly });
363
+ }
364
+
365
+ export function buildResolvedReferenceResult(resolved, { syncedOnly = false } = {}) {
366
+ if (!resolved || lyricContentScore(resolved) <= 0) {
367
+ return { matches: [], best: null };
368
+ }
369
+
370
+ if (syncedOnly && !resolved.synced) {
371
+ return { matches: [], best: null };
372
+ }
373
+
374
+ return {
375
+ matches: [{ provider: resolved.provider, result: resolved }],
376
+ best: resolved
377
+ };
378
+ }
379
+
171
380
  export const mcpToolDefinitions = [
172
381
  {
173
382
  name: 'find_lyrics',
174
383
  description:
175
- 'Find the best lyric match across providers (prefers synced when available). ' +
384
+ 'Find the best lyric match across providers (prefers synced when available), or resolve an exact provider reference returned by the MCP search tools. ' +
176
385
  'Returns lyricsCacheKey when lyrics are resolved — pass this to push_catalog_to_airtable ' +
177
386
  'without calling build_catalog_payload first.',
178
387
  inputSchema: {
179
388
  type: 'object',
180
- description: 'Provide a track description (and optional hints) to look up lyrics.',
389
+ description:
390
+ 'Provide track metadata, or pass a compact provider reference / selected search result from search_lyrics or search_provider.',
181
391
  properties: {
182
392
  track: trackSchema,
393
+ reference: providerReferenceSchema,
394
+ match: matchSchema,
183
395
  options: {
184
396
  type: 'object',
185
397
  description: 'Optional provider hints or overrides.',
186
398
  additionalProperties: false
187
399
  }
188
400
  },
189
- required: ['track']
401
+ additionalProperties: false
190
402
  }
191
403
  },
192
404
  {
193
405
  name: 'build_catalog_payload',
194
406
  description:
195
- 'Return a compact payload suitable for Airtable inserts/exports. For large lyrics, send object args and use omitInlineLyrics + lyricsPayloadMode to avoid JSON truncation in downstream automations. Airtable-safe compact mode can auto-promote payload transport to reference.',
407
+ 'Return a compact payload suitable for Airtable inserts/exports. Accepts track metadata or an exact provider reference from the MCP search tools. For large lyrics, send object args and use omitInlineLyrics + lyricsPayloadMode to avoid JSON truncation in downstream automations. Airtable-safe compact mode can auto-promote payload transport to reference.',
196
408
  inputSchema: {
197
409
  type: 'object',
198
- description: 'Provide a track plus optional catalog preferences.',
410
+ description:
411
+ 'Provide a track, or pass a compact provider reference / selected search result plus optional catalog preferences.',
199
412
  properties: {
200
413
  track: trackSchema,
414
+ reference: providerReferenceSchema,
415
+ match: matchSchema,
201
416
  options: catalogOptionsSchema
202
417
  },
203
- required: ['track']
418
+ additionalProperties: false
204
419
  }
205
420
  },
206
421
  {
@@ -211,24 +426,28 @@ export const mcpToolDefinitions = [
211
426
  inputSchema: {
212
427
  type: 'object',
213
428
  description:
214
- 'Provide a track description (and optional hints) to look up synced lyrics only.',
429
+ 'Provide track metadata, or pass a compact provider reference / selected search result to look up synced lyrics only.',
215
430
  properties: {
216
431
  track: trackSchema,
432
+ reference: providerReferenceSchema,
433
+ match: matchSchema,
217
434
  options: {
218
435
  type: 'object',
219
436
  description: 'Optional provider hints or overrides.',
220
437
  additionalProperties: false
221
438
  }
222
439
  },
223
- required: ['track']
440
+ additionalProperties: false
224
441
  }
225
442
  },
226
443
  {
227
444
  name: 'search_lyrics',
228
- description: 'List candidate matches from every provider without downloading the lyrics yet.',
445
+ description:
446
+ 'List preview-only candidate matches from every provider. MCP search results never include full lyrics or raw provider payloads.',
229
447
  inputSchema: {
230
448
  type: 'object',
231
- description: 'Provide the basic track metadata to retrieve unhydrated matches.',
449
+ description:
450
+ 'Provide the basic track metadata to retrieve preview-only matches and reusable provider references.',
232
451
  properties: {
233
452
  track: trackSchema
234
453
  },
@@ -237,10 +456,12 @@ export const mcpToolDefinitions = [
237
456
  },
238
457
  {
239
458
  name: 'search_provider',
240
- description: 'Search a single provider (e.g., LRCLIB, Genius, Melon) for potential matches.',
459
+ description:
460
+ 'Search a single provider (e.g., LRCLIB, Genius, Melon) for preview-only potential matches. MCP search results never include full lyrics or raw provider payloads.',
241
461
  inputSchema: {
242
462
  type: 'object',
243
- description: 'Provide a track plus a provider slug to limit the search scope.',
463
+ description:
464
+ 'Provide a track plus a provider slug to limit the search scope and return reusable provider references.',
244
465
  properties: {
245
466
  provider: {
246
467
  type: 'string',
@@ -259,16 +480,19 @@ export const mcpToolDefinitions = [
259
480
  {
260
481
  name: 'export_lyrics',
261
482
  description:
262
- 'Find lyrics and save plain/LRC/SRT plus romanized variants to disk. ' +
483
+ 'Find lyrics and save plain/LRC/SRT plus romanized variants to disk, or resolve an exact provider reference from MCP search tools. ' +
263
484
  'Also returns lyricsCacheKey so push_catalog_to_airtable can be called immediately.',
264
485
  inputSchema: {
265
486
  type: 'object',
266
- description: 'Provide the track and export options to write files to disk.',
487
+ description:
488
+ 'Provide the track, or pass a compact provider reference / selected search result and export options to write files to disk.',
267
489
  properties: {
268
490
  track: trackSchema,
491
+ reference: providerReferenceSchema,
492
+ match: matchSchema,
269
493
  options: exportOptionsSchema
270
494
  },
271
- required: ['track']
495
+ additionalProperties: false
272
496
  }
273
497
  },
274
498
  {
@@ -281,9 +505,11 @@ export const mcpToolDefinitions = [
281
505
  description: 'Provide the track and formatting options for in-memory rendering.',
282
506
  properties: {
283
507
  track: trackSchema,
508
+ reference: providerReferenceSchema,
509
+ match: matchSchema,
284
510
  options: formatOptionsSchema
285
511
  },
286
- required: ['track']
512
+ additionalProperties: false
287
513
  }
288
514
  },
289
515
  {
@@ -295,12 +521,22 @@ export const mcpToolDefinitions = [
295
521
  description:
296
522
  'Select a single result either by passing matches + criteria or by supplying match directly.',
297
523
  properties: {
524
+ items: {
525
+ type: 'array',
526
+ description: 'Grouped results returned from search_lyrics.',
527
+ items: searchGroupSchema
528
+ },
298
529
  matches: {
299
530
  type: 'array',
300
- description: 'Results returned from search_lyrics or search_provider.',
301
- items: matchSchema
531
+ description:
532
+ 'Results returned from search_provider, or flattened match entries from previous search output.',
533
+ items: {
534
+ anyOf: [matchSchema, compactSearchResultSchema]
535
+ }
536
+ },
537
+ match: {
538
+ anyOf: [matchSchema, compactSearchResultSchema]
302
539
  },
303
- match: matchSchema,
304
540
  criteria: selectCriteriaSchema
305
541
  }
306
542
  }
@@ -370,28 +606,33 @@ export const mcpToolDefinitions = [
370
606
  ];
371
607
 
372
608
  export async function handleMcpTool(name, args = {}) {
373
- const track = args.track || {};
374
609
  const options = args.options || {};
375
610
 
376
611
  if (name === 'find_lyrics') {
377
- const result = await runFind(track, options);
612
+ const result = await resolveFindResult(args, options);
378
613
  return buildPayloadFromResult(result, buildActionContext(options));
379
614
  }
380
615
 
381
616
  if (name === 'find_synced_lyrics') {
382
- const result = await runFind(track, { ...options, syncedOnly: true });
617
+ const result = await resolveFindResult(args, options, { syncedOnly: true });
383
618
  return buildPayloadFromResult(result, buildActionContext(options));
384
619
  }
385
620
 
386
621
  if (name === 'search_lyrics') {
387
- return runSearch(track);
622
+ const { track } = resolveLookupInputs(args);
623
+ assertLookupInputs(track, null);
624
+ return projectSearchGroups(await runSearch(track));
388
625
  }
389
626
 
390
627
  if (name === 'search_provider') {
391
628
  if (!args.provider) {
392
629
  throw new McpError(ErrorCode.InvalidParams, 'provider is required');
393
630
  }
394
- return runProviderSearch(args.provider, track);
631
+ const { track } = resolveLookupInputs(args);
632
+ assertLookupInputs(track, null);
633
+ return (await runProviderSearch(args.provider, track)).map((record) =>
634
+ buildCompactSearchResult(record)
635
+ );
395
636
  }
396
637
 
397
638
  if (name === 'get_provider_status') {
@@ -399,7 +640,7 @@ export async function handleMcpTool(name, args = {}) {
399
640
  }
400
641
 
401
642
  if (name === 'export_lyrics') {
402
- const result = await runFind(track, options);
643
+ const result = await resolveFindResult(args, options);
403
644
  const context = buildActionContext({ ...options, export: true });
404
645
  const payload = await buildPayloadFromResult(result, context);
405
646
  // buildPayloadFromResult already calls exportBestResult internally when
@@ -408,7 +649,7 @@ export async function handleMcpTool(name, args = {}) {
408
649
  }
409
650
 
410
651
  if (name === 'format_lyrics') {
411
- const result = await runFind(track, options);
652
+ const result = await resolveFindResult(args, options);
412
653
  const best = result?.best;
413
654
  if (!best) {
414
655
  return { error: 'No match found' };
@@ -433,24 +674,28 @@ export async function handleMcpTool(name, args = {}) {
433
674
  }
434
675
 
435
676
  if (name === 'build_catalog_payload') {
436
- return buildCatalogPayload(track, options);
677
+ const { track, reference } = resolveLookupInputs(args);
678
+ assertLookupInputs(track, reference);
679
+
680
+ if (!reference) {
681
+ return buildCatalogPayload(track, options);
682
+ }
683
+
684
+ const resolved = await resolveProviderReference(reference, track);
685
+ const { best } = buildResolvedReferenceResult(resolved);
686
+ return buildCatalogPayloadFromResult(best, track, options);
437
687
  }
438
688
 
439
689
  if (name === 'select_match') {
440
690
  if (args.match) {
441
- return args.match;
691
+ return normalizeMatchInput(args.match) ?? args.match;
442
692
  }
443
- const matches = Array.isArray(args.matches) ? args.matches : [];
693
+ const matches = flattenSelectableMatches(args);
444
694
  if (matches.length === 0) {
445
695
  return { error: 'No matches provided' };
446
696
  }
447
697
  const { provider, requireSynced, index } = args.criteria || {};
448
- // Strip entries with no actual lyric content (e.g. unhydrated search stubs)
449
- // so the caller never accidentally selects an empty result.
450
- let filtered = matches.filter((entry) => lyricContentScore(entry.result) > 0);
451
- if (filtered.length === 0) {
452
- return { error: 'No matches with lyric content found' };
453
- }
698
+ let filtered = matches;
454
699
  if (provider) {
455
700
  filtered = filtered.filter((entry) => entry.provider === provider);
456
701
  }