@tokenoftrust/cli 2.0.12 → 2.0.15

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.
@@ -13,6 +13,7 @@ import { establishSession } from "../auth.mjs";
13
13
  import { createMcpClient } from "../mcp.mjs";
14
14
  import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
15
15
  import { recordServerPolicy } from "../update-check.mjs";
16
+ import { recordDiagnostic } from "../diagnostics.mjs";
16
17
 
17
18
  /**
18
19
  * Pure summary of the cached session — no network. Returned for both display and
@@ -73,7 +74,10 @@ export async function run(argv, _ctx) {
73
74
  console.log(` (${g.headline})`);
74
75
  console.log(` → next: ${g.next}`);
75
76
  }
76
- } catch {
77
+ } catch (error) {
78
+ recordDiagnostic(error, {
79
+ command: "whoami", operation: "client_list", required: false, degraded: true,
80
+ }, env);
77
81
  console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");
78
82
  }
79
83
  return 0;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Safe, bounded diagnostics for the standalone CLI. Detailed failures live here;
3
+ * normal command output carries only a short message and diagnostic id.
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import {
7
+ chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync,
8
+ } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+
12
+ const CLI_PACKAGE_VERSION = (() => {
13
+ try {
14
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || "0.0.0";
15
+ } catch {
16
+ return "0.0.0";
17
+ }
18
+ })();
19
+
20
+ export const MAX_DIAGNOSTIC_ENTRIES = 200;
21
+ export const MAX_DIAGNOSTIC_BYTES = 256 * 1024;
22
+ export const MAX_DIAGNOSTIC_EXCERPT = 1024;
23
+
24
+ const SECRET_KEYS = new Set([
25
+ "authorization", "password", "secret", "apikey", "token", "accesstoken",
26
+ "refreshtoken", "credential", "cookie", "code", "confirmationcode",
27
+ ]);
28
+ const TOKEN_VALUE = /\b(?:eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|[A-Za-z0-9_-]{40,})\b/gi;
29
+ const URL_CREDENTIAL = /(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
30
+ const QUERY_SECRET = /([?&](?:code|token|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)=)[^&#\s]*/gi;
31
+ const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
32
+ const PAYMENT_CARD_VALUE = /\b(?:\d[ -]*?){13,19}\b/g;
33
+ const PHONE_VALUE = /(?<!\w)\+?\d{1,3}[\s.-]*(?:\(\d{2,4}\)|\d{2,4})[\s.-]*\d{3,4}[\s.-]*\d{4}(?!\w)/g;
34
+ const PRIVATE_KEY_VALUE = /-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/gi;
35
+ const CORRELATION_KEYS = new Set(["diagnosticid", "traceid", "requestid"]);
36
+ const STRUCTURED_CORRELATION_ID = /^(?:[0-9a-f]{16,64}|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|(?:req(?:uest)?|trace|diag)[-_:][A-Za-z0-9._:-]{1,120})$/i;
37
+
38
+ let invocationDiagnostics = [];
39
+ let invocationContext = {};
40
+
41
+ export function diagnosticsLogPath(env = process.env) {
42
+ return join(env.TOT_HOME || homedir(), ".tot", "diagnostics.log");
43
+ }
44
+
45
+ export function sanitizeDiagnosticText(value, maxLength = MAX_DIAGNOSTIC_EXCERPT) {
46
+ return String(value ?? "")
47
+ .replace(URL_CREDENTIAL, "$1«redacted»@")
48
+ .replace(QUERY_SECRET, "$1«redacted»")
49
+ .replace(/\b(Bearer|Basic)\s+\S+/gi, "$1 «redacted»")
50
+ .replace(PRIVATE_KEY_VALUE, "«redacted-private-key»")
51
+ .replace(EMAIL_VALUE, "«redacted-email»")
52
+ .replace(PAYMENT_CARD_VALUE, "«redacted-card»")
53
+ .replace(PHONE_VALUE, "«redacted-phone»")
54
+ .replace(TOKEN_VALUE, "«redacted»")
55
+ .slice(0, maxLength);
56
+ }
57
+
58
+ function safeObject(value, depth = 0, key = "") {
59
+ if (depth > 5) return "[depth-limit]";
60
+ if (value == null || typeof value === "boolean" || typeof value === "number") return value;
61
+ if (typeof value === "string") {
62
+ const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
63
+ if (CORRELATION_KEYS.has(normalizedKey) && STRUCTURED_CORRELATION_ID.test(value)) return value;
64
+ return sanitizeDiagnosticText(value);
65
+ }
66
+ if (Array.isArray(value)) return value.slice(0, 20).map((item) => safeObject(item, depth + 1));
67
+ if (typeof value !== "object") return sanitizeDiagnosticText(value);
68
+ const out = {};
69
+ for (const [key, item] of Object.entries(value).slice(0, 30)) {
70
+ const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
71
+ out[key] = SECRET_KEYS.has(normalizedKey) ? "[REDACTED]" : safeObject(item, depth + 1, key);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function causeClasses(error) {
77
+ const classes = [];
78
+ const seen = new Set();
79
+ let current = error;
80
+ while (current && typeof current === "object" && classes.length < 6 && !seen.has(current)) {
81
+ seen.add(current);
82
+ classes.push(String(current.name || current.constructor?.name || "Error").slice(0, 80));
83
+ current = current.cause;
84
+ }
85
+ return classes;
86
+ }
87
+
88
+ function diagnosticSource(error) {
89
+ const seen = new Set();
90
+ let current = error;
91
+ while (current && typeof current === "object" && !seen.has(current)) {
92
+ seen.add(current);
93
+ if (current.diagnostic && typeof current.diagnostic === "object") return current.diagnostic;
94
+ current = current.cause;
95
+ }
96
+ return {};
97
+ }
98
+
99
+ function parsedLines(path) {
100
+ try {
101
+ return readFileSync(path, "utf8").split("\n").filter(Boolean);
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ function atomicWrite(path, lines) {
108
+ const tmp = `${path}.tmp`;
109
+ writeFileSync(tmp, lines.length ? `${lines.join("\n")}\n` : "", { mode: 0o600 });
110
+ renameSync(tmp, path);
111
+ chmodSync(path, 0o600);
112
+ }
113
+
114
+ function persist(record, env) {
115
+ const path = diagnosticsLogPath(env);
116
+ const dir = dirname(path);
117
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
118
+ chmodSync(dir, 0o700);
119
+ const line = JSON.stringify(record);
120
+ let lines = parsedLines(path);
121
+ lines.push(line);
122
+ lines = lines.slice(-MAX_DIAGNOSTIC_ENTRIES);
123
+ const rendered = `${lines.join("\n")}\n`;
124
+ if (Buffer.byteLength(rendered) > MAX_DIAGNOSTIC_BYTES) {
125
+ const prior = parsedLines(path);
126
+ if (prior.length) atomicWrite(`${path}.1`, prior.slice(-MAX_DIAGNOSTIC_ENTRIES));
127
+ lines = [line];
128
+ }
129
+ atomicWrite(path, lines);
130
+ }
131
+
132
+ export class CliDiagnosticError extends Error {
133
+ constructor(message, diagnostic = {}, options = {}) {
134
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
135
+ this.name = "CliDiagnosticError";
136
+ this.diagnostic = safeObject(diagnostic);
137
+ }
138
+ }
139
+
140
+ export function recordDiagnostic(error, context = {}, env = process.env) {
141
+ if (error && typeof error === "object" && error.diagnosticRecord) {
142
+ return error.diagnosticRecord;
143
+ }
144
+ context = { ...invocationContext, ...context };
145
+ const source = diagnosticSource(error);
146
+ const record = safeObject({
147
+ diagnosticId: source.diagnosticId || randomUUID(),
148
+ timestamp: source.timestamp || new Date().toISOString(),
149
+ cliVersion: context.cliVersion || CLI_PACKAGE_VERSION,
150
+ packages: context.packages || { cli: context.cliVersion || CLI_PACKAGE_VERSION },
151
+ command: context.command,
152
+ operation: source.operation || context.operation,
153
+ category: source.category || context.category || "unexpected",
154
+ httpStatus: source.httpStatus,
155
+ statusText: source.statusText,
156
+ serverCode: source.serverCode,
157
+ message: source.message || (error && typeof error === "object" ? error.message : error),
158
+ traceId: source.traceId,
159
+ requestId: source.requestId,
160
+ contentType: source.contentType,
161
+ responseBytes: source.responseBytes,
162
+ responseExcerpt: source.responseExcerpt,
163
+ causeClasses: causeClasses(error),
164
+ required: context.required ?? source.required ?? true,
165
+ degraded: context.degraded ?? source.degraded ?? false,
166
+ });
167
+ try {
168
+ persist(record, env);
169
+ } catch {
170
+ // Diagnostics must never replace the original result.
171
+ }
172
+ invocationDiagnostics.push(record);
173
+ if (error && typeof error === "object") {
174
+ try {
175
+ error.diagnosticId = record.diagnosticId;
176
+ error.diagnosticRecord = record;
177
+ } catch {
178
+ // Frozen/foreign errors still retain the record in the invocation registry.
179
+ }
180
+ }
181
+ return record;
182
+ }
183
+
184
+ export function readDiagnostics(env = process.env, { limit = 40 } = {}) {
185
+ const path = diagnosticsLogPath(env);
186
+ const lines = [...parsedLines(`${path}.1`), ...parsedLines(path)].slice(-limit);
187
+ return lines.flatMap((line) => {
188
+ try {
189
+ return [JSON.parse(line)];
190
+ } catch {
191
+ return [];
192
+ }
193
+ });
194
+ }
195
+
196
+ export function formatDiagnostics(records) {
197
+ return records.map((record) => JSON.stringify(record)).join("\n");
198
+ }
199
+
200
+ export function diagnosticReference(record, env = process.env) {
201
+ return record?.diagnosticId
202
+ ? `diagnostic ${record.diagnosticId} (${diagnosticsLogPath(env)})`
203
+ : null;
204
+ }
205
+
206
+ export function resetInvocationDiagnostics(context = {}) {
207
+ invocationDiagnostics = [];
208
+ invocationContext = { ...context };
209
+ }
210
+
211
+ export function currentInvocationDiagnostics() {
212
+ return [...invocationDiagnostics];
213
+ }
214
+
215
+ export function hasRequiredInvocationDiagnostic() {
216
+ return invocationDiagnostics.some((entry) => entry.required && !entry.degraded);
217
+ }
218
+
219
+ export function invocationDegradedResult() {
220
+ const degraded = invocationDiagnostics.filter((record) => record.degraded);
221
+ return degraded.length
222
+ ? { degraded: true, diagnosticId: degraded.at(-1).diagnosticId }
223
+ : {};
224
+ }
package/src/errors.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  * can't cycle back here.
15
15
  */
16
16
  import { versionStamp } from "./mcp.mjs";
17
+ import { diagnosticReference, recordDiagnostic } from "./diagnostics.mjs";
17
18
 
18
19
  /**
19
20
  * The version/OS context appended to every failure's stderr — so a bug report
@@ -67,7 +68,11 @@ export function fail(what, next) {
67
68
  * @returns {string}
68
69
  */
69
70
  export function formatError(err) {
70
- return formatErrorBody(err) + versionFooter();
71
+ const record = recordDiagnostic(err, {
72
+ required: true,
73
+ });
74
+ const ref = diagnosticReference(record);
75
+ return formatErrorBody(err) + (ref ? `\n → ${ref}` : "") + versionFooter();
71
76
  }
72
77
 
73
78
  /** The house-style failure line(s), WITHOUT the trailing version/OS footer. */
package/src/mcp.mjs CHANGED
@@ -8,7 +8,66 @@
8
8
  * `Mcp-Session-Id` header across calls. Dependency-free (global fetch, Node 20+).
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
+ import { randomUUID } from "node:crypto";
11
12
  import os from "node:os";
13
+ import { CliDiagnosticError, recordDiagnostic, sanitizeDiagnosticText } from "./diagnostics.mjs";
14
+
15
+ const TRACE_HEADER = "X-Trace-Id";
16
+ const REQUEST_ID_HEADERS = ["x-request-id", "x-amzn-trace-id", "x-correlation-id"];
17
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
18
+
19
+ function responseRequestId(headers) {
20
+ for (const name of REQUEST_ID_HEADERS) {
21
+ const value = headers.get(name);
22
+ if (value) return value;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function parseResponseText(text, contentType) {
28
+ if (!text) return null;
29
+ if (contentType.includes("text/event-stream")) {
30
+ const frames = text
31
+ .split(/\r?\n/)
32
+ .filter((line) => line.startsWith("data:"))
33
+ .map((line) => line.slice(5).trim())
34
+ .filter(Boolean);
35
+ if (!frames.length) throw new SyntaxError("SSE response contained no data frame");
36
+ return JSON.parse(frames[frames.length - 1]);
37
+ }
38
+ return JSON.parse(text);
39
+ }
40
+
41
+ function safeServerFailure(parsed) {
42
+ const raw = parsed && typeof parsed === "object" ? parsed.error : null;
43
+ if (raw && typeof raw === "object") {
44
+ return {
45
+ serverCode: typeof raw.code === "string" || typeof raw.code === "number" ? raw.code : undefined,
46
+ message: typeof raw.message === "string" ? raw.message : undefined,
47
+ requestId: typeof raw.requestId === "string" ? raw.requestId : undefined,
48
+ };
49
+ }
50
+ if (typeof raw === "string") {
51
+ return {
52
+ serverCode: raw,
53
+ message: typeof parsed?.error_description === "string"
54
+ ? parsed.error_description
55
+ : typeof parsed?.message === "string" ? parsed.message : raw,
56
+ };
57
+ }
58
+ if (parsed && typeof parsed === "object") {
59
+ return {
60
+ serverCode: typeof parsed.code === "string" || typeof parsed.code === "number" ? parsed.code : undefined,
61
+ message: typeof parsed.message === "string" ? parsed.message : undefined,
62
+ requestId: typeof parsed.requestId === "string" ? parsed.requestId : undefined,
63
+ };
64
+ }
65
+ return {};
66
+ }
67
+
68
+ function diagnosticError(message, details, cause) {
69
+ return new CliDiagnosticError(message, details, { cause });
70
+ }
12
71
 
13
72
  // The CLI's REAL version for the MCP handshake — the transmission channel the
14
73
  // server-side support policy (update-awareness Layer 2) decides against. Read from
@@ -83,13 +142,15 @@ export function versionStamp(mode) {
83
142
 
84
143
  /**
85
144
  * @param {string} baseUrl - MCP base URL; `/mcp` is appended if absent.
86
- * @param {{ token?: string, clientVersion?: string }} [opts] - optional developer OAuth
87
- * bearer to attach; `clientVersion` overrides the handshake version (tests).
145
+ * @param {{ token?: string, clientVersion?: string, timeoutMs?: number, exactUrl?: boolean }} [opts]
146
+ * optional developer OAuth bearer to attach; `clientVersion` overrides the handshake version
147
+ * (tests), `timeoutMs` bounds each request, and `exactUrl` preserves a non-default MCP resource
148
+ * path such as `/workstreams`.
88
149
  * @returns a small client: { mcpUrl, initialize, callRaw, callTool, sessionId(), setToken }
89
150
  */
90
151
  export function createMcpClient(baseUrl, opts = {}) {
91
152
  const trimmed = String(baseUrl).replace(/\/+$/, "");
92
- const mcpUrl = trimmed.endsWith("/mcp") ? trimmed : `${trimmed}/mcp`;
153
+ const mcpUrl = opts.exactUrl ? trimmed : trimmed.endsWith("/mcp") ? trimmed : `${trimmed}/mcp`;
93
154
  let rpcId = 0;
94
155
  let sessionId = null;
95
156
  // The developer OAuth bearer (set at login-resolve time via setToken, or up
@@ -102,44 +163,122 @@ export function createMcpClient(baseUrl, opts = {}) {
102
163
  bearer = token || null;
103
164
  }
104
165
 
105
- async function callRaw(method, params) {
166
+ async function callRawWithContext(method, params) {
167
+ const traceId = randomUUID().replaceAll('-', '');
106
168
  const envelope = { jsonrpc: "2.0", id: ++rpcId, method, params };
107
169
  const headers = {
108
170
  "Content-Type": "application/json",
109
171
  Accept: "application/json, text/event-stream",
172
+ [TRACE_HEADER]: traceId,
110
173
  };
111
174
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
112
175
  if (sessionId) headers["Mcp-Session-Id"] = sessionId;
113
176
 
114
- const res = await fetch(mcpUrl, {
115
- method: "POST",
116
- headers,
117
- body: JSON.stringify(envelope),
118
- });
177
+ const timeoutMs = opts.timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
178
+ const signal = AbortSignal.timeout(timeoutMs);
179
+ let res;
180
+ try {
181
+ res = await fetch(mcpUrl, {
182
+ method: "POST",
183
+ headers,
184
+ body: JSON.stringify(envelope),
185
+ signal,
186
+ });
187
+ } catch (cause) {
188
+ const category = signal.aborted ? "timeout" : "network";
189
+ const message = signal.aborted
190
+ ? `${method} timed out after ${timeoutMs}ms`
191
+ : `${method} failed before a response arrived: ${sanitizeDiagnosticText(cause?.message || cause, 300)}`;
192
+ throw diagnosticError(message, { operation: method, category, message, traceId }, cause);
193
+ }
119
194
  const sid = res.headers.get("Mcp-Session-Id");
120
195
  if (sid) sessionId = sid;
121
196
 
122
197
  const ct = res.headers.get("content-type") || "";
123
- const text = await res.text();
124
- if (!res.ok && !text) {
125
- throw new Error(`${method} failed: HTTP ${res.status} ${res.statusText}`);
198
+ const responseTraceId = res.headers.get("x-trace-id") || traceId;
199
+ const headerRequestId = responseRequestId(res.headers);
200
+ let text;
201
+ try {
202
+ text = await res.text();
203
+ } catch (cause) {
204
+ throw diagnosticError(`${method} failed: response body was unreadable`, {
205
+ operation: method, category: "malformed_response", httpStatus: res.status,
206
+ statusText: res.statusText, traceId: responseTraceId, requestId: headerRequestId,
207
+ contentType: ct,
208
+ }, cause);
126
209
  }
210
+ const responseBytes = Buffer.byteLength(text);
127
211
 
128
212
  let parsed;
129
- if (ct.includes("text/event-stream")) {
130
- const lines = text
131
- .split(/\r?\n/)
132
- .filter((l) => l.startsWith("data:"))
133
- .map((l) => l.slice(5).trim())
134
- .filter(Boolean);
135
- parsed = lines.length ? JSON.parse(lines[lines.length - 1]) : null;
136
- } else {
137
- parsed = text ? JSON.parse(text) : null;
213
+ try {
214
+ parsed = parseResponseText(text, ct);
215
+ } catch (cause) {
216
+ if (res.ok) {
217
+ throw diagnosticError(`${method} failed: malformed MCP response`, {
218
+ operation: method, category: "malformed_response", httpStatus: res.status,
219
+ statusText: res.statusText, traceId: responseTraceId, requestId: headerRequestId,
220
+ contentType: ct, responseBytes,
221
+ }, cause);
222
+ }
223
+ parsed = null;
224
+ }
225
+
226
+ if (!res.ok) {
227
+ const failure = safeServerFailure(parsed);
228
+ const oauth = typeof parsed?.error === "string" && typeof parsed?.error_description === "string";
229
+ const category = oauth ? "oauth" : res.status === 413 ? "http_413" : `http_${res.status}`;
230
+ const message = sanitizeDiagnosticText(
231
+ failure.message || parsed?.error_description || `${res.status} ${res.statusText}`,
232
+ 300,
233
+ );
234
+ throw diagnosticError(`${method} failed: HTTP ${res.status}${message ? ` — ${message}` : ""}`, {
235
+ operation: method, category, httpStatus: res.status, statusText: res.statusText,
236
+ serverCode: failure.serverCode, message, traceId: responseTraceId,
237
+ requestId: failure.requestId || headerRequestId, contentType: ct, responseBytes,
238
+ });
239
+ }
240
+
241
+ if (parsed === null) {
242
+ throw diagnosticError(`${method} failed: empty MCP response`, {
243
+ operation: method, category: "empty_response", httpStatus: res.status,
244
+ statusText: res.statusText, message: "MCP returned an empty success response",
245
+ traceId: responseTraceId, requestId: headerRequestId,
246
+ contentType: ct, responseBytes,
247
+ });
138
248
  }
249
+
139
250
  if (parsed?.error) {
140
- throw new Error(`${method} failed: ${JSON.stringify(parsed.error)}`);
251
+ const failure = safeServerFailure(parsed);
252
+ const message = sanitizeDiagnosticText(failure.message || "JSON-RPC error", 300);
253
+ throw diagnosticError(`${method} failed: ${message}`, {
254
+ operation: method, category: "json_rpc", httpStatus: res.status,
255
+ statusText: res.statusText, serverCode: failure.serverCode, message,
256
+ traceId: responseTraceId, requestId: failure.requestId || headerRequestId,
257
+ contentType: ct, responseBytes,
258
+ });
259
+ }
260
+ if (!parsed || typeof parsed !== "object" || !Object.hasOwn(parsed, "result")) {
261
+ throw diagnosticError(`${method} failed: MCP response contained no result`, {
262
+ operation: method, category: "malformed_response", httpStatus: res.status,
263
+ statusText: res.statusText, message: "MCP response contained neither result nor error",
264
+ traceId: responseTraceId, requestId: headerRequestId,
265
+ contentType: ct, responseBytes,
266
+ });
141
267
  }
142
- return parsed?.result;
268
+ return {
269
+ result: parsed.result,
270
+ context: {
271
+ traceId: responseTraceId,
272
+ requestId: headerRequestId,
273
+ contentType: ct,
274
+ responseBytes,
275
+ httpStatus: res.status,
276
+ },
277
+ };
278
+ }
279
+
280
+ async function callRaw(method, params) {
281
+ return (await callRawWithContext(method, params)).result;
143
282
  }
144
283
 
145
284
  /**
@@ -149,7 +288,41 @@ export function createMcpClient(baseUrl, opts = {}) {
149
288
  * @returns {Promise<any>}
150
289
  */
151
290
  async function callTool(name, args) {
152
- const r = await callRaw("tools/call", { name, arguments: args });
291
+ let r;
292
+ let responseContext = {};
293
+ try {
294
+ const response = await callRawWithContext("tools/call", { name, arguments: args });
295
+ r = response.result;
296
+ responseContext = response.context;
297
+ } catch (error) {
298
+ if (error?.diagnostic) error.diagnostic.operation = name;
299
+ throw error;
300
+ }
301
+ if (r?.isError) {
302
+ const tb = Array.isArray(r?.content)
303
+ ? r.content.find((c) => c?.type === "text")
304
+ : null;
305
+ let payload = null;
306
+ try {
307
+ payload = tb?.text ? JSON.parse(tb.text) : null;
308
+ } catch {
309
+ payload = null;
310
+ }
311
+ const message = sanitizeDiagnosticText(
312
+ payload?.message || payload?.error?.message || tb?.text || `${name} failed`,
313
+ 300,
314
+ );
315
+ throw diagnosticError(`${name} failed: ${message}`, {
316
+ operation: name, category: "tool_error",
317
+ serverCode: payload?.code || payload?.error?.code,
318
+ message,
319
+ traceId: payload?.traceId || responseContext.traceId,
320
+ requestId: payload?.requestId || responseContext.requestId,
321
+ contentType: responseContext.contentType,
322
+ responseBytes: responseContext.responseBytes,
323
+ httpStatus: responseContext.httpStatus,
324
+ });
325
+ }
153
326
  if (r?.structuredContent) return r.structuredContent;
154
327
  const tb = Array.isArray(r?.content)
155
328
  ? r.content.find((c) => c?.type === "text")
@@ -157,11 +330,19 @@ export function createMcpClient(baseUrl, opts = {}) {
157
330
  if (tb?.text) {
158
331
  try {
159
332
  return JSON.parse(tb.text);
160
- } catch {
161
- return { raw: tb.text };
333
+ } catch (cause) {
334
+ throw diagnosticError(`${name} failed: tool result contained malformed JSON`, {
335
+ operation: name, category: "malformed_response",
336
+ message: "MCP tool text result was not valid JSON",
337
+ ...responseContext,
338
+ }, cause);
162
339
  }
163
340
  }
164
- return r;
341
+ if (r && typeof r === "object") return r;
342
+ throw diagnosticError(`${name} failed: tool result was empty`, {
343
+ operation: name, category: "empty_response", message: "MCP tool returned no result",
344
+ ...responseContext,
345
+ });
165
346
  }
166
347
 
167
348
  /** Complete the MCP handshake. Call once before any tool call. */
@@ -182,7 +363,11 @@ export function createMcpClient(baseUrl, opts = {}) {
182
363
  clientInfo,
183
364
  });
184
365
  // Best-effort — some servers don't require the notification.
185
- await callRaw("notifications/initialized", undefined).catch(() => {});
366
+ await callRaw("notifications/initialized", undefined).catch((error) => {
367
+ recordDiagnostic(error, {
368
+ operation: "notifications/initialized", required: false, degraded: true,
369
+ });
370
+ });
186
371
  }
187
372
 
188
373
  return { mcpUrl, initialize, callRaw, callTool, sessionId: () => sessionId, setToken };