mcp-lyricscom 1.2.0 → 1.3.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/README.md +16 -0
- package/dist/index.js +46 -16
- package/dist/index.js.map +1 -1
- package/dist/lyricscom/client.js +1 -1
- package/dist/lyricscom/client.js.map +1 -1
- package/package.json +1 -1
- package/server.json +5 -6
package/README.md
CHANGED
|
@@ -41,6 +41,14 @@ claude mcp add lyricscom -- npx -y mcp-lyricscom
|
|
|
41
41
|
|
|
42
42
|
That is the whole setup. There is nothing to sign up for.
|
|
43
43
|
|
|
44
|
+
**Bundle, without npm**
|
|
45
|
+
|
|
46
|
+
Download `mcp-lyricscom-<version>.mcpb` from
|
|
47
|
+
[the latest release](https://github.com/smeet666/mcp-lyricscom/releases/latest) and open
|
|
48
|
+
it. A client that supports MCP bundles installs it on its own, with no npm and
|
|
49
|
+
no configuration file to edit. The bundle carries its dependencies, so nothing
|
|
50
|
+
is fetched at install time.
|
|
51
|
+
|
|
44
52
|
## Tools
|
|
45
53
|
|
|
46
54
|
| Tool | What it does | Key parameters |
|
|
@@ -240,6 +248,14 @@ claude mcp add lyricscom -- npx -y mcp-lyricscom
|
|
|
240
248
|
|
|
241
249
|
C'est toute l'installation. Il n'y a aucune inscription.
|
|
242
250
|
|
|
251
|
+
**Bundle, sans npm**
|
|
252
|
+
|
|
253
|
+
Téléchargez `mcp-lyricscom-<version>.mcpb` depuis
|
|
254
|
+
[la dernière release](https://github.com/smeet666/mcp-lyricscom/releases/latest) et
|
|
255
|
+
ouvrez-le. Un client compatible avec les bundles MCP l'installe seul, sans npm
|
|
256
|
+
ni fichier de configuration à modifier. Le bundle embarque ses dépendances,
|
|
257
|
+
donc rien n'est téléchargé à l'installation.
|
|
258
|
+
|
|
243
259
|
## Outils
|
|
244
260
|
|
|
245
261
|
| Outil | Rôle | Paramètres principaux |
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var PKG_VERSION = "1.
|
|
7
|
+
var PKG_VERSION = "1.3.0";
|
|
8
8
|
var REPO_URL = "https://github.com/smeet666/mcp-lyricscom";
|
|
9
9
|
|
|
10
10
|
// src/config.ts
|
|
@@ -737,6 +737,7 @@ function matchedLineExcerpt(text, keyword, options = {}) {
|
|
|
737
737
|
// src/tools/shared.ts
|
|
738
738
|
import { z } from "zod";
|
|
739
739
|
var MAX_TEXT_MIRROR_CHARS = 1500;
|
|
740
|
+
var MAX_PAGE = 20;
|
|
740
741
|
var songResultSchema = z.object({
|
|
741
742
|
id: z.string().describe("lyrics.com song id. Pass this to get_lyrics."),
|
|
742
743
|
title: z.string(),
|
|
@@ -757,9 +758,17 @@ function toSongResultOut(song, excerpt) {
|
|
|
757
758
|
excerpt
|
|
758
759
|
};
|
|
759
760
|
}
|
|
760
|
-
function
|
|
761
|
+
function indentMarkerLines(body) {
|
|
762
|
+
return body.replace(/^(Note:)/gm, " $1");
|
|
763
|
+
}
|
|
764
|
+
function ok(structured, text, notes = []) {
|
|
765
|
+
const trailer = notes.map((note) => `Note: ${note}`).join("\n");
|
|
766
|
+
const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
|
|
767
|
+
const body = truncate(indentMarkerLines(text), Math.max(0, budget));
|
|
761
768
|
return {
|
|
762
|
-
content: [{ type: "text", text:
|
|
769
|
+
content: [{ type: "text", text: trailer ? `${body}
|
|
770
|
+
|
|
771
|
+
${trailer}` : body }],
|
|
763
772
|
structuredContent: structured
|
|
764
773
|
};
|
|
765
774
|
}
|
|
@@ -824,7 +833,8 @@ var getLyricsOutputShape = {
|
|
|
824
833
|
line: z2.string().nullable()
|
|
825
834
|
}).nullable(),
|
|
826
835
|
attribution: z2.string().describe("Ready-to-display credit line."),
|
|
827
|
-
source: z2.literal("lyrics.com")
|
|
836
|
+
source: z2.literal("lyrics.com"),
|
|
837
|
+
notes: z2.array(z2.string())
|
|
828
838
|
};
|
|
829
839
|
function sliceAtLineBoundary(text, offset, maxChars) {
|
|
830
840
|
if (offset >= text.length) return { slice: "", nextOffset: null };
|
|
@@ -840,7 +850,9 @@ async function runGetLyrics(client, args) {
|
|
|
840
850
|
const ref = {};
|
|
841
851
|
if (args.id) ref.id = args.id;
|
|
842
852
|
else if (args.url) ref.url = args.url;
|
|
843
|
-
const { data } = await client.getSong(ref);
|
|
853
|
+
const { data, cached } = await client.getSong(ref);
|
|
854
|
+
const notes = [];
|
|
855
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
844
856
|
const credit = [data.title, data.artist].filter(Boolean).join(" \u2014 ");
|
|
845
857
|
const attribution = `${credit || "Lyrics"} via lyrics.com \u2014 ${data.url}`;
|
|
846
858
|
if (!data.hasLyrics) {
|
|
@@ -856,6 +868,7 @@ async function runGetLyrics(client, args) {
|
|
|
856
868
|
offset: 0,
|
|
857
869
|
next_offset: null,
|
|
858
870
|
truncated: false,
|
|
871
|
+
notes,
|
|
859
872
|
line_count: 0,
|
|
860
873
|
highlight: null,
|
|
861
874
|
attribution,
|
|
@@ -889,17 +902,24 @@ ${attribution}`
|
|
|
889
902
|
line: match?.line ?? null
|
|
890
903
|
} : null,
|
|
891
904
|
attribution,
|
|
892
|
-
source: "lyrics.com"
|
|
905
|
+
source: "lyrics.com",
|
|
906
|
+
notes
|
|
893
907
|
};
|
|
908
|
+
const pastTheEnd = args.offset > 0 && slice === "" && data.lyrics.length > 0;
|
|
909
|
+
if (pastTheEnd) {
|
|
910
|
+
notes.push(
|
|
911
|
+
`offset=${args.offset} is past the end of a body of ${data.lyrics.length} characters. Call again with offset=0 to read it from the start.`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
894
914
|
const summary = [
|
|
895
915
|
attribution,
|
|
896
916
|
`${structured.line_count} lines, ${structured.total_chars} characters.`,
|
|
897
|
-
nextOffset !== null ? `Truncated: call again with offset=${nextOffset} to continue.` : "Complete.",
|
|
917
|
+
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.",
|
|
898
918
|
structured.highlight ? structured.highlight.found ? `"${structured.highlight.word}" appears on line ${structured.highlight.line_number}.` : `"${structured.highlight.word}" does not appear in these lyrics.` : ""
|
|
899
919
|
].filter(Boolean).join("\n");
|
|
900
920
|
return ok(structured, truncate(`${summary}
|
|
901
921
|
|
|
902
|
-
${slice}`, 4e3));
|
|
922
|
+
${slice}`, 4e3), notes);
|
|
903
923
|
} catch (error) {
|
|
904
924
|
return toToolError(error);
|
|
905
925
|
}
|
|
@@ -1006,14 +1026,16 @@ async function runSearchLyrics(client, args) {
|
|
|
1006
1026
|
raw_result_count: data.rawCount,
|
|
1007
1027
|
filtered_out: filteredOut,
|
|
1008
1028
|
has_more: data.hasMore,
|
|
1009
|
-
next_page: data.hasMore ? args.page + 1 : null,
|
|
1029
|
+
next_page: data.hasMore && args.page < MAX_PAGE ? args.page + 1 : null,
|
|
1010
1030
|
source: "lyrics.com",
|
|
1011
1031
|
notes
|
|
1012
1032
|
};
|
|
1013
1033
|
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}".`;
|
|
1014
|
-
const footer = data.hasMore ? `
|
|
1034
|
+
const footer = data.hasMore && args.page < MAX_PAGE ? `
|
|
1035
|
+
|
|
1036
|
+
More results available: call again with page=${args.page + 1}.` : data.hasMore ? `
|
|
1015
1037
|
|
|
1016
|
-
|
|
1038
|
+
lyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.` : "";
|
|
1017
1039
|
return ok(structured, `${header}
|
|
1018
1040
|
${renderResultList(results)}${footer}`);
|
|
1019
1041
|
} catch (error) {
|
|
@@ -1097,16 +1119,24 @@ async function runSearchSongs(client, args) {
|
|
|
1097
1119
|
raw_result_count: data.rawCount,
|
|
1098
1120
|
filtered_out: filteredOut,
|
|
1099
1121
|
has_more: data.hasMore,
|
|
1100
|
-
next_page: data.hasMore ? args.page + 1 : null,
|
|
1122
|
+
next_page: data.hasMore && args.page < MAX_PAGE ? args.page + 1 : null,
|
|
1101
1123
|
source: "lyrics.com",
|
|
1102
1124
|
notes
|
|
1103
1125
|
};
|
|
1104
|
-
const
|
|
1105
|
-
const
|
|
1126
|
+
const loose = args.match !== "strict";
|
|
1127
|
+
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}".`;
|
|
1128
|
+
const footer = data.hasMore && args.page < MAX_PAGE ? `
|
|
1106
1129
|
|
|
1107
|
-
More results available: call again with page=${args.page + 1}.` :
|
|
1130
|
+
More results available: call again with page=${args.page + 1}.` : data.hasMore ? `
|
|
1131
|
+
|
|
1132
|
+
lyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.` : "";
|
|
1133
|
+
if (results.length > 0 && loose) {
|
|
1134
|
+
notes.push(
|
|
1135
|
+
`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".`
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1108
1138
|
return ok(structured, `${header}
|
|
1109
|
-
${renderResultList(results)}${footer}
|
|
1139
|
+
${renderResultList(results)}${footer}`, notes);
|
|
1110
1140
|
} catch (error) {
|
|
1111
1141
|
return toToolError(error);
|
|
1112
1142
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/version.ts","../src/config.ts","../src/server.ts","../src/lyricscom/cache.ts","../src/errors.ts","../src/lyricscom/parsers/detectThrottle.ts","../src/lyricscom/rateLimiter.ts","../src/lyricscom/http.ts","../src/lyricscom/parsers/searchResults.ts","../src/text/normalize.ts","../src/lyricscom/urls.ts","../src/lyricscom/parsers/selectors.ts","../src/lyricscom/parsers/songPage.ts","../src/lyricscom/client.ts","../src/tools/getLyrics.ts","../src/text/keywordIndex.ts","../src/text/excerpt.ts","../src/tools/shared.ts","../src/tools/searchLyrics.ts","../src/text/dedupe.ts","../src/tools/searchSongs.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Entry point: an MCP server for lyrics.com over stdio.\n */\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { createServer } from \"./server.js\";\n\nasync function main(): Promise<void> {\n const config = loadConfig();\n const logger = createLogger(config.logLevel);\n const server = createServer({ config, logger });\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n const shutdown = () => {\n void server.close().finally(() => process.exit(0));\n };\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n}\n\nmain().catch((error: unknown) => {\n process.stderr.write(\n `[mcp-lyricscom] fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exit(1);\n});\n","/**\n * Kept in sync with package.json. The User-Agent embeds it, so it must not\n * drift; the release checklist bumps both together.\n */\nexport const PKG_VERSION = \"1.2.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-lyricscom\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * Identifies the client honestly. lyrics.com serves this fine; it blocks\n * generic tool agents such as `curl/x.y.z`, which is what the override is for.\n */\nexport const DEFAULT_USER_AGENT = `mcp-lyricscom/${PKG_VERSION} (+${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1100,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * The pacing is what keeps this client from behaving like a crawler against a\n * site that has no API and no published rate limit. Leaving it configurable down\n * to zero would make the politeness of every installation depend on whoever\n * edited a JSON file, so a value below this floor is refused and the default is\n * used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n const clamped = Math.min(range.max, Math.max(range.min, Math.round(parsed)));\n if (clamped !== Math.round(parsed)) {\n warn(`${name}=${raw} is out of range, clamped to ${clamped}`);\n }\n return clamped;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 500, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.LYRICSCOM_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`LYRICSCOM_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `LYRICSCOM_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. This floor keeps the client from hammering lyrics.com.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.LYRICSCOM_USER_AGENT?.trim();\n const rawLogLevel = env.LYRICSCOM_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`LYRICSCOM_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"LYRICSCOM_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"LYRICSCOM_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"LYRICSCOM_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"LYRICSCOM_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * MCP server wiring.\n *\n * One client, one rate limiter and one cache are shared by all three tools:\n * per-tool instances would let three tools each open their own request stream\n * and defeat the throttling protection.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport type { Config, Logger } from \"./config.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { LyricsComClient } from \"./lyricscom/client.js\";\nimport {\n getLyricsDescription,\n getLyricsInputShape,\n getLyricsOutputShape,\n runGetLyrics,\n} from \"./tools/getLyrics.js\";\nimport type { GetLyricsArgs } from \"./tools/getLyrics.js\";\nimport {\n runSearchLyrics,\n searchLyricsDescription,\n searchLyricsInputShape,\n searchLyricsOutputShape,\n} from \"./tools/searchLyrics.js\";\nimport type { SearchLyricsArgs } from \"./tools/searchLyrics.js\";\nimport {\n runSearchSongs,\n searchSongsDescription,\n searchSongsInputShape,\n searchSongsOutputShape,\n} from \"./tools/searchSongs.js\";\nimport type { SearchSongsArgs } from \"./tools/searchSongs.js\";\nimport { PKG_VERSION } from \"./version.js\";\n\nexport interface CreateServerOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nconst READ_ONLY = {\n readOnlyHint: true,\n destructiveHint: false,\n idempotentHint: true,\n openWorldHint: true,\n} as const;\n\nexport function createServer(options: CreateServerOptions = {}): McpServer {\n const config = options.config ?? loadConfig();\n const logger = options.logger ?? createLogger(config.logLevel);\n const client = new LyricsComClient({\n config,\n logger,\n ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),\n });\n\n const server = new McpServer(\n { name: \"mcp-lyricscom\", version: PKG_VERSION },\n {\n instructions:\n \"Tools for looking up song lyrics on lyrics.com. No API key is needed. \" +\n \"Typical flow: search_lyrics (by a word in the lyrics) or search_songs (by title) to get a song id, \" +\n \"then get_lyrics with that id. When you show lyrics to a user, credit the artist and link the source URL. \" +\n \"A 'throttled' error means lyrics.com is rate limiting this client, not that the song does not exist.\",\n },\n );\n\n server.registerTool(\n \"search_lyrics\",\n {\n title: \"Search lyrics by word\",\n description: searchLyricsDescription,\n inputSchema: z.object(searchLyricsInputShape),\n outputSchema: z.object(searchLyricsOutputShape),\n annotations: READ_ONLY,\n },\n async (args) => runSearchLyrics(client, args as SearchLyricsArgs),\n );\n\n server.registerTool(\n \"search_songs\",\n {\n title: \"Search songs by title\",\n description: searchSongsDescription,\n inputSchema: z.object(searchSongsInputShape),\n outputSchema: z.object(searchSongsOutputShape),\n annotations: READ_ONLY,\n },\n async (args) => runSearchSongs(client, args as SearchSongsArgs),\n );\n\n server.registerTool(\n \"get_lyrics\",\n {\n title: \"Get full lyrics\",\n description: getLyricsDescription,\n inputSchema: z.object(getLyricsInputShape),\n outputSchema: z.object(getLyricsOutputShape),\n annotations: READ_ONLY,\n },\n async (args) => runGetLyrics(client, args as GetLyricsArgs),\n );\n\n logger.info(\n `ready: user-agent=\"${config.userAgent}\", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms`,\n );\n\n return server;\n}\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The point of this file is that an LLM must be able to tell \"lyrics.com pushed\n * back on me\" apart from \"there is no such song\". The reference implementation\n * this server replaces collapsed every failure into an empty list, which made\n * rate limiting look like an absence of results.\n */\n\nexport type ErrorCode =\n | \"throttled\"\n | \"blocked_user_agent\"\n | \"not_found\"\n | \"invalid_input\"\n | \"parse_failure\"\n | \"network_error\"\n | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class LyricsComError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"LyricsComError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-lyricscom/issues\";\n\nexport function throttled(url: string, retryAfterMs: number): LyricsComError {\n return new LyricsComError(\n \"throttled\",\n \"lyrics.com is rate limiting this client. It answered with an empty response instead of a page. \" +\n \"This does NOT mean there are no results for this query.\",\n {\n url,\n retryAfterMs,\n hint:\n \"Wait at least a minute before calling the same tool again with the same arguments. Once lyrics.com \" +\n \"starts throttling, the window commonly lasts several minutes, so retrying immediately will fail again. \" +\n \"If it keeps happening, raise LYRICSCOM_MIN_INTERVAL_MS in your MCP client configuration.\",\n },\n );\n}\n\nexport function blockedUserAgent(url: string): LyricsComError {\n return new LyricsComError(\n \"blocked_user_agent\",\n \"lyrics.com refused the request because of the User-Agent this server sends (HTTP 403).\",\n {\n url,\n status: 403,\n hint:\n \"Set the LYRICSCOM_USER_AGENT environment variable in your MCP client configuration to a different \" +\n \"value. See the Configuration section of the project README.\",\n },\n );\n}\n\nexport function notFound(url: string): LyricsComError {\n return new LyricsComError(\"not_found\", \"lyrics.com has no page at this address.\", {\n url,\n status: 404,\n hint: \"Check the song id. Ids come from the search tools; they are not stable to guess by hand.\",\n });\n}\n\nexport function parseFailure(url: string, what: string): LyricsComError {\n return new LyricsComError(\n \"parse_failure\",\n `The page loaded but the expected lyrics.com markup was not found (${what}). The site layout may have changed.`,\n { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },\n );\n}\n\nexport function invalidInput(message: string, hint?: string): LyricsComError {\n return new LyricsComError(\"invalid_input\", message, hint ? { hint } : {});\n}\n","/**\n * Response classification.\n *\n * lyrics.com does not answer rate limiting with 429. It answers with HTTP 202\n * and a zero-byte body, which a naive client reads as a successful but empty\n * page. Every response therefore goes through this classifier before any\n * parsing happens.\n */\n\nexport type ResponseVerdict =\n | { kind: \"ok\"; html: string }\n | { kind: \"throttled\"; reason: ThrottleReason }\n | { kind: \"blocked\"; status: number }\n | { kind: \"not-found\" }\n | { kind: \"server-error\"; status: number };\n\nexport type ThrottleReason =\n \"status-202\" | \"status-429\" | \"status-503\" | \"empty-body\" | \"short-body\";\n\n/**\n * A real search page weighs about 187 KB and a song page 60 KB or more, so a\n * body this small is never a genuine lyrics.com page.\n */\nexport const MIN_PLAUSIBLE_HTML = 2000;\n\nexport function classifyResponse(status: number, body: string): ResponseVerdict {\n if (status === 202) return { kind: \"throttled\", reason: \"status-202\" };\n if (status === 429) return { kind: \"throttled\", reason: \"status-429\" };\n if (status === 503) return { kind: \"throttled\", reason: \"status-503\" };\n if (status === 403) return { kind: \"blocked\", status };\n if (status === 404) return { kind: \"not-found\" };\n if (status >= 500) return { kind: \"server-error\", status };\n\n const trimmed = body.trim();\n if (trimmed.length === 0) return { kind: \"throttled\", reason: \"empty-body\" };\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n return { kind: \"throttled\", reason: \"short-body\" };\n }\n\n return { kind: \"ok\", html: body };\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // virtual machine, would otherwise make this wait for the size of the step,\n // and the queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, classified, with backoff.\n *\n * The retry policy is domain specific rather than generic, because the failure\n * this server exists to handle does not look like a failure at the transport\n * level: lyrics.com answers a throttled request with HTTP 202 and an empty\n * body. Only `classifyResponse` can tell that apart from a real page.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { blockedUserAgent, LyricsComError, notFound, throttled } from \"../errors.js\";\nimport { classifyResponse } from \"./parsers/detectThrottle.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;\n const capped = Math.min(BACKOFF_MAX_MS, uncapped);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The whole retry loop, backoff sleeps included, runs inside a single limiter\n * slot. Waiting outside it would let a queued request slip into the same\n * throttling window that the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: LyricsComError | undefined;\n\n // Set when the site says how long to stay away; it replaces our own guess\n // for the next attempt. Applied here rather than where it is read, so no\n // wait is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.9,fr;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n const verdict = classifyResponse(status, body);\n switch (verdict.kind) {\n case \"ok\":\n limiter.relax();\n return verdict.html;\n\n case \"throttled\":\n limiter.penalize();\n logger.info(\n `throttled (${verdict.reason}) on ${url}, interval now ${limiter.currentIntervalMs}ms`,\n );\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = throttled(url, retryAfterMs ?? backoffDelay(attempt));\n continue;\n\n case \"server-error\":\n lastError = new LyricsComError(\n \"network_error\",\n `lyrics.com returned HTTP ${verdict.status}.`,\n {\n url,\n status: verdict.status,\n },\n );\n continue;\n\n // Retrying these would only repeat the same answer.\n case \"blocked\":\n throw blockedUserAgent(url);\n case \"not-found\":\n throw notFound(url);\n }\n }\n\n throw lastError ?? new LyricsComError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): LyricsComError {\n if (error instanceof LyricsComError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new LyricsComError(\"timeout\", \"lyrics.com did not answer in time.\", {\n url,\n hint: \"Raise LYRICSCOM_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new LyricsComError(\"network_error\", `Could not reach lyrics.com: ${message}`, { url });\n}\n","/**\n * Parser for a lyrics.com search results page.\n *\n * Page 1 (`/lyrics/{term}`) and later pages (`/serp.php?st=...&p=N`) share the\n * same markup, so one parser covers both.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SearchPage, SongResult } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { extractSongId, RESULTS_PER_PAGE, toAbsoluteUrl } from \"../urls.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSearchOptions {\n page: number;\n /** Reported in errors so a failure points at the request that caused it. */\n url: string;\n}\n\nexport function parseSearchResults(html: string, options: ParseSearchOptions): SearchPage {\n const $ = cheerio.load(html);\n const rows = $(SEL.serpItem);\n\n if (rows.length === 0) {\n // An empty result set still renders the page chrome. Its absence means the\n // response was not the page we asked for, which must not be reported as\n // \"no songs found\".\n if ($(SEL.serpShell).length === 0) {\n throw parseFailure(options.url, \"no result rows and no recognizable search page structure\");\n }\n return { results: [], page: options.page, rawCount: 0, hasMore: false };\n }\n\n const results: SongResult[] = [];\n let skipped = 0;\n\n rows.each((_, element) => {\n const row = $(element);\n const titleLink = row.find(SEL.resultTitle).first();\n const href = titleLink.attr(\"href\");\n const title = cleanInlineText(titleLink.text());\n const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());\n\n const id = href ? extractSongId(href) : null;\n if (!id || !title || !artist) {\n skipped += 1;\n return;\n }\n\n const album = cleanInlineText(row.find(SEL.resultAlbum).first().text()) || null;\n const year = parseYear(row.find(SEL.resultYear).first().text());\n const snippetRaw = row.find(SEL.resultSnippet).first().text();\n const snippet = snippetRaw ? cleanLyricsText(snippetRaw) || null : null;\n\n results.push({\n id,\n url: toAbsoluteUrl(href as string),\n title,\n artist,\n album,\n year,\n snippet,\n });\n });\n\n if (results.length === 0) {\n throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(\n `[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}\\n`,\n );\n }\n\n return {\n results,\n page: options.page,\n rawCount: rows.length,\n hasMore: rows.length >= RESULTS_PER_PAGE,\n };\n}\n\n/** Year cells look like \"1971\" but are sometimes decorated or empty. */\nfunction parseYear(raw: string): number | null {\n const match = /\\b(1[89]\\d{2}|20\\d{2})\\b/.exec(raw);\n if (!match?.[1]) return null;\n return Number.parseInt(match[1], 10);\n}\n","/**\n * Text cleaning for scraped content: HTML entities, mojibake, whitespace.\n */\n\nconst HTML_ENTITIES: Record<string, string> = {\n amp: \"&\",\n lt: \"<\",\n gt: \">\",\n quot: '\"',\n apos: \"'\",\n nbsp: \" \",\n hellip: \"…\",\n mdash: \"—\",\n ndash: \"–\",\n rsquo: \"’\",\n lsquo: \"‘\",\n ldquo: \"“\",\n rdquo: \"”\",\n};\n\n/** Decode named and numeric HTML entities. */\nexport function unescapeHtml(text: string): string {\n if (!text) return text;\n return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity: string) => {\n if (entity.startsWith(\"#\")) {\n const isHex = entity[1] === \"x\" || entity[1] === \"X\";\n const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);\n if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;\n try {\n return String.fromCodePoint(codePoint);\n } catch {\n return match;\n }\n }\n return HTML_ENTITIES[entity.toLowerCase()] ?? match;\n });\n}\n\n/**\n * Repair UTF-8 bytes that were decoded as Latin-1, which turns \"é\" into \"é\".\n * lyrics.com serves French titles that occasionally arrive in this state.\n * Re-encoding through latin1 recovers the original text; the round trip is only\n * kept when it removes replacement characters, so correct text is never touched.\n */\nexport function fixEncodingIssues(text: string): string {\n if (!text || !/[ÃÂ]/.test(text)) return text;\n try {\n const repaired = Buffer.from(text, \"latin1\").toString(\"utf8\");\n if (repaired.includes(\"�\")) return text;\n return repaired;\n } catch {\n return text;\n }\n}\n\n/** Collapse runs of whitespace and trim. */\nexport function collapseWhitespace(text: string): string {\n return text.replace(/\\s+/g, \" \").trim();\n}\n\n/** Full cleaning pass for a scraped title or artist name. */\nexport function cleanInlineText(text: string): string {\n if (!text) return \"\";\n return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));\n}\n\n/**\n * Normalize a lyrics block: trim each line, drop leading and trailing blank\n * lines, and collapse runs of blank lines to a single verse separator.\n */\nexport function cleanLyricsText(text: string): string {\n if (!text) return \"\";\n const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\\r\\n?/g, \"\\n\");\n const lines = normalized.split(\"\\n\").map((line) => line.trim());\n\n const out: string[] = [];\n for (const line of lines) {\n if (line === \"\" && (out.length === 0 || out[out.length - 1] === \"\")) continue;\n out.push(line);\n }\n while (out.length > 0 && out[out.length - 1] === \"\") out.pop();\n return out.join(\"\\n\");\n}\n","/**\n * URL construction and validation for lyrics.com.\n */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.lyrics.com\";\n\n/** lyrics.com renders 24 rows per search page. */\nexport const RESULTS_PER_PAGE = 24;\n\n/**\n * Song page paths come in two shapes: the current `/lyric-lf/{id}/...` and the\n * older `/lyric/{id}/...` still present in archived links.\n */\nexport const SONG_HREF_RE = /^\\/lyric(?:-lf)?\\/(\\d+)\\//;\n\nconst ALLOWED_HOSTS = new Set([\"lyrics.com\", \"www.lyrics.com\"]);\n\n/**\n * Percent-encode a search term the way lyrics.com expects, with spaces as `+`.\n */\nfunction encodeTerm(term: string): string {\n return encodeURIComponent(term).replace(/%20/g, \"+\");\n}\n\n/**\n * Page 1 lives at a path, later pages at a query string. Both return the same\n * markup, so the parser does not need to know which one produced it.\n */\nexport function buildSearchUrl(term: string, page: number): string {\n const encoded = encodeTerm(term);\n if (page <= 1) return `${BASE_URL}/lyrics/${encoded}`;\n return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;\n}\n\nexport function buildSongUrl(id: string): string {\n return `${BASE_URL}/lyric-lf/${id}/`;\n}\n\n/** True only for lyrics.com itself, so a hostile URL cannot be used as a proxy. */\nexport function isLyricsComHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric song id from a lyrics.com song path or absolute URL. */\nexport function extractSongId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isLyricsComHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n if (!path.startsWith(\"/\")) path = `/${path}`;\n const match = SONG_HREF_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/**\n * Resolve the `id` / `url` pair accepted by get_lyrics into a canonical\n * request URL. `id` wins when both are given.\n */\nexport function resolveSongRef(input: { id?: string; url?: string }): { id: string; url: string } {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a lyrics.com song id.`,\n \"Ids are digits only, as returned by the search tools.\",\n );\n }\n return { id: input.id, url: buildSongUrl(input.id) };\n }\n\n if (input.url) {\n if (!isLyricsComHost(input.url)) {\n throw invalidInput(\n \"Only www.lyrics.com URLs are accepted.\",\n \"Pass the source_url returned by search_lyrics or search_songs, or use the numeric id instead.\",\n );\n }\n const id = extractSongId(input.url);\n if (!id) {\n throw invalidInput(\n \"That lyrics.com URL is not a song page.\",\n \"Song pages look like https://www.lyrics.com/lyric-lf/1234567/Artist/Title\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\n/** Absolute URL for a href scraped out of a result row. */\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Every lyrics.com CSS selector lives here, so a site redesign is a one-file fix.\n *\n * Selectors deliberately target a single stable class rather than a full class\n * list (`div.sec-lyric`, not `div.sec-lyric.clearfix`), so that a layout tweak\n * adding or removing a utility class does not break extraction.\n */\n\nexport const SEL = {\n /** One search result row. */\n serpItem: \"div.sec-lyric\",\n /**\n * Page chrome that is present whether or not there are results. Used to tell\n * \"zero results\" apart from \"the markup changed\", which must not look alike.\n */\n serpShell: \"#lyric-search, .lyric-search, .pagination, #main-content, form[action*='serp']\",\n resultTitle: \"p.lyric-meta-title a\",\n /**\n * Rows tied to an album expose the artist under `lyric-meta-album-artist`,\n * rows without one under `lyric-meta-artists`. Both shapes appear on the same\n * page, so missing the second silently drops results.\n */\n resultArtist: \"p.lyric-meta-album-artist a, p.lyric-meta-artists a\",\n resultAlbum: \"p.lyric-meta-album\",\n resultYear: \"p.lyric-meta-album-year\",\n resultSnippet: \"pre.lyric-body\",\n\n /** Full lyrics on a song page, with the historical fallback. */\n songBody: \"pre#lyric-body-text, pre.lyric-body\",\n /** Marker for a valid song page that simply has no lyrics on file. */\n songNoData: \"div.lyric-no-data\",\n songTitle: \"#lyric-title-text, .lyric-title\",\n songArtist: \".lyric-artist a, .lyric-artist\",\n} as const;\n","/**\n * Parser for a lyrics.com song page.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SongPage } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSongOptions {\n id: string;\n url: string;\n}\n\nexport function parseSongPage(html: string, options: ParseSongOptions): SongPage {\n const $ = cheerio.load(html);\n\n const title = cleanInlineText($(SEL.songTitle).first().text()) || null;\n const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;\n\n const body = $(SEL.songBody).first();\n if (body.length === 0) {\n // A valid song page can legitimately carry no lyrics. That is an answer,\n // not a failure, so it is only treated as breakage when the page says\n // nothing about why the text is missing.\n const declaresNoLyrics =\n $(SEL.songNoData).length > 0 || /no lyrics found/i.test($(\"title\").text());\n if (!declaresNoLyrics) {\n throw parseFailure(options.url, \"no lyrics container and no 'no lyrics' marker\");\n }\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n const lyrics = cleanLyricsText(extractBodyText($, body));\n if (!lyrics) {\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n return { id: options.id, url: options.url, title, artist, lyrics, hasLyrics: true };\n}\n\n/**\n * lyrics.com wraps many individual words in links to its dictionary pages.\n * Those anchors are unwrapped in the raw HTML rather than through the DOM,\n * because DOM-level removal loses the line breaks that give lyrics their shape.\n */\nfunction extractBodyText($: cheerio.CheerioAPI, body: ReturnType<cheerio.CheerioAPI>): string {\n const rawHtml = $.html(body);\n const withoutAnchors = rawHtml.replace(/<a[^>]*>([\\s\\S]*?)<\\/a>/gi, \"$1\");\n const withNewlines = withoutAnchors.replace(/<br\\s*\\/?>/gi, \"\\n\");\n return cheerio.load(withNewlines).root().text();\n}\n","/**\n * High-level lyrics.com client.\n *\n * This module knows nothing about MCP, which keeps it unit-testable against\n * plain strings and usable as a plain library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type { SearchPage, SongPage } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseSearchResults } from \"./parsers/searchResults.js\";\nimport { parseSongPage } from \"./parsers/songPage.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveSongRef } from \"./urls.js\";\n\nexport interface LyricsComClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface FetchOutcome<T> {\n data: T;\n /** True when the page came from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `LyricsComClient` is published as a\n * library through the `./client` export and takes a caller-built config, so\n * without this the pacing floor and the honest identity are optional for anyone\n * importing it. lyrics.com answers a burst by refusing, and an honest User-Agent is what it accepts, and those promises hold on every path.\n *\n * A caller may still name their own application in the User-Agent. Passing the\n * traffic off as a browser is a different thing, and gets the project's own\n * identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class LyricsComClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<unknown>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: LyricsComClientOptions = {}) {\n this.config = withGuarantees(options.config ?? loadConfig());\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<unknown>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n async search(term: string, page: number): Promise<FetchOutcome<SearchPage>> {\n const url = buildSearchUrl(term, page);\n return this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));\n }\n\n async getSong(ref: { id?: string; url?: string }): Promise<FetchOutcome<SongPage>> {\n const { id, url } = resolveSongRef(ref);\n return this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at lyrics.com cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<FetchOutcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n\n const data = parse(html);\n this.cache.set(url, data);\n return { data, cached: false };\n }\n}\n","/**\n * get_lyrics: fetch the full lyrics of one song.\n */\n\nimport { z } from \"zod\";\nimport type { LyricsComClient } from \"../lyricscom/client.js\";\nimport { findMatchingLine } from \"../text/excerpt.js\";\nimport { ok, toToolError, truncate } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const getLyricsDescription = [\n \"Fetch the full lyrics of one song from lyrics.com, given the song id or URL returned by search_lyrics or search_songs.\",\n \"Lyrics can be long, so the response is truncated by default: check 'truncated' and call again with 'offset' set to\",\n \"'next_offset' to continue reading.\",\n 'Some lyrics.com pages legitimately have no lyrics on file; those come back with status \"no_lyrics\" and an empty',\n \"lyrics field, which is a valid answer and not an error, so do not retry them.\",\n \"Always cite 'source_url' and the artist when showing lyrics to a user.\",\n].join(\" \");\n\nexport const getLyricsInputShape = {\n id: z\n .string()\n .regex(/^\\d+$/, \"Song ids are digits only.\")\n .optional()\n .describe(\"lyrics.com numeric song id, as returned by the search tools. Preferred over 'url'.\"),\n url: z\n .string()\n .url()\n .optional()\n .describe(\n \"Full lyrics.com song URL. Only www.lyrics.com URLs are accepted. Ignored when 'id' is given.\",\n ),\n max_chars: z\n .number()\n .int()\n .min(200)\n .max(20000)\n .default(6000)\n .describe(\"Maximum characters of lyrics text to return in this call.\"),\n offset: z\n .number()\n .int()\n .min(0)\n .default(0)\n .describe(\"Character offset to resume from, for lyrics longer than max_chars.\"),\n highlight: z\n .string()\n .max(120)\n .optional()\n .describe(\n \"Optional word. When set, the response reports whether it appears in the lyrics and on which line.\",\n ),\n};\n\nexport const getLyricsOutputShape = {\n status: z.enum([\"ok\", \"no_lyrics\"]),\n id: z.string(),\n title: z.string().nullable(),\n artist: z.string().nullable(),\n source_url: z.string(),\n lyrics: z.string().describe(\"Slice of the lyrics text. Empty when status is 'no_lyrics'.\"),\n total_chars: z.number().int(),\n returned_chars: z.number().int(),\n offset: z.number().int(),\n next_offset: z.number().int().nullable(),\n truncated: z.boolean(),\n line_count: z.number().int(),\n highlight: z\n .object({\n word: z.string(),\n found: z.boolean(),\n line_number: z.number().int().nullable(),\n line: z.string().nullable(),\n })\n .nullable(),\n attribution: z.string().describe(\"Ready-to-display credit line.\"),\n source: z.literal(\"lyrics.com\"),\n};\n\nexport interface GetLyricsArgs {\n id?: string;\n url?: string;\n max_chars: number;\n offset: number;\n highlight?: string;\n}\n\n/**\n * Cut at the last newline that fits, so a continuation never splits a line in\n * half. Falls back to a hard cut for a single line longer than the budget.\n */\nexport function sliceAtLineBoundary(\n text: string,\n offset: number,\n maxChars: number,\n): { slice: string; nextOffset: number | null } {\n if (offset >= text.length) return { slice: \"\", nextOffset: null };\n\n const remaining = text.slice(offset);\n if (remaining.length <= maxChars) return { slice: remaining, nextOffset: null };\n\n const window = remaining.slice(0, maxChars);\n const lastBreak = window.lastIndexOf(\"\\n\");\n const cut = lastBreak > 0 ? lastBreak : maxChars;\n return { slice: remaining.slice(0, cut), nextOffset: offset + cut };\n}\n\nexport async function runGetLyrics(\n client: LyricsComClient,\n args: GetLyricsArgs,\n): Promise<ToolResult> {\n try {\n const ref: { id?: string; url?: string } = {};\n if (args.id) ref.id = args.id;\n else if (args.url) ref.url = args.url;\n\n const { data } = await client.getSong(ref);\n const credit = [data.title, data.artist].filter(Boolean).join(\" — \");\n const attribution = `${credit || \"Lyrics\"} via lyrics.com — ${data.url}`;\n\n if (!data.hasLyrics) {\n const structured = {\n status: \"no_lyrics\" as const,\n id: data.id,\n title: data.title,\n artist: data.artist,\n source_url: data.url,\n lyrics: \"\",\n total_chars: 0,\n returned_chars: 0,\n offset: 0,\n next_offset: null,\n truncated: false,\n line_count: 0,\n highlight: null,\n attribution,\n source: \"lyrics.com\" as const,\n };\n return ok(\n structured,\n `lyrics.com has a page for this song but no lyrics on file.\\n${attribution}`,\n );\n }\n\n const { slice, nextOffset } = sliceAtLineBoundary(data.lyrics, args.offset, args.max_chars);\n const match = args.highlight ? findMatchingLine(data.lyrics, args.highlight) : null;\n\n const structured = {\n status: \"ok\" as const,\n id: data.id,\n title: data.title,\n artist: data.artist,\n source_url: data.url,\n lyrics: slice,\n total_chars: data.lyrics.length,\n returned_chars: slice.length,\n offset: args.offset,\n next_offset: nextOffset,\n truncated: nextOffset !== null,\n line_count: data.lyrics === \"\" ? 0 : data.lyrics.split(\"\\n\").length,\n highlight: args.highlight\n ? {\n word: args.highlight,\n found: match !== null,\n line_number: match?.lineNumber ?? null,\n line: match?.line ?? null,\n }\n : null,\n attribution,\n source: \"lyrics.com\" as const,\n };\n\n // The text mirror stays a pointer to the structured payload rather than a\n // second copy of the lyrics.\n const summary = [\n attribution,\n `${structured.line_count} lines, ${structured.total_chars} characters.`,\n nextOffset !== null\n ? `Truncated: call again with offset=${nextOffset} to continue.`\n : \"Complete.\",\n structured.highlight\n ? structured.highlight.found\n ? `\"${structured.highlight.word}\" appears on line ${structured.highlight.line_number}.`\n : `\"${structured.highlight.word}\" does not appear in these lyrics.`\n : \"\",\n ]\n .filter(Boolean)\n .join(\"\\n\");\n\n return ok(structured, truncate(`${summary}\\n\\n${slice}`, 4000));\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * Two-pass keyword matcher.\n *\n * lyrics.com's own search is fuzzy: it returns title-only matches and loose\n * matches alongside genuine lyric-body hits. This matcher is what lets the\n * server promise that a returned song really contains the query.\n *\n * Pass 1 requires the keyword to be delimited on both sides, so \"coup\" does not\n * match \"beaucoup\". Pass 2 relaxes the right-hand side to a word start, so\n * \"enfant\" still matches \"enfants\". Pass 2 only runs when pass 1 finds nothing,\n * which keeps exact hits ranked ahead of prefix hits.\n */\n\n/**\n * Characters treated as word boundaries. Includes the typographic apostrophe,\n * which is common in French lyrics (\"l’enfant\").\n */\nconst WORD_DELIMITERS = \"[\\\\s,;:'\\\".!?()\\\\[\\\\]&’`@-]\";\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Index of `keyword` inside `text`, or -1 when absent.\n * The returned index points at the keyword itself, not at the leading delimiter.\n */\nexport function getKeywordIndex(keyword: string, text: string): number {\n if (!keyword || !text) return -1;\n\n const escaped = escapeRegExp(keyword.toLowerCase());\n const haystack = text.toLowerCase();\n\n const passes = [\n new RegExp(`(^|${WORD_DELIMITERS})${escaped}(${WORD_DELIMITERS}|$)`),\n new RegExp(`(^|${WORD_DELIMITERS})${escaped}`),\n ];\n\n for (const pattern of passes) {\n const match = pattern.exec(haystack);\n if (match) {\n const leadingDelimiter = match[1] ?? \"\";\n return match.index + leadingDelimiter.length;\n }\n }\n\n return -1;\n}\n\nexport function containsWord(keyword: string, text: string): boolean {\n return getKeywordIndex(keyword, text) !== -1;\n}\n","/**\n * Excerpt extraction.\n *\n * Search results carry one short line showing where the query appears, never a\n * lyrics block. Excerpts stay small on purpose: they are enough for a model to\n * pick the right song, and search results are not the place to reproduce a\n * song's text.\n */\n\nimport { getKeywordIndex } from \"./keywordIndex.js\";\n\nexport interface ExcerptOptions {\n /** Hard cap on the returned excerpt. */\n maxChars?: number;\n}\n\nexport interface LineMatch {\n line: string;\n /** 1-based line number within the text. */\n lineNumber: number;\n}\n\n/** First line containing `keyword`, using the two-pass matcher. */\nexport function findMatchingLine(text: string, keyword: string): LineMatch | null {\n if (!text || !keyword) return null;\n const lines = text.split(\"\\n\");\n for (let i = 0; i < lines.length; i += 1) {\n const line = lines[i] ?? \"\";\n if (line.trim() === \"\") continue;\n if (getKeywordIndex(keyword, line) !== -1) {\n return { line: line.trim(), lineNumber: i + 1 };\n }\n }\n return null;\n}\n\n/**\n * Excerpt of the line where `keyword` appears, trimmed to a window centred on\n * the match. Falls back to the first non-empty line when the keyword is absent.\n */\nexport function matchedLineExcerpt(\n text: string,\n keyword: string,\n options: ExcerptOptions = {},\n): string | null {\n const maxChars = options.maxChars ?? 160;\n if (!text) return null;\n\n const match = findMatchingLine(text, keyword);\n const line =\n match?.line ??\n text\n .split(\"\\n\")\n .find((candidate) => candidate.trim() !== \"\")\n ?.trim();\n if (!line) return null;\n if (line.length <= maxChars) return line;\n\n const keywordAt = match ? Math.max(getKeywordIndex(keyword, line), 0) : 0;\n const start = Math.max(0, keywordAt - Math.floor(maxChars / 2));\n const end = Math.min(line.length, start + maxChars);\n const slice = line.slice(start, end).trim();\n return `${start > 0 ? \"…\" : \"\"}${slice}${end < line.length ? \"…\" : \"\"}`;\n}\n","/**\n * Pieces shared by the three tools: result schema, error mapping, text mirrors.\n */\n\nimport { z } from \"zod\";\nimport { LyricsComError } from \"../errors.js\";\nimport type { SongResult } from \"../types.js\";\n\n/** Many MCP clients render only the text block, so it must stay readable on its own. */\nexport const MAX_TEXT_MIRROR_CHARS = 1500;\n\nexport const songResultSchema = z.object({\n id: z.string().describe(\"lyrics.com song id. Pass this to get_lyrics.\"),\n title: z.string(),\n artist: z.string(),\n album: z.string().nullable(),\n year: z.number().int().nullable(),\n source_url: z.string().describe(\"Canonical lyrics.com page for this song.\"),\n excerpt: z\n .string()\n .nullable()\n .describe(\"Short lyric line showing where the query appears, when available.\"),\n});\n\nexport type SongResultOut = z.infer<typeof songResultSchema>;\n\nexport function toSongResultOut(song: SongResult, excerpt: string | null): SongResultOut {\n return {\n id: song.id,\n title: song.title,\n artist: song.artist,\n album: song.album,\n year: song.year,\n source_url: song.url,\n excerpt,\n };\n}\n\nexport interface ToolResult {\n // The SDK's CallToolResult carries an index signature for protocol extensions.\n [key: string]: unknown;\n content: Array<{ type: \"text\"; text: string }>;\n structuredContent?: Record<string, unknown>;\n isError?: boolean;\n}\n\nexport function ok(structured: Record<string, unknown>, text: string): ToolResult {\n return {\n content: [{ type: \"text\", text: truncate(text, MAX_TEXT_MIRROR_CHARS) }],\n structuredContent: structured,\n };\n}\n\n/**\n * Error results carry no structuredContent: the SDK validates it against the\n * tool's declared output schema, which an error payload does not satisfy.\n */\nexport function toToolError(error: unknown): ToolResult {\n const known =\n error instanceof LyricsComError\n ? error\n : new LyricsComError(\"network_error\", error instanceof Error ? error.message : String(error));\n\n const lines = [`[${known.code}] ${known.message}`];\n if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);\n if (known.details.url) lines.push(`URL: ${known.details.url}`);\n\n return { content: [{ type: \"text\", text: lines.join(\"\\n\") }], isError: true };\n}\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return `${text.slice(0, maxChars - 1).trimEnd()}…`;\n}\n\n/** Compact listing used by both search tools. */\nexport function renderResultList(results: SongResultOut[]): string {\n return results\n .map((result, index) => {\n const year = result.year ? ` (${result.year})` : \"\";\n const album = result.album ? ` [${result.album}]` : \"\";\n const head = `${index + 1}. ${result.title} — ${result.artist}${year}${album} · id: ${result.id}`;\n return result.excerpt ? `${head}\\n \"${result.excerpt}\"` : head;\n })\n .join(\"\\n\");\n}\n","/**\n * search_lyrics: find songs by a word or phrase occurring in the lyrics.\n */\n\nimport { z } from \"zod\";\nimport type { LyricsComClient } from \"../lyricscom/client.js\";\nimport { dedupeSongs } from \"../text/dedupe.js\";\nimport { matchedLineExcerpt } from \"../text/excerpt.js\";\nimport { containsWord } from \"../text/keywordIndex.js\";\nimport type { SongResult } from \"../types.js\";\nimport { ok, renderResultList, songResultSchema, toSongResultOut, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\n/** Verifying against full lyrics costs one page fetch per candidate. */\nconst MAX_FULL_VERIFICATIONS = 5;\n\nexport const searchLyricsDescription = [\n \"Search lyrics.com for songs whose lyrics contain a given word or phrase.\",\n \"Returns matching songs with artist, title, a short excerpt of the line where the word appears,\",\n \"and the lyrics.com id and URL needed to fetch the full text with get_lyrics.\",\n \"Use this when someone remembers a fragment of lyrics but not the song, or wants songs mentioning a word or theme.\",\n \"One call returns one page of lyrics.com results; to see more, increase 'page' rather than 'limit'.\",\n \"lyrics.com also returns loose and title-only matches, so results are filtered locally to keep only songs\",\n \"where the word genuinely appears in the lyrics. Set 'verify' to \\\"none\\\" to see the raw, unfiltered list.\",\n].join(\" \");\n\nexport const searchLyricsInputShape = {\n query: z\n .string()\n .min(1)\n .max(120)\n .describe(\"Word or short phrase to look for inside the lyrics, for example 'autrefois'.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(50)\n .default(10)\n .describe(\"Maximum songs to return from this page.\"),\n page: z\n .number()\n .int()\n .min(1)\n .max(20)\n .default(1)\n .describe(\"Result page on lyrics.com. Each page holds up to 24 raw results.\"),\n verify: z\n .enum([\"snippet\", \"full\", \"none\"])\n .default(\"snippet\")\n .describe(\n \"How to confirm the query really appears in the lyrics. 'snippet' checks the excerpt lyrics.com already \" +\n \"returned and costs nothing. 'full' fetches up to 5 song pages and checks the complete lyrics, which is \" +\n \"slow and can trigger rate limiting. 'none' disables filtering.\",\n ),\n include_excerpt: z\n .boolean()\n .default(true)\n .describe(\"Include the matching lyric line excerpt in each result.\"),\n};\n\nexport const searchLyricsOutputShape = {\n query: z.string(),\n page: z.number().int(),\n results: z.array(songResultSchema),\n raw_result_count: z\n .number()\n .int()\n .describe(\"Rows lyrics.com returned before local filtering and deduplication.\"),\n filtered_out: z\n .number()\n .int()\n .describe(\"Rows dropped because the query was not found in their lyrics.\"),\n has_more: z.boolean(),\n next_page: z.number().int().nullable(),\n source: z.literal(\"lyrics.com\"),\n notes: z\n .array(z.string())\n .describe(\"Caveats worth knowing, such as a cache hit or partial verification.\"),\n};\n\nexport interface SearchLyricsArgs {\n query: string;\n limit: number;\n page: number;\n verify: \"snippet\" | \"full\" | \"none\";\n include_excerpt: boolean;\n}\n\nexport async function runSearchLyrics(\n client: LyricsComClient,\n args: SearchLyricsArgs,\n): Promise<ToolResult> {\n try {\n const { data, cached } = await client.search(args.query, args.page);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n\n const unique = dedupeSongs(data.results);\n const kept: Array<{ song: SongResult; excerpt: string | null }> = [];\n let filteredOut = 0;\n let fullChecks = 0;\n\n for (const song of unique) {\n if (kept.length >= args.limit) break;\n\n let text = song.snippet ?? \"\";\n\n if (args.verify === \"full\" && fullChecks < MAX_FULL_VERIFICATIONS) {\n fullChecks += 1;\n try {\n const page = await client.getSong({ id: song.id });\n if (page.data.hasLyrics) text = page.data.lyrics;\n } catch (error) {\n // A single unreadable song page must not sink the whole search.\n notes.push(`Could not verify \"${song.title}\" against its full lyrics.`);\n void error;\n }\n }\n\n const matches = args.verify === \"none\" || (text !== \"\" && containsWord(args.query, text));\n if (!matches) {\n filteredOut += 1;\n continue;\n }\n\n const excerpt =\n args.include_excerpt && text\n ? matchedLineExcerpt(text, args.query, { maxChars: 160 })\n : null;\n kept.push({ song, excerpt });\n }\n\n if (args.verify === \"full\" && unique.length > MAX_FULL_VERIFICATIONS) {\n notes.push(\n `Full verification is capped at ${MAX_FULL_VERIFICATIONS} songs per call to avoid rate limiting; ` +\n \"the remaining results were checked against their search excerpt.\",\n );\n }\n if (kept.length === 0 && data.rawCount > 0) {\n notes.push(\n \"lyrics.com returned results but none of them actually contain the query in their lyrics. \" +\n 'Try verify=\"none\" to see the raw matches.',\n );\n }\n\n const results = kept.map((entry) => toSongResultOut(entry.song, entry.excerpt));\n const structured = {\n query: args.query,\n page: args.page,\n results,\n raw_result_count: data.rawCount,\n filtered_out: filteredOut,\n has_more: data.hasMore,\n next_page: data.hasMore ? args.page + 1 : null,\n source: \"lyrics.com\" as const,\n notes,\n };\n\n const header =\n results.length > 0\n ? `${results.length} song(s) on page ${args.page} for \"${args.query}\":`\n : `No song on page ${args.page} matched \"${args.query}\".`;\n const footer = data.hasMore\n ? `\\n\\nMore results available: call again with page=${args.page + 1}.`\n : \"\";\n\n return ok(structured, `${header}\\n${renderResultList(results)}${footer}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * Deduplication of search results.\n *\n * lyrics.com lists the same recording once per album it appears on, so a single\n * query routinely returns \"Title\", \"Title [Deluxe]\" and \"Title (Remastered)\" by\n * the same artist. Bracketed and parenthesised suffixes are stripped before\n * comparison so those collapse into one result.\n */\n\nimport type { SongResult } from \"../types.js\";\n\nexport function songKey(artist: string, title: string): string {\n const withoutSuffixes = title.replace(/\\s*[[(].*?[\\])]/g, \"\");\n const titleNorm = withoutSuffixes.replace(/\\s+/g, \" \").trim().toLowerCase();\n const artistNorm = artist.replace(/\\s+/g, \" \").trim().toLowerCase();\n return `${artistNorm}|${titleNorm}`;\n}\n\n/** Keep the first occurrence of each artist/title pair, preserving order. */\nexport function dedupeSongs<T extends SongResult>(songs: T[]): T[] {\n const seen = new Set<string>();\n const unique: T[] = [];\n for (const song of songs) {\n const key = songKey(song.artist, song.title);\n if (seen.has(key)) continue;\n seen.add(key);\n unique.push(song);\n }\n return unique;\n}\n","/**\n * search_songs: find songs by title.\n *\n * lyrics.com has no title-only endpoint, so this runs the same search and ranks\n * and filters the rows by how well their title matches. The tool description\n * says so plainly rather than implying a precision the site does not offer.\n */\n\nimport { z } from \"zod\";\nimport type { LyricsComClient } from \"../lyricscom/client.js\";\nimport { dedupeSongs } from \"../text/dedupe.js\";\nimport type { SongResult } from \"../types.js\";\nimport { ok, renderResultList, songResultSchema, toSongResultOut, toToolError } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const searchSongsDescription = [\n \"Find songs on lyrics.com by their title.\",\n \"Returns candidate songs with artist, album, year, and the lyrics.com id and URL.\",\n \"Use this when someone names a song and you need its id to call get_lyrics, or to tell covers and versions\",\n \"by different artists apart. An optional 'artist' narrows the list.\",\n \"lyrics.com searches titles and lyrics together, so results are ranked and filtered locally by title match;\",\n 'use match=\"strict\" to keep only genuine title matches.',\n].join(\" \");\n\nexport const searchSongsInputShape = {\n title: z.string().min(1).max(120).describe(\"Song title, or part of it.\"),\n artist: z\n .string()\n .max(120)\n .optional()\n .describe(\"Optional artist name, matched case-insensitively against each result.\"),\n limit: z.number().int().min(1).max(50).default(10),\n page: z.number().int().min(1).max(20).default(1),\n match: z\n .enum([\"loose\", \"strict\"])\n .default(\"loose\")\n .describe(\n \"'strict' keeps only results whose title matches the query as a whole title, ignoring suffixes such as \" +\n \"'(Remastered)'. 'loose' keeps anything lyrics.com returned, best match first.\",\n ),\n};\n\nexport const searchSongsOutputShape = {\n title: z.string(),\n artist_filter: z.string().nullable(),\n page: z.number().int(),\n results: z.array(songResultSchema),\n raw_result_count: z.number().int(),\n filtered_out: z.number().int(),\n has_more: z.boolean(),\n next_page: z.number().int().nullable(),\n source: z.literal(\"lyrics.com\"),\n notes: z.array(z.string()),\n};\n\nexport interface SearchSongsArgs {\n title: string;\n artist?: string;\n limit: number;\n page: number;\n match: \"loose\" | \"strict\";\n}\n\n/** Normalized form used for title comparison: no edition suffix, no punctuation noise. */\nfunction normalizeTitle(title: string): string {\n return title\n .replace(/\\s*[[(].*?[\\])]/g, \"\")\n .replace(/\\s+/g, \" \")\n .trim()\n .toLowerCase();\n}\n\n/** 3 = same title, 2 = starts with the query, 1 = contains it, 0 = no title match. */\nexport function titleScore(candidate: string, query: string): number {\n const a = normalizeTitle(candidate);\n const b = normalizeTitle(query);\n if (!a || !b) return 0;\n if (a === b) return 3;\n if (a.startsWith(b)) return 2;\n if (a.includes(b)) return 1;\n return 0;\n}\n\nexport async function runSearchSongs(\n client: LyricsComClient,\n args: SearchSongsArgs,\n): Promise<ToolResult> {\n try {\n const { data, cached } = await client.search(args.title, args.page);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n\n const artistFilter = args.artist?.trim().toLowerCase() ?? null;\n const unique = dedupeSongs(data.results);\n\n const scored: Array<{ song: SongResult; score: number }> = [];\n let filteredOut = 0;\n\n for (const song of unique) {\n const score = titleScore(song.title, args.title);\n const artistOk = !artistFilter || song.artist.toLowerCase().includes(artistFilter);\n if (!artistOk || (args.match === \"strict\" && score < 2)) {\n filteredOut += 1;\n continue;\n }\n scored.push({ song, score });\n }\n\n // Stable sort by score, so equally scored rows keep lyrics.com's own order.\n scored.sort((a, b) => b.score - a.score);\n const results = scored.slice(0, args.limit).map((entry) => toSongResultOut(entry.song, null));\n\n if (results.length === 0 && data.rawCount > 0) {\n notes.push(\n args.match === \"strict\"\n ? 'lyrics.com returned results but none matched the title strictly. Try match=\"loose\".'\n : \"lyrics.com returned results but none matched the requested artist.\",\n );\n }\n\n const structured = {\n title: args.title,\n artist_filter: args.artist ?? null,\n page: args.page,\n results,\n raw_result_count: data.rawCount,\n filtered_out: filteredOut,\n has_more: data.hasMore,\n next_page: data.hasMore ? args.page + 1 : null,\n source: \"lyrics.com\" as const,\n notes,\n };\n\n const header =\n results.length > 0\n ? `${results.length} song(s) on page ${args.page} matching \"${args.title}\":`\n : `No song on page ${args.page} matched \"${args.title}\".`;\n const footer = data.hasMore\n ? `\\n\\nMore results available: call again with page=${args.page + 1}.`\n : \"\";\n\n return ok(structured, `${header}\\n${renderResultList(results)}${footer}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n"],"mappings":"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","names":["z","cheerio","z","z","structured","z","z","z","z","z"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/version.ts","../src/config.ts","../src/server.ts","../src/lyricscom/cache.ts","../src/errors.ts","../src/lyricscom/parsers/detectThrottle.ts","../src/lyricscom/rateLimiter.ts","../src/lyricscom/http.ts","../src/lyricscom/parsers/searchResults.ts","../src/text/normalize.ts","../src/lyricscom/urls.ts","../src/lyricscom/parsers/selectors.ts","../src/lyricscom/parsers/songPage.ts","../src/lyricscom/client.ts","../src/tools/getLyrics.ts","../src/text/keywordIndex.ts","../src/text/excerpt.ts","../src/tools/shared.ts","../src/tools/searchLyrics.ts","../src/text/dedupe.ts","../src/tools/searchSongs.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Entry point: an MCP server for lyrics.com over stdio.\n */\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { createServer } from \"./server.js\";\n\nasync function main(): Promise<void> {\n const config = loadConfig();\n const logger = createLogger(config.logLevel);\n const server = createServer({ config, logger });\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n const shutdown = () => {\n void server.close().finally(() => process.exit(0));\n };\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n}\n\nmain().catch((error: unknown) => {\n process.stderr.write(\n `[mcp-lyricscom] fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exit(1);\n});\n","/**\n * Kept in sync with package.json. The User-Agent embeds it, so it must not\n * drift; the release checklist bumps both together.\n */\nexport const PKG_VERSION = \"1.3.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-lyricscom\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * Identifies the client honestly. lyrics.com serves this fine; it blocks\n * generic tool agents such as `curl/x.y.z`, which is what the override is for.\n */\nexport const DEFAULT_USER_AGENT = `mcp-lyricscom/${PKG_VERSION} (+${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1100,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * The pacing is what keeps this client from behaving like a crawler against a\n * site that has no API and no published rate limit. Leaving it configurable down\n * to zero would make the politeness of every installation depend on whoever\n * edited a JSON file, so a value below this floor is refused and the default is\n * used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n const clamped = Math.min(range.max, Math.max(range.min, Math.round(parsed)));\n if (clamped !== Math.round(parsed)) {\n warn(`${name}=${raw} is out of range, clamped to ${clamped}`);\n }\n return clamped;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 500, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.LYRICSCOM_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`LYRICSCOM_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `LYRICSCOM_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. This floor keeps the client from hammering lyrics.com.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.LYRICSCOM_USER_AGENT?.trim();\n const rawLogLevel = env.LYRICSCOM_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`LYRICSCOM_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"LYRICSCOM_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"LYRICSCOM_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"LYRICSCOM_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"LYRICSCOM_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * MCP server wiring.\n *\n * One client, one rate limiter and one cache are shared by all three tools:\n * per-tool instances would let three tools each open their own request stream\n * and defeat the throttling protection.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport type { Config, Logger } from \"./config.js\";\nimport { createLogger, loadConfig } from \"./config.js\";\nimport { LyricsComClient } from \"./lyricscom/client.js\";\nimport {\n getLyricsDescription,\n getLyricsInputShape,\n getLyricsOutputShape,\n runGetLyrics,\n} from \"./tools/getLyrics.js\";\nimport type { GetLyricsArgs } from \"./tools/getLyrics.js\";\nimport {\n runSearchLyrics,\n searchLyricsDescription,\n searchLyricsInputShape,\n searchLyricsOutputShape,\n} from \"./tools/searchLyrics.js\";\nimport type { SearchLyricsArgs } from \"./tools/searchLyrics.js\";\nimport {\n runSearchSongs,\n searchSongsDescription,\n searchSongsInputShape,\n searchSongsOutputShape,\n} from \"./tools/searchSongs.js\";\nimport type { SearchSongsArgs } from \"./tools/searchSongs.js\";\nimport { PKG_VERSION } from \"./version.js\";\n\nexport interface CreateServerOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nconst READ_ONLY = {\n readOnlyHint: true,\n destructiveHint: false,\n idempotentHint: true,\n openWorldHint: true,\n} as const;\n\nexport function createServer(options: CreateServerOptions = {}): McpServer {\n const config = options.config ?? loadConfig();\n const logger = options.logger ?? createLogger(config.logLevel);\n const client = new LyricsComClient({\n config,\n logger,\n ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),\n });\n\n const server = new McpServer(\n { name: \"mcp-lyricscom\", version: PKG_VERSION },\n {\n instructions:\n \"Tools for looking up song lyrics on lyrics.com. No API key is needed. \" +\n \"Typical flow: search_lyrics (by a word in the lyrics) or search_songs (by title) to get a song id, \" +\n \"then get_lyrics with that id. When you show lyrics to a user, credit the artist and link the source URL. \" +\n \"A 'throttled' error means lyrics.com is rate limiting this client, not that the song does not exist.\",\n },\n );\n\n server.registerTool(\n \"search_lyrics\",\n {\n title: \"Search lyrics by word\",\n description: searchLyricsDescription,\n inputSchema: z.object(searchLyricsInputShape),\n outputSchema: z.object(searchLyricsOutputShape),\n annotations: READ_ONLY,\n },\n async (args) => runSearchLyrics(client, args as SearchLyricsArgs),\n );\n\n server.registerTool(\n \"search_songs\",\n {\n title: \"Search songs by title\",\n description: searchSongsDescription,\n inputSchema: z.object(searchSongsInputShape),\n outputSchema: z.object(searchSongsOutputShape),\n annotations: READ_ONLY,\n },\n async (args) => runSearchSongs(client, args as SearchSongsArgs),\n );\n\n server.registerTool(\n \"get_lyrics\",\n {\n title: \"Get full lyrics\",\n description: getLyricsDescription,\n inputSchema: z.object(getLyricsInputShape),\n outputSchema: z.object(getLyricsOutputShape),\n annotations: READ_ONLY,\n },\n async (args) => runGetLyrics(client, args as GetLyricsArgs),\n );\n\n logger.info(\n `ready: user-agent=\"${config.userAgent}\", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms`,\n );\n\n return server;\n}\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The point of this file is that an LLM must be able to tell \"lyrics.com pushed\n * back on me\" apart from \"there is no such song\". The reference implementation\n * this server replaces collapsed every failure into an empty list, which made\n * rate limiting look like an absence of results.\n */\n\nexport type ErrorCode =\n | \"throttled\"\n | \"blocked_user_agent\"\n | \"not_found\"\n | \"invalid_input\"\n | \"parse_failure\"\n | \"network_error\"\n | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class LyricsComError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"LyricsComError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-lyricscom/issues\";\n\nexport function throttled(url: string, retryAfterMs: number): LyricsComError {\n return new LyricsComError(\n \"throttled\",\n \"lyrics.com is rate limiting this client. It answered with an empty response instead of a page. \" +\n \"This does NOT mean there are no results for this query.\",\n {\n url,\n retryAfterMs,\n hint:\n \"Wait at least a minute before calling the same tool again with the same arguments. Once lyrics.com \" +\n \"starts throttling, the window commonly lasts several minutes, so retrying immediately will fail again. \" +\n \"If it keeps happening, raise LYRICSCOM_MIN_INTERVAL_MS in your MCP client configuration.\",\n },\n );\n}\n\nexport function blockedUserAgent(url: string): LyricsComError {\n return new LyricsComError(\n \"blocked_user_agent\",\n \"lyrics.com refused the request because of the User-Agent this server sends (HTTP 403).\",\n {\n url,\n status: 403,\n hint:\n \"Set the LYRICSCOM_USER_AGENT environment variable in your MCP client configuration to a different \" +\n \"value. See the Configuration section of the project README.\",\n },\n );\n}\n\nexport function notFound(url: string): LyricsComError {\n return new LyricsComError(\"not_found\", \"lyrics.com has no page at this address.\", {\n url,\n status: 404,\n hint: \"Check the song id. Ids come from the search tools; they are not stable to guess by hand.\",\n });\n}\n\nexport function parseFailure(url: string, what: string): LyricsComError {\n return new LyricsComError(\n \"parse_failure\",\n `The page loaded but the expected lyrics.com markup was not found (${what}). The site layout may have changed.`,\n { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },\n );\n}\n\nexport function invalidInput(message: string, hint?: string): LyricsComError {\n return new LyricsComError(\"invalid_input\", message, hint ? { hint } : {});\n}\n","/**\n * Response classification.\n *\n * lyrics.com does not answer rate limiting with 429. It answers with HTTP 202\n * and a zero-byte body, which a naive client reads as a successful but empty\n * page. Every response therefore goes through this classifier before any\n * parsing happens.\n */\n\nexport type ResponseVerdict =\n | { kind: \"ok\"; html: string }\n | { kind: \"throttled\"; reason: ThrottleReason }\n | { kind: \"blocked\"; status: number }\n | { kind: \"not-found\" }\n | { kind: \"server-error\"; status: number };\n\nexport type ThrottleReason =\n \"status-202\" | \"status-429\" | \"status-503\" | \"empty-body\" | \"short-body\";\n\n/**\n * A real search page weighs about 187 KB and a song page 60 KB or more, so a\n * body this small is never a genuine lyrics.com page.\n */\nexport const MIN_PLAUSIBLE_HTML = 2000;\n\nexport function classifyResponse(status: number, body: string): ResponseVerdict {\n if (status === 202) return { kind: \"throttled\", reason: \"status-202\" };\n if (status === 429) return { kind: \"throttled\", reason: \"status-429\" };\n if (status === 503) return { kind: \"throttled\", reason: \"status-503\" };\n if (status === 403) return { kind: \"blocked\", status };\n if (status === 404) return { kind: \"not-found\" };\n if (status >= 500) return { kind: \"server-error\", status };\n\n const trimmed = body.trim();\n if (trimmed.length === 0) return { kind: \"throttled\", reason: \"empty-body\" };\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n return { kind: \"throttled\", reason: \"short-body\" };\n }\n\n return { kind: \"ok\", html: body };\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // virtual machine, would otherwise make this wait for the size of the step,\n // and the queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, classified, with backoff.\n *\n * The retry policy is domain specific rather than generic, because the failure\n * this server exists to handle does not look like a failure at the transport\n * level: lyrics.com answers a throttled request with HTTP 202 and an empty\n * body. Only `classifyResponse` can tell that apart from a real page.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { blockedUserAgent, LyricsComError, notFound, throttled } from \"../errors.js\";\nimport { classifyResponse } from \"./parsers/detectThrottle.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;\n const capped = Math.min(BACKOFF_MAX_MS, uncapped);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The whole retry loop, backoff sleeps included, runs inside a single limiter\n * slot. Waiting outside it would let a queued request slip into the same\n * throttling window that the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: LyricsComError | undefined;\n\n // Set when the site says how long to stay away; it replaces our own guess\n // for the next attempt. Applied here rather than where it is read, so no\n // wait is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.9,fr;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n const verdict = classifyResponse(status, body);\n switch (verdict.kind) {\n case \"ok\":\n limiter.relax();\n return verdict.html;\n\n case \"throttled\":\n limiter.penalize();\n logger.info(\n `throttled (${verdict.reason}) on ${url}, interval now ${limiter.currentIntervalMs}ms`,\n );\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = throttled(url, retryAfterMs ?? backoffDelay(attempt));\n continue;\n\n case \"server-error\":\n lastError = new LyricsComError(\n \"network_error\",\n `lyrics.com returned HTTP ${verdict.status}.`,\n {\n url,\n status: verdict.status,\n },\n );\n continue;\n\n // Retrying these would only repeat the same answer.\n case \"blocked\":\n throw blockedUserAgent(url);\n case \"not-found\":\n throw notFound(url);\n }\n }\n\n throw lastError ?? new LyricsComError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): LyricsComError {\n if (error instanceof LyricsComError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new LyricsComError(\"timeout\", \"lyrics.com did not answer in time.\", {\n url,\n hint: \"Raise LYRICSCOM_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new LyricsComError(\"network_error\", `Could not reach lyrics.com: ${message}`, { url });\n}\n","/**\n * Parser for a lyrics.com search results page.\n *\n * Page 1 (`/lyrics/{term}`) and later pages (`/serp.php?st=...&p=N`) share the\n * same markup, so one parser covers both.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SearchPage, SongResult } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { extractSongId, RESULTS_PER_PAGE, toAbsoluteUrl } from \"../urls.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSearchOptions {\n page: number;\n /** Reported in errors so a failure points at the request that caused it. */\n url: string;\n}\n\nexport function parseSearchResults(html: string, options: ParseSearchOptions): SearchPage {\n const $ = cheerio.load(html);\n const rows = $(SEL.serpItem);\n\n if (rows.length === 0) {\n // An empty result set still renders the page chrome. Its absence means the\n // response was not the page we asked for, which must not be reported as\n // \"no songs found\".\n if ($(SEL.serpShell).length === 0) {\n throw parseFailure(options.url, \"no result rows and no recognizable search page structure\");\n }\n return { results: [], page: options.page, rawCount: 0, hasMore: false };\n }\n\n const results: SongResult[] = [];\n let skipped = 0;\n\n rows.each((_, element) => {\n const row = $(element);\n const titleLink = row.find(SEL.resultTitle).first();\n const href = titleLink.attr(\"href\");\n const title = cleanInlineText(titleLink.text());\n const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());\n\n const id = href ? extractSongId(href) : null;\n if (!id || !title || !artist) {\n skipped += 1;\n return;\n }\n\n const album = cleanInlineText(row.find(SEL.resultAlbum).first().text()) || null;\n const year = parseYear(row.find(SEL.resultYear).first().text());\n const snippetRaw = row.find(SEL.resultSnippet).first().text();\n const snippet = snippetRaw ? cleanLyricsText(snippetRaw) || null : null;\n\n results.push({\n id,\n url: toAbsoluteUrl(href as string),\n title,\n artist,\n album,\n year,\n snippet,\n });\n });\n\n if (results.length === 0) {\n throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(\n `[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}\\n`,\n );\n }\n\n return {\n results,\n page: options.page,\n rawCount: rows.length,\n hasMore: rows.length >= RESULTS_PER_PAGE,\n };\n}\n\n/** Year cells look like \"1971\" but are sometimes decorated or empty. */\nfunction parseYear(raw: string): number | null {\n const match = /\\b(1[89]\\d{2}|20\\d{2})\\b/.exec(raw);\n if (!match?.[1]) return null;\n return Number.parseInt(match[1], 10);\n}\n","/**\n * Text cleaning for scraped content: HTML entities, mojibake, whitespace.\n */\n\nconst HTML_ENTITIES: Record<string, string> = {\n amp: \"&\",\n lt: \"<\",\n gt: \">\",\n quot: '\"',\n apos: \"'\",\n nbsp: \" \",\n hellip: \"…\",\n mdash: \"—\",\n ndash: \"–\",\n rsquo: \"’\",\n lsquo: \"‘\",\n ldquo: \"“\",\n rdquo: \"”\",\n};\n\n/** Decode named and numeric HTML entities. */\nexport function unescapeHtml(text: string): string {\n if (!text) return text;\n return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity: string) => {\n if (entity.startsWith(\"#\")) {\n const isHex = entity[1] === \"x\" || entity[1] === \"X\";\n const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);\n if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;\n try {\n return String.fromCodePoint(codePoint);\n } catch {\n return match;\n }\n }\n return HTML_ENTITIES[entity.toLowerCase()] ?? match;\n });\n}\n\n/**\n * Repair UTF-8 bytes that were decoded as Latin-1, which turns \"é\" into \"é\".\n * lyrics.com serves French titles that occasionally arrive in this state.\n * Re-encoding through latin1 recovers the original text; the round trip is only\n * kept when it removes replacement characters, so correct text is never touched.\n */\nexport function fixEncodingIssues(text: string): string {\n if (!text || !/[ÃÂ]/.test(text)) return text;\n try {\n const repaired = Buffer.from(text, \"latin1\").toString(\"utf8\");\n if (repaired.includes(\"�\")) return text;\n return repaired;\n } catch {\n return text;\n }\n}\n\n/** Collapse runs of whitespace and trim. */\nexport function collapseWhitespace(text: string): string {\n return text.replace(/\\s+/g, \" \").trim();\n}\n\n/** Full cleaning pass for a scraped title or artist name. */\nexport function cleanInlineText(text: string): string {\n if (!text) return \"\";\n return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));\n}\n\n/**\n * Normalize a lyrics block: trim each line, drop leading and trailing blank\n * lines, and collapse runs of blank lines to a single verse separator.\n */\nexport function cleanLyricsText(text: string): string {\n if (!text) return \"\";\n const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\\r\\n?/g, \"\\n\");\n const lines = normalized.split(\"\\n\").map((line) => line.trim());\n\n const out: string[] = [];\n for (const line of lines) {\n if (line === \"\" && (out.length === 0 || out[out.length - 1] === \"\")) continue;\n out.push(line);\n }\n while (out.length > 0 && out[out.length - 1] === \"\") out.pop();\n return out.join(\"\\n\");\n}\n","/**\n * URL construction and validation for lyrics.com.\n */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.lyrics.com\";\n\n/** lyrics.com renders 24 rows per search page. */\nexport const RESULTS_PER_PAGE = 24;\n\n/**\n * Song page paths come in two shapes: the current `/lyric-lf/{id}/...` and the\n * older `/lyric/{id}/...` still present in archived links.\n */\nexport const SONG_HREF_RE = /^\\/lyric(?:-lf)?\\/(\\d+)\\//;\n\nconst ALLOWED_HOSTS = new Set([\"lyrics.com\", \"www.lyrics.com\"]);\n\n/**\n * Percent-encode a search term the way lyrics.com expects, with spaces as `+`.\n */\nfunction encodeTerm(term: string): string {\n return encodeURIComponent(term).replace(/%20/g, \"+\");\n}\n\n/**\n * Page 1 lives at a path, later pages at a query string. Both return the same\n * markup, so the parser does not need to know which one produced it.\n */\nexport function buildSearchUrl(term: string, page: number): string {\n const encoded = encodeTerm(term);\n if (page <= 1) return `${BASE_URL}/lyrics/${encoded}`;\n return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;\n}\n\nexport function buildSongUrl(id: string): string {\n return `${BASE_URL}/lyric-lf/${id}/`;\n}\n\n/** True only for lyrics.com itself, so a hostile URL cannot be used as a proxy. */\nexport function isLyricsComHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric song id from a lyrics.com song path or absolute URL. */\nexport function extractSongId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isLyricsComHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n if (!path.startsWith(\"/\")) path = `/${path}`;\n const match = SONG_HREF_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/**\n * Resolve the `id` / `url` pair accepted by get_lyrics into a canonical\n * request URL. `id` wins when both are given.\n */\nexport function resolveSongRef(input: { id?: string; url?: string }): { id: string; url: string } {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a lyrics.com song id.`,\n \"Ids are digits only, as returned by the search tools.\",\n );\n }\n return { id: input.id, url: buildSongUrl(input.id) };\n }\n\n if (input.url) {\n if (!isLyricsComHost(input.url)) {\n throw invalidInput(\n \"Only www.lyrics.com URLs are accepted.\",\n \"Pass the source_url returned by search_lyrics or search_songs, or use the numeric id instead.\",\n );\n }\n const id = extractSongId(input.url);\n if (!id) {\n throw invalidInput(\n \"That lyrics.com URL is not a song page.\",\n \"Song pages look like https://www.lyrics.com/lyric-lf/1234567/Artist/Title\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\n/** Absolute URL for a href scraped out of a result row. */\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Every lyrics.com CSS selector lives here, so a site redesign is a one-file fix.\n *\n * Selectors deliberately target a single stable class rather than a full class\n * list (`div.sec-lyric`, not `div.sec-lyric.clearfix`), so that a layout tweak\n * adding or removing a utility class does not break extraction.\n */\n\nexport const SEL = {\n /** One search result row. */\n serpItem: \"div.sec-lyric\",\n /**\n * Page chrome that is present whether or not there are results. Used to tell\n * \"zero results\" apart from \"the markup changed\", which must not look alike.\n */\n serpShell: \"#lyric-search, .lyric-search, .pagination, #main-content, form[action*='serp']\",\n resultTitle: \"p.lyric-meta-title a\",\n /**\n * Rows tied to an album expose the artist under `lyric-meta-album-artist`,\n * rows without one under `lyric-meta-artists`. Both shapes appear on the same\n * page, so missing the second silently drops results.\n */\n resultArtist: \"p.lyric-meta-album-artist a, p.lyric-meta-artists a\",\n resultAlbum: \"p.lyric-meta-album\",\n resultYear: \"p.lyric-meta-album-year\",\n resultSnippet: \"pre.lyric-body\",\n\n /** Full lyrics on a song page, with the historical fallback. */\n songBody: \"pre#lyric-body-text, pre.lyric-body\",\n /** Marker for a valid song page that simply has no lyrics on file. */\n songNoData: \"div.lyric-no-data\",\n songTitle: \"#lyric-title-text, .lyric-title\",\n songArtist: \".lyric-artist a, .lyric-artist\",\n} as const;\n","/**\n * Parser for a lyrics.com song page.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SongPage } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSongOptions {\n id: string;\n url: string;\n}\n\nexport function parseSongPage(html: string, options: ParseSongOptions): SongPage {\n const $ = cheerio.load(html);\n\n const title = cleanInlineText($(SEL.songTitle).first().text()) || null;\n const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;\n\n const body = $(SEL.songBody).first();\n if (body.length === 0) {\n // A valid song page can legitimately carry no lyrics. That is an answer,\n // not a failure, so it is only treated as breakage when the page says\n // nothing about why the text is missing.\n const declaresNoLyrics =\n $(SEL.songNoData).length > 0 || /no lyrics found/i.test($(\"title\").text());\n if (!declaresNoLyrics) {\n throw parseFailure(options.url, \"no lyrics container and no 'no lyrics' marker\");\n }\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n const lyrics = cleanLyricsText(extractBodyText($, body));\n if (!lyrics) {\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n return { id: options.id, url: options.url, title, artist, lyrics, hasLyrics: true };\n}\n\n/**\n * lyrics.com wraps many individual words in links to its dictionary pages.\n * Those anchors are unwrapped in the raw HTML rather than through the DOM,\n * because DOM-level removal loses the line breaks that give lyrics their shape.\n */\nfunction extractBodyText($: cheerio.CheerioAPI, body: ReturnType<cheerio.CheerioAPI>): string {\n const rawHtml = $.html(body);\n const withoutAnchors = rawHtml.replace(/<a[^>]*>([\\s\\S]*?)<\\/a>/gi, \"$1\");\n const withNewlines = withoutAnchors.replace(/<br\\s*\\/?>/gi, \"\\n\");\n return cheerio.load(withNewlines).root().text();\n}\n","/**\n * High-level lyrics.com client.\n *\n * This module knows nothing about MCP, which keeps it unit-testable against\n * plain strings and usable as a plain library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type { SearchPage, SongPage } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseSearchResults } from \"./parsers/searchResults.js\";\nimport { parseSongPage } from \"./parsers/songPage.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveSongRef } from \"./urls.js\";\n\nexport interface LyricsComClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface FetchOutcome<T> {\n data: T;\n /** True when the page came from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `LyricsComClient` is published as a\n * library through the `./client` export and takes a caller-built config, so\n * without this the pacing floor and the honest identity are optional for anyone\n * importing it. lyrics.com answers a burst by refusing, and an honest User-Agent is what it accepts, and those promises hold on every path.\n *\n * A caller may still name their own application in the User-Agent. Passing the\n * traffic off as a browser is a different thing, and gets the project's own\n * identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class LyricsComClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<unknown>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: LyricsComClientOptions = {}) {\n this.config = withGuarantees(options.config ?? loadConfig());\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<unknown>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n async search(term: string, page: number): Promise<FetchOutcome<SearchPage>> {\n const url = buildSearchUrl(term, page);\n return this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));\n }\n\n async getSong(ref: { id?: string; url?: string }): Promise<FetchOutcome<SongPage>> {\n const { id, url } = resolveSongRef(ref);\n return this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at lyrics.com cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<FetchOutcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n\n const data = parse(html);\n this.cache.set(url, data);\n return { data, cached: false };\n }\n}\n","/**\n * get_lyrics: fetch the full lyrics of one song.\n */\n\nimport { z } from \"zod\";\nimport type { LyricsComClient } from \"../lyricscom/client.js\";\nimport { findMatchingLine } from \"../text/excerpt.js\";\nimport { ok, toToolError, truncate } from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const getLyricsDescription = [\n \"Fetch the full lyrics of one song from lyrics.com, given the song id or URL returned by search_lyrics or search_songs.\",\n \"Lyrics can be long, so the response is truncated by default: check 'truncated' and call again with 'offset' set to\",\n \"'next_offset' to continue reading.\",\n 'Some lyrics.com pages legitimately have no lyrics on file; those come back with status \"no_lyrics\" and an empty',\n \"lyrics field, which is a valid answer and not an error, so do not retry them.\",\n \"Always cite 'source_url' and the artist when showing lyrics to a user.\",\n].join(\" \");\n\nexport const getLyricsInputShape = {\n id: z\n .string()\n .regex(/^\\d+$/, \"Song ids are digits only.\")\n .optional()\n .describe(\"lyrics.com numeric song id, as returned by the search tools. Preferred over 'url'.\"),\n url: z\n .string()\n .url()\n .optional()\n .describe(\n \"Full lyrics.com song URL. Only www.lyrics.com URLs are accepted. Ignored when 'id' is given.\",\n ),\n max_chars: z\n .number()\n .int()\n .min(200)\n .max(20000)\n .default(6000)\n .describe(\"Maximum characters of lyrics text to return in this call.\"),\n offset: z\n .number()\n .int()\n .min(0)\n .default(0)\n .describe(\"Character offset to resume from, for lyrics longer than max_chars.\"),\n highlight: z\n .string()\n .max(120)\n .optional()\n .describe(\n \"Optional word. When set, the response reports whether it appears in the lyrics and on which line.\",\n ),\n};\n\nexport const getLyricsOutputShape = {\n status: z.enum([\"ok\", \"no_lyrics\"]),\n id: z.string(),\n title: z.string().nullable(),\n artist: z.string().nullable(),\n source_url: z.string(),\n lyrics: z.string().describe(\"Slice of the lyrics text. Empty when status is 'no_lyrics'.\"),\n total_chars: z.number().int(),\n returned_chars: z.number().int(),\n offset: z.number().int(),\n next_offset: z.number().int().nullable(),\n truncated: z.boolean(),\n line_count: z.number().int(),\n highlight: z\n .object({\n word: z.string(),\n found: z.boolean(),\n line_number: z.number().int().nullable(),\n line: z.string().nullable(),\n })\n .nullable(),\n attribution: z.string().describe(\"Ready-to-display credit line.\"),\n source: z.literal(\"lyrics.com\"),\n notes: z.array(z.string()),\n};\n\nexport interface GetLyricsArgs {\n id?: string;\n url?: string;\n max_chars: number;\n offset: number;\n highlight?: string;\n}\n\n/**\n * Cut at the last newline that fits, so a continuation never splits a line in\n * half. Falls back to a hard cut for a single line longer than the budget.\n */\nexport function sliceAtLineBoundary(\n text: string,\n offset: number,\n maxChars: number,\n): { slice: string; nextOffset: number | null } {\n if (offset >= text.length) return { slice: \"\", nextOffset: null };\n\n const remaining = text.slice(offset);\n if (remaining.length <= maxChars) return { slice: remaining, nextOffset: null };\n\n const window = remaining.slice(0, maxChars);\n const lastBreak = window.lastIndexOf(\"\\n\");\n const cut = lastBreak > 0 ? lastBreak : maxChars;\n return { slice: remaining.slice(0, cut), nextOffset: offset + cut };\n}\n\nexport async function runGetLyrics(\n client: LyricsComClient,\n args: GetLyricsArgs,\n): Promise<ToolResult> {\n try {\n const ref: { id?: string; url?: string } = {};\n if (args.id) ref.id = args.id;\n else if (args.url) ref.url = args.url;\n\n const { data, cached } = await client.getSong(ref);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n const credit = [data.title, data.artist].filter(Boolean).join(\" — \");\n const attribution = `${credit || \"Lyrics\"} via lyrics.com — ${data.url}`;\n\n if (!data.hasLyrics) {\n const structured = {\n status: \"no_lyrics\" as const,\n id: data.id,\n title: data.title,\n artist: data.artist,\n source_url: data.url,\n lyrics: \"\",\n total_chars: 0,\n returned_chars: 0,\n offset: 0,\n next_offset: null,\n truncated: false,\n notes,\n line_count: 0,\n highlight: null,\n attribution,\n source: \"lyrics.com\" as const,\n };\n return ok(\n structured,\n `lyrics.com has a page for this song but no lyrics on file.\\n${attribution}`,\n );\n }\n\n const { slice, nextOffset } = sliceAtLineBoundary(data.lyrics, args.offset, args.max_chars);\n const match = args.highlight ? findMatchingLine(data.lyrics, args.highlight) : null;\n\n const structured = {\n status: \"ok\" as const,\n id: data.id,\n title: data.title,\n artist: data.artist,\n source_url: data.url,\n lyrics: slice,\n total_chars: data.lyrics.length,\n returned_chars: slice.length,\n offset: args.offset,\n next_offset: nextOffset,\n truncated: nextOffset !== null,\n line_count: data.lyrics === \"\" ? 0 : data.lyrics.split(\"\\n\").length,\n highlight: args.highlight\n ? {\n word: args.highlight,\n found: match !== null,\n line_number: match?.lineNumber ?? null,\n line: match?.line ?? null,\n }\n : null,\n attribution,\n source: \"lyrics.com\" as const,\n notes,\n };\n\n // An offset beyond the words yields an empty body, which on its own reads\n // as a song that carries none. What happened is that the caller asked for\n // a position that does not exist, and only saying so tells the two apart.\n const pastTheEnd = args.offset > 0 && slice === \"\" && data.lyrics.length > 0;\n if (pastTheEnd) {\n notes.push(\n `offset=${args.offset} is past the end of a body of ${data.lyrics.length} characters. Call again with offset=0 to read it from the start.`,\n );\n }\n\n // The text mirror stays a pointer to the structured payload rather than a\n // second copy of the lyrics.\n const summary = [\n attribution,\n `${structured.line_count} lines, ${structured.total_chars} characters.`,\n pastTheEnd\n ? `Nothing at offset=${args.offset}: that is past the end. Call again with offset=0.`\n : nextOffset !== null\n ? `Truncated: call again with offset=${nextOffset} to continue.`\n : \"Complete.\",\n structured.highlight\n ? structured.highlight.found\n ? `\"${structured.highlight.word}\" appears on line ${structured.highlight.line_number}.`\n : `\"${structured.highlight.word}\" does not appear in these lyrics.`\n : \"\",\n ]\n .filter(Boolean)\n .join(\"\\n\");\n\n return ok(structured, truncate(`${summary}\\n\\n${slice}`, 4000), notes);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * Two-pass keyword matcher.\n *\n * lyrics.com's own search is fuzzy: it returns title-only matches and loose\n * matches alongside genuine lyric-body hits. This matcher is what lets the\n * server promise that a returned song really contains the query.\n *\n * Pass 1 requires the keyword to be delimited on both sides, so \"coup\" does not\n * match \"beaucoup\". Pass 2 relaxes the right-hand side to a word start, so\n * \"enfant\" still matches \"enfants\". Pass 2 only runs when pass 1 finds nothing,\n * which keeps exact hits ranked ahead of prefix hits.\n */\n\n/**\n * Characters treated as word boundaries. Includes the typographic apostrophe,\n * which is common in French lyrics (\"l’enfant\").\n */\nconst WORD_DELIMITERS = \"[\\\\s,;:'\\\".!?()\\\\[\\\\]&’`@-]\";\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Index of `keyword` inside `text`, or -1 when absent.\n * The returned index points at the keyword itself, not at the leading delimiter.\n */\nexport function getKeywordIndex(keyword: string, text: string): number {\n if (!keyword || !text) return -1;\n\n const escaped = escapeRegExp(keyword.toLowerCase());\n const haystack = text.toLowerCase();\n\n const passes = [\n new RegExp(`(^|${WORD_DELIMITERS})${escaped}(${WORD_DELIMITERS}|$)`),\n new RegExp(`(^|${WORD_DELIMITERS})${escaped}`),\n ];\n\n for (const pattern of passes) {\n const match = pattern.exec(haystack);\n if (match) {\n const leadingDelimiter = match[1] ?? \"\";\n return match.index + leadingDelimiter.length;\n }\n }\n\n return -1;\n}\n\nexport function containsWord(keyword: string, text: string): boolean {\n return getKeywordIndex(keyword, text) !== -1;\n}\n","/**\n * Excerpt extraction.\n *\n * Search results carry one short line showing where the query appears, never a\n * lyrics block. Excerpts stay small on purpose: they are enough for a model to\n * pick the right song, and search results are not the place to reproduce a\n * song's text.\n */\n\nimport { getKeywordIndex } from \"./keywordIndex.js\";\n\nexport interface ExcerptOptions {\n /** Hard cap on the returned excerpt. */\n maxChars?: number;\n}\n\nexport interface LineMatch {\n line: string;\n /** 1-based line number within the text. */\n lineNumber: number;\n}\n\n/** First line containing `keyword`, using the two-pass matcher. */\nexport function findMatchingLine(text: string, keyword: string): LineMatch | null {\n if (!text || !keyword) return null;\n const lines = text.split(\"\\n\");\n for (let i = 0; i < lines.length; i += 1) {\n const line = lines[i] ?? \"\";\n if (line.trim() === \"\") continue;\n if (getKeywordIndex(keyword, line) !== -1) {\n return { line: line.trim(), lineNumber: i + 1 };\n }\n }\n return null;\n}\n\n/**\n * Excerpt of the line where `keyword` appears, trimmed to a window centred on\n * the match. Falls back to the first non-empty line when the keyword is absent.\n */\nexport function matchedLineExcerpt(\n text: string,\n keyword: string,\n options: ExcerptOptions = {},\n): string | null {\n const maxChars = options.maxChars ?? 160;\n if (!text) return null;\n\n const match = findMatchingLine(text, keyword);\n const line =\n match?.line ??\n text\n .split(\"\\n\")\n .find((candidate) => candidate.trim() !== \"\")\n ?.trim();\n if (!line) return null;\n if (line.length <= maxChars) return line;\n\n const keywordAt = match ? Math.max(getKeywordIndex(keyword, line), 0) : 0;\n const start = Math.max(0, keywordAt - Math.floor(maxChars / 2));\n const end = Math.min(line.length, start + maxChars);\n const slice = line.slice(start, end).trim();\n return `${start > 0 ? \"…\" : \"\"}${slice}${end < line.length ? \"…\" : \"\"}`;\n}\n","/**\n * Pieces shared by the three tools: result schema, error mapping, text mirrors.\n */\n\nimport { z } from \"zod\";\nimport { LyricsComError } from \"../errors.js\";\nimport type { SongResult } from \"../types.js\";\n\n/** Many MCP clients render only the text block, so it must stay readable on its own. */\nexport const MAX_TEXT_MIRROR_CHARS = 1500;\n\n/**\n * The last page a search will accept.\n *\n * Offering a page beyond this sends a caller into an argument the schema\n * refuses, so the number that bounds the input is the number an answer is\n * allowed to suggest.\n */\nexport const MAX_PAGE = 20;\n\nexport const songResultSchema = z.object({\n id: z.string().describe(\"lyrics.com song id. Pass this to get_lyrics.\"),\n title: z.string(),\n artist: z.string(),\n album: z.string().nullable(),\n year: z.number().int().nullable(),\n source_url: z.string().describe(\"Canonical lyrics.com page for this song.\"),\n excerpt: z\n .string()\n .nullable()\n .describe(\"Short lyric line showing where the query appears, when available.\"),\n});\n\nexport type SongResultOut = z.infer<typeof songResultSchema>;\n\nexport function toSongResultOut(song: SongResult, excerpt: string | null): SongResultOut {\n return {\n id: song.id,\n title: song.title,\n artist: song.artist,\n album: song.album,\n year: song.year,\n source_url: song.url,\n excerpt,\n };\n}\n\nexport interface ToolResult {\n // The SDK's CallToolResult carries an index signature for protocol extensions.\n [key: string]: unknown;\n content: Array<{ type: \"text\"; text: string }>;\n structuredContent?: Record<string, unknown>;\n isError?: boolean;\n}\n\n/**\n * Keep text from the site out of the shape this server's own lines take.\n *\n * A block ending with lines that open \"Note:\" gives a caller no way to tell one\n * of those from the same words inside a title someone else published.\n * Indenting such a line in the body keeps the two apart, and the structured\n * output still carries it exactly as it was.\n */\nfunction indentMarkerLines(body: string): string {\n return body.replace(/^(Note:)/gm, \" $1\");\n}\n\n/**\n * Build a result whose text block ends with its notes.\n *\n * The notes are what qualifies an answer: that an offset landed past the end,\n * that the site chose these results rather than matching them, that the words\n * came from this server's cache. A client rendering only the text reads an\n * unqualified answer without them.\n */\nexport function ok(\n structured: Record<string, unknown>,\n text: string,\n notes: string[] = [],\n): ToolResult {\n const trailer = notes.map((note) => `Note: ${note}`).join(\"\\n\");\n const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);\n const body = truncate(indentMarkerLines(text), Math.max(0, budget));\n\n return {\n content: [{ type: \"text\", text: trailer ? `${body}\\n\\n${trailer}` : body }],\n structuredContent: structured,\n };\n}\n\n/**\n * Error results carry no structuredContent: the SDK validates it against the\n * tool's declared output schema, which an error payload does not satisfy.\n */\nexport function toToolError(error: unknown): ToolResult {\n const known =\n error instanceof LyricsComError\n ? error\n : new LyricsComError(\"network_error\", error instanceof Error ? error.message : String(error));\n\n const lines = [`[${known.code}] ${known.message}`];\n if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);\n if (known.details.url) lines.push(`URL: ${known.details.url}`);\n\n return { content: [{ type: \"text\", text: lines.join(\"\\n\") }], isError: true };\n}\n\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return `${text.slice(0, maxChars - 1).trimEnd()}…`;\n}\n\n/** Compact listing used by both search tools. */\nexport function renderResultList(results: SongResultOut[]): string {\n return results\n .map((result, index) => {\n const year = result.year ? ` (${result.year})` : \"\";\n const album = result.album ? ` [${result.album}]` : \"\";\n const head = `${index + 1}. ${result.title} — ${result.artist}${year}${album} · id: ${result.id}`;\n return result.excerpt ? `${head}\\n \"${result.excerpt}\"` : head;\n })\n .join(\"\\n\");\n}\n","/**\n * search_lyrics: find songs by a word or phrase occurring in the lyrics.\n */\n\nimport { z } from \"zod\";\nimport type { LyricsComClient } from \"../lyricscom/client.js\";\nimport { dedupeSongs } from \"../text/dedupe.js\";\nimport { matchedLineExcerpt } from \"../text/excerpt.js\";\nimport { containsWord } from \"../text/keywordIndex.js\";\nimport type { SongResult } from \"../types.js\";\nimport {\n MAX_PAGE,\n ok,\n renderResultList,\n songResultSchema,\n toSongResultOut,\n toToolError,\n} from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\n/** Verifying against full lyrics costs one page fetch per candidate. */\nconst MAX_FULL_VERIFICATIONS = 5;\n\nexport const searchLyricsDescription = [\n \"Search lyrics.com for songs whose lyrics contain a given word or phrase.\",\n \"Returns matching songs with artist, title, a short excerpt of the line where the word appears,\",\n \"and the lyrics.com id and URL needed to fetch the full text with get_lyrics.\",\n \"Use this when someone remembers a fragment of lyrics but not the song, or wants songs mentioning a word or theme.\",\n \"One call returns one page of lyrics.com results; to see more, increase 'page' rather than 'limit'.\",\n \"lyrics.com also returns loose and title-only matches, so results are filtered locally to keep only songs\",\n \"where the word genuinely appears in the lyrics. Set 'verify' to \\\"none\\\" to see the raw, unfiltered list.\",\n].join(\" \");\n\nexport const searchLyricsInputShape = {\n query: z\n .string()\n .min(1)\n .max(120)\n .describe(\"Word or short phrase to look for inside the lyrics, for example 'autrefois'.\"),\n limit: z\n .number()\n .int()\n .min(1)\n .max(50)\n .default(10)\n .describe(\"Maximum songs to return from this page.\"),\n page: z\n .number()\n .int()\n .min(1)\n .max(20)\n .default(1)\n .describe(\"Result page on lyrics.com. Each page holds up to 24 raw results.\"),\n verify: z\n .enum([\"snippet\", \"full\", \"none\"])\n .default(\"snippet\")\n .describe(\n \"How to confirm the query really appears in the lyrics. 'snippet' checks the excerpt lyrics.com already \" +\n \"returned and costs nothing. 'full' fetches up to 5 song pages and checks the complete lyrics, which is \" +\n \"slow and can trigger rate limiting. 'none' disables filtering.\",\n ),\n include_excerpt: z\n .boolean()\n .default(true)\n .describe(\"Include the matching lyric line excerpt in each result.\"),\n};\n\nexport const searchLyricsOutputShape = {\n query: z.string(),\n page: z.number().int(),\n results: z.array(songResultSchema),\n raw_result_count: z\n .number()\n .int()\n .describe(\"Rows lyrics.com returned before local filtering and deduplication.\"),\n filtered_out: z\n .number()\n .int()\n .describe(\"Rows dropped because the query was not found in their lyrics.\"),\n has_more: z.boolean(),\n next_page: z.number().int().nullable(),\n source: z.literal(\"lyrics.com\"),\n notes: z\n .array(z.string())\n .describe(\"Caveats worth knowing, such as a cache hit or partial verification.\"),\n};\n\nexport interface SearchLyricsArgs {\n query: string;\n limit: number;\n page: number;\n verify: \"snippet\" | \"full\" | \"none\";\n include_excerpt: boolean;\n}\n\nexport async function runSearchLyrics(\n client: LyricsComClient,\n args: SearchLyricsArgs,\n): Promise<ToolResult> {\n try {\n const { data, cached } = await client.search(args.query, args.page);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n\n const unique = dedupeSongs(data.results);\n const kept: Array<{ song: SongResult; excerpt: string | null }> = [];\n let filteredOut = 0;\n let fullChecks = 0;\n\n for (const song of unique) {\n if (kept.length >= args.limit) break;\n\n let text = song.snippet ?? \"\";\n\n if (args.verify === \"full\" && fullChecks < MAX_FULL_VERIFICATIONS) {\n fullChecks += 1;\n try {\n const page = await client.getSong({ id: song.id });\n if (page.data.hasLyrics) text = page.data.lyrics;\n } catch (error) {\n // A single unreadable song page must not sink the whole search.\n notes.push(`Could not verify \"${song.title}\" against its full lyrics.`);\n void error;\n }\n }\n\n const matches = args.verify === \"none\" || (text !== \"\" && containsWord(args.query, text));\n if (!matches) {\n filteredOut += 1;\n continue;\n }\n\n const excerpt =\n args.include_excerpt && text\n ? matchedLineExcerpt(text, args.query, { maxChars: 160 })\n : null;\n kept.push({ song, excerpt });\n }\n\n if (args.verify === \"full\" && unique.length > MAX_FULL_VERIFICATIONS) {\n notes.push(\n `Full verification is capped at ${MAX_FULL_VERIFICATIONS} songs per call to avoid rate limiting; ` +\n \"the remaining results were checked against their search excerpt.\",\n );\n }\n if (kept.length === 0 && data.rawCount > 0) {\n notes.push(\n \"lyrics.com returned results but none of them actually contain the query in their lyrics. \" +\n 'Try verify=\"none\" to see the raw matches.',\n );\n }\n\n const results = kept.map((entry) => toSongResultOut(entry.song, entry.excerpt));\n const structured = {\n query: args.query,\n page: args.page,\n results,\n raw_result_count: data.rawCount,\n filtered_out: filteredOut,\n has_more: data.hasMore,\n next_page: data.hasMore && args.page < MAX_PAGE ? args.page + 1 : null,\n source: \"lyrics.com\" as const,\n notes,\n };\n\n const header =\n results.length > 0\n ? `${results.length} song(s) on page ${args.page} for \"${args.query}\":`\n : `No song on page ${args.page} matched \"${args.query}\".`;\n // The footer may only name a page the schema would accept, or it sends a\n // caller into an argument that is refused.\n const footer =\n data.hasMore && args.page < MAX_PAGE\n ? `\\n\\nMore results available: call again with page=${args.page + 1}.`\n : data.hasMore\n ? `\\n\\nlyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.`\n : \"\";\n\n return ok(structured, `${header}\\n${renderResultList(results)}${footer}`);\n } catch (error) {\n return toToolError(error);\n }\n}\n","/**\n * Deduplication of search results.\n *\n * lyrics.com lists the same recording once per album it appears on, so a single\n * query routinely returns \"Title\", \"Title [Deluxe]\" and \"Title (Remastered)\" by\n * the same artist. Bracketed and parenthesised suffixes are stripped before\n * comparison so those collapse into one result.\n */\n\nimport type { SongResult } from \"../types.js\";\n\nexport function songKey(artist: string, title: string): string {\n const withoutSuffixes = title.replace(/\\s*[[(].*?[\\])]/g, \"\");\n const titleNorm = withoutSuffixes.replace(/\\s+/g, \" \").trim().toLowerCase();\n const artistNorm = artist.replace(/\\s+/g, \" \").trim().toLowerCase();\n return `${artistNorm}|${titleNorm}`;\n}\n\n/** Keep the first occurrence of each artist/title pair, preserving order. */\nexport function dedupeSongs<T extends SongResult>(songs: T[]): T[] {\n const seen = new Set<string>();\n const unique: T[] = [];\n for (const song of songs) {\n const key = songKey(song.artist, song.title);\n if (seen.has(key)) continue;\n seen.add(key);\n unique.push(song);\n }\n return unique;\n}\n","/**\n * search_songs: find songs by title.\n *\n * lyrics.com has no title-only endpoint, so this runs the same search and ranks\n * and filters the rows by how well their title matches. The tool description\n * says so plainly rather than implying a precision the site does not offer.\n */\n\nimport { z } from \"zod\";\nimport type { LyricsComClient } from \"../lyricscom/client.js\";\nimport { dedupeSongs } from \"../text/dedupe.js\";\nimport type { SongResult } from \"../types.js\";\nimport {\n MAX_PAGE,\n ok,\n renderResultList,\n songResultSchema,\n toSongResultOut,\n toToolError,\n} from \"./shared.js\";\nimport type { ToolResult } from \"./shared.js\";\n\nexport const searchSongsDescription = [\n \"Find songs on lyrics.com by their title.\",\n \"Returns candidate songs with artist, album, year, and the lyrics.com id and URL.\",\n \"Use this when someone names a song and you need its id to call get_lyrics, or to tell covers and versions\",\n \"by different artists apart. An optional 'artist' narrows the list.\",\n \"lyrics.com searches titles and lyrics together, so results are ranked and filtered locally by title match;\",\n 'use match=\"strict\" to keep only genuine title matches.',\n].join(\" \");\n\nexport const searchSongsInputShape = {\n title: z.string().min(1).max(120).describe(\"Song title, or part of it.\"),\n artist: z\n .string()\n .max(120)\n .optional()\n .describe(\"Optional artist name, matched case-insensitively against each result.\"),\n limit: z.number().int().min(1).max(50).default(10),\n page: z.number().int().min(1).max(20).default(1),\n match: z\n .enum([\"loose\", \"strict\"])\n .default(\"loose\")\n .describe(\n \"'strict' keeps only results whose title matches the query as a whole title, ignoring suffixes such as \" +\n \"'(Remastered)'. 'loose' keeps anything lyrics.com returned, best match first.\",\n ),\n};\n\nexport const searchSongsOutputShape = {\n title: z.string(),\n artist_filter: z.string().nullable(),\n page: z.number().int(),\n results: z.array(songResultSchema),\n raw_result_count: z.number().int(),\n filtered_out: z.number().int(),\n has_more: z.boolean(),\n next_page: z.number().int().nullable(),\n source: z.literal(\"lyrics.com\"),\n notes: z.array(z.string()),\n};\n\nexport interface SearchSongsArgs {\n title: string;\n artist?: string;\n limit: number;\n page: number;\n match: \"loose\" | \"strict\";\n}\n\n/** Normalized form used for title comparison: no edition suffix, no punctuation noise. */\nfunction normalizeTitle(title: string): string {\n return title\n .replace(/\\s*[[(].*?[\\])]/g, \"\")\n .replace(/\\s+/g, \" \")\n .trim()\n .toLowerCase();\n}\n\n/** 3 = same title, 2 = starts with the query, 1 = contains it, 0 = no title match. */\nexport function titleScore(candidate: string, query: string): number {\n const a = normalizeTitle(candidate);\n const b = normalizeTitle(query);\n if (!a || !b) return 0;\n if (a === b) return 3;\n if (a.startsWith(b)) return 2;\n if (a.includes(b)) return 1;\n return 0;\n}\n\nexport async function runSearchSongs(\n client: LyricsComClient,\n args: SearchSongsArgs,\n): Promise<ToolResult> {\n try {\n const { data, cached } = await client.search(args.title, args.page);\n const notes: string[] = [];\n if (cached) notes.push(\"Served from this server's short-lived in-memory cache.\");\n\n const artistFilter = args.artist?.trim().toLowerCase() ?? null;\n const unique = dedupeSongs(data.results);\n\n const scored: Array<{ song: SongResult; score: number }> = [];\n let filteredOut = 0;\n\n for (const song of unique) {\n const score = titleScore(song.title, args.title);\n const artistOk = !artistFilter || song.artist.toLowerCase().includes(artistFilter);\n if (!artistOk || (args.match === \"strict\" && score < 2)) {\n filteredOut += 1;\n continue;\n }\n scored.push({ song, score });\n }\n\n // Stable sort by score, so equally scored rows keep lyrics.com's own order.\n scored.sort((a, b) => b.score - a.score);\n const results = scored.slice(0, args.limit).map((entry) => toSongResultOut(entry.song, null));\n\n if (results.length === 0 && data.rawCount > 0) {\n notes.push(\n args.match === \"strict\"\n ? 'lyrics.com returned results but none matched the title strictly. Try match=\"loose\".'\n : \"lyrics.com returned results but none matched the requested artist.\",\n );\n }\n\n const structured = {\n title: args.title,\n artist_filter: args.artist ?? null,\n page: args.page,\n results,\n raw_result_count: data.rawCount,\n filtered_out: filteredOut,\n has_more: data.hasMore,\n next_page: data.hasMore && args.page < MAX_PAGE ? args.page + 1 : null,\n source: \"lyrics.com\" as const,\n notes,\n };\n\n // In loose mode the site returns its own guesses, and a row whose title is\n // not the one asked for is among them. Calling the list a set of matches is\n // how one song's words end up quoted for another.\n const loose = args.match !== \"strict\";\n const header =\n results.length > 0\n ? loose\n ? `${results.length} song(s) lyrics.com offers on page ${args.page} for \"${args.title}\":`\n : `${results.length} song(s) on page ${args.page} matching \"${args.title}\":`\n : `No song on page ${args.page} matched \"${args.title}\".`;\n // The footer may only name a page the schema would accept, or it sends a\n // caller into an argument that is refused.\n const footer =\n data.hasMore && args.page < MAX_PAGE\n ? `\\n\\nMore results available: call again with page=${args.page + 1}.`\n : data.hasMore\n ? `\\n\\nlyrics.com holds more, but page ${MAX_PAGE} is as far as this tool reads. Narrow the query instead.`\n : \"\";\n\n if (results.length > 0 && loose) {\n notes.push(\n `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\".`,\n );\n }\n\n return ok(structured, `${header}\\n${renderResultList(results)}${footer}`, notes);\n } catch (error) {\n return toToolError(error);\n }\n}\n"],"mappings":"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","names":["z","cheerio","z","z","structured","z","z","z","z","z"]}
|
package/dist/lyricscom/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/version.ts","../../src/config.ts","../../src/lyricscom/cache.ts","../../src/errors.ts","../../src/lyricscom/parsers/detectThrottle.ts","../../src/lyricscom/rateLimiter.ts","../../src/lyricscom/http.ts","../../src/lyricscom/parsers/searchResults.ts","../../src/text/normalize.ts","../../src/lyricscom/urls.ts","../../src/lyricscom/parsers/selectors.ts","../../src/lyricscom/parsers/songPage.ts","../../src/lyricscom/client.ts"],"sourcesContent":["/**\n * Kept in sync with package.json. The User-Agent embeds it, so it must not\n * drift; the release checklist bumps both together.\n */\nexport const PKG_VERSION = \"1.2.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-lyricscom\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * Identifies the client honestly. lyrics.com serves this fine; it blocks\n * generic tool agents such as `curl/x.y.z`, which is what the override is for.\n */\nexport const DEFAULT_USER_AGENT = `mcp-lyricscom/${PKG_VERSION} (+${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1100,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * The pacing is what keeps this client from behaving like a crawler against a\n * site that has no API and no published rate limit. Leaving it configurable down\n * to zero would make the politeness of every installation depend on whoever\n * edited a JSON file, so a value below this floor is refused and the default is\n * used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n const clamped = Math.min(range.max, Math.max(range.min, Math.round(parsed)));\n if (clamped !== Math.round(parsed)) {\n warn(`${name}=${raw} is out of range, clamped to ${clamped}`);\n }\n return clamped;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 500, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.LYRICSCOM_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`LYRICSCOM_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `LYRICSCOM_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. This floor keeps the client from hammering lyrics.com.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.LYRICSCOM_USER_AGENT?.trim();\n const rawLogLevel = env.LYRICSCOM_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`LYRICSCOM_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"LYRICSCOM_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"LYRICSCOM_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"LYRICSCOM_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"LYRICSCOM_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The point of this file is that an LLM must be able to tell \"lyrics.com pushed\n * back on me\" apart from \"there is no such song\". The reference implementation\n * this server replaces collapsed every failure into an empty list, which made\n * rate limiting look like an absence of results.\n */\n\nexport type ErrorCode =\n | \"throttled\"\n | \"blocked_user_agent\"\n | \"not_found\"\n | \"invalid_input\"\n | \"parse_failure\"\n | \"network_error\"\n | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class LyricsComError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"LyricsComError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-lyricscom/issues\";\n\nexport function throttled(url: string, retryAfterMs: number): LyricsComError {\n return new LyricsComError(\n \"throttled\",\n \"lyrics.com is rate limiting this client. It answered with an empty response instead of a page. \" +\n \"This does NOT mean there are no results for this query.\",\n {\n url,\n retryAfterMs,\n hint:\n \"Wait at least a minute before calling the same tool again with the same arguments. Once lyrics.com \" +\n \"starts throttling, the window commonly lasts several minutes, so retrying immediately will fail again. \" +\n \"If it keeps happening, raise LYRICSCOM_MIN_INTERVAL_MS in your MCP client configuration.\",\n },\n );\n}\n\nexport function blockedUserAgent(url: string): LyricsComError {\n return new LyricsComError(\n \"blocked_user_agent\",\n \"lyrics.com refused the request because of the User-Agent this server sends (HTTP 403).\",\n {\n url,\n status: 403,\n hint:\n \"Set the LYRICSCOM_USER_AGENT environment variable in your MCP client configuration to a different \" +\n \"value. See the Configuration section of the project README.\",\n },\n );\n}\n\nexport function notFound(url: string): LyricsComError {\n return new LyricsComError(\"not_found\", \"lyrics.com has no page at this address.\", {\n url,\n status: 404,\n hint: \"Check the song id. Ids come from the search tools; they are not stable to guess by hand.\",\n });\n}\n\nexport function parseFailure(url: string, what: string): LyricsComError {\n return new LyricsComError(\n \"parse_failure\",\n `The page loaded but the expected lyrics.com markup was not found (${what}). The site layout may have changed.`,\n { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },\n );\n}\n\nexport function invalidInput(message: string, hint?: string): LyricsComError {\n return new LyricsComError(\"invalid_input\", message, hint ? { hint } : {});\n}\n","/**\n * Response classification.\n *\n * lyrics.com does not answer rate limiting with 429. It answers with HTTP 202\n * and a zero-byte body, which a naive client reads as a successful but empty\n * page. Every response therefore goes through this classifier before any\n * parsing happens.\n */\n\nexport type ResponseVerdict =\n | { kind: \"ok\"; html: string }\n | { kind: \"throttled\"; reason: ThrottleReason }\n | { kind: \"blocked\"; status: number }\n | { kind: \"not-found\" }\n | { kind: \"server-error\"; status: number };\n\nexport type ThrottleReason =\n \"status-202\" | \"status-429\" | \"status-503\" | \"empty-body\" | \"short-body\";\n\n/**\n * A real search page weighs about 187 KB and a song page 60 KB or more, so a\n * body this small is never a genuine lyrics.com page.\n */\nexport const MIN_PLAUSIBLE_HTML = 2000;\n\nexport function classifyResponse(status: number, body: string): ResponseVerdict {\n if (status === 202) return { kind: \"throttled\", reason: \"status-202\" };\n if (status === 429) return { kind: \"throttled\", reason: \"status-429\" };\n if (status === 503) return { kind: \"throttled\", reason: \"status-503\" };\n if (status === 403) return { kind: \"blocked\", status };\n if (status === 404) return { kind: \"not-found\" };\n if (status >= 500) return { kind: \"server-error\", status };\n\n const trimmed = body.trim();\n if (trimmed.length === 0) return { kind: \"throttled\", reason: \"empty-body\" };\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n return { kind: \"throttled\", reason: \"short-body\" };\n }\n\n return { kind: \"ok\", html: body };\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // virtual machine, would otherwise make this wait for the size of the step,\n // and the queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, classified, with backoff.\n *\n * The retry policy is domain specific rather than generic, because the failure\n * this server exists to handle does not look like a failure at the transport\n * level: lyrics.com answers a throttled request with HTTP 202 and an empty\n * body. Only `classifyResponse` can tell that apart from a real page.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { blockedUserAgent, LyricsComError, notFound, throttled } from \"../errors.js\";\nimport { classifyResponse } from \"./parsers/detectThrottle.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;\n const capped = Math.min(BACKOFF_MAX_MS, uncapped);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The whole retry loop, backoff sleeps included, runs inside a single limiter\n * slot. Waiting outside it would let a queued request slip into the same\n * throttling window that the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: LyricsComError | undefined;\n\n // Set when the site says how long to stay away; it replaces our own guess\n // for the next attempt. Applied here rather than where it is read, so no\n // wait is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.9,fr;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n const verdict = classifyResponse(status, body);\n switch (verdict.kind) {\n case \"ok\":\n limiter.relax();\n return verdict.html;\n\n case \"throttled\":\n limiter.penalize();\n logger.info(\n `throttled (${verdict.reason}) on ${url}, interval now ${limiter.currentIntervalMs}ms`,\n );\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = throttled(url, retryAfterMs ?? backoffDelay(attempt));\n continue;\n\n case \"server-error\":\n lastError = new LyricsComError(\n \"network_error\",\n `lyrics.com returned HTTP ${verdict.status}.`,\n {\n url,\n status: verdict.status,\n },\n );\n continue;\n\n // Retrying these would only repeat the same answer.\n case \"blocked\":\n throw blockedUserAgent(url);\n case \"not-found\":\n throw notFound(url);\n }\n }\n\n throw lastError ?? new LyricsComError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): LyricsComError {\n if (error instanceof LyricsComError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new LyricsComError(\"timeout\", \"lyrics.com did not answer in time.\", {\n url,\n hint: \"Raise LYRICSCOM_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new LyricsComError(\"network_error\", `Could not reach lyrics.com: ${message}`, { url });\n}\n","/**\n * Parser for a lyrics.com search results page.\n *\n * Page 1 (`/lyrics/{term}`) and later pages (`/serp.php?st=...&p=N`) share the\n * same markup, so one parser covers both.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SearchPage, SongResult } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { extractSongId, RESULTS_PER_PAGE, toAbsoluteUrl } from \"../urls.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSearchOptions {\n page: number;\n /** Reported in errors so a failure points at the request that caused it. */\n url: string;\n}\n\nexport function parseSearchResults(html: string, options: ParseSearchOptions): SearchPage {\n const $ = cheerio.load(html);\n const rows = $(SEL.serpItem);\n\n if (rows.length === 0) {\n // An empty result set still renders the page chrome. Its absence means the\n // response was not the page we asked for, which must not be reported as\n // \"no songs found\".\n if ($(SEL.serpShell).length === 0) {\n throw parseFailure(options.url, \"no result rows and no recognizable search page structure\");\n }\n return { results: [], page: options.page, rawCount: 0, hasMore: false };\n }\n\n const results: SongResult[] = [];\n let skipped = 0;\n\n rows.each((_, element) => {\n const row = $(element);\n const titleLink = row.find(SEL.resultTitle).first();\n const href = titleLink.attr(\"href\");\n const title = cleanInlineText(titleLink.text());\n const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());\n\n const id = href ? extractSongId(href) : null;\n if (!id || !title || !artist) {\n skipped += 1;\n return;\n }\n\n const album = cleanInlineText(row.find(SEL.resultAlbum).first().text()) || null;\n const year = parseYear(row.find(SEL.resultYear).first().text());\n const snippetRaw = row.find(SEL.resultSnippet).first().text();\n const snippet = snippetRaw ? cleanLyricsText(snippetRaw) || null : null;\n\n results.push({\n id,\n url: toAbsoluteUrl(href as string),\n title,\n artist,\n album,\n year,\n snippet,\n });\n });\n\n if (results.length === 0) {\n throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(\n `[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}\\n`,\n );\n }\n\n return {\n results,\n page: options.page,\n rawCount: rows.length,\n hasMore: rows.length >= RESULTS_PER_PAGE,\n };\n}\n\n/** Year cells look like \"1971\" but are sometimes decorated or empty. */\nfunction parseYear(raw: string): number | null {\n const match = /\\b(1[89]\\d{2}|20\\d{2})\\b/.exec(raw);\n if (!match?.[1]) return null;\n return Number.parseInt(match[1], 10);\n}\n","/**\n * Text cleaning for scraped content: HTML entities, mojibake, whitespace.\n */\n\nconst HTML_ENTITIES: Record<string, string> = {\n amp: \"&\",\n lt: \"<\",\n gt: \">\",\n quot: '\"',\n apos: \"'\",\n nbsp: \" \",\n hellip: \"…\",\n mdash: \"—\",\n ndash: \"–\",\n rsquo: \"’\",\n lsquo: \"‘\",\n ldquo: \"“\",\n rdquo: \"”\",\n};\n\n/** Decode named and numeric HTML entities. */\nexport function unescapeHtml(text: string): string {\n if (!text) return text;\n return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity: string) => {\n if (entity.startsWith(\"#\")) {\n const isHex = entity[1] === \"x\" || entity[1] === \"X\";\n const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);\n if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;\n try {\n return String.fromCodePoint(codePoint);\n } catch {\n return match;\n }\n }\n return HTML_ENTITIES[entity.toLowerCase()] ?? match;\n });\n}\n\n/**\n * Repair UTF-8 bytes that were decoded as Latin-1, which turns \"é\" into \"é\".\n * lyrics.com serves French titles that occasionally arrive in this state.\n * Re-encoding through latin1 recovers the original text; the round trip is only\n * kept when it removes replacement characters, so correct text is never touched.\n */\nexport function fixEncodingIssues(text: string): string {\n if (!text || !/[ÃÂ]/.test(text)) return text;\n try {\n const repaired = Buffer.from(text, \"latin1\").toString(\"utf8\");\n if (repaired.includes(\"�\")) return text;\n return repaired;\n } catch {\n return text;\n }\n}\n\n/** Collapse runs of whitespace and trim. */\nexport function collapseWhitespace(text: string): string {\n return text.replace(/\\s+/g, \" \").trim();\n}\n\n/** Full cleaning pass for a scraped title or artist name. */\nexport function cleanInlineText(text: string): string {\n if (!text) return \"\";\n return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));\n}\n\n/**\n * Normalize a lyrics block: trim each line, drop leading and trailing blank\n * lines, and collapse runs of blank lines to a single verse separator.\n */\nexport function cleanLyricsText(text: string): string {\n if (!text) return \"\";\n const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\\r\\n?/g, \"\\n\");\n const lines = normalized.split(\"\\n\").map((line) => line.trim());\n\n const out: string[] = [];\n for (const line of lines) {\n if (line === \"\" && (out.length === 0 || out[out.length - 1] === \"\")) continue;\n out.push(line);\n }\n while (out.length > 0 && out[out.length - 1] === \"\") out.pop();\n return out.join(\"\\n\");\n}\n","/**\n * URL construction and validation for lyrics.com.\n */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.lyrics.com\";\n\n/** lyrics.com renders 24 rows per search page. */\nexport const RESULTS_PER_PAGE = 24;\n\n/**\n * Song page paths come in two shapes: the current `/lyric-lf/{id}/...` and the\n * older `/lyric/{id}/...` still present in archived links.\n */\nexport const SONG_HREF_RE = /^\\/lyric(?:-lf)?\\/(\\d+)\\//;\n\nconst ALLOWED_HOSTS = new Set([\"lyrics.com\", \"www.lyrics.com\"]);\n\n/**\n * Percent-encode a search term the way lyrics.com expects, with spaces as `+`.\n */\nfunction encodeTerm(term: string): string {\n return encodeURIComponent(term).replace(/%20/g, \"+\");\n}\n\n/**\n * Page 1 lives at a path, later pages at a query string. Both return the same\n * markup, so the parser does not need to know which one produced it.\n */\nexport function buildSearchUrl(term: string, page: number): string {\n const encoded = encodeTerm(term);\n if (page <= 1) return `${BASE_URL}/lyrics/${encoded}`;\n return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;\n}\n\nexport function buildSongUrl(id: string): string {\n return `${BASE_URL}/lyric-lf/${id}/`;\n}\n\n/** True only for lyrics.com itself, so a hostile URL cannot be used as a proxy. */\nexport function isLyricsComHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric song id from a lyrics.com song path or absolute URL. */\nexport function extractSongId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isLyricsComHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n if (!path.startsWith(\"/\")) path = `/${path}`;\n const match = SONG_HREF_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/**\n * Resolve the `id` / `url` pair accepted by get_lyrics into a canonical\n * request URL. `id` wins when both are given.\n */\nexport function resolveSongRef(input: { id?: string; url?: string }): { id: string; url: string } {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a lyrics.com song id.`,\n \"Ids are digits only, as returned by the search tools.\",\n );\n }\n return { id: input.id, url: buildSongUrl(input.id) };\n }\n\n if (input.url) {\n if (!isLyricsComHost(input.url)) {\n throw invalidInput(\n \"Only www.lyrics.com URLs are accepted.\",\n \"Pass the source_url returned by search_lyrics or search_songs, or use the numeric id instead.\",\n );\n }\n const id = extractSongId(input.url);\n if (!id) {\n throw invalidInput(\n \"That lyrics.com URL is not a song page.\",\n \"Song pages look like https://www.lyrics.com/lyric-lf/1234567/Artist/Title\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\n/** Absolute URL for a href scraped out of a result row. */\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Every lyrics.com CSS selector lives here, so a site redesign is a one-file fix.\n *\n * Selectors deliberately target a single stable class rather than a full class\n * list (`div.sec-lyric`, not `div.sec-lyric.clearfix`), so that a layout tweak\n * adding or removing a utility class does not break extraction.\n */\n\nexport const SEL = {\n /** One search result row. */\n serpItem: \"div.sec-lyric\",\n /**\n * Page chrome that is present whether or not there are results. Used to tell\n * \"zero results\" apart from \"the markup changed\", which must not look alike.\n */\n serpShell: \"#lyric-search, .lyric-search, .pagination, #main-content, form[action*='serp']\",\n resultTitle: \"p.lyric-meta-title a\",\n /**\n * Rows tied to an album expose the artist under `lyric-meta-album-artist`,\n * rows without one under `lyric-meta-artists`. Both shapes appear on the same\n * page, so missing the second silently drops results.\n */\n resultArtist: \"p.lyric-meta-album-artist a, p.lyric-meta-artists a\",\n resultAlbum: \"p.lyric-meta-album\",\n resultYear: \"p.lyric-meta-album-year\",\n resultSnippet: \"pre.lyric-body\",\n\n /** Full lyrics on a song page, with the historical fallback. */\n songBody: \"pre#lyric-body-text, pre.lyric-body\",\n /** Marker for a valid song page that simply has no lyrics on file. */\n songNoData: \"div.lyric-no-data\",\n songTitle: \"#lyric-title-text, .lyric-title\",\n songArtist: \".lyric-artist a, .lyric-artist\",\n} as const;\n","/**\n * Parser for a lyrics.com song page.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SongPage } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSongOptions {\n id: string;\n url: string;\n}\n\nexport function parseSongPage(html: string, options: ParseSongOptions): SongPage {\n const $ = cheerio.load(html);\n\n const title = cleanInlineText($(SEL.songTitle).first().text()) || null;\n const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;\n\n const body = $(SEL.songBody).first();\n if (body.length === 0) {\n // A valid song page can legitimately carry no lyrics. That is an answer,\n // not a failure, so it is only treated as breakage when the page says\n // nothing about why the text is missing.\n const declaresNoLyrics =\n $(SEL.songNoData).length > 0 || /no lyrics found/i.test($(\"title\").text());\n if (!declaresNoLyrics) {\n throw parseFailure(options.url, \"no lyrics container and no 'no lyrics' marker\");\n }\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n const lyrics = cleanLyricsText(extractBodyText($, body));\n if (!lyrics) {\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n return { id: options.id, url: options.url, title, artist, lyrics, hasLyrics: true };\n}\n\n/**\n * lyrics.com wraps many individual words in links to its dictionary pages.\n * Those anchors are unwrapped in the raw HTML rather than through the DOM,\n * because DOM-level removal loses the line breaks that give lyrics their shape.\n */\nfunction extractBodyText($: cheerio.CheerioAPI, body: ReturnType<cheerio.CheerioAPI>): string {\n const rawHtml = $.html(body);\n const withoutAnchors = rawHtml.replace(/<a[^>]*>([\\s\\S]*?)<\\/a>/gi, \"$1\");\n const withNewlines = withoutAnchors.replace(/<br\\s*\\/?>/gi, \"\\n\");\n return cheerio.load(withNewlines).root().text();\n}\n","/**\n * High-level lyrics.com client.\n *\n * This module knows nothing about MCP, which keeps it unit-testable against\n * plain strings and usable as a plain library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type { SearchPage, SongPage } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseSearchResults } from \"./parsers/searchResults.js\";\nimport { parseSongPage } from \"./parsers/songPage.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveSongRef } from \"./urls.js\";\n\nexport interface LyricsComClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface FetchOutcome<T> {\n data: T;\n /** True when the page came from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `LyricsComClient` is published as a\n * library through the `./client` export and takes a caller-built config, so\n * without this the pacing floor and the honest identity are optional for anyone\n * importing it. lyrics.com answers a burst by refusing, and an honest User-Agent is what it accepts, and those promises hold on every path.\n *\n * A caller may still name their own application in the User-Agent. Passing the\n * traffic off as a browser is a different thing, and gets the project's own\n * identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class LyricsComClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<unknown>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: LyricsComClientOptions = {}) {\n this.config = withGuarantees(options.config ?? loadConfig());\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<unknown>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n async search(term: string, page: number): Promise<FetchOutcome<SearchPage>> {\n const url = buildSearchUrl(term, page);\n return this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));\n }\n\n async getSong(ref: { id?: string; url?: string }): Promise<FetchOutcome<SongPage>> {\n const { id, url } = resolveSongRef(ref);\n return this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at lyrics.com cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<FetchOutcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n\n const data = parse(html);\n this.cache.set(url, data);\n return { data, cached: false };\n }\n}\n"],"mappings":"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","names":["cheerio"]}
|
|
1
|
+
{"version":3,"sources":["../../src/version.ts","../../src/config.ts","../../src/lyricscom/cache.ts","../../src/errors.ts","../../src/lyricscom/parsers/detectThrottle.ts","../../src/lyricscom/rateLimiter.ts","../../src/lyricscom/http.ts","../../src/lyricscom/parsers/searchResults.ts","../../src/text/normalize.ts","../../src/lyricscom/urls.ts","../../src/lyricscom/parsers/selectors.ts","../../src/lyricscom/parsers/songPage.ts","../../src/lyricscom/client.ts"],"sourcesContent":["/**\n * Kept in sync with package.json. The User-Agent embeds it, so it must not\n * drift; the release checklist bumps both together.\n */\nexport const PKG_VERSION = \"1.3.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-lyricscom\";\n","/**\n * Runtime configuration, read from environment variables.\n *\n * A bad value never crashes the process: an MCP server that dies at startup\n * because of a typo in a client config file is very hard to diagnose from the\n * host application, so invalid input is clamped and reported on stderr.\n */\n\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport type LogLevel = \"silent\" | \"error\" | \"info\" | \"debug\";\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\n/**\n * Identifies the client honestly. lyrics.com serves this fine; it blocks\n * generic tool agents such as `curl/x.y.z`, which is what the override is for.\n */\nexport const DEFAULT_USER_AGENT = `mcp-lyricscom/${PKG_VERSION} (+${REPO_URL})`;\n\nexport const DEFAULTS = {\n minIntervalMs: 1100,\n timeoutMs: 15_000,\n maxRetries: 3,\n cacheTtlMs: 15 * 60 * 1000,\n cacheMaxEntries: 200,\n logLevel: \"error\" as LogLevel,\n};\n\n/**\n * Floor on the request interval, enforced regardless of configuration.\n *\n * The pacing is what keeps this client from behaving like a crawler against a\n * site that has no API and no published rate limit. Leaving it configurable down\n * to zero would make the politeness of every installation depend on whoever\n * edited a JSON file, so a value below this floor is refused and the default is\n * used instead.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n\nconst LOG_LEVELS: LogLevel[] = [\"silent\", \"error\", \"info\", \"debug\"];\n\ninterface NumericRange {\n min: number;\n max: number;\n fallback: number;\n}\n\nfunction readNumber(name: string, env: NodeJS.ProcessEnv, range: NumericRange): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") return range.fallback;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`${name}=\"${raw}\" is not a number, using ${range.fallback}`);\n return range.fallback;\n }\n const clamped = Math.min(range.max, Math.max(range.min, Math.round(parsed)));\n if (clamped !== Math.round(parsed)) {\n warn(`${name}=${raw} is out of range, clamped to ${clamped}`);\n }\n return clamped;\n}\n\nfunction warn(message: string): void {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n}\n\n/**\n * Read the request interval, refusing anything below the floor.\n *\n * A value under the floor falls back to the default rather than to the floor\n * itself: someone who set 0 was not asking for 500, they were asking for no\n * pacing at all, and the safe reading of that request is to ignore it.\n */\nfunction readInterval(env: NodeJS.ProcessEnv): number {\n const raw = env.LYRICSCOM_MIN_INTERVAL_MS;\n if (raw === undefined || raw.trim() === \"\") return DEFAULTS.minIntervalMs;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed)) {\n warn(`LYRICSCOM_MIN_INTERVAL_MS=\"${raw}\" is not a number, using ${DEFAULTS.minIntervalMs}ms`);\n return DEFAULTS.minIntervalMs;\n }\n\n const rounded = Math.round(parsed);\n if (rounded < MIN_ALLOWED_INTERVAL_MS) {\n warn(\n `LYRICSCOM_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; ` +\n `using ${DEFAULTS.minIntervalMs}ms. This floor keeps the client from hammering lyrics.com.`,\n );\n return DEFAULTS.minIntervalMs;\n }\n\n return Math.min(60_000, rounded);\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const rawUserAgent = env.LYRICSCOM_USER_AGENT?.trim();\n const rawLogLevel = env.LYRICSCOM_LOG_LEVEL?.trim().toLowerCase();\n\n let logLevel = DEFAULTS.logLevel;\n if (rawLogLevel) {\n if (LOG_LEVELS.includes(rawLogLevel as LogLevel)) {\n logLevel = rawLogLevel as LogLevel;\n } else {\n warn(`LYRICSCOM_LOG_LEVEL=\"${rawLogLevel}\" is unknown, using \"${DEFAULTS.logLevel}\"`);\n }\n }\n\n return {\n userAgent: rawUserAgent || DEFAULT_USER_AGENT,\n minIntervalMs: readInterval(env),\n timeoutMs: readNumber(\"LYRICSCOM_TIMEOUT_MS\", env, {\n min: 1000,\n max: 120_000,\n fallback: DEFAULTS.timeoutMs,\n }),\n maxRetries: readNumber(\"LYRICSCOM_MAX_RETRIES\", env, {\n min: 0,\n max: 10,\n fallback: DEFAULTS.maxRetries,\n }),\n cacheTtlMs: readNumber(\"LYRICSCOM_CACHE_TTL_MS\", env, {\n min: 0,\n max: 24 * 60 * 60 * 1000,\n fallback: DEFAULTS.cacheTtlMs,\n }),\n cacheMaxEntries: readNumber(\"LYRICSCOM_CACHE_MAX_ENTRIES\", env, {\n min: 0,\n max: 10_000,\n fallback: DEFAULTS.cacheMaxEntries,\n }),\n logLevel,\n };\n}\n\nconst LEVEL_RANK: Record<LogLevel, number> = { silent: 0, error: 1, info: 2, debug: 3 };\n\n/**\n * Logs go to stderr without exception. On a stdio transport, stdout carries the\n * protocol and any stray write there corrupts the session.\n */\nexport function createLogger(level: LogLevel) {\n const emit = (at: LogLevel, message: string) => {\n if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {\n process.stderr.write(`[mcp-lyricscom] ${message}\\n`);\n }\n };\n return {\n error: (message: string) => emit(\"error\", message),\n info: (message: string) => emit(\"info\", message),\n debug: (message: string) => emit(\"debug\", message),\n };\n}\n\nexport type Logger = ReturnType<typeof createLogger>;\n","/**\n * In-memory TTL + LRU cache.\n *\n * Nothing is written to disk. The cache exists so that repeated questions about\n * the same song inside one conversation do not hit lyrics.com again, not to\n * build a local copy of the site.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) return;\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) break;\n this.store.delete(oldest.value);\n }\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Error taxonomy surfaced to the calling model.\n *\n * The point of this file is that an LLM must be able to tell \"lyrics.com pushed\n * back on me\" apart from \"there is no such song\". The reference implementation\n * this server replaces collapsed every failure into an empty list, which made\n * rate limiting look like an absence of results.\n */\n\nexport type ErrorCode =\n | \"throttled\"\n | \"blocked_user_agent\"\n | \"not_found\"\n | \"invalid_input\"\n | \"parse_failure\"\n | \"network_error\"\n | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class LyricsComError extends Error {\n constructor(\n readonly code: ErrorCode,\n message: string,\n readonly details: ErrorDetails = {},\n ) {\n super(message);\n this.name = \"LyricsComError\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-lyricscom/issues\";\n\nexport function throttled(url: string, retryAfterMs: number): LyricsComError {\n return new LyricsComError(\n \"throttled\",\n \"lyrics.com is rate limiting this client. It answered with an empty response instead of a page. \" +\n \"This does NOT mean there are no results for this query.\",\n {\n url,\n retryAfterMs,\n hint:\n \"Wait at least a minute before calling the same tool again with the same arguments. Once lyrics.com \" +\n \"starts throttling, the window commonly lasts several minutes, so retrying immediately will fail again. \" +\n \"If it keeps happening, raise LYRICSCOM_MIN_INTERVAL_MS in your MCP client configuration.\",\n },\n );\n}\n\nexport function blockedUserAgent(url: string): LyricsComError {\n return new LyricsComError(\n \"blocked_user_agent\",\n \"lyrics.com refused the request because of the User-Agent this server sends (HTTP 403).\",\n {\n url,\n status: 403,\n hint:\n \"Set the LYRICSCOM_USER_AGENT environment variable in your MCP client configuration to a different \" +\n \"value. See the Configuration section of the project README.\",\n },\n );\n}\n\nexport function notFound(url: string): LyricsComError {\n return new LyricsComError(\"not_found\", \"lyrics.com has no page at this address.\", {\n url,\n status: 404,\n hint: \"Check the song id. Ids come from the search tools; they are not stable to guess by hand.\",\n });\n}\n\nexport function parseFailure(url: string, what: string): LyricsComError {\n return new LyricsComError(\n \"parse_failure\",\n `The page loaded but the expected lyrics.com markup was not found (${what}). The site layout may have changed.`,\n { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },\n );\n}\n\nexport function invalidInput(message: string, hint?: string): LyricsComError {\n return new LyricsComError(\"invalid_input\", message, hint ? { hint } : {});\n}\n","/**\n * Response classification.\n *\n * lyrics.com does not answer rate limiting with 429. It answers with HTTP 202\n * and a zero-byte body, which a naive client reads as a successful but empty\n * page. Every response therefore goes through this classifier before any\n * parsing happens.\n */\n\nexport type ResponseVerdict =\n | { kind: \"ok\"; html: string }\n | { kind: \"throttled\"; reason: ThrottleReason }\n | { kind: \"blocked\"; status: number }\n | { kind: \"not-found\" }\n | { kind: \"server-error\"; status: number };\n\nexport type ThrottleReason =\n \"status-202\" | \"status-429\" | \"status-503\" | \"empty-body\" | \"short-body\";\n\n/**\n * A real search page weighs about 187 KB and a song page 60 KB or more, so a\n * body this small is never a genuine lyrics.com page.\n */\nexport const MIN_PLAUSIBLE_HTML = 2000;\n\nexport function classifyResponse(status: number, body: string): ResponseVerdict {\n if (status === 202) return { kind: \"throttled\", reason: \"status-202\" };\n if (status === 429) return { kind: \"throttled\", reason: \"status-429\" };\n if (status === 503) return { kind: \"throttled\", reason: \"status-503\" };\n if (status === 403) return { kind: \"blocked\", status };\n if (status === 404) return { kind: \"not-found\" };\n if (status >= 500) return { kind: \"server-error\", status };\n\n const trimmed = body.trim();\n if (trimmed.length === 0) return { kind: \"throttled\", reason: \"empty-body\" };\n if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\\/html>/i.test(trimmed)) {\n return { kind: \"throttled\", reason: \"short-body\" };\n }\n\n return { kind: \"ok\", html: body };\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * lyrics.com tolerates a steady trickle but stops answering after a short burst,\n * so requests are run one at a time with a floor on the gap between starts.\n * When the site does push back, the interval doubles and then decays back down\n * as requests succeed, which recovers faster than a fixed delay and behaves\n * better than hammering at a constant rate.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 10_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site signals it is under load. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so a single hiccup does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) return;\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards, by NTP or a resumed\n // virtual machine, would otherwise make this wait for the size of the step,\n // and the queue is serial so every pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) await sleep(remaining);\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * HTTP layer: one GET, classified, with backoff.\n *\n * The retry policy is domain specific rather than generic, because the failure\n * this server exists to handle does not look like a failure at the transport\n * level: lyrics.com answers a throttled request with HTTP 202 and an empty\n * body. Only `classifyResponse` can tell that apart from a real page.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { blockedUserAgent, LyricsComError, notFound, throttled } from \"../errors.js\";\nimport { classifyResponse } from \"./parsers/detectThrottle.js\";\nimport { RateLimiter, sleep } from \"./rateLimiter.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 20_000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;\n const capped = Math.min(BACKOFF_MAX_MS, uncapped);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one page as HTML, retrying transient conditions.\n *\n * The whole retry loop, backoff sleeps included, runs inside a single limiter\n * slot. Waiting outside it would let a queued request slip into the same\n * throttling window that the current one is backing away from.\n */\nexport async function fetchHtml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n\n return limiter.schedule(async () => {\n let lastError: LyricsComError | undefined;\n\n // Set when the site says how long to stay away; it replaces our own guess\n // for the next attempt. Applied here rather than where it is read, so no\n // wait is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.9,fr;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, url);\n logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);\n continue;\n }\n\n const verdict = classifyResponse(status, body);\n switch (verdict.kind) {\n case \"ok\":\n limiter.relax();\n return verdict.html;\n\n case \"throttled\":\n limiter.penalize();\n logger.info(\n `throttled (${verdict.reason}) on ${url}, interval now ${limiter.currentIntervalMs}ms`,\n );\n // A server that says when to come back knows better than our own guess.\n askedWaitMs = retryAfterMs;\n lastError = throttled(url, retryAfterMs ?? backoffDelay(attempt));\n continue;\n\n case \"server-error\":\n lastError = new LyricsComError(\n \"network_error\",\n `lyrics.com returned HTTP ${verdict.status}.`,\n {\n url,\n status: verdict.status,\n },\n );\n continue;\n\n // Retrying these would only repeat the same answer.\n case \"blocked\":\n throw blockedUserAgent(url);\n case \"not-found\":\n throw notFound(url);\n }\n }\n\n throw lastError ?? new LyricsComError(\"network_error\", `Could not fetch ${url}.`, { url });\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) return null;\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);\n const when = Date.parse(raw);\n if (Number.isNaN(when)) return null;\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, url: string): LyricsComError {\n if (error instanceof LyricsComError) return error;\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new LyricsComError(\"timeout\", \"lyrics.com did not answer in time.\", {\n url,\n hint: \"Raise LYRICSCOM_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new LyricsComError(\"network_error\", `Could not reach lyrics.com: ${message}`, { url });\n}\n","/**\n * Parser for a lyrics.com search results page.\n *\n * Page 1 (`/lyrics/{term}`) and later pages (`/serp.php?st=...&p=N`) share the\n * same markup, so one parser covers both.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SearchPage, SongResult } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { extractSongId, RESULTS_PER_PAGE, toAbsoluteUrl } from \"../urls.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSearchOptions {\n page: number;\n /** Reported in errors so a failure points at the request that caused it. */\n url: string;\n}\n\nexport function parseSearchResults(html: string, options: ParseSearchOptions): SearchPage {\n const $ = cheerio.load(html);\n const rows = $(SEL.serpItem);\n\n if (rows.length === 0) {\n // An empty result set still renders the page chrome. Its absence means the\n // response was not the page we asked for, which must not be reported as\n // \"no songs found\".\n if ($(SEL.serpShell).length === 0) {\n throw parseFailure(options.url, \"no result rows and no recognizable search page structure\");\n }\n return { results: [], page: options.page, rawCount: 0, hasMore: false };\n }\n\n const results: SongResult[] = [];\n let skipped = 0;\n\n rows.each((_, element) => {\n const row = $(element);\n const titleLink = row.find(SEL.resultTitle).first();\n const href = titleLink.attr(\"href\");\n const title = cleanInlineText(titleLink.text());\n const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());\n\n const id = href ? extractSongId(href) : null;\n if (!id || !title || !artist) {\n skipped += 1;\n return;\n }\n\n const album = cleanInlineText(row.find(SEL.resultAlbum).first().text()) || null;\n const year = parseYear(row.find(SEL.resultYear).first().text());\n const snippetRaw = row.find(SEL.resultSnippet).first().text();\n const snippet = snippetRaw ? cleanLyricsText(snippetRaw) || null : null;\n\n results.push({\n id,\n url: toAbsoluteUrl(href as string),\n title,\n artist,\n album,\n year,\n snippet,\n });\n });\n\n if (results.length === 0) {\n throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);\n }\n if (skipped > 0) {\n process.stderr.write(\n `[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}\\n`,\n );\n }\n\n return {\n results,\n page: options.page,\n rawCount: rows.length,\n hasMore: rows.length >= RESULTS_PER_PAGE,\n };\n}\n\n/** Year cells look like \"1971\" but are sometimes decorated or empty. */\nfunction parseYear(raw: string): number | null {\n const match = /\\b(1[89]\\d{2}|20\\d{2})\\b/.exec(raw);\n if (!match?.[1]) return null;\n return Number.parseInt(match[1], 10);\n}\n","/**\n * Text cleaning for scraped content: HTML entities, mojibake, whitespace.\n */\n\nconst HTML_ENTITIES: Record<string, string> = {\n amp: \"&\",\n lt: \"<\",\n gt: \">\",\n quot: '\"',\n apos: \"'\",\n nbsp: \" \",\n hellip: \"…\",\n mdash: \"—\",\n ndash: \"–\",\n rsquo: \"’\",\n lsquo: \"‘\",\n ldquo: \"“\",\n rdquo: \"”\",\n};\n\n/** Decode named and numeric HTML entities. */\nexport function unescapeHtml(text: string): string {\n if (!text) return text;\n return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity: string) => {\n if (entity.startsWith(\"#\")) {\n const isHex = entity[1] === \"x\" || entity[1] === \"X\";\n const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);\n if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;\n try {\n return String.fromCodePoint(codePoint);\n } catch {\n return match;\n }\n }\n return HTML_ENTITIES[entity.toLowerCase()] ?? match;\n });\n}\n\n/**\n * Repair UTF-8 bytes that were decoded as Latin-1, which turns \"é\" into \"é\".\n * lyrics.com serves French titles that occasionally arrive in this state.\n * Re-encoding through latin1 recovers the original text; the round trip is only\n * kept when it removes replacement characters, so correct text is never touched.\n */\nexport function fixEncodingIssues(text: string): string {\n if (!text || !/[ÃÂ]/.test(text)) return text;\n try {\n const repaired = Buffer.from(text, \"latin1\").toString(\"utf8\");\n if (repaired.includes(\"�\")) return text;\n return repaired;\n } catch {\n return text;\n }\n}\n\n/** Collapse runs of whitespace and trim. */\nexport function collapseWhitespace(text: string): string {\n return text.replace(/\\s+/g, \" \").trim();\n}\n\n/** Full cleaning pass for a scraped title or artist name. */\nexport function cleanInlineText(text: string): string {\n if (!text) return \"\";\n return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));\n}\n\n/**\n * Normalize a lyrics block: trim each line, drop leading and trailing blank\n * lines, and collapse runs of blank lines to a single verse separator.\n */\nexport function cleanLyricsText(text: string): string {\n if (!text) return \"\";\n const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\\r\\n?/g, \"\\n\");\n const lines = normalized.split(\"\\n\").map((line) => line.trim());\n\n const out: string[] = [];\n for (const line of lines) {\n if (line === \"\" && (out.length === 0 || out[out.length - 1] === \"\")) continue;\n out.push(line);\n }\n while (out.length > 0 && out[out.length - 1] === \"\") out.pop();\n return out.join(\"\\n\");\n}\n","/**\n * URL construction and validation for lyrics.com.\n */\n\nimport { invalidInput } from \"../errors.js\";\n\nexport const BASE_URL = \"https://www.lyrics.com\";\n\n/** lyrics.com renders 24 rows per search page. */\nexport const RESULTS_PER_PAGE = 24;\n\n/**\n * Song page paths come in two shapes: the current `/lyric-lf/{id}/...` and the\n * older `/lyric/{id}/...` still present in archived links.\n */\nexport const SONG_HREF_RE = /^\\/lyric(?:-lf)?\\/(\\d+)\\//;\n\nconst ALLOWED_HOSTS = new Set([\"lyrics.com\", \"www.lyrics.com\"]);\n\n/**\n * Percent-encode a search term the way lyrics.com expects, with spaces as `+`.\n */\nfunction encodeTerm(term: string): string {\n return encodeURIComponent(term).replace(/%20/g, \"+\");\n}\n\n/**\n * Page 1 lives at a path, later pages at a query string. Both return the same\n * markup, so the parser does not need to know which one produced it.\n */\nexport function buildSearchUrl(term: string, page: number): string {\n const encoded = encodeTerm(term);\n if (page <= 1) return `${BASE_URL}/lyrics/${encoded}`;\n return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;\n}\n\nexport function buildSongUrl(id: string): string {\n return `${BASE_URL}/lyric-lf/${id}/`;\n}\n\n/** True only for lyrics.com itself, so a hostile URL cannot be used as a proxy. */\nexport function isLyricsComHost(rawUrl: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return false;\n return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());\n}\n\n/** Extract the numeric song id from a lyrics.com song path or absolute URL. */\nexport function extractSongId(hrefOrUrl: string): string | null {\n let path = hrefOrUrl;\n if (/^https?:\\/\\//i.test(hrefOrUrl)) {\n if (!isLyricsComHost(hrefOrUrl)) return null;\n path = new URL(hrefOrUrl).pathname;\n }\n if (!path.startsWith(\"/\")) path = `/${path}`;\n const match = SONG_HREF_RE.exec(path);\n return match?.[1] ?? null;\n}\n\n/**\n * Resolve the `id` / `url` pair accepted by get_lyrics into a canonical\n * request URL. `id` wins when both are given.\n */\nexport function resolveSongRef(input: { id?: string; url?: string }): { id: string; url: string } {\n if (input.id) {\n if (!/^\\d+$/.test(input.id)) {\n throw invalidInput(\n `\"${input.id}\" is not a lyrics.com song id.`,\n \"Ids are digits only, as returned by the search tools.\",\n );\n }\n return { id: input.id, url: buildSongUrl(input.id) };\n }\n\n if (input.url) {\n if (!isLyricsComHost(input.url)) {\n throw invalidInput(\n \"Only www.lyrics.com URLs are accepted.\",\n \"Pass the source_url returned by search_lyrics or search_songs, or use the numeric id instead.\",\n );\n }\n const id = extractSongId(input.url);\n if (!id) {\n throw invalidInput(\n \"That lyrics.com URL is not a song page.\",\n \"Song pages look like https://www.lyrics.com/lyric-lf/1234567/Artist/Title\",\n );\n }\n return { id, url: input.url };\n }\n\n throw invalidInput(\"Either 'id' or 'url' must be provided.\");\n}\n\n/** Absolute URL for a href scraped out of a result row. */\nexport function toAbsoluteUrl(href: string): string {\n return new URL(href, BASE_URL).toString();\n}\n","/**\n * Every lyrics.com CSS selector lives here, so a site redesign is a one-file fix.\n *\n * Selectors deliberately target a single stable class rather than a full class\n * list (`div.sec-lyric`, not `div.sec-lyric.clearfix`), so that a layout tweak\n * adding or removing a utility class does not break extraction.\n */\n\nexport const SEL = {\n /** One search result row. */\n serpItem: \"div.sec-lyric\",\n /**\n * Page chrome that is present whether or not there are results. Used to tell\n * \"zero results\" apart from \"the markup changed\", which must not look alike.\n */\n serpShell: \"#lyric-search, .lyric-search, .pagination, #main-content, form[action*='serp']\",\n resultTitle: \"p.lyric-meta-title a\",\n /**\n * Rows tied to an album expose the artist under `lyric-meta-album-artist`,\n * rows without one under `lyric-meta-artists`. Both shapes appear on the same\n * page, so missing the second silently drops results.\n */\n resultArtist: \"p.lyric-meta-album-artist a, p.lyric-meta-artists a\",\n resultAlbum: \"p.lyric-meta-album\",\n resultYear: \"p.lyric-meta-album-year\",\n resultSnippet: \"pre.lyric-body\",\n\n /** Full lyrics on a song page, with the historical fallback. */\n songBody: \"pre#lyric-body-text, pre.lyric-body\",\n /** Marker for a valid song page that simply has no lyrics on file. */\n songNoData: \"div.lyric-no-data\",\n songTitle: \"#lyric-title-text, .lyric-title\",\n songArtist: \".lyric-artist a, .lyric-artist\",\n} as const;\n","/**\n * Parser for a lyrics.com song page.\n */\n\nimport * as cheerio from \"cheerio\";\nimport { parseFailure } from \"../../errors.js\";\nimport type { SongPage } from \"../../types.js\";\nimport { cleanInlineText, cleanLyricsText } from \"../../text/normalize.js\";\nimport { SEL } from \"./selectors.js\";\n\nexport interface ParseSongOptions {\n id: string;\n url: string;\n}\n\nexport function parseSongPage(html: string, options: ParseSongOptions): SongPage {\n const $ = cheerio.load(html);\n\n const title = cleanInlineText($(SEL.songTitle).first().text()) || null;\n const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;\n\n const body = $(SEL.songBody).first();\n if (body.length === 0) {\n // A valid song page can legitimately carry no lyrics. That is an answer,\n // not a failure, so it is only treated as breakage when the page says\n // nothing about why the text is missing.\n const declaresNoLyrics =\n $(SEL.songNoData).length > 0 || /no lyrics found/i.test($(\"title\").text());\n if (!declaresNoLyrics) {\n throw parseFailure(options.url, \"no lyrics container and no 'no lyrics' marker\");\n }\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n const lyrics = cleanLyricsText(extractBodyText($, body));\n if (!lyrics) {\n return { id: options.id, url: options.url, title, artist, lyrics: \"\", hasLyrics: false };\n }\n\n return { id: options.id, url: options.url, title, artist, lyrics, hasLyrics: true };\n}\n\n/**\n * lyrics.com wraps many individual words in links to its dictionary pages.\n * Those anchors are unwrapped in the raw HTML rather than through the DOM,\n * because DOM-level removal loses the line breaks that give lyrics their shape.\n */\nfunction extractBodyText($: cheerio.CheerioAPI, body: ReturnType<cheerio.CheerioAPI>): string {\n const rawHtml = $.html(body);\n const withoutAnchors = rawHtml.replace(/<a[^>]*>([\\s\\S]*?)<\\/a>/gi, \"$1\");\n const withNewlines = withoutAnchors.replace(/<br\\s*\\/?>/gi, \"\\n\");\n return cheerio.load(withNewlines).root().text();\n}\n","/**\n * High-level lyrics.com client.\n *\n * This module knows nothing about MCP, which keeps it unit-testable against\n * plain strings and usable as a plain library through the `./client` export.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n DEFAULT_USER_AGENT,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport type { SearchPage, SongPage } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchHtml } from \"./http.js\";\nimport { parseSearchResults } from \"./parsers/searchResults.js\";\nimport { parseSongPage } from \"./parsers/songPage.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport { buildSearchUrl, resolveSongRef } from \"./urls.js\";\n\nexport interface LyricsComClientOptions {\n config?: Config;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport interface FetchOutcome<T> {\n data: T;\n /** True when the page came from the in-memory cache rather than the network. */\n cached: boolean;\n}\n\n/**\n * Apply the guarantees this project makes about its own traffic.\n *\n * The environment parser already enforces both, but `LyricsComClient` is published as a\n * library through the `./client` export and takes a caller-built config, so\n * without this the pacing floor and the honest identity are optional for anyone\n * importing it. lyrics.com answers a burst by refusing, and an honest User-Agent is what it accepts, and those promises hold on every path.\n *\n * A caller may still name their own application in the User-Agent. Passing the\n * traffic off as a browser is a different thing, and gets the project's own\n * identity appended so it stays attributable.\n */\nfunction withGuarantees(config: Config): Config {\n const userAgent = /mozilla\\/|applewebkit|chrome\\/|safari\\/|gecko/i.test(config.userAgent)\n ? `${config.userAgent} ${DEFAULT_USER_AGENT}`\n : config.userAgent;\n return {\n ...config,\n userAgent,\n minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs),\n };\n}\n\nexport class LyricsComClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<unknown>;\n private readonly fetchImpl: typeof fetch | undefined;\n\n constructor(options: LyricsComClientOptions = {}) {\n this.config = withGuarantees(options.config ?? loadConfig());\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });\n this.cache = new TtlLruCache<unknown>(this.config.cacheMaxEntries, this.config.cacheTtlMs);\n this.fetchImpl = options.fetchImpl;\n }\n\n async search(term: string, page: number): Promise<FetchOutcome<SearchPage>> {\n const url = buildSearchUrl(term, page);\n return this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));\n }\n\n async getSong(ref: { id?: string; url?: string }): Promise<FetchOutcome<SongPage>> {\n const { id, url } = resolveSongRef(ref);\n return this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));\n }\n\n /**\n * Fetch, parse, then cache. In that order: a page that could not be read is\n * never stored, so a bad minute at lyrics.com cannot be replayed from memory for\n * the rest of the cache lifetime, leaving the tool unable to recover after\n * the site comes back.\n *\n * The cached value is the parsed result rather than the raw page, which also\n * keeps a few hundred kilobytes of markup per entry out of memory.\n */\n private async fetchParsed<T>(url: string, parse: (html: string) => T): Promise<FetchOutcome<T>> {\n const hit = this.cache.get(url);\n if (hit !== undefined) {\n this.logger.debug(`cache hit ${url}`);\n return { data: hit as T, cached: true };\n }\n\n const html = await fetchHtml(url, {\n config: this.config,\n limiter: this.limiter,\n logger: this.logger,\n ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),\n });\n\n const data = parse(html);\n this.cache.set(url, data);\n return { data, cached: false };\n }\n}\n"],"mappings":"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","names":["cheerio"]}
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
"url": "https://github.com/smeet666/mcp-lyricscom",
|
|
20
20
|
"source": "github"
|
|
21
21
|
},
|
|
22
|
-
"version": "1.
|
|
22
|
+
"version": "1.3.0",
|
|
23
23
|
"packages": [
|
|
24
24
|
{
|
|
25
25
|
"registryType": "npm",
|
|
26
26
|
"identifier": "mcp-lyricscom",
|
|
27
|
-
"version": "1.
|
|
27
|
+
"version": "1.3.0",
|
|
28
28
|
"transport": {
|
|
29
29
|
"type": "stdio"
|
|
30
30
|
},
|
|
@@ -48,12 +48,11 @@
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"registryType": "mcpb",
|
|
51
|
-
"identifier": "https://github.com/smeet666/mcp-lyricscom/releases/download/v1.
|
|
52
|
-
"version": "1.
|
|
51
|
+
"identifier": "https://github.com/smeet666/mcp-lyricscom/releases/download/v1.3.0/mcp-lyricscom-1.3.0.mcpb",
|
|
52
|
+
"version": "1.3.0",
|
|
53
53
|
"transport": {
|
|
54
54
|
"type": "stdio"
|
|
55
|
-
}
|
|
56
|
-
"registryBaseUrl": "https://github.com"
|
|
55
|
+
}
|
|
57
56
|
}
|
|
58
57
|
]
|
|
59
58
|
}
|