mr-magic-mcp-server 0.4.0 → 0.5.1
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 +28 -0
- package/package.json +8 -6
- package/src/bin/cli.js +39 -0
- package/src/providers/genius.js +23 -17
- package/src/providers/melon.js +22 -15
- package/src/tests/run-tests.js +24 -3
- package/src/tools/cli.js +2 -1
package/README.md
CHANGED
|
@@ -401,6 +401,8 @@ The `MR_MAGIC_EXPORT_BACKEND` variable controls where formatted lyrics are store
|
|
|
401
401
|
- **`local`** (default) — writes files to `MR_MAGIC_EXPORT_DIR` (or `exports/` when
|
|
402
402
|
unset). Make sure the target directory is writable. The `export_lyrics` tool also
|
|
403
403
|
returns the raw `content` field so clients can inline results when file writes fail.
|
|
404
|
+
For global or npx CLI usage, the default `exports/` directory is relative to the
|
|
405
|
+
directory where you run `mrmagic-cli`, not the npm package install directory.
|
|
404
406
|
|
|
405
407
|
- **`inline`** — skips disk writes entirely. Each export is returned in the tool
|
|
406
408
|
response with `content` populated and `skipped: true` to signal that persistence
|
|
@@ -435,6 +437,16 @@ Or against the JSON HTTP server:
|
|
|
435
437
|
MR_MAGIC_DOWNLOAD_BASE_URL=http://127.0.0.1:3333
|
|
436
438
|
```
|
|
437
439
|
|
|
440
|
+
For global installs, start the JSON HTTP download server with the same persisted env
|
|
441
|
+
file or environment variables used for export commands:
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
mrmagic-cli server --port 3333
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Download URLs are only needed for the `redis` export backend. Local exports return
|
|
448
|
+
`file://` URLs and write files directly to `MR_MAGIC_EXPORT_DIR` or `./exports`.
|
|
449
|
+
|
|
438
450
|
## Local Deployment
|
|
439
451
|
|
|
440
452
|
Run whichever entrypoint you need via npm scripts:
|
|
@@ -1166,6 +1178,9 @@ Global CLI options:
|
|
|
1166
1178
|
- `--env-path <path>` / `--env-file <path>` — load credentials from a custom `.env` file
|
|
1167
1179
|
before running the command. This is useful for global installs, `npm link`, and `npx`
|
|
1168
1180
|
usage where the package install directory is not your project directory.
|
|
1181
|
+
- `--save-env-path` — persist the provided `--env-path` to
|
|
1182
|
+
`~/.config/mrmagic-cli/config.json` so future `mrmagic-cli` commands load the same
|
|
1183
|
+
credentials file automatically.
|
|
1169
1184
|
|
|
1170
1185
|
### Commands
|
|
1171
1186
|
|
|
@@ -1190,12 +1205,25 @@ npm run cli -- search --artist "BLACKPINK" --title "Kill This Love"
|
|
|
1190
1205
|
# Global/custom install with an explicit credential file
|
|
1191
1206
|
mrmagic-cli --env-path /absolute/path/to/.env find --artist "Nayeon" --title "POP!"
|
|
1192
1207
|
|
|
1208
|
+
# Save that credential file path once for future global CLI commands
|
|
1209
|
+
mrmagic-cli --env-path /absolute/path/to/.env --save-env-path status
|
|
1210
|
+
|
|
1193
1211
|
# Find best lyric (prefers synced LRC)
|
|
1194
1212
|
npm run cli -- find --artist "Nayeon" --title "POP!"
|
|
1195
1213
|
|
|
1196
1214
|
# Export plain text and SRT files for the best match
|
|
1197
1215
|
npm run cli -- export --artist "Nayeon" --title "POP!" --format plain --format srt --output ./exports
|
|
1198
1216
|
|
|
1217
|
+
# Global install: local export files are written to ./exports relative to your shell's current directory
|
|
1218
|
+
mrmagic-cli export --artist "Nayeon" --title "POP!" --format srt
|
|
1219
|
+
|
|
1220
|
+
# Global install: Redis-backed download URLs served by the JSON HTTP server
|
|
1221
|
+
MR_MAGIC_EXPORT_BACKEND=redis \
|
|
1222
|
+
MR_MAGIC_DOWNLOAD_BASE_URL=http://127.0.0.1:3333 \
|
|
1223
|
+
mrmagic-cli export --artist "Nayeon" --title "POP!" --format srt
|
|
1224
|
+
|
|
1225
|
+
mrmagic-cli server --port 3333
|
|
1226
|
+
|
|
1199
1227
|
# Pick first synced match from a prioritized provider list
|
|
1200
1228
|
npm run cli -- select --providers lrclib,genius --artist "Nayeon" --title "POP!" --require-synced
|
|
1201
1229
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-magic-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -61,14 +61,16 @@
|
|
|
61
61
|
"node": ">=20"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@dotenvx/dotenvx": "^1.
|
|
64
|
+
"@dotenvx/dotenvx": "^1.65.0",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
-
"axios": "^1.
|
|
67
|
-
"
|
|
68
|
-
"
|
|
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"
|
|
69
71
|
},
|
|
70
72
|
"devDependencies": {
|
|
71
|
-
"eslint": "^10.
|
|
73
|
+
"eslint": "^10.3.0",
|
|
72
74
|
"eslint-config-prettier": "^10.1.8",
|
|
73
75
|
"eslint-plugin-import-x": "^4.16.2",
|
|
74
76
|
"playwright": "^1.59.1",
|
package/src/bin/cli.js
CHANGED
|
@@ -3,15 +3,54 @@
|
|
|
3
3
|
// Reduce structured-log noise and env-missing warnings for interactive CLI usage.
|
|
4
4
|
// These must be set before any module that reads them is evaluated, so we use
|
|
5
5
|
// a dynamic import below instead of a static one.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
6
10
|
if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'warn';
|
|
7
11
|
if (!process.env.MR_MAGIC_QUIET_STDIO) process.env.MR_MAGIC_QUIET_STDIO = '1';
|
|
8
12
|
|
|
13
|
+
const configPath = process.env.MR_MAGIC_CLI_CONFIG_PATH
|
|
14
|
+
? path.resolve(process.env.MR_MAGIC_CLI_CONFIG_PATH)
|
|
15
|
+
: path.join(os.homedir(), '.config', 'mrmagic-cli', 'config.json');
|
|
16
|
+
const configDir = path.dirname(configPath);
|
|
17
|
+
|
|
18
|
+
function readPersistedEnvPath() {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
21
|
+
return typeof parsed.envPath === 'string' && parsed.envPath.trim() ? parsed.envPath : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function persistEnvPath(envPath) {
|
|
28
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
29
|
+
fs.writeFileSync(configPath, `${JSON.stringify({ envPath }, null, 2)}\n`, {
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
mode: 0o600
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
const envPathFlagIndex = process.argv.findIndex(
|
|
10
36
|
(arg) => arg === '--env-path' || arg === '--env-file'
|
|
11
37
|
);
|
|
12
38
|
if (envPathFlagIndex >= 0 && process.argv[envPathFlagIndex + 1]) {
|
|
13
39
|
process.env.MR_MAGIC_ENV_PATH = process.argv[envPathFlagIndex + 1];
|
|
14
40
|
process.argv.splice(envPathFlagIndex, 2);
|
|
41
|
+
if (process.argv.includes('--save-env-path')) {
|
|
42
|
+
persistEnvPath(process.env.MR_MAGIC_ENV_PATH);
|
|
43
|
+
}
|
|
44
|
+
} else if (!process.env.MR_MAGIC_ENV_PATH) {
|
|
45
|
+
const persistedEnvPath = readPersistedEnvPath();
|
|
46
|
+
if (persistedEnvPath) {
|
|
47
|
+
process.env.MR_MAGIC_ENV_PATH = persistedEnvPath;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const saveEnvPathFlagIndex = process.argv.indexOf('--save-env-path');
|
|
52
|
+
if (saveEnvPathFlagIndex >= 0) {
|
|
53
|
+
process.argv.splice(saveEnvPathFlagIndex, 1);
|
|
15
54
|
}
|
|
16
55
|
|
|
17
56
|
await import('../tools/cli.js');
|
package/src/providers/genius.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import
|
|
2
|
+
import { selectAll } from 'css-select';
|
|
3
|
+
import * as DomUtils from 'domutils';
|
|
4
|
+
import { parseDocument } from 'htmlparser2';
|
|
3
5
|
|
|
4
6
|
import { normalizeLyricRecord } from '../provider-result-schema.js';
|
|
5
7
|
import { assertEnv, getEnvValue } from '../utils/config.js';
|
|
@@ -119,7 +121,7 @@ export async function fetchFromGenius(track) {
|
|
|
119
121
|
return primary;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
function normalizeNodeText(
|
|
124
|
+
function normalizeNodeText(node) {
|
|
123
125
|
const lines = [];
|
|
124
126
|
const traverse = (node) => {
|
|
125
127
|
if (!node) return;
|
|
@@ -145,7 +147,7 @@ function normalizeNodeText($node) {
|
|
|
145
147
|
}
|
|
146
148
|
};
|
|
147
149
|
|
|
148
|
-
traverse(
|
|
150
|
+
traverse(node);
|
|
149
151
|
|
|
150
152
|
return lines
|
|
151
153
|
.join(' ')
|
|
@@ -155,18 +157,19 @@ function normalizeNodeText($node) {
|
|
|
155
157
|
.trim();
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
function
|
|
160
|
+
function removeUnwantedNodes(node) {
|
|
161
|
+
const unwanted = selectAll(
|
|
162
|
+
'script,noscript,img,style,aside,.song_media_dropdown,.header_with_cover_art-primary_info',
|
|
163
|
+
node
|
|
164
|
+
);
|
|
165
|
+
unwanted.forEach((element) => DomUtils.removeElement(element));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractFromNodes(nodes) {
|
|
159
169
|
const blocks = [];
|
|
160
170
|
nodes.forEach((element) => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.find(
|
|
164
|
-
'script,noscript,img,style,aside,.song_media_dropdown,.header_with_cover_art-primary_info'
|
|
165
|
-
)
|
|
166
|
-
.remove()
|
|
167
|
-
.end();
|
|
168
|
-
|
|
169
|
-
const text = normalizeNodeText(cleaned);
|
|
171
|
+
removeUnwantedNodes(element);
|
|
172
|
+
const text = normalizeNodeText(element);
|
|
170
173
|
const stripped = stripSummaryText(text);
|
|
171
174
|
|
|
172
175
|
if (stripped) {
|
|
@@ -224,15 +227,18 @@ export async function fetchLyricsForGeniusSong(url) {
|
|
|
224
227
|
}
|
|
225
228
|
});
|
|
226
229
|
|
|
227
|
-
const
|
|
228
|
-
let blocks = extractFromNodes(
|
|
230
|
+
const document = parseDocument(response.data);
|
|
231
|
+
let blocks = extractFromNodes(selectAll('div[data-lyrics-container="true"]', document));
|
|
229
232
|
|
|
230
233
|
if (!blocks.length) {
|
|
231
|
-
blocks = extractFromNodes(
|
|
234
|
+
blocks = extractFromNodes(selectAll('div[class^="Lyrics__Container"]', document));
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
if (!blocks.length) {
|
|
235
|
-
const fallback =
|
|
238
|
+
const fallback = selectAll('.lyrics', document)
|
|
239
|
+
.map((node) => DomUtils.textContent(node))
|
|
240
|
+
.join('\n')
|
|
241
|
+
.trim();
|
|
236
242
|
if (fallback) {
|
|
237
243
|
blocks.push(fallback);
|
|
238
244
|
}
|
package/src/providers/melon.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import
|
|
2
|
+
import { selectAll } from 'css-select';
|
|
3
|
+
import * as DomUtils from 'domutils';
|
|
4
|
+
import { parseDocument } from 'htmlparser2';
|
|
3
5
|
|
|
4
6
|
import { normalizeLyricRecord, recomputeSyncFlags } from '../provider-result-schema.js';
|
|
5
7
|
import { MELON_COOKIE, warnMissingEnv } from '../utils/config.js';
|
|
@@ -99,25 +101,30 @@ function extractSongId(value) {
|
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
function parseSearchPage(html) {
|
|
102
|
-
const
|
|
104
|
+
const document = parseDocument(html);
|
|
103
105
|
const seenIds = new Set();
|
|
104
|
-
return
|
|
105
|
-
.map((
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
return selectAll('#frm_defaultList > div > table > tbody > tr', document)
|
|
107
|
+
.map((row) => {
|
|
108
|
+
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];
|
|
112
|
+
const titleHref =
|
|
113
|
+
DomUtils.getAttributeValue(titleAnchor, 'href') ||
|
|
114
|
+
DomUtils.getAttributeValue(titleAnchor, 'onclick') ||
|
|
115
|
+
'';
|
|
116
|
+
const artistHref =
|
|
117
|
+
DomUtils.getAttributeValue(artistAnchor, 'href') ||
|
|
118
|
+
DomUtils.getAttributeValue(artistAnchor, 'onclick') ||
|
|
119
|
+
'';
|
|
113
120
|
let songId = extractSongId(titleHref) || extractSongId(artistHref);
|
|
114
|
-
if (!songId) songId = extractSongId(
|
|
121
|
+
if (!songId) songId = extractSongId(DomUtils.getOuterHTML(row) || '');
|
|
115
122
|
if (!songId || seenIds.has(songId)) {
|
|
116
123
|
return null;
|
|
117
124
|
}
|
|
118
|
-
const title =
|
|
119
|
-
const artist =
|
|
120
|
-
const album =
|
|
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, ' ');
|
|
121
128
|
seenIds.add(songId);
|
|
122
129
|
if (!title && !artist) return null;
|
|
123
130
|
return { songId, title, artist, album };
|
package/src/tests/run-tests.js
CHANGED
|
@@ -456,31 +456,52 @@ function testCliExportCommandHelp() {
|
|
|
456
456
|
function testCliEnvPathLoadsCustomEnvFile() {
|
|
457
457
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrmagic-cli-env-'));
|
|
458
458
|
const envPath = path.join(tempDir, '.env.custom');
|
|
459
|
+
const configPath = path.join(tempDir, 'config.json');
|
|
459
460
|
fs.writeFileSync(envPath, 'GENIUS_DIRECT_TOKEN=cli-env-path-token\n', 'utf8');
|
|
460
461
|
|
|
461
462
|
try {
|
|
462
463
|
const output = execFileSync(
|
|
463
464
|
process.execPath,
|
|
464
|
-
['src/bin/cli.js', '--env-path', envPath, 'status'],
|
|
465
|
+
['src/bin/cli.js', '--env-path', envPath, '--save-env-path', 'status'],
|
|
465
466
|
{
|
|
466
467
|
encoding: 'utf8',
|
|
467
468
|
env: {
|
|
468
469
|
PATH: process.env.PATH,
|
|
469
470
|
NODE_ENV: process.env.NODE_ENV,
|
|
470
471
|
LOG_LEVEL: 'error',
|
|
471
|
-
MR_MAGIC_QUIET_STDIO: '1'
|
|
472
|
+
MR_MAGIC_QUIET_STDIO: '1',
|
|
473
|
+
MR_MAGIC_CLI_CONFIG_PATH: configPath
|
|
472
474
|
}
|
|
473
475
|
}
|
|
474
476
|
);
|
|
475
477
|
|
|
476
478
|
assert.ok(output.includes('genius'));
|
|
477
479
|
assert.ok(output.includes('Ready'), 'custom env file should make Genius status ready');
|
|
480
|
+
|
|
481
|
+
const saved = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
482
|
+
assert.equal(saved.envPath, envPath, '--save-env-path should persist the selected env file');
|
|
483
|
+
|
|
484
|
+
const persistedOutput = execFileSync(process.execPath, ['src/bin/cli.js', 'status'], {
|
|
485
|
+
encoding: 'utf8',
|
|
486
|
+
env: {
|
|
487
|
+
PATH: process.env.PATH,
|
|
488
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
489
|
+
LOG_LEVEL: 'error',
|
|
490
|
+
MR_MAGIC_QUIET_STDIO: '1',
|
|
491
|
+
MR_MAGIC_CLI_CONFIG_PATH: configPath
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
assert.ok(
|
|
496
|
+
persistedOutput.includes('Ready'),
|
|
497
|
+
'CLI should reuse the persisted env path on later commands'
|
|
498
|
+
);
|
|
478
499
|
} finally {
|
|
479
500
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
480
501
|
}
|
|
481
502
|
|
|
482
503
|
divider();
|
|
483
|
-
console.log('CLI --env-path loads custom env files: ok');
|
|
504
|
+
console.log('CLI --env-path loads and persists custom env files: ok');
|
|
484
505
|
}
|
|
485
506
|
|
|
486
507
|
async function run() {
|
package/src/tools/cli.js
CHANGED
|
@@ -109,7 +109,8 @@ program
|
|
|
109
109
|
.description('Lyrics MCP server CLI powered by LRCLIB, Genius, Musixmatch, and Melon')
|
|
110
110
|
.version('0.1.3')
|
|
111
111
|
.option('--env-path <path>', 'Path to a .env file to load before running a CLI command')
|
|
112
|
-
.option('--env-file <path>', 'Alias for --env-path')
|
|
112
|
+
.option('--env-file <path>', 'Alias for --env-path')
|
|
113
|
+
.option('--save-env-path', 'Persist --env-path for future CLI commands');
|
|
113
114
|
|
|
114
115
|
function normalizeFormatOptions(value) {
|
|
115
116
|
if (!value) return [];
|