@aipermission/mcp 0.1.14 → 0.2.1
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 +43 -38
- package/dist/cli.js +2 -3
- package/dist/init.js +3 -4
- package/dist/resources/aipermission-operator/SKILL.md +64 -200
- package/dist/server.js +41 -445
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
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
|
|
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
|

|
|
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
|
-
- `
|
|
53
|
-
- `
|
|
54
|
-
- `
|
|
55
|
-
- `
|
|
56
|
-
- `
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
10
|
+
Use AIPermission as a local, developer-controlled permission gateway.
|
|
11
11
|
|
|
12
|
-
You are allowed to operate only the
|
|
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
|
|
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
|
-
##
|
|
20
|
+
## Discovery
|
|
17
21
|
|
|
18
|
-
Before
|
|
22
|
+
Before acting:
|
|
19
23
|
|
|
20
|
-
1. Call `
|
|
21
|
-
2.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
If `expires_at
|
|
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
|
-
|
|
31
|
-
may still be powered off, unreachable, reject
|
|
32
|
-
key review.
|
|
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
|
-
##
|
|
39
|
+
## Reasons
|
|
36
40
|
|
|
37
|
-
Every
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
104
|
-
If the request is `stale`, the approval context changed before
|
|
105
|
-
|
|
106
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
-
##
|
|
100
|
+
## SSH Practice
|
|
145
101
|
|
|
146
|
-
|
|
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
|
-
|
|
142
|
+
## File Transfer
|
|
212
143
|
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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,
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
23
|
+
version: packageMetadata.version,
|
|
29
24
|
});
|
|
30
25
|
|
|
31
26
|
server.tool(
|
|
32
|
-
"
|
|
33
|
-
"List
|
|
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/
|
|
31
|
+
return jsonToolResult(() => apiGet("/api/mcp/connector-targets"));
|
|
37
32
|
}
|
|
38
33
|
);
|
|
39
34
|
|
|
40
35
|
server.tool(
|
|
41
|
-
"
|
|
42
|
-
"
|
|
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
|
-
|
|
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 ({
|
|
41
|
+
async ({ target_ref }) => {
|
|
50
42
|
return jsonToolResult(() => {
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
"
|
|
78
|
-
"
|
|
50
|
+
"get_connector_actions",
|
|
51
|
+
"List actions exposed by one connector target/profile. Action execution is still checked against token permissions.",
|
|
79
52
|
{
|
|
80
|
-
|
|
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 ({
|
|
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
|
-
|
|
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
|
-
"
|
|
170
|
-
"
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 ({
|
|
177
|
-
return jsonToolResult(() => apiPost("/api/mcp/
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
"
|
|
187
|
-
"
|
|
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
|
-
|
|
86
|
+
request_id: z.number().int().positive().describe("Request id returned by call_connector_action."),
|
|
368
87
|
},
|
|
369
|
-
async ({
|
|
370
|
-
return jsonToolResult(() =>
|
|
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 || `
|
|
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
|
|
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
|
-
...
|
|
132
|
+
...options,
|
|
452
133
|
signal: controller.signal,
|
|
453
134
|
headers: {
|
|
454
135
|
Authorization: `Bearer ${apiToken}`,
|
|
455
|
-
...(
|
|
136
|
+
...(options.headers || {}),
|
|
456
137
|
},
|
|
457
138
|
});
|
|
458
139
|
} catch (error) {
|
|
459
140
|
if (error?.name === "AbortError") {
|
|
460
|
-
throw new Error(`
|
|
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
|
|
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
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.2.1",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@aipermission/mcp",
|
|
11
|
-
"version": "0.1
|
|
11
|
+
"version": "0.2.1",
|
|
12
12
|
"transport": {
|
|
13
13
|
"type": "stdio"
|
|
14
14
|
}
|