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/LICENSE +202 -0
- package/README.md +110 -0
- package/bin/forget-connect.js +9 -0
- package/package.json +37 -0
- package/src/cli.js +484 -0
- package/src/core.js +680 -0
- package/src/doctor.js +456 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
chmod,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
realpath,
|
|
10
|
+
rename,
|
|
11
|
+
stat,
|
|
12
|
+
unlink,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MCP_URL = "https://api.multi-turn.ai/mcp";
|
|
18
|
+
export const SERVER_KEY = "forget";
|
|
19
|
+
export const BACKUP_SUFFIX = ".forget-backup";
|
|
20
|
+
|
|
21
|
+
export const RULES_START = "<!-- forget:memory:start -->";
|
|
22
|
+
export const RULES_END = "<!-- forget:memory:end -->";
|
|
23
|
+
const LEGACY_RULES_START = "<!-- enacta:memory:start -->";
|
|
24
|
+
const LEGACY_RULES_END = "<!-- enacta:memory:end -->";
|
|
25
|
+
const LEGACY_SERVER_KEY = "enacta";
|
|
26
|
+
|
|
27
|
+
export const MEMORY_RULES = [
|
|
28
|
+
RULES_START,
|
|
29
|
+
"# Memory (Forget)",
|
|
30
|
+
"You have the user's long-term memory via the `forget` MCP server.",
|
|
31
|
+
"ALWAYS call `search_memories` on `forget` FIRST — before any shell command or file search — whenever the user refers to their own past decisions, preferences, plans, or anything previously discussed. Trust recent memories over old ones.",
|
|
32
|
+
"For resume or continue requests, then call `get_task_state` and `prepare_context_autopilot` with the current working directory before searching files.",
|
|
33
|
+
"When the user states a durable decision, preference, or lasting fact, save it with `add_memory`.",
|
|
34
|
+
RULES_END,
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
export class ConfigError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "ConfigError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isObject(value) {
|
|
45
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeUrl(value) {
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(value);
|
|
52
|
+
} catch {
|
|
53
|
+
// The supplied URL can contain credentials or query tokens. Never reflect
|
|
54
|
+
// an invalid value into terminal-visible configuration errors.
|
|
55
|
+
throw new ConfigError("Invalid MCP URL.");
|
|
56
|
+
}
|
|
57
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
58
|
+
throw new ConfigError("The MCP URL must use http:// or https://.");
|
|
59
|
+
}
|
|
60
|
+
if (parsed.username || parsed.password) {
|
|
61
|
+
throw new ConfigError("Put credentials in FORGET_API_KEY, not in the MCP URL.");
|
|
62
|
+
}
|
|
63
|
+
parsed.hash = "";
|
|
64
|
+
return parsed.toString().replace(/\/$/, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function redactUrlForDisplay(value) {
|
|
68
|
+
const parsed = new URL(normalizeUrl(value));
|
|
69
|
+
const hadQuery = Boolean(parsed.search);
|
|
70
|
+
parsed.search = "";
|
|
71
|
+
const clean = parsed.toString().replace(/\/$/, "");
|
|
72
|
+
return hadQuery ? `${clean}?redacted` : clean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateScopeId(value, label) {
|
|
76
|
+
const text = String(value ?? "").trim();
|
|
77
|
+
if (!text) throw new ConfigError(`${label} must not be empty.`);
|
|
78
|
+
if (text.length > 200) throw new ConfigError(`${label} must be at most 200 characters.`);
|
|
79
|
+
if (text === "." || text === ".." || /[\u0000-\u001f\u007f/\\?#]/.test(text)) {
|
|
80
|
+
throw new ConfigError(`${label} contains unsupported path or control characters.`);
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function encodeScopeComponent(value, label) {
|
|
86
|
+
const text = validateScopeId(value, label);
|
|
87
|
+
try {
|
|
88
|
+
// Encode the whole identity as one path component. In particular, encoding
|
|
89
|
+
// '%' prevents inputs such as "%2f" or "%2e%2e" from becoming a path
|
|
90
|
+
// separator or dot segment when a downstream HTTP stack decodes the URL.
|
|
91
|
+
return encodeURIComponent(text);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new ConfigError(`${label} contains unsupported Unicode characters.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function scopedMcpUrl(baseUrl, { userId, appId }) {
|
|
98
|
+
const normalized = normalizeUrl(baseUrl);
|
|
99
|
+
const parsed = new URL(normalized);
|
|
100
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
101
|
+
if (!pathname.endsWith("/mcp")) {
|
|
102
|
+
throw new ConfigError("Scoped connections require an MCP URL ending in /mcp.");
|
|
103
|
+
}
|
|
104
|
+
const safeUserId = encodeScopeComponent(userId, "user_id");
|
|
105
|
+
const safeAppId = encodeScopeComponent(appId, "app_id");
|
|
106
|
+
parsed.pathname = `${pathname}/${safeAppId}/http/${safeUserId}`;
|
|
107
|
+
return parsed.toString().replace(/\/$/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function validateApiKey(value) {
|
|
111
|
+
if (!value) return "";
|
|
112
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
|
113
|
+
throw new ConfigError("The API key contains unsupported control characters.");
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tomlString(value) {
|
|
119
|
+
return JSON.stringify(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function jsonServerBlock(clientId, url, apiKey) {
|
|
123
|
+
if (clientId === "claude-desktop") {
|
|
124
|
+
const args = ["-y", "mcp-remote@latest", url];
|
|
125
|
+
if (apiKey) args.push("--header", `Authorization: Bearer ${apiKey}`);
|
|
126
|
+
return { command: "npx", args };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const block = { type: "http", url };
|
|
130
|
+
if (apiKey) block.headers = { Authorization: `Bearer ${apiKey}` };
|
|
131
|
+
return block;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function tomlServerBlock(url, apiKey) {
|
|
135
|
+
const lines = [`[mcp_servers.${SERVER_KEY}]`, `url = ${tomlString(url)}`];
|
|
136
|
+
if (apiKey) {
|
|
137
|
+
lines.push(
|
|
138
|
+
`http_headers = { Authorization = ${tomlString(`Bearer ${apiKey}`)} }`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function parseJsonStrict(raw, label = "JSON config") {
|
|
145
|
+
if (!raw.trim()) return {};
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new ConfigError(
|
|
151
|
+
`${label} is not valid JSON. Nothing was changed; repair it and retry.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (!isObject(parsed)) {
|
|
155
|
+
throw new ConfigError(`${label} must contain a JSON object. Nothing was changed.`);
|
|
156
|
+
}
|
|
157
|
+
return parsed;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function serverUsesUrl(server, expectedUrl) {
|
|
161
|
+
if (!isObject(server)) return false;
|
|
162
|
+
if (typeof server.url === "string") {
|
|
163
|
+
try {
|
|
164
|
+
return normalizeUrl(server.url) === normalizeUrl(expectedUrl);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return Array.isArray(server.args) && server.args.some((arg) => {
|
|
170
|
+
if (typeof arg !== "string") return false;
|
|
171
|
+
try {
|
|
172
|
+
return normalizeUrl(arg) === normalizeUrl(expectedUrl);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function serverUsesAnyUrl(server, expectedUrls) {
|
|
180
|
+
return expectedUrls.some((expectedUrl) => serverUsesUrl(server, expectedUrl));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function connectJson(
|
|
184
|
+
raw,
|
|
185
|
+
{ clientId, url, apiKey, migrateLegacy = true, legacyUrls = [url] },
|
|
186
|
+
) {
|
|
187
|
+
const config = parseJsonStrict(raw);
|
|
188
|
+
if (config.mcpServers !== undefined && !isObject(config.mcpServers)) {
|
|
189
|
+
throw new ConfigError(
|
|
190
|
+
"The existing mcpServers value is not a JSON object. Nothing was changed.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const servers = config.mcpServers ?? {};
|
|
194
|
+
if (migrateLegacy && serverUsesAnyUrl(servers[LEGACY_SERVER_KEY], legacyUrls)) {
|
|
195
|
+
delete servers[LEGACY_SERVER_KEY];
|
|
196
|
+
}
|
|
197
|
+
servers[SERVER_KEY] = jsonServerBlock(clientId, url, apiKey);
|
|
198
|
+
config.mcpServers = servers;
|
|
199
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function disconnectJson(raw) {
|
|
203
|
+
const config = parseJsonStrict(raw);
|
|
204
|
+
if (config.mcpServers === undefined) return raw;
|
|
205
|
+
if (!isObject(config.mcpServers)) {
|
|
206
|
+
throw new ConfigError(
|
|
207
|
+
"The existing mcpServers value is not a JSON object. Nothing was changed.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (!(SERVER_KEY in config.mcpServers)) return raw;
|
|
211
|
+
delete config.mcpServers[SERVER_KEY];
|
|
212
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sectionBounds(text, serverKey) {
|
|
216
|
+
const lines = text.split(/\r?\n/);
|
|
217
|
+
const header = `[mcp_servers.${serverKey}]`;
|
|
218
|
+
const starts = lines
|
|
219
|
+
.map((line, index) => line.trim() === header ? index : -1)
|
|
220
|
+
.filter((index) => index !== -1);
|
|
221
|
+
const start = starts[0] ?? -1;
|
|
222
|
+
if (start === -1) return { lines, start: -1, end: -1, count: 0 };
|
|
223
|
+
let end = start + 1;
|
|
224
|
+
while (end < lines.length && !lines[end].trim().startsWith("[")) end += 1;
|
|
225
|
+
return { lines, start, end, count: starts.length };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function removeTomlSection(text, serverKey) {
|
|
229
|
+
const { lines, start, end, count } = sectionBounds(text, serverKey);
|
|
230
|
+
if (start === -1) return { text, removed: false, section: "" };
|
|
231
|
+
if (count !== 1) {
|
|
232
|
+
throw new ConfigError(
|
|
233
|
+
`Found more than one [mcp_servers.${serverKey}] section. Nothing was changed.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const section = lines.slice(start, end).join("\n");
|
|
237
|
+
const output = [...lines.slice(0, start), ...lines.slice(end)]
|
|
238
|
+
.join("\n")
|
|
239
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
240
|
+
return { text: output, removed: true, section };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function tomlSectionUsesUrl(section, expectedUrl) {
|
|
244
|
+
const match = section.match(/^\s*url\s*=\s*("(?:[^"\\]|\\.)*")\s*$/m);
|
|
245
|
+
if (!match) return false;
|
|
246
|
+
try {
|
|
247
|
+
return normalizeUrl(JSON.parse(match[1])) === normalizeUrl(expectedUrl);
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function connectToml(
|
|
254
|
+
raw,
|
|
255
|
+
{ url, apiKey, migrateLegacy = true, legacyUrls = [url] },
|
|
256
|
+
) {
|
|
257
|
+
let base = removeTomlSection(raw, SERVER_KEY).text;
|
|
258
|
+
if (migrateLegacy) {
|
|
259
|
+
const legacy = removeTomlSection(base, LEGACY_SERVER_KEY);
|
|
260
|
+
if (legacy.removed && legacyUrls.some((legacyUrl) => tomlSectionUsesUrl(legacy.section, legacyUrl))) {
|
|
261
|
+
base = legacy.text;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
base = base.replace(/\s+$/, "");
|
|
265
|
+
return `${base ? `${base}\n\n` : ""}${tomlServerBlock(url, apiKey)}\n`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function disconnectToml(raw) {
|
|
269
|
+
const result = removeTomlSection(raw, SERVER_KEY);
|
|
270
|
+
return result.removed ? result.text.replace(/\s+$/, "") + "\n" : raw;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findManagedBlock(text, startMarker, endMarker) {
|
|
274
|
+
const start = text.indexOf(startMarker);
|
|
275
|
+
const endOnly = text.indexOf(endMarker);
|
|
276
|
+
if (start === -1) {
|
|
277
|
+
if (endOnly !== -1) {
|
|
278
|
+
throw new ConfigError(`Found ${endMarker} without its start marker.`);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const end = text.indexOf(endMarker, start + startMarker.length);
|
|
283
|
+
if (end === -1) {
|
|
284
|
+
throw new ConfigError(`Found ${startMarker} without its end marker.`);
|
|
285
|
+
}
|
|
286
|
+
if (text.indexOf(startMarker, start + startMarker.length) !== -1) {
|
|
287
|
+
throw new ConfigError(`Found more than one ${startMarker} block.`);
|
|
288
|
+
}
|
|
289
|
+
return { start, end: end + endMarker.length };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function removeRulesBlock(text, startMarker, endMarker) {
|
|
293
|
+
const bounds = findManagedBlock(text, startMarker, endMarker);
|
|
294
|
+
if (!bounds) return text;
|
|
295
|
+
const before = text.slice(0, bounds.start);
|
|
296
|
+
const after = text.slice(bounds.end);
|
|
297
|
+
// installRules appends exactly one separator newline and one final newline.
|
|
298
|
+
// Recognizing that shape lets disconnect restore the pre-install bytes.
|
|
299
|
+
if (after === "\n") {
|
|
300
|
+
return before.endsWith("\n") ? before.slice(0, -1) : before;
|
|
301
|
+
}
|
|
302
|
+
if (!(before + after).trim()) return "";
|
|
303
|
+
const left = before.replace(/\n+$/, "");
|
|
304
|
+
const right = after.replace(/^\n+/, "");
|
|
305
|
+
if (left && right) return `${left}\n\n${right}`;
|
|
306
|
+
return left || right;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function installRules(raw, { migrateLegacy = true } = {}) {
|
|
310
|
+
let next = raw;
|
|
311
|
+
if (migrateLegacy) {
|
|
312
|
+
next = removeRulesBlock(next, LEGACY_RULES_START, LEGACY_RULES_END);
|
|
313
|
+
}
|
|
314
|
+
const bounds = findManagedBlock(next, RULES_START, RULES_END);
|
|
315
|
+
if (bounds) {
|
|
316
|
+
return next.slice(0, bounds.start) + MEMORY_RULES + next.slice(bounds.end);
|
|
317
|
+
}
|
|
318
|
+
return next ? `${next}\n${MEMORY_RULES}\n` : `${MEMORY_RULES}\n`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function removeRules(raw) {
|
|
322
|
+
return removeRulesBlock(raw, RULES_START, RULES_END);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function desktopConfigPath(home, platform, env) {
|
|
326
|
+
if (platform === "win32") {
|
|
327
|
+
const appData = env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
328
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
329
|
+
}
|
|
330
|
+
if (platform === "darwin") {
|
|
331
|
+
return path.join(
|
|
332
|
+
home,
|
|
333
|
+
"Library",
|
|
334
|
+
"Application Support",
|
|
335
|
+
"Claude",
|
|
336
|
+
"claude_desktop_config.json",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return path.join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function getClients(options = {}) {
|
|
343
|
+
const env = options.env ?? process.env;
|
|
344
|
+
const platform = options.platform ?? process.platform;
|
|
345
|
+
const home = options.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
346
|
+
const codexHome = env.CODEX_HOME
|
|
347
|
+
? path.resolve(env.CODEX_HOME)
|
|
348
|
+
: path.join(home, ".codex");
|
|
349
|
+
return [
|
|
350
|
+
{
|
|
351
|
+
id: "claude-code",
|
|
352
|
+
name: "Claude Code",
|
|
353
|
+
kind: "json",
|
|
354
|
+
configPath: path.join(home, ".claude.json"),
|
|
355
|
+
rulesPath: path.join(home, ".claude", "CLAUDE.md"),
|
|
356
|
+
restart: "Restart every open Claude Code session.",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "codex",
|
|
360
|
+
name: "Codex",
|
|
361
|
+
kind: "toml",
|
|
362
|
+
configPath: path.join(codexHome, "config.toml"),
|
|
363
|
+
rulesPath: path.join(codexHome, "AGENTS.md"),
|
|
364
|
+
restart: "Restart every open Codex session.",
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: "claude-desktop",
|
|
368
|
+
name: "Claude Desktop",
|
|
369
|
+
kind: "json",
|
|
370
|
+
configPath: desktopConfigPath(home, platform, env),
|
|
371
|
+
restart: "Quit Claude Desktop completely and reopen it.",
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function pathExists(filePath) {
|
|
377
|
+
try {
|
|
378
|
+
await access(filePath);
|
|
379
|
+
return true;
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function readOptional(filePath) {
|
|
386
|
+
try {
|
|
387
|
+
return await readFile(filePath, "utf8");
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error && error.code === "ENOENT") return "";
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function detectClients(clients) {
|
|
395
|
+
const detected = [];
|
|
396
|
+
for (const client of clients) {
|
|
397
|
+
const configParent = path.dirname(client.configPath);
|
|
398
|
+
const rulesParent = client.rulesPath ? path.dirname(client.rulesPath) : "";
|
|
399
|
+
if (
|
|
400
|
+
(await pathExists(client.configPath)) ||
|
|
401
|
+
(await pathExists(configParent)) ||
|
|
402
|
+
(rulesParent && (await pathExists(rulesParent)))
|
|
403
|
+
) {
|
|
404
|
+
detected.push(client);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return detected;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function configConnected(client, raw) {
|
|
411
|
+
if (!raw) return false;
|
|
412
|
+
if (client.kind === "toml") {
|
|
413
|
+
const bounds = sectionBounds(raw, SERVER_KEY);
|
|
414
|
+
return bounds.start !== -1 && bounds.count === 1;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const config = parseJsonStrict(raw, client.configPath);
|
|
418
|
+
return isObject(config.mcpServers) && SERVER_KEY in config.mcpServers;
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function jsonAuthorizationMatches(client, server, apiKey) {
|
|
425
|
+
const expected = apiKey ? `Bearer ${apiKey}` : "";
|
|
426
|
+
if (client.id === "claude-desktop") {
|
|
427
|
+
if (!Array.isArray(server.args)) return false;
|
|
428
|
+
return expected
|
|
429
|
+
? server.args.length === 5
|
|
430
|
+
&& server.args[3] === "--header"
|
|
431
|
+
&& server.args[4] === `Authorization: ${expected}`
|
|
432
|
+
: server.args.length === 3;
|
|
433
|
+
}
|
|
434
|
+
const headers = isObject(server.headers) ? server.headers : {};
|
|
435
|
+
const entries = Object.entries(headers)
|
|
436
|
+
.filter(([name]) => name.toLowerCase() === "authorization");
|
|
437
|
+
const actual = entries.length === 1 && typeof entries[0][1] === "string"
|
|
438
|
+
? entries[0][1]
|
|
439
|
+
: "";
|
|
440
|
+
return expected
|
|
441
|
+
? entries.length === 1 && actual === expected
|
|
442
|
+
: entries.length === 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function jsonTransportValid(client, server, apiKey) {
|
|
446
|
+
if (!isObject(server)) return false;
|
|
447
|
+
if (client.id === "claude-desktop") {
|
|
448
|
+
const baseValid = server.command === "npx"
|
|
449
|
+
&& Array.isArray(server.args)
|
|
450
|
+
&& server.args[0] === "-y"
|
|
451
|
+
&& server.args[1] === "mcp-remote@latest"
|
|
452
|
+
&& typeof server.args[2] === "string";
|
|
453
|
+
return apiKey
|
|
454
|
+
? baseValid
|
|
455
|
+
&& server.args.length === 5
|
|
456
|
+
&& server.args[3] === "--header"
|
|
457
|
+
&& typeof server.args[4] === "string"
|
|
458
|
+
&& /^Authorization\s*:\s*Bearer\s+/.test(server.args[4])
|
|
459
|
+
: baseValid && server.args.length === 3;
|
|
460
|
+
}
|
|
461
|
+
return server.type === "http" && typeof server.url === "string";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function tomlAuthorizationMatches(section, apiKey) {
|
|
465
|
+
const hasAuthorization = /\bAuthorization\s*=/.test(section);
|
|
466
|
+
if (!apiKey) return !hasAuthorization;
|
|
467
|
+
const match = section.match(/\bAuthorization\s*=\s*("(?:[^"\\]|\\.)*")/);
|
|
468
|
+
if (!match) return false;
|
|
469
|
+
try {
|
|
470
|
+
return JSON.parse(match[1]) === `Bearer ${apiKey}`;
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function inspectExpectedConfig(client, raw, url, apiKey) {
|
|
477
|
+
if (client.kind === "toml") {
|
|
478
|
+
const bounds = sectionBounds(raw, SERVER_KEY);
|
|
479
|
+
if (bounds.start === -1 || bounds.count !== 1) {
|
|
480
|
+
return { transportValid: false, urlMatches: false, authMatches: false };
|
|
481
|
+
}
|
|
482
|
+
const section = bounds.lines.slice(bounds.start, bounds.end).join("\n");
|
|
483
|
+
return {
|
|
484
|
+
transportValid: tomlSectionUsesUrl(section, url),
|
|
485
|
+
urlMatches: tomlSectionUsesUrl(section, url),
|
|
486
|
+
authMatches: tomlAuthorizationMatches(section, apiKey),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const config = parseJsonStrict(raw, client.configPath);
|
|
491
|
+
const server = isObject(config.mcpServers) ? config.mcpServers[SERVER_KEY] : null;
|
|
492
|
+
const urlMatches = client.id === "claude-desktop"
|
|
493
|
+
? Array.isArray(server?.args)
|
|
494
|
+
&& typeof server.args[2] === "string"
|
|
495
|
+
&& serverUsesUrl({ url: server.args[2] }, url)
|
|
496
|
+
: serverUsesUrl(server, url);
|
|
497
|
+
return {
|
|
498
|
+
transportValid: jsonTransportValid(client, server, apiKey),
|
|
499
|
+
urlMatches,
|
|
500
|
+
authMatches: isObject(server) && jsonAuthorizationMatches(client, server, apiKey),
|
|
501
|
+
};
|
|
502
|
+
} catch {
|
|
503
|
+
return { transportValid: false, urlMatches: false, authMatches: false };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function rulesConnected(raw) {
|
|
508
|
+
try {
|
|
509
|
+
return Boolean(findManagedBlock(raw, RULES_START, RULES_END));
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function rulesCurrent(raw) {
|
|
516
|
+
try {
|
|
517
|
+
const bounds = findManagedBlock(raw, RULES_START, RULES_END);
|
|
518
|
+
return Boolean(bounds && raw.slice(bounds.start, bounds.end) === MEMORY_RULES);
|
|
519
|
+
} catch {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function inspectClients(clients, { url = "", apiKey = "" } = {}) {
|
|
525
|
+
const expectedUrl = url ? normalizeUrl(url) : "";
|
|
526
|
+
return Promise.all(
|
|
527
|
+
clients.map(async (client) => {
|
|
528
|
+
const configRaw = await readOptional(client.configPath);
|
|
529
|
+
const rulesRaw = client.rulesPath ? await readOptional(client.rulesPath) : "";
|
|
530
|
+
const expected = expectedUrl
|
|
531
|
+
? inspectExpectedConfig(client, configRaw, expectedUrl, apiKey)
|
|
532
|
+
: { transportValid: null, urlMatches: null, authMatches: null };
|
|
533
|
+
return {
|
|
534
|
+
client,
|
|
535
|
+
config: configConnected(client, configRaw),
|
|
536
|
+
rules: client.rulesPath ? rulesConnected(rulesRaw) : null,
|
|
537
|
+
rulesCurrent: client.rulesPath ? rulesCurrent(rulesRaw) : null,
|
|
538
|
+
...expected,
|
|
539
|
+
};
|
|
540
|
+
}),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function addChange(changes, client, filePath, before, after, kind, sensitive, backup) {
|
|
545
|
+
if (before === after) return;
|
|
546
|
+
changes.push({ client, filePath, before, after, kind, sensitive, backup });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function buildPlan(
|
|
550
|
+
action,
|
|
551
|
+
clients,
|
|
552
|
+
{
|
|
553
|
+
url = DEFAULT_MCP_URL,
|
|
554
|
+
apiKey = "",
|
|
555
|
+
installInstructionRules = true,
|
|
556
|
+
migrateLegacy = true,
|
|
557
|
+
legacyUrls = [url],
|
|
558
|
+
} = {},
|
|
559
|
+
) {
|
|
560
|
+
if (!['connect', 'disconnect'].includes(action)) {
|
|
561
|
+
throw new ConfigError(`Unsupported action: ${action}`);
|
|
562
|
+
}
|
|
563
|
+
const changes = [];
|
|
564
|
+
for (const client of clients) {
|
|
565
|
+
const configRaw = await readOptional(client.configPath);
|
|
566
|
+
let configNext;
|
|
567
|
+
if (client.kind === "toml") {
|
|
568
|
+
configNext = action === "connect"
|
|
569
|
+
? connectToml(configRaw, { url, apiKey, migrateLegacy, legacyUrls })
|
|
570
|
+
: disconnectToml(configRaw);
|
|
571
|
+
} else {
|
|
572
|
+
configNext = action === "connect"
|
|
573
|
+
? connectJson(configRaw, {
|
|
574
|
+
clientId: client.id,
|
|
575
|
+
url,
|
|
576
|
+
apiKey,
|
|
577
|
+
migrateLegacy,
|
|
578
|
+
legacyUrls,
|
|
579
|
+
})
|
|
580
|
+
: disconnectJson(configRaw);
|
|
581
|
+
}
|
|
582
|
+
addChange(
|
|
583
|
+
changes,
|
|
584
|
+
client,
|
|
585
|
+
client.configPath,
|
|
586
|
+
configRaw,
|
|
587
|
+
configNext,
|
|
588
|
+
"config",
|
|
589
|
+
true,
|
|
590
|
+
action === "connect",
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
if (client.rulesPath && installInstructionRules) {
|
|
594
|
+
const rulesRaw = await readOptional(client.rulesPath);
|
|
595
|
+
const rulesNext = action === "connect"
|
|
596
|
+
? installRules(rulesRaw, { migrateLegacy })
|
|
597
|
+
: removeRules(rulesRaw);
|
|
598
|
+
addChange(
|
|
599
|
+
changes,
|
|
600
|
+
client,
|
|
601
|
+
client.rulesPath,
|
|
602
|
+
rulesRaw,
|
|
603
|
+
rulesNext,
|
|
604
|
+
"rules",
|
|
605
|
+
false,
|
|
606
|
+
action === "connect",
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return changes;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function resolvedWritePath(filePath) {
|
|
614
|
+
try {
|
|
615
|
+
const info = await lstat(filePath);
|
|
616
|
+
return info.isSymbolicLink() ? await realpath(filePath) : filePath;
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (error && error.code === "ENOENT") return filePath;
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function sourceMode(filePath, fallback) {
|
|
624
|
+
try {
|
|
625
|
+
return (await stat(filePath)).mode & 0o777;
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (error && error.code === "ENOENT") return fallback;
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function atomicWrite(filePath, content, mode) {
|
|
633
|
+
const target = await resolvedWritePath(filePath);
|
|
634
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
635
|
+
const temp = `${target}.forget-connect-${process.pid}-${randomBytes(5).toString("hex")}`;
|
|
636
|
+
const handle = await open(temp, "wx", mode);
|
|
637
|
+
try {
|
|
638
|
+
await handle.writeFile(content, "utf8");
|
|
639
|
+
await handle.sync();
|
|
640
|
+
} catch (error) {
|
|
641
|
+
await handle.close();
|
|
642
|
+
await unlink(temp).catch(() => {});
|
|
643
|
+
throw error;
|
|
644
|
+
} finally {
|
|
645
|
+
if (handle.fd !== -1) await handle.close();
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
await rename(temp, target);
|
|
649
|
+
await chmod(target, mode);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
await unlink(temp).catch(() => {});
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function backupOnce(filePath, raw) {
|
|
657
|
+
if (!raw) return null;
|
|
658
|
+
const backupPath = `${filePath}${BACKUP_SUFFIX}`;
|
|
659
|
+
if (await pathExists(backupPath)) return null;
|
|
660
|
+
await atomicWrite(backupPath, raw, 0o600);
|
|
661
|
+
return backupPath;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function applyPlan(changes, { dryRun = false } = {}) {
|
|
665
|
+
if (dryRun) return { changed: [], backups: [] };
|
|
666
|
+
const changed = [];
|
|
667
|
+
const backups = [];
|
|
668
|
+
for (const change of changes) {
|
|
669
|
+
const backup = change.backup
|
|
670
|
+
? await backupOnce(change.filePath, change.before)
|
|
671
|
+
: null;
|
|
672
|
+
if (backup) backups.push(backup);
|
|
673
|
+
const mode = change.sensitive
|
|
674
|
+
? 0o600
|
|
675
|
+
: await sourceMode(change.filePath, 0o644);
|
|
676
|
+
await atomicWrite(change.filePath, change.after, mode);
|
|
677
|
+
changed.push(change.filePath);
|
|
678
|
+
}
|
|
679
|
+
return { changed, backups };
|
|
680
|
+
}
|