@mixmake/cli 0.1.1 → 0.1.5
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 +36 -13
- package/package.json +4 -3
- package/src/config.js +5 -2
- package/src/index.js +172 -40
- package/src/localUpload.js +42 -0
- package/src/localUpload.test.js +87 -0
- package/src/mcpClient.js +106 -16
- package/src/mcpClient.test.js +201 -0
- package/src/parseJson.js +8 -0
- package/src/parseJson.test.js +17 -0
package/README.md
CHANGED
|
@@ -4,26 +4,29 @@ Official command-line client for MixMake MCP.
|
|
|
4
4
|
|
|
5
5
|
It handles MCP initialize/session details for common workflows:
|
|
6
6
|
- get API key
|
|
7
|
-
- transcribe audio
|
|
7
|
+
- transcribe audio (from URL, existing `audio_id`, or a local `.mp3` / `.wav` / `.m4a` via `transcribe --file`)
|
|
8
8
|
- edit transcript deletions
|
|
9
9
|
- render output
|
|
10
10
|
- run diagnostics
|
|
11
11
|
|
|
12
12
|
## Quick start
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
npx @mixmake/cli@latest doctor
|
|
16
|
-
```
|
|
14
|
+
Use `doctor` first, then follow the Commands block below.
|
|
17
15
|
|
|
18
16
|
## Commands
|
|
19
17
|
|
|
18
|
+
When passing flags, include `--` after the package name so args go to MixMake CLI (not npm).
|
|
19
|
+
|
|
20
20
|
```bash
|
|
21
|
-
npx @mixmake/cli@latest doctor
|
|
22
|
-
npx @mixmake/cli@latest
|
|
23
|
-
npx @mixmake/cli@latest
|
|
24
|
-
npx @mixmake/cli@latest
|
|
25
|
-
npx @mixmake/cli@latest
|
|
26
|
-
npx @mixmake/cli@latest
|
|
21
|
+
npx -y @mixmake/cli@latest -- doctor
|
|
22
|
+
npx -y @mixmake/cli@latest -- doctor --verbose
|
|
23
|
+
npx -y @mixmake/cli@latest -- setup --email you@example.com
|
|
24
|
+
npx -y @mixmake/cli@latest -- key --email you@example.com
|
|
25
|
+
npx -y @mixmake/cli@latest -- transcribe --audio-url https://example.com/file.mp3
|
|
26
|
+
npx -y @mixmake/cli@latest -- transcribe --audio-id <audio_id>
|
|
27
|
+
npx -y @mixmake/cli@latest -- transcribe --file ./recording.mp3
|
|
28
|
+
npx -y @mixmake/cli@latest -- edit --transcript-id <id> --delete id1,id2,id3
|
|
29
|
+
npx -y @mixmake/cli@latest -- render --transcript-id <id>
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
## Configuration
|
|
@@ -36,7 +39,7 @@ Example:
|
|
|
36
39
|
|
|
37
40
|
```json
|
|
38
41
|
{
|
|
39
|
-
"endpoint": "https://mixmake.com/api/mcp",
|
|
42
|
+
"endpoint": "https://www.mixmake.com/api/mcp",
|
|
40
43
|
"apiKey": "mmk_..."
|
|
41
44
|
}
|
|
42
45
|
```
|
|
@@ -47,16 +50,36 @@ Environment overrides:
|
|
|
47
50
|
|
|
48
51
|
## Troubleshooting
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
For diagnostics, run:
|
|
51
54
|
|
|
52
55
|
```bash
|
|
53
|
-
npx @mixmake/cli@latest doctor
|
|
56
|
+
npx -y @mixmake/cli@latest -- doctor --verbose
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
Session notes:
|
|
60
|
+
- If a replay-safe command hits `Unknown session` or `Session expired`, CLI re-initializes once and retries automatically.
|
|
61
|
+
- CLI intentionally does **not** auto-replay potentially side-effecting commands like `transcribe` and `render` to avoid duplicate jobs.
|
|
62
|
+
|
|
63
|
+
Local file notes:
|
|
64
|
+
- `transcribe --file` uploads via signed URL under the hood, then starts transcription (same MCP `upload_audio` + `transcribe_audio` flow as the server). You need a MixMake **API key**. Run `setup` or `key` first.
|
|
65
|
+
- The JSON output includes `transcript_id` and `audio_id`; use **`transcript_id`** with `edit` / `render` once transcription completes (editing does not re-run transcription).
|
|
66
|
+
- Max file size **100MB**. Extensions: **.mp3**, **.wav**, **.m4a**.
|
|
67
|
+
|
|
68
|
+
Setup notes:
|
|
69
|
+
- `setup` validates MCP connectivity via `tools/list`, fetches an API key, and saves it to config.
|
|
70
|
+
- Use `doctor --verbose` for lightweight request traces (`stage`, status, endpoint, request id).
|
|
71
|
+
|
|
56
72
|
If you contact support, include:
|
|
57
73
|
- request id (if present)
|
|
58
74
|
- command you ran
|
|
59
75
|
- timestamp and endpoint URL
|
|
76
|
+
- contact email: teammixmake@gmail.com
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cd cli && npm test
|
|
82
|
+
```
|
|
60
83
|
|
|
61
84
|
## Links
|
|
62
85
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mixmake/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MixMake CLI for MCP happy-path workflows",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://mixmake.com/for-agents",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"
|
|
14
|
+
"email": "teammixmake@gmail.com"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"mixmake",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node ./src/index.js",
|
|
28
|
-
"doctor": "node ./src/index.js doctor"
|
|
28
|
+
"doctor": "node ./src/index.js doctor",
|
|
29
|
+
"test": "node --test src/*.test.js"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=20"
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
const DEFAULT_ENDPOINT = 'https://mixmake.com/api/mcp';
|
|
5
|
+
const DEFAULT_ENDPOINT = 'https://www.mixmake.com/api/mcp';
|
|
6
6
|
const CONFIG_DIR = path.join(homedir(), '.config', 'mixmake');
|
|
7
7
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
8
8
|
|
|
@@ -14,7 +14,10 @@ export async function loadConfig() {
|
|
|
14
14
|
endpoint: parsed.endpoint || DEFAULT_ENDPOINT,
|
|
15
15
|
apiKey: parsed.apiKey || null,
|
|
16
16
|
};
|
|
17
|
-
} catch {
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
18
21
|
return {
|
|
19
22
|
endpoint: DEFAULT_ENDPOINT,
|
|
20
23
|
apiKey: null,
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
callRpc,
|
|
6
|
+
callToolWithOptions,
|
|
7
|
+
extractToolText,
|
|
8
|
+
initializeWithOptions,
|
|
9
|
+
shouldReinitializeSession,
|
|
10
|
+
} from './mcpClient.js';
|
|
3
11
|
import { getConfigPath, loadConfig, saveConfig, withEnvOverrides } from './config.js';
|
|
12
|
+
import { tryParseJson } from './parseJson.js';
|
|
13
|
+
import {
|
|
14
|
+
defaultUploadLabel,
|
|
15
|
+
putBufferToSignedUrl,
|
|
16
|
+
readAudioFileForUpload,
|
|
17
|
+
} from './localUpload.js';
|
|
18
|
+
|
|
19
|
+
function toolResultForCli(out) {
|
|
20
|
+
const text = extractToolText(out.payload);
|
|
21
|
+
const parsed = tryParseJson(text);
|
|
22
|
+
return { text, parsed, display: parsed ?? out.payload };
|
|
23
|
+
}
|
|
4
24
|
|
|
5
25
|
function parseArgs(argv) {
|
|
6
26
|
const positionals = [];
|
|
@@ -23,13 +43,35 @@ function parseArgs(argv) {
|
|
|
23
43
|
return { positionals, flags };
|
|
24
44
|
}
|
|
25
45
|
|
|
46
|
+
async function uploadLocalThroughMcp({ endpoint, apiKey, filePath, labelFlag, trace }) {
|
|
47
|
+
const { buffer, contentType } = await readAudioFileForUpload(filePath);
|
|
48
|
+
const label = defaultUploadLabel(filePath, labelFlag);
|
|
49
|
+
const out = await callToolWithSessionRecovery({
|
|
50
|
+
endpoint,
|
|
51
|
+
apiKey,
|
|
52
|
+
name: 'upload_audio',
|
|
53
|
+
args: { label },
|
|
54
|
+
allowReplay: false,
|
|
55
|
+
trace,
|
|
56
|
+
});
|
|
57
|
+
const { parsed } = toolResultForCli(out);
|
|
58
|
+
const uploadUrl = parsed?.upload_url;
|
|
59
|
+
const audioId = parsed?.audio_id;
|
|
60
|
+
if (typeof uploadUrl !== 'string' || typeof audioId !== 'string') {
|
|
61
|
+
throw new Error('upload_audio did not return upload_url and audio_id');
|
|
62
|
+
}
|
|
63
|
+
await putBufferToSignedUrl(uploadUrl, buffer, contentType);
|
|
64
|
+
return { audio_id: audioId, label };
|
|
65
|
+
}
|
|
66
|
+
|
|
26
67
|
function usage() {
|
|
27
68
|
console.log(`mixmake <command> [flags]
|
|
28
69
|
|
|
29
70
|
Commands:
|
|
30
|
-
doctor
|
|
71
|
+
doctor [--verbose]
|
|
72
|
+
setup [--email <email>] [--verbose]
|
|
31
73
|
key --email <email>
|
|
32
|
-
transcribe --audio-url <url> | --audio-id <id> [--label <label>]
|
|
74
|
+
transcribe --audio-url <url> | --audio-id <id> | --file <path> [--label <label>]
|
|
33
75
|
edit --transcript-id <id> --delete <id1,id2,...>
|
|
34
76
|
render --transcript-id <id>
|
|
35
77
|
|
|
@@ -45,6 +87,47 @@ function printResult(label, data) {
|
|
|
45
87
|
console.log(JSON.stringify(data, null, 2));
|
|
46
88
|
}
|
|
47
89
|
|
|
90
|
+
function printRecoveryNotice(toolName) {
|
|
91
|
+
console.error(`Session expired or drifted while calling ${toolName}; re-initializing once and retrying.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function callToolWithSessionRecovery({
|
|
95
|
+
endpoint,
|
|
96
|
+
apiKey,
|
|
97
|
+
name,
|
|
98
|
+
args,
|
|
99
|
+
allowReplay = false,
|
|
100
|
+
trace,
|
|
101
|
+
}) {
|
|
102
|
+
const invoke = (init) =>
|
|
103
|
+
callToolWithOptions(
|
|
104
|
+
{ endpoint, sessionId: init.sessionId, token: init.token, apiKey, name, args },
|
|
105
|
+
{ trace }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
let init = await initializeWithOptions(endpoint, { trace });
|
|
109
|
+
try {
|
|
110
|
+
return await invoke(init);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (!allowReplay || !shouldReinitializeSession(err)) throw err;
|
|
113
|
+
printRecoveryNotice(name);
|
|
114
|
+
init = await initializeWithOptions(endpoint, { trace });
|
|
115
|
+
return invoke(init);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function resolveSetupEmail(flagEmail) {
|
|
120
|
+
if (typeof flagEmail === 'string' && flagEmail.trim()) return flagEmail.trim();
|
|
121
|
+
if (!process.stdin.isTTY) return null;
|
|
122
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
123
|
+
try {
|
|
124
|
+
const email = (await rl.question('Email for MixMake API key: ')).trim();
|
|
125
|
+
return email || null;
|
|
126
|
+
} finally {
|
|
127
|
+
rl.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
48
131
|
async function main() {
|
|
49
132
|
const { positionals, flags } = parseArgs(process.argv.slice(2));
|
|
50
133
|
const cmd = positionals[0];
|
|
@@ -55,9 +138,15 @@ async function main() {
|
|
|
55
138
|
|
|
56
139
|
const config = withEnvOverrides(await loadConfig());
|
|
57
140
|
const endpoint = config.endpoint;
|
|
141
|
+
const verbose = Boolean(flags.verbose);
|
|
142
|
+
const trace = verbose
|
|
143
|
+
? (event) => {
|
|
144
|
+
console.error('[trace]', JSON.stringify(event));
|
|
145
|
+
}
|
|
146
|
+
: undefined;
|
|
58
147
|
|
|
59
148
|
if (cmd === 'doctor') {
|
|
60
|
-
const init = await
|
|
149
|
+
const init = await initializeWithOptions(endpoint, { trace });
|
|
61
150
|
printResult('doctor', {
|
|
62
151
|
ok: true,
|
|
63
152
|
endpoint,
|
|
@@ -69,30 +158,56 @@ async function main() {
|
|
|
69
158
|
return;
|
|
70
159
|
}
|
|
71
160
|
|
|
161
|
+
if (cmd === 'setup') {
|
|
162
|
+
const email = await resolveSetupEmail(flags.email);
|
|
163
|
+
if (!email) throw new Error('No email provided. Use --email or enter one at prompt.');
|
|
164
|
+
const init = await initializeWithOptions(endpoint, { trace });
|
|
165
|
+
const session = {
|
|
166
|
+
endpoint,
|
|
167
|
+
sessionId: init.sessionId,
|
|
168
|
+
token: init.token,
|
|
169
|
+
apiKey: config.apiKey,
|
|
170
|
+
};
|
|
171
|
+
const listOut = await callRpc({ ...session, method: 'tools/list' }, { trace });
|
|
172
|
+
const keyOut = await callToolWithOptions({ ...session, name: 'get_api_key', args: { email } }, { trace });
|
|
173
|
+
const { text, parsed } = toolResultForCli(keyOut);
|
|
174
|
+
const key = parsed?.api_key || parsed?.key || null;
|
|
175
|
+
if (!key) {
|
|
176
|
+
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
177
|
+
throw new Error(trimmed || 'get_api_key did not return a parsable key');
|
|
178
|
+
}
|
|
179
|
+
if (!process.env.MIXMAKE_API_KEY) {
|
|
180
|
+
await saveConfig({ endpoint, apiKey: key });
|
|
181
|
+
}
|
|
182
|
+
printResult('setup', {
|
|
183
|
+
ok: true,
|
|
184
|
+
endpoint,
|
|
185
|
+
email,
|
|
186
|
+
config_path: getConfigPath(),
|
|
187
|
+
tools_count: listOut.payload?.result?.tools?.length ?? null,
|
|
188
|
+
request_id: keyOut.requestId || init.requestId || null,
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
72
193
|
if (cmd === 'key') {
|
|
73
194
|
const email = flags.email;
|
|
74
195
|
if (!email || typeof email !== 'string') throw new Error('--email is required');
|
|
75
|
-
const
|
|
76
|
-
const out = await callTool({
|
|
196
|
+
const out = await callToolWithSessionRecovery({
|
|
77
197
|
endpoint,
|
|
78
|
-
sessionId: init.sessionId,
|
|
79
|
-
token: init.token,
|
|
80
198
|
apiKey: config.apiKey,
|
|
81
199
|
name: 'get_api_key',
|
|
82
200
|
args: { email },
|
|
201
|
+
allowReplay: true,
|
|
202
|
+
trace,
|
|
83
203
|
});
|
|
84
|
-
const
|
|
85
|
-
printResult('get_api_key',
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await saveConfig({ endpoint, apiKey: key });
|
|
92
|
-
console.log(`\nSaved api_key to ${getConfigPath()}`);
|
|
93
|
-
}
|
|
94
|
-
} catch {
|
|
95
|
-
// no-op: only auto-save if response shape is predictable JSON
|
|
204
|
+
const { parsed, display } = toolResultForCli(out);
|
|
205
|
+
printResult('get_api_key', display);
|
|
206
|
+
|
|
207
|
+
const key = parsed?.api_key || parsed?.key || null;
|
|
208
|
+
if (key && !process.env.MIXMAKE_API_KEY) {
|
|
209
|
+
await saveConfig({ endpoint, apiKey: key });
|
|
210
|
+
console.log(`\nSaved api_key to ${getConfigPath()}`);
|
|
96
211
|
}
|
|
97
212
|
return;
|
|
98
213
|
}
|
|
@@ -100,19 +215,38 @@ async function main() {
|
|
|
100
215
|
if (cmd === 'transcribe') {
|
|
101
216
|
const audioUrl = flags['audio-url'];
|
|
102
217
|
const audioId = flags['audio-id'];
|
|
218
|
+
const file = flags.file;
|
|
103
219
|
const label = flags.label;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
220
|
+
const sources = [audioUrl, audioId, file].filter((x) => typeof x === 'string' && x).length;
|
|
221
|
+
if (sources !== 1) {
|
|
222
|
+
throw new Error('Provide exactly one of --audio-url, --audio-id, or --file');
|
|
223
|
+
}
|
|
224
|
+
let resolvedUrl;
|
|
225
|
+
let resolvedId;
|
|
226
|
+
if (typeof audioUrl === 'string' && audioUrl) {
|
|
227
|
+
resolvedUrl = audioUrl;
|
|
228
|
+
} else if (typeof audioId === 'string' && audioId) {
|
|
229
|
+
resolvedId = audioId;
|
|
230
|
+
} else {
|
|
231
|
+
const up = await uploadLocalThroughMcp({
|
|
232
|
+
endpoint,
|
|
233
|
+
apiKey: config.apiKey,
|
|
234
|
+
filePath: file,
|
|
235
|
+
labelFlag: label,
|
|
236
|
+
trace,
|
|
237
|
+
});
|
|
238
|
+
resolvedId = up.audio_id;
|
|
239
|
+
}
|
|
240
|
+
const out = await callToolWithSessionRecovery({
|
|
107
241
|
endpoint,
|
|
108
|
-
sessionId: init.sessionId,
|
|
109
|
-
token: init.token,
|
|
110
242
|
apiKey: config.apiKey,
|
|
111
243
|
name: 'transcribe_audio',
|
|
112
|
-
args: { audio_url:
|
|
244
|
+
args: { audio_url: resolvedUrl, audio_id: resolvedId, label },
|
|
245
|
+
allowReplay: false,
|
|
246
|
+
trace,
|
|
113
247
|
});
|
|
114
|
-
const
|
|
115
|
-
printResult('transcribe_audio',
|
|
248
|
+
const result = toolResultForCli(out);
|
|
249
|
+
printResult('transcribe_audio', result.display);
|
|
116
250
|
return;
|
|
117
251
|
}
|
|
118
252
|
|
|
@@ -125,34 +259,32 @@ async function main() {
|
|
|
125
259
|
.split(',')
|
|
126
260
|
.map((s) => s.trim())
|
|
127
261
|
.filter(Boolean);
|
|
128
|
-
const
|
|
129
|
-
const out = await callTool({
|
|
262
|
+
const out = await callToolWithSessionRecovery({
|
|
130
263
|
endpoint,
|
|
131
|
-
sessionId: init.sessionId,
|
|
132
|
-
token: init.token,
|
|
133
264
|
apiKey: config.apiKey,
|
|
134
265
|
name: 'edit_transcript',
|
|
135
266
|
args: { transcript_id: transcriptId, deleted_ids: deletedIds },
|
|
267
|
+
allowReplay: true,
|
|
268
|
+
trace,
|
|
136
269
|
});
|
|
137
|
-
const
|
|
138
|
-
printResult('edit_transcript',
|
|
270
|
+
const result = toolResultForCli(out);
|
|
271
|
+
printResult('edit_transcript', result.display);
|
|
139
272
|
return;
|
|
140
273
|
}
|
|
141
274
|
|
|
142
275
|
if (cmd === 'render') {
|
|
143
276
|
const transcriptId = flags['transcript-id'];
|
|
144
277
|
if (!transcriptId || typeof transcriptId !== 'string') throw new Error('--transcript-id is required');
|
|
145
|
-
const
|
|
146
|
-
const out = await callTool({
|
|
278
|
+
const out = await callToolWithSessionRecovery({
|
|
147
279
|
endpoint,
|
|
148
|
-
sessionId: init.sessionId,
|
|
149
|
-
token: init.token,
|
|
150
280
|
apiKey: config.apiKey,
|
|
151
281
|
name: 'render_audio',
|
|
152
282
|
args: { transcript_id: transcriptId },
|
|
283
|
+
allowReplay: false,
|
|
284
|
+
trace,
|
|
153
285
|
});
|
|
154
|
-
const
|
|
155
|
-
printResult('render_audio',
|
|
286
|
+
const result = toolResultForCli(out);
|
|
287
|
+
printResult('render_audio', result.display);
|
|
156
288
|
return;
|
|
157
289
|
}
|
|
158
290
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const MAX_AUDIO_BYTES = 100 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
/** Rejects byte sizes over the CLI upload limit. */
|
|
7
|
+
export function assertWithinMaxAudioBytes(size) {
|
|
8
|
+
if (size > MAX_AUDIO_BYTES) throw new Error('File exceeds 100MB limit');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function audioContentTypeForPath(filePath) {
|
|
12
|
+
const lower = filePath.toLowerCase();
|
|
13
|
+
if (lower.endsWith('.mp3')) return 'audio/mpeg';
|
|
14
|
+
if (lower.endsWith('.wav')) return 'audio/wav';
|
|
15
|
+
if (lower.endsWith('.m4a')) return 'audio/mp4';
|
|
16
|
+
throw new Error('Unsupported extension: use .mp3, .wav, or .m4a');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readAudioFileForUpload(filePath) {
|
|
20
|
+
const st = await stat(filePath);
|
|
21
|
+
assertWithinMaxAudioBytes(st.size);
|
|
22
|
+
const buffer = await readFile(filePath);
|
|
23
|
+
const contentType = audioContentTypeForPath(filePath);
|
|
24
|
+
return { buffer, contentType, size: st.size };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function putBufferToSignedUrl(uploadUrl, buffer, contentType) {
|
|
28
|
+
const res = await fetch(uploadUrl, {
|
|
29
|
+
method: 'PUT',
|
|
30
|
+
headers: { 'Content-Type': contentType },
|
|
31
|
+
body: buffer,
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
const detail = (await res.text()).trim().slice(0, 200);
|
|
35
|
+
throw new Error(`Storage upload failed (${res.status}): ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function defaultUploadLabel(filePath, labelFlag) {
|
|
40
|
+
if (typeof labelFlag === 'string' && labelFlag.trim()) return labelFlag.trim();
|
|
41
|
+
return basename(filePath);
|
|
42
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
MAX_AUDIO_BYTES,
|
|
8
|
+
assertWithinMaxAudioBytes,
|
|
9
|
+
audioContentTypeForPath,
|
|
10
|
+
defaultUploadLabel,
|
|
11
|
+
putBufferToSignedUrl,
|
|
12
|
+
readAudioFileForUpload,
|
|
13
|
+
} from './localUpload.js';
|
|
14
|
+
|
|
15
|
+
test('audioContentTypeForPath maps extensions', () => {
|
|
16
|
+
assert.equal(audioContentTypeForPath('/a/b.X.mp3'), 'audio/mpeg');
|
|
17
|
+
assert.equal(audioContentTypeForPath('c.wav'), 'audio/wav');
|
|
18
|
+
assert.equal(audioContentTypeForPath('d.m4a'), 'audio/mp4');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('audioContentTypeForPath rejects unknown extension', () => {
|
|
22
|
+
assert.throws(() => audioContentTypeForPath('x.flac'), /Unsupported extension/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('defaultUploadLabel prefers flag', () => {
|
|
26
|
+
assert.equal(defaultUploadLabel('/path/to/foo.mp3', ' my label '), 'my label');
|
|
27
|
+
assert.equal(defaultUploadLabel('/path/to/foo.mp3', undefined), 'foo.mp3');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('readAudioFileForUpload reads small file', async (t) => {
|
|
31
|
+
const dir = await mkdtemp(join(tmpdir(), 'mixmake-cli-test-'));
|
|
32
|
+
const filePath = join(dir, 't.mp3');
|
|
33
|
+
await writeFile(filePath, Buffer.from([1, 2, 3]));
|
|
34
|
+
t.after(async () => {
|
|
35
|
+
await rm(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
const { buffer, contentType, size } = await readAudioFileForUpload(filePath);
|
|
38
|
+
assert.equal(contentType, 'audio/mpeg');
|
|
39
|
+
assert.equal(size, 3);
|
|
40
|
+
assert.equal(buffer.length, 3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('assertWithinMaxAudioBytes allows at limit', () => {
|
|
44
|
+
assertWithinMaxAudioBytes(MAX_AUDIO_BYTES);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('assertWithinMaxAudioBytes rejects over limit', () => {
|
|
48
|
+
assert.throws(() => assertWithinMaxAudioBytes(MAX_AUDIO_BYTES + 1), /100MB/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('putBufferToSignedUrl PUTs buffer and returns on OK', async (t) => {
|
|
52
|
+
const orig = globalThis.fetch;
|
|
53
|
+
t.after(() => {
|
|
54
|
+
globalThis.fetch = orig;
|
|
55
|
+
});
|
|
56
|
+
let seenUrl;
|
|
57
|
+
let seenInit;
|
|
58
|
+
globalThis.fetch = async (url, init) => {
|
|
59
|
+
seenUrl = url;
|
|
60
|
+
seenInit = init;
|
|
61
|
+
return { ok: true, status: 200, text: async () => '' };
|
|
62
|
+
};
|
|
63
|
+
const buf = Buffer.from([9, 8, 7]);
|
|
64
|
+
await putBufferToSignedUrl('https://signed.example/put', buf, 'audio/mpeg');
|
|
65
|
+
assert.equal(seenUrl, 'https://signed.example/put');
|
|
66
|
+
assert.equal(seenInit.method, 'PUT');
|
|
67
|
+
assert.deepEqual(seenInit.body, buf);
|
|
68
|
+
const ct =
|
|
69
|
+
seenInit.headers?.['Content-Type'] ?? seenInit.headers?.['content-type'];
|
|
70
|
+
assert.equal(ct, 'audio/mpeg');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('putBufferToSignedUrl throws with status and trimmed body on failure', async (t) => {
|
|
74
|
+
const orig = globalThis.fetch;
|
|
75
|
+
t.after(() => {
|
|
76
|
+
globalThis.fetch = orig;
|
|
77
|
+
});
|
|
78
|
+
globalThis.fetch = async () => ({
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 403,
|
|
81
|
+
text: async () => ' forbidden ',
|
|
82
|
+
});
|
|
83
|
+
await assert.rejects(
|
|
84
|
+
() => putBufferToSignedUrl('https://signed.example/put', Buffer.from([1]), 'audio/wav'),
|
|
85
|
+
/Storage upload failed \(403\): forbidden/,
|
|
86
|
+
);
|
|
87
|
+
});
|
package/src/mcpClient.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
const ACCEPT = 'application/json, text/event-stream';
|
|
2
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
3
|
+
|
|
4
|
+
export class McpHttpError extends Error {
|
|
5
|
+
constructor(message, { status, requestId, payload } = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'McpHttpError';
|
|
8
|
+
this.status = status ?? null;
|
|
9
|
+
this.requestId = requestId ?? null;
|
|
10
|
+
this.payload = payload ?? null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
function stripTrailingSlash(url) {
|
|
4
15
|
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
@@ -8,23 +19,35 @@ function headers(base = {}) {
|
|
|
8
19
|
return {
|
|
9
20
|
Accept: ACCEPT,
|
|
10
21
|
'Content-Type': 'application/json',
|
|
22
|
+
'MCP-Protocol-Version': PROTOCOL_VERSION,
|
|
11
23
|
...base,
|
|
12
24
|
};
|
|
13
25
|
}
|
|
14
26
|
|
|
15
|
-
|
|
27
|
+
function bearerHeaders(token, sessionId, apiKey) {
|
|
28
|
+
const h = {
|
|
29
|
+
Authorization: `Bearer ${token}`,
|
|
30
|
+
'Mcp-Session-Id': sessionId,
|
|
31
|
+
};
|
|
32
|
+
if (apiKey) h['X-Mixmake-Api-Key'] = apiKey;
|
|
33
|
+
return h;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function initializeWithOptions(endpoint, options = {}) {
|
|
37
|
+
const { trace } = options;
|
|
16
38
|
const body = {
|
|
17
39
|
jsonrpc: '2.0',
|
|
18
40
|
id: 1,
|
|
19
41
|
method: 'initialize',
|
|
20
42
|
params: {
|
|
21
|
-
protocolVersion:
|
|
43
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
22
44
|
capabilities: {},
|
|
23
45
|
clientInfo: { name: 'mixmake-cli', version: '0.1.0' },
|
|
24
46
|
},
|
|
25
47
|
};
|
|
26
48
|
|
|
27
|
-
const
|
|
49
|
+
const url = stripTrailingSlash(endpoint);
|
|
50
|
+
const res = await fetch(url, {
|
|
28
51
|
method: 'POST',
|
|
29
52
|
headers: headers(),
|
|
30
53
|
body: JSON.stringify(body),
|
|
@@ -32,20 +55,32 @@ export async function initialize(endpoint) {
|
|
|
32
55
|
});
|
|
33
56
|
|
|
34
57
|
const requestId = res.headers.get('x-request-id');
|
|
35
|
-
const sessionId = res.headers.get('mcp-session-id')
|
|
58
|
+
const sessionId = res.headers.get('mcp-session-id');
|
|
36
59
|
const payload = await safeJson(res);
|
|
37
60
|
const token = payload?.result?._meta?.sessionToken;
|
|
38
61
|
|
|
39
62
|
if (!res.ok) {
|
|
40
|
-
throw new
|
|
63
|
+
throw new McpHttpError(formatHttpError('initialize', res.status, requestId, payload), {
|
|
64
|
+
status: res.status,
|
|
65
|
+
requestId,
|
|
66
|
+
payload,
|
|
67
|
+
});
|
|
41
68
|
}
|
|
69
|
+
trace?.({
|
|
70
|
+
stage: 'initialize',
|
|
71
|
+
endpoint: url,
|
|
72
|
+
status: res.status,
|
|
73
|
+
requestId: requestId || null,
|
|
74
|
+
ok: true,
|
|
75
|
+
});
|
|
42
76
|
if (!sessionId || !token) {
|
|
43
77
|
throw new Error(`initialize missing session details${requestId ? ` request_id=${requestId}` : ''}`);
|
|
44
78
|
}
|
|
45
79
|
return { sessionId, token, requestId, payload };
|
|
46
80
|
}
|
|
47
81
|
|
|
48
|
-
export async function
|
|
82
|
+
export async function callToolWithOptions({ endpoint, sessionId, token, apiKey, name, args, id = 2 }, options = {}) {
|
|
83
|
+
const { trace } = options;
|
|
49
84
|
const body = {
|
|
50
85
|
jsonrpc: '2.0',
|
|
51
86
|
id,
|
|
@@ -55,23 +90,30 @@ export async function callTool({ endpoint, sessionId, token, apiKey, name, args,
|
|
|
55
90
|
arguments: args,
|
|
56
91
|
},
|
|
57
92
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
'Mcp-Session-Id': sessionId,
|
|
61
|
-
};
|
|
62
|
-
if (apiKey) extraHeaders['X-Mixmake-Api-Key'] = apiKey;
|
|
63
|
-
|
|
64
|
-
const res = await fetch(stripTrailingSlash(endpoint), {
|
|
93
|
+
const url = stripTrailingSlash(endpoint);
|
|
94
|
+
const res = await fetch(url, {
|
|
65
95
|
method: 'POST',
|
|
66
|
-
headers: headers(
|
|
96
|
+
headers: headers(bearerHeaders(token, sessionId, apiKey)),
|
|
67
97
|
body: JSON.stringify(body),
|
|
68
98
|
redirect: 'follow',
|
|
69
99
|
});
|
|
70
100
|
const requestId = res.headers.get('x-request-id');
|
|
71
101
|
const payload = await safeJson(res);
|
|
72
102
|
if (!res.ok) {
|
|
73
|
-
throw new
|
|
103
|
+
throw new McpHttpError(formatHttpError(`tool ${name}`, res.status, requestId, payload), {
|
|
104
|
+
status: res.status,
|
|
105
|
+
requestId,
|
|
106
|
+
payload,
|
|
107
|
+
});
|
|
74
108
|
}
|
|
109
|
+
trace?.({
|
|
110
|
+
stage: 'tools/call',
|
|
111
|
+
tool: name,
|
|
112
|
+
endpoint: url,
|
|
113
|
+
status: res.status,
|
|
114
|
+
requestId: requestId || null,
|
|
115
|
+
ok: true,
|
|
116
|
+
});
|
|
75
117
|
if (payload?.error) {
|
|
76
118
|
const msg = payload.error?.message || 'unknown error';
|
|
77
119
|
throw new Error(`tool ${name} error: ${msg}${requestId ? ` request_id=${requestId}` : ''}`);
|
|
@@ -79,12 +121,60 @@ export async function callTool({ endpoint, sessionId, token, apiKey, name, args,
|
|
|
79
121
|
return { payload, requestId };
|
|
80
122
|
}
|
|
81
123
|
|
|
124
|
+
export function shouldReinitializeSession(error) {
|
|
125
|
+
if (!(error instanceof McpHttpError)) return false;
|
|
126
|
+
if (error.status === 404 || error.status === 410) return true;
|
|
127
|
+
const msg = `${error.message} ${(error.payload?.error?.message ?? '')}`.toLowerCase();
|
|
128
|
+
return msg.includes('unknown session') || msg.includes('session expired');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function callRpc({ endpoint, sessionId, token, apiKey, method, params, id = 2 }, options = {}) {
|
|
132
|
+
const { trace } = options;
|
|
133
|
+
const body = { jsonrpc: '2.0', id, method, params };
|
|
134
|
+
|
|
135
|
+
const url = stripTrailingSlash(endpoint);
|
|
136
|
+
const res = await fetch(url, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: headers(bearerHeaders(token, sessionId, apiKey)),
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
redirect: 'follow',
|
|
141
|
+
});
|
|
142
|
+
const requestId = res.headers.get('x-request-id');
|
|
143
|
+
const payload = await safeJson(res);
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
throw new McpHttpError(formatHttpError(method, res.status, requestId, payload), {
|
|
146
|
+
status: res.status,
|
|
147
|
+
requestId,
|
|
148
|
+
payload,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
trace?.({
|
|
152
|
+
stage: method,
|
|
153
|
+
endpoint: url,
|
|
154
|
+
status: res.status,
|
|
155
|
+
requestId: requestId || null,
|
|
156
|
+
ok: true,
|
|
157
|
+
});
|
|
158
|
+
if (payload?.error) {
|
|
159
|
+
const msg = payload.error?.message || 'unknown error';
|
|
160
|
+
throw new Error(`${method} error: ${msg}${requestId ? ` request_id=${requestId}` : ''}`);
|
|
161
|
+
}
|
|
162
|
+
return { payload, requestId };
|
|
163
|
+
}
|
|
164
|
+
|
|
82
165
|
function safeJson(res) {
|
|
83
166
|
return res
|
|
84
167
|
.json()
|
|
85
168
|
.catch(async () => ({ raw: await res.text().catch(() => ''), error: { message: 'non-json response' } }));
|
|
86
169
|
}
|
|
87
170
|
|
|
171
|
+
function formatHttpError(label, status, requestId, payload) {
|
|
172
|
+
const rpcMessage = payload?.error?.message;
|
|
173
|
+
const raw = typeof payload?.raw === 'string' ? payload.raw.trim() : '';
|
|
174
|
+
const details = rpcMessage || raw || 'no response details';
|
|
175
|
+
return `${label} failed (${status}): ${details}${requestId ? ` request_id=${requestId}` : ''}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
88
178
|
export function extractToolText(payload) {
|
|
89
|
-
return payload?.result?.content?.find
|
|
179
|
+
return payload?.result?.content?.find((item) => item.type === 'text')?.text;
|
|
90
180
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
McpHttpError,
|
|
5
|
+
initializeWithOptions,
|
|
6
|
+
callRpc,
|
|
7
|
+
callToolWithOptions,
|
|
8
|
+
shouldReinitializeSession,
|
|
9
|
+
extractToolText,
|
|
10
|
+
} from './mcpClient.js';
|
|
11
|
+
|
|
12
|
+
function headersMap(obj) {
|
|
13
|
+
const map = new Map(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]));
|
|
14
|
+
return {
|
|
15
|
+
get(name) {
|
|
16
|
+
const v = map.get(name.toLowerCase());
|
|
17
|
+
return v === undefined ? null : v;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jsonResponse({ ok = true, status = 200, headers = {}, json }) {
|
|
23
|
+
return {
|
|
24
|
+
ok,
|
|
25
|
+
status,
|
|
26
|
+
headers: headersMap(headers),
|
|
27
|
+
json: async () => json,
|
|
28
|
+
text: async () => '',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
33
|
+
|
|
34
|
+
test.afterEach(() => {
|
|
35
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('shouldReinitializeSession treats 404 and 410 as recoverable', () => {
|
|
39
|
+
assert.equal(shouldReinitializeSession(new McpHttpError('x', { status: 404 })), true);
|
|
40
|
+
assert.equal(shouldReinitializeSession(new McpHttpError('x', { status: 410 })), true);
|
|
41
|
+
assert.equal(shouldReinitializeSession(new McpHttpError('x', { status: 401 })), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('shouldReinitializeSession matches session messaging', () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
shouldReinitializeSession(new McpHttpError('Unknown session x', { status: 400, payload: {} })),
|
|
47
|
+
true
|
|
48
|
+
);
|
|
49
|
+
const err = new McpHttpError('x', {
|
|
50
|
+
status: 400,
|
|
51
|
+
payload: { error: { message: 'Session expired' } },
|
|
52
|
+
});
|
|
53
|
+
assert.equal(shouldReinitializeSession(err), true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('shouldReinitializeSession false for non-McpHttpError', () => {
|
|
57
|
+
assert.equal(shouldReinitializeSession(new Error('x')), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('extractToolText reads first text content item', () => {
|
|
61
|
+
assert.equal(extractToolText(null), undefined);
|
|
62
|
+
assert.equal(
|
|
63
|
+
extractToolText({ result: { content: [{ type: 'text', text: 'hello' }] } }),
|
|
64
|
+
'hello'
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('initializeWithOptions returns session token and runs trace', async () => {
|
|
69
|
+
const traces = [];
|
|
70
|
+
globalThis.fetch = async (url, init) => {
|
|
71
|
+
assert.match(String(url), /https:\/\/example\.com\/api\/mcp$/);
|
|
72
|
+
assert.equal(init.method, 'POST');
|
|
73
|
+
const body = JSON.parse(init.body);
|
|
74
|
+
assert.equal(body.method, 'initialize');
|
|
75
|
+
return jsonResponse({
|
|
76
|
+
headers: { 'mcp-session-id': 'sid-1', 'x-request-id': 'req-1' },
|
|
77
|
+
json: {
|
|
78
|
+
result: {
|
|
79
|
+
serverInfo: { name: 'mixmake', version: '1' },
|
|
80
|
+
_meta: { sessionToken: 'jwt-here' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const r = await initializeWithOptions('https://example.com/api/mcp/', {
|
|
87
|
+
trace: (e) => traces.push(e),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(r.sessionId, 'sid-1');
|
|
91
|
+
assert.equal(r.token, 'jwt-here');
|
|
92
|
+
assert.equal(r.requestId, 'req-1');
|
|
93
|
+
assert.equal(traces.length, 1);
|
|
94
|
+
assert.equal(traces[0].stage, 'initialize');
|
|
95
|
+
assert.ok(!('sessionId' in traces[0]));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('initializeWithOptions throws McpHttpError when HTTP not ok', async () => {
|
|
99
|
+
globalThis.fetch = async () =>
|
|
100
|
+
jsonResponse({
|
|
101
|
+
ok: false,
|
|
102
|
+
status: 503,
|
|
103
|
+
json: { error: { message: 'unavailable' } },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
() => initializeWithOptions('https://example.com/api/mcp'),
|
|
108
|
+
(err) => err instanceof McpHttpError && err.status === 503
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('initializeWithOptions throws when session id or token missing on 200', async () => {
|
|
113
|
+
globalThis.fetch = async () =>
|
|
114
|
+
jsonResponse({
|
|
115
|
+
json: { result: { serverInfo: {}, _meta: { sessionToken: 't' } } },
|
|
116
|
+
headers: {},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await assert.rejects(
|
|
120
|
+
() => initializeWithOptions('https://example.com/api/mcp'),
|
|
121
|
+
/initialize missing session details/
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('callRpc tools/list without params omits params from JSON body', async () => {
|
|
126
|
+
let body;
|
|
127
|
+
globalThis.fetch = async (url, init) => {
|
|
128
|
+
body = JSON.parse(init.body);
|
|
129
|
+
return jsonResponse({ json: { result: { tools: [] } } });
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
await callRpc({
|
|
133
|
+
endpoint: 'https://example.com/api/mcp',
|
|
134
|
+
sessionId: 's',
|
|
135
|
+
token: 't',
|
|
136
|
+
method: 'tools/list',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assert.equal(body.jsonrpc, '2.0');
|
|
140
|
+
assert.equal(body.method, 'tools/list');
|
|
141
|
+
assert.equal('params' in body, false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('callRpc runs trace on HTTP success', async () => {
|
|
145
|
+
const traces = [];
|
|
146
|
+
globalThis.fetch = async () => jsonResponse({ json: { result: { tools: [] } } });
|
|
147
|
+
|
|
148
|
+
await callRpc(
|
|
149
|
+
{ endpoint: 'https://example.com/api/mcp', sessionId: 's', token: 't', method: 'tools/list' },
|
|
150
|
+
{ trace: (e) => traces.push(e) }
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
assert.equal(traces.length, 1);
|
|
154
|
+
assert.equal(traces[0].stage, 'tools/list');
|
|
155
|
+
assert.equal(traces[0].ok, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('callToolWithOptions sends auth headers and invokes trace', async () => {
|
|
159
|
+
const traces = [];
|
|
160
|
+
globalThis.fetch = async (url, init) => {
|
|
161
|
+
const h = new Headers(init.headers);
|
|
162
|
+
assert.equal(h.get('Authorization'), 'Bearer mytoken');
|
|
163
|
+
assert.equal(h.get('Mcp-Session-Id'), 'msid');
|
|
164
|
+
const body = JSON.parse(init.body);
|
|
165
|
+
assert.equal(body.method, 'tools/call');
|
|
166
|
+
assert.equal(body.params.name, 'get_api_key');
|
|
167
|
+
return jsonResponse({ json: { result: { content: [{ type: 'text', text: '{"api_key":"mmk_x"}' }] } } });
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const out = await callToolWithOptions(
|
|
171
|
+
{
|
|
172
|
+
endpoint: 'https://example.com/api/mcp',
|
|
173
|
+
sessionId: 'msid',
|
|
174
|
+
token: 'mytoken',
|
|
175
|
+
name: 'get_api_key',
|
|
176
|
+
args: { email: 'u@example.com' },
|
|
177
|
+
},
|
|
178
|
+
{ trace: (e) => traces.push(e) }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
assert.equal(traces[0].stage, 'tools/call');
|
|
182
|
+
assert.equal(traces[0].tool, 'get_api_key');
|
|
183
|
+
assert.match(out.payload.result.content[0].text, /mmk_/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('callToolWithOptions throws on JSON-RPC error in 200 body', async () => {
|
|
187
|
+
globalThis.fetch = async () =>
|
|
188
|
+
jsonResponse({ json: { error: { message: 'tool failed' } } });
|
|
189
|
+
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
() =>
|
|
192
|
+
callToolWithOptions({
|
|
193
|
+
endpoint: 'https://example.com/api/mcp',
|
|
194
|
+
sessionId: 's',
|
|
195
|
+
token: 't',
|
|
196
|
+
name: 'x',
|
|
197
|
+
args: {},
|
|
198
|
+
}),
|
|
199
|
+
/tool x error: tool failed/
|
|
200
|
+
);
|
|
201
|
+
});
|
package/src/parseJson.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { tryParseJson } from './parseJson.js';
|
|
4
|
+
|
|
5
|
+
test('tryParseJson parses valid JSON', () => {
|
|
6
|
+
assert.deepEqual(tryParseJson('{"a":1}'), { a: 1 });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('tryParseJson returns null for invalid JSON', () => {
|
|
10
|
+
assert.equal(tryParseJson('{'), null);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('tryParseJson returns null for empty or non-string', () => {
|
|
14
|
+
assert.equal(tryParseJson(''), null);
|
|
15
|
+
assert.equal(tryParseJson(null), null);
|
|
16
|
+
assert.equal(tryParseJson(1), null);
|
|
17
|
+
});
|