mr-magic-mcp-server 0.5.1 → 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 +29 -15
- package/src/tests/run-tests.js +114 -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
|
@@ -91,7 +91,7 @@ async function buildLyricHeaders(songId) {
|
|
|
91
91
|
|
|
92
92
|
function extractSongId(value) {
|
|
93
93
|
if (!value) return null;
|
|
94
|
-
const goSongMatch = value.match(/goSongDetail\([^
|
|
94
|
+
const goSongMatch = value.match(/goSongDetail\([^)]*?(\d+)[^)]*\)/);
|
|
95
95
|
if (goSongMatch) return goSongMatch[1];
|
|
96
96
|
const searchLogMatch = value.match(/searchLog\('[^']+','[^']+','[^']+','[^']+','(\d+)'\)/);
|
|
97
97
|
if (searchLogMatch) return searchLogMatch[1];
|
|
@@ -100,36 +100,50 @@ function extractSongId(value) {
|
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function
|
|
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
|
+
|
|
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 };
|
|
131
146
|
})
|
|
132
|
-
.get()
|
|
133
147
|
.filter(Boolean);
|
|
134
148
|
}
|
|
135
149
|
|
package/src/tests/run-tests.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
catalogCacheKey,
|
|
20
20
|
catalogCache
|
|
21
21
|
} from '../services/lyrics-service.js';
|
|
22
|
+
import { parseSearchPage } from '../providers/melon.js';
|
|
22
23
|
import { mcpToolDefinitions, handleMcpTool } from '../transport/mcp-tools.js';
|
|
23
24
|
import { romanizePlainLyrics } from '../utils/lyrics-format.js';
|
|
24
25
|
|
|
@@ -453,6 +454,41 @@ function testCliExportCommandHelp() {
|
|
|
453
454
|
console.log('CLI export command help is available: ok');
|
|
454
455
|
}
|
|
455
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
|
+
|
|
456
492
|
function testCliEnvPathLoadsCustomEnvFile() {
|
|
457
493
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrmagic-cli-env-'));
|
|
458
494
|
const envPath = path.join(tempDir, '.env.custom');
|
|
@@ -504,6 +540,81 @@ function testCliEnvPathLoadsCustomEnvFile() {
|
|
|
504
540
|
console.log('CLI --env-path loads and persists custom env files: ok');
|
|
505
541
|
}
|
|
506
542
|
|
|
543
|
+
function testMelonSearchParserWithoutCheerioCollection() {
|
|
544
|
+
const html = `
|
|
545
|
+
<div id="frm_defaultList">
|
|
546
|
+
<div>
|
|
547
|
+
<table>
|
|
548
|
+
<tbody>
|
|
549
|
+
<tr>
|
|
550
|
+
<td></td>
|
|
551
|
+
<td></td>
|
|
552
|
+
<td><a class="fc_gray" href="javascript:goSongDetail('38914304')">Summer Nights</a></td>
|
|
553
|
+
<td><div id="artistName"><a href="#artist">St. Lucia</a></div></td>
|
|
554
|
+
<td><a href="#album">When The Night</a></td>
|
|
555
|
+
</tr>
|
|
556
|
+
</tbody>
|
|
557
|
+
</table>
|
|
558
|
+
</div>
|
|
559
|
+
</div>`;
|
|
560
|
+
|
|
561
|
+
const results = parseSearchPage(html);
|
|
562
|
+
assert.equal(results.length, 1, 'Melon parser should return plain arrays, not Cheerio chains');
|
|
563
|
+
assert.deepEqual(results[0], {
|
|
564
|
+
songId: '38914304',
|
|
565
|
+
title: 'Summer Nights',
|
|
566
|
+
artist: 'St. Lucia',
|
|
567
|
+
album: 'When The Night'
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
divider();
|
|
571
|
+
console.log('Melon parser works without Cheerio collection helpers: ok');
|
|
572
|
+
}
|
|
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
|
+
|
|
507
618
|
async function run() {
|
|
508
619
|
testAutoPickPrefersSynced();
|
|
509
620
|
testAutoPickFallbackWhenNoSynced();
|
|
@@ -521,7 +632,10 @@ async function run() {
|
|
|
521
632
|
await testBuildPayloadFromResultReturnsCacheKey();
|
|
522
633
|
await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
|
|
523
634
|
testCliExportCommandHelp();
|
|
635
|
+
testCliMusixmatchTokenCommandHelp();
|
|
524
636
|
testCliEnvPathLoadsCustomEnvFile();
|
|
637
|
+
testMelonSearchParserWithoutCheerioCollection();
|
|
638
|
+
testMelonSearchParserHandlesMissingAnchors();
|
|
525
639
|
const toolNames = mcpToolDefinitions.map((tool) => tool.name);
|
|
526
640
|
console.log('MCP tooling available:', toolNames.join(', '));
|
|
527
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 = {
|