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 +19 -11
- package/package.json +11 -11
- package/src/providers/lrclib.js +1 -1
- package/src/providers/melon.js +27 -12
- package/src/tests/run-tests.js +81 -0
- package/src/tools/cli.js +61 -0
package/README.md
CHANGED
|
@@ -1184,17 +1184,19 @@ Global CLI options:
|
|
|
1184
1184
|
|
|
1185
1185
|
### Commands
|
|
1186
1186
|
|
|
1187
|
-
| Command
|
|
1188
|
-
|
|
|
1189
|
-
| `mrmagic-cli search`
|
|
1190
|
-
| `mrmagic-cli find`
|
|
1191
|
-
| `mrmagic-cli export`
|
|
1192
|
-
| `mrmagic-cli select`
|
|
1193
|
-
| `mrmagic-cli server`
|
|
1194
|
-
| `mrmagic-cli server:mcp`
|
|
1195
|
-
| `mrmagic-cli server:mcp:http`
|
|
1196
|
-
| `mrmagic-cli search-provider`
|
|
1197
|
-
| `mrmagic-cli status`
|
|
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.
|
|
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.
|
|
64
|
+
"@dotenvx/dotenvx": "^1.75.1",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
-
"axios": "^1.
|
|
67
|
-
"commander": "^
|
|
68
|
-
"css-select": "^
|
|
69
|
-
"domutils": "^
|
|
70
|
-
"htmlparser2": "^
|
|
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.
|
|
73
|
+
"eslint": "^10.5.0",
|
|
74
74
|
"eslint-config-prettier": "^10.1.8",
|
|
75
|
-
"eslint-plugin-import-x": "^4.
|
|
76
|
-
"playwright": "^1.
|
|
77
|
-
"prettier": "^3.8.
|
|
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",
|
package/src/providers/lrclib.js
CHANGED
|
@@ -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 ||
|
|
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
|
|
package/src/providers/melon.js
CHANGED
|
@@ -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
|
|
110
|
-
const
|
|
111
|
-
const
|
|
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
|
-
|
|
114
|
-
DomUtils.getAttributeValue(titleAnchor, 'onclick') ||
|
|
115
|
-
'';
|
|
132
|
+
getAttributeValue(titleAnchor, 'href') || getAttributeValue(titleAnchor, 'onclick');
|
|
116
133
|
const artistHref =
|
|
117
|
-
|
|
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 =
|
|
126
|
-
const artist =
|
|
127
|
-
const album =
|
|
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 };
|
package/src/tests/run-tests.js
CHANGED
|
@@ -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 = {
|