@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const SKILL_NAME = "aipermission-operator";
|
|
7
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
export async function runInstallSkill(argv = []) {
|
|
10
|
+
const flags = parseFlags(argv);
|
|
11
|
+
const client = normalizeClient(flags.client || "codex");
|
|
12
|
+
const skill = await loadSkill(flags.source);
|
|
13
|
+
const homeDir = flags.home || os.homedir();
|
|
14
|
+
const projectDir = flags.projectDir || process.cwd();
|
|
15
|
+
|
|
16
|
+
if (client === "custom") {
|
|
17
|
+
console.log(renderInstruction("custom", skill));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const targetPath = skillPathForClient(client, { homeDir, projectDir });
|
|
22
|
+
const content = renderInstruction(client, skill);
|
|
23
|
+
if (client === "gemini") {
|
|
24
|
+
await upsertMarkedSection(targetPath, content);
|
|
25
|
+
} else {
|
|
26
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
27
|
+
await fs.writeFile(targetPath, content, { mode: 0o644 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`Installed ${SKILL_NAME} instructions for ${clientLabel(client)}:`);
|
|
31
|
+
console.log(targetPath);
|
|
32
|
+
console.log("");
|
|
33
|
+
console.log("Restart the AI client or open a new session so the instructions refresh.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function codexSkillPath(homeDir) {
|
|
37
|
+
return path.join(homeDir, ".codex", "skills", SKILL_NAME, "SKILL.md");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function skillPathForClient(client, { homeDir = os.homedir(), projectDir = process.cwd() } = {}) {
|
|
41
|
+
const normalized = normalizeClient(client);
|
|
42
|
+
if (normalized === "codex") {
|
|
43
|
+
return codexSkillPath(homeDir);
|
|
44
|
+
}
|
|
45
|
+
if (normalized === "claude-code") {
|
|
46
|
+
return path.join(projectDir, ".claude", "rules", `${SKILL_NAME}.md`);
|
|
47
|
+
}
|
|
48
|
+
if (normalized === "cursor") {
|
|
49
|
+
return path.join(projectDir, ".cursor", "rules", `${SKILL_NAME}.mdc`);
|
|
50
|
+
}
|
|
51
|
+
if (normalized === "vscode") {
|
|
52
|
+
return path.join(projectDir, ".github", "instructions", `${SKILL_NAME}.instructions.md`);
|
|
53
|
+
}
|
|
54
|
+
if (normalized === "windsurf") {
|
|
55
|
+
return path.join(projectDir, ".windsurf", "rules", `${SKILL_NAME}.md`);
|
|
56
|
+
}
|
|
57
|
+
if (normalized === "antigravity") {
|
|
58
|
+
return path.join(projectDir, ".agents", "rules", `${SKILL_NAME}.md`);
|
|
59
|
+
}
|
|
60
|
+
if (normalized === "gemini") {
|
|
61
|
+
return path.join(projectDir, "GEMINI.md");
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Unsupported client: ${client}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadSkill(source) {
|
|
67
|
+
if (source) {
|
|
68
|
+
return readSkillSource(source);
|
|
69
|
+
}
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const candidate of bundledSkillCandidates()) {
|
|
72
|
+
try {
|
|
73
|
+
return await readSkillSource(candidate);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
errors.push(`${candidate}: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Could not load bundled ${SKILL_NAME} skill.\n${errors.join("\n")}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bundledSkillCandidates() {
|
|
82
|
+
return [
|
|
83
|
+
path.join(moduleDir, "resources", SKILL_NAME, "SKILL.md"),
|
|
84
|
+
path.join(moduleDir, "..", "resources", SKILL_NAME, "SKILL.md"),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readSkillSource(source) {
|
|
89
|
+
if (/^https?:\/\//i.test(source)) {
|
|
90
|
+
throw new Error("remote skill sources are not supported; use the bundled skill or a local file path");
|
|
91
|
+
}
|
|
92
|
+
return validateSkill(await fs.readFile(source, "utf8"));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateSkill(value) {
|
|
96
|
+
if (!value.includes(`name: ${SKILL_NAME}`)) {
|
|
97
|
+
throw new Error(`source does not look like ${SKILL_NAME}`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderInstruction(client, skill) {
|
|
103
|
+
const normalized = normalizeClient(client);
|
|
104
|
+
if (normalized === "codex") {
|
|
105
|
+
return skill;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const body = stripSkillFrontmatter(skill).trim();
|
|
109
|
+
if (normalized === "claude-code") {
|
|
110
|
+
return `${body}\n`;
|
|
111
|
+
}
|
|
112
|
+
if (normalized === "cursor") {
|
|
113
|
+
return `---\ndescription: AIPermission MCP operator workflow for approval polling, console reads, reasons, and secret-safe commands.\nglobs:\nalwaysApply: true\n---\n\n${body}\n`;
|
|
114
|
+
}
|
|
115
|
+
if (normalized === "vscode") {
|
|
116
|
+
return `---\nname: AIPermission Operator\ndescription: Use AIPermission MCP safely with approvals, console reads, reasons, and secret hygiene.\napplyTo: "**"\n---\n\n${body}\n`;
|
|
117
|
+
}
|
|
118
|
+
if (normalized === "windsurf") {
|
|
119
|
+
return `---\ntrigger: always_on\n---\n\n${body}\n`;
|
|
120
|
+
}
|
|
121
|
+
if (normalized === "antigravity") {
|
|
122
|
+
return `---\ndescription: AIPermission MCP operator workflow\ntrigger: always_on\n---\n\n${body}\n`;
|
|
123
|
+
}
|
|
124
|
+
if (normalized === "gemini") {
|
|
125
|
+
return `## AIPermission Operator\n\n${body.replace(/^# AIPermission Operator\s*/m, "").trim()}\n`;
|
|
126
|
+
}
|
|
127
|
+
if (normalized === "custom") {
|
|
128
|
+
return `${body}\n`;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`Unsupported client: ${client}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function normalizeClient(value) {
|
|
134
|
+
const client = String(value || "").trim().toLowerCase();
|
|
135
|
+
const aliases = {
|
|
136
|
+
claude: "claude-code",
|
|
137
|
+
"claude_code": "claude-code",
|
|
138
|
+
"claude-code": "claude-code",
|
|
139
|
+
copilot: "vscode",
|
|
140
|
+
"vs-code": "vscode",
|
|
141
|
+
"google-antigravity": "antigravity",
|
|
142
|
+
agy: "antigravity",
|
|
143
|
+
"gemini-cli": "gemini",
|
|
144
|
+
};
|
|
145
|
+
const normalized = aliases[client] || client;
|
|
146
|
+
const supported = new Set(["codex", "claude-code", "cursor", "vscode", "windsurf", "antigravity", "gemini", "custom"]);
|
|
147
|
+
if (!supported.has(normalized)) {
|
|
148
|
+
throw new Error(`Unknown client: ${value}`);
|
|
149
|
+
}
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clientLabel(client) {
|
|
154
|
+
return {
|
|
155
|
+
codex: "Codex",
|
|
156
|
+
"claude-code": "Claude Code",
|
|
157
|
+
cursor: "Cursor",
|
|
158
|
+
vscode: "VS Code / GitHub Copilot",
|
|
159
|
+
windsurf: "Windsurf",
|
|
160
|
+
antigravity: "Google Antigravity",
|
|
161
|
+
gemini: "Gemini CLI",
|
|
162
|
+
custom: "Custom",
|
|
163
|
+
}[client] || client;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stripSkillFrontmatter(value) {
|
|
167
|
+
return value.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function upsertMarkedSection(filePath, content) {
|
|
171
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
172
|
+
const start = "<!-- aipermission-operator:start -->";
|
|
173
|
+
const end = "<!-- aipermission-operator:end -->";
|
|
174
|
+
const section = `${start}\n${content.trim()}\n${end}\n`;
|
|
175
|
+
let existing = "";
|
|
176
|
+
try {
|
|
177
|
+
existing = await fs.readFile(filePath, "utf8");
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code !== "ENOENT") {
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`);
|
|
184
|
+
const next = pattern.test(existing)
|
|
185
|
+
? existing.replace(pattern, section)
|
|
186
|
+
: `${existing.replace(/\s*$/, "")}${existing.trim() ? "\n\n" : ""}${section}`;
|
|
187
|
+
await fs.writeFile(filePath, next, { mode: 0o644 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function escapeRegExp(value) {
|
|
191
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseFlags(argv) {
|
|
195
|
+
const result = {};
|
|
196
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
197
|
+
const arg = argv[i];
|
|
198
|
+
if (!arg.startsWith("--")) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
|
202
|
+
const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
203
|
+
result[key] = inlineValue ?? argv[i + 1] ?? "";
|
|
204
|
+
if (inlineValue === undefined) {
|
|
205
|
+
i += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const DEFAULT_API_URL = "http://localhost:3210";
|
|
2
|
+
|
|
3
|
+
const allowedLocalHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
4
|
+
|
|
5
|
+
export function normalizeLocalAPIURL(value = DEFAULT_API_URL) {
|
|
6
|
+
const raw = String(value || DEFAULT_API_URL).trim();
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = new URL(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error("AIPERMISSION_API_URL must be a valid local HTTP URL.");
|
|
12
|
+
}
|
|
13
|
+
if (parsed.protocol !== "http:") {
|
|
14
|
+
throw new Error("AIPERMISSION_API_URL must use http:// for the local gateway.");
|
|
15
|
+
}
|
|
16
|
+
let hostname = parsed.hostname.toLowerCase();
|
|
17
|
+
if (hostname === "[::1]") {
|
|
18
|
+
hostname = "::1";
|
|
19
|
+
}
|
|
20
|
+
if (!allowedLocalHosts.has(hostname)) {
|
|
21
|
+
throw new Error("AIPERMISSION_API_URL must point to localhost, 127.0.0.1, or [::1].");
|
|
22
|
+
}
|
|
23
|
+
if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) {
|
|
24
|
+
throw new Error("AIPERMISSION_API_URL must be the gateway origin only, for example http://localhost:3210.");
|
|
25
|
+
}
|
|
26
|
+
return parsed.toString().replace(/\/$/, "");
|
|
27
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aipermission-operator
|
|
3
|
+
description: Use when operating servers through the AIPermission MCP gateway. Guides AI agents to handle approval_pending/running states, poll get_request, read live console output, write short reasons, avoid leaking secrets, and keep command execution safe and auditable.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AIPermission Operator
|
|
7
|
+
|
|
8
|
+
## Core Rule
|
|
9
|
+
|
|
10
|
+
Use AIPermission as a local, developer-controlled execution gateway.
|
|
11
|
+
|
|
12
|
+
You are allowed to operate only the servers returned by `list_servers()`. Do not ask for SSH passwords, private keys, database passwords, or raw credentials. The gateway owns credentials, permissions, approvals, console sessions, and audit history.
|
|
13
|
+
|
|
14
|
+
AIPermission is not a general DevOps control plane. Treat it as a temporary, scoped maintenance/debugging channel controlled by the human operator.
|
|
15
|
+
|
|
16
|
+
## Initial Discovery
|
|
17
|
+
|
|
18
|
+
Before executing commands:
|
|
19
|
+
|
|
20
|
+
1. Call `list_servers()`.
|
|
21
|
+
2. Read each server's `name`, `id`, `execution_rule`, and `hints`.
|
|
22
|
+
3. Use the numeric `id` returned by the tool.
|
|
23
|
+
4. Pick the narrowest server set that can answer the task.
|
|
24
|
+
|
|
25
|
+
If no server is visible, say that the current token has no accessible servers.
|
|
26
|
+
|
|
27
|
+
## Command Reasons
|
|
28
|
+
|
|
29
|
+
Every `exec` call should include a short `reason`.
|
|
30
|
+
|
|
31
|
+
Good reasons:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
Check Docker service state before cleanup.
|
|
35
|
+
Inspect recent kubelet errors on worker node.
|
|
36
|
+
Verify trial worker deployment after node label change.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Avoid vague reasons:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
run command
|
|
43
|
+
debug
|
|
44
|
+
test
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Approval Flow
|
|
48
|
+
|
|
49
|
+
`approval_pending` is not terminal.
|
|
50
|
+
|
|
51
|
+
When `exec` returns `approval_pending`:
|
|
52
|
+
|
|
53
|
+
1. Read `retry_after_seconds`; default to 3 seconds if missing.
|
|
54
|
+
2. Wait that long.
|
|
55
|
+
3. Call `get_request(request_id)`.
|
|
56
|
+
4. Continue polling until the status is terminal.
|
|
57
|
+
5. If status becomes `running`, keep polling `get_request(request_id)`; `read_console` is only available when the token has `always_run` permission for that server.
|
|
58
|
+
|
|
59
|
+
Terminal statuses:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
completed
|
|
63
|
+
failed
|
|
64
|
+
declined
|
|
65
|
+
blocked
|
|
66
|
+
error
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If the request is `declined`, read `user_note` and follow the user's correction.
|
|
70
|
+
|
|
71
|
+
## Running Flow
|
|
72
|
+
|
|
73
|
+
When `exec` or `get_request` returns `running`:
|
|
74
|
+
|
|
75
|
+
1. If the server permission is `always_run`, call `read_console(server_id)` before sending another long-running command to the same server.
|
|
76
|
+
2. Poll `get_request(request_id)` every 3-5 seconds.
|
|
77
|
+
3. Use `read_console(server_id)` between polls only when the token has `always_run` permission.
|
|
78
|
+
4. Do not start another long-running command on the same server until the active request reaches a terminal status, unless the user explicitly asks.
|
|
79
|
+
|
|
80
|
+
## Message Flow
|
|
81
|
+
|
|
82
|
+
Use `send_message(message, server_id?, session_id?)` for short operator-visible notes.
|
|
83
|
+
|
|
84
|
+
Good messages:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
Docker install started; waiting for package installation to finish.
|
|
88
|
+
K3s agent joined. Checking node labels now.
|
|
89
|
+
The command is still running; reading console output before next step.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
When a response includes `user_note`, treat it as live operator guidance. Apply it before continuing.
|
|
93
|
+
|
|
94
|
+
## Safe Shell Practice
|
|
95
|
+
|
|
96
|
+
Prefer commands that are:
|
|
97
|
+
|
|
98
|
+
- non-interactive
|
|
99
|
+
- bounded in output
|
|
100
|
+
- explicit about destructive actions
|
|
101
|
+
- easy to audit from history
|
|
102
|
+
|
|
103
|
+
MCP `exec` closes stdin for the command body. Do not use commands that wait for interactive stdin. Use flags such as `-y`, `--no-pager`, heredoc-created files, or the manual web console for interactive work.
|
|
104
|
+
|
|
105
|
+
Use examples like:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
systemctl is-active docker
|
|
109
|
+
journalctl --no-pager -u k3s-agent -n 100
|
|
110
|
+
docker logs --tail 100 CONTAINER
|
|
111
|
+
kubectl get nodes -o wide
|
|
112
|
+
df -h
|
|
113
|
+
free -m
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For apt on Debian/Ubuntu:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
120
|
+
apt-get update
|
|
121
|
+
apt-get install -y PACKAGE
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
After install/uninstall checks in the same shell, refresh command lookup:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
hash -r 2>/dev/null || true
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For package verification, prefer installed-state checks over ambiguous removed-package residue:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package} ${Version}\n' docker-ce docker-ce-cli 2>/dev/null | grep '^ii'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Output Hygiene
|
|
137
|
+
|
|
138
|
+
Avoid huge unbounded output.
|
|
139
|
+
|
|
140
|
+
Prefer:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
tail -n 100 /path/to/log
|
|
144
|
+
journalctl --no-pager -n 100
|
|
145
|
+
docker logs --tail 100 NAME
|
|
146
|
+
kubectl logs --tail=100 POD
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Avoid:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
cat huge.log
|
|
153
|
+
journalctl
|
|
154
|
+
docker logs NAME
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Secret Hygiene
|
|
158
|
+
|
|
159
|
+
Command text, command output, history, audit records, and console transcript may be stored in the encrypted local database.
|
|
160
|
+
|
|
161
|
+
Do not print secrets unless the user explicitly asks and accepts the risk. Avoid commands that dump:
|
|
162
|
+
|
|
163
|
+
- private keys
|
|
164
|
+
- `.env` files
|
|
165
|
+
- token files
|
|
166
|
+
- database passwords
|
|
167
|
+
- cloud credentials
|
|
168
|
+
- Kubernetes secrets
|
|
169
|
+
|
|
170
|
+
Prefer existence and metadata checks:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
test -f /path/to/.env && echo exists
|
|
174
|
+
ls -l /path/to/secret-file
|
|
175
|
+
kubectl get secret NAME -o jsonpath='{.metadata.name}'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Destructive Actions
|
|
179
|
+
|
|
180
|
+
Before destructive actions:
|
|
181
|
+
|
|
182
|
+
1. Inspect current state.
|
|
183
|
+
2. Explain the exact destructive command in the `reason`.
|
|
184
|
+
3. Prefer one clear destructive step at a time.
|
|
185
|
+
4. Verify after completion.
|
|
186
|
+
|
|
187
|
+
Examples of destructive actions:
|
|
188
|
+
|
|
189
|
+
- deleting containers, volumes, images
|
|
190
|
+
- uninstalling packages
|
|
191
|
+
- removing files
|
|
192
|
+
- restarting critical services
|
|
193
|
+
- changing Kubernetes labels or draining nodes
|
|
194
|
+
|
|
195
|
+
## Multi-Server Work
|
|
196
|
+
|
|
197
|
+
For multiple servers:
|
|
198
|
+
|
|
199
|
+
1. Check visibility with `list_servers()`.
|
|
200
|
+
2. Work one risky operation at a time.
|
|
201
|
+
3. Keep each command targeted to one server unless the user explicitly requests batch behavior.
|
|
202
|
+
4. Summarize per-server status after each phase.
|
|
203
|
+
|
|
204
|
+
## Final Response
|
|
205
|
+
|
|
206
|
+
When reporting back to the user, include:
|
|
207
|
+
|
|
208
|
+
- servers touched
|
|
209
|
+
- commands or command groups run
|
|
210
|
+
- important findings
|
|
211
|
+
- changes made
|
|
212
|
+
- verification results
|
|
213
|
+
- any pending/recommended next steps
|
|
214
|
+
|
|
215
|
+
Keep it concise and operational.
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function textResult(value) {
|
|
2
|
+
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
3
|
+
return {
|
|
4
|
+
content: [
|
|
5
|
+
{
|
|
6
|
+
type: "text",
|
|
7
|
+
text,
|
|
8
|
+
},
|
|
9
|
+
],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function errorResult(error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error || "Unknown aipermission MCP error");
|
|
15
|
+
return {
|
|
16
|
+
isError: true,
|
|
17
|
+
content: [
|
|
18
|
+
{
|
|
19
|
+
type: "text",
|
|
20
|
+
text: JSON.stringify({ status: "error", error: message }, null, 2),
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function jsonToolResult(callback) {
|
|
27
|
+
try {
|
|
28
|
+
return textResult(await callback());
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return errorResult(error);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
if (process.argv[2] === "init") {
|
|
4
|
+
const { runInit } = await import("./init.js");
|
|
5
|
+
await runInit(process.argv.slice(3));
|
|
6
|
+
process.exit(0);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { normalizeLocalAPIURL } from "./local-url.js";
|
|
13
|
+
import { jsonToolResult } from "./results.js";
|
|
14
|
+
|
|
15
|
+
const apiUrl = normalizeLocalAPIURL(process.env.AIPERMISSION_API_URL);
|
|
16
|
+
const apiToken = process.env.AIPERMISSION_API_TOKEN || "";
|
|
17
|
+
const apiTimeoutMs = Number.parseInt(process.env.AIPERMISSION_HTTP_TIMEOUT_MS || "60000", 10);
|
|
18
|
+
|
|
19
|
+
const server = new McpServer({
|
|
20
|
+
name: "aipermission",
|
|
21
|
+
version: "0.1.0",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
server.tool(
|
|
25
|
+
"list_servers",
|
|
26
|
+
"List servers this aipermission token can access. Credentials are never returned.",
|
|
27
|
+
{},
|
|
28
|
+
async () => {
|
|
29
|
+
return jsonToolResult(() => apiGet("/api/mcp/servers"));
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
server.tool(
|
|
34
|
+
"exec",
|
|
35
|
+
"Execute a shell command on an allowed server through the local aipermission gateway. If status is approval_pending, follow assistant_hint and poll get_request. Long always_run commands return running; use read_console to continue watching.",
|
|
36
|
+
{
|
|
37
|
+
server_id: z.number().int().positive().describe("Server id from list_servers."),
|
|
38
|
+
command: z.string().min(1).describe("Shell command to execute."),
|
|
39
|
+
reason: z.string().optional().describe("Why this command is needed."),
|
|
40
|
+
},
|
|
41
|
+
async ({ server_id, command, reason }) => {
|
|
42
|
+
return jsonToolResult(() => apiPost("/api/mcp/exec", {
|
|
43
|
+
server_id,
|
|
44
|
+
command,
|
|
45
|
+
reason: reason || "",
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
server.tool(
|
|
51
|
+
"read_console",
|
|
52
|
+
"Read the latest persistent console transcript for an allowed server. Use this after a long-running exec returns running.",
|
|
53
|
+
{
|
|
54
|
+
server_id: z.number().int().positive().describe("Server id from list_servers."),
|
|
55
|
+
tail: z.number().int().positive().max(100000).optional().describe("Maximum transcript characters to return."),
|
|
56
|
+
},
|
|
57
|
+
async ({ server_id, tail }) => {
|
|
58
|
+
return jsonToolResult(() => {
|
|
59
|
+
const params = new URLSearchParams({ server_id: String(server_id) });
|
|
60
|
+
if (tail) {
|
|
61
|
+
params.set("tail", String(tail));
|
|
62
|
+
}
|
|
63
|
+
return apiGet(`/api/mcp/console?${params.toString()}`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
server.tool(
|
|
69
|
+
"get_request",
|
|
70
|
+
"Read an aipermission command request by id. Use this after exec returns approval_pending or running.",
|
|
71
|
+
{
|
|
72
|
+
request_id: z.number().int().positive().describe("Request id returned by exec."),
|
|
73
|
+
},
|
|
74
|
+
async ({ request_id }) => {
|
|
75
|
+
return jsonToolResult(() => apiGet(`/api/mcp/requests/${request_id}`));
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
server.tool(
|
|
80
|
+
"list_requests",
|
|
81
|
+
"List command requests for this token. Optionally filter by status such as pending_approval, running, completed, failed, declined, or error.",
|
|
82
|
+
{
|
|
83
|
+
status: z.string().optional().describe("Optional request status filter."),
|
|
84
|
+
},
|
|
85
|
+
async ({ status }) => {
|
|
86
|
+
return jsonToolResult(() => {
|
|
87
|
+
const params = new URLSearchParams();
|
|
88
|
+
if (status) {
|
|
89
|
+
params.set("status", status);
|
|
90
|
+
}
|
|
91
|
+
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
92
|
+
return apiGet(`/api/mcp/requests${suffix}`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.tool(
|
|
98
|
+
"send_message",
|
|
99
|
+
"Send a short note to the aipermission Console messages panel for the human operator.",
|
|
100
|
+
{
|
|
101
|
+
message: z.string().min(1).describe("Message to show in the Console messages panel."),
|
|
102
|
+
server_id: z.number().int().positive().optional().describe("Optional server id this message is about."),
|
|
103
|
+
session_id: z.number().int().positive().optional().describe("Optional console session id this message is about."),
|
|
104
|
+
},
|
|
105
|
+
async ({ message, server_id, session_id }) => {
|
|
106
|
+
return jsonToolResult(() => apiPost("/api/mcp/messages", {
|
|
107
|
+
message,
|
|
108
|
+
server_id: server_id || null,
|
|
109
|
+
session_id: session_id || null,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const transport = new StdioServerTransport();
|
|
115
|
+
await server.connect(transport);
|
|
116
|
+
|
|
117
|
+
async function apiGet(path) {
|
|
118
|
+
return apiRequest(path, {
|
|
119
|
+
method: "GET",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function apiPost(path, body) {
|
|
124
|
+
return apiRequest(path, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function apiRequest(path, options) {
|
|
131
|
+
if (!apiToken) {
|
|
132
|
+
throw new Error("AIPERMISSION_API_TOKEN is required.");
|
|
133
|
+
}
|
|
134
|
+
const timeout = Number.isFinite(apiTimeoutMs) && apiTimeoutMs > 0 ? apiTimeoutMs : 60000;
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
137
|
+
let response;
|
|
138
|
+
try {
|
|
139
|
+
response = await fetch(`${apiUrl}${path}`, {
|
|
140
|
+
...options,
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
headers: {
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
Authorization: `Bearer ${apiToken}`,
|
|
145
|
+
...(options.headers || {}),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.name === "AbortError") {
|
|
150
|
+
throw new Error(`aipermission API request timed out after ${timeout}ms`);
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
const text = await response.text();
|
|
157
|
+
const data = parseResponseBody(text);
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(data?.error || `aipermission API request failed with ${response.status}`);
|
|
160
|
+
}
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseResponseBody(text) {
|
|
165
|
+
if (!text) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(text);
|
|
170
|
+
} catch {
|
|
171
|
+
return { error: text.trim() || "Invalid non-JSON response from aipermission gateway." };
|
|
172
|
+
}
|
|
173
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aipermission/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"mcpName": "io.github.aipermission/aipermission-mcp",
|
|
5
|
+
"description": "Local-first MCP bridge for the aipermission gateway.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/aipermission/aipermission/tree/main/packages/mcp#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/aipermission/aipermission.git",
|
|
12
|
+
"directory": "packages/mcp"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/aipermission/aipermission/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"aipermission-mcp": "dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"server.json"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node scripts/build.js",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"test": "node --test test/*.test.js",
|
|
33
|
+
"start": "node dist/cli.js",
|
|
34
|
+
"dev": "node src/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
41
|
+
"zod": "3.25.76"
|
|
42
|
+
}
|
|
43
|
+
}
|