mr-magic-mcp-server 0.5.2 → 0.6.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 CHANGED
@@ -1184,17 +1184,19 @@ Global CLI options:
1184
1184
 
1185
1185
  ### Commands
1186
1186
 
1187
- | Command | Purpose | Notable flags |
1188
- | ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
1189
- | `mrmagic-cli search` | List candidates across providers without downloading. | `--artist`, `--title`, `--provider`, `--duration`, `--show-all`, `--pick` |
1190
- | `mrmagic-cli find` | Resolve best lyric (prefers synced) and print / export. | `--providers`, `--synced-only`, `--export`, `--format`, `--output`, `--no-romanize`, `--choose`, `--index` |
1191
- | `mrmagic-cli export` | Resolve best lyric and write export files directly. | `--providers`, `--synced-only`, `--format`, `--output`, `--no-romanize` |
1192
- | `mrmagic-cli select` | Pick first match from a prioritized provider list. | `--providers`, `--artist`, `--title`, `--require-synced` |
1193
- | `mrmagic-cli server` | Start the JSON automation API. | `--host`, `--port`, `--remote` |
1194
- | `mrmagic-cli server:mcp` | Start the MCP stdio server. | — |
1195
- | `mrmagic-cli server:mcp:http` | Start the Streamable HTTP & SSE MCP server. | `--host`, `--port`, `--remote`, `--sessionless` |
1196
- | `mrmagic-cli search-provider` | Query a single provider only. | `--provider`, `--artist`, `--title` |
1197
- | `mrmagic-cli status` | Print provider readiness. | — |
1187
+ | Command | Purpose | Notable flags |
1188
+ | ------------------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
1189
+ | `mrmagic-cli search` | List candidates across providers without downloading. | `--artist`, `--title`, `--provider`, `--duration`, `--show-all`, `--pick` |
1190
+ | `mrmagic-cli find` | Resolve best lyric (prefers synced) and print / export. | `--providers`, `--synced-only`, `--export`, `--format`, `--output`, `--no-romanize`, `--choose`, `--index` |
1191
+ | `mrmagic-cli export` | Resolve best lyric and write export files directly. | `--providers`, `--synced-only`, `--format`, `--output`, `--no-romanize` |
1192
+ | `mrmagic-cli select` | Pick first match from a prioritized provider list. | `--providers`, `--artist`, `--title`, `--require-synced` |
1193
+ | `mrmagic-cli server` | Start the JSON automation API. | `--host`, `--port`, `--remote` |
1194
+ | `mrmagic-cli server:mcp` | Start the MCP stdio server. | — |
1195
+ | `mrmagic-cli server:mcp:http` | Start the Streamable HTTP & SSE MCP server. | `--host`, `--port`, `--remote`, `--sessionless` |
1196
+ | `mrmagic-cli search-provider` | Query a single provider only. | `--provider`, `--artist`, `--title` |
1197
+ | `mrmagic-cli status` | Print provider readiness. | — |
1198
+ | `mrmagic-cli fetch:musixmatch-token` | Launch the browser workflow to fetch/cache a Musixmatch token. | `--headless`, `--browser`, `--session-dir` |
1199
+ | `mrmagic-cli push:musixmatch-token` | Push an existing Musixmatch token to configured storage backends. | `--token` |
1198
1200
 
1199
1201
  ### Examples
1200
1202
 
@@ -1227,6 +1229,12 @@ mrmagic-cli server --port 3333
1227
1229
  # Pick first synced match from a prioritized provider list
1228
1230
  npm run cli -- select --providers lrclib,genius --artist "Nayeon" --title "POP!" --require-synced
1229
1231
 
1232
+ # Fetch and cache a Musixmatch token through the CLI
1233
+ npm run cli -- fetch:musixmatch-token
1234
+
1235
+ # Push an existing Musixmatch token through the CLI
1236
+ npm run cli -- push:musixmatch-token --token '{"message":...}'
1237
+
1230
1238
  # Start JSON automation API on a custom port
1231
1239
  mrmagic-cli server --port 4000
1232
1240
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-magic-mcp-server",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,20 +61,20 @@
61
61
  "node": ">=20"
62
62
  },
63
63
  "dependencies": {
64
- "@dotenvx/dotenvx": "^1.65.0",
64
+ "@dotenvx/dotenvx": "^1.75.1",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "axios": "^1.16.0",
67
- "commander": "^14.0.3",
68
- "css-select": "^6.0.0",
69
- "domutils": "^3.2.2",
70
- "htmlparser2": "^10.1.0"
66
+ "axios": "^1.18.1",
67
+ "commander": "^15.0.0",
68
+ "css-select": "^7.0.0",
69
+ "domutils": "^4.0.2",
70
+ "htmlparser2": "^12.0.0"
71
71
  },
72
72
  "devDependencies": {
73
- "eslint": "^10.3.0",
73
+ "eslint": "^10.5.0",
74
74
  "eslint-config-prettier": "^10.1.8",
75
- "eslint-plugin-import-x": "^4.16.2",
76
- "playwright": "^1.59.1",
77
- "prettier": "^3.8.3"
75
+ "eslint-plugin-import-x": "^4.17.0",
76
+ "playwright": "^1.61.1",
77
+ "prettier": "^3.8.4"
78
78
  },
79
79
  "overrides": {
80
80
  "entities": "^7.0.1",
@@ -10,7 +10,7 @@ function normalizeLrclibRecord(record) {
10
10
  }
11
11
 
12
12
  const BASE_URL = 'https://lrclib.net/api';
13
- const HTTP_TIMEOUT_MS = Number(process.env.MR_MAGIC_HTTP_TIMEOUT_MS || 10000);
13
+ const HTTP_TIMEOUT_MS = Number(process.env.MR_MAGIC_HTTP_TIMEOUT_MS || 15000);
14
14
  const MOZILLA_USER_AGENT =
15
15
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36';
16
16
 
@@ -100,31 +100,46 @@ function extractSongId(value) {
100
100
  return null;
101
101
  }
102
102
 
103
+ function selectFirst(selector, root) {
104
+ if (!root) return null;
105
+ return selectAll(selector, root)[0] || null;
106
+ }
107
+
108
+ function getAttributeValue(node, name) {
109
+ return node ? DomUtils.getAttributeValue(node, name) || '' : '';
110
+ }
111
+
112
+ function normalizedText(node) {
113
+ return node ? DomUtils.textContent(node).trim().replace(/\s+/g, ' ') : '';
114
+ }
115
+
103
116
  export function parseSearchPage(html) {
104
117
  const document = parseDocument(html);
105
118
  const seenIds = new Set();
106
119
  return selectAll('#frm_defaultList > div > table > tbody > tr', document)
107
120
  .map((row) => {
108
121
  const cells = selectAll('td', row);
109
- const titleAnchor = selectAll('a.fc_gray', cells[2] || [])[0];
110
- const artistAnchor = selectAll('#artistName > a', cells[3] || [])[0];
111
- const albumAnchor = selectAll('a', cells[4] || [])[0];
122
+ const titleCell = cells[2] || null;
123
+ const artistCell = cells[3] || null;
124
+ const albumCell = cells[4] || null;
125
+ const titleAnchor = selectFirst('a.fc_gray', titleCell) || selectFirst('a', titleCell);
126
+ const artistAnchor =
127
+ selectFirst('#artistName > a', artistCell) ||
128
+ selectFirst('#artistName', artistCell) ||
129
+ selectFirst('a', artistCell);
130
+ const albumAnchor = selectFirst('a', albumCell);
112
131
  const titleHref =
113
- DomUtils.getAttributeValue(titleAnchor, 'href') ||
114
- DomUtils.getAttributeValue(titleAnchor, 'onclick') ||
115
- '';
132
+ getAttributeValue(titleAnchor, 'href') || getAttributeValue(titleAnchor, 'onclick');
116
133
  const artistHref =
117
- DomUtils.getAttributeValue(artistAnchor, 'href') ||
118
- DomUtils.getAttributeValue(artistAnchor, 'onclick') ||
119
- '';
134
+ getAttributeValue(artistAnchor, 'href') || getAttributeValue(artistAnchor, 'onclick');
120
135
  let songId = extractSongId(titleHref) || extractSongId(artistHref);
121
136
  if (!songId) songId = extractSongId(DomUtils.getOuterHTML(row) || '');
122
137
  if (!songId || seenIds.has(songId)) {
123
138
  return null;
124
139
  }
125
- const title = DomUtils.textContent(titleAnchor).trim().replace(/\s+/g, ' ');
126
- const artist = DomUtils.textContent(artistAnchor).trim().replace(/\s+/g, ' ');
127
- const album = DomUtils.textContent(albumAnchor).trim().replace(/\s+/g, ' ');
140
+ const title = normalizedText(titleAnchor || titleCell);
141
+ const artist = normalizedText(artistAnchor || artistCell);
142
+ const album = normalizedText(albumAnchor || albumCell);
128
143
  seenIds.add(songId);
129
144
  if (!title && !artist) return null;
130
145
  return { songId, title, artist, album };
@@ -454,6 +454,41 @@ function testCliExportCommandHelp() {
454
454
  console.log('CLI export command help is available: ok');
455
455
  }
456
456
 
457
+ function testCliMusixmatchTokenCommandHelp() {
458
+ const fetchOutput = execFileSync(
459
+ process.execPath,
460
+ ['src/bin/cli.js', 'fetch:musixmatch-token', '--help'],
461
+ {
462
+ encoding: 'utf8',
463
+ env: {
464
+ ...process.env,
465
+ LOG_LEVEL: 'error',
466
+ MR_MAGIC_QUIET_STDIO: '1'
467
+ }
468
+ }
469
+ );
470
+ const pushOutput = execFileSync(
471
+ process.execPath,
472
+ ['src/bin/cli.js', 'push:musixmatch-token', '--help'],
473
+ {
474
+ encoding: 'utf8',
475
+ env: {
476
+ ...process.env,
477
+ LOG_LEVEL: 'error',
478
+ MR_MAGIC_QUIET_STDIO: '1'
479
+ }
480
+ }
481
+ );
482
+
483
+ assert.ok(fetchOutput.includes('Launch the browser workflow'));
484
+ assert.ok(fetchOutput.includes('--browser <name>'));
485
+ assert.ok(pushOutput.includes('Push an existing Musixmatch token'));
486
+ assert.ok(pushOutput.includes('--token <json_or_string>'));
487
+
488
+ divider();
489
+ console.log('CLI Musixmatch token command help is available: ok');
490
+ }
491
+
457
492
  function testCliEnvPathLoadsCustomEnvFile() {
458
493
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrmagic-cli-env-'));
459
494
  const envPath = path.join(tempDir, '.env.custom');
@@ -536,6 +571,50 @@ function testMelonSearchParserWithoutCheerioCollection() {
536
571
  console.log('Melon parser works without Cheerio collection helpers: ok');
537
572
  }
538
573
 
574
+ function testMelonSearchParserHandlesMissingAnchors() {
575
+ const html = `
576
+ <div id="frm_defaultList">
577
+ <div>
578
+ <table>
579
+ <tbody>
580
+ <tr>
581
+ <td></td>
582
+ <td></td>
583
+ <td><a class="fc_gray" href="javascript:goSongDetail('12345678')">This Feeling</a></td>
584
+ <td><div id="artistName">The Chainsmokers</div></td>
585
+ <td><a href="#album">Sick Boy</a></td>
586
+ </tr>
587
+ <tr>
588
+ <td></td>
589
+ <td></td>
590
+ <td><a href="javascript:goSongDetail('87654321')">Fallback Title Link</a></td>
591
+ <td><a href="#artist">Fallback Artist</a></td>
592
+ <td>Fallback Album</td>
593
+ </tr>
594
+ </tbody>
595
+ </table>
596
+ </div>
597
+ </div>`;
598
+
599
+ const results = parseSearchPage(html);
600
+ assert.equal(results.length, 2, 'Melon parser should skip missing anchors without crashing');
601
+ assert.deepEqual(results[0], {
602
+ songId: '12345678',
603
+ title: 'This Feeling',
604
+ artist: 'The Chainsmokers',
605
+ album: 'Sick Boy'
606
+ });
607
+ assert.deepEqual(results[1], {
608
+ songId: '87654321',
609
+ title: 'Fallback Title Link',
610
+ artist: 'Fallback Artist',
611
+ album: 'Fallback Album'
612
+ });
613
+
614
+ divider();
615
+ console.log('Melon parser handles missing class/id anchors: ok');
616
+ }
617
+
539
618
  async function run() {
540
619
  testAutoPickPrefersSynced();
541
620
  testAutoPickFallbackWhenNoSynced();
@@ -553,8 +632,10 @@ async function run() {
553
632
  await testBuildPayloadFromResultReturnsCacheKey();
554
633
  await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
555
634
  testCliExportCommandHelp();
635
+ testCliMusixmatchTokenCommandHelp();
556
636
  testCliEnvPathLoadsCustomEnvFile();
557
637
  testMelonSearchParserWithoutCheerioCollection();
638
+ testMelonSearchParserHandlesMissingAnchors();
558
639
  const toolNames = mcpToolDefinitions.map((tool) => tool.name);
559
640
  console.log('MCP tooling available:', toolNames.join(', '));
560
641
  console.log('All sanity checks passed');
package/src/tools/cli.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import '../utils/config.js';
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
3
5
  import process from 'node:process';
6
+ import { fileURLToPath } from 'node:url';
4
7
 
5
8
  import { Command } from 'commander';
6
9
 
@@ -77,6 +80,38 @@ function hasOption(argv, names) {
77
80
  return argv.some((token) => names.some((name) => token === name || token.startsWith(`${name}=`)));
78
81
  }
79
82
 
83
+ function runScript(scriptFile, { args = [], env = process.env } = {}) {
84
+ const scriptPath = path.resolve(
85
+ path.dirname(fileURLToPath(import.meta.url)),
86
+ '..',
87
+ 'scripts',
88
+ scriptFile
89
+ );
90
+
91
+ return new Promise((resolve) => {
92
+ const child = spawn(process.execPath, [scriptPath, ...args], {
93
+ stdio: 'inherit',
94
+ env
95
+ });
96
+
97
+ child.on('error', (error) => {
98
+ console.error(`Failed to start ${scriptFile}: ${error.message}`);
99
+ process.exitCode = 1;
100
+ resolve();
101
+ });
102
+
103
+ child.on('close', (code, signal) => {
104
+ if (signal) {
105
+ console.error(`${scriptFile} terminated by signal ${signal}`);
106
+ process.exitCode = 1;
107
+ } else if (code && code !== 0) {
108
+ process.exitCode = code;
109
+ }
110
+ resolve();
111
+ });
112
+ });
113
+ }
114
+
80
115
  function normalizeLegacyNpmInvocation(argv) {
81
116
  const base = Array.isArray(argv) ? [...argv] : [];
82
117
  if (base.length < 3) return base;
@@ -175,6 +210,32 @@ program
175
210
  console.table(await getProviderStatus());
176
211
  });
177
212
 
213
+ program
214
+ .command('fetch:musixmatch-token')
215
+ .description('Launch the browser workflow to fetch and cache a Musixmatch token')
216
+ .option('--headless', 'Run the Playwright browser workflow headlessly', false)
217
+ .option(
218
+ '--browser <name>',
219
+ 'Prefer a browser candidate (chrome, brave, msedge, comet, firefox, safari)'
220
+ )
221
+ .option('--session-dir <dir>', 'Override the persistent Playwright session directory')
222
+ .action(async (options) => {
223
+ const env = { ...process.env };
224
+ if (options.headless) env.HEADLESS = '1';
225
+ if (options.browser) env.BROWSER = options.browser;
226
+ if (options.sessionDir) env.PLAYWRIGHT_SESSION_DIR = options.sessionDir;
227
+ await runScript('fetch_musixmatch_token.mjs', { env });
228
+ });
229
+
230
+ program
231
+ .command('push:musixmatch-token')
232
+ .description('Push an existing Musixmatch token to configured storage backends')
233
+ .option('-t, --token <json_or_string>', 'Token JSON payload or raw token string')
234
+ .action(async (options) => {
235
+ const args = options.token ? ['--token', options.token] : [];
236
+ await runScript('push_musixmatch_token.mjs', { args });
237
+ });
238
+
178
239
  const DEFAULT_FORMATS = ['plain', 'srt'];
179
240
 
180
241
  const COLORS = {