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 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 | Transport | When to use |
574
- | ---- | --------- | ----------- |
575
- | **Local (stdio)** | `mcp-server` binary via stdin/stdout | Cline, Claude Desktop, and any client that runs locally on the same machine |
576
- | **Remote (Streamable HTTP)** | `POST https://your-server.com/mcp` | TypingMind, browser-based clients, and any client connecting to a deployed server |
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.21",
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": ">=18.17"
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) Progress reporting rules
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
- When reporting progress:
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
- - show how many songs are being processed in the current batch
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
- const configuredSessionless = Boolean(options.sessionless);
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