forget-connect 0.1.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.
package/src/doctor.js ADDED
@@ -0,0 +1,456 @@
1
+ import { ConfigError } from "./core.js";
2
+
3
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
4
+ export const REQUIRED_TOOLS = [
5
+ "search_memories",
6
+ "add_memory",
7
+ "assemble_context",
8
+ "prepare_context_autopilot",
9
+ "record_context_outcome",
10
+ "record_task_state",
11
+ "get_task_state",
12
+ ];
13
+
14
+ function addSensitiveValueVariants(variants, value) {
15
+ const raw = String(value ?? "");
16
+ if (!raw) return;
17
+
18
+ const decoded = new Set([raw]);
19
+ for (const candidate of [raw, raw.replace(/\+/g, " ")]) {
20
+ try {
21
+ decoded.add(decodeURIComponent(candidate));
22
+ } catch {
23
+ // Keep the exact raw value even when it contains malformed escapes.
24
+ }
25
+ }
26
+
27
+ for (const candidate of decoded) {
28
+ if (!candidate) continue;
29
+ variants.add(candidate);
30
+ variants.add(encodeURIComponent(candidate));
31
+ const formEncoded = new URLSearchParams([["value", candidate]])
32
+ .toString()
33
+ .slice("value=".length);
34
+ variants.add(formEncoded);
35
+ }
36
+ }
37
+
38
+ function requestSensitiveValues(url, apiKey) {
39
+ const variants = new Set();
40
+ addSensitiveValueVariants(variants, apiKey);
41
+
42
+ const source = String(url ?? "");
43
+ const queryStart = source.indexOf("?");
44
+ if (queryStart !== -1) {
45
+ const fragmentStart = source.indexOf("#", queryStart + 1);
46
+ const query = source.slice(
47
+ queryStart + 1,
48
+ fragmentStart === -1 ? undefined : fragmentStart,
49
+ );
50
+ for (const field of query.split("&")) {
51
+ const equals = field.indexOf("=");
52
+ if (equals !== -1) addSensitiveValueVariants(variants, field.slice(equals + 1));
53
+ }
54
+ }
55
+
56
+ return [...variants]
57
+ .filter(Boolean)
58
+ .sort((left, right) => right.length - left.length);
59
+ }
60
+
61
+ function redactSensitiveText(value, sensitiveValues = []) {
62
+ let text = String(value ?? "");
63
+ for (const variant of sensitiveValues) {
64
+ text = text.split(variant).join("[redacted]");
65
+ }
66
+ return text.replace(/\b(https?:\/\/[^\s?]+)\?[^\s]*/gi, "$1?redacted");
67
+ }
68
+
69
+ function safeErrorMessage(value, sensitiveValues = []) {
70
+ return redactSensitiveText(value || "Unknown MCP error", sensitiveValues)
71
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
72
+ .replace(/\s+/g, " ")
73
+ .trim()
74
+ .slice(0, 240);
75
+ }
76
+
77
+ function safeServerText(value, fallback = "unknown", sensitiveValues = []) {
78
+ const text = redactSensitiveText(value || "", sensitiveValues)
79
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, "")
80
+ .trim()
81
+ .slice(0, 120);
82
+ return text || fallback;
83
+ }
84
+
85
+ function safeProtocolHeader(value, fallback = MCP_PROTOCOL_VERSION) {
86
+ const text = String(value || "")
87
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, "")
88
+ .trim()
89
+ .slice(0, 120);
90
+ return text || fallback;
91
+ }
92
+
93
+ function jsonRpcError(payload, method, sensitiveValues) {
94
+ if (!payload || typeof payload !== "object") {
95
+ throw new ConfigError(`MCP ${method} returned an invalid response.`);
96
+ }
97
+ if (payload.error) {
98
+ const code = safeServerText(payload.error.code, "unknown", sensitiveValues);
99
+ const message = safeErrorMessage(payload.error.message, sensitiveValues);
100
+ throw new ConfigError(`MCP ${method} error ${code}: ${message}`);
101
+ }
102
+ return payload;
103
+ }
104
+
105
+ function matchingSsePayload(frame, requestId, method, sensitiveValues) {
106
+ const dataLines = [];
107
+ for (const line of frame.split(/\r?\n/)) {
108
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
109
+ }
110
+ if (!dataLines.length) return null;
111
+ let payload;
112
+ try {
113
+ payload = JSON.parse(dataLines.join("\n"));
114
+ } catch {
115
+ return null;
116
+ }
117
+ if (payload.id !== requestId) return null;
118
+ return jsonRpcError(payload, method, sensitiveValues);
119
+ }
120
+
121
+ async function parseSseResponse(response, requestId, method, sensitiveValues) {
122
+ if (!response.body || typeof response.body.getReader !== "function") {
123
+ throw new ConfigError(`MCP ${method} returned an unreadable event stream.`);
124
+ }
125
+ const reader = response.body.getReader();
126
+ const decoder = new TextDecoder();
127
+ let buffer = "";
128
+ try {
129
+ while (true) {
130
+ const { value, done } = await reader.read();
131
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
132
+ const frames = buffer.split(/\r?\n\r?\n/);
133
+ buffer = frames.pop() || "";
134
+ for (const frame of frames) {
135
+ const payload = matchingSsePayload(frame, requestId, method, sensitiveValues);
136
+ if (payload) {
137
+ await reader.cancel().catch(() => {});
138
+ return payload;
139
+ }
140
+ }
141
+ if (done) {
142
+ const payload = matchingSsePayload(buffer, requestId, method, sensitiveValues);
143
+ if (payload) return payload;
144
+ break;
145
+ }
146
+ }
147
+ } finally {
148
+ reader.releaseLock();
149
+ }
150
+ throw new ConfigError(`MCP ${method} event stream ended without response id ${requestId}.`);
151
+ }
152
+
153
+ async function parseRpcResponse(response, requestId, method, sensitiveValues) {
154
+ const contentType = response.headers.get("content-type") || "";
155
+ if (contentType.toLowerCase().includes("text/event-stream")) {
156
+ return parseSseResponse(response, requestId, method, sensitiveValues);
157
+ }
158
+ let payload;
159
+ try {
160
+ payload = await response.json();
161
+ } catch {
162
+ throw new ConfigError(`MCP ${method} did not return JSON or an event stream.`);
163
+ }
164
+ if (payload.id !== requestId) {
165
+ throw new ConfigError(
166
+ `MCP ${method} returned response id ${safeServerText(payload.id, "unknown", sensitiveValues)} instead of ${requestId}.`,
167
+ );
168
+ }
169
+ return jsonRpcError(payload, method, sensitiveValues);
170
+ }
171
+
172
+ async function postMcp({
173
+ url,
174
+ apiKey,
175
+ sensitiveValues,
176
+ payload,
177
+ requestId,
178
+ method,
179
+ sessionId = "",
180
+ protocolVersion = "",
181
+ timeoutMs,
182
+ fetchImpl,
183
+ notification = false,
184
+ }) {
185
+ const controller = new AbortController();
186
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
187
+ const headers = {
188
+ Accept: "application/json, text/event-stream",
189
+ "Content-Type": "application/json",
190
+ };
191
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
192
+ if (protocolVersion) headers["MCP-Protocol-Version"] = protocolVersion;
193
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId;
194
+
195
+ try {
196
+ const response = await fetchImpl(url, {
197
+ method: "POST",
198
+ headers,
199
+ body: JSON.stringify(payload),
200
+ signal: controller.signal,
201
+ redirect: "error",
202
+ });
203
+ if (!response.ok) {
204
+ await response.body?.cancel().catch(() => {});
205
+ throw new ConfigError(`MCP ${method} returned HTTP ${response.status}.`);
206
+ }
207
+ const returnedSessionId = response.headers.get("mcp-session-id") || sessionId;
208
+ if (notification) {
209
+ await response.body?.cancel().catch(() => {});
210
+ return { payload: null, sessionId: returnedSessionId };
211
+ }
212
+ return {
213
+ payload: await parseRpcResponse(response, requestId, method, sensitiveValues),
214
+ sessionId: returnedSessionId,
215
+ };
216
+ } catch (error) {
217
+ if (error?.name === "AbortError") {
218
+ throw new ConfigError(`MCP ${method} timed out after ${timeoutMs} ms.`);
219
+ }
220
+ if (error instanceof ConfigError) throw error;
221
+ throw new ConfigError(`MCP ${method} request failed: ${safeErrorMessage(error?.message, sensitiveValues)}`);
222
+ } finally {
223
+ clearTimeout(timer);
224
+ }
225
+ }
226
+
227
+ function emptyScopeProbe({ requested, required, ok }) {
228
+ return {
229
+ requested: Boolean(requested),
230
+ required: Boolean(required),
231
+ attempted: false,
232
+ skipped: true,
233
+ response_valid: false,
234
+ user_id_matches: false,
235
+ app_id_matches: false,
236
+ ok: Boolean(ok),
237
+ };
238
+ }
239
+
240
+ async function probeScope({
241
+ url,
242
+ apiKey,
243
+ expectedScope,
244
+ requireScope,
245
+ timeoutMs,
246
+ fetchImpl,
247
+ }) {
248
+ const requested = Boolean(expectedScope);
249
+ const expectedUserId = typeof expectedScope?.userId === "string"
250
+ ? expectedScope.userId
251
+ : "";
252
+ const expectedAppId = typeof expectedScope?.appId === "string"
253
+ ? expectedScope.appId
254
+ : "";
255
+ const hasExpectedScope = Boolean(expectedUserId && expectedAppId);
256
+
257
+ if (!hasExpectedScope) {
258
+ return emptyScopeProbe({
259
+ requested,
260
+ required: requireScope,
261
+ ok: !requireScope && !requested,
262
+ });
263
+ }
264
+
265
+ const controller = new AbortController();
266
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
267
+ const headers = { Accept: "application/json" };
268
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
269
+
270
+ try {
271
+ const response = await fetchImpl(url, {
272
+ method: "GET",
273
+ headers,
274
+ signal: controller.signal,
275
+ redirect: "error",
276
+ });
277
+ if (!response.ok) {
278
+ await response.body?.cancel().catch(() => {});
279
+ throw new ConfigError(`Scope probe returned HTTP ${response.status}.`);
280
+ }
281
+
282
+ let payload;
283
+ try {
284
+ payload = await response.json();
285
+ } catch {
286
+ return {
287
+ requested: true,
288
+ required: Boolean(requireScope),
289
+ attempted: true,
290
+ skipped: false,
291
+ response_valid: false,
292
+ user_id_matches: false,
293
+ app_id_matches: false,
294
+ ok: false,
295
+ };
296
+ }
297
+
298
+ const responseValid = Boolean(
299
+ payload
300
+ && typeof payload === "object"
301
+ && !Array.isArray(payload)
302
+ && typeof payload.user_id === "string"
303
+ && typeof payload.client_name === "string"
304
+ );
305
+ const userIdMatches = responseValid && payload.user_id === expectedUserId;
306
+ const appIdMatches = responseValid && payload.client_name === expectedAppId;
307
+ return {
308
+ requested: true,
309
+ required: Boolean(requireScope),
310
+ attempted: true,
311
+ skipped: false,
312
+ response_valid: responseValid,
313
+ user_id_matches: userIdMatches,
314
+ app_id_matches: appIdMatches,
315
+ ok: userIdMatches && appIdMatches,
316
+ };
317
+ } catch (error) {
318
+ if (error?.name === "AbortError") {
319
+ throw new ConfigError(`Scope probe timed out after ${timeoutMs} ms.`);
320
+ }
321
+ if (error instanceof ConfigError) throw error;
322
+ throw new ConfigError("Scope probe request failed.");
323
+ } finally {
324
+ clearTimeout(timer);
325
+ }
326
+ }
327
+
328
+ export async function doctorRemote({
329
+ url,
330
+ apiKey = "",
331
+ timeoutMs = 10000,
332
+ fetchImpl = globalThis.fetch,
333
+ requiredTools = REQUIRED_TOOLS,
334
+ clientVersion = "0.1.0",
335
+ expectedScope = null,
336
+ requireScope = false,
337
+ } = {}) {
338
+ if (typeof fetchImpl !== "function") {
339
+ throw new ConfigError("Node.js 18 or newer is required for the MCP doctor.");
340
+ }
341
+ const sensitiveValues = requestSensitiveValues(url, apiKey);
342
+ const scopeProbe = await probeScope({
343
+ url,
344
+ apiKey,
345
+ expectedScope,
346
+ requireScope,
347
+ timeoutMs,
348
+ fetchImpl,
349
+ });
350
+ const initialize = await postMcp({
351
+ url,
352
+ apiKey,
353
+ sensitiveValues,
354
+ payload: {
355
+ jsonrpc: "2.0",
356
+ id: 1,
357
+ method: "initialize",
358
+ params: {
359
+ protocolVersion: MCP_PROTOCOL_VERSION,
360
+ capabilities: {},
361
+ clientInfo: { name: "forget-connect", version: clientVersion },
362
+ },
363
+ },
364
+ requestId: 1,
365
+ method: "initialize",
366
+ timeoutMs,
367
+ fetchImpl,
368
+ });
369
+ const result = initialize.payload.result || {};
370
+ const negotiatedProtocolVersion = safeProtocolHeader(
371
+ result.protocolVersion || MCP_PROTOCOL_VERSION,
372
+ MCP_PROTOCOL_VERSION,
373
+ );
374
+ const protocolVersion = safeServerText(
375
+ negotiatedProtocolVersion,
376
+ MCP_PROTOCOL_VERSION,
377
+ sensitiveValues,
378
+ );
379
+ let sessionId = initialize.sessionId || "";
380
+
381
+ const notified = await postMcp({
382
+ url,
383
+ apiKey,
384
+ sensitiveValues,
385
+ payload: { jsonrpc: "2.0", method: "notifications/initialized" },
386
+ method: "notifications/initialized",
387
+ sessionId,
388
+ protocolVersion: negotiatedProtocolVersion,
389
+ timeoutMs,
390
+ fetchImpl,
391
+ notification: true,
392
+ });
393
+ sessionId = notified.sessionId || sessionId;
394
+
395
+ const toolNameSet = new Set();
396
+ const seenCursors = new Set();
397
+ let cursor = "";
398
+ let toolPageCount = 0;
399
+ while (true) {
400
+ toolPageCount += 1;
401
+ if (toolPageCount > 100) {
402
+ throw new ConfigError("MCP tools/list exceeded the 100-page safety limit.");
403
+ }
404
+ const requestId = toolPageCount + 1;
405
+ const listed = await postMcp({
406
+ url,
407
+ apiKey,
408
+ sensitiveValues,
409
+ payload: {
410
+ jsonrpc: "2.0",
411
+ id: requestId,
412
+ method: "tools/list",
413
+ params: cursor ? { cursor } : {},
414
+ },
415
+ requestId,
416
+ method: "tools/list",
417
+ sessionId,
418
+ protocolVersion: negotiatedProtocolVersion,
419
+ timeoutMs,
420
+ fetchImpl,
421
+ });
422
+ sessionId = listed.sessionId || sessionId;
423
+ const tools = Array.isArray(listed.payload.result?.tools)
424
+ ? listed.payload.result.tools
425
+ : [];
426
+ for (const tool of tools) {
427
+ if (tool && typeof tool.name === "string" && tool.name) {
428
+ toolNameSet.add(tool.name);
429
+ }
430
+ }
431
+ const nextCursor = listed.payload.result?.nextCursor;
432
+ if (nextCursor === undefined || nextCursor === null || nextCursor === "") break;
433
+ if (typeof nextCursor !== "string") {
434
+ throw new ConfigError("MCP tools/list returned an invalid nextCursor.");
435
+ }
436
+ if (seenCursors.has(nextCursor)) {
437
+ throw new ConfigError("MCP tools/list returned a repeated nextCursor.");
438
+ }
439
+ seenCursors.add(nextCursor);
440
+ cursor = nextCursor;
441
+ }
442
+ const toolNames = [...toolNameSet].sort();
443
+ const missingTools = requiredTools.filter((name) => !toolNames.includes(name));
444
+ return {
445
+ ok: missingTools.length === 0 && scopeProbe.ok,
446
+ server_name: safeServerText(result.serverInfo?.name, "unknown", sensitiveValues),
447
+ server_version: safeServerText(result.serverInfo?.version, "unknown", sensitiveValues),
448
+ protocol_version: protocolVersion,
449
+ session_negotiated: Boolean(sessionId),
450
+ tool_page_count: toolPageCount,
451
+ tool_count: toolNames.length,
452
+ required_tools: [...requiredTools],
453
+ missing_tools: missingTools,
454
+ scope_probe: scopeProbe,
455
+ };
456
+ }