mcp-lyricscom 1.4.0 → 2.0.0
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/CHANGELOG.md +130 -0
- package/LICENSE +1 -1
- package/README.md +39 -0
- package/dist/index.js +326 -120
- package/dist/index.js.map +1 -1
- package/dist/lyricscom/client.js +130 -48
- package/dist/lyricscom/client.js.map +1 -1
- package/package.json +15 -11
- package/server.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import process3 from "process";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import process from "process";
|
|
9
|
+
|
|
6
10
|
// src/version.ts
|
|
7
|
-
var PKG_VERSION = "
|
|
11
|
+
var PKG_VERSION = "2.0.0";
|
|
8
12
|
var REPO_URL = "https://github.com/smeet666/mcp-lyricscom";
|
|
9
13
|
|
|
10
14
|
// src/config.ts
|
|
@@ -21,7 +25,9 @@ var MIN_ALLOWED_INTERVAL_MS = 500;
|
|
|
21
25
|
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
22
26
|
function readNumber(name, env, range) {
|
|
23
27
|
const raw = env[name];
|
|
24
|
-
if (raw === void 0 || raw.trim() === "")
|
|
28
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
29
|
+
return range.fallback;
|
|
30
|
+
}
|
|
25
31
|
const parsed = Number(raw);
|
|
26
32
|
if (!Number.isFinite(parsed)) {
|
|
27
33
|
warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
|
|
@@ -39,7 +45,9 @@ function warn(message) {
|
|
|
39
45
|
}
|
|
40
46
|
function readInterval(env) {
|
|
41
47
|
const raw = env.LYRICSCOM_MIN_INTERVAL_MS;
|
|
42
|
-
if (raw === void 0 || raw.trim() === "")
|
|
48
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
49
|
+
return DEFAULTS.minIntervalMs;
|
|
50
|
+
}
|
|
43
51
|
const parsed = Number(raw);
|
|
44
52
|
if (!Number.isFinite(parsed)) {
|
|
45
53
|
warn(`LYRICSCOM_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
|
|
@@ -121,7 +129,9 @@ var TtlLruCache = class {
|
|
|
121
129
|
store = /* @__PURE__ */ new Map();
|
|
122
130
|
get(key) {
|
|
123
131
|
const entry = this.store.get(key);
|
|
124
|
-
if (!entry)
|
|
132
|
+
if (!entry) {
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
125
135
|
if (Date.now() >= entry.expiresAt) {
|
|
126
136
|
this.store.delete(key);
|
|
127
137
|
return void 0;
|
|
@@ -131,12 +141,16 @@ var TtlLruCache = class {
|
|
|
131
141
|
return entry.value;
|
|
132
142
|
}
|
|
133
143
|
set(key, value) {
|
|
134
|
-
if (this.maxEntries <= 0 || this.ttlMs <= 0)
|
|
144
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
135
147
|
this.store.delete(key);
|
|
136
148
|
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
137
149
|
while (this.store.size > this.maxEntries) {
|
|
138
150
|
const oldest = this.store.keys().next();
|
|
139
|
-
if (oldest.done)
|
|
151
|
+
if (oldest.done) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
140
154
|
this.store.delete(oldest.value);
|
|
141
155
|
}
|
|
142
156
|
}
|
|
@@ -201,17 +215,32 @@ function invalidInput(message, hint) {
|
|
|
201
215
|
}
|
|
202
216
|
|
|
203
217
|
// src/lyricscom/parsers/detectThrottle.ts
|
|
218
|
+
var CLOSING_HTML_TAG = /<\/html>/i;
|
|
204
219
|
var MIN_PLAUSIBLE_HTML = 2e3;
|
|
205
220
|
function classifyResponse(status, body) {
|
|
206
|
-
if (status === 202)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (status ===
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
if (status === 202) {
|
|
222
|
+
return { kind: "throttled", reason: "status-202" };
|
|
223
|
+
}
|
|
224
|
+
if (status === 429) {
|
|
225
|
+
return { kind: "throttled", reason: "status-429" };
|
|
226
|
+
}
|
|
227
|
+
if (status === 503) {
|
|
228
|
+
return { kind: "throttled", reason: "status-503" };
|
|
229
|
+
}
|
|
230
|
+
if (status === 403) {
|
|
231
|
+
return { kind: "blocked", status };
|
|
232
|
+
}
|
|
233
|
+
if (status === 404) {
|
|
234
|
+
return { kind: "not-found" };
|
|
235
|
+
}
|
|
236
|
+
if (status >= 500) {
|
|
237
|
+
return { kind: "server-error", status };
|
|
238
|
+
}
|
|
212
239
|
const trimmed = body.trim();
|
|
213
|
-
if (trimmed.length === 0)
|
|
214
|
-
|
|
240
|
+
if (trimmed.length === 0) {
|
|
241
|
+
return { kind: "throttled", reason: "empty-body" };
|
|
242
|
+
}
|
|
243
|
+
if (trimmed.length < MIN_PLAUSIBLE_HTML && !CLOSING_HTML_TAG.test(trimmed)) {
|
|
215
244
|
return { kind: "throttled", reason: "short-body" };
|
|
216
245
|
}
|
|
217
246
|
return { kind: "ok", html: body };
|
|
@@ -267,10 +296,14 @@ var RateLimiter = class {
|
|
|
267
296
|
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
268
297
|
}
|
|
269
298
|
async waitForSlot() {
|
|
270
|
-
if (this.intervalMs === 0 || this.lastStart === 0)
|
|
299
|
+
if (this.intervalMs === 0 || this.lastStart === 0) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
271
302
|
const elapsed = Date.now() - this.lastStart;
|
|
272
303
|
const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
273
|
-
if (remaining > 0)
|
|
304
|
+
if (remaining > 0) {
|
|
305
|
+
await sleep(remaining);
|
|
306
|
+
}
|
|
274
307
|
}
|
|
275
308
|
};
|
|
276
309
|
function sleep(ms) {
|
|
@@ -289,7 +322,7 @@ function backoffDelay(attempt, random = Math.random) {
|
|
|
289
322
|
async function fetchHtml(url, deps) {
|
|
290
323
|
const { config, limiter, logger } = deps;
|
|
291
324
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
292
|
-
return limiter.schedule(async () => {
|
|
325
|
+
return await limiter.schedule(async () => {
|
|
293
326
|
let lastError;
|
|
294
327
|
let askedWaitMs = null;
|
|
295
328
|
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
@@ -349,21 +382,38 @@ async function fetchHtml(url, deps) {
|
|
|
349
382
|
throw blockedUserAgent(url);
|
|
350
383
|
case "not-found":
|
|
351
384
|
throw notFound(url);
|
|
385
|
+
// Every kind the classifier declares is handled above, and the linter
|
|
386
|
+
// holds that. This clause is the runtime backstop: a response nothing
|
|
387
|
+
// above matched leaves as an error rather than as a silent retry.
|
|
388
|
+
default:
|
|
389
|
+
throw new LyricsComError(
|
|
390
|
+
"network_error",
|
|
391
|
+
`lyrics.com returned a response this client cannot classify for ${url}.`,
|
|
392
|
+
{ url }
|
|
393
|
+
);
|
|
352
394
|
}
|
|
353
395
|
}
|
|
354
396
|
throw lastError ?? new LyricsComError("network_error", `Could not fetch ${url}.`, { url });
|
|
355
397
|
});
|
|
356
398
|
}
|
|
357
399
|
function parseRetryAfter(raw) {
|
|
358
|
-
if (!raw)
|
|
400
|
+
if (!raw) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
359
403
|
const seconds = Number(raw.trim());
|
|
360
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
404
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
405
|
+
return Math.round(seconds * 1e3);
|
|
406
|
+
}
|
|
361
407
|
const when = Date.parse(raw);
|
|
362
|
-
if (Number.isNaN(when))
|
|
408
|
+
if (Number.isNaN(when)) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
363
411
|
return Math.max(0, when - Date.now());
|
|
364
412
|
}
|
|
365
413
|
function asTransportError(error, url) {
|
|
366
|
-
if (error instanceof LyricsComError)
|
|
414
|
+
if (error instanceof LyricsComError) {
|
|
415
|
+
return error;
|
|
416
|
+
}
|
|
367
417
|
const name = error instanceof Error ? error.name : "";
|
|
368
418
|
if (name === "TimeoutError" || name === "AbortError") {
|
|
369
419
|
return new LyricsComError("timeout", "lyrics.com did not answer in time.", {
|
|
@@ -376,9 +426,11 @@ function asTransportError(error, url) {
|
|
|
376
426
|
}
|
|
377
427
|
|
|
378
428
|
// src/lyricscom/parsers/searchResults.ts
|
|
379
|
-
import
|
|
429
|
+
import process2 from "process";
|
|
430
|
+
import { load } from "cheerio";
|
|
380
431
|
|
|
381
432
|
// src/text/normalize.ts
|
|
433
|
+
var MOJIBAKE_MARKS = /[ÃÂ]/;
|
|
382
434
|
var HTML_ENTITIES = {
|
|
383
435
|
amp: "&",
|
|
384
436
|
lt: "<",
|
|
@@ -395,12 +447,16 @@ var HTML_ENTITIES = {
|
|
|
395
447
|
rdquo: "\u201D"
|
|
396
448
|
};
|
|
397
449
|
function unescapeHtml(text) {
|
|
398
|
-
if (!text)
|
|
450
|
+
if (!text) {
|
|
451
|
+
return text;
|
|
452
|
+
}
|
|
399
453
|
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity) => {
|
|
400
454
|
if (entity.startsWith("#")) {
|
|
401
455
|
const isHex = entity[1] === "x" || entity[1] === "X";
|
|
402
456
|
const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);
|
|
403
|
-
if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 1114111)
|
|
457
|
+
if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 1114111) {
|
|
458
|
+
return match;
|
|
459
|
+
}
|
|
404
460
|
try {
|
|
405
461
|
return String.fromCodePoint(codePoint);
|
|
406
462
|
} catch {
|
|
@@ -411,10 +467,14 @@ function unescapeHtml(text) {
|
|
|
411
467
|
});
|
|
412
468
|
}
|
|
413
469
|
function fixEncodingIssues(text) {
|
|
414
|
-
if (!text
|
|
470
|
+
if (!(text && MOJIBAKE_MARKS.test(text))) {
|
|
471
|
+
return text;
|
|
472
|
+
}
|
|
415
473
|
try {
|
|
416
474
|
const repaired = Buffer.from(text, "latin1").toString("utf8");
|
|
417
|
-
if (repaired.includes("\uFFFD"))
|
|
475
|
+
if (repaired.includes("\uFFFD")) {
|
|
476
|
+
return text;
|
|
477
|
+
}
|
|
418
478
|
return repaired;
|
|
419
479
|
} catch {
|
|
420
480
|
return text;
|
|
@@ -424,23 +484,33 @@ function collapseWhitespace(text) {
|
|
|
424
484
|
return text.replace(/\s+/g, " ").trim();
|
|
425
485
|
}
|
|
426
486
|
function cleanInlineText(text) {
|
|
427
|
-
if (!text)
|
|
487
|
+
if (!text) {
|
|
488
|
+
return "";
|
|
489
|
+
}
|
|
428
490
|
return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));
|
|
429
491
|
}
|
|
430
492
|
function cleanLyricsText(text) {
|
|
431
|
-
if (!text)
|
|
493
|
+
if (!text) {
|
|
494
|
+
return "";
|
|
495
|
+
}
|
|
432
496
|
const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\r\n?/g, "\n");
|
|
433
497
|
const lines = normalized.split("\n").map((line) => line.trim());
|
|
434
498
|
const out = [];
|
|
435
499
|
for (const line of lines) {
|
|
436
|
-
if (line === "" && (out.length === 0 || out
|
|
500
|
+
if (line === "" && (out.length === 0 || out.at(-1) === "")) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
437
503
|
out.push(line);
|
|
438
504
|
}
|
|
439
|
-
while (out.length > 0 && out
|
|
505
|
+
while (out.length > 0 && out.at(-1) === "") {
|
|
506
|
+
out.pop();
|
|
507
|
+
}
|
|
440
508
|
return out.join("\n");
|
|
441
509
|
}
|
|
442
510
|
|
|
443
511
|
// src/lyricscom/urls.ts
|
|
512
|
+
var ABSOLUTE_URL = /^https?:\/\//i;
|
|
513
|
+
var DIGITS_ONLY = /^\d+$/;
|
|
444
514
|
var BASE_URL = "https://www.lyrics.com";
|
|
445
515
|
var RESULTS_PER_PAGE = 24;
|
|
446
516
|
var SONG_HREF_RE = /^\/lyric(?:-lf)?\/(\d+)\//;
|
|
@@ -450,7 +520,9 @@ function encodeTerm(term) {
|
|
|
450
520
|
}
|
|
451
521
|
function buildSearchUrl(term, page) {
|
|
452
522
|
const encoded = encodeTerm(term);
|
|
453
|
-
if (page <= 1)
|
|
523
|
+
if (page <= 1) {
|
|
524
|
+
return `${BASE_URL}/lyrics/${encoded}`;
|
|
525
|
+
}
|
|
454
526
|
return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;
|
|
455
527
|
}
|
|
456
528
|
function buildSongUrl(id) {
|
|
@@ -463,22 +535,28 @@ function isLyricsComHost(rawUrl) {
|
|
|
463
535
|
} catch {
|
|
464
536
|
return false;
|
|
465
537
|
}
|
|
466
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
538
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
467
541
|
return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
|
|
468
542
|
}
|
|
469
543
|
function extractSongId(hrefOrUrl) {
|
|
470
544
|
let path = hrefOrUrl;
|
|
471
|
-
if (
|
|
472
|
-
if (!isLyricsComHost(hrefOrUrl))
|
|
545
|
+
if (ABSOLUTE_URL.test(hrefOrUrl)) {
|
|
546
|
+
if (!isLyricsComHost(hrefOrUrl)) {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
473
549
|
path = new URL(hrefOrUrl).pathname;
|
|
474
550
|
}
|
|
475
|
-
if (!path.startsWith("/"))
|
|
551
|
+
if (!path.startsWith("/")) {
|
|
552
|
+
path = `/${path}`;
|
|
553
|
+
}
|
|
476
554
|
const match = SONG_HREF_RE.exec(path);
|
|
477
555
|
return match?.[1] ?? null;
|
|
478
556
|
}
|
|
479
557
|
function resolveSongRef(input) {
|
|
480
558
|
if (input.id) {
|
|
481
|
-
if (
|
|
559
|
+
if (!DIGITS_ONLY.test(input.id)) {
|
|
482
560
|
throw invalidInput(
|
|
483
561
|
`"${input.id}" is not a lyrics.com song id.`,
|
|
484
562
|
"Ids are digits only, as returned by the search tools."
|
|
@@ -536,8 +614,9 @@ var SEL = {
|
|
|
536
614
|
};
|
|
537
615
|
|
|
538
616
|
// src/lyricscom/parsers/searchResults.ts
|
|
617
|
+
var YEAR = /\b(1[89]\d{2}|20\d{2})\b/;
|
|
539
618
|
function parseSearchResults(html, options) {
|
|
540
|
-
const $ =
|
|
619
|
+
const $ = load(html);
|
|
541
620
|
const rows = $(SEL.serpItem);
|
|
542
621
|
if (rows.length === 0) {
|
|
543
622
|
if ($(SEL.serpShell).length === 0) {
|
|
@@ -554,7 +633,7 @@ function parseSearchResults(html, options) {
|
|
|
554
633
|
const title = cleanInlineText(titleLink.text());
|
|
555
634
|
const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());
|
|
556
635
|
const id = href ? extractSongId(href) : null;
|
|
557
|
-
if (!id
|
|
636
|
+
if (!(id && title && artist)) {
|
|
558
637
|
skipped += 1;
|
|
559
638
|
return;
|
|
560
639
|
}
|
|
@@ -576,7 +655,7 @@ function parseSearchResults(html, options) {
|
|
|
576
655
|
throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);
|
|
577
656
|
}
|
|
578
657
|
if (skipped > 0) {
|
|
579
|
-
|
|
658
|
+
process2.stderr.write(
|
|
580
659
|
`[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}
|
|
581
660
|
`
|
|
582
661
|
);
|
|
@@ -589,20 +668,23 @@ function parseSearchResults(html, options) {
|
|
|
589
668
|
};
|
|
590
669
|
}
|
|
591
670
|
function parseYear(raw) {
|
|
592
|
-
const match =
|
|
593
|
-
if (!match?.[1])
|
|
671
|
+
const match = YEAR.exec(raw);
|
|
672
|
+
if (!match?.[1]) {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
594
675
|
return Number.parseInt(match[1], 10);
|
|
595
676
|
}
|
|
596
677
|
|
|
597
678
|
// src/lyricscom/parsers/songPage.ts
|
|
598
|
-
import
|
|
679
|
+
import { load as load2 } from "cheerio";
|
|
680
|
+
var NO_LYRICS_FOUND = /no lyrics found/i;
|
|
599
681
|
function parseSongPage(html, options) {
|
|
600
|
-
const $ =
|
|
682
|
+
const $ = load2(html);
|
|
601
683
|
const title = cleanInlineText($(SEL.songTitle).first().text()) || null;
|
|
602
684
|
const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;
|
|
603
685
|
const body = $(SEL.songBody).first();
|
|
604
686
|
if (body.length === 0) {
|
|
605
|
-
const declaresNoLyrics = $(SEL.songNoData).length > 0 ||
|
|
687
|
+
const declaresNoLyrics = $(SEL.songNoData).length > 0 || NO_LYRICS_FOUND.test($("title").text());
|
|
606
688
|
if (!declaresNoLyrics) {
|
|
607
689
|
throw parseFailure(options.url, "no lyrics container and no 'no lyrics' marker");
|
|
608
690
|
}
|
|
@@ -618,12 +700,13 @@ function extractBodyText($, body) {
|
|
|
618
700
|
const rawHtml = $.html(body);
|
|
619
701
|
const withoutAnchors = rawHtml.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, "$1");
|
|
620
702
|
const withNewlines = withoutAnchors.replace(/<br\s*\/?>/gi, "\n");
|
|
621
|
-
return
|
|
703
|
+
return load2(withNewlines).root().text();
|
|
622
704
|
}
|
|
623
705
|
|
|
624
706
|
// src/lyricscom/client.ts
|
|
707
|
+
var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
|
|
625
708
|
function withGuarantees(config) {
|
|
626
|
-
const userAgent =
|
|
709
|
+
const userAgent = BROWSER_IDENTITY.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
|
|
627
710
|
return {
|
|
628
711
|
...config,
|
|
629
712
|
userAgent,
|
|
@@ -645,11 +728,11 @@ var LyricsComClient = class {
|
|
|
645
728
|
}
|
|
646
729
|
async search(term, page) {
|
|
647
730
|
const url = buildSearchUrl(term, page);
|
|
648
|
-
return this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));
|
|
731
|
+
return await this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));
|
|
649
732
|
}
|
|
650
733
|
async getSong(ref) {
|
|
651
734
|
const { id, url } = resolveSongRef(ref);
|
|
652
|
-
return this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));
|
|
735
|
+
return await this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));
|
|
653
736
|
}
|
|
654
737
|
/**
|
|
655
738
|
* Fetch, parse, then cache. In that order: a page that could not be read is
|
|
@@ -687,7 +770,9 @@ function escapeRegExp(value) {
|
|
|
687
770
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
688
771
|
}
|
|
689
772
|
function getKeywordIndex(keyword, text) {
|
|
690
|
-
if (!keyword
|
|
773
|
+
if (!(keyword && text)) {
|
|
774
|
+
return -1;
|
|
775
|
+
}
|
|
691
776
|
const escaped = escapeRegExp(keyword.toLowerCase());
|
|
692
777
|
const haystack = text.toLowerCase();
|
|
693
778
|
const passes = [
|
|
@@ -709,11 +794,15 @@ function containsWord(keyword, text) {
|
|
|
709
794
|
|
|
710
795
|
// src/text/excerpt.ts
|
|
711
796
|
function findMatchingLine(text, keyword) {
|
|
712
|
-
if (!text
|
|
797
|
+
if (!(text && keyword)) {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
713
800
|
const lines = text.split("\n");
|
|
714
801
|
for (let i = 0; i < lines.length; i += 1) {
|
|
715
802
|
const line = lines[i] ?? "";
|
|
716
|
-
if (line.trim() === "")
|
|
803
|
+
if (line.trim() === "") {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
717
806
|
if (getKeywordIndex(keyword, line) !== -1) {
|
|
718
807
|
return { line: line.trim(), lineNumber: i + 1 };
|
|
719
808
|
}
|
|
@@ -722,11 +811,17 @@ function findMatchingLine(text, keyword) {
|
|
|
722
811
|
}
|
|
723
812
|
function matchedLineExcerpt(text, keyword, options = {}) {
|
|
724
813
|
const maxChars = options.maxChars ?? 160;
|
|
725
|
-
if (!text)
|
|
814
|
+
if (!text) {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
726
817
|
const match = findMatchingLine(text, keyword);
|
|
727
818
|
const line = match?.line ?? text.split("\n").find((candidate) => candidate.trim() !== "")?.trim();
|
|
728
|
-
if (!line)
|
|
729
|
-
|
|
819
|
+
if (!line) {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
if (line.length <= maxChars) {
|
|
823
|
+
return line;
|
|
824
|
+
}
|
|
730
825
|
const keywordAt = match ? Math.max(getKeywordIndex(keyword, line), 0) : 0;
|
|
731
826
|
const start = Math.max(0, keywordAt - Math.floor(maxChars / 2));
|
|
732
827
|
const end = Math.min(line.length, start + maxChars);
|
|
@@ -737,10 +832,24 @@ function matchedLineExcerpt(text, keyword, options = {}) {
|
|
|
737
832
|
// src/tools/arguments.ts
|
|
738
833
|
import { z } from "zod";
|
|
739
834
|
var INVALID_INPUT = "invalid_input";
|
|
835
|
+
var defaultMessage = z.core.locales.en().localeError;
|
|
740
836
|
function strictInput(shape) {
|
|
741
837
|
const declared = Object.keys(shape);
|
|
742
|
-
const
|
|
743
|
-
|
|
838
|
+
const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
|
|
839
|
+
for (const argument of Object.values(shape)) {
|
|
840
|
+
carryTheCode(argument, refuse);
|
|
841
|
+
}
|
|
842
|
+
return z.strictObject(shape, { error: refuse });
|
|
843
|
+
}
|
|
844
|
+
function carryTheCode(schema, refuse) {
|
|
845
|
+
const definition = schema._zod.def;
|
|
846
|
+
definition.error = refuse;
|
|
847
|
+
for (const check of definition.checks ?? []) {
|
|
848
|
+
check._zod.def.error = refuse;
|
|
849
|
+
}
|
|
850
|
+
if (definition.innerType) {
|
|
851
|
+
carryTheCode(definition.innerType, refuse);
|
|
852
|
+
}
|
|
744
853
|
}
|
|
745
854
|
function unknownArgumentMessage(keys, declared) {
|
|
746
855
|
const named = keys.map((key) => {
|
|
@@ -752,15 +861,21 @@ function unknownArgumentMessage(keys, declared) {
|
|
|
752
861
|
function nearestArgument(key, declared) {
|
|
753
862
|
const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
754
863
|
const flat = flatten(key);
|
|
755
|
-
if (flat.length === 0)
|
|
864
|
+
if (flat.length === 0) {
|
|
865
|
+
return void 0;
|
|
866
|
+
}
|
|
756
867
|
const sameName = declared.find((name) => flatten(name) === flat);
|
|
757
|
-
if (sameName)
|
|
868
|
+
if (sameName) {
|
|
869
|
+
return sameName;
|
|
870
|
+
}
|
|
758
871
|
const overlapping = declared.find((name) => {
|
|
759
872
|
const other = flatten(name);
|
|
760
873
|
const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
|
|
761
874
|
return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
|
|
762
875
|
});
|
|
763
|
-
if (overlapping)
|
|
876
|
+
if (overlapping) {
|
|
877
|
+
return overlapping;
|
|
878
|
+
}
|
|
764
879
|
let closest;
|
|
765
880
|
let shortest = Number.POSITIVE_INFINITY;
|
|
766
881
|
for (const name of declared) {
|
|
@@ -774,15 +889,25 @@ function nearestArgument(key, declared) {
|
|
|
774
889
|
}
|
|
775
890
|
function editDistance(left, right) {
|
|
776
891
|
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
892
|
+
let distance = right.length;
|
|
777
893
|
for (let row = 1; row <= left.length; row += 1) {
|
|
778
894
|
const current = [row];
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
895
|
+
let diagonal = row - 1;
|
|
896
|
+
let leftward = row;
|
|
897
|
+
for (const [column, above] of previous.entries()) {
|
|
898
|
+
if (column === 0) {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
|
|
902
|
+
const cell = Math.min(substitution, above + 1, leftward + 1);
|
|
903
|
+
current.push(cell);
|
|
904
|
+
diagonal = above;
|
|
905
|
+
leftward = cell;
|
|
782
906
|
}
|
|
783
907
|
previous = current;
|
|
908
|
+
distance = leftward;
|
|
784
909
|
}
|
|
785
|
-
return
|
|
910
|
+
return distance;
|
|
786
911
|
}
|
|
787
912
|
|
|
788
913
|
// src/tools/shared.ts
|
|
@@ -826,12 +951,18 @@ ${trailer}` : body }],
|
|
|
826
951
|
function toToolError(error) {
|
|
827
952
|
const known = error instanceof LyricsComError ? error : new LyricsComError("network_error", error instanceof Error ? error.message : String(error));
|
|
828
953
|
const lines = [`[${known.code}] ${known.message}`];
|
|
829
|
-
if (known.details.hint)
|
|
830
|
-
|
|
954
|
+
if (known.details.hint) {
|
|
955
|
+
lines.push(`Hint: ${known.details.hint}`);
|
|
956
|
+
}
|
|
957
|
+
if (known.details.url) {
|
|
958
|
+
lines.push(`URL: ${known.details.url}`);
|
|
959
|
+
}
|
|
831
960
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
832
961
|
}
|
|
833
962
|
function truncate(text, maxChars) {
|
|
834
|
-
if (text.length <= maxChars)
|
|
963
|
+
if (text.length <= maxChars) {
|
|
964
|
+
return text;
|
|
965
|
+
}
|
|
835
966
|
return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`;
|
|
836
967
|
}
|
|
837
968
|
function renderResultList(results) {
|
|
@@ -845,6 +976,24 @@ function renderResultList(results) {
|
|
|
845
976
|
}
|
|
846
977
|
|
|
847
978
|
// src/tools/getLyrics.ts
|
|
979
|
+
function howFarTheTextGoes(pastTheEnd, offset, nextOffset) {
|
|
980
|
+
if (pastTheEnd) {
|
|
981
|
+
return `Nothing at offset=${offset}: that is past the end. Call again with offset=0.`;
|
|
982
|
+
}
|
|
983
|
+
if (nextOffset === null) {
|
|
984
|
+
return "Complete.";
|
|
985
|
+
}
|
|
986
|
+
return `Truncated: call again with offset=${nextOffset} to continue.`;
|
|
987
|
+
}
|
|
988
|
+
function whereTheWordIs(highlight) {
|
|
989
|
+
if (!highlight) {
|
|
990
|
+
return "";
|
|
991
|
+
}
|
|
992
|
+
if (!highlight.found) {
|
|
993
|
+
return `"${highlight.word}" does not appear in these lyrics.`;
|
|
994
|
+
}
|
|
995
|
+
return `"${highlight.word}" appears on line ${highlight.line_number}.`;
|
|
996
|
+
}
|
|
848
997
|
var getLyricsDescription = [
|
|
849
998
|
"Fetch the full lyrics of one song from lyrics.com, given the song id or URL returned by search_lyrics or search_songs.",
|
|
850
999
|
"Lyrics can be long, so the response is truncated by default: check 'truncated' and call again with 'offset' set to",
|
|
@@ -888,9 +1037,13 @@ var getLyricsOutputShape = {
|
|
|
888
1037
|
notes: z3.array(z3.string())
|
|
889
1038
|
};
|
|
890
1039
|
function sliceAtLineBoundary(text, offset, maxChars) {
|
|
891
|
-
if (offset >= text.length)
|
|
1040
|
+
if (offset >= text.length) {
|
|
1041
|
+
return { slice: "", nextOffset: null };
|
|
1042
|
+
}
|
|
892
1043
|
const remaining = text.slice(offset);
|
|
893
|
-
if (remaining.length <= maxChars)
|
|
1044
|
+
if (remaining.length <= maxChars) {
|
|
1045
|
+
return { slice: remaining, nextOffset: null };
|
|
1046
|
+
}
|
|
894
1047
|
const window = remaining.slice(0, maxChars);
|
|
895
1048
|
const lastBreak = window.lastIndexOf("\n");
|
|
896
1049
|
const cut = lastBreak > 0 ? lastBreak : maxChars;
|
|
@@ -899,11 +1052,16 @@ function sliceAtLineBoundary(text, offset, maxChars) {
|
|
|
899
1052
|
async function runGetLyrics(client, args) {
|
|
900
1053
|
try {
|
|
901
1054
|
const ref = {};
|
|
902
|
-
if (args.id)
|
|
903
|
-
|
|
1055
|
+
if (args.id) {
|
|
1056
|
+
ref.id = args.id;
|
|
1057
|
+
} else if (args.url) {
|
|
1058
|
+
ref.url = args.url;
|
|
1059
|
+
}
|
|
904
1060
|
const { data, cached } = await client.getSong(ref);
|
|
905
1061
|
const notes = [];
|
|
906
|
-
if (cached)
|
|
1062
|
+
if (cached) {
|
|
1063
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1064
|
+
}
|
|
907
1065
|
const credit = [data.title, data.artist].filter(Boolean).join(" \u2014 ");
|
|
908
1066
|
const attribution = `${credit || "Lyrics"} via lyrics.com \u2014 ${data.url}`;
|
|
909
1067
|
if (!data.hasLyrics) {
|
|
@@ -965,8 +1123,8 @@ ${attribution}`
|
|
|
965
1123
|
const summary = [
|
|
966
1124
|
attribution,
|
|
967
1125
|
`${structured.line_count} lines, ${structured.total_chars} characters.`,
|
|
968
|
-
pastTheEnd
|
|
969
|
-
structured.highlight
|
|
1126
|
+
howFarTheTextGoes(pastTheEnd, args.offset, nextOffset),
|
|
1127
|
+
whereTheWordIs(structured.highlight)
|
|
970
1128
|
].filter(Boolean).join("\n");
|
|
971
1129
|
return ok(structured, truncate(`${summary}
|
|
972
1130
|
|
|
@@ -991,7 +1149,9 @@ function dedupeSongs(songs) {
|
|
|
991
1149
|
const unique = [];
|
|
992
1150
|
for (const song of songs) {
|
|
993
1151
|
const key = songKey(song.artist, song.title);
|
|
994
|
-
if (seen.has(key))
|
|
1152
|
+
if (seen.has(key)) {
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
995
1155
|
seen.add(key);
|
|
996
1156
|
unique.push(song);
|
|
997
1157
|
}
|
|
@@ -1000,6 +1160,49 @@ function dedupeSongs(songs) {
|
|
|
1000
1160
|
|
|
1001
1161
|
// src/tools/searchLyrics.ts
|
|
1002
1162
|
var MAX_FULL_VERIFICATIONS = 5;
|
|
1163
|
+
async function keepTheOnesCarryingTheWords(client, args, unique, notes) {
|
|
1164
|
+
const kept = [];
|
|
1165
|
+
let filteredOut = 0;
|
|
1166
|
+
let fullChecks = 0;
|
|
1167
|
+
for (const song of unique) {
|
|
1168
|
+
if (kept.length >= args.limit) {
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
let text = song.snippet ?? "";
|
|
1172
|
+
if (args.verify === "full" && fullChecks < MAX_FULL_VERIFICATIONS) {
|
|
1173
|
+
fullChecks += 1;
|
|
1174
|
+
try {
|
|
1175
|
+
const page = await client.getSong({ id: song.id });
|
|
1176
|
+
if (page.data.hasLyrics) {
|
|
1177
|
+
text = page.data.lyrics;
|
|
1178
|
+
}
|
|
1179
|
+
} catch {
|
|
1180
|
+
notes.push(`Could not verify "${song.title}" against its full lyrics.`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const matches = args.verify === "none" || text !== "" && containsWord(args.query, text);
|
|
1184
|
+
if (!matches) {
|
|
1185
|
+
filteredOut += 1;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const excerpt = args.include_excerpt && text ? matchedLineExcerpt(text, args.query, { maxChars: 160 }) : null;
|
|
1189
|
+
kept.push({ song, excerpt });
|
|
1190
|
+
}
|
|
1191
|
+
return { kept, filteredOut };
|
|
1192
|
+
}
|
|
1193
|
+
function whatFollowsThisPage(hasMore, page) {
|
|
1194
|
+
if (!hasMore) {
|
|
1195
|
+
return "";
|
|
1196
|
+
}
|
|
1197
|
+
if (page < MAX_PAGE) {
|
|
1198
|
+
return `
|
|
1199
|
+
|
|
1200
|
+
More results available: call again with page=${page + 1}.`;
|
|
1201
|
+
}
|
|
1202
|
+
return `
|
|
1203
|
+
|
|
1204
|
+
lyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.`;
|
|
1205
|
+
}
|
|
1003
1206
|
var searchLyricsDescription = [
|
|
1004
1207
|
"Search lyrics.com for songs whose lyrics contain a given word or phrase.",
|
|
1005
1208
|
"Returns matching songs with artist, title, a short excerpt of the line where the word appears,",
|
|
@@ -1033,32 +1236,11 @@ async function runSearchLyrics(client, args) {
|
|
|
1033
1236
|
try {
|
|
1034
1237
|
const { data, cached } = await client.search(args.query, args.page);
|
|
1035
1238
|
const notes = [];
|
|
1036
|
-
if (cached)
|
|
1037
|
-
|
|
1038
|
-
const kept = [];
|
|
1039
|
-
let filteredOut = 0;
|
|
1040
|
-
let fullChecks = 0;
|
|
1041
|
-
for (const song of unique) {
|
|
1042
|
-
if (kept.length >= args.limit) break;
|
|
1043
|
-
let text = song.snippet ?? "";
|
|
1044
|
-
if (args.verify === "full" && fullChecks < MAX_FULL_VERIFICATIONS) {
|
|
1045
|
-
fullChecks += 1;
|
|
1046
|
-
try {
|
|
1047
|
-
const page = await client.getSong({ id: song.id });
|
|
1048
|
-
if (page.data.hasLyrics) text = page.data.lyrics;
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
notes.push(`Could not verify "${song.title}" against its full lyrics.`);
|
|
1051
|
-
void error;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
const matches = args.verify === "none" || text !== "" && containsWord(args.query, text);
|
|
1055
|
-
if (!matches) {
|
|
1056
|
-
filteredOut += 1;
|
|
1057
|
-
continue;
|
|
1058
|
-
}
|
|
1059
|
-
const excerpt = args.include_excerpt && text ? matchedLineExcerpt(text, args.query, { maxChars: 160 }) : null;
|
|
1060
|
-
kept.push({ song, excerpt });
|
|
1239
|
+
if (cached) {
|
|
1240
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1061
1241
|
}
|
|
1242
|
+
const unique = dedupeSongs(data.results);
|
|
1243
|
+
const { kept, filteredOut } = await keepTheOnesCarryingTheWords(client, args, unique, notes);
|
|
1062
1244
|
if (args.verify === "full" && unique.length > MAX_FULL_VERIFICATIONS) {
|
|
1063
1245
|
notes.push(
|
|
1064
1246
|
`Full verification is capped at ${MAX_FULL_VERIFICATIONS} songs per call to avoid rate limiting; the remaining results were checked against their search excerpt.`
|
|
@@ -1082,11 +1264,7 @@ async function runSearchLyrics(client, args) {
|
|
|
1082
1264
|
notes
|
|
1083
1265
|
};
|
|
1084
1266
|
const header = results.length > 0 ? `${results.length} song(s) on page ${args.page} for "${args.query}":` : `No song on page ${args.page} matched "${args.query}".`;
|
|
1085
|
-
const footer = data.hasMore
|
|
1086
|
-
|
|
1087
|
-
More results available: call again with page=${args.page + 1}.` : data.hasMore ? `
|
|
1088
|
-
|
|
1089
|
-
lyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.` : "";
|
|
1267
|
+
const footer = whatFollowsThisPage(data.hasMore, args.page);
|
|
1090
1268
|
return ok(structured, `${header}
|
|
1091
1269
|
${renderResultList(results)}${footer}`);
|
|
1092
1270
|
} catch (error) {
|
|
@@ -1096,6 +1274,28 @@ ${renderResultList(results)}${footer}`);
|
|
|
1096
1274
|
|
|
1097
1275
|
// src/tools/searchSongs.ts
|
|
1098
1276
|
import { z as z5 } from "zod";
|
|
1277
|
+
function whatFollowsThisPage2(hasMore, page) {
|
|
1278
|
+
if (!hasMore) {
|
|
1279
|
+
return "";
|
|
1280
|
+
}
|
|
1281
|
+
if (page < MAX_PAGE) {
|
|
1282
|
+
return `
|
|
1283
|
+
|
|
1284
|
+
More results available: call again with page=${page + 1}.`;
|
|
1285
|
+
}
|
|
1286
|
+
return `
|
|
1287
|
+
|
|
1288
|
+
lyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.`;
|
|
1289
|
+
}
|
|
1290
|
+
function headerFor(found, loose, page, title) {
|
|
1291
|
+
if (found === 0) {
|
|
1292
|
+
return `No song on page ${page} matched "${title}".`;
|
|
1293
|
+
}
|
|
1294
|
+
if (loose) {
|
|
1295
|
+
return `${found} song(s) lyrics.com offers on page ${page} for "${title}":`;
|
|
1296
|
+
}
|
|
1297
|
+
return `${found} song(s) on page ${page} matching "${title}":`;
|
|
1298
|
+
}
|
|
1099
1299
|
var searchSongsDescription = [
|
|
1100
1300
|
"Find songs on lyrics.com by their title.",
|
|
1101
1301
|
"Returns candidate songs with artist, album, year, and the lyrics.com id and URL.",
|
|
@@ -1131,17 +1331,27 @@ function normalizeTitle(title) {
|
|
|
1131
1331
|
function titleScore(candidate, query) {
|
|
1132
1332
|
const a = normalizeTitle(candidate);
|
|
1133
1333
|
const b = normalizeTitle(query);
|
|
1134
|
-
if (!a
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
if (a
|
|
1334
|
+
if (!(a && b)) {
|
|
1335
|
+
return 0;
|
|
1336
|
+
}
|
|
1337
|
+
if (a === b) {
|
|
1338
|
+
return 3;
|
|
1339
|
+
}
|
|
1340
|
+
if (a.startsWith(b)) {
|
|
1341
|
+
return 2;
|
|
1342
|
+
}
|
|
1343
|
+
if (a.includes(b)) {
|
|
1344
|
+
return 1;
|
|
1345
|
+
}
|
|
1138
1346
|
return 0;
|
|
1139
1347
|
}
|
|
1140
1348
|
async function runSearchSongs(client, args) {
|
|
1141
1349
|
try {
|
|
1142
1350
|
const { data, cached } = await client.search(args.title, args.page);
|
|
1143
1351
|
const notes = [];
|
|
1144
|
-
if (cached)
|
|
1352
|
+
if (cached) {
|
|
1353
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1354
|
+
}
|
|
1145
1355
|
const artistFilter = args.artist?.trim().toLowerCase() ?? null;
|
|
1146
1356
|
const unique = dedupeSongs(data.results);
|
|
1147
1357
|
const scored = [];
|
|
@@ -1175,12 +1385,8 @@ async function runSearchSongs(client, args) {
|
|
|
1175
1385
|
notes
|
|
1176
1386
|
};
|
|
1177
1387
|
const loose = args.match !== "strict";
|
|
1178
|
-
const header = results.length
|
|
1179
|
-
const footer = data.hasMore
|
|
1180
|
-
|
|
1181
|
-
More results available: call again with page=${args.page + 1}.` : data.hasMore ? `
|
|
1182
|
-
|
|
1183
|
-
lyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.` : "";
|
|
1388
|
+
const header = headerFor(results.length, loose, args.page, args.title);
|
|
1389
|
+
const footer = whatFollowsThisPage2(data.hasMore, args.page);
|
|
1184
1390
|
if (results.length > 0 && loose) {
|
|
1185
1391
|
notes.push(
|
|
1186
1392
|
`These are lyrics.com's own suggestions for "${args.title}", not rows whose title matches it. Check each title before quoting one, or ask again with match="strict".`
|
|
@@ -1261,16 +1467,16 @@ async function main() {
|
|
|
1261
1467
|
const transport = new StdioServerTransport();
|
|
1262
1468
|
await server.connect(transport);
|
|
1263
1469
|
const shutdown = () => {
|
|
1264
|
-
|
|
1470
|
+
server.close().catch(() => void 0).finally(() => process3.exit(0));
|
|
1265
1471
|
};
|
|
1266
|
-
|
|
1267
|
-
|
|
1472
|
+
process3.on("SIGINT", shutdown);
|
|
1473
|
+
process3.on("SIGTERM", shutdown);
|
|
1268
1474
|
}
|
|
1269
1475
|
main().catch((error) => {
|
|
1270
|
-
|
|
1476
|
+
process3.stderr.write(
|
|
1271
1477
|
`[mcp-lyricscom] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1272
1478
|
`
|
|
1273
1479
|
);
|
|
1274
|
-
|
|
1480
|
+
process3.exit(1);
|
|
1275
1481
|
});
|
|
1276
1482
|
//# sourceMappingURL=index.js.map
|