@aipermission/mcp 0.2.37 → 0.2.39
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/README.md +58 -16
- package/dist/cli-flags.js +81 -0
- package/dist/cli.js +37 -6
- package/dist/client-registry.js +281 -0
- package/dist/doctor.js +134 -0
- package/dist/init.js +411 -225
- package/dist/install-skill.js +67 -151
- package/dist/instructions.js +1 -0
- package/dist/private-file.js +335 -0
- package/dist/server.js +42 -29
- package/package.json +20 -2
- package/server.json +2 -2
package/dist/init.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import readline from "node:readline/promises";
|
|
7
6
|
import { promisify } from "node:util";
|
|
8
7
|
import { pathToFileURL } from "node:url";
|
|
9
8
|
import { stdin as input, stdout as output } from "node:process";
|
|
9
|
+
import { parse as parseTOML } from "smol-toml";
|
|
10
|
+
import { parseCommandFlags } from "./cli-flags.js";
|
|
10
11
|
import { DEFAULT_API_URL, normalizeLocalAPIURL } from "./local-url.js";
|
|
12
|
+
import { adaptMCPServerConfig, getClient, MCP_PROVIDERS, resolveMCPConfigTarget, resolveMCPPrintTarget } from "./client-registry.js";
|
|
13
|
+
import { commitSkillInstallation, prepareSkillInstallation } from "./install-skill.js";
|
|
14
|
+
import {
|
|
15
|
+
atomicWritePrivateFile,
|
|
16
|
+
privateLockPath,
|
|
17
|
+
privateStagingIgnorePath,
|
|
18
|
+
privateStagingPath,
|
|
19
|
+
privateTemporaryIgnorePath,
|
|
20
|
+
privateTemporaryPath,
|
|
21
|
+
withPrivateFileLock,
|
|
22
|
+
} from "./private-file.js";
|
|
11
23
|
|
|
12
24
|
const require = createRequire(import.meta.url);
|
|
13
25
|
const execFileAsync = promisify(execFile);
|
|
@@ -26,86 +38,70 @@ const color = {
|
|
|
26
38
|
yellow: useColor ? "\x1b[33m" : "",
|
|
27
39
|
};
|
|
28
40
|
|
|
29
|
-
const providers = [
|
|
30
|
-
{
|
|
31
|
-
id: "codex",
|
|
32
|
-
label: "OpenAI Codex",
|
|
33
|
-
description: "Writes ~/.codex/config.toml",
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: "claude-code",
|
|
37
|
-
label: "Claude Code",
|
|
38
|
-
description: "Writes .mcp.json in the current project",
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
id: "cursor",
|
|
42
|
-
label: "Cursor",
|
|
43
|
-
description: "Writes .cursor/mcp.json in the current project",
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: "vscode",
|
|
47
|
-
label: "VS Code",
|
|
48
|
-
description: "Writes .vscode/mcp.json in the current project",
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
id: "windsurf",
|
|
52
|
-
label: "Windsurf",
|
|
53
|
-
description: "Writes ~/.codeium/windsurf/mcp_config.json",
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
id: "antigravity",
|
|
57
|
-
label: "Google Antigravity",
|
|
58
|
-
description: "Writes ~/.gemini/antigravity/mcp_config.json",
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: "gemini",
|
|
62
|
-
label: "Gemini CLI",
|
|
63
|
-
description: "Writes ~/.gemini/settings.json",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: "custom",
|
|
67
|
-
label: "Custom / copy-paste",
|
|
68
|
-
description: "Prints config snippets only",
|
|
69
|
-
},
|
|
70
|
-
];
|
|
71
|
-
|
|
72
41
|
export async function runInit(argv = []) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
42
|
+
return runConfiguration("init", argv);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runSetup(argv = []) {
|
|
46
|
+
return runConfiguration("setup", argv);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runConfiguration(command, argv) {
|
|
50
|
+
const flags = parseCommandFlags(command, argv);
|
|
51
|
+
const interactive = Boolean(input.isTTY && output.isTTY);
|
|
52
|
+
assertProviderSelectionAvailable(flags.provider, interactive);
|
|
76
53
|
const rl = readline.createInterface({ input, output });
|
|
77
54
|
try {
|
|
78
55
|
const provider = flags.provider
|
|
79
56
|
? findProvider(flags.provider)
|
|
80
|
-
: await selectProvider("Which AI client should use this token?",
|
|
81
|
-
const name = sanitizeName(
|
|
82
|
-
flags.name || (await ask(rl, "MCP server name", "aipermission"))
|
|
83
|
-
);
|
|
57
|
+
: await selectProvider("Which AI client should use this token?", MCP_PROVIDERS);
|
|
58
|
+
const name = sanitizeName(flags.name || (interactive ? await ask(rl, "MCP server name", "aipermission") : "aipermission"));
|
|
84
59
|
const apiUrl = normalizeURL(flags.apiUrl || DEFAULT_API_URL);
|
|
85
|
-
const
|
|
60
|
+
const outputTarget =
|
|
61
|
+
provider.id === "custom"
|
|
62
|
+
? undefined
|
|
63
|
+
: flags.print
|
|
64
|
+
? resolveMCPPrintTarget(provider.id, flags.scope)
|
|
65
|
+
: resolveMCPConfigTarget(provider.id, flags.scope);
|
|
66
|
+
const shouldPrepareSkill = command === "setup" && (!flags.print || provider.id === "custom");
|
|
67
|
+
const preparedSkill = shouldPrepareSkill ? await prepareSetupSkill(provider.id, flags) : undefined;
|
|
68
|
+
if (provider.id === "custom" || flags.print) {
|
|
69
|
+
const skillResult = preparedSkill ? await reportInstalledSkill(preparedSkill) : undefined;
|
|
70
|
+
printPlaceholderConfigNotice();
|
|
71
|
+
printProviderConfig(name, provider.id, apiUrl, outputTarget);
|
|
72
|
+
if (command === "setup" && flags.print && provider.id !== "custom") {
|
|
73
|
+
console.error("No files were changed. Run install-skill separately to install the native operator skill.");
|
|
74
|
+
}
|
|
75
|
+
return { provider: provider.id, name, printed: true, skill: skillResult };
|
|
76
|
+
}
|
|
86
77
|
|
|
78
|
+
const stdinToken = flags.tokenStdin ? (await readStdin()).trim() : "";
|
|
79
|
+
const token = await resolveToken({ ...flags, stdinToken }, rl);
|
|
87
80
|
if (!token) {
|
|
88
81
|
throw new Error("API token is required.");
|
|
89
82
|
}
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const result = await writeProviderConfig(provider.id, name, config, { force: Boolean(flags.force) });
|
|
83
|
+
const config = adaptMCPServerConfig(provider.id, buildMCPServerConfig({ apiUrl, token }));
|
|
84
|
+
const skillResult = preparedSkill ? await reportInstalledSkill(preparedSkill) : undefined;
|
|
85
|
+
const result = await writeProviderConfig(provider.id, name, config, {
|
|
86
|
+
force: Boolean(flags.force),
|
|
87
|
+
scope: flags.scope,
|
|
88
|
+
homeDir: flags.home,
|
|
89
|
+
projectDir: flags.projectDir,
|
|
90
|
+
});
|
|
99
91
|
console.log("");
|
|
100
92
|
console.log(`${color.green}Configured ${provider.label}${color.reset}`);
|
|
101
93
|
console.log(`${color.dim}Name:${color.reset} ${name}`);
|
|
102
94
|
console.log(`${color.dim}Path:${color.reset} ${result.path}`);
|
|
95
|
+
console.log(`${color.dim}Scope:${color.reset} ${result.scope}`);
|
|
103
96
|
if (result.gitExcluded) {
|
|
104
97
|
console.log(`${color.dim}Git:${color.reset} added ${result.gitExcludeEntry} to .git/info/exclude`);
|
|
105
98
|
}
|
|
106
99
|
console.log("");
|
|
107
|
-
console.log(
|
|
100
|
+
console.log(
|
|
101
|
+
`${color.yellow}Keep this config private:${color.reset} it contains an AIPermission bearer token. If it is committed, revoke the token.`,
|
|
102
|
+
);
|
|
108
103
|
console.log(`${color.yellow}Restart the AI client so it reloads MCP servers.${color.reset}`);
|
|
104
|
+
return { provider: provider.id, name, config: result, skill: skillResult };
|
|
109
105
|
} finally {
|
|
110
106
|
rl.close();
|
|
111
107
|
}
|
|
@@ -118,46 +114,39 @@ export function assertProviderSelectionAvailable(provider, interactive) {
|
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
export function parseFlags(argv) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
if (key === "token") {
|
|
142
|
-
throw new Error("--token is not supported; use the hidden prompt or --token-stdin");
|
|
143
|
-
}
|
|
144
|
-
result[key] = inlineValue ?? argv[i + 1] ?? "";
|
|
145
|
-
if (inlineValue === undefined) {
|
|
146
|
-
i += 1;
|
|
147
|
-
}
|
|
117
|
+
return parseCommandFlags("init", argv);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function prepareSetupSkill(client, flags) {
|
|
121
|
+
return prepareSkillInstallation({
|
|
122
|
+
client,
|
|
123
|
+
scope: flags.skillScope || flags.scope,
|
|
124
|
+
source: flags.skillSource,
|
|
125
|
+
homeDir: flags.home,
|
|
126
|
+
projectDir: flags.projectDir,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function reportInstalledSkill(prepared) {
|
|
131
|
+
const result = await commitSkillInstallation(prepared);
|
|
132
|
+
if (!result.path) {
|
|
133
|
+
console.log("");
|
|
134
|
+
console.log(result.content);
|
|
135
|
+
return result;
|
|
148
136
|
}
|
|
137
|
+
console.log("");
|
|
138
|
+
console.log(`${color.green}Installed native operator skill${color.reset}`);
|
|
139
|
+
console.log(`${color.dim}Path:${color.reset} ${result.path}`);
|
|
140
|
+
console.log(`${color.dim}Scope:${color.reset} ${result.scope}`);
|
|
149
141
|
return result;
|
|
150
142
|
}
|
|
151
143
|
|
|
152
144
|
function findProvider(idOrLabel) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
);
|
|
157
|
-
if (!provider) {
|
|
145
|
+
try {
|
|
146
|
+
return getClient(idOrLabel);
|
|
147
|
+
} catch {
|
|
158
148
|
throw new Error(`Unknown provider: ${idOrLabel}`);
|
|
159
149
|
}
|
|
160
|
-
return provider;
|
|
161
150
|
}
|
|
162
151
|
|
|
163
152
|
async function selectProvider(title, items) {
|
|
@@ -175,18 +164,12 @@ async function selectProvider(title, items) {
|
|
|
175
164
|
output.write(`\x1b[${renderedLines}A`);
|
|
176
165
|
output.write("\x1b[J");
|
|
177
166
|
}
|
|
178
|
-
const lines = [
|
|
179
|
-
`${color.bold}${color.cyan}${title}${color.reset}`,
|
|
180
|
-
`${color.dim}Use ↑/↓ and Enter.${color.reset}`,
|
|
181
|
-
"",
|
|
182
|
-
];
|
|
167
|
+
const lines = [`${color.bold}${color.cyan}${title}${color.reset}`, `${color.dim}Use ↑/↓ and Enter.${color.reset}`, ""];
|
|
183
168
|
for (let i = 0; i < items.length; i += 1) {
|
|
184
169
|
const selected = i === index;
|
|
185
170
|
const marker = selected ? `${color.green}›${color.reset}` : " ";
|
|
186
171
|
const label = selected ? `${color.bold}${items[i].label}${color.reset}` : items[i].label;
|
|
187
|
-
lines.push(
|
|
188
|
-
`${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`
|
|
189
|
-
);
|
|
172
|
+
lines.push(`${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`);
|
|
190
173
|
}
|
|
191
174
|
output.write("\x1b[?25l");
|
|
192
175
|
output.write(`${lines.join("\n")}\n`);
|
|
@@ -210,7 +193,7 @@ async function selectProvider(title, items) {
|
|
|
210
193
|
};
|
|
211
194
|
const onData = (buffer) => {
|
|
212
195
|
const value = buffer.toString("utf8");
|
|
213
|
-
const keys = value
|
|
196
|
+
const keys = splitInputKeys(value);
|
|
214
197
|
for (const key of keys) {
|
|
215
198
|
if (key === "\u0003") {
|
|
216
199
|
cleanup();
|
|
@@ -313,94 +296,113 @@ export function buildMCPServerConfig({ apiUrl, token }) {
|
|
|
313
296
|
}
|
|
314
297
|
|
|
315
298
|
export async function writeProviderConfig(providerID, name, config, options = {}) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (providerID === "cursor") {
|
|
328
|
-
const filePath = path.join(process.cwd(), ".cursor", "mcp.json");
|
|
329
|
-
await assertProjectConfigWritable(filePath, options);
|
|
330
|
-
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
331
|
-
return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
|
|
332
|
-
}
|
|
333
|
-
if (providerID === "vscode") {
|
|
334
|
-
const filePath = path.join(process.cwd(), ".vscode", "mcp.json");
|
|
335
|
-
await assertProjectConfigWritable(filePath, options);
|
|
336
|
-
await writeJSONMCPConfig(filePath, name, config, "servers");
|
|
337
|
-
return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
|
|
338
|
-
}
|
|
339
|
-
if (providerID === "windsurf") {
|
|
340
|
-
const filePath = path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
341
|
-
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
342
|
-
return { path: filePath };
|
|
343
|
-
}
|
|
344
|
-
if (providerID === "antigravity") {
|
|
345
|
-
const filePath = path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
|
|
346
|
-
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
347
|
-
return { path: filePath };
|
|
348
|
-
}
|
|
349
|
-
if (providerID === "gemini") {
|
|
350
|
-
const filePath = path.join(os.homedir(), ".gemini", "settings.json");
|
|
351
|
-
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
352
|
-
return { path: filePath };
|
|
353
|
-
}
|
|
354
|
-
throw new Error(`Unsupported provider: ${providerID}`);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
export async function writeJSONMCPConfig(filePath, name, config, rootKey) {
|
|
358
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
359
|
-
let root = {};
|
|
360
|
-
try {
|
|
361
|
-
root = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
362
|
-
} catch (error) {
|
|
363
|
-
if (error.code !== "ENOENT") {
|
|
364
|
-
throw new Error(`Could not read JSON config at ${filePath}: ${error.message}`);
|
|
365
|
-
}
|
|
299
|
+
const projectRoot = options.projectDir || process.cwd();
|
|
300
|
+
const target = resolveMCPConfigTarget(providerID, options.scope, {
|
|
301
|
+
homeDir: options.homeDir,
|
|
302
|
+
projectDir: projectRoot,
|
|
303
|
+
env: options.env,
|
|
304
|
+
});
|
|
305
|
+
const providerConfig = adaptMCPServerConfig(providerID, config);
|
|
306
|
+
let protection = {};
|
|
307
|
+
if (target.projectConfig) {
|
|
308
|
+
await assertProjectConfigWritable(target.path, options);
|
|
309
|
+
protection = await protectGitIgnoredConfig(target.path, projectRoot, { allowTracked: Boolean(options.force) });
|
|
366
310
|
}
|
|
367
|
-
|
|
368
|
-
|
|
311
|
+
const trustedRoot = target.trustedRoot;
|
|
312
|
+
const writeOptions = {
|
|
313
|
+
trustedRoot,
|
|
314
|
+
beforeWrite: target.projectConfig
|
|
315
|
+
? async () => {
|
|
316
|
+
await assertProjectConfigWritable(target.path, options);
|
|
317
|
+
await options.beforeWrite?.();
|
|
318
|
+
await assertProjectConfigWritable(target.path, options);
|
|
319
|
+
}
|
|
320
|
+
: options.beforeWrite,
|
|
321
|
+
};
|
|
322
|
+
if (target.format === "toml") {
|
|
323
|
+
await writeTOMLMCPConfig(target.path, name, providerConfig, writeOptions);
|
|
324
|
+
} else {
|
|
325
|
+
await writeJSONMCPConfig(target.path, name, providerConfig, target.rootKey, writeOptions);
|
|
369
326
|
}
|
|
370
|
-
|
|
371
|
-
root[rootKey] && typeof root[rootKey] === "object" && !Array.isArray(root[rootKey])
|
|
372
|
-
? root[rootKey]
|
|
373
|
-
: {};
|
|
374
|
-
root[rootKey][name] = config;
|
|
375
|
-
await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`);
|
|
327
|
+
return { path: target.path, scope: target.scope, ...protection };
|
|
376
328
|
}
|
|
377
329
|
|
|
378
|
-
async function
|
|
379
|
-
await
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
330
|
+
export async function writeJSONMCPConfig(filePath, name, config, rootKey, options = {}) {
|
|
331
|
+
await withPrivateFileLock(
|
|
332
|
+
filePath,
|
|
333
|
+
async () => {
|
|
334
|
+
await options.beforeWrite?.();
|
|
335
|
+
let root = {};
|
|
336
|
+
try {
|
|
337
|
+
root = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error.code !== "ENOENT") {
|
|
340
|
+
redactParseError(error);
|
|
341
|
+
throw new Error(`Could not parse JSON config at ${filePath}; the existing file was left unchanged`, {
|
|
342
|
+
cause: error,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!root || typeof root !== "object" || Array.isArray(root)) root = {};
|
|
347
|
+
const currentServers = root[rootKey];
|
|
348
|
+
const servers =
|
|
349
|
+
currentServers && typeof currentServers === "object" && !Array.isArray(currentServers)
|
|
350
|
+
? { ...currentServers }
|
|
351
|
+
: Object.create(null);
|
|
352
|
+
Object.defineProperty(servers, name, { value: config, enumerable: true, configurable: true, writable: true });
|
|
353
|
+
root[rootKey] = servers;
|
|
354
|
+
await options.beforeWrite?.();
|
|
355
|
+
await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`, options);
|
|
356
|
+
},
|
|
357
|
+
options,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function splitInputKeys(value) {
|
|
362
|
+
const keys = [];
|
|
363
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
364
|
+
const sequence = value.slice(index, index + 3);
|
|
365
|
+
if (sequence === "\u001b[A" || sequence === "\u001b[B") {
|
|
366
|
+
keys.push(sequence);
|
|
367
|
+
index += 2;
|
|
368
|
+
continue;
|
|
386
369
|
}
|
|
370
|
+
keys.push(value[index]);
|
|
387
371
|
}
|
|
372
|
+
return keys;
|
|
373
|
+
}
|
|
388
374
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
375
|
+
export async function writeTOMLMCPConfig(filePath, name, config, options = {}) {
|
|
376
|
+
await withPrivateFileLock(
|
|
377
|
+
filePath,
|
|
378
|
+
async () => {
|
|
379
|
+
await options.beforeWrite?.();
|
|
380
|
+
let current = "";
|
|
381
|
+
try {
|
|
382
|
+
current = await fs.readFile(filePath, "utf8");
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error.code !== "ENOENT") throw error;
|
|
385
|
+
}
|
|
386
|
+
const next = removeTOMLServer(current, name).trimEnd();
|
|
387
|
+
const block = tomlServerBlock(name, config);
|
|
388
|
+
const outputContent = `${next ? `${next}\n\n` : ""}${block}\n`;
|
|
389
|
+
parseTOMLDocument(outputContent, filePath);
|
|
390
|
+
await options.beforeWrite?.();
|
|
391
|
+
await writePrivateFile(filePath, outputContent, options);
|
|
392
|
+
},
|
|
393
|
+
options,
|
|
394
|
+
);
|
|
392
395
|
}
|
|
393
396
|
|
|
394
|
-
async function writePrivateFile(filePath, contents) {
|
|
395
|
-
await
|
|
396
|
-
await fs.chmod(filePath, 0o600);
|
|
397
|
+
async function writePrivateFile(filePath, contents, options = {}) {
|
|
398
|
+
await atomicWritePrivateFile(filePath, contents, options);
|
|
397
399
|
}
|
|
398
400
|
|
|
399
401
|
async function assertProjectConfigWritable(filePath, options = {}) {
|
|
400
402
|
if (options.force) {
|
|
401
403
|
return;
|
|
402
404
|
}
|
|
403
|
-
const tracked = await gitTrackedPath(filePath);
|
|
405
|
+
const tracked = await gitTrackedPath(filePath, options.projectDir || process.cwd());
|
|
404
406
|
if (!tracked) {
|
|
405
407
|
return;
|
|
406
408
|
}
|
|
@@ -408,12 +410,12 @@ async function assertProjectConfigWritable(filePath, options = {}) {
|
|
|
408
410
|
[
|
|
409
411
|
`Refusing to write AIPERMISSION_API_TOKEN into tracked git file: ${tracked}`,
|
|
410
412
|
"Use --print to copy the config manually, untrack/ignore that file, or rerun with --force if you intentionally accept commit risk.",
|
|
411
|
-
].join("\n")
|
|
413
|
+
].join("\n"),
|
|
412
414
|
);
|
|
413
415
|
}
|
|
414
416
|
|
|
415
|
-
async function protectGitIgnoredConfig(filePath) {
|
|
416
|
-
const repository = await discoverGitRepository(
|
|
417
|
+
async function protectGitIgnoredConfig(filePath, startDir = process.cwd(), options = {}) {
|
|
418
|
+
const repository = await discoverGitRepository(startDir);
|
|
417
419
|
if (!repository) {
|
|
418
420
|
return {};
|
|
419
421
|
}
|
|
@@ -421,30 +423,72 @@ async function protectGitIgnoredConfig(filePath) {
|
|
|
421
423
|
if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) {
|
|
422
424
|
return {};
|
|
423
425
|
}
|
|
424
|
-
const excludePath =
|
|
425
|
-
|
|
426
|
+
const excludePath = repository.excludePath;
|
|
427
|
+
const temporaryRelativePath = path.relative(repository.workTree, privateTemporaryIgnorePath(filePath)).split(path.sep).join("/");
|
|
428
|
+
const stagingRelativePath = path.relative(repository.workTree, privateStagingIgnorePath(filePath)).split(path.sep).join("/");
|
|
429
|
+
const lockRelativePath = path.relative(repository.workTree, privateLockPath(filePath)).split(path.sep).join("/");
|
|
430
|
+
const ignoreEntries = [
|
|
431
|
+
gitIgnoreLiteral(relativePath),
|
|
432
|
+
gitIgnoreWildcardPath(temporaryRelativePath),
|
|
433
|
+
gitIgnoreWildcardPath(stagingRelativePath),
|
|
434
|
+
gitIgnoreLiteral(lockRelativePath),
|
|
435
|
+
];
|
|
426
436
|
try {
|
|
427
|
-
|
|
437
|
+
await withPrivateFileLock(
|
|
438
|
+
excludePath,
|
|
439
|
+
async () => {
|
|
440
|
+
let current = "";
|
|
441
|
+
try {
|
|
442
|
+
current = await fs.readFile(excludePath, "utf8");
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error.code !== "ENOENT") throw error;
|
|
445
|
+
}
|
|
446
|
+
const entries = new Set(current.split(/\r?\n/));
|
|
447
|
+
const missingEntries = ignoreEntries.filter((entry) => !entries.has(entry));
|
|
448
|
+
if (missingEntries.length === 0) return;
|
|
449
|
+
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
450
|
+
await atomicWritePrivateFile(excludePath, `${current}${prefix}${missingEntries.join("\n")}\n`, {
|
|
451
|
+
trustedRoot: path.dirname(excludePath),
|
|
452
|
+
});
|
|
453
|
+
},
|
|
454
|
+
{ trustedRoot: path.dirname(excludePath) },
|
|
455
|
+
);
|
|
456
|
+
await assertGitIgnored(repository, [
|
|
457
|
+
...(options.allowTracked ? [] : [relativePath]),
|
|
458
|
+
privateTemporaryCheckPath(filePath, repository.workTree),
|
|
459
|
+
privateStagingCheckPath(filePath, repository.workTree),
|
|
460
|
+
lockRelativePath,
|
|
461
|
+
]);
|
|
428
462
|
} catch (error) {
|
|
429
|
-
|
|
430
|
-
return {};
|
|
431
|
-
}
|
|
463
|
+
throw new Error(`Could not protect MCP config with local Git excludes: ${error.message}`, { cause: error });
|
|
432
464
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
return {};
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
return { gitExcluded: true, gitExcludeEntry: relativePath };
|
|
465
|
+
return {
|
|
466
|
+
gitExcluded: true,
|
|
467
|
+
gitExcludeEntry: relativePath,
|
|
468
|
+
gitExcludeTemporaryEntry: temporaryRelativePath,
|
|
469
|
+
gitExcludeStagingEntry: stagingRelativePath,
|
|
470
|
+
gitExcludeLockEntry: lockRelativePath,
|
|
471
|
+
};
|
|
444
472
|
}
|
|
445
473
|
|
|
446
|
-
async function
|
|
447
|
-
const repository = await discoverGitRepository(
|
|
474
|
+
export async function inspectProjectConfigProtection(filePath, startDir = process.cwd()) {
|
|
475
|
+
const repository = await discoverGitRepository(startDir);
|
|
476
|
+
if (!repository) return { repository: false };
|
|
477
|
+
const relativePath = path.relative(repository.workTree, filePath).split(path.sep).join("/");
|
|
478
|
+
if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) return { repository: false };
|
|
479
|
+
const tracked = await gitTrackedPath(filePath, startDir);
|
|
480
|
+
if (tracked) throw new Error(`MCP config is tracked by Git: ${tracked}`);
|
|
481
|
+
await assertGitIgnored(repository, [
|
|
482
|
+
relativePath,
|
|
483
|
+
privateTemporaryCheckPath(filePath, repository.workTree),
|
|
484
|
+
privateStagingCheckPath(filePath, repository.workTree),
|
|
485
|
+
path.relative(repository.workTree, privateLockPath(filePath)).split(path.sep).join("/"),
|
|
486
|
+
]);
|
|
487
|
+
return { repository: true, relativePath };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function gitTrackedPath(filePath, startDir = process.cwd()) {
|
|
491
|
+
const repository = await discoverGitRepository(startDir);
|
|
448
492
|
if (!repository) {
|
|
449
493
|
return "";
|
|
450
494
|
}
|
|
@@ -453,44 +497,108 @@ async function gitTrackedPath(filePath) {
|
|
|
453
497
|
return "";
|
|
454
498
|
}
|
|
455
499
|
try {
|
|
456
|
-
await execFileAsync("git", ["-C", repository.workTree, "ls-files", "--error-unmatch", "--", relativePath], {
|
|
500
|
+
await execFileAsync("git", ["-C", repository.workTree, "ls-files", "--error-unmatch", "--", relativePath], {
|
|
501
|
+
windowsHide: true,
|
|
502
|
+
});
|
|
457
503
|
return relativePath;
|
|
458
|
-
} catch {
|
|
459
|
-
return "";
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (error.code === 1) return "";
|
|
506
|
+
throw new Error(`Could not verify whether MCP config is tracked by Git: ${gitErrorMessage(error)}`, { cause: error });
|
|
460
507
|
}
|
|
461
508
|
}
|
|
462
509
|
|
|
463
510
|
async function discoverGitRepository(startDir) {
|
|
464
511
|
try {
|
|
465
|
-
const { stdout } = await execFileAsync(
|
|
466
|
-
"
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
);
|
|
512
|
+
const { stdout } = await execFileAsync("git", ["-C", path.resolve(startDir), "rev-parse", "--show-toplevel", "--absolute-git-dir"], {
|
|
513
|
+
encoding: "utf8",
|
|
514
|
+
windowsHide: true,
|
|
515
|
+
});
|
|
470
516
|
const [workTree, gitDir] = stdout.trim().split(/\r?\n/);
|
|
471
517
|
if (!workTree || !gitDir) {
|
|
472
518
|
return null;
|
|
473
519
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
520
|
+
const { stdout: excludeOutput } = await execFileAsync(
|
|
521
|
+
"git",
|
|
522
|
+
["-C", path.resolve(startDir), "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
|
|
523
|
+
{ encoding: "utf8", windowsHide: true },
|
|
524
|
+
);
|
|
525
|
+
const excludePath = excludeOutput.trim();
|
|
526
|
+
if (!excludePath) throw new Error("Git did not return an exclude path");
|
|
527
|
+
return { workTree: path.resolve(workTree), gitDir: path.resolve(gitDir), excludePath: path.resolve(excludePath) };
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (/not a git repository/i.test(`${error.stderr || ""}\n${error.message || ""}`)) return null;
|
|
530
|
+
throw new Error(`Could not inspect Git repository: ${gitErrorMessage(error)}`, { cause: error });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function assertGitIgnored(repository, relativePaths) {
|
|
535
|
+
for (const relativePath of relativePaths) {
|
|
536
|
+
try {
|
|
537
|
+
await execFileAsync("git", ["-C", repository.workTree, "check-ignore", "-q", "--", relativePath], {
|
|
538
|
+
windowsHide: true,
|
|
539
|
+
});
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (error.code === 1) {
|
|
542
|
+
throw new Error(`Git still permits sensitive MCP path: ${relativePath}`, { cause: error });
|
|
543
|
+
}
|
|
544
|
+
throw new Error(`Could not verify local Git exclusion: ${gitErrorMessage(error)}`, { cause: error });
|
|
545
|
+
}
|
|
477
546
|
}
|
|
478
547
|
}
|
|
479
548
|
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
549
|
+
function privateTemporaryCheckPath(filePath, workTree) {
|
|
550
|
+
return path.relative(workTree, privateTemporaryPath(filePath, "git-check")).split(path.sep).join("/");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function privateStagingCheckPath(filePath, workTree) {
|
|
554
|
+
return path.relative(workTree, privateStagingPath(filePath, "git-check")).split(path.sep).join("/");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function escapeGitIgnoreFragment(value) {
|
|
558
|
+
let result = "";
|
|
559
|
+
for (const character of value) {
|
|
560
|
+
result += ["\\", "*", "?", "[", "]", "#", "!", " "].includes(character) ? `\\${character}` : character;
|
|
561
|
+
}
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function gitIgnoreLiteral(relativePath) {
|
|
566
|
+
return `/${escapeGitIgnoreFragment(relativePath)}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function gitIgnoreWildcardPath(relativePath) {
|
|
570
|
+
const wildcardIndex = relativePath.lastIndexOf("*");
|
|
571
|
+
if (wildcardIndex < 0) throw new Error(`Git ignore wildcard path is missing its generated wildcard: ${relativePath}`);
|
|
572
|
+
return `/${escapeGitIgnoreFragment(relativePath.slice(0, wildcardIndex))}*${escapeGitIgnoreFragment(
|
|
573
|
+
relativePath.slice(wildcardIndex + 1),
|
|
574
|
+
)}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function gitErrorMessage(error) {
|
|
578
|
+
return String(error.stderr || error.message || error).trim();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function redactParseError(error, format = "JSON") {
|
|
582
|
+
error.message = `${format} parsing failed`;
|
|
583
|
+
error.stack = `${error.name || "Error"}: ${error.message}`;
|
|
584
|
+
return error;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function removeTOMLServer(source, name) {
|
|
588
|
+
if (!source.trim()) return source;
|
|
589
|
+
parseTOMLDocument(source, "existing TOML config");
|
|
483
590
|
const lines = source.split(/\r?\n/);
|
|
484
591
|
const kept = [];
|
|
485
592
|
let skipping = false;
|
|
593
|
+
const scanState = { multiline: "" };
|
|
486
594
|
for (const line of lines) {
|
|
487
|
-
const
|
|
488
|
-
const
|
|
489
|
-
if (
|
|
595
|
+
const header = scanTOMLHeader(line, scanState);
|
|
596
|
+
const selected = header?.[0] === "mcp_servers" && header?.[1] === name;
|
|
597
|
+
if (selected) {
|
|
490
598
|
skipping = true;
|
|
491
599
|
continue;
|
|
492
600
|
}
|
|
493
|
-
if (
|
|
601
|
+
if (header && skipping) {
|
|
494
602
|
skipping = false;
|
|
495
603
|
}
|
|
496
604
|
if (!skipping) {
|
|
@@ -500,10 +608,57 @@ function removeCodexServer(source, name) {
|
|
|
500
608
|
return kept.join("\n");
|
|
501
609
|
}
|
|
502
610
|
|
|
503
|
-
function
|
|
611
|
+
function scanTOMLHeader(line, state) {
|
|
612
|
+
if (state.multiline) {
|
|
613
|
+
if (hasMultilineDelimiter(line, state.multiline)) state.multiline = "";
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
const trimmed = line.trimStart();
|
|
617
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith("[[")) {
|
|
618
|
+
try {
|
|
619
|
+
return findMarkerPath(parseTOML(`${line}\n__aipermission_header_marker = true\n`));
|
|
620
|
+
} catch {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const delimiter of ['"""', "'''"]) {
|
|
625
|
+
if (!hasMultilineDelimiter(line, delimiter)) continue;
|
|
626
|
+
if ((line.split(delimiter).length - 1) % 2 === 1) state.multiline = delimiter;
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function hasMultilineDelimiter(line, delimiter) {
|
|
633
|
+
const comment = line.indexOf("#");
|
|
634
|
+
return (comment < 0 ? line : line.slice(0, comment)).includes(delimiter);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function findMarkerPath(value, pathParts = []) {
|
|
638
|
+
if (!value || typeof value !== "object") return null;
|
|
639
|
+
if (value.__aipermission_header_marker === true) return pathParts;
|
|
640
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
641
|
+
const match = findMarkerPath(nested, [...pathParts, key]);
|
|
642
|
+
if (match) return match;
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function parseTOMLDocument(contents, location) {
|
|
648
|
+
try {
|
|
649
|
+
return parseTOML(contents);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
redactParseError(error, "TOML");
|
|
652
|
+
throw new Error(`Could not parse TOML config at ${location}; the existing file was left unchanged`, { cause: error });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function tomlServerBlock(name, config) {
|
|
657
|
+
const fields = Object.entries(config)
|
|
658
|
+
.filter(([key]) => key !== "env")
|
|
659
|
+
.map(([key, value]) => `${key} = ${tomlValue(value)}`);
|
|
504
660
|
return `[mcp_servers.${tomlKey(name)}]
|
|
505
|
-
|
|
506
|
-
args = [${config.args.map(tomlString).join(", ")}]
|
|
661
|
+
${fields.join("\n")}
|
|
507
662
|
enabled = true
|
|
508
663
|
|
|
509
664
|
[mcp_servers.${tomlKey(name)}.env]
|
|
@@ -512,17 +667,38 @@ AIPERMISSION_API_URL = ${tomlString(config.env.AIPERMISSION_API_URL)}
|
|
|
512
667
|
AIPERMISSION_API_TOKEN = ${tomlString(config.env.AIPERMISSION_API_TOKEN)}`;
|
|
513
668
|
}
|
|
514
669
|
|
|
515
|
-
function
|
|
670
|
+
function printProviderConfig(name, provider, apiUrl, target) {
|
|
671
|
+
const baseConfig = buildMCPServerConfig({ apiUrl, token: "YOUR_TOKEN_HERE" });
|
|
672
|
+
const previewConfig = provider === "custom" ? baseConfig : adaptMCPServerConfig(provider, baseConfig);
|
|
516
673
|
console.log("");
|
|
517
674
|
console.log(`${color.bold}${color.cyan}Copy-paste config:${color.reset}`);
|
|
518
675
|
console.log("");
|
|
519
|
-
|
|
676
|
+
if (target?.format === "toml") {
|
|
677
|
+
console.log(tomlPreviewServerBlock(name, previewConfig));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
console.log(JSON.stringify({ [target?.rootKey || "mcpServers"]: { [name]: previewConfig } }, null, 2));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Keep stdout rendering separate from the secret-bearing TOML writer.
|
|
684
|
+
function tomlPreviewServerBlock(name, config) {
|
|
685
|
+
const fields = Object.entries(config)
|
|
686
|
+
.filter(([key]) => key !== "env")
|
|
687
|
+
.map(([key, value]) => `${key} = ${tomlValue(value)}`);
|
|
688
|
+
return `[mcp_servers.${tomlKey(name)}]
|
|
689
|
+
${fields.join("\n")}
|
|
690
|
+
enabled = true
|
|
691
|
+
|
|
692
|
+
[mcp_servers.${tomlKey(name)}.env]
|
|
693
|
+
NODE_ENV = "production"
|
|
694
|
+
AIPERMISSION_API_URL = ${tomlString(config.env.AIPERMISSION_API_URL)}
|
|
695
|
+
AIPERMISSION_API_TOKEN = "YOUR_TOKEN_HERE"`;
|
|
520
696
|
}
|
|
521
697
|
|
|
522
|
-
function
|
|
698
|
+
function printPlaceholderConfigNotice() {
|
|
523
699
|
console.log("");
|
|
524
|
-
console.log(`${color.yellow}
|
|
525
|
-
console.log(`${color.yellow}
|
|
700
|
+
console.log(`${color.yellow}Preview:${color.reset} the printed config uses YOUR_TOKEN_HERE and contains no bearer token.`);
|
|
701
|
+
console.log(`${color.yellow}Replace the placeholder through the client's private environment or config mechanism.${color.reset}`);
|
|
526
702
|
}
|
|
527
703
|
|
|
528
704
|
export function sanitizeName(value) {
|
|
@@ -533,6 +709,9 @@ export function sanitizeName(value) {
|
|
|
533
709
|
if (!name) {
|
|
534
710
|
throw new Error("MCP server name is required.");
|
|
535
711
|
}
|
|
712
|
+
if (["__proto__", "prototype", "constructor"].includes(name.toLowerCase())) {
|
|
713
|
+
throw new Error("MCP server name is reserved.");
|
|
714
|
+
}
|
|
536
715
|
return name;
|
|
537
716
|
}
|
|
538
717
|
|
|
@@ -551,6 +730,13 @@ export function tomlString(value) {
|
|
|
551
730
|
return JSON.stringify(String(value));
|
|
552
731
|
}
|
|
553
732
|
|
|
733
|
+
function tomlValue(value) {
|
|
734
|
+
if (typeof value === "string") return tomlString(value);
|
|
735
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
736
|
+
if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`;
|
|
737
|
+
throw new Error(`Unsupported TOML MCP config value: ${typeof value}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
554
740
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
555
741
|
await runInit(process.argv.slice(2));
|
|
556
742
|
}
|