@tokenoftrust/cli 2.0.11 → 2.0.14
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/bin/tot.mjs +22 -3
- package/package.json +1 -1
- package/src/activity-log.mjs +2 -1
- package/src/activity.mjs +1 -1
- package/src/candidate-state.mjs +52 -27
- package/src/commands/clone.mjs +6 -1
- package/src/commands/dev.mjs +12 -2
- package/src/commands/feedback.mjs +18 -8
- package/src/commands/grants.mjs +5 -1
- package/src/commands/link.mjs +9 -1
- package/src/commands/submit.mjs +267 -211
- package/src/commands/sync.mjs +4 -0
- package/src/commands/whoami.mjs +5 -1
- package/src/diagnostics.mjs +224 -0
- package/src/errors.mjs +6 -1
- package/src/mcp.mjs +213 -28
- package/src/oauth.mjs +107 -37
- package/src/open.mjs +18 -3
package/src/commands/sync.mjs
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { execFileSync } from "node:child_process";
|
|
33
33
|
import { fail } from "../errors.mjs";
|
|
34
34
|
import { ensureTokenlessRemote } from "../git-credential.mjs";
|
|
35
|
+
import { recordDiagnostic } from "../diagnostics.mjs";
|
|
35
36
|
|
|
36
37
|
/** The protected branch `tot sync` fetches + merges from by default. */
|
|
37
38
|
export const DEFAULT_SYNC_BRANCH = "preview";
|
|
@@ -173,6 +174,9 @@ export async function run(argv, ctx) {
|
|
|
173
174
|
// missing repo, with nothing pointing back here.
|
|
174
175
|
console.error(`~ could not repair this checkout's forge credentials: ${e?.message || e}`);
|
|
175
176
|
console.error(" a fetch failure below is likely this, not a missing repo.");
|
|
177
|
+
recordDiagnostic(e, {
|
|
178
|
+
command: "sync", operation: "credential_helper_install", required: false, degraded: true,
|
|
179
|
+
});
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
// Refuse a dirty tree up front — a merge on top of uncommitted edits is how
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
87
|
-
* bearer to attach; `clientVersion` overrides the handshake version
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|