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/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 = "1.4.0";
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() === "") return range.fallback;
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() === "") return DEFAULTS.minIntervalMs;
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) return void 0;
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) return;
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) break;
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) return { kind: "throttled", reason: "status-202" };
207
- if (status === 429) return { kind: "throttled", reason: "status-429" };
208
- if (status === 503) return { kind: "throttled", reason: "status-503" };
209
- if (status === 403) return { kind: "blocked", status };
210
- if (status === 404) return { kind: "not-found" };
211
- if (status >= 500) return { kind: "server-error", status };
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) return { kind: "throttled", reason: "empty-body" };
214
- if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\/html>/i.test(trimmed)) {
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) return;
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) await sleep(remaining);
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) return null;
400
+ if (!raw) {
401
+ return null;
402
+ }
359
403
  const seconds = Number(raw.trim());
360
- if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
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)) return null;
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) return error;
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 * as cheerio from "cheerio";
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) return 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) return match;
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 || !/[ÃÂ]/.test(text)) return 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")) return text;
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) return "";
487
+ if (!text) {
488
+ return "";
489
+ }
428
490
  return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));
429
491
  }
430
492
  function cleanLyricsText(text) {
431
- if (!text) return "";
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[out.length - 1] === "")) continue;
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[out.length - 1] === "") out.pop();
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) return `${BASE_URL}/lyrics/${encoded}`;
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:") return false;
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 (/^https?:\/\//i.test(hrefOrUrl)) {
472
- if (!isLyricsComHost(hrefOrUrl)) return null;
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("/")) path = `/${path}`;
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 (!/^\d+$/.test(input.id)) {
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 $ = cheerio.load(html);
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 || !title || !artist) {
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
- process.stderr.write(
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 = /\b(1[89]\d{2}|20\d{2})\b/.exec(raw);
593
- if (!match?.[1]) return null;
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 * as cheerio2 from "cheerio";
679
+ import { load as load2 } from "cheerio";
680
+ var NO_LYRICS_FOUND = /no lyrics found/i;
599
681
  function parseSongPage(html, options) {
600
- const $ = cheerio2.load(html);
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 || /no lyrics found/i.test($("title").text());
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 cheerio2.load(withNewlines).root().text();
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 = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.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 || !text) return -1;
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 || !keyword) return null;
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() === "") continue;
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) return null;
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) return null;
729
- if (line.length <= maxChars) return line;
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 errorMap = (issue, ctx) => issue.code === z.ZodIssueCode.unrecognized_keys ? { message: unknownArgumentMessage(issue.keys, declared) } : { message: ctx.defaultError };
743
- return z.strictObject(shape, { errorMap });
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) return void 0;
864
+ if (flat.length === 0) {
865
+ return void 0;
866
+ }
756
867
  const sameName = declared.find((name) => flatten(name) === flat);
757
- if (sameName) return 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) return 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
- for (let column = 1; column <= right.length; column += 1) {
780
- const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
781
- current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
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 previous[right.length];
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) lines.push(`Hint: ${known.details.hint}`);
830
- if (known.details.url) lines.push(`URL: ${known.details.url}`);
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) return text;
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) return { slice: "", nextOffset: null };
1040
+ if (offset >= text.length) {
1041
+ return { slice: "", nextOffset: null };
1042
+ }
892
1043
  const remaining = text.slice(offset);
893
- if (remaining.length <= maxChars) return { slice: remaining, nextOffset: null };
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) ref.id = args.id;
903
- else if (args.url) ref.url = args.url;
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) notes.push("Served from this server's short-lived in-memory cache.");
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 ? `Nothing at offset=${args.offset}: that is past the end. Call again with offset=0.` : nextOffset !== null ? `Truncated: call again with offset=${nextOffset} to continue.` : "Complete.",
969
- structured.highlight ? structured.highlight.found ? `"${structured.highlight.word}" appears on line ${structured.highlight.line_number}.` : `"${structured.highlight.word}" does not appear in these lyrics.` : ""
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)) continue;
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) notes.push("Served from this server's short-lived in-memory cache.");
1037
- const unique = dedupeSongs(data.results);
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 && args.page < MAX_PAGE ? `
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 || !b) return 0;
1135
- if (a === b) return 3;
1136
- if (a.startsWith(b)) return 2;
1137
- if (a.includes(b)) return 1;
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) notes.push("Served from this server's short-lived in-memory cache.");
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 > 0 ? loose ? `${results.length} song(s) lyrics.com offers on page ${args.page} for "${args.title}":` : `${results.length} song(s) on page ${args.page} matching "${args.title}":` : `No song on page ${args.page} matched "${args.title}".`;
1179
- const footer = data.hasMore && args.page < MAX_PAGE ? `
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
- void server.close().finally(() => process.exit(0));
1470
+ server.close().catch(() => void 0).finally(() => process3.exit(0));
1265
1471
  };
1266
- process.on("SIGINT", shutdown);
1267
- process.on("SIGTERM", shutdown);
1472
+ process3.on("SIGINT", shutdown);
1473
+ process3.on("SIGTERM", shutdown);
1268
1474
  }
1269
1475
  main().catch((error) => {
1270
- process.stderr.write(
1476
+ process3.stderr.write(
1271
1477
  `[mcp-lyricscom] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
1272
1478
  `
1273
1479
  );
1274
- process.exit(1);
1480
+ process3.exit(1);
1275
1481
  });
1276
1482
  //# sourceMappingURL=index.js.map