@aipermission/mcp 0.1.1 → 0.1.7

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 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,7 +97,7 @@ 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
 
@@ -91,6 +91,37 @@ The command is still running; reading console output before next step.
91
91
 
92
92
  When a response includes `user_note`, treat it as live operator guidance. Apply it before continuing.
93
93
 
94
+ ## File Transfer Flow
95
+
96
+ File transfer tools use the target server permission. `always_run` starts the
97
+ queue immediately. `approval_required` creates a local approval queue in
98
+ AIPermission Transfer Center; wait for the operator to approve selected files or
99
+ reject the queue with a note.
100
+
101
+ Use them only when the user explicitly asks to move files or inspect transfer
102
+ state. Prefer the smallest explicit path set. Do not use globs, recursive copy,
103
+ or directory transfer unless a future tool explicitly supports those behaviors.
104
+
105
+ For remote-to-local downloads:
106
+
107
+ 1. Call `start_file_download(server_id, remote_paths, archive_name?)`.
108
+ 2. Poll `get_file_transfer_batch(batch_id)` until the batch is terminal.
109
+ If the status is `pending_approval`, wait and poll again after the operator
110
+ decides in the local UI.
111
+ 3. If completed and the user asked for a local copy, call
112
+ `save_file_download(batch_id, local_path, overwrite?)` with an explicit local
113
+ destination.
114
+ 4. Report the saved path and status metadata. Do not print file contents unless
115
+ the user explicitly asks you to inspect the saved file.
116
+
117
+ For local-to-remote uploads, call `upload_files(server_id, local_paths,
118
+ remote_dir, overwrite?)` only with explicit local paths supplied by the user or
119
+ clearly located in the current local workspace. Poll `get_file_transfer_batch`
120
+ for progress.
121
+
122
+ The local AIPermission UI shows active and recent transfer queues in Transfer
123
+ Center. The operator can pause, resume, or cancel queues there.
124
+
94
125
  ## Safe Shell Practice
95
126
 
96
127
  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.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 timeout = Number.isFinite(apiTimeoutMs) && apiTimeoutMs > 0 ? apiTimeoutMs : 60000;
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
- ...options,
387
+ ...requestOptions,
141
388
  signal: controller.signal,
142
389
  headers: {
143
- "Content-Type": "application/json",
144
390
  Authorization: `Bearer ${apiToken}`,
145
- ...(options.headers || {}),
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
- 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;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipermission/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.7",
4
4
  "mcpName": "io.github.aipermission/aipermission-mcp",
5
5
  "description": "Local-first MCP bridge for the aipermission gateway.",
6
6
  "license": "MIT",
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.1",
6
+ "version": "0.1.7",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@aipermission/mcp",
11
- "version": "0.1.1",
11
+ "version": "0.1.7",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  }