@aipermission/mcp 0.1.14 → 0.2.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/README.md CHANGED
@@ -1,10 +1,16 @@
1
1
  # @aipermission/mcp
2
2
 
3
- Local-first MCP bridge for the AIPermission gateway.
3
+ Local-first MCP bridge for the AIPermission connector gateway.
4
4
 
5
- AIPermission lets AI coding assistants use scoped server access through a local gateway without receiving SSH private keys or server credentials.
5
+ AIPermission lets AI coding assistants use scoped connector actions through a
6
+ local gateway without receiving SSH private keys, database passwords, API
7
+ credentials, or other connector secrets.
6
8
 
7
- The gateway is intentionally local-only. Run it on the developer machine and keep the URL on `localhost`; remote servers are SSH targets, not places to host the gateway for LAN or internet users.
9
+ The gateway is intentionally local-only. Run it on the developer machine and
10
+ keep the URL on `localhost`; remote systems are connector targets, not places
11
+ to host the gateway for LAN or internet users. SSH and Postgres are built-in
12
+ connectors that use the same target/profile/action permission model as future
13
+ connectors.
8
14
 
9
15
  ![AIPermission demo: AI installs Uptime Kuma through approval-based SSH access](https://raw.githubusercontent.com/aipermission/aipermission/main/docs/assets/demo/aipermission-demo.gif)
10
16
 
@@ -49,39 +55,25 @@ The generated MCP config contains a bearer token. Keep it private. For project-l
49
55
 
50
56
  ## Tools
51
57
 
52
- - `list_servers`
53
- - `exec`
54
- - `get_request`
55
- - `list_requests`
56
- - `read_console`
57
- - `restart_console_session`
58
- - `send_message`
59
- - `list_file_transfers`
60
- - `get_file_transfer`
61
- - `list_file_transfer_batches`
62
- - `get_file_transfer_batch`
63
- - `browse_remote_files`
64
- - `start_file_download`
65
- - `save_file_download`
66
- - `upload_files`
67
- - `pause_file_transfer_batch`
68
- - `resume_file_transfer_batch`
69
- - `cancel_file_transfer_batch`
70
-
71
- `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.
72
-
73
- Command responses can include `policy_warnings` for common high-risk command
74
- patterns. They are best-effort safety rails and do not replace token
75
- permissions, approval review, or operator judgment.
76
-
77
- File transfer tools are intentionally conservative. MCP can list transfer
78
- metadata, browse remote directories, start remote download queues, save
79
- completed downloads to explicit local paths, upload explicit local files, and
80
- pause/resume/cancel queues. `always_run` starts queues immediately.
81
- `approval_required` creates a local approval queue in AIPermission Transfer
82
- Center; the operator can approve selected files and reject the rest with a note.
83
- MCP tool responses never include file contents, gateway temporary paths, archive
84
- staging paths, or local upload contents.
58
+ - `list_connector_targets`
59
+ - `get_connector_help`
60
+ - `get_connector_actions`
61
+ - `call_connector_action`
62
+ - `get_connector_action_request`
63
+
64
+ All integration work goes through connector targets. SSH, Postgres, and future
65
+ connectors share the same model: target, credential profile, connector action,
66
+ token action permission, approval, history, and audit.
67
+
68
+ For SSH, call `get_connector_actions(target_ref)` to discover actions such as
69
+ `exec`, `read_console`, `restart_console_session`, `browse_remote_files`, and
70
+ `start_file_download`. SSH `exec` is intended for non-interactive commands. Use
71
+ the web console for truly interactive work.
72
+
73
+ Connector responses can include `approval_pending` or `running`. Poll
74
+ `get_connector_action_request(request_id)` until the request reaches a terminal
75
+ status. MCP tool responses never include file contents, gateway temporary paths,
76
+ archive staging paths, or local upload contents.
85
77
 
86
78
  ## Operator Skill
87
79
 
@@ -102,11 +94,24 @@ Supported clients:
102
94
  - `gemini`: `GEMINI.md`
103
95
  - `custom`: prints portable Markdown to stdout
104
96
 
105
- These instructions teach the agent how to poll `approval_pending` and `running` requests, handle `stale` approvals by sending a fresh request, read live console output, recover stuck persistent console sessions with `restart_console_session`, 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.
97
+ These instructions teach the agent how to discover connector targets, poll
98
+ `approval_pending` and `running` connector action requests, handle `stale`
99
+ approvals by sending a fresh request, write short reasons, use explicit file
100
+ transfer paths, and avoid printing secrets. The default installer uses the
101
+ operator instruction bundled in the npm package; `--source` accepts local file
102
+ paths only and rejects HTTP(S) sources.
106
103
 
107
104
  ## Security Boundary
108
105
 
109
- 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. `list_servers` is permission-scoped, not a live SSH health check; treat `exec` connection errors as the current reachability signal.
106
+ This package talks to a local AIPermission gateway. `AIPERMISSION_API_URL` must
107
+ point to `localhost`, `127.0.0.1`, or `[::1]`; remote URLs are rejected before
108
+ the bearer token is sent. Do not expose the gateway on LAN or the public
109
+ internet, and do not use it as a shared DevOps service. Tokens grant access only
110
+ to connector targets, credential profiles, and action rules configured in the
111
+ gateway UI. Connector permissions may be temporary; expired grants are omitted
112
+ from `list_connector_targets` and no longer authorize connector actions. Target
113
+ visibility is permission-scoped, not a live health check; treat action execution
114
+ errors as the current reachability signal.
110
115
 
111
116
  ## License
112
117
 
package/dist/cli.js CHANGED
@@ -39,8 +39,7 @@ Install skill flags:
39
39
  --source /path/to/SKILL.md Local file only; HTTP(S) sources are rejected
40
40
 
41
41
  Security:
42
- Prefer the hidden token prompt or --token-stdin. --token is still accepted
43
- for automation, but it can be saved in shell history. AIPERMISSION_API_URL
44
- must point to localhost, 127.0.0.1, or [::1].
42
+ Use the hidden token prompt or --token-stdin. AIPERMISSION_API_URL must point
43
+ to localhost, 127.0.0.1, or [::1].
45
44
  `);
46
45
  }
package/dist/init.js CHANGED
@@ -122,6 +122,9 @@ export function parseFlags(argv) {
122
122
  result.tokenStdin = true;
123
123
  continue;
124
124
  }
125
+ if (key === "token") {
126
+ throw new Error("--token is not supported; use the hidden prompt or --token-stdin");
127
+ }
125
128
  result[key] = inlineValue ?? argv[i + 1] ?? "";
126
129
  if (inlineValue === undefined) {
127
130
  i += 1;
@@ -228,10 +231,6 @@ async function resolveToken(flags, rl) {
228
231
  if (flags.tokenStdin) {
229
232
  return flags.stdinToken;
230
233
  }
231
- if (flags.token) {
232
- console.warn(`${color.yellow}Warning:${color.reset} --token can be saved in shell history. Prefer the hidden prompt or --token-stdin.`);
233
- return String(flags.token).trim();
234
- }
235
234
  return askSecret(rl, "API token");
236
235
  }
237
236
 
@@ -1,48 +1,51 @@
1
1
  ---
2
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.
3
+ description: Use when operating targets through the AIPermission MCP gateway. Guides AI agents to discover connector targets, call connector actions, handle approval_pending/running states, write short reasons, avoid leaking secrets, and keep execution auditable.
4
4
  ---
5
5
 
6
6
  # AIPermission Operator
7
7
 
8
8
  ## Core Rule
9
9
 
10
- Use AIPermission as a local, developer-controlled execution gateway.
10
+ Use AIPermission as a local, developer-controlled permission gateway.
11
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.
12
+ You are allowed to operate only the connector targets returned by
13
+ `list_connector_targets()`. Do not ask for SSH passwords, private keys, database
14
+ passwords, API keys, or raw credentials. The gateway owns credentials,
15
+ permissions, approvals, runtime sessions, and audit history.
13
16
 
14
- AIPermission is not a general DevOps control plane. Treat it as a temporary, scoped maintenance/debugging channel controlled by the human operator.
17
+ AIPermission is not a hosted DevOps control plane. Treat it as a temporary,
18
+ scoped maintenance/debugging channel controlled by the human operator.
15
19
 
16
- ## Initial Discovery
20
+ ## Discovery
17
21
 
18
- Before executing commands:
22
+ Before acting:
19
23
 
20
- 1. Call `list_servers()`.
21
- 2. Read each server's `name`, `id`, `execution_rule`, optional `expires_at`,
22
- and `hints`.
23
- 3. Use the numeric `id` returned by the tool.
24
- 4. Pick the narrowest server set that can answer the task.
24
+ 1. Call `list_connector_targets()`.
25
+ 2. Pick the relevant `target_ref`.
26
+ 3. Call `get_connector_help(target_ref)` the first time you use that connector.
27
+ 4. Call `get_connector_actions(target_ref)` and choose the narrowest action.
28
+ 5. Call `call_connector_action(target_ref, action_name, input, reason)`.
25
29
 
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.
30
+ If no target is visible, say that the current token has no accessible connector
31
+ targets. If an action grant includes `expires_at`, treat access as temporary and
32
+ finish within that maintenance window or ask the operator to extend access.
29
33
 
30
- `list_servers()` is permission-scoped, not a live health check. A visible server
31
- may still be powered off, unreachable, reject SSH authentication, or require host
32
- key review. Treat `exec` dial, timeout, authentication, and host-key errors as
33
- the current reachability signal.
34
+ Target visibility is permission-scoped, not a live health check. A visible SSH
35
+ target may still be powered off, unreachable, reject authentication, or require
36
+ host-key review. A visible database/API target may still reject the credential
37
+ profile. Treat action errors as the current reachability/authorization signal.
34
38
 
35
- ## Command Reasons
39
+ ## Reasons
36
40
 
37
- Every single-server `exec` call should include a short `reason`. Multi-server
38
- `exec` calls with `server_ids` must include a short `reason`.
41
+ Every `call_connector_action` should include a short `reason`.
39
42
 
40
43
  Good reasons:
41
44
 
42
45
  ```text
43
46
  Check Docker service state before cleanup.
44
47
  Inspect recent kubelet errors on worker node.
45
- Verify trial worker deployment after node label change.
48
+ List Postgres schemas before a read-only metadata query.
46
49
  ```
47
50
 
48
51
  Avoid vague reasons:
@@ -53,41 +56,16 @@ debug
53
56
  test
54
57
  ```
55
58
 
56
- ## Bulk Command Flow
57
-
58
- Use `exec(server_ids, command, reason)` when the operator asks for the same
59
- non-interactive command on multiple servers, such as checking package update
60
- state, uptime, disk usage, or a service status across a small fleet.
61
-
62
- Before using multi-server `exec`:
63
-
64
- 1. Call `list_servers()`.
65
- 2. Choose only the requested target servers.
66
- 3. Keep the command non-interactive and bounded.
67
- 4. Use one clear reason that applies to every target.
68
-
69
- After multi-server `exec` returns:
70
-
71
- 1. Inspect every item.
72
- 2. For each item with `request_id`, poll `get_request(request_id)` until terminal.
73
- 3. If several `always_run` items are still running, use `read_console(server_ids, tail)` to inspect live output in one read-only call.
74
- 4. Treat `blocked` or unauthorized items as skipped, not failed executions.
75
- 5. Do not assume all targets started just because the top-level status is `accepted`.
76
-
77
- Prefer individual `exec` calls when each server needs a different command,
78
- different reason, or separate investigation path.
79
-
80
59
  ## Approval Flow
81
60
 
82
61
  `approval_pending` is not terminal.
83
62
 
84
- When `exec` returns `approval_pending`:
63
+ When `call_connector_action` returns `approval_pending`:
85
64
 
86
65
  1. Read `retry_after_seconds`; default to 3 seconds if missing.
87
66
  2. Wait that long.
88
- 3. Call `get_request(request_id)`.
67
+ 3. Call `get_connector_action_request(request_id)`.
89
68
  4. Continue polling until the status is terminal.
90
- 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.
91
69
 
92
70
  Terminal statuses:
93
71
 
@@ -100,89 +78,34 @@ error
100
78
  stale
101
79
  ```
102
80
 
103
- If the request is `declined`, read `user_note` and follow the user's correction.
104
- If the request is `stale`, the approval context changed before execution; send a fresh `exec` request with the current command and reason instead of trying to reuse the old approval.
105
- If the request is `error`, read the error text. SSH reachability, refused ports,
106
- authentication failures, and host-key failures are current connection signals;
107
- do not assume a listed server is live just because `list_servers` returned it.
108
-
109
- If a response or request contains `policy_warnings`, mention the warning briefly
110
- when it affects the next action. These warnings are best-effort safety rails, not
111
- permission to skip human judgment.
81
+ If the request is `declined`, read the note/error and follow the user's
82
+ correction. If the request is `stale`, the approval context changed before
83
+ execution; send a fresh `call_connector_action` request with the current target,
84
+ action, input, and reason.
112
85
 
113
86
  ## Running Flow
114
87
 
115
- When `exec` or `get_request` returns `running`:
116
-
117
- 1. If the server permission is `always_run`, call `read_console(server_id)` before sending another long-running command to the same server.
118
- 2. Poll `get_request(request_id)` every 3-5 seconds.
119
- 3. Use `read_console(server_id)` between polls only when the token has `always_run` permission. For a multi-server command, use `read_console(server_ids, tail)` to inspect several running consoles at once.
120
- 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.
121
-
122
- If the request appears stuck for an unusually long time and `read_console`
123
- shows no useful progress, ask the operator before recovery unless they already
124
- asked you to recover the session. When recovery is approved, call
125
- `restart_console_session(server_id)`. This does not intentionally run a remote
126
- shell command; it closes the gateway-owned persistent console session and the
127
- next `exec` opens a fresh SSH session. Treat any canceled request as
128
- inconclusive and re-run only the minimal safe inspection needed.
129
-
130
- ## Message Flow
131
-
132
- Use `send_message(message, server_id?, session_id?)` for short operator-visible notes.
133
-
134
- Good messages:
135
-
136
- ```text
137
- Docker install started; waiting for package installation to finish.
138
- K3s agent joined. Checking node labels now.
139
- The command is still running; reading console output before next step.
140
- ```
88
+ When `call_connector_action` or `get_connector_action_request` returns
89
+ `running`:
141
90
 
142
- When a response includes `user_note`, treat it as live operator guidance. Apply it before continuing.
91
+ 1. Poll `get_connector_action_request(request_id)` every 3-5 seconds.
92
+ 2. Do not start another long-running action on the same target/profile until the
93
+ active request reaches a terminal status, unless the user explicitly asks.
94
+ 3. For SSH `exec`, use the SSH connector's `read_console` action when live
95
+ output is needed and the token has permission for it.
96
+ 4. If an SSH request appears stuck for an unusually long time, ask the operator
97
+ before recovery unless they already asked you to recover. When approved, call
98
+ the SSH connector's `restart_console_session` action for the same target_ref.
143
99
 
144
- ## File Transfer Flow
100
+ ## SSH Practice
145
101
 
146
- File transfer tools use the target server permission. `always_run` starts the
147
- queue immediately. `approval_required` creates a local approval queue in
148
- AIPermission Transfer Center; wait for the operator to approve selected files or
149
- reject the queue with a note.
150
-
151
- Use them only when the user explicitly asks to move files or inspect transfer
152
- state. Prefer the smallest explicit path set. Do not use globs, recursive copy,
153
- or directory transfer unless a future tool explicitly supports those behaviors.
154
-
155
- For remote-to-local downloads:
156
-
157
- 1. Call `start_file_download(server_id, remote_paths, archive_name?)`.
158
- 2. Poll `get_file_transfer_batch(batch_id)` until the batch is terminal.
159
- If the status is `pending_approval`, wait and poll again after the operator
160
- decides in the local UI.
161
- 3. If completed and the user asked for a local copy, call
162
- `save_file_download(batch_id, local_path, overwrite?)` with an explicit local
163
- destination.
164
- 4. Report the saved path and status metadata. Do not print file contents unless
165
- the user explicitly asks you to inspect the saved file.
166
-
167
- For local-to-remote uploads, call `upload_files(server_id, local_paths,
168
- remote_dir, overwrite?)` only with explicit local paths supplied by the user or
169
- clearly located in the current local workspace. Poll `get_file_transfer_batch`
170
- for progress.
171
-
172
- The local AIPermission UI shows active and recent transfer queues in Transfer
173
- Center. The operator can pause, resume, or cancel queues there.
174
-
175
- ## Safe Shell Practice
176
-
177
- Prefer commands that are:
102
+ For SSH connector `exec`, prefer commands that are:
178
103
 
179
104
  - non-interactive
180
105
  - bounded in output
181
106
  - explicit about destructive actions
182
107
  - easy to audit from history
183
108
 
184
- 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.
185
-
186
109
  Use examples like:
187
110
 
188
111
  ```sh
@@ -194,6 +117,14 @@ df -h
194
117
  free -m
195
118
  ```
196
119
 
120
+ Avoid huge unbounded output:
121
+
122
+ ```sh
123
+ cat huge.log
124
+ journalctl
125
+ docker logs NAME
126
+ ```
127
+
197
128
  For apt on Debian/Ubuntu:
198
129
 
199
130
  ```sh
@@ -208,89 +139,22 @@ After install/uninstall checks in the same shell, refresh command lookup:
208
139
  hash -r 2>/dev/null || true
209
140
  ```
210
141
 
211
- For package verification, prefer installed-state checks over ambiguous removed-package residue:
142
+ ## File Transfer
212
143
 
213
- ```sh
214
- dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package} ${Version}\n' docker-ce docker-ce-cli 2>/dev/null | grep '^ii'
215
- ```
216
-
217
- ## Output Hygiene
218
-
219
- Avoid huge unbounded output.
220
-
221
- Prefer:
222
-
223
- ```sh
224
- tail -n 100 /path/to/log
225
- journalctl --no-pager -n 100
226
- docker logs --tail 100 NAME
227
- kubectl logs --tail=100 POD
228
- ```
144
+ Use connector file-transfer actions only when the user explicitly asks to move
145
+ files or inspect remote paths. Prefer the smallest explicit path set. Do not use
146
+ globs, recursive copy, or directory transfer unless a connector action
147
+ explicitly supports that behavior.
229
148
 
230
- Avoid:
231
-
232
- ```sh
233
- cat huge.log
234
- journalctl
235
- docker logs NAME
236
- ```
149
+ MCP connector responses never include file contents, gateway temp paths, archive
150
+ staging paths, or local upload contents.
237
151
 
238
152
  ## Secret Hygiene
239
153
 
240
- Command text, command output, history, audit records, and console transcript may be stored in the encrypted local database.
241
-
242
- Do not print secrets unless the user explicitly asks and accepts the risk. Avoid commands that dump:
243
-
244
- - private keys
245
- - `.env` files
246
- - token files
247
- - database passwords
248
- - cloud credentials
249
- - Kubernetes secrets
250
-
251
- Prefer existence and metadata checks:
252
-
253
- ```sh
254
- test -f /path/to/.env && echo exists
255
- ls -l /path/to/secret-file
256
- kubectl get secret NAME -o jsonpath='{.metadata.name}'
257
- ```
258
-
259
- ## Destructive Actions
260
-
261
- Before destructive actions:
262
-
263
- 1. Inspect current state.
264
- 2. Explain the exact destructive command in the `reason`.
265
- 3. Prefer one clear destructive step at a time.
266
- 4. Verify after completion.
267
-
268
- Examples of destructive actions:
269
-
270
- - deleting containers, volumes, images
271
- - uninstalling packages
272
- - removing files
273
- - restarting critical services
274
- - changing Kubernetes labels or draining nodes
275
-
276
- ## Multi-Server Work
277
-
278
- For multiple servers:
279
-
280
- 1. Check visibility with `list_servers()`.
281
- 2. Work one risky operation at a time.
282
- 3. Keep each command targeted to one server unless the user explicitly requests batch behavior.
283
- 4. Summarize per-server status after each phase.
284
-
285
- ## Final Response
286
-
287
- When reporting back to the user, include:
288
-
289
- - servers touched
290
- - commands or command groups run
291
- - important findings
292
- - changes made
293
- - verification results
294
- - any pending/recommended next steps
154
+ Command text, action input, action output, history, audit records, and console
155
+ transcripts may be stored in the encrypted local database. Avoid printing
156
+ secrets. Prefer checking whether a file/key exists before reading credential
157
+ files or environment files.
295
158
 
296
- Keep it concise and operational.
159
+ If a secret appears in output, do not repeat it. Summarize the finding and ask
160
+ the operator how to rotate or redact it.
package/dist/server.js CHANGED
@@ -6,368 +6,87 @@ 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
+ import { readFileSync } from "node:fs";
15
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
12
  import { z } from "zod";
18
13
  import { normalizeLocalAPIURL } from "./local-url.js";
19
14
  import { jsonToolResult } from "./results.js";
20
15
 
16
+ const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
17
  const apiUrl = normalizeLocalAPIURL(process.env.AIPERMISSION_API_URL);
22
18
  const apiToken = process.env.AIPERMISSION_API_TOKEN || "";
23
19
  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);
25
20
 
26
21
  const server = new McpServer({
27
22
  name: "aipermission",
28
- version: "0.1.14",
23
+ version: packageMetadata.version,
29
24
  });
30
25
 
31
26
  server.tool(
32
- "list_servers",
33
- "List servers this aipermission token can access. Credentials are never returned.",
27
+ "list_connector_targets",
28
+ "List connector targets this AIPermission token can access. Credentials and secrets are never returned.",
34
29
  {},
35
30
  async () => {
36
- return jsonToolResult(() => apiGet("/api/mcp/servers"));
31
+ return jsonToolResult(() => apiGet("/api/mcp/connector-targets"));
37
32
  }
38
33
  );
39
34
 
40
35
  server.tool(
41
- "exec",
42
- "Execute a shell command on one allowed server, or the same shell command across multiple allowed servers, 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
+ "get_connector_help",
37
+ "Read AI-facing help for one connector target/profile. Use this before calling connector actions for the first time.",
43
38
  {
44
- server_id: z.number().int().positive().optional().describe("Single server id from list_servers. Use either server_id or server_ids, not both."),
45
- server_ids: z.array(z.number().int().positive()).min(1).max(25).optional().describe("Multiple server ids from list_servers for bulk execution. Up to 25 targets. Use either server_id or server_ids, not both."),
46
- command: z.string().min(1).describe("Shell command to execute."),
47
- reason: z.string().optional().describe("Why this command is needed. Required when using server_ids."),
39
+ target_ref: z.string().min(1).describe("Target ref from list_connector_targets in connector:target_id:profile_id format."),
48
40
  },
49
- async ({ server_id, server_ids, command, reason }) => {
41
+ async ({ target_ref }) => {
50
42
  return jsonToolResult(() => {
51
- if (server_id && server_ids?.length) {
52
- throw new Error("Use either server_id or server_ids, not both.");
53
- }
54
- if (!server_id && !server_ids?.length) {
55
- throw new Error("server_id or server_ids is required.");
56
- }
57
- if (server_ids?.length) {
58
- if (!String(reason || "").trim()) {
59
- throw new Error("reason is required when using server_ids.");
60
- }
61
- return apiPost("/api/mcp/bulk-exec", {
62
- server_ids,
63
- command,
64
- reason,
65
- });
66
- }
67
- return apiPost("/api/mcp/exec", {
68
- server_id,
69
- command,
70
- reason: reason || "",
71
- });
43
+ const params = new URLSearchParams({ target_ref });
44
+ return apiGet(`/api/mcp/connector-help?${params.toString()}`);
72
45
  });
73
46
  }
74
47
  );
75
48
 
76
49
  server.tool(
77
- "read_console",
78
- "Read the latest persistent console transcript for one allowed server, or for multiple allowed servers after a multi-server exec. Use this after a long-running exec returns running.",
50
+ "get_connector_actions",
51
+ "List actions exposed by one connector target/profile. Action execution is still checked against token permissions.",
79
52
  {
80
- server_id: z.number().int().positive().optional().describe("Single server id from list_servers. Use either server_id or server_ids, not both."),
81
- server_ids: z.array(z.number().int().positive()).min(1).max(25).optional().describe("Multiple server ids from list_servers. Up to 25 targets. Use either server_id or server_ids, not both."),
82
- tail: z.number().int().positive().max(100000).optional().describe("Maximum transcript characters to return."),
53
+ target_ref: z.string().min(1).describe("Target ref from list_connector_targets in connector:target_id:profile_id format."),
83
54
  },
84
- async ({ server_id, server_ids, tail }) => {
85
- return jsonToolResult(async () => {
86
- if (server_id && server_ids?.length) {
87
- throw new Error("Use either server_id or server_ids, not both.");
88
- }
89
- if (!server_id && !server_ids?.length) {
90
- throw new Error("server_id or server_ids is required.");
91
- }
92
- const readOne = (id) => {
93
- const params = new URLSearchParams({ server_id: String(id) });
94
- if (tail) {
95
- params.set("tail", String(tail));
96
- }
97
- return apiGet(`/api/mcp/console?${params.toString()}`);
98
- };
99
- if (server_ids?.length) {
100
- const unique = new Set(server_ids);
101
- if (unique.size !== server_ids.length) {
102
- throw new Error("server_ids must not contain duplicates.");
103
- }
104
- const items = await Promise.all(server_ids.map(async (id) => {
105
- try {
106
- return await readOne(id);
107
- } catch (error) {
108
- return {
109
- status: "error",
110
- server_id: id,
111
- error: error?.message || "failed to read console",
112
- };
113
- }
114
- }));
115
- return {
116
- status: "ok",
117
- items,
118
- assistant_hint: "Inspect each item independently. A listed server may have no active console, blocked read permission, or an SSH/session error.",
119
- };
120
- }
121
- return readOne(server_id);
122
- });
123
- }
124
- );
125
-
126
- server.tool(
127
- "restart_console_session",
128
- "Restart the persistent console session for a server when it appears stuck. This closes the current gateway-owned console session and the next exec will open a fresh SSH session.",
129
- {
130
- server_id: z.number().int().positive().describe("Server id from list_servers."),
131
- },
132
- async ({ server_id }) => {
133
- return jsonToolResult(() => apiPost("/api/mcp/console/restart", {
134
- server_id,
135
- }));
136
- }
137
- );
138
-
139
- server.tool(
140
- "get_request",
141
- "Read an aipermission command request by id. Use this after exec returns approval_pending or running.",
142
- {
143
- request_id: z.number().int().positive().describe("Request id returned by exec."),
144
- },
145
- async ({ request_id }) => {
146
- return jsonToolResult(() => apiGet(`/api/mcp/requests/${request_id}`));
147
- }
148
- );
149
-
150
- server.tool(
151
- "list_requests",
152
- "List command requests for this token. Optionally filter by status such as pending_approval, running, completed, failed, declined, or error.",
153
- {
154
- status: z.string().optional().describe("Optional request status filter."),
155
- },
156
- async ({ status }) => {
55
+ async ({ target_ref }) => {
157
56
  return jsonToolResult(() => {
158
- const params = new URLSearchParams();
159
- if (status) {
160
- params.set("status", status);
161
- }
162
- const suffix = params.toString() ? `?${params.toString()}` : "";
163
- return apiGet(`/api/mcp/requests${suffix}`);
57
+ const params = new URLSearchParams({ target_ref });
58
+ return apiGet(`/api/mcp/connector-actions?${params.toString()}`);
164
59
  });
165
60
  }
166
61
  );
167
62
 
168
63
  server.tool(
169
- "send_message",
170
- "Send a short note to the aipermission Console messages panel for the human operator.",
64
+ "call_connector_action",
65
+ "Call one connector action through AIPermission. If status is approval_pending or running, follow assistant_hint and poll get_connector_action_request.",
171
66
  {
172
- message: z.string().min(1).describe("Message to show in the Console messages panel."),
173
- server_id: z.number().int().positive().optional().describe("Optional server id this message is about."),
174
- session_id: z.number().int().positive().optional().describe("Optional console session id this message is about."),
67
+ target_ref: z.string().min(1).describe("Target ref from list_connector_targets."),
68
+ action_name: z.string().min(1).describe("Action name from get_connector_actions."),
69
+ input: z.record(z.unknown()).optional().describe("Connector-specific action input."),
70
+ reason: z.string().optional().describe("Why this connector action is needed."),
175
71
  },
176
- async ({ message, server_id, session_id }) => {
177
- return jsonToolResult(() => apiPost("/api/mcp/messages", {
178
- message,
179
- server_id: server_id || null,
180
- session_id: session_id || null,
72
+ async ({ target_ref, action_name, input, reason }) => {
73
+ return jsonToolResult(() => apiPost("/api/mcp/connector-actions/call", {
74
+ target_ref,
75
+ action_name,
76
+ input: input || {},
77
+ reason: reason || "",
181
78
  }));
182
79
  }
183
80
  );
184
81
 
185
82
  server.tool(
186
- "list_file_transfers",
187
- "List file transfer records visible to this token. Results never include local temp paths, archive paths, local upload contents, or file contents.",
188
- {
189
- server_id: z.number().int().positive().optional().describe("Optional server id from list_servers."),
190
- direction: z.enum(["upload", "download"]).optional().describe("Optional transfer direction."),
191
- status: z.enum(["pending_approval", "pending", "running", "paused", "completed", "failed", "canceled"]).optional().describe("Optional transfer status."),
192
- limit: z.number().int().positive().max(100).optional().describe("Maximum records to return."),
193
- offset: z.number().int().min(0).optional().describe("Pagination offset."),
194
- },
195
- async ({ server_id, direction, status, limit, offset }) => {
196
- return jsonToolResult(() => {
197
- const params = new URLSearchParams();
198
- if (server_id) params.set("server_id", String(server_id));
199
- if (direction) params.set("direction", direction);
200
- if (status) params.set("status", status);
201
- if (limit) params.set("limit", String(limit));
202
- if (offset) params.set("offset", String(offset));
203
- const suffix = params.toString() ? `?${params.toString()}` : "";
204
- return apiGet(`/api/mcp/file-transfers${suffix}`);
205
- });
206
- }
207
- );
208
-
209
- server.tool(
210
- "get_file_transfer",
211
- "Read one file transfer record visible to this token. Local temp paths, archive paths, local upload contents, and file contents are never returned.",
212
- {
213
- transfer_id: z.number().int().positive().describe("Transfer id from list_file_transfers or get_file_transfer_batch."),
214
- },
215
- async ({ transfer_id }) => {
216
- return jsonToolResult(() => apiGet(`/api/mcp/file-transfers/${transfer_id}`));
217
- }
218
- );
219
-
220
- server.tool(
221
- "list_file_transfer_batches",
222
- "List file transfer queues visible to this token. Use get_file_transfer_batch for per-file progress.",
223
- {
224
- server_id: z.number().int().positive().optional().describe("Optional server id from list_servers."),
225
- direction: z.enum(["upload", "download"]).optional().describe("Optional transfer direction."),
226
- status: z.enum(["pending_approval", "pending", "running", "paused", "completed", "failed", "canceled"]).optional().describe("Optional transfer status."),
227
- limit: z.number().int().positive().max(100).optional().describe("Maximum records to return."),
228
- offset: z.number().int().min(0).optional().describe("Pagination offset."),
229
- },
230
- async ({ server_id, direction, status, limit, offset }) => {
231
- return jsonToolResult(() => {
232
- const params = new URLSearchParams();
233
- if (server_id) params.set("server_id", String(server_id));
234
- if (direction) params.set("direction", direction);
235
- if (status) params.set("status", status);
236
- if (limit) params.set("limit", String(limit));
237
- if (offset) params.set("offset", String(offset));
238
- const suffix = params.toString() ? `?${params.toString()}` : "";
239
- return apiGet(`/api/mcp/file-transfer-batches${suffix}`);
240
- });
241
- }
242
- );
243
-
244
- server.tool(
245
- "get_file_transfer_batch",
246
- "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.",
247
- {
248
- batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
249
- },
250
- async ({ batch_id }) => {
251
- return jsonToolResult(() => apiGet(`/api/mcp/file-transfer-batches/${batch_id}`));
252
- }
253
- );
254
-
255
- server.tool(
256
- "browse_remote_files",
257
- "Browse a remote server directory through AIPermission. Requires always_run permission. This lists remote metadata only and does not read local files.",
258
- {
259
- server_id: z.number().int().positive().describe("Server id from list_servers."),
260
- path: z.string().optional().describe("Absolute remote directory path. Defaults to /."),
261
- },
262
- async ({ server_id, path }) => {
263
- return jsonToolResult(() => apiPost("/api/mcp/file-transfers/browse", {
264
- server_id,
265
- path: path || "/",
266
- }));
267
- }
268
- );
269
-
270
- server.tool(
271
- "start_file_download",
272
- "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.",
273
- {
274
- server_id: z.number().int().positive().describe("Server id from list_servers."),
275
- remote_paths: z.array(z.string().min(1)).min(1).max(100).describe("Absolute remote file paths to download sequentially."),
276
- archive_name: z.string().optional().describe("Optional archive filename for multi-file downloads."),
277
- },
278
- async ({ server_id, remote_paths, archive_name }) => {
279
- return jsonToolResult(() => apiPost("/api/mcp/file-transfers/download-batch", {
280
- server_id,
281
- remote_paths,
282
- archive_name: archive_name || "",
283
- }));
284
- }
285
- );
286
-
287
- server.tool(
288
- "save_file_download",
289
- "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.",
290
- {
291
- batch_id: z.number().int().positive().describe("Completed download batch id from start_file_download or list_file_transfer_batches."),
292
- local_path: z.string().min(1).describe("Local file path or existing directory where the completed download should be saved."),
293
- overwrite: z.boolean().optional().describe("Whether to overwrite an existing local file. Defaults to false."),
294
- },
295
- async ({ batch_id, local_path, overwrite }) => {
296
- return jsonToolResult(async () => {
297
- const batch = await apiGet(`/api/mcp/file-transfer-batches/${batch_id}`);
298
- if (batch?.direction !== "download") {
299
- throw new Error("batch is not a download");
300
- }
301
- if (batch?.status !== "completed") {
302
- throw new Error(`download batch is not completed; current status is ${batch?.status || "unknown"}`);
303
- }
304
- const filename = suggestedDownloadFilename(batch);
305
- const destination = await resolveLocalDestination(local_path, filename, Boolean(overwrite));
306
- const saved = await apiDownloadToFile(`/api/mcp/file-transfer-batches/${batch_id}/download`, destination, Boolean(overwrite));
307
- return {
308
- status: "saved",
309
- batch_id,
310
- local_path: saved.path,
311
- file_name: path.basename(saved.path),
312
- bytes_written: saved.bytes,
313
- 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.",
314
- };
315
- });
316
- }
317
- );
318
-
319
- server.tool(
320
- "upload_files",
321
- "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.",
322
- {
323
- server_id: z.number().int().positive().describe("Server id from list_servers."),
324
- local_paths: z.array(z.string().min(1)).min(1).max(100).describe("Local file paths to upload sequentially."),
325
- remote_dir: z.string().min(1).describe("Absolute remote directory where files should be uploaded."),
326
- overwrite: z.boolean().optional().describe("Whether to overwrite existing remote files. Defaults to false."),
327
- },
328
- async ({ server_id, local_paths, remote_dir, overwrite }) => {
329
- return jsonToolResult(async () => {
330
- const files = await resolveUploadFiles(local_paths);
331
- const batch = await apiPostMultipart("/api/mcp/file-transfers/upload-batch", {
332
- server_id: String(server_id),
333
- remote_dir,
334
- overwrite: overwrite ? "true" : "false",
335
- }, files);
336
- return batch;
337
- });
338
- }
339
- );
340
-
341
- server.tool(
342
- "pause_file_transfer_batch",
343
- "Pause an active file transfer queue started or visible through AIPermission. Requires always_run permission for that server.",
344
- {
345
- batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
346
- },
347
- async ({ batch_id }) => {
348
- return jsonToolResult(() => apiPost(`/api/mcp/file-transfer-batches/${batch_id}/pause`, {}));
349
- }
350
- );
351
-
352
- server.tool(
353
- "resume_file_transfer_batch",
354
- "Resume a paused file transfer queue. Requires always_run permission for that server.",
355
- {
356
- batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
357
- },
358
- async ({ batch_id }) => {
359
- return jsonToolResult(() => apiPost(`/api/mcp/file-transfer-batches/${batch_id}/resume`, {}));
360
- }
361
- );
362
-
363
- server.tool(
364
- "cancel_file_transfer_batch",
365
- "Cancel a pending, running, or paused file transfer queue. Requires always_run permission for that server.",
83
+ "get_connector_action_request",
84
+ "Read one connector action request by id. Use this after call_connector_action returns approval_pending or running.",
366
85
  {
367
- batch_id: z.number().int().positive().describe("Batch id from list_file_transfer_batches or start_file_download."),
86
+ request_id: z.number().int().positive().describe("Request id returned by call_connector_action."),
368
87
  },
369
- async ({ batch_id }) => {
370
- return jsonToolResult(() => apiPost(`/api/mcp/file-transfer-batches/${batch_id}/cancel`, {}));
88
+ async ({ request_id }) => {
89
+ return jsonToolResult(() => apiGet(`/api/mcp/connector-action-requests/${request_id}`));
371
90
  }
372
91
  );
373
92
 
@@ -390,48 +109,12 @@ async function apiPost(path, body) {
390
109
  });
391
110
  }
392
111
 
393
- async function apiPostMultipart(path, fields, files) {
394
- const boundary = `aipermission-${Date.now()}-${Math.random().toString(16).slice(2)}`;
395
- return apiRequest(path, {
396
- method: "POST",
397
- headers: {
398
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
399
- },
400
- body: Readable.from(multipartBody(boundary, fields, files)),
401
- duplex: "half",
402
- timeoutMs: apiTransferTimeoutMs,
403
- });
404
- }
405
-
406
- async function apiDownloadToFile(pathValue, destination, overwrite) {
407
- const response = await apiFetch(pathValue, {
408
- method: "GET",
409
- timeoutMs: apiTransferTimeoutMs,
410
- });
411
- if (!response.ok) {
412
- const text = await response.text();
413
- const data = parseResponseBody(text);
414
- throw new Error(data?.error || `aipermission API request failed with ${response.status}`);
415
- }
416
- let bytes = 0;
417
- const output = fs.createWriteStream(destination, {
418
- flags: overwrite ? "w" : "wx",
419
- mode: 0o600,
420
- });
421
- output.on("bytesWritten", (value) => {
422
- bytes = value;
423
- });
424
- await pipeline(Readable.fromWeb(response.body), output);
425
- const stat = await fsp.stat(destination);
426
- return { path: destination, bytes: stat.size || bytes };
427
- }
428
-
429
112
  async function apiRequest(path, options) {
430
113
  const response = await apiFetch(path, options);
431
114
  const text = await response.text();
432
115
  const data = parseResponseBody(text);
433
116
  if (!response.ok) {
434
- throw new Error(data?.error || `aipermission API request failed with ${response.status}`);
117
+ throw new Error(data?.error || `AIPermission API request failed with ${response.status}`);
435
118
  }
436
119
  return data;
437
120
  }
@@ -440,24 +123,22 @@ async function apiFetch(path, options) {
440
123
  if (!apiToken) {
441
124
  throw new Error("AIPERMISSION_API_TOKEN is required.");
442
125
  }
443
- const timeoutValue = options.timeoutMs || apiTimeoutMs;
444
- const timeout = Number.isFinite(timeoutValue) && timeoutValue > 0 ? timeoutValue : 60000;
126
+ const timeout = Number.isFinite(apiTimeoutMs) && apiTimeoutMs > 0 ? apiTimeoutMs : 60000;
445
127
  const controller = new AbortController();
446
128
  const timer = setTimeout(() => controller.abort(), timeout);
447
- const { timeoutMs: _timeoutMs, ...requestOptions } = options;
448
129
  let response;
449
130
  try {
450
131
  response = await fetch(`${apiUrl}${path}`, {
451
- ...requestOptions,
132
+ ...options,
452
133
  signal: controller.signal,
453
134
  headers: {
454
135
  Authorization: `Bearer ${apiToken}`,
455
- ...(requestOptions.headers || {}),
136
+ ...(options.headers || {}),
456
137
  },
457
138
  });
458
139
  } catch (error) {
459
140
  if (error?.name === "AbortError") {
460
- throw new Error(`aipermission API request timed out after ${timeout}ms`);
141
+ throw new Error(`AIPermission API request timed out after ${timeout}ms`);
461
142
  }
462
143
  throw error;
463
144
  } finally {
@@ -473,91 +154,6 @@ function parseResponseBody(text) {
473
154
  try {
474
155
  return JSON.parse(text);
475
156
  } catch {
476
- return { error: text.trim() || "Invalid non-JSON response from aipermission gateway." };
477
- }
478
- }
479
-
480
- async function resolveUploadFiles(localPaths) {
481
- const files = [];
482
- const seen = new Set();
483
- for (const rawPath of localPaths) {
484
- const resolved = expandHome(rawPath);
485
- if (seen.has(resolved)) {
486
- throw new Error(`duplicate local path: ${resolved}`);
487
- }
488
- seen.add(resolved);
489
- const stat = await fsp.stat(resolved).catch((error) => {
490
- throw new Error(`cannot read local file ${resolved}: ${error.message}`);
491
- });
492
- if (!stat.isFile()) {
493
- throw new Error(`local path is not a regular file: ${resolved}`);
494
- }
495
- files.push({
496
- field: "files",
497
- path: resolved,
498
- name: path.basename(resolved),
499
- size: stat.size,
500
- });
501
- }
502
- return files;
503
- }
504
-
505
- async function resolveLocalDestination(localPath, suggestedName, overwrite) {
506
- const resolved = expandHome(localPath);
507
- const existing = await fsp.stat(resolved).catch((error) => {
508
- if (error?.code === "ENOENT") return null;
509
- throw error;
510
- });
511
- if (existing?.isDirectory()) {
512
- return resolveLocalDestination(path.join(resolved, suggestedName), suggestedName, overwrite);
513
- }
514
- if (existing && !overwrite) {
515
- throw new Error(`local file already exists: ${resolved}`);
157
+ return { error: text };
516
158
  }
517
- const parent = path.dirname(resolved);
518
- const parentStat = await fsp.stat(parent).catch((error) => {
519
- throw new Error(`local directory does not exist: ${parent}: ${error.message}`);
520
- });
521
- if (!parentStat.isDirectory()) {
522
- throw new Error(`local parent path is not a directory: ${parent}`);
523
- }
524
- return resolved;
525
- }
526
-
527
- function suggestedDownloadFilename(batch) {
528
- if (Array.isArray(batch?.items) && batch.items.length === 1 && batch.items[0]?.file_name) {
529
- return safeLocalFilename(batch.items[0].file_name);
530
- }
531
- if (batch?.archive_name) {
532
- return safeLocalFilename(batch.archive_name);
533
- }
534
- return `aipermission-download-${batch?.id || Date.now()}.zip`;
535
- }
536
-
537
- function safeLocalFilename(value) {
538
- const base = path.basename(String(value || "").replaceAll("\0", ""));
539
- return base || "aipermission-download";
540
- }
541
-
542
- function expandHome(value) {
543
- const text = String(value || "").trim();
544
- if (text === "~") return os.homedir();
545
- if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
546
- return path.resolve(text);
547
- }
548
-
549
- async function* multipartBody(boundary, fields, files) {
550
- for (const [name, value] of Object.entries(fields)) {
551
- yield Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartName(name)}"\r\n\r\n${String(value)}\r\n`);
552
- }
553
- for (const file of files) {
554
- 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`);
555
- yield* fs.createReadStream(file.path);
556
- yield Buffer.from("\r\n");
557
- }
558
- yield Buffer.from(`--${boundary}--\r\n`);
559
- }
560
-
561
- function escapeMultipartName(value) {
562
- return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
563
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipermission/mcp",
3
- "version": "0.1.14",
3
+ "version": "0.2.0",
4
4
  "mcpName": "io.github.aipermission/aipermission-mcp",
5
5
  "description": "Local-first MCP bridge for the aipermission gateway.",
6
6
  "license": "AGPL-3.0-only",
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.14",
6
+ "version": "0.2.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@aipermission/mcp",
11
- "version": "0.1.14",
11
+ "version": "0.2.0",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  }