@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.
@@ -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.
@@ -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
+ }