@aipermission/mcp 0.1.1 → 0.1.8
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 +22 -2
- package/dist/resources/aipermission-operator/SKILL.md +35 -1
- package/dist/server.js +337 -11
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -55,9 +55,29 @@ The generated MCP config contains a bearer token. Keep it private. For project-l
|
|
|
55
55
|
- `list_requests`
|
|
56
56
|
- `read_console`
|
|
57
57
|
- `send_message`
|
|
58
|
+
- `list_file_transfers`
|
|
59
|
+
- `get_file_transfer`
|
|
60
|
+
- `list_file_transfer_batches`
|
|
61
|
+
- `get_file_transfer_batch`
|
|
62
|
+
- `browse_remote_files`
|
|
63
|
+
- `start_file_download`
|
|
64
|
+
- `save_file_download`
|
|
65
|
+
- `upload_files`
|
|
66
|
+
- `pause_file_transfer_batch`
|
|
67
|
+
- `resume_file_transfer_batch`
|
|
68
|
+
- `cancel_file_transfer_batch`
|
|
58
69
|
|
|
59
70
|
`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.
|
|
60
71
|
|
|
72
|
+
File transfer tools are intentionally conservative. MCP can list transfer
|
|
73
|
+
metadata, browse remote directories, start remote download queues, save
|
|
74
|
+
completed downloads to explicit local paths, upload explicit local files, and
|
|
75
|
+
pause/resume/cancel queues. `always_run` starts queues immediately.
|
|
76
|
+
`approval_required` creates a local approval queue in AIPermission Transfer
|
|
77
|
+
Center; the operator can approve selected files and reject the rest with a note.
|
|
78
|
+
MCP tool responses never include file contents, gateway temporary paths, archive
|
|
79
|
+
staging paths, or local upload contents.
|
|
80
|
+
|
|
61
81
|
## Operator Skill
|
|
62
82
|
|
|
63
83
|
Install the optional AIPermission operator instructions for your AI client:
|
|
@@ -77,11 +97,11 @@ Supported clients:
|
|
|
77
97
|
- `gemini`: `GEMINI.md`
|
|
78
98
|
- `custom`: prints portable Markdown to stdout
|
|
79
99
|
|
|
80
|
-
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.
|
|
100
|
+
These instructions teach the agent how to poll `approval_pending` and `running` requests, read live console output, write short reasons, use explicit file transfer paths, 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.
|
|
81
101
|
|
|
82
102
|
## Security Boundary
|
|
83
103
|
|
|
84
|
-
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.
|
|
104
|
+
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. Token/server permissions may be temporary; expired grants are omitted from `list_servers` and no longer authorize command, console, or file-transfer tools.
|
|
85
105
|
|
|
86
106
|
## License
|
|
87
107
|
|
|
@@ -18,11 +18,14 @@ AIPermission is not a general DevOps control plane. Treat it as a temporary, sco
|
|
|
18
18
|
Before executing commands:
|
|
19
19
|
|
|
20
20
|
1. Call `list_servers()`.
|
|
21
|
-
2. Read each server's `name`, `id`, `execution_rule`,
|
|
21
|
+
2. Read each server's `name`, `id`, `execution_rule`, optional `expires_at`,
|
|
22
|
+
and `hints`.
|
|
22
23
|
3. Use the numeric `id` returned by the tool.
|
|
23
24
|
4. Pick the narrowest server set that can answer the task.
|
|
24
25
|
|
|
25
26
|
If no server is visible, say that the current token has no accessible servers.
|
|
27
|
+
If `expires_at` is present, treat access as temporary. Finish within that
|
|
28
|
+
maintenance window or ask the operator to extend access.
|
|
26
29
|
|
|
27
30
|
## Command Reasons
|
|
28
31
|
|
|
@@ -91,6 +94,37 @@ The command is still running; reading console output before next step.
|
|
|
91
94
|
|
|
92
95
|
When a response includes `user_note`, treat it as live operator guidance. Apply it before continuing.
|
|
93
96
|
|
|
97
|
+
## File Transfer Flow
|
|
98
|
+
|
|
99
|
+
File transfer tools use the target server permission. `always_run` starts the
|
|
100
|
+
queue immediately. `approval_required` creates a local approval queue in
|
|
101
|
+
AIPermission Transfer Center; wait for the operator to approve selected files or
|
|
102
|
+
reject the queue with a note.
|
|
103
|
+
|
|
104
|
+
Use them only when the user explicitly asks to move files or inspect transfer
|
|
105
|
+
state. Prefer the smallest explicit path set. Do not use globs, recursive copy,
|
|
106
|
+
or directory transfer unless a future tool explicitly supports those behaviors.
|
|
107
|
+
|
|
108
|
+
For remote-to-local downloads:
|
|
109
|
+
|
|
110
|
+
1. Call `start_file_download(server_id, remote_paths, archive_name?)`.
|
|
111
|
+
2. Poll `get_file_transfer_batch(batch_id)` until the batch is terminal.
|
|
112
|
+
If the status is `pending_approval`, wait and poll again after the operator
|
|
113
|
+
decides in the local UI.
|
|
114
|
+
3. If completed and the user asked for a local copy, call
|
|
115
|
+
`save_file_download(batch_id, local_path, overwrite?)` with an explicit local
|
|
116
|
+
destination.
|
|
117
|
+
4. Report the saved path and status metadata. Do not print file contents unless
|
|
118
|
+
the user explicitly asks you to inspect the saved file.
|
|
119
|
+
|
|
120
|
+
For local-to-remote uploads, call `upload_files(server_id, local_paths,
|
|
121
|
+
remote_dir, overwrite?)` only with explicit local paths supplied by the user or
|
|
122
|
+
clearly located in the current local workspace. Poll `get_file_transfer_batch`
|
|
123
|
+
for progress.
|
|
124
|
+
|
|
125
|
+
The local AIPermission UI shows active and recent transfer queues in Transfer
|
|
126
|
+
Center. The operator can pause, resume, or cancel queues there.
|
|
127
|
+
|
|
94
128
|
## Safe Shell Practice
|
|
95
129
|
|
|
96
130
|
Prefer commands that are:
|
package/dist/server.js
CHANGED
|
@@ -6,6 +6,12 @@ if (process.argv[2] === "init") {
|
|
|
6
6
|
process.exit(0);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import fsp from "node:fs/promises";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
9
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
17
|
import { z } from "zod";
|
|
@@ -15,10 +21,11 @@ import { jsonToolResult } from "./results.js";
|
|
|
15
21
|
const apiUrl = normalizeLocalAPIURL(process.env.AIPERMISSION_API_URL);
|
|
16
22
|
const apiToken = process.env.AIPERMISSION_API_TOKEN || "";
|
|
17
23
|
const apiTimeoutMs = Number.parseInt(process.env.AIPERMISSION_HTTP_TIMEOUT_MS || "60000", 10);
|
|
24
|
+
const apiTransferTimeoutMs = Number.parseInt(process.env.AIPERMISSION_TRANSFER_TIMEOUT_MS || "7200000", 10);
|
|
18
25
|
|
|
19
26
|
const server = new McpServer({
|
|
20
27
|
name: "aipermission",
|
|
21
|
-
version: "0.1.
|
|
28
|
+
version: "0.1.2",
|
|
22
29
|
});
|
|
23
30
|
|
|
24
31
|
server.tool(
|
|
@@ -111,6 +118,195 @@ server.tool(
|
|
|
111
118
|
}
|
|
112
119
|
);
|
|
113
120
|
|
|
121
|
+
server.tool(
|
|
122
|
+
"list_file_transfers",
|
|
123
|
+
"List file transfer records visible to this token. Results never include local temp paths, archive paths, local upload contents, or file contents.",
|
|
124
|
+
{
|
|
125
|
+
server_id: z.number().int().positive().optional().describe("Optional server id from list_servers."),
|
|
126
|
+
direction: z.enum(["upload", "download"]).optional().describe("Optional transfer direction."),
|
|
127
|
+
status: z.enum(["pending_approval", "pending", "running", "paused", "completed", "failed", "canceled"]).optional().describe("Optional transfer status."),
|
|
128
|
+
limit: z.number().int().positive().max(100).optional().describe("Maximum records to return."),
|
|
129
|
+
offset: z.number().int().min(0).optional().describe("Pagination offset."),
|
|
130
|
+
},
|
|
131
|
+
async ({ server_id, direction, status, limit, offset }) => {
|
|
132
|
+
return jsonToolResult(() => {
|
|
133
|
+
const params = new URLSearchParams();
|
|
134
|
+
if (server_id) params.set("server_id", String(server_id));
|
|
135
|
+
if (direction) params.set("direction", direction);
|
|
136
|
+
if (status) params.set("status", status);
|
|
137
|
+
if (limit) params.set("limit", String(limit));
|
|
138
|
+
if (offset) params.set("offset", String(offset));
|
|
139
|
+
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
140
|
+
return apiGet(`/api/mcp/file-transfers${suffix}`);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
server.tool(
|
|
146
|
+
"get_file_transfer",
|
|
147
|
+
"Read one file transfer record visible to this token. Local temp paths, archive paths, local upload contents, and file contents are never returned.",
|
|
148
|
+
{
|
|
149
|
+
transfer_id: z.number().int().positive().describe("Transfer id from list_file_transfers or get_file_transfer_batch."),
|
|
150
|
+
},
|
|
151
|
+
async ({ transfer_id }) => {
|
|
152
|
+
return jsonToolResult(() => apiGet(`/api/mcp/file-transfers/${transfer_id}`));
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
server.tool(
|
|
157
|
+
"list_file_transfer_batches",
|
|
158
|
+
"List file transfer queues visible to this token. Use get_file_transfer_batch for per-file progress.",
|
|
159
|
+
{
|
|
160
|
+
server_id: z.number().int().positive().optional().describe("Optional server id from list_servers."),
|
|
161
|
+
direction: z.enum(["upload", "download"]).optional().describe("Optional transfer direction."),
|
|
162
|
+
status: z.enum(["pending_approval", "pending", "running", "paused", "completed", "failed", "canceled"]).optional().describe("Optional transfer status."),
|
|
163
|
+
limit: z.number().int().positive().max(100).optional().describe("Maximum records to return."),
|
|
164
|
+
offset: z.number().int().min(0).optional().describe("Pagination offset."),
|
|
165
|
+
},
|
|
166
|
+
async ({ server_id, direction, status, limit, offset }) => {
|
|
167
|
+
return jsonToolResult(() => {
|
|
168
|
+
const params = new URLSearchParams();
|
|
169
|
+
if (server_id) params.set("server_id", String(server_id));
|
|
170
|
+
if (direction) params.set("direction", direction);
|
|
171
|
+
if (status) params.set("status", status);
|
|
172
|
+
if (limit) params.set("limit", String(limit));
|
|
173
|
+
if (offset) params.set("offset", String(offset));
|
|
174
|
+
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
175
|
+
return apiGet(`/api/mcp/file-transfer-batches${suffix}`);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
server.tool(
|
|
181
|
+
"get_file_transfer_batch",
|
|
182
|
+
"Read one file transfer queue with per-file progress. Use save_file_download to write a completed MCP-started download to an explicit local path.",
|
|
183
|
+
{
|
|
184
|
+
batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
|
|
185
|
+
},
|
|
186
|
+
async ({ batch_id }) => {
|
|
187
|
+
return jsonToolResult(() => apiGet(`/api/mcp/file-transfer-batches/${batch_id}`));
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
server.tool(
|
|
192
|
+
"browse_remote_files",
|
|
193
|
+
"Browse a remote server directory through AIPermission. Requires always_run permission. This lists remote metadata only and does not read local files.",
|
|
194
|
+
{
|
|
195
|
+
server_id: z.number().int().positive().describe("Server id from list_servers."),
|
|
196
|
+
path: z.string().optional().describe("Absolute remote directory path. Defaults to /."),
|
|
197
|
+
},
|
|
198
|
+
async ({ server_id, path }) => {
|
|
199
|
+
return jsonToolResult(() => apiPost("/api/mcp/file-transfers/browse", {
|
|
200
|
+
server_id,
|
|
201
|
+
path: path || "/",
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
server.tool(
|
|
207
|
+
"start_file_download",
|
|
208
|
+
"Start a remote file download queue through AIPermission. always_run starts immediately; approval_required creates a local approval queue. Use get_file_transfer_batch for progress, then save_file_download to write a completed download to the local machine.",
|
|
209
|
+
{
|
|
210
|
+
server_id: z.number().int().positive().describe("Server id from list_servers."),
|
|
211
|
+
remote_paths: z.array(z.string().min(1)).min(1).max(100).describe("Absolute remote file paths to download sequentially."),
|
|
212
|
+
archive_name: z.string().optional().describe("Optional archive filename for multi-file downloads."),
|
|
213
|
+
},
|
|
214
|
+
async ({ server_id, remote_paths, archive_name }) => {
|
|
215
|
+
return jsonToolResult(() => apiPost("/api/mcp/file-transfers/download-batch", {
|
|
216
|
+
server_id,
|
|
217
|
+
remote_paths,
|
|
218
|
+
archive_name: archive_name || "",
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
server.tool(
|
|
224
|
+
"save_file_download",
|
|
225
|
+
"Save a completed MCP-started download batch to the local filesystem. File contents are written by the local MCP process and are not returned to the AI response.",
|
|
226
|
+
{
|
|
227
|
+
batch_id: z.number().int().positive().describe("Completed download batch id from start_file_download or list_file_transfer_batches."),
|
|
228
|
+
local_path: z.string().min(1).describe("Local file path or existing directory where the completed download should be saved."),
|
|
229
|
+
overwrite: z.boolean().optional().describe("Whether to overwrite an existing local file. Defaults to false."),
|
|
230
|
+
},
|
|
231
|
+
async ({ batch_id, local_path, overwrite }) => {
|
|
232
|
+
return jsonToolResult(async () => {
|
|
233
|
+
const batch = await apiGet(`/api/mcp/file-transfer-batches/${batch_id}`);
|
|
234
|
+
if (batch?.direction !== "download") {
|
|
235
|
+
throw new Error("batch is not a download");
|
|
236
|
+
}
|
|
237
|
+
if (batch?.status !== "completed") {
|
|
238
|
+
throw new Error(`download batch is not completed; current status is ${batch?.status || "unknown"}`);
|
|
239
|
+
}
|
|
240
|
+
const filename = suggestedDownloadFilename(batch);
|
|
241
|
+
const destination = await resolveLocalDestination(local_path, filename, Boolean(overwrite));
|
|
242
|
+
const saved = await apiDownloadToFile(`/api/mcp/file-transfer-batches/${batch_id}/download`, destination, Boolean(overwrite));
|
|
243
|
+
return {
|
|
244
|
+
status: "saved",
|
|
245
|
+
batch_id,
|
|
246
|
+
local_path: saved.path,
|
|
247
|
+
file_name: path.basename(saved.path),
|
|
248
|
+
bytes_written: saved.bytes,
|
|
249
|
+
assistant_hint: "The file was saved by the local MCP process. Do not print file contents unless the user explicitly asks you to inspect the saved file.",
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
server.tool(
|
|
256
|
+
"upload_files",
|
|
257
|
+
"Upload local files to a remote server through AIPermission. always_run starts immediately; approval_required stages the files locally and waits for local approval before writing to the remote server. File contents are read by the local MCP process and are not returned to the AI response.",
|
|
258
|
+
{
|
|
259
|
+
server_id: z.number().int().positive().describe("Server id from list_servers."),
|
|
260
|
+
local_paths: z.array(z.string().min(1)).min(1).max(100).describe("Local file paths to upload sequentially."),
|
|
261
|
+
remote_dir: z.string().min(1).describe("Absolute remote directory where files should be uploaded."),
|
|
262
|
+
overwrite: z.boolean().optional().describe("Whether to overwrite existing remote files. Defaults to false."),
|
|
263
|
+
},
|
|
264
|
+
async ({ server_id, local_paths, remote_dir, overwrite }) => {
|
|
265
|
+
return jsonToolResult(async () => {
|
|
266
|
+
const files = await resolveUploadFiles(local_paths);
|
|
267
|
+
const batch = await apiPostMultipart("/api/mcp/file-transfers/upload-batch", {
|
|
268
|
+
server_id: String(server_id),
|
|
269
|
+
remote_dir,
|
|
270
|
+
overwrite: overwrite ? "true" : "false",
|
|
271
|
+
}, files);
|
|
272
|
+
return batch;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
server.tool(
|
|
278
|
+
"pause_file_transfer_batch",
|
|
279
|
+
"Pause an active file transfer queue started or visible through AIPermission. Requires always_run permission for that server.",
|
|
280
|
+
{
|
|
281
|
+
batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
|
|
282
|
+
},
|
|
283
|
+
async ({ batch_id }) => {
|
|
284
|
+
return jsonToolResult(() => apiPost(`/api/mcp/file-transfer-batches/${batch_id}/pause`, {}));
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
server.tool(
|
|
289
|
+
"resume_file_transfer_batch",
|
|
290
|
+
"Resume a paused file transfer queue. Requires always_run permission for that server.",
|
|
291
|
+
{
|
|
292
|
+
batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
|
|
293
|
+
},
|
|
294
|
+
async ({ batch_id }) => {
|
|
295
|
+
return jsonToolResult(() => apiPost(`/api/mcp/file-transfer-batches/${batch_id}/resume`, {}));
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
server.tool(
|
|
300
|
+
"cancel_file_transfer_batch",
|
|
301
|
+
"Cancel a pending, running, or paused file transfer queue. Requires always_run permission for that server.",
|
|
302
|
+
{
|
|
303
|
+
batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
|
|
304
|
+
},
|
|
305
|
+
async ({ batch_id }) => {
|
|
306
|
+
return jsonToolResult(() => apiPost(`/api/mcp/file-transfer-batches/${batch_id}/cancel`, {}));
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
|
|
114
310
|
const transport = new StdioServerTransport();
|
|
115
311
|
await server.connect(transport);
|
|
116
312
|
|
|
@@ -123,26 +319,76 @@ async function apiGet(path) {
|
|
|
123
319
|
async function apiPost(path, body) {
|
|
124
320
|
return apiRequest(path, {
|
|
125
321
|
method: "POST",
|
|
322
|
+
headers: {
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
},
|
|
126
325
|
body: JSON.stringify(body),
|
|
127
326
|
});
|
|
128
327
|
}
|
|
129
328
|
|
|
329
|
+
async function apiPostMultipart(path, fields, files) {
|
|
330
|
+
const boundary = `aipermission-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
331
|
+
return apiRequest(path, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: {
|
|
334
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
335
|
+
},
|
|
336
|
+
body: Readable.from(multipartBody(boundary, fields, files)),
|
|
337
|
+
duplex: "half",
|
|
338
|
+
timeoutMs: apiTransferTimeoutMs,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function apiDownloadToFile(pathValue, destination, overwrite) {
|
|
343
|
+
const response = await apiFetch(pathValue, {
|
|
344
|
+
method: "GET",
|
|
345
|
+
timeoutMs: apiTransferTimeoutMs,
|
|
346
|
+
});
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
const text = await response.text();
|
|
349
|
+
const data = parseResponseBody(text);
|
|
350
|
+
throw new Error(data?.error || `aipermission API request failed with ${response.status}`);
|
|
351
|
+
}
|
|
352
|
+
let bytes = 0;
|
|
353
|
+
const output = fs.createWriteStream(destination, {
|
|
354
|
+
flags: overwrite ? "w" : "wx",
|
|
355
|
+
mode: 0o600,
|
|
356
|
+
});
|
|
357
|
+
output.on("bytesWritten", (value) => {
|
|
358
|
+
bytes = value;
|
|
359
|
+
});
|
|
360
|
+
await pipeline(Readable.fromWeb(response.body), output);
|
|
361
|
+
const stat = await fsp.stat(destination);
|
|
362
|
+
return { path: destination, bytes: stat.size || bytes };
|
|
363
|
+
}
|
|
364
|
+
|
|
130
365
|
async function apiRequest(path, options) {
|
|
366
|
+
const response = await apiFetch(path, options);
|
|
367
|
+
const text = await response.text();
|
|
368
|
+
const data = parseResponseBody(text);
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
throw new Error(data?.error || `aipermission API request failed with ${response.status}`);
|
|
371
|
+
}
|
|
372
|
+
return data;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function apiFetch(path, options) {
|
|
131
376
|
if (!apiToken) {
|
|
132
377
|
throw new Error("AIPERMISSION_API_TOKEN is required.");
|
|
133
378
|
}
|
|
134
|
-
const
|
|
379
|
+
const timeoutValue = options.timeoutMs || apiTimeoutMs;
|
|
380
|
+
const timeout = Number.isFinite(timeoutValue) && timeoutValue > 0 ? timeoutValue : 60000;
|
|
135
381
|
const controller = new AbortController();
|
|
136
382
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
383
|
+
const { timeoutMs: _timeoutMs, ...requestOptions } = options;
|
|
137
384
|
let response;
|
|
138
385
|
try {
|
|
139
386
|
response = await fetch(`${apiUrl}${path}`, {
|
|
140
|
-
...
|
|
387
|
+
...requestOptions,
|
|
141
388
|
signal: controller.signal,
|
|
142
389
|
headers: {
|
|
143
|
-
"Content-Type": "application/json",
|
|
144
390
|
Authorization: `Bearer ${apiToken}`,
|
|
145
|
-
...(
|
|
391
|
+
...(requestOptions.headers || {}),
|
|
146
392
|
},
|
|
147
393
|
});
|
|
148
394
|
} catch (error) {
|
|
@@ -153,12 +399,7 @@ async function apiRequest(path, options) {
|
|
|
153
399
|
} finally {
|
|
154
400
|
clearTimeout(timer);
|
|
155
401
|
}
|
|
156
|
-
|
|
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;
|
|
402
|
+
return response;
|
|
162
403
|
}
|
|
163
404
|
|
|
164
405
|
function parseResponseBody(text) {
|
|
@@ -171,3 +412,88 @@ function parseResponseBody(text) {
|
|
|
171
412
|
return { error: text.trim() || "Invalid non-JSON response from aipermission gateway." };
|
|
172
413
|
}
|
|
173
414
|
}
|
|
415
|
+
|
|
416
|
+
async function resolveUploadFiles(localPaths) {
|
|
417
|
+
const files = [];
|
|
418
|
+
const seen = new Set();
|
|
419
|
+
for (const rawPath of localPaths) {
|
|
420
|
+
const resolved = expandHome(rawPath);
|
|
421
|
+
if (seen.has(resolved)) {
|
|
422
|
+
throw new Error(`duplicate local path: ${resolved}`);
|
|
423
|
+
}
|
|
424
|
+
seen.add(resolved);
|
|
425
|
+
const stat = await fsp.stat(resolved).catch((error) => {
|
|
426
|
+
throw new Error(`cannot read local file ${resolved}: ${error.message}`);
|
|
427
|
+
});
|
|
428
|
+
if (!stat.isFile()) {
|
|
429
|
+
throw new Error(`local path is not a regular file: ${resolved}`);
|
|
430
|
+
}
|
|
431
|
+
files.push({
|
|
432
|
+
field: "files",
|
|
433
|
+
path: resolved,
|
|
434
|
+
name: path.basename(resolved),
|
|
435
|
+
size: stat.size,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return files;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function resolveLocalDestination(localPath, suggestedName, overwrite) {
|
|
442
|
+
const resolved = expandHome(localPath);
|
|
443
|
+
const existing = await fsp.stat(resolved).catch((error) => {
|
|
444
|
+
if (error?.code === "ENOENT") return null;
|
|
445
|
+
throw error;
|
|
446
|
+
});
|
|
447
|
+
if (existing?.isDirectory()) {
|
|
448
|
+
return resolveLocalDestination(path.join(resolved, suggestedName), suggestedName, overwrite);
|
|
449
|
+
}
|
|
450
|
+
if (existing && !overwrite) {
|
|
451
|
+
throw new Error(`local file already exists: ${resolved}`);
|
|
452
|
+
}
|
|
453
|
+
const parent = path.dirname(resolved);
|
|
454
|
+
const parentStat = await fsp.stat(parent).catch((error) => {
|
|
455
|
+
throw new Error(`local directory does not exist: ${parent}: ${error.message}`);
|
|
456
|
+
});
|
|
457
|
+
if (!parentStat.isDirectory()) {
|
|
458
|
+
throw new Error(`local parent path is not a directory: ${parent}`);
|
|
459
|
+
}
|
|
460
|
+
return resolved;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function suggestedDownloadFilename(batch) {
|
|
464
|
+
if (Array.isArray(batch?.items) && batch.items.length === 1 && batch.items[0]?.file_name) {
|
|
465
|
+
return safeLocalFilename(batch.items[0].file_name);
|
|
466
|
+
}
|
|
467
|
+
if (batch?.archive_name) {
|
|
468
|
+
return safeLocalFilename(batch.archive_name);
|
|
469
|
+
}
|
|
470
|
+
return `aipermission-download-${batch?.id || Date.now()}.zip`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function safeLocalFilename(value) {
|
|
474
|
+
const base = path.basename(String(value || "").replaceAll("\0", ""));
|
|
475
|
+
return base || "aipermission-download";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function expandHome(value) {
|
|
479
|
+
const text = String(value || "").trim();
|
|
480
|
+
if (text === "~") return os.homedir();
|
|
481
|
+
if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
|
|
482
|
+
return path.resolve(text);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function* multipartBody(boundary, fields, files) {
|
|
486
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
487
|
+
yield Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartName(name)}"\r\n\r\n${String(value)}\r\n`);
|
|
488
|
+
}
|
|
489
|
+
for (const file of files) {
|
|
490
|
+
yield Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartName(file.field)}"; filename="${escapeMultipartName(file.name)}"\r\nContent-Type: application/octet-stream\r\n\r\n`);
|
|
491
|
+
yield* fs.createReadStream(file.path);
|
|
492
|
+
yield Buffer.from("\r\n");
|
|
493
|
+
}
|
|
494
|
+
yield Buffer.from(`--${boundary}--\r\n`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function escapeMultipartName(value) {
|
|
498
|
+
return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
499
|
+
}
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"name": "io.github.aipermission/aipermission-mcp",
|
|
4
4
|
"title": "AIPermission",
|
|
5
5
|
"description": "Local-first MCP bridge for the AIPermission gateway.",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.8",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@aipermission/mcp",
|
|
11
|
-
"version": "0.1.
|
|
11
|
+
"version": "0.1.8",
|
|
12
12
|
"transport": {
|
|
13
13
|
"type": "stdio"
|
|
14
14
|
}
|