mr-magic-mcp-server 0.1.21 → 0.1.25
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/.env.example +1 -0
- package/README.md +34 -14
- package/package.json +7 -8
- package/prompts/airtable-song-importer.md +27 -7
- package/src/scripts/fetch_genius_token.mjs +76 -0
- package/src/scripts/fetch_musixmatch_token.mjs +86 -0
- package/src/scripts/mcp-arg-boundary-repro.mjs +143 -0
- package/src/scripts/mcp-arg-boundary-sdk-repro.mjs +206 -0
- package/src/tests/mcp-tools.test.js +251 -0
- package/src/tests/run-tests.js +104 -0
- package/src/transport/mcp-http-server.js +9 -1
- package/src/utils/tokens/genius-token-manager.js +1 -1
package/.env.example
CHANGED
|
@@ -119,6 +119,7 @@ MR_MAGIC_TOOL_ARG_CHUNK_SIZE=400 # Chunk size in chars (only used when LOG_TOOL
|
|
|
119
119
|
# Streamable HTTP MCP transport diagnostics
|
|
120
120
|
MR_MAGIC_MCP_HTTP_DIAGNOSTICS=0 # Set to 1 to log enriched request diagnostics at transport ingress
|
|
121
121
|
MR_MAGIC_ALLOWED_HOSTS= # Optional. Comma-separated extra allowed hostnames for DNS rebinding protection when binding to 0.0.0.0 (e.g. custom domains). RENDER_EXTERNAL_HOSTNAME is auto-included on Render.
|
|
122
|
+
MR_MAGIC_SESSIONLESS=0 # Set to 1 to force sessionless mode (each request handled by a fresh server/transport, no in-memory session). Auto-enabled on Render. Set manually on ECS, Fly.io, Railway, etc.
|
|
122
123
|
|
|
123
124
|
# SDK repro harness verbose HTTP logging
|
|
124
125
|
MR_MAGIC_SDK_REPRO_HTTP_DEBUG=0 # Set to 1 for verbose HTTP previews in the SDK repro harness script
|
package/README.md
CHANGED
|
@@ -94,15 +94,16 @@ grouped below by purpose.
|
|
|
94
94
|
|
|
95
95
|
### Server and runtime
|
|
96
96
|
|
|
97
|
-
| Variable | Default | Description
|
|
98
|
-
| -------------------------- | ---------------- |
|
|
99
|
-
| `PORT` | `3444` / `3333` | Override server port. On Render this is set automatically (default `10000`).
|
|
100
|
-
| `LOG_LEVEL` | `info` | Verbosity: `error` \| `warn` \| `info` \| `debug`.
|
|
101
|
-
| `MR_MAGIC_QUIET_STDIO` | `0` | Set to `1` to suppress non-error stdout logs (forces `LOG_LEVEL=error`). Recommended under stdio MCP clients.
|
|
102
|
-
| `MR_MAGIC_HTTP_TIMEOUT_MS` | `10000` | Global outbound HTTP timeout in milliseconds.
|
|
103
|
-
| `MR_MAGIC_ROOT` | _(project root)_ | Override the project root used for `.env` and `.cache` path resolution.
|
|
104
|
-
| `MR_MAGIC_ENV_PATH` | _(auto)_ | Point to a specific `.env` file instead of `<project root>/.env`.
|
|
105
|
-
| `MR_MAGIC_ALLOWED_HOSTS` | _(empty)_ | Comma-separated extra hostnames allowed for DNS rebinding protection when binding to `0.0.0.0`. `RENDER_EXTERNAL_HOSTNAME` is included automatically on Render. Only needed for custom domains.
|
|
97
|
+
| Variable | Default | Description |
|
|
98
|
+
| -------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `PORT` | `3444` / `3333` | Override server port. On Render this is set automatically (default `10000`). |
|
|
100
|
+
| `LOG_LEVEL` | `info` | Verbosity: `error` \| `warn` \| `info` \| `debug`. |
|
|
101
|
+
| `MR_MAGIC_QUIET_STDIO` | `0` | Set to `1` to suppress non-error stdout logs (forces `LOG_LEVEL=error`). Recommended under stdio MCP clients. |
|
|
102
|
+
| `MR_MAGIC_HTTP_TIMEOUT_MS` | `10000` | Global outbound HTTP timeout in milliseconds. |
|
|
103
|
+
| `MR_MAGIC_ROOT` | _(project root)_ | Override the project root used for `.env` and `.cache` path resolution. |
|
|
104
|
+
| `MR_MAGIC_ENV_PATH` | _(auto)_ | Point to a specific `.env` file instead of `<project root>/.env`. |
|
|
105
|
+
| `MR_MAGIC_ALLOWED_HOSTS` | _(empty)_ | Comma-separated extra hostnames allowed for DNS rebinding protection when binding to `0.0.0.0`. `RENDER_EXTERNAL_HOSTNAME` is included automatically on Render. Only needed for custom domains. |
|
|
106
|
+
| `MR_MAGIC_SESSIONLESS` | `0` | Set to `1` to force **sessionless mode** on the MCP Streamable HTTP server — each request is handled by a fresh, temporary server/transport with no in-memory session state. Auto-enabled on Render (see below). |
|
|
106
107
|
|
|
107
108
|
### Genius credentials
|
|
108
109
|
|
|
@@ -315,6 +316,25 @@ Recommended Render service settings:
|
|
|
315
316
|
> your Render environment so the DNS rebinding protection accepts requests with
|
|
316
317
|
> those `Host` headers.
|
|
317
318
|
|
|
319
|
+
#### Sessionless mode on Render (automatic)
|
|
320
|
+
|
|
321
|
+
When `RENDER=true` is detected, the MCP Streamable HTTP server automatically operates
|
|
322
|
+
in **sessionless mode**. This is essential for multi-instance deployments where Render
|
|
323
|
+
routes requests across several processes:
|
|
324
|
+
|
|
325
|
+
- An `initialize` request served by **Instance A** would store the session in A's
|
|
326
|
+
in-memory `Map`. A follow-up `tools/list` call routed to **Instance B** cannot
|
|
327
|
+
find that session and returns `{"error": "Session not found. …"}`.
|
|
328
|
+
- In sessionless mode, every request — `initialize`, `tools/list`, `tools/call`, etc.
|
|
329
|
+
— is handled by a fresh, short-lived `Server + StreamableHTTPServerTransport` pair.
|
|
330
|
+
No `Mcp-Session-Id` header is issued and no session state is stored. Each request
|
|
331
|
+
is fully self-contained and works correctly regardless of which instance handles it.
|
|
332
|
+
|
|
333
|
+
You do **not** need to set `MR_MAGIC_SESSIONLESS=1` manually on Render — it is
|
|
334
|
+
auto-enabled via the platform-injected `RENDER` env var. Set `MR_MAGIC_SESSIONLESS=1`
|
|
335
|
+
explicitly on other multi-instance platforms (ECS, Fly.io, Railway, etc.) where
|
|
336
|
+
a similar load-balanced, stateless deployment is used.
|
|
337
|
+
|
|
318
338
|
### Transport selection
|
|
319
339
|
|
|
320
340
|
| Transport | Command | Use case |
|
|
@@ -570,10 +590,10 @@ Recommended presets:
|
|
|
570
590
|
|
|
571
591
|
Mr. Magic supports two connection modes depending on where the MCP client runs:
|
|
572
592
|
|
|
573
|
-
| Mode
|
|
574
|
-
|
|
|
575
|
-
| **Local (stdio)**
|
|
576
|
-
| **Remote (Streamable HTTP)** | `POST https://your-server.com/mcp`
|
|
593
|
+
| Mode | Transport | When to use |
|
|
594
|
+
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
|
|
595
|
+
| **Local (stdio)** | `mcp-server` binary via stdin/stdout | Cline, Claude Desktop, and any client that runs locally on the same machine |
|
|
596
|
+
| **Remote (Streamable HTTP)** | `POST https://your-server.com/mcp` | TypingMind, browser-based clients, and any client connecting to a deployed server |
|
|
577
597
|
|
|
578
598
|
---
|
|
579
599
|
|
|
@@ -755,7 +775,7 @@ Automated checks:
|
|
|
755
775
|
|
|
756
776
|
```bash
|
|
757
777
|
npm run test # full bundled test runner
|
|
758
|
-
node tests/mcp-tools.test.js # raw MCP integration harness
|
|
778
|
+
node src/tests/mcp-tools.test.js # raw MCP integration harness
|
|
759
779
|
npm run repro:mcp:arg-boundary # JSON-RPC argument boundary repro
|
|
760
780
|
npm run repro:mcp:arg-boundary:sdk # SDK client transport repro
|
|
761
781
|
npm run lint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-magic-mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -29,12 +29,11 @@
|
|
|
29
29
|
"lint:fix": "eslint . --fix",
|
|
30
30
|
"format": "prettier --write .",
|
|
31
31
|
"format:check": "prettier --check .",
|
|
32
|
-
"test": "node tests/run-tests.js",
|
|
33
|
-
"fetch:musixmatch-token": "node scripts/fetch_MUSIXMATCH_ALT_FALLBACK_TOKEN.mjs",
|
|
34
|
-
"fetch:genius-token": "node scripts/fetch_genius_token.mjs",
|
|
35
|
-
"repro:mcp:arg-boundary": "node scripts/mcp-arg-boundary-repro.mjs",
|
|
36
|
-
"repro:mcp:arg-boundary:sdk": "node scripts/mcp-arg-boundary-sdk-repro.mjs"
|
|
37
|
-
"prepack": "npm run test"
|
|
32
|
+
"test": "node src/tests/run-tests.js",
|
|
33
|
+
"fetch:musixmatch-token": "node src/scripts/fetch_MUSIXMATCH_ALT_FALLBACK_TOKEN.mjs",
|
|
34
|
+
"fetch:genius-token": "node src/scripts/fetch_genius_token.mjs",
|
|
35
|
+
"repro:mcp:arg-boundary": "node src/scripts/mcp-arg-boundary-repro.mjs",
|
|
36
|
+
"repro:mcp:arg-boundary:sdk": "node src/scripts/mcp-arg-boundary-sdk-repro.mjs"
|
|
38
37
|
},
|
|
39
38
|
"keywords": [
|
|
40
39
|
"lyrics",
|
|
@@ -55,7 +54,7 @@
|
|
|
55
54
|
},
|
|
56
55
|
"homepage": "https://github.com/mrnajiboy/mr-magic-mcp-server#readme",
|
|
57
56
|
"engines": {
|
|
58
|
-
"node": ">=
|
|
57
|
+
"node": ">=20"
|
|
59
58
|
},
|
|
60
59
|
"dependencies": {
|
|
61
60
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# Airtable Song Importer
|
|
2
|
+
|
|
1
3
|
You are an Airtable Song Importing Assistant. You are helping the user add songs to an Airtable catalog.
|
|
2
4
|
|
|
3
5
|
The user will provide:
|
|
@@ -15,6 +17,7 @@ Use Airtable tools to determine the correct:
|
|
|
15
17
|
|
|
16
18
|
- base ID
|
|
17
19
|
- table ID
|
|
20
|
+
- view ID (needed for constructing final entry links)
|
|
18
21
|
- target field IDs or field names
|
|
19
22
|
|
|
20
23
|
Always verify field IDs against field names before inserting or updating records.
|
|
@@ -214,15 +217,32 @@ If the tool returns a URL, present that URL clearly.
|
|
|
214
217
|
If the tool returns a file path, present that file path clearly.
|
|
215
218
|
If the tool returns inline content because persistence was skipped or failed, provide the SRT content in the conversation and clearly explain that no downloadable file/link was available.
|
|
216
219
|
|
|
217
|
-
## 9)
|
|
220
|
+
## 9) Final output — Entry Summary
|
|
221
|
+
|
|
222
|
+
When all processing is complete, output a concise **Entry Summary** — one line (or short block) per song. Do not explain phases or steps.
|
|
223
|
+
|
|
224
|
+
Each entry should include:
|
|
225
|
+
|
|
226
|
+
- The formatted `Song (Video)` title
|
|
227
|
+
- Status: `created` or `updated`
|
|
228
|
+
- A direct Airtable link to the record, constructed as:
|
|
229
|
+
`https://airtable.com/{baseId}/{tableId}/{viewId}/{recordId}`
|
|
230
|
+
- Any per-entry notes (e.g. lyrics fallback used, SRT export path, or a failure)
|
|
218
231
|
|
|
219
|
-
|
|
232
|
+
### Example output
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
✅ BLACKPINK, Doja Cat - Crazy (Lyrics) — created
|
|
236
|
+
https://airtable.com/appeBUkVEp3N4RT0C/tbl0y5XHFXpjUJXHu/viwXXXXXXXXXXXXX/recABCDEFG1234567
|
|
237
|
+
|
|
238
|
+
✅ Joji - Glimpse of Us (Lyrics) — created
|
|
239
|
+
https://airtable.com/appeBUkVEp3N4RT0C/tbl0y5XHFXpjUJXHu/viwXXXXXXXXXXXXX/rec1234567ABCDEFG
|
|
240
|
+
|
|
241
|
+
❌ Some Artist - Song Title (Lyrics) — lyrics write failed (splitLyricsUpdate retried)
|
|
242
|
+
https://airtable.com/appeBUkVEp3N4RT0C/tbl0y5XHFXpjUJXHu/viwXXXXXXXXXXXXX/recZZZZZZZZZZZZZZ
|
|
243
|
+
```
|
|
220
244
|
|
|
221
|
-
|
|
222
|
-
- confirm each Airtable bulk create/update result (report record IDs created)
|
|
223
|
-
- confirm each lyrics write separately (report `success`, `recordId`, `lyricsWritten` from `push_catalog_to_airtable`)
|
|
224
|
-
- clearly separate Airtable insertion phases from SRT export
|
|
225
|
-
- if a fallback or retry was required, state exactly which step failed and what workaround was used
|
|
245
|
+
If the view ID could not be resolved, omit it from the URL rather than guessing.
|
|
226
246
|
|
|
227
247
|
## 10) Tool responsibility summary
|
|
228
248
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import '../src/utils/config.js';
|
|
7
|
+
|
|
8
|
+
const TOKEN_ENDPOINT = 'https://api.genius.com/oauth/token';
|
|
9
|
+
|
|
10
|
+
function printDeploymentBlock(accessToken) {
|
|
11
|
+
console.log('\n' + '─'.repeat(68));
|
|
12
|
+
console.log('Token captured successfully!\n');
|
|
13
|
+
console.log('RECOMMENDED: AUTO-REFRESH (no script needed on redeploy)');
|
|
14
|
+
console.log(' Set GENIUS_CLIENT_ID and GENIUS_CLIENT_SECRET in your platform');
|
|
15
|
+
console.log(' dashboard. The server calls the Genius OAuth endpoint at runtime');
|
|
16
|
+
console.log(' and auto-refreshes the token in memory — no filesystem, no scripts.\n');
|
|
17
|
+
console.log('LOCAL DEVELOPMENT (cache token)');
|
|
18
|
+
console.log(' Token written to the cache file above.');
|
|
19
|
+
console.log(' The server reads it on startup when a writable filesystem is available.\n');
|
|
20
|
+
console.log('RENDER / EPHEMERAL DEPLOYMENTS (fallback token)');
|
|
21
|
+
console.log(' If you cannot use client_credentials, set the token as an env var');
|
|
22
|
+
console.log(' in your platform dashboard. It acts as a static fallback token:\n');
|
|
23
|
+
console.log(` GENIUS_ACCESS_TOKEN=${accessToken}\n`);
|
|
24
|
+
console.log(" Note: static tokens don't auto-refresh. Redeploy with a new token");
|
|
25
|
+
console.log(' if/when it expires. The client_credentials path avoids this entirely.');
|
|
26
|
+
console.log('─'.repeat(68) + '\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const clientId = process.env.GENIUS_CLIENT_ID;
|
|
31
|
+
const clientSecret = process.env.GENIUS_CLIENT_SECRET;
|
|
32
|
+
if (!clientId || !clientSecret) {
|
|
33
|
+
console.error('GENIUS_CLIENT_ID and GENIUS_CLIENT_SECRET must be set in the environment.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const params = new URLSearchParams({
|
|
39
|
+
client_id: clientId,
|
|
40
|
+
client_secret: clientSecret,
|
|
41
|
+
grant_type: 'client_credentials'
|
|
42
|
+
});
|
|
43
|
+
const response = await axios.post(TOKEN_ENDPOINT, params.toString(), {
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
const { access_token: accessToken, expires_in: expiresIn } = response.data ?? {};
|
|
49
|
+
if (!accessToken) {
|
|
50
|
+
console.error('Response did not include access_token:', response.data);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
console.log('Genius access token refreshed successfully.');
|
|
54
|
+
console.log(`Expires in: ${expiresIn || 'unknown'} seconds`);
|
|
55
|
+
|
|
56
|
+
// Uses the same env var as the server runtime so both read/write the same path.
|
|
57
|
+
const cachePath = process.env.GENIUS_TOKEN_CACHE || path.resolve('.cache', 'genius-token.json');
|
|
58
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
59
|
+
await writeFile(
|
|
60
|
+
cachePath,
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
access_token: accessToken,
|
|
63
|
+
expires_at: Date.now() + (expiresIn || 3600) * 1000
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
console.log(`\nCache token written to: ${cachePath}`);
|
|
67
|
+
console.log('(The server reads this file on startup when a writable filesystem is available.)');
|
|
68
|
+
|
|
69
|
+
printDeploymentBlock(accessToken);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error('Failed to refresh Genius token:', error.response?.data || error.message);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { chromium } from 'playwright-chromium';
|
|
6
|
+
import '../src/utils/config.js';
|
|
7
|
+
|
|
8
|
+
const AUTH_URL = 'https://auth.musixmatch.com/';
|
|
9
|
+
|
|
10
|
+
async function saveToken(token, desktopCookie) {
|
|
11
|
+
// Uses the same env var as the server runtime so both read/write the same path.
|
|
12
|
+
const cachePath =
|
|
13
|
+
process.env.MUSIXMATCH_TOKEN_CACHE || path.resolve('.cache', 'musixmatch-token.json');
|
|
14
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
15
|
+
const payload = { token };
|
|
16
|
+
if (desktopCookie) {
|
|
17
|
+
payload.desktopCookie = desktopCookie;
|
|
18
|
+
}
|
|
19
|
+
await writeFile(cachePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
20
|
+
console.log(`\nCache token written to: ${cachePath}`);
|
|
21
|
+
console.log('(The server reads this file on startup when a writable filesystem is available.)');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function printDeploymentBlock(tokenValue) {
|
|
25
|
+
const tokenString =
|
|
26
|
+
typeof tokenValue === 'string'
|
|
27
|
+
? tokenValue
|
|
28
|
+
: (tokenValue?.message?.body?.usertoken ?? JSON.stringify(tokenValue));
|
|
29
|
+
console.log('\n' + '─'.repeat(68));
|
|
30
|
+
console.log('Token captured successfully!\n');
|
|
31
|
+
console.log('LOCAL DEVELOPMENT (cache token)');
|
|
32
|
+
console.log(' The token has been written to the cache file above.');
|
|
33
|
+
console.log(' The server loads it at startup — no further action needed.\n');
|
|
34
|
+
console.log('RENDER / EPHEMERAL DEPLOYMENTS (fallback token)');
|
|
35
|
+
console.log(' The filesystem is wiped on restart, so set the token as an');
|
|
36
|
+
console.log(' environment variable in your platform dashboard instead:\n');
|
|
37
|
+
console.log(` MUSIXMATCH_FALLBACK_TOKEN=${tokenString}\n`);
|
|
38
|
+
console.log(' The server reads MUSIXMATCH_FALLBACK_TOKEN on startup (1st priority)');
|
|
39
|
+
console.log(' and never touches the cache file on ephemeral hosts.');
|
|
40
|
+
console.log('─'.repeat(68) + '\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
console.log('Launching Playwright to acquire Musixmatch token...');
|
|
45
|
+
const browser = await chromium.launch({ headless: process.env.HEADLESS !== 'false' });
|
|
46
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
47
|
+
const page = await context.newPage();
|
|
48
|
+
|
|
49
|
+
console.log('Navigate to Musixmatch login and sign in.');
|
|
50
|
+
await page.goto(AUTH_URL, { waitUntil: 'domcontentloaded' });
|
|
51
|
+
console.log('Waiting to be redirected to https://www.musixmatch.com/discover ...');
|
|
52
|
+
await page.waitForURL('**/discover', { timeout: 0 });
|
|
53
|
+
|
|
54
|
+
const cookies = await context.cookies('https://www.musixmatch.com');
|
|
55
|
+
const userCookie = cookies.find((cookie) => cookie.name === 'musixmatchUserToken');
|
|
56
|
+
const desktopCookie = cookies.find((cookie) => cookie.name === 'web-desktop-app-v1.0');
|
|
57
|
+
if (!userCookie) {
|
|
58
|
+
console.error('musixmatchUserToken cookie not found; ensure you completed login.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const decoded = decodeURIComponent(userCookie.value);
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(decoded);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('Unable to parse musixmatchUserToken JSON payload. Raw value:');
|
|
67
|
+
console.error(decoded);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
console.log('\nMusixmatch token payload:');
|
|
71
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
72
|
+
|
|
73
|
+
await saveToken(parsed, desktopCookie ? decodeURIComponent(desktopCookie.value) : null);
|
|
74
|
+
|
|
75
|
+
// Extract the raw token string for the deployment hint.
|
|
76
|
+
// The parsed payload is the full musixmatchUserToken JSON object; the server
|
|
77
|
+
// stores and reads the entire parsed object as the `token` field.
|
|
78
|
+
printDeploymentBlock(parsed);
|
|
79
|
+
|
|
80
|
+
await browser.close();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main().catch((error) => {
|
|
84
|
+
console.error('Failed to fetch Musixmatch token:', error);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const endpoint = process.env.MR_MAGIC_MCP_ENDPOINT || 'http://127.0.0.1:3444/mcp';
|
|
4
|
+
let sessionId = null;
|
|
5
|
+
|
|
6
|
+
async function postJsonRpc(body) {
|
|
7
|
+
const headers = {
|
|
8
|
+
'content-type': 'application/json',
|
|
9
|
+
accept: 'application/json, text/event-stream'
|
|
10
|
+
};
|
|
11
|
+
if (sessionId) {
|
|
12
|
+
headers['mcp-session-id'] = sessionId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const response = await fetch(endpoint, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers,
|
|
18
|
+
body: JSON.stringify(body)
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const responseSessionId = response.headers.get('mcp-session-id');
|
|
22
|
+
if (responseSessionId) {
|
|
23
|
+
sessionId = responseSessionId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const text = await response.text();
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(text);
|
|
30
|
+
} catch {
|
|
31
|
+
parsed = { raw: text };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
status: response.status,
|
|
36
|
+
ok: response.ok,
|
|
37
|
+
body: parsed
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function initializeMcp() {
|
|
42
|
+
const initializeResponse = await postJsonRpc({
|
|
43
|
+
jsonrpc: '2.0',
|
|
44
|
+
id: 0,
|
|
45
|
+
method: 'initialize',
|
|
46
|
+
params: {
|
|
47
|
+
protocolVersion: '2024-11-05',
|
|
48
|
+
capabilities: {},
|
|
49
|
+
clientInfo: {
|
|
50
|
+
name: 'mcp-arg-boundary-repro',
|
|
51
|
+
version: '0.1.0'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await postJsonRpc({
|
|
57
|
+
jsonrpc: '2.0',
|
|
58
|
+
method: 'notifications/initialized',
|
|
59
|
+
params: {}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return initializeResponse;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function callMcp(id, name, args) {
|
|
66
|
+
const body = {
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
id,
|
|
69
|
+
method: 'tools/call',
|
|
70
|
+
params: {
|
|
71
|
+
name,
|
|
72
|
+
arguments: args
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return postJsonRpc(body);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function summarize(result) {
|
|
80
|
+
const json = result.body || {};
|
|
81
|
+
const isRpcError = Boolean(json?.error);
|
|
82
|
+
const errorMessage = json?.error?.message || null;
|
|
83
|
+
return {
|
|
84
|
+
httpStatus: result.status,
|
|
85
|
+
ok: result.ok,
|
|
86
|
+
rpcError: isRpcError,
|
|
87
|
+
errorMessage
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printCase(name, payload, result) {
|
|
92
|
+
console.log(`\n=== ${name} ===`);
|
|
93
|
+
console.log('request.arguments.type:', Array.isArray(payload) ? 'array' : typeof payload);
|
|
94
|
+
if (typeof payload === 'string') {
|
|
95
|
+
console.log('request.arguments.length:', payload.length);
|
|
96
|
+
}
|
|
97
|
+
console.log('result:', JSON.stringify(summarize(result), null, 2));
|
|
98
|
+
console.log('rawBody:', JSON.stringify(result.body, null, 2));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function run() {
|
|
102
|
+
console.log('MCP endpoint:', endpoint);
|
|
103
|
+
const init = await initializeMcp();
|
|
104
|
+
console.log('initialize:', JSON.stringify(summarize(init), null, 2));
|
|
105
|
+
|
|
106
|
+
const caseAArgs = {
|
|
107
|
+
track: { title: "I'LL SHOW YOU", artist: 'K/DA' },
|
|
108
|
+
options: { omitInlineLyrics: true, lyricsPayloadMode: 'payload', airtableSafePayload: true }
|
|
109
|
+
};
|
|
110
|
+
const caseA = await callMcp(1, 'build_catalog_payload', caseAArgs);
|
|
111
|
+
printCase('Case A - valid object args (recommended)', caseAArgs, caseA);
|
|
112
|
+
|
|
113
|
+
const caseBArgs = '{"track":{"title":"I\'LL SHOW YOU","artist":"K/DA"}';
|
|
114
|
+
const caseB = await callMcp(2, 'build_catalog_payload', caseBArgs);
|
|
115
|
+
printCase('Case B - malformed/truncated string args', caseBArgs, caseB);
|
|
116
|
+
|
|
117
|
+
const longLyrics = Array.from(
|
|
118
|
+
{ length: 120 },
|
|
119
|
+
(_, i) => `Line ${String(i + 1).padStart(3, '0')}: I'll show you ✨`
|
|
120
|
+
).join('\n');
|
|
121
|
+
const caseCObject = {
|
|
122
|
+
match: {
|
|
123
|
+
provider: 'debug',
|
|
124
|
+
result: {
|
|
125
|
+
provider: 'debug',
|
|
126
|
+
title: "I'LL SHOW YOU",
|
|
127
|
+
artist: 'K/DA',
|
|
128
|
+
plainLyrics: longLyrics
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const caseCStringArgs = JSON.stringify(caseCObject);
|
|
133
|
+
const caseC = await callMcp(3, 'select_match', caseCStringArgs);
|
|
134
|
+
printCase('Case C - large multiline payload via string args', caseCStringArgs, caseC);
|
|
135
|
+
|
|
136
|
+
const caseD = await callMcp(4, 'select_match', caseCObject);
|
|
137
|
+
printCase('Case D - large multiline payload via object args (safest)', caseCObject, caseD);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
run().catch((error) => {
|
|
141
|
+
console.error('Repro script failed:', error);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
+
import {
|
|
5
|
+
StreamableHTTPClientTransport,
|
|
6
|
+
StreamableHTTPError
|
|
7
|
+
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
8
|
+
|
|
9
|
+
const endpoint = process.env.MR_MAGIC_MCP_ENDPOINT || 'http://127.0.0.1:3444/mcp';
|
|
10
|
+
const debugHttp = process.env.MR_MAGIC_SDK_REPRO_HTTP_DEBUG === '1';
|
|
11
|
+
|
|
12
|
+
async function loggingFetch(input, init) {
|
|
13
|
+
const response = await fetch(input, init);
|
|
14
|
+
|
|
15
|
+
if (debugHttp) {
|
|
16
|
+
const method = init?.method || 'GET';
|
|
17
|
+
const url = typeof input === 'string' ? input : input?.url;
|
|
18
|
+
const responseClone = response.clone();
|
|
19
|
+
let responseText = '';
|
|
20
|
+
try {
|
|
21
|
+
responseText = await responseClone.text();
|
|
22
|
+
} catch {
|
|
23
|
+
responseText = '<failed to read response body>';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log('\n--- HTTP Debug ---');
|
|
27
|
+
console.log('request:', JSON.stringify({ method, url }, null, 2));
|
|
28
|
+
console.log(
|
|
29
|
+
'response:',
|
|
30
|
+
JSON.stringify(
|
|
31
|
+
{
|
|
32
|
+
status: response.status,
|
|
33
|
+
ok: response.ok,
|
|
34
|
+
contentType: response.headers.get('content-type'),
|
|
35
|
+
mcpSessionId: response.headers.get('mcp-session-id')
|
|
36
|
+
},
|
|
37
|
+
null,
|
|
38
|
+
2
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
console.log('responseBodyPreview:', responseText.slice(0, 1200));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function summarizeSuccess(result) {
|
|
48
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
49
|
+
const textBlocks = content
|
|
50
|
+
.filter((block) => block?.type === 'text' && typeof block?.text === 'string')
|
|
51
|
+
.map((block) => block.text);
|
|
52
|
+
const firstText = textBlocks[0] || null;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
transportError: false,
|
|
56
|
+
toolReportedError: Boolean(result?.isError),
|
|
57
|
+
hasStructuredContent: Boolean(result?.structuredContent),
|
|
58
|
+
contentBlocks: content.length,
|
|
59
|
+
firstTextPreview: firstText ? firstText.slice(0, 200) : null
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function summarizeFailure(error) {
|
|
64
|
+
const isStreamableHttpError = error instanceof StreamableHTTPError;
|
|
65
|
+
return {
|
|
66
|
+
transportError: true,
|
|
67
|
+
type: error?.name || 'Error',
|
|
68
|
+
message: error?.message || String(error),
|
|
69
|
+
httpCode: isStreamableHttpError ? error.code : null
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printCase(name, payload, response) {
|
|
74
|
+
console.log(`\n=== ${name} ===`);
|
|
75
|
+
console.log('request.arguments.type:', Array.isArray(payload) ? 'array' : typeof payload);
|
|
76
|
+
if (typeof payload === 'string') {
|
|
77
|
+
console.log('request.arguments.length:', payload.length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (response.ok) {
|
|
81
|
+
console.log('summary:', JSON.stringify(summarizeSuccess(response.result), null, 2));
|
|
82
|
+
console.log('rawResult:', JSON.stringify(response.result, null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('summary:', JSON.stringify(summarizeFailure(response.error), null, 2));
|
|
87
|
+
console.log(
|
|
88
|
+
'rawError:',
|
|
89
|
+
JSON.stringify(response.error, Object.getOwnPropertyNames(response.error || {}), 2)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runCase(client, id, title, toolName, args) {
|
|
94
|
+
try {
|
|
95
|
+
const result = await client.callTool({
|
|
96
|
+
name: toolName,
|
|
97
|
+
arguments: args
|
|
98
|
+
});
|
|
99
|
+
return { ok: true, id, title, result };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { ok: false, id, title, error };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function run() {
|
|
106
|
+
console.log('MCP endpoint:', endpoint);
|
|
107
|
+
|
|
108
|
+
const client = new Client(
|
|
109
|
+
{ name: 'mcp-arg-boundary-sdk-repro', version: '0.1.0' },
|
|
110
|
+
{ capabilities: {} }
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
|
|
114
|
+
fetch: loggingFetch
|
|
115
|
+
});
|
|
116
|
+
transport.onerror = (error) => {
|
|
117
|
+
console.error('[transport.onerror]', error);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await client.connect(transport);
|
|
121
|
+
console.log('connect: ok');
|
|
122
|
+
|
|
123
|
+
const tools = await client.listTools();
|
|
124
|
+
console.log(
|
|
125
|
+
'listTools:',
|
|
126
|
+
JSON.stringify(
|
|
127
|
+
{
|
|
128
|
+
toolCount: Array.isArray(tools?.tools) ? tools.tools.length : 0,
|
|
129
|
+
toolNames: Array.isArray(tools?.tools) ? tools.tools.map((tool) => tool.name) : []
|
|
130
|
+
},
|
|
131
|
+
null,
|
|
132
|
+
2
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const caseAArgs = {
|
|
137
|
+
track: { title: "I'LL SHOW YOU", artist: 'K/DA' },
|
|
138
|
+
options: { omitInlineLyrics: true, lyricsPayloadMode: 'payload', airtableSafePayload: true }
|
|
139
|
+
};
|
|
140
|
+
const caseA = await runCase(
|
|
141
|
+
client,
|
|
142
|
+
1,
|
|
143
|
+
'Case A - valid object args (recommended)',
|
|
144
|
+
'build_catalog_payload',
|
|
145
|
+
caseAArgs
|
|
146
|
+
);
|
|
147
|
+
printCase(caseA.title, caseAArgs, caseA);
|
|
148
|
+
|
|
149
|
+
const caseBArgs = '{"track":{"title":"I\'LL SHOW YOU","artist":"K/DA"}';
|
|
150
|
+
const caseB = await runCase(
|
|
151
|
+
client,
|
|
152
|
+
2,
|
|
153
|
+
'Case B - malformed/truncated string args',
|
|
154
|
+
'build_catalog_payload',
|
|
155
|
+
caseBArgs
|
|
156
|
+
);
|
|
157
|
+
printCase(caseB.title, caseBArgs, caseB);
|
|
158
|
+
|
|
159
|
+
const longLyrics = Array.from(
|
|
160
|
+
{ length: 120 },
|
|
161
|
+
(_, i) => `Line ${String(i + 1).padStart(3, '0')}: I'll show you ✨`
|
|
162
|
+
).join('\n');
|
|
163
|
+
const caseCObject = {
|
|
164
|
+
match: {
|
|
165
|
+
provider: 'debug',
|
|
166
|
+
result: {
|
|
167
|
+
provider: 'debug',
|
|
168
|
+
title: "I'LL SHOW YOU",
|
|
169
|
+
artist: 'K/DA',
|
|
170
|
+
plainLyrics: longLyrics
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const caseCStringArgs = JSON.stringify(caseCObject);
|
|
176
|
+
const caseC = await runCase(
|
|
177
|
+
client,
|
|
178
|
+
3,
|
|
179
|
+
'Case C - large multiline payload via string args',
|
|
180
|
+
'select_match',
|
|
181
|
+
caseCStringArgs
|
|
182
|
+
);
|
|
183
|
+
printCase(caseC.title, caseCStringArgs, caseC);
|
|
184
|
+
|
|
185
|
+
const caseD = await runCase(
|
|
186
|
+
client,
|
|
187
|
+
4,
|
|
188
|
+
'Case D - large multiline payload via object args (safest)',
|
|
189
|
+
'select_match',
|
|
190
|
+
caseCObject
|
|
191
|
+
);
|
|
192
|
+
printCase(caseD.title, caseCObject, caseD);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await transport.terminateSession();
|
|
196
|
+
} catch {
|
|
197
|
+
// Session termination is optional and may be unsupported in sessionless mode.
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await client.close();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
run().catch((error) => {
|
|
204
|
+
console.error('SDK repro script failed:', error);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { mcpToolDefinitions, handleMcpTool } from '../src/transport/mcp-tools.js';
|
|
4
|
+
import { buildMcpResponse } from '../src/transport/mcp-response.js';
|
|
5
|
+
|
|
6
|
+
const sampleTrack = {
|
|
7
|
+
title: 'Kill This Love',
|
|
8
|
+
artist: 'BLACKPINK'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
async function testToolRegistry() {
|
|
12
|
+
const toolNames = mcpToolDefinitions.map((tool) => tool.name);
|
|
13
|
+
const expected = [
|
|
14
|
+
'find_lyrics',
|
|
15
|
+
'build_catalog_payload',
|
|
16
|
+
'find_synced_lyrics',
|
|
17
|
+
'search_lyrics',
|
|
18
|
+
'search_provider',
|
|
19
|
+
'get_provider_status',
|
|
20
|
+
'export_lyrics',
|
|
21
|
+
'format_lyrics',
|
|
22
|
+
'select_match',
|
|
23
|
+
'runtime_status'
|
|
24
|
+
];
|
|
25
|
+
expected.forEach((tool) => {
|
|
26
|
+
assert.ok(toolNames.includes(tool), `expected tool ${tool}`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function testFindLyricsTool() {
|
|
31
|
+
const payload = await handleMcpTool('find_lyrics', { track: sampleTrack });
|
|
32
|
+
assert.ok(payload?.best, 'find_lyrics should return best match');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function testFindLyricsAllowsPartialTrack() {
|
|
36
|
+
const payload = await handleMcpTool('find_lyrics', { track: { title: sampleTrack.title } });
|
|
37
|
+
assert.ok(payload?.matches?.length > 0, 'find_lyrics should tolerate partial track metadata');
|
|
38
|
+
const firstResult = payload.matches[0]?.result;
|
|
39
|
+
assert.ok(
|
|
40
|
+
firstResult && Object.prototype.hasOwnProperty.call(firstResult, 'providerId'),
|
|
41
|
+
'normalized match results should expose providerId'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function testFindSyncedLyricsTool() {
|
|
46
|
+
const payload = await handleMcpTool('find_synced_lyrics', { track: sampleTrack });
|
|
47
|
+
assert.ok(payload);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function testSearchProviderRequiresProvider() {
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
() => handleMcpTool('search_provider', { track: sampleTrack }),
|
|
53
|
+
/provider is required/
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function testSearchProviderReturnsArray() {
|
|
58
|
+
const results = await handleMcpTool('search_provider', {
|
|
59
|
+
provider: 'lrclib',
|
|
60
|
+
track: sampleTrack
|
|
61
|
+
});
|
|
62
|
+
assert.ok(Array.isArray(results));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function testFormatLyricsShape() {
|
|
66
|
+
const response = await handleMcpTool('format_lyrics', {
|
|
67
|
+
track: sampleTrack,
|
|
68
|
+
options: { includeSynced: false }
|
|
69
|
+
});
|
|
70
|
+
assert.ok(response?.formatted || response?.error, 'format_lyrics should format or report error');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function testBuildCatalogPayload() {
|
|
74
|
+
const response = await handleMcpTool('build_catalog_payload', {
|
|
75
|
+
track: sampleTrack,
|
|
76
|
+
options: { preferRomanized: false }
|
|
77
|
+
});
|
|
78
|
+
assert.ok(response?.songVideoTitle, 'catalog payload should include songVideoTitle');
|
|
79
|
+
assert.ok(response?.lyrics, 'catalog payload should include lyrics');
|
|
80
|
+
assert.ok(response?.provider, 'catalog payload should include provider info');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function testBuildCatalogPayloadWithLyricsPayload() {
|
|
84
|
+
const response = await handleMcpTool('build_catalog_payload', {
|
|
85
|
+
track: sampleTrack,
|
|
86
|
+
options: {
|
|
87
|
+
preferRomanized: false,
|
|
88
|
+
omitInlineLyrics: true,
|
|
89
|
+
lyricsPayloadMode: 'payload'
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.ok(!response?.lyrics, 'inline lyrics should be omitted');
|
|
94
|
+
assert.ok(response?.lyricsPayload, 'lyricsPayload bundle should exist');
|
|
95
|
+
assert.equal(response.lyricsPayload.contentType, 'text/plain');
|
|
96
|
+
|
|
97
|
+
if (response.lyricsPayload.transport === 'inline') {
|
|
98
|
+
assert.ok(response.lyricsPayload.preview?.length > 0, 'preview should be populated');
|
|
99
|
+
assert.ok(response.lyricsPayload.content?.length > 0, 'inline payload should include content');
|
|
100
|
+
} else {
|
|
101
|
+
assert.equal(
|
|
102
|
+
response.lyricsPayload.transport,
|
|
103
|
+
'reference',
|
|
104
|
+
'payload mode should only resolve to inline or reference transport'
|
|
105
|
+
);
|
|
106
|
+
assert.ok(
|
|
107
|
+
response.lyricsPayload.reference,
|
|
108
|
+
'reference transport should include reference metadata'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function testBuildCatalogPayloadWithAirtableSafePayload() {
|
|
114
|
+
const response = await handleMcpTool('build_catalog_payload', {
|
|
115
|
+
track: sampleTrack,
|
|
116
|
+
options: {
|
|
117
|
+
preferRomanized: false,
|
|
118
|
+
omitInlineLyrics: true,
|
|
119
|
+
lyricsPayloadMode: 'payload',
|
|
120
|
+
airtableSafePayload: true
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.ok(
|
|
125
|
+
response?.lyricsPayload?.airtableEscapedContent,
|
|
126
|
+
'Airtable escaped content should exist'
|
|
127
|
+
);
|
|
128
|
+
assert.equal(response.lyricsPayload.transportRequested, 'inline');
|
|
129
|
+
assert.equal(response.lyricsPayload.compact, true);
|
|
130
|
+
assert.ok(!response.lyrics, 'inline lyrics should stay omitted');
|
|
131
|
+
assert.ok(
|
|
132
|
+
response.lyricsPayload.airtableEscapedContent.includes('\\n'),
|
|
133
|
+
'escaped content should include literal \\n'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function testSelectMatchErrors() {
|
|
138
|
+
const response = await handleMcpTool('select_match', { matches: [] });
|
|
139
|
+
assert.equal(response.error, 'No matches provided');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function testRuntimeStatusIncludesEnvOverview() {
|
|
143
|
+
const response = await handleMcpTool('runtime_status');
|
|
144
|
+
assert.ok(Array.isArray(response?.providers));
|
|
145
|
+
assert.ok(Array.isArray(response?.env));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function testMcpResponseHandlesMultilineLyrics() {
|
|
149
|
+
const lyricBlob = `This line has quotes "like this" and commas,
|
|
150
|
+
and spans multiple lines,
|
|
151
|
+
ending with unicode ♥`;
|
|
152
|
+
const result = {
|
|
153
|
+
provider: 'test-provider',
|
|
154
|
+
track: { title: 'Sample', artist: 'Tester' },
|
|
155
|
+
lyrics: lyricBlob,
|
|
156
|
+
extras: { airtableEscapedContent: 'Line 1\nLine 2\nLine 3' }
|
|
157
|
+
};
|
|
158
|
+
const response = buildMcpResponse(result);
|
|
159
|
+
assert.equal(
|
|
160
|
+
response.structuredContent,
|
|
161
|
+
result,
|
|
162
|
+
'structuredContent should pass through original object'
|
|
163
|
+
);
|
|
164
|
+
const primaryText = response.content?.[0]?.text;
|
|
165
|
+
assert.ok(
|
|
166
|
+
primaryText?.includes('This line has quotes "like this"'),
|
|
167
|
+
'lyric payload responses should expose full JSON as first content item'
|
|
168
|
+
);
|
|
169
|
+
assert.equal(
|
|
170
|
+
response.content?.length,
|
|
171
|
+
1,
|
|
172
|
+
'lyric payload responses should omit secondary summary preview text'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function testMcpResponseHandlesStringResults() {
|
|
177
|
+
const lyricString = 'Line 1\nLine 2\nLine 3 "quoted"';
|
|
178
|
+
const response = buildMcpResponse(lyricString);
|
|
179
|
+
assert.deepEqual(
|
|
180
|
+
response.structuredContent,
|
|
181
|
+
{ value: lyricString },
|
|
182
|
+
'structuredContent should wrap string payloads'
|
|
183
|
+
);
|
|
184
|
+
const summary = response.content?.[0]?.text;
|
|
185
|
+
assert.ok(
|
|
186
|
+
typeof summary === 'string' && summary.length > 0,
|
|
187
|
+
'summary text should exist for strings'
|
|
188
|
+
);
|
|
189
|
+
assert.ok(summary.includes('Line 1'), 'summary should include first line of lyrics');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function testMcpResponsePreservesArrayResults() {
|
|
193
|
+
const items = [{ provider: 'lrclib', result: { title: 'Song' } }];
|
|
194
|
+
const response = buildMcpResponse(items);
|
|
195
|
+
assert.deepEqual(
|
|
196
|
+
response.structuredContent,
|
|
197
|
+
{ items },
|
|
198
|
+
'structuredContent should wrap array payloads for MCP tools'
|
|
199
|
+
);
|
|
200
|
+
assert.ok(
|
|
201
|
+
response.content?.[1]?.text.includes('lrclib'),
|
|
202
|
+
'raw JSON content should include serialized array data'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function testExportLyricsReturnsFileUrl() {
|
|
207
|
+
const response = await handleMcpTool('export_lyrics', {
|
|
208
|
+
track: sampleTrack,
|
|
209
|
+
options: { formats: ['plain'] }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const plainExport = response?.exports?.plain;
|
|
213
|
+
if (plainExport && !plainExport.skipped) {
|
|
214
|
+
assert.ok(
|
|
215
|
+
plainExport.filePath || plainExport.url,
|
|
216
|
+
'plain export should include either filePath or url depending on storage backend'
|
|
217
|
+
);
|
|
218
|
+
if (plainExport.filePath) {
|
|
219
|
+
assert.ok(plainExport.url?.startsWith('file://'), 'local exports should include file URL');
|
|
220
|
+
} else {
|
|
221
|
+
assert.ok(
|
|
222
|
+
typeof plainExport.url === 'string' && plainExport.url.length > 0,
|
|
223
|
+
'remote exports should include url'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function run() {
|
|
230
|
+
await testToolRegistry();
|
|
231
|
+
await testFindLyricsTool();
|
|
232
|
+
await testFindLyricsAllowsPartialTrack();
|
|
233
|
+
await testFindSyncedLyricsTool();
|
|
234
|
+
await testSearchProviderRequiresProvider();
|
|
235
|
+
await testSearchProviderReturnsArray();
|
|
236
|
+
await testFormatLyricsShape();
|
|
237
|
+
await testBuildCatalogPayload();
|
|
238
|
+
await testBuildCatalogPayloadWithLyricsPayload();
|
|
239
|
+
await testBuildCatalogPayloadWithAirtableSafePayload();
|
|
240
|
+
await testSelectMatchErrors();
|
|
241
|
+
await testRuntimeStatusIncludesEnvOverview();
|
|
242
|
+
await testMcpResponseHandlesMultilineLyrics();
|
|
243
|
+
await testMcpResponseHandlesStringResults();
|
|
244
|
+
await testMcpResponsePreservesArrayResults();
|
|
245
|
+
await testExportLyricsReturnsFileUrl();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
run().catch((error) => {
|
|
249
|
+
console.error(error);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { selectMatch } from '../index.js';
|
|
5
|
+
import { buildChooserEntries, autoPick } from '../core/find-service.js';
|
|
6
|
+
import { normalizeLyricRecord, detectSyncedState } from '../provider-result-schema.js';
|
|
7
|
+
import { mcpToolDefinitions, handleMcpTool } from '../transport/mcp-tools.js';
|
|
8
|
+
|
|
9
|
+
const divider = () => console.log('\n---');
|
|
10
|
+
|
|
11
|
+
function mockRecord({
|
|
12
|
+
provider,
|
|
13
|
+
synced = false,
|
|
14
|
+
confidence = 0.5,
|
|
15
|
+
title = 'Song',
|
|
16
|
+
artist = 'Artist',
|
|
17
|
+
plainLyrics = synced ? null : 'plain',
|
|
18
|
+
syncedLyrics = synced ? '[00:00.00] synced line\n[00:01.00] next' : null
|
|
19
|
+
}) {
|
|
20
|
+
return {
|
|
21
|
+
provider,
|
|
22
|
+
result: {
|
|
23
|
+
provider,
|
|
24
|
+
synced,
|
|
25
|
+
confidence,
|
|
26
|
+
title,
|
|
27
|
+
artist,
|
|
28
|
+
plainLyrics,
|
|
29
|
+
syncedLyrics
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function testAutoPickPrefersSynced() {
|
|
35
|
+
const matches = [
|
|
36
|
+
mockRecord({ provider: 'plain', synced: false }),
|
|
37
|
+
mockRecord({ provider: 'synced', synced: true })
|
|
38
|
+
];
|
|
39
|
+
const chooser = buildChooserEntries(matches);
|
|
40
|
+
const picked = autoPick(chooser, true);
|
|
41
|
+
assert.equal(picked.provider, 'synced');
|
|
42
|
+
divider();
|
|
43
|
+
console.log('autoPick prefers synced: ok');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function testAutoPickFallbackWhenNoSynced() {
|
|
47
|
+
const matches = [
|
|
48
|
+
mockRecord({ provider: 'plainA', synced: false }),
|
|
49
|
+
mockRecord({ provider: 'plainB', synced: false })
|
|
50
|
+
];
|
|
51
|
+
const chooser = buildChooserEntries(matches);
|
|
52
|
+
const picked = autoPick(chooser, true);
|
|
53
|
+
assert.equal(picked.provider, 'plainA');
|
|
54
|
+
divider();
|
|
55
|
+
console.log('autoPick fallback first result: ok');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function testSelectMatchRespectsProviderAndSynced() {
|
|
59
|
+
const matches = [
|
|
60
|
+
{ provider: 'lrclib', result: { synced: true } },
|
|
61
|
+
{ provider: 'genius', result: { synced: false } }
|
|
62
|
+
];
|
|
63
|
+
const syncedOnly = selectMatch(matches, { requireSynced: true });
|
|
64
|
+
assert.equal(syncedOnly.provider, 'lrclib');
|
|
65
|
+
const filtered = selectMatch(matches, { providerName: 'genius' });
|
|
66
|
+
assert.equal(filtered.provider, 'genius');
|
|
67
|
+
divider();
|
|
68
|
+
console.log('selectMatch provider/synced filtering: ok');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function testNormalizationDetectsSyncedState() {
|
|
72
|
+
const raw = {
|
|
73
|
+
provider: 'test',
|
|
74
|
+
id: '1',
|
|
75
|
+
trackName: 'Song',
|
|
76
|
+
artistName: 'Artist',
|
|
77
|
+
syncedLyrics: '[00:00.00] hi\n[00:02.00] bye',
|
|
78
|
+
plainLyrics: 'hi\nbye',
|
|
79
|
+
confidence: 0.5
|
|
80
|
+
};
|
|
81
|
+
const normalized = normalizeLyricRecord(raw);
|
|
82
|
+
assert.equal(normalized.synced, true);
|
|
83
|
+
assert.equal(normalized.plainOnly, false);
|
|
84
|
+
const { hasSynced, timestampCount } = detectSyncedState('[00:00.00] hi');
|
|
85
|
+
assert.equal(hasSynced, false);
|
|
86
|
+
assert.equal(timestampCount, 1);
|
|
87
|
+
divider();
|
|
88
|
+
console.log('normalize/detect synced state: ok');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function run() {
|
|
92
|
+
testAutoPickPrefersSynced();
|
|
93
|
+
testAutoPickFallbackWhenNoSynced();
|
|
94
|
+
testSelectMatchRespectsProviderAndSynced();
|
|
95
|
+
testNormalizationDetectsSyncedState();
|
|
96
|
+
const toolNames = mcpToolDefinitions.map((tool) => tool.name);
|
|
97
|
+
console.log('MCP tooling available:', toolNames.join(', '));
|
|
98
|
+
console.log('All sanity checks passed');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
run().catch((error) => {
|
|
102
|
+
console.error(error);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
});
|
|
@@ -87,7 +87,15 @@ function createMcpServer() {
|
|
|
87
87
|
export async function startMcpHttpServer(options = {}) {
|
|
88
88
|
const logger = createLogger('mcp-http-server');
|
|
89
89
|
const httpDiagnostics = process.env.MR_MAGIC_MCP_HTTP_DIAGNOSTICS === '1';
|
|
90
|
-
|
|
90
|
+
// Sessionless mode: skip persistent in-memory session tracking so every
|
|
91
|
+
// request is handled independently. This is required on platforms like
|
|
92
|
+
// Render.com that run multiple instances (a session created on instance A is
|
|
93
|
+
// invisible to instance B). Auto-enable when the RENDER env var is present,
|
|
94
|
+
// or when MR_MAGIC_SESSIONLESS=1 is set explicitly.
|
|
95
|
+
const configuredSessionless =
|
|
96
|
+
Boolean(options.sessionless) ||
|
|
97
|
+
Boolean(process.env.MR_MAGIC_SESSIONLESS) ||
|
|
98
|
+
Boolean(process.env.RENDER);
|
|
91
99
|
const host = options.remote
|
|
92
100
|
? '0.0.0.0'
|
|
93
101
|
: options.host || process.env.HOST || (process.env.RENDER ? '0.0.0.0' : '127.0.0.1');
|
|
@@ -23,7 +23,7 @@ const logger = createLogger('genius-token-manager');
|
|
|
23
23
|
// (i.e. local development). Ephemeral hosts (Render free tier, etc.)
|
|
24
24
|
// should use the auto-refresh or fallback token paths instead.
|
|
25
25
|
|
|
26
|
-
// Token cache path — must match the path used by scripts/fetch_genius_token.mjs.
|
|
26
|
+
// Token cache path — must match the path used by src/scripts/fetch_genius_token.mjs.
|
|
27
27
|
const TOKEN_CACHE_PATH =
|
|
28
28
|
process.env.GENIUS_TOKEN_CACHE || path.join(getProjectRoot(), '.cache', 'genius-token.json');
|
|
29
29
|
|