@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 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
- ```bash
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 key --email you@example.com
23
- npx @mixmake/cli@latest transcribe --audio-url https://example.com/file.mp3
24
- npx @mixmake/cli@latest transcribe --audio-id <audio_id>
25
- npx @mixmake/cli@latest edit --transcript-id <id> --delete id1,id2,id3
26
- npx @mixmake/cli@latest render --transcript-id <id>
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
- Run:
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.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
- "url": "https://github.com/swappysh/mixmake/issues"
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 { callTool, extractToolText, initialize } from './mcpClient.js';
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 initialize(endpoint);
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 init = await initialize(endpoint);
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 text = extractToolText(out.payload);
85
- printResult('get_api_key', text ? JSON.parse(text) : out.payload);
86
-
87
- try {
88
- const parsed = text ? JSON.parse(text) : null;
89
- const key = parsed?.api_key || parsed?.key || null;
90
- if (key && !process.env.MIXMAKE_API_KEY) {
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
- if (!audioUrl && !audioId) throw new Error('Provide --audio-url or --audio-id');
105
- const init = await initialize(endpoint);
106
- const out = await callTool({
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: audioUrl, audio_id: audioId, label },
244
+ args: { audio_url: resolvedUrl, audio_id: resolvedId, label },
245
+ allowReplay: false,
246
+ trace,
113
247
  });
114
- const text = extractToolText(out.payload);
115
- printResult('transcribe_audio', text ? JSON.parse(text) : out.payload);
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 init = await initialize(endpoint);
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 text = extractToolText(out.payload);
138
- printResult('edit_transcript', text ? JSON.parse(text) : out.payload);
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 init = await initialize(endpoint);
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 text = extractToolText(out.payload);
155
- printResult('render_audio', text ? JSON.parse(text) : out.payload);
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
- export async function initialize(endpoint) {
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: '2025-03-26',
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 res = await fetch(stripTrailingSlash(endpoint), {
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') || 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 Error(`initialize failed (${res.status})${requestId ? ` request_id=${requestId}` : ''}`);
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 callTool({ endpoint, sessionId, token, apiKey, name, args, id = 2 }) {
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 extraHeaders = {
59
- Authorization: `Bearer ${token}`,
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(extraHeaders),
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 Error(`tool ${name} failed (${res.status})${requestId ? ` request_id=${requestId}` : ''}`);
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?.((item) => item.type === 'text')?.text;
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
+ });
@@ -0,0 +1,8 @@
1
+ export function tryParseJson(input) {
2
+ if (typeof input !== 'string' || !input) return null;
3
+ try {
4
+ return JSON.parse(input);
5
+ } catch {
6
+ return null;
7
+ }
8
+ }
@@ -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
+ });