@anyslate/cli 0.1.0 → 0.3.0

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.
@@ -1,7 +1,7 @@
1
1
  // Minimal JSON-RPC client for the AnySlate MCP service.
2
2
  //
3
3
  // The MCP service speaks the standard MCP JSON-RPC protocol. We only need
4
- // `tools/call` from the CLI that one method covers `activity_submit`,
4
+ // `tools/call` from the CLI - that one method covers `activity_submit`,
5
5
  // `checkpoint_session`, and `upload_artifact`.
6
6
  //
7
7
  // The server replies with `{ jsonrpc: '2.0', id, result | error }`. When the
@@ -9,12 +9,34 @@
9
9
  // JSON-parse it (the AnySlate dispatcher always returns JSON-encoded text).
10
10
  //
11
11
  // Streamable HTTP transport requires every non-initialize request to carry
12
- // an `Mcp-Session-Id` header (mcp-protocol.ts:2566). The CLI is one-shot per
12
+ // an `Mcp-Session-Id` header (mcp-protocol.ts:5825). The CLI is one-shot per
13
13
  // invocation, so we run `initialize` first, capture the server-issued
14
14
  // session id from the response header, and reuse it for the `tools/call`.
15
+ //
16
+ // IMPORTANT (W2): the MCP wire protocol returns **HTTP 200 with
17
+ // `result.isError === true`** for tool-level rejections — 403 handle denial,
18
+ // 400 unsupported kind, 500 insert failure, tool-level 429. Trusting
19
+ // `res.ok` alone reports every one of those as success. `callTool` therefore
20
+ // inspects `result.isError` and maps it to `ok: false` with the embedded
21
+ // status, which is what makes `--strict` and the checkpoint/upload-artifact
22
+ // fail-closed contract actually mean anything.
23
+
24
+ import { USER_AGENT } from './version.mjs';
15
25
 
16
26
  let nextRequestId = 1;
17
27
 
28
+ const MCP_HEADERS = (token, sessionId) => {
29
+ const h = {
30
+ 'content-type': 'application/json',
31
+ authorization: `Bearer ${token}`,
32
+ accept: 'application/json, text/event-stream',
33
+ 'mcp-protocol-version': '2025-06-18',
34
+ 'user-agent': USER_AGENT,
35
+ };
36
+ if (sessionId) h['mcp-session-id'] = sessionId;
37
+ return h;
38
+ };
39
+
18
40
  async function readBody(res) {
19
41
  const ctype = res.headers.get('content-type') || '';
20
42
  // The MCP service may stream JSON-RPC results as text/event-stream
@@ -42,16 +64,153 @@ async function readBody(res) {
42
64
  }
43
65
  }
44
66
 
67
+ /**
68
+ * Render a server error body as a human string, preserving `error_description`.
69
+ *
70
+ * Dropping `error_description` makes *revoked* and *mistyped* indistinguishable
71
+ * (defect #15) — the single most common real-world 401.
72
+ *
73
+ * @param {unknown} raw
74
+ * @returns {string|object|null}
75
+ */
76
+ export function formatErrorPayload(raw) {
77
+ if (raw == null) return null;
78
+ if (typeof raw === 'string') return raw;
79
+ if (typeof raw !== 'object') return String(raw);
80
+
81
+ const desc =
82
+ typeof raw.error_description === 'string'
83
+ ? raw.error_description
84
+ : typeof raw.errorDescription === 'string'
85
+ ? raw.errorDescription
86
+ : null;
87
+ const err = raw.error;
88
+
89
+ if (typeof err === 'string' && err) {
90
+ return desc ? `${err}: ${desc}` : err;
91
+ }
92
+ if (err && typeof err === 'object') {
93
+ // JSON-RPC error object — keep the object so callers can read `.message`.
94
+ return err;
95
+ }
96
+ if (desc) return desc;
97
+ if (typeof raw.message === 'string') return raw.message;
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Extract 429 metadata. `Retry-After` was never read and
103
+ * `retry_after_seconds`/`reset_at`/`reason` survived only in `raw`
104
+ * (defect #31). Surfacing only — no retry/backoff logic.
105
+ *
106
+ * @param {Response} res
107
+ * @param {unknown} raw
108
+ */
109
+ export function extractRateLimit(res, raw) {
110
+ const header = res?.headers?.get ? res.headers.get('retry-after') : null;
111
+ const body = raw && typeof raw === 'object' ? raw : {};
112
+ const fromBody = body.retry_after_seconds ?? body.retryAfterSeconds;
113
+ const parsedHeader = header != null && header !== '' ? Number(header) : NaN;
114
+ const retryAfterSeconds = Number.isFinite(fromBody)
115
+ ? Number(fromBody)
116
+ : Number.isFinite(parsedHeader)
117
+ ? parsedHeader
118
+ : null;
119
+ return {
120
+ retry_after_seconds: retryAfterSeconds,
121
+ retry_after_header: header ?? null,
122
+ reset_at: body.reset_at ?? body.resetAt ?? null,
123
+ reason: body.reason ?? null,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Turn a thrown fetch error into something that names the host and the cause.
129
+ * Previously DNS failure, connection refused and timeout were all
130
+ * `request failed (fetch failed)` (defect #30).
131
+ *
132
+ * @param {unknown} err
133
+ * @param {string} host
134
+ * @param {number} timeoutMs
135
+ * @returns {string}
136
+ */
137
+ /**
138
+ * Undici wraps the real cause: `fetch failed` carries `cause`, which for a
139
+ * refused/multi-address connect is an AggregateError whose `.errors[]` hold
140
+ * the codes. Walk the whole chain, or every connection error looks identical.
141
+ *
142
+ * @param {unknown} err
143
+ * @param {number} [depth]
144
+ * @returns {string|null}
145
+ */
146
+ export function findErrorCode(err, depth = 0) {
147
+ if (!err || depth > 5) return null;
148
+ if (typeof err.code === 'string' && err.code) return err.code;
149
+ if (Array.isArray(err.errors)) {
150
+ for (const inner of err.errors) {
151
+ const code = findErrorCode(inner, depth + 1);
152
+ if (code) return code;
153
+ }
154
+ }
155
+ return findErrorCode(err.cause, depth + 1);
156
+ }
157
+
158
+ export function classifyNetworkError(err, host, timeoutMs) {
159
+ const message = String(err?.message ?? err ?? '');
160
+ const code = findErrorCode(err);
161
+ const seconds = Math.round((timeoutMs ?? 0) / 1000);
162
+
163
+ if (err?.name === 'AbortError' || /aborted/i.test(message)) {
164
+ return `timed out after ${seconds}s contacting ${host}`;
165
+ }
166
+ switch (code) {
167
+ case 'ENOTFOUND':
168
+ return `ENOTFOUND ${host} (DNS)`;
169
+ case 'EAI_AGAIN':
170
+ return `EAI_AGAIN ${host} (DNS)`;
171
+ case 'ECONNREFUSED':
172
+ return `ECONNREFUSED ${host}`;
173
+ case 'ECONNRESET':
174
+ return `ECONNRESET ${host}`;
175
+ case 'ETIMEDOUT':
176
+ case 'UND_ERR_CONNECT_TIMEOUT':
177
+ case 'UND_ERR_HEADERS_TIMEOUT':
178
+ return `timed out contacting ${host} (${code})`;
179
+ default:
180
+ break;
181
+ }
182
+ if (code && /CERT|SSL|TLS/i.test(code)) return `TLS error contacting ${host} (${code})`;
183
+ if (/certificate|self[- ]signed|SSL/i.test(message)) return `TLS error contacting ${host}`;
184
+ if (code) return `${code} ${host}`;
185
+ return `${message || 'request failed'} (${host})`;
186
+ }
187
+
188
+ /**
189
+ * Uniform failure line for every command. Network failures are not "server
190
+ * <status>" failures — printing `server 0` would be a lie.
191
+ *
192
+ * @param {string} prefix e.g. `anyslate hook stop`
193
+ * @param {{status: number, data: unknown, networkError?: boolean}} res
194
+ * @returns {string}
195
+ */
196
+ export function formatCallFailure(prefix, res) {
197
+ const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
198
+ if (res.networkError) return `${prefix}: request failed: ${msg}\n`;
199
+ return `${prefix}: server ${res.status} — ${msg}\n`;
200
+ }
201
+
202
+ function hostOf(url) {
203
+ try {
204
+ return new URL(url).host;
205
+ } catch {
206
+ return url;
207
+ }
208
+ }
209
+
45
210
  async function initializeSession({ url, token, fetchImpl, ac }) {
46
211
  const res = await fetchImpl(url, {
47
212
  method: 'POST',
48
- headers: {
49
- 'content-type': 'application/json',
50
- authorization: `Bearer ${token}`,
51
- accept: 'application/json, text/event-stream',
52
- 'mcp-protocol-version': '2025-06-18',
53
- 'user-agent': 'anyslate-cli/0.1.0',
54
- },
213
+ headers: MCP_HEADERS(token),
55
214
  body: JSON.stringify({
56
215
  jsonrpc: '2.0',
57
216
  id: nextRequestId++,
@@ -59,7 +218,7 @@ async function initializeSession({ url, token, fetchImpl, ac }) {
59
218
  params: {
60
219
  protocolVersion: '2025-06-18',
61
220
  capabilities: {},
62
- clientInfo: { name: 'anyslate-cli', version: '0.1.0' },
221
+ clientInfo: { name: 'anyslate-cli', version: USER_AGENT.split('/')[1] },
63
222
  },
64
223
  }),
65
224
  signal: ac.signal,
@@ -67,45 +226,61 @@ async function initializeSession({ url, token, fetchImpl, ac }) {
67
226
  const sid = res.headers.get('mcp-session-id');
68
227
  if (!res.ok) {
69
228
  const raw = await readBody(res);
70
- return { ok: false, status: res.status, sessionId: null, raw };
229
+ return { ok: false, status: res.status, sessionId: null, raw, res };
71
230
  }
72
231
  if (!sid) {
73
- return { ok: false, status: res.status, sessionId: null, raw: { error: 'server omitted Mcp-Session-Id header on initialize' } };
232
+ return {
233
+ ok: false,
234
+ status: res.status,
235
+ sessionId: null,
236
+ raw: { error: 'server omitted Mcp-Session-Id header on initialize' },
237
+ res,
238
+ };
74
239
  }
75
- // Best-effort `notifications/initialized` server returns 202 and we
240
+ // Best-effort `notifications/initialized` - server returns 202 and we
76
241
  // proceed regardless. Required by spec; harmless if dropped on the floor.
77
242
  try {
78
243
  await fetchImpl(url, {
79
244
  method: 'POST',
80
- headers: {
81
- 'content-type': 'application/json',
82
- authorization: `Bearer ${token}`,
83
- accept: 'application/json, text/event-stream',
84
- 'mcp-protocol-version': '2025-06-18',
85
- 'mcp-session-id': sid,
86
- 'user-agent': 'anyslate-cli/0.1.0',
87
- },
245
+ headers: MCP_HEADERS(token, sid),
88
246
  body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
89
247
  signal: ac.signal,
90
248
  });
91
249
  } catch {
92
- // Swallow we have the session id; the notification is advisory.
250
+ // Swallow - we have the session id; the notification is advisory.
93
251
  }
94
252
  return { ok: true, status: res.status, sessionId: sid, raw: null };
95
253
  }
96
254
 
97
255
  /**
98
256
  * @param {object} opts
99
- * @param {string} opts.apiUrl
257
+ * @param {string} opts.apiUrl service ROOT (the client appends `/mcp`)
100
258
  * @param {string} opts.token Bearer token (MCP token).
101
259
  * @param {string} opts.toolName
102
260
  * @param {Record<string, unknown>} opts.args
103
261
  * @param {typeof fetch} [opts.fetchImpl] override for tests
104
262
  * @param {number} [opts.timeoutMs]
105
- * @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown }>}
263
+ * @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown,
264
+ * isError?: boolean, networkError?: boolean, retryAfterSeconds?: number|null,
265
+ * rateLimit?: object }>}
106
266
  */
107
- export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetch, timeoutMs = 15000, sessionId: presetSessionId }) {
108
- const url = `${apiUrl.replace(/\/+$/, '')}/mcp`;
267
+ export async function callTool({
268
+ apiUrl,
269
+ token,
270
+ toolName,
271
+ args,
272
+ fetchImpl = fetch,
273
+ timeoutMs = 15000,
274
+ sessionId: presetSessionId,
275
+ }) {
276
+ // `apiUrl` is already normalized by loadConfig; strip again defensively so a
277
+ // direct caller cannot reintroduce `/mcp/mcp`.
278
+ const root = String(apiUrl ?? '')
279
+ .trim()
280
+ .replace(/\/+$/, '')
281
+ .replace(/(\/mcp)+$/i, '');
282
+ const url = `${root}/mcp`;
283
+ const host = hostOf(url);
109
284
  const ac = new AbortController();
110
285
  const timer = setTimeout(() => ac.abort(), timeoutMs);
111
286
 
@@ -116,12 +291,16 @@ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetc
116
291
  if (!init.ok) {
117
292
  // Surface the auth/init failure exactly as if the tool call itself failed,
118
293
  // so the CLI's existing error handling (server 401 etc.) keeps working.
119
- const errLike = init.raw && typeof init.raw === 'object' && 'error' in init.raw
120
- ? init.raw.error
121
- : init.raw && typeof init.raw === 'object' && 'message' in init.raw
122
- ? init.raw
123
- : { message: `initialize failed (HTTP ${init.status})` };
124
- return { ok: false, status: init.status, data: errLike, raw: init.raw };
294
+ const formatted = formatErrorPayload(init.raw);
295
+ const errLike = formatted ?? { message: `initialize failed (HTTP ${init.status})` };
296
+ const out = { ok: false, status: init.status, data: errLike, raw: init.raw };
297
+ if (init.status === 429) {
298
+ const rl = extractRateLimit(init.res, init.raw);
299
+ out.rateLimit = rl;
300
+ out.retryAfterSeconds = rl.retry_after_seconds;
301
+ out.data = withRetryHint(errLike, rl);
302
+ }
303
+ return out;
125
304
  }
126
305
  sessionId = init.sessionId;
127
306
  }
@@ -136,39 +315,106 @@ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetc
136
315
 
137
316
  const res = await fetchImpl(url, {
138
317
  method: 'POST',
139
- headers: {
140
- 'content-type': 'application/json',
141
- authorization: `Bearer ${token}`,
142
- accept: 'application/json, text/event-stream',
143
- 'mcp-protocol-version': '2025-06-18',
144
- 'mcp-session-id': sessionId,
145
- 'user-agent': 'anyslate-cli/0.1.0',
146
- },
318
+ headers: MCP_HEADERS(token, sessionId),
147
319
  body: JSON.stringify(body),
148
320
  signal: ac.signal,
149
321
  });
150
322
 
151
323
  const status = res.status;
152
324
  const raw = await readBody(res);
325
+ const rateLimit = status === 429 ? extractRateLimit(res, raw) : null;
153
326
 
327
+ const finish = (out) => {
328
+ if (rateLimit) {
329
+ out.rateLimit = rateLimit;
330
+ out.retryAfterSeconds = rateLimit.retry_after_seconds;
331
+ out.data = withRetryHint(out.data, rateLimit);
332
+ }
333
+ return out;
334
+ };
335
+
336
+ // JSON-RPC transport-level error.
154
337
  if (raw && typeof raw === 'object' && 'error' in raw && raw.error) {
155
- return { ok: false, status, data: raw.error, raw };
338
+ return finish({ ok: false, status, data: formatErrorPayload(raw) ?? raw.error, raw });
156
339
  }
157
340
 
158
- // `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
159
341
  const result = raw && typeof raw === 'object' ? raw.result : null;
342
+
343
+ // ---- Tool-level error: HTTP 200 + result.isError === true (W2). ----
344
+ if (result && typeof result === 'object' && result.isError === true) {
345
+ const content = Array.isArray(result.content) ? result.content[0] : null;
346
+ const text = content && typeof content.text === 'string' ? content.text : null;
347
+ let parsed = null;
348
+ if (text) {
349
+ try {
350
+ parsed = JSON.parse(text);
351
+ } catch {
352
+ parsed = null;
353
+ }
354
+ }
355
+ if (parsed && typeof parsed === 'object') {
356
+ const embeddedStatus = Number.isFinite(parsed.status) ? Number(parsed.status) : status;
357
+ const data = formatErrorPayload(parsed) ?? parsed.error ?? text ?? 'tool returned isError';
358
+ const out = { ok: false, status: embeddedStatus, data, raw, isError: true };
359
+ if (embeddedStatus === 429) {
360
+ const rl = extractRateLimit(res, parsed);
361
+ out.rateLimit = rl;
362
+ out.retryAfterSeconds = rl.retry_after_seconds;
363
+ out.data = withRetryHint(data, rl);
364
+ }
365
+ return out;
366
+ }
367
+ return finish({
368
+ ok: false,
369
+ status,
370
+ data: text ?? 'tool returned isError with no content',
371
+ raw,
372
+ isError: true,
373
+ });
374
+ }
375
+
376
+ // `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
160
377
  const content = result && Array.isArray(result.content) ? result.content[0] : null;
161
378
  if (content && content.type === 'text' && typeof content.text === 'string') {
162
379
  try {
163
380
  const parsed = JSON.parse(content.text);
164
- return { ok: res.ok, status, data: parsed, raw };
381
+ return finish({ ok: res.ok, status, data: parsed, raw });
165
382
  } catch {
166
- return { ok: res.ok, status, data: content.text, raw };
383
+ return finish({ ok: res.ok, status, data: content.text, raw });
167
384
  }
168
385
  }
169
386
 
170
- return { ok: res.ok, status, data: result, raw };
387
+ // Non-2xx with no parseable JSON-RPC body: never print the literal `null`.
388
+ if (!res.ok) {
389
+ return finish({
390
+ ok: false,
391
+ status,
392
+ data: formatErrorPayload(raw) ?? `server returned HTTP ${status} with no JSON-RPC body`,
393
+ raw,
394
+ });
395
+ }
396
+
397
+ return finish({ ok: res.ok, status, data: result, raw });
398
+ } catch (e) {
399
+ return {
400
+ ok: false,
401
+ status: 0,
402
+ data: classifyNetworkError(e, host, timeoutMs),
403
+ raw: null,
404
+ networkError: true,
405
+ };
171
406
  } finally {
172
407
  clearTimeout(timer);
173
408
  }
174
409
  }
410
+
411
+ function withRetryHint(data, rl) {
412
+ if (!rl) return data;
413
+ const bits = [];
414
+ if (rl.retry_after_seconds != null) bits.push(`retry after ${rl.retry_after_seconds}s`);
415
+ if (rl.reset_at) bits.push(`resets at ${rl.reset_at}`);
416
+ if (rl.reason) bits.push(String(rl.reason));
417
+ if (!bits.length) return data;
418
+ const base = typeof data === 'string' ? data : data == null ? 'rate limited' : JSON.stringify(data);
419
+ return `${base} — ${bits.join(', ')}`;
420
+ }