@aipermission/mcp 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 +21 -0
- package/README.md +84 -0
- package/dist/cli.js +46 -0
- package/dist/init.js +555 -0
- package/dist/install-skill.js +209 -0
- package/dist/local-url.js +27 -0
- package/dist/resources/aipermission-operator/SKILL.md +215 -0
- package/dist/results.js +32 -0
- package/dist/server.js +173 -0
- package/package.json +43 -0
- package/server.json +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aipermission contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @aipermission/mcp
|
|
2
|
+
|
|
3
|
+
Local-first MCP bridge for the AIPermission gateway.
|
|
4
|
+
|
|
5
|
+
AIPermission lets AI coding assistants use scoped server access through a local gateway without receiving SSH private keys or server credentials.
|
|
6
|
+
|
|
7
|
+
The gateway is intentionally local-only. Run it on the developer machine and keep the URL on `localhost`; remote servers are SSH targets, not places to host the gateway for LAN or internet users.
|
|
8
|
+
|
|
9
|
+
`@aipermission/mcp` is the official MCP bridge package. The unscoped `aipermission` npm package is only a placeholder that points users here.
|
|
10
|
+
|
|
11
|
+
The package includes MCP Registry metadata:
|
|
12
|
+
|
|
13
|
+
- `mcpName` in `package.json`
|
|
14
|
+
- `server.json` with the npm stdio package declaration
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx -y @aipermission/mcp init \
|
|
20
|
+
--provider codex \
|
|
21
|
+
--name aipermission
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The init command prompts for your AIPermission API token and writes the MCP client configuration for the selected provider.
|
|
25
|
+
|
|
26
|
+
The generated MCP config contains a bearer token. Keep it private. For project-local configs such as `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json`, the init command refuses to write into files already tracked by Git unless `--force` is passed. For untracked project-local configs, it adds the file to `.git/info/exclude` when it detects a Git repository. Use `--print` if you prefer to copy the config manually. If a token config is committed or shared, revoke that token in the AIPermission UI.
|
|
27
|
+
|
|
28
|
+
## Manual Config
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"aipermission": {
|
|
34
|
+
"command": "npx",
|
|
35
|
+
"args": ["-y", "@aipermission/mcp"],
|
|
36
|
+
"env": {
|
|
37
|
+
"NODE_ENV": "production",
|
|
38
|
+
"AIPERMISSION_API_URL": "http://localhost:3210",
|
|
39
|
+
"AIPERMISSION_API_TOKEN": "YOUR_TOKEN_HERE"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
- `list_servers`
|
|
49
|
+
- `exec`
|
|
50
|
+
- `get_request`
|
|
51
|
+
- `list_requests`
|
|
52
|
+
- `read_console`
|
|
53
|
+
- `send_message`
|
|
54
|
+
|
|
55
|
+
`exec` is intended for non-interactive commands. The gateway closes stdin for MCP command bodies so stdin-reading commands cannot consume the internal shell wrapper. Use the web console for interactive work.
|
|
56
|
+
|
|
57
|
+
## Operator Skill
|
|
58
|
+
|
|
59
|
+
Install the optional AIPermission operator instructions for your AI client:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx -y @aipermission/mcp install-skill --client codex
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Supported clients:
|
|
66
|
+
|
|
67
|
+
- `codex`: `~/.codex/skills/aipermission-operator/SKILL.md`
|
|
68
|
+
- `claude-code`: `.claude/rules/aipermission-operator.md`
|
|
69
|
+
- `cursor`: `.cursor/rules/aipermission-operator.mdc`
|
|
70
|
+
- `vscode`: `.github/instructions/aipermission-operator.instructions.md`
|
|
71
|
+
- `windsurf`: `.windsurf/rules/aipermission-operator.md`
|
|
72
|
+
- `antigravity`: `.agents/rules/aipermission-operator.md`
|
|
73
|
+
- `gemini`: `GEMINI.md`
|
|
74
|
+
- `custom`: prints portable Markdown to stdout
|
|
75
|
+
|
|
76
|
+
These instructions teach the agent how to poll `approval_pending` and `running` requests, read live console output, write short reasons, and avoid printing secrets. The default installer uses the operator instruction bundled in the npm package; `--source` accepts local file paths only and rejects HTTP(S) sources.
|
|
77
|
+
|
|
78
|
+
## Security Boundary
|
|
79
|
+
|
|
80
|
+
This package talks to a local AIPermission gateway. `AIPERMISSION_API_URL` must point to `localhost`, `127.0.0.1`, or `[::1]`; remote URLs are rejected before the bearer token is sent. Do not expose the gateway on LAN or the public internet, and do not use it as a shared DevOps service. Tokens grant access only to the servers and execution rules configured in the gateway UI.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const command = process.argv[2] || "serve";
|
|
4
|
+
|
|
5
|
+
if (command === "init") {
|
|
6
|
+
const { runInit } = await import("./init.js");
|
|
7
|
+
await runInit(process.argv.slice(3));
|
|
8
|
+
} else if (command === "install-skill") {
|
|
9
|
+
const { runInstallSkill } = await import("./install-skill.js");
|
|
10
|
+
await runInstallSkill(process.argv.slice(3));
|
|
11
|
+
} else if (command === "serve" || command === "server" || command === "start") {
|
|
12
|
+
await import("./server.js");
|
|
13
|
+
} else if (command === "--help" || command === "-h" || command === "help") {
|
|
14
|
+
printHelp();
|
|
15
|
+
} else {
|
|
16
|
+
console.error(`Unknown command: ${command}`);
|
|
17
|
+
printHelp();
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printHelp() {
|
|
22
|
+
console.log(`aipermission MCP
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
aipermission-mcp Start the MCP stdio server
|
|
26
|
+
aipermission-mcp init Configure an AI client interactively
|
|
27
|
+
aipermission-mcp install-skill Install the operator skill for an AI client
|
|
28
|
+
|
|
29
|
+
Init flags:
|
|
30
|
+
--provider codex|claude-code|cursor|vscode|windsurf|antigravity|gemini|custom
|
|
31
|
+
--name aipermission
|
|
32
|
+
--token-stdin
|
|
33
|
+
--api-url http://localhost:3210
|
|
34
|
+
--print
|
|
35
|
+
|
|
36
|
+
Install skill flags:
|
|
37
|
+
--client codex|claude-code|cursor|vscode|windsurf|antigravity|gemini|custom
|
|
38
|
+
--project-dir /path/to/workspace
|
|
39
|
+
--source /path/to/SKILL.md Local file only; HTTP(S) sources are rejected
|
|
40
|
+
|
|
41
|
+
Security:
|
|
42
|
+
Prefer the hidden token prompt or --token-stdin. --token is still accepted
|
|
43
|
+
for automation, but it can be saved in shell history. AIPERMISSION_API_URL
|
|
44
|
+
must point to localhost, 127.0.0.1, or [::1].
|
|
45
|
+
`);
|
|
46
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
7
|
+
import { DEFAULT_API_URL, normalizeLocalAPIURL } from "./local-url.js";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_NAME = "@aipermission/mcp";
|
|
10
|
+
const useColor = output.isTTY && !process.env.NO_COLOR;
|
|
11
|
+
const color = {
|
|
12
|
+
reset: useColor ? "\x1b[0m" : "",
|
|
13
|
+
bold: useColor ? "\x1b[1m" : "",
|
|
14
|
+
dim: useColor ? "\x1b[2m" : "",
|
|
15
|
+
green: useColor ? "\x1b[32m" : "",
|
|
16
|
+
cyan: useColor ? "\x1b[36m" : "",
|
|
17
|
+
yellow: useColor ? "\x1b[33m" : "",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const providers = [
|
|
21
|
+
{
|
|
22
|
+
id: "codex",
|
|
23
|
+
label: "OpenAI Codex",
|
|
24
|
+
description: "Writes ~/.codex/config.toml",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "claude-code",
|
|
28
|
+
label: "Claude Code",
|
|
29
|
+
description: "Writes .mcp.json in the current project",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "cursor",
|
|
33
|
+
label: "Cursor",
|
|
34
|
+
description: "Writes .cursor/mcp.json in the current project",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "vscode",
|
|
38
|
+
label: "VS Code",
|
|
39
|
+
description: "Writes .vscode/mcp.json in the current project",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "windsurf",
|
|
43
|
+
label: "Windsurf",
|
|
44
|
+
description: "Writes ~/.codeium/windsurf/mcp_config.json",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "antigravity",
|
|
48
|
+
label: "Google Antigravity",
|
|
49
|
+
description: "Writes ~/.gemini/antigravity/mcp_config.json",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "gemini",
|
|
53
|
+
label: "Gemini CLI",
|
|
54
|
+
description: "Writes ~/.gemini/settings.json",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "custom",
|
|
58
|
+
label: "Custom / copy-paste",
|
|
59
|
+
description: "Prints config snippets only",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export async function runInit(argv = []) {
|
|
64
|
+
const flags = parseFlags(argv);
|
|
65
|
+
const stdinToken = flags.tokenStdin ? (await readStdin()).trim() : "";
|
|
66
|
+
const rl = readline.createInterface({ input, output });
|
|
67
|
+
try {
|
|
68
|
+
const provider = flags.provider
|
|
69
|
+
? findProvider(flags.provider)
|
|
70
|
+
: await selectProvider("Which AI client should use this token?", providers);
|
|
71
|
+
const name = sanitizeName(
|
|
72
|
+
flags.name || (await ask(rl, "MCP server name", "aipermission"))
|
|
73
|
+
);
|
|
74
|
+
const apiUrl = normalizeURL(flags.apiUrl || DEFAULT_API_URL);
|
|
75
|
+
const token = await resolveToken({ ...flags, stdinToken }, rl);
|
|
76
|
+
|
|
77
|
+
if (!token) {
|
|
78
|
+
throw new Error("API token is required.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const config = buildMCPServerConfig({ apiUrl, token });
|
|
82
|
+
if (provider.id === "custom" || flags.print) {
|
|
83
|
+
printTokenConfigWarning();
|
|
84
|
+
printCustomConfig(name, config);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = await writeProviderConfig(provider.id, name, config, { force: Boolean(flags.force) });
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log(`${color.green}Configured ${provider.label}${color.reset}`);
|
|
91
|
+
console.log(`${color.dim}Name:${color.reset} ${name}`);
|
|
92
|
+
console.log(`${color.dim}Path:${color.reset} ${result.path}`);
|
|
93
|
+
if (result.gitExcluded) {
|
|
94
|
+
console.log(`${color.dim}Git:${color.reset} added ${result.gitExcludeEntry} to .git/info/exclude`);
|
|
95
|
+
}
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log(`${color.yellow}Keep this config private:${color.reset} it contains an AIPermission bearer token. If it is committed, revoke the token.`);
|
|
98
|
+
console.log(`${color.yellow}Restart the AI client so it reloads MCP servers.${color.reset}`);
|
|
99
|
+
} finally {
|
|
100
|
+
rl.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseFlags(argv) {
|
|
105
|
+
const result = {};
|
|
106
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
107
|
+
const arg = argv[i];
|
|
108
|
+
if (!arg.startsWith("--")) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
|
112
|
+
const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
113
|
+
if (key === "print") {
|
|
114
|
+
result.print = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (key === "force") {
|
|
118
|
+
result.force = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (key === "tokenStdin") {
|
|
122
|
+
result.tokenStdin = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
result[key] = inlineValue ?? argv[i + 1] ?? "";
|
|
126
|
+
if (inlineValue === undefined) {
|
|
127
|
+
i += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findProvider(idOrLabel) {
|
|
134
|
+
const normalized = String(idOrLabel).trim().toLowerCase();
|
|
135
|
+
const provider = providers.find(
|
|
136
|
+
(item) => item.id === normalized || item.label.toLowerCase() === normalized
|
|
137
|
+
);
|
|
138
|
+
if (!provider) {
|
|
139
|
+
throw new Error(`Unknown provider: ${idOrLabel}`);
|
|
140
|
+
}
|
|
141
|
+
return provider;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function selectProvider(title, items) {
|
|
145
|
+
if (!input.isTTY || !output.isTTY) {
|
|
146
|
+
return items[0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let index = 0;
|
|
150
|
+
let renderedLines = 0;
|
|
151
|
+
input.setRawMode(true);
|
|
152
|
+
input.resume();
|
|
153
|
+
|
|
154
|
+
const render = () => {
|
|
155
|
+
if (renderedLines > 0) {
|
|
156
|
+
output.write(`\x1b[${renderedLines}A`);
|
|
157
|
+
output.write("\x1b[J");
|
|
158
|
+
}
|
|
159
|
+
const lines = [
|
|
160
|
+
`${color.bold}${color.cyan}${title}${color.reset}`,
|
|
161
|
+
`${color.dim}Use ↑/↓ and Enter.${color.reset}`,
|
|
162
|
+
"",
|
|
163
|
+
];
|
|
164
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
165
|
+
const selected = i === index;
|
|
166
|
+
const marker = selected ? `${color.green}›${color.reset}` : " ";
|
|
167
|
+
const label = selected ? `${color.bold}${items[i].label}${color.reset}` : items[i].label;
|
|
168
|
+
lines.push(
|
|
169
|
+
`${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
output.write("\x1b[?25l");
|
|
173
|
+
output.write(`${lines.join("\n")}\n`);
|
|
174
|
+
renderedLines = lines.length;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
render();
|
|
178
|
+
|
|
179
|
+
return await new Promise((resolve) => {
|
|
180
|
+
const cleanup = (selected) => {
|
|
181
|
+
input.off("data", onData);
|
|
182
|
+
input.setRawMode(false);
|
|
183
|
+
output.write("\x1b[?25h");
|
|
184
|
+
if (renderedLines > 0) {
|
|
185
|
+
output.write(`\x1b[${renderedLines}A`);
|
|
186
|
+
output.write("\x1b[J");
|
|
187
|
+
}
|
|
188
|
+
if (selected) {
|
|
189
|
+
output.write(`${color.green}Selected:${color.reset} ${selected.label}\n`);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const onData = (buffer) => {
|
|
193
|
+
const value = buffer.toString("utf8");
|
|
194
|
+
const keys = value.match(/\u001b\[[AB]|\r|\n|\u0003|./g) || [];
|
|
195
|
+
for (const key of keys) {
|
|
196
|
+
if (key === "\u0003") {
|
|
197
|
+
cleanup();
|
|
198
|
+
process.exit(130);
|
|
199
|
+
}
|
|
200
|
+
if (key === "\r" || key === "\n") {
|
|
201
|
+
const selected = items[index];
|
|
202
|
+
cleanup(selected);
|
|
203
|
+
resolve(selected);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (key === "\u001b[A") {
|
|
207
|
+
index = (index - 1 + items.length) % items.length;
|
|
208
|
+
render();
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (key === "\u001b[B") {
|
|
212
|
+
index = (index + 1) % items.length;
|
|
213
|
+
render();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
input.on("data", onData);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function ask(rl, label, defaultValue = "") {
|
|
222
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
223
|
+
const answer = await rl.question(`${label}${suffix}: `);
|
|
224
|
+
return answer.trim() || defaultValue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function resolveToken(flags, rl) {
|
|
228
|
+
if (flags.tokenStdin) {
|
|
229
|
+
return flags.stdinToken;
|
|
230
|
+
}
|
|
231
|
+
if (flags.token) {
|
|
232
|
+
console.warn(`${color.yellow}Warning:${color.reset} --token can be saved in shell history. Prefer the hidden prompt or --token-stdin.`);
|
|
233
|
+
return String(flags.token).trim();
|
|
234
|
+
}
|
|
235
|
+
return askSecret(rl, "API token");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function askSecret(rl, label) {
|
|
239
|
+
if (!input.isTTY || !output.isTTY) {
|
|
240
|
+
const answer = await rl.question(`${label}: `);
|
|
241
|
+
return answer.trim();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
rl.pause();
|
|
245
|
+
output.write(`${label}: `);
|
|
246
|
+
input.setRawMode(true);
|
|
247
|
+
input.resume();
|
|
248
|
+
|
|
249
|
+
let value = "";
|
|
250
|
+
return await new Promise((resolve) => {
|
|
251
|
+
const cleanup = () => {
|
|
252
|
+
input.off("data", onData);
|
|
253
|
+
input.setRawMode(false);
|
|
254
|
+
output.write("\n");
|
|
255
|
+
rl.resume();
|
|
256
|
+
};
|
|
257
|
+
const onData = (buffer) => {
|
|
258
|
+
const text = buffer.toString("utf8");
|
|
259
|
+
for (const char of text) {
|
|
260
|
+
if (char === "\u0003") {
|
|
261
|
+
cleanup();
|
|
262
|
+
process.exit(130);
|
|
263
|
+
}
|
|
264
|
+
if (char === "\r" || char === "\n") {
|
|
265
|
+
cleanup();
|
|
266
|
+
resolve(value.trim());
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (char === "\u007f" || char === "\b") {
|
|
270
|
+
value = value.slice(0, -1);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
value += char;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
input.on("data", onData);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function readStdin() {
|
|
281
|
+
const chunks = [];
|
|
282
|
+
for await (const chunk of input) {
|
|
283
|
+
chunks.push(Buffer.from(chunk));
|
|
284
|
+
}
|
|
285
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function buildMCPServerConfig({ apiUrl, token }) {
|
|
289
|
+
return {
|
|
290
|
+
command: "npx",
|
|
291
|
+
args: ["-y", PACKAGE_NAME],
|
|
292
|
+
env: {
|
|
293
|
+
NODE_ENV: "production",
|
|
294
|
+
AIPERMISSION_API_URL: apiUrl,
|
|
295
|
+
AIPERMISSION_API_TOKEN: token,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function writeProviderConfig(providerID, name, config, options = {}) {
|
|
301
|
+
if (providerID === "codex") {
|
|
302
|
+
const filePath = path.join(os.homedir(), ".codex", "config.toml");
|
|
303
|
+
await writeCodexConfig(filePath, name, config);
|
|
304
|
+
return { path: filePath };
|
|
305
|
+
}
|
|
306
|
+
if (providerID === "claude-code") {
|
|
307
|
+
const filePath = path.join(process.cwd(), ".mcp.json");
|
|
308
|
+
await assertProjectConfigWritable(filePath, options);
|
|
309
|
+
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
310
|
+
return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
|
|
311
|
+
}
|
|
312
|
+
if (providerID === "cursor") {
|
|
313
|
+
const filePath = path.join(process.cwd(), ".cursor", "mcp.json");
|
|
314
|
+
await assertProjectConfigWritable(filePath, options);
|
|
315
|
+
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
316
|
+
return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
|
|
317
|
+
}
|
|
318
|
+
if (providerID === "vscode") {
|
|
319
|
+
const filePath = path.join(process.cwd(), ".vscode", "mcp.json");
|
|
320
|
+
await assertProjectConfigWritable(filePath, options);
|
|
321
|
+
await writeJSONMCPConfig(filePath, name, config, "servers");
|
|
322
|
+
return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
|
|
323
|
+
}
|
|
324
|
+
if (providerID === "windsurf") {
|
|
325
|
+
const filePath = path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
326
|
+
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
327
|
+
return { path: filePath };
|
|
328
|
+
}
|
|
329
|
+
if (providerID === "antigravity") {
|
|
330
|
+
const filePath = path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
|
|
331
|
+
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
332
|
+
return { path: filePath };
|
|
333
|
+
}
|
|
334
|
+
if (providerID === "gemini") {
|
|
335
|
+
const filePath = path.join(os.homedir(), ".gemini", "settings.json");
|
|
336
|
+
await writeJSONMCPConfig(filePath, name, config, "mcpServers");
|
|
337
|
+
return { path: filePath };
|
|
338
|
+
}
|
|
339
|
+
throw new Error(`Unsupported provider: ${providerID}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function writeJSONMCPConfig(filePath, name, config, rootKey) {
|
|
343
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
344
|
+
let root = {};
|
|
345
|
+
try {
|
|
346
|
+
root = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error.code !== "ENOENT") {
|
|
349
|
+
throw new Error(`Could not read JSON config at ${filePath}: ${error.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!root || typeof root !== "object" || Array.isArray(root)) {
|
|
353
|
+
root = {};
|
|
354
|
+
}
|
|
355
|
+
root[rootKey] =
|
|
356
|
+
root[rootKey] && typeof root[rootKey] === "object" && !Array.isArray(root[rootKey])
|
|
357
|
+
? root[rootKey]
|
|
358
|
+
: {};
|
|
359
|
+
root[rootKey][name] = config;
|
|
360
|
+
await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function writeCodexConfig(filePath, name, config) {
|
|
364
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
365
|
+
let current = "";
|
|
366
|
+
try {
|
|
367
|
+
current = await fs.readFile(filePath, "utf8");
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (error.code !== "ENOENT") {
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const next = removeCodexServer(current, name).trimEnd();
|
|
375
|
+
const block = codexServerBlock(name, config);
|
|
376
|
+
await writePrivateFile(filePath, `${next ? `${next}\n\n` : ""}${block}\n`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function writePrivateFile(filePath, contents) {
|
|
380
|
+
await fs.writeFile(filePath, contents, { mode: 0o600 });
|
|
381
|
+
await fs.chmod(filePath, 0o600);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function assertProjectConfigWritable(filePath, options = {}) {
|
|
385
|
+
if (options.force) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const tracked = await gitTrackedPath(filePath);
|
|
389
|
+
if (!tracked) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
throw new Error(
|
|
393
|
+
[
|
|
394
|
+
`Refusing to write AIPERMISSION_API_TOKEN into tracked git file: ${tracked}`,
|
|
395
|
+
"Use --print to copy the config manually, untrack/ignore that file, or rerun with --force if you intentionally accept commit risk.",
|
|
396
|
+
].join("\n")
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function protectGitIgnoredConfig(filePath) {
|
|
401
|
+
const gitRoot = await findGitRoot(process.cwd());
|
|
402
|
+
if (!gitRoot) {
|
|
403
|
+
return {};
|
|
404
|
+
}
|
|
405
|
+
const relativePath = path.relative(gitRoot, filePath).split(path.sep).join("/");
|
|
406
|
+
if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) {
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
const excludePath = path.join(gitRoot, ".git", "info", "exclude");
|
|
410
|
+
let current = "";
|
|
411
|
+
try {
|
|
412
|
+
current = await fs.readFile(excludePath, "utf8");
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error.code !== "ENOENT") {
|
|
415
|
+
return {};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const entries = current.split(/\r?\n/).map((line) => line.trim());
|
|
419
|
+
if (!entries.includes(relativePath)) {
|
|
420
|
+
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
421
|
+
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
|
422
|
+
await fs.appendFile(excludePath, `${prefix}${relativePath}\n`, { mode: 0o600 });
|
|
423
|
+
}
|
|
424
|
+
return { gitExcluded: true, gitExcludeEntry: relativePath };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function gitTrackedPath(filePath) {
|
|
428
|
+
const gitRoot = await findGitRoot(process.cwd());
|
|
429
|
+
if (!gitRoot) {
|
|
430
|
+
return "";
|
|
431
|
+
}
|
|
432
|
+
const relativePath = path.relative(gitRoot, filePath).split(path.sep).join("/");
|
|
433
|
+
if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) {
|
|
434
|
+
return "";
|
|
435
|
+
}
|
|
436
|
+
const gitDir = path.join(gitRoot, ".git");
|
|
437
|
+
const args = ["--git-dir", gitDir, "--work-tree", gitRoot, "ls-files", "--error-unmatch", "--", relativePath];
|
|
438
|
+
try {
|
|
439
|
+
const { spawn } = await import("node:child_process");
|
|
440
|
+
await new Promise((resolve, reject) => {
|
|
441
|
+
const child = spawn("git", args, { stdio: "ignore" });
|
|
442
|
+
child.on("error", reject);
|
|
443
|
+
child.on("exit", (code) => {
|
|
444
|
+
if (code === 0) {
|
|
445
|
+
resolve();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
reject(Object.assign(new Error("not tracked"), { code }));
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
return relativePath;
|
|
452
|
+
} catch {
|
|
453
|
+
return "";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function findGitRoot(startDir) {
|
|
458
|
+
let current = path.resolve(startDir);
|
|
459
|
+
for (;;) {
|
|
460
|
+
const gitPath = path.join(current, ".git");
|
|
461
|
+
try {
|
|
462
|
+
const stat = await fs.stat(gitPath);
|
|
463
|
+
if (stat.isDirectory()) {
|
|
464
|
+
return current;
|
|
465
|
+
}
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error.code !== "ENOENT") {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const parent = path.dirname(current);
|
|
472
|
+
if (parent === current) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
current = parent;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function removeCodexServer(source, name) {
|
|
480
|
+
const main = `[mcp_servers.${tomlKey(name)}]`;
|
|
481
|
+
const env = `[mcp_servers.${tomlKey(name)}.env]`;
|
|
482
|
+
const lines = source.split(/\r?\n/);
|
|
483
|
+
const kept = [];
|
|
484
|
+
let skipping = false;
|
|
485
|
+
for (const line of lines) {
|
|
486
|
+
const trimmed = line.trim();
|
|
487
|
+
const isHeader = trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
488
|
+
if (trimmed === main || trimmed === env) {
|
|
489
|
+
skipping = true;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (isHeader && skipping) {
|
|
493
|
+
skipping = false;
|
|
494
|
+
}
|
|
495
|
+
if (!skipping) {
|
|
496
|
+
kept.push(line);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return kept.join("\n");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function codexServerBlock(name, config) {
|
|
503
|
+
return `[mcp_servers.${tomlKey(name)}]
|
|
504
|
+
command = ${tomlString(config.command)}
|
|
505
|
+
args = [${config.args.map(tomlString).join(", ")}]
|
|
506
|
+
enabled = true
|
|
507
|
+
|
|
508
|
+
[mcp_servers.${tomlKey(name)}.env]
|
|
509
|
+
NODE_ENV = ${tomlString(config.env.NODE_ENV)}
|
|
510
|
+
AIPERMISSION_API_URL = ${tomlString(config.env.AIPERMISSION_API_URL)}
|
|
511
|
+
AIPERMISSION_API_TOKEN = ${tomlString(config.env.AIPERMISSION_API_TOKEN)}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function printCustomConfig(name, config) {
|
|
515
|
+
console.log("");
|
|
516
|
+
console.log(`${color.bold}${color.cyan}Copy-paste config:${color.reset}`);
|
|
517
|
+
console.log("");
|
|
518
|
+
console.log(JSON.stringify({ mcpServers: { [name]: config } }, null, 2));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function printTokenConfigWarning() {
|
|
522
|
+
console.log("");
|
|
523
|
+
console.log(`${color.yellow}Warning:${color.reset} the printed config contains an AIPermission bearer token.`);
|
|
524
|
+
console.log(`${color.yellow}Keep it private and revoke the token if it is shared or committed.${color.reset}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function sanitizeName(value) {
|
|
528
|
+
const name = String(value || "")
|
|
529
|
+
.trim()
|
|
530
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
531
|
+
.replace(/^-+|-+$/g, "");
|
|
532
|
+
if (!name) {
|
|
533
|
+
throw new Error("MCP server name is required.");
|
|
534
|
+
}
|
|
535
|
+
return name;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function normalizeURL(value) {
|
|
539
|
+
return normalizeLocalAPIURL(value || DEFAULT_API_URL);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function tomlKey(value) {
|
|
543
|
+
if (/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
544
|
+
return value;
|
|
545
|
+
}
|
|
546
|
+
return tomlString(value);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function tomlString(value) {
|
|
550
|
+
return JSON.stringify(String(value));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
554
|
+
await runInit(process.argv.slice(2));
|
|
555
|
+
}
|