@veertu/anka-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/dist/anka.d.ts +41 -0
  4. package/dist/anka.js +65 -0
  5. package/dist/anka.js.map +1 -0
  6. package/dist/auth.d.ts +10 -0
  7. package/dist/auth.js +43 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/config.d.ts +69 -0
  10. package/dist/config.js +98 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/controller.d.ts +73 -0
  13. package/dist/controller.js +125 -0
  14. package/dist/controller.js.map +1 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +7 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/log.d.ts +107 -0
  19. package/dist/log.js +254 -0
  20. package/dist/log.js.map +1 -0
  21. package/dist/security/host.d.ts +2 -0
  22. package/dist/security/host.js +9 -0
  23. package/dist/security/host.js.map +1 -0
  24. package/dist/security/rate-limit.d.ts +18 -0
  25. package/dist/security/rate-limit.js +71 -0
  26. package/dist/security/rate-limit.js.map +1 -0
  27. package/dist/security/sanitize.d.ts +6 -0
  28. package/dist/security/sanitize.js +33 -0
  29. package/dist/security/sanitize.js.map +1 -0
  30. package/dist/security/schemas.d.ts +9 -0
  31. package/dist/security/schemas.js +20 -0
  32. package/dist/security/schemas.js.map +1 -0
  33. package/dist/server.d.ts +3 -0
  34. package/dist/server.js +13 -0
  35. package/dist/server.js.map +1 -0
  36. package/dist/ssh-key.d.ts +23 -0
  37. package/dist/ssh-key.js +73 -0
  38. package/dist/ssh-key.js.map +1 -0
  39. package/dist/tokens/cleanup.d.ts +10 -0
  40. package/dist/tokens/cleanup.js +28 -0
  41. package/dist/tokens/cleanup.js.map +1 -0
  42. package/dist/tokens/ownership.d.ts +6 -0
  43. package/dist/tokens/ownership.js +31 -0
  44. package/dist/tokens/ownership.js.map +1 -0
  45. package/dist/tokens/schema.d.ts +3 -0
  46. package/dist/tokens/schema.js +35 -0
  47. package/dist/tokens/schema.js.map +1 -0
  48. package/dist/tokens/store.d.ts +45 -0
  49. package/dist/tokens/store.js +145 -0
  50. package/dist/tokens/store.js.map +1 -0
  51. package/dist/tools/controller/get-vm.d.ts +3 -0
  52. package/dist/tools/controller/get-vm.js +34 -0
  53. package/dist/tools/controller/get-vm.js.map +1 -0
  54. package/dist/tools/controller/index.d.ts +3 -0
  55. package/dist/tools/controller/index.js +12 -0
  56. package/dist/tools/controller/index.js.map +1 -0
  57. package/dist/tools/controller/list-templates.d.ts +1 -0
  58. package/dist/tools/controller/list-templates.js +21 -0
  59. package/dist/tools/controller/list-templates.js.map +1 -0
  60. package/dist/tools/controller/request-vm.d.ts +8 -0
  61. package/dist/tools/controller/request-vm.js +101 -0
  62. package/dist/tools/controller/request-vm.js.map +1 -0
  63. package/dist/tools/controller/results.d.ts +5 -0
  64. package/dist/tools/controller/results.js +23 -0
  65. package/dist/tools/controller/results.js.map +1 -0
  66. package/dist/tools/controller/terminate-vm.d.ts +3 -0
  67. package/dist/tools/controller/terminate-vm.js +32 -0
  68. package/dist/tools/controller/terminate-vm.js.map +1 -0
  69. package/dist/tools/define-tool.d.ts +34 -0
  70. package/dist/tools/define-tool.js +51 -0
  71. package/dist/tools/define-tool.js.map +1 -0
  72. package/dist/tools/index.d.ts +8 -0
  73. package/dist/tools/index.js +20 -0
  74. package/dist/tools/index.js.map +1 -0
  75. package/dist/tools/local/delete-vm.d.ts +3 -0
  76. package/dist/tools/local/delete-vm.js +20 -0
  77. package/dist/tools/local/delete-vm.js.map +1 -0
  78. package/dist/tools/local/index.d.ts +3 -0
  79. package/dist/tools/local/index.js +14 -0
  80. package/dist/tools/local/index.js.map +1 -0
  81. package/dist/tools/local/list-templates.d.ts +1 -0
  82. package/dist/tools/local/list-templates.js +17 -0
  83. package/dist/tools/local/list-templates.js.map +1 -0
  84. package/dist/tools/local/show-vm.d.ts +3 -0
  85. package/dist/tools/local/show-vm.js +23 -0
  86. package/dist/tools/local/show-vm.js.map +1 -0
  87. package/dist/tools/local/ssh-access.d.ts +3 -0
  88. package/dist/tools/local/ssh-access.js +68 -0
  89. package/dist/tools/local/ssh-access.js.map +1 -0
  90. package/dist/tools/local/start-vm.d.ts +7 -0
  91. package/dist/tools/local/start-vm.js +52 -0
  92. package/dist/tools/local/start-vm.js.map +1 -0
  93. package/dist/tools/local/vms.d.ts +62 -0
  94. package/dist/tools/local/vms.js +91 -0
  95. package/dist/tools/local/vms.js.map +1 -0
  96. package/dist/transports/admin.d.ts +3 -0
  97. package/dist/transports/admin.js +74 -0
  98. package/dist/transports/admin.js.map +1 -0
  99. package/dist/transports/http.d.ts +14 -0
  100. package/dist/transports/http.js +283 -0
  101. package/dist/transports/http.js.map +1 -0
  102. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Veertu Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # anka-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for [Anka](https://veertu.com/) macOS virtualization. It runs as a **streamable HTTP server** with bearer-token auth and exposes two curated, purpose-built tool sets that auto-enable based on configuration:
4
+
5
+ - **Controller backend** - talks to the [Anka Build Cloud Controller](https://docs.veertu.com/anka/anka-build-cloud/working-with-controller-and-api/) REST API to request a VM from a fleet and hand back SSH connection details. No `anka` CLI is used.
6
+ - **Local backend** - drives the local `anka` CLI with a small, safe set of lifecycle commands, guarded by a running-VM limit.
7
+
8
+ There is intentionally no generic "run any anka command" tool.
9
+
10
+ ## Backends and when they enable
11
+
12
+ | Backend | Enabled when | Tools |
13
+ | ---------- | --------------------------------------------------------- | ----- |
14
+ | Controller | `ANKA_CONTROLLER_URL` is set | `controller_list_templates`, `controller_request_vm`, `controller_get_vm`, `controller_terminate_vm` |
15
+ | Local | `ANKA_LOCAL=on`, or `auto` when no controller is configured (detects the `anka` CLI) | `local_list_templates`, `local_start_vm`, `local_show_vm`, `local_ssh_access`, `local_delete_vm` |
16
+
17
+ When `ANKA_CONTROLLER_URL` is set, the local backend defaults to **off** so only controller tools are exposed. Set `ANKA_LOCAL=on` or `ANKA_LOCAL=auto` to enable both. The server refuses to start if neither backend is enabled.
18
+
19
+ ## Requirements
20
+
21
+ - Node.js >= 18
22
+ - For the local backend: the `anka` CLI installed and on `PATH` (or point `ANKA_BIN` at it)
23
+ - For the controller backend: network access to an Anka Build Cloud Controller
24
+
25
+ ## Install and run
26
+
27
+ ### From npm
28
+
29
+ Requires Node.js >= 18.
30
+
31
+ ```bash
32
+ # run without a global install
33
+ npx @veertu/anka-mcp
34
+
35
+ # or install globally
36
+ npm install -g @veertu/anka-mcp
37
+ anka-mcp
38
+ ```
39
+
40
+ Set auth and backend env vars before starting (see [Configuration](#configuration)). Example:
41
+
42
+ ```bash
43
+ export MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
44
+ echo "MCP_AUTH_TOKEN=$MCP_AUTH_TOKEN"
45
+ anka-mcp
46
+ ```
47
+
48
+ ### From source (contributors)
49
+
50
+ ```bash
51
+ npm install
52
+ npm run build
53
+ export MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
54
+ echo "MCP_AUTH_TOKEN=$MCP_AUTH_TOKEN"
55
+ npm start
56
+ ```
57
+
58
+ For **multi-client** deployments, use the admin API instead of a single shared token:
59
+
60
+ ```bash
61
+ export MCP_ADMIN_TOKEN="$(openssl rand -hex 32)"
62
+ export ANKA_CONTROLLER_URL="http://your-controller:8090"
63
+ npm start
64
+
65
+ # Create a per-client MCP token (plaintext shown once):
66
+ curl -s -X POST "http://localhost:9111/admin/tokens" \
67
+ -H "Authorization: Bearer $MCP_ADMIN_TOKEN" \
68
+ -H "Content-Type: application/json" \
69
+ -d '{"label":"team-a"}'
70
+ ```
71
+
72
+ The endpoint is served at `http://<host>:<port>/mcp`. For local dev without auth (never expose beyond localhost):
73
+
74
+ ```bash
75
+ MCP_ALLOW_NO_AUTH=1 npm run dev
76
+ ```
77
+
78
+ ## Use-case 1: Controller fleet
79
+
80
+ The agent asks the MCP server for a VM; the server generates a temporary SSH key, passes it to the VM via the controller `startup_script` (with `startup_script_condition: 1` so the script runs immediately, before networking), waits until the controller reports the instance started and an SSH auth probe with that key succeeds over the forwarded port, then returns the host IP, forwarded SSH port, private key path, and a ready-to-use `ssh` command. The agent then SSHes in itself.
81
+
82
+ ```mermaid
83
+ flowchart LR
84
+ agent["AI agent"] -->|"controller_request_vm {vmid}"| mcp["anka-mcp"]
85
+ mcp -->|"POST /api/v1/vm"| ctl["Controller API"]
86
+ mcp -->|"poll GET /api/v1/vm?id="| ctl
87
+ mcp -->|"{host, port, username, private_key_path, command}"| agent
88
+ agent -->|"ssh -p port username@host"| vm["macOS VM"]
89
+ ```
90
+
91
+ The VM template must expose port forwarding for the SSH guest port (default `22`, e.g. a `port-forward-22` tag), or pass `addSshPortForward: true` to `controller_request_vm` to add a rule at start time.
92
+
93
+ ## Use-case 2: Local laptop
94
+
95
+ The agent manages VMs on the developer's own machine through a limited command set: list templates, start (which always clones the chosen template into a fresh VM so the original is never touched), show, prepare SSH access, delete. A running-VM limit (default 2) prevents exceeding the local Anka concurrency limit. The delete tool always requires a specific VM name and can never delete all VMs.
96
+
97
+ For SSH, `local_ssh_access` generates a throwaway ed25519 keypair on the host, copies the public key into the running VM with `anka cp`, installs it into the VM's `~/.ssh/authorized_keys` (via `anka run`), and returns the private key path plus a ready-to-use `ssh` command. The agent (on the same machine) then connects directly to the VM's shared-network IP. The VM template must have Remote Login (sshd) enabled.
98
+
99
+ ## Configuration
100
+
101
+ All configuration is via environment variables.
102
+
103
+ ### HTTP transport + auth
104
+
105
+ | Variable | Default | Description |
106
+ | --------------------- | --------- | --------------------------------------------------------------------- |
107
+ | `MCP_HTTP_PORT` | `9111` | Port the HTTP server listens on. |
108
+ | `MCP_HTTP_HOST` | `127.0.0.1` | Interface to bind to. Defaults to localhost; set `0.0.0.0` for remote access behind TLS. |
109
+ | `MCP_AUTH_TOKEN` | (none) | Legacy single bearer token for all MCP clients. Still supported. |
110
+ | `MCP_ADMIN_TOKEN` | (none) | Admin bearer token for `/admin/*` routes. Enables token management. |
111
+ | `MCP_DB_PATH` | `./anka-mcp.db` | SQLite database for client tokens and instance ownership. |
112
+ | `MCP_REVOKE_CLEANUP` | `on` | When a token is revoked, terminate its controller VMs (set `off` to skip). |
113
+ | `MCP_ALLOW_NO_AUTH` | `false` | Set to `1` to run unauthenticated (local dev only). |
114
+ | `MCP_ALLOWED_ORIGINS` | (none) | Comma-separated Origin allow-list for DNS-rebinding protection. |
115
+ | `MCP_LOG` | `on` | Request logging to stderr. Set to `off` (or `0`/`false`/`no`) to disable. |
116
+ | `MCP_AUDIT_LOG` | (none) | Optional append-only audit log file (duplicates stderr log lines). |
117
+ | `MCP_MAX_BODY_BYTES` | `1048576` | Max JSON request body size (1 MiB). |
118
+ | `MCP_RATE_LIMIT_RPM` | `120` | Max requests per client IP per minute (`0` = disabled). |
119
+ | `MCP_SESSION_IDLE_MS` | `3600000` | Idle MCP session eviction threshold (1 hour). |
120
+ | `MCP_MAX_SESSIONS` | `50` | Max concurrent MCP sessions. |
121
+ | `MCP_MAX_RESPONSE_CHARS` | `32768` | Max serialized tool response size (32 KiB). |
122
+
123
+ When logging is enabled, each MCP request is written to stderr with the client source (IP and user-agent), JSON-RPC method, tool name and arguments, tool response (with passwords and private keys redacted), and any underlying `anka` or controller API calls. Example:
124
+
125
+ ```
126
+ anka-mcp: 2026-06-22T16:52:15.021Z [127.0.0.1 (Cursor/1.x)] mcp tools/call {"tool":"local_list_templates","args":{}}
127
+ anka-mcp: 2026-06-22T16:52:15.059Z [127.0.0.1 (Cursor/1.x)] anka anka -j list -> ok
128
+ anka-mcp: 2026-06-22T16:52:15.059Z [127.0.0.1 (Cursor/1.x)] tool local_list_templates args={} -> {"vms":[...]}
129
+ ```
130
+
131
+ ### Controller backend
132
+
133
+ | Variable | Default | Description |
134
+ | --------------------------------- | -------- | ------------------------------------------------------------------ |
135
+ | `ANKA_CONTROLLER_URL` | (none) | Base URL, e.g. `http://anka.controller:8090`. Enables the backend. |
136
+ | `ANKA_CONTROLLER_AUTH` | (none) | Raw `Authorization` header value (root token / UAK / Basic). |
137
+ | `ANKA_CONTROLLER_TLS_INSECURE` | `false` | Set to `1` to skip TLS certificate verification. |
138
+ | `ANKA_CONTROLLER_POLL_INTERVAL_MS`| `3000` | Interval between instance-status polls. |
139
+ | `ANKA_CONTROLLER_START_TIMEOUT_MS`| `180000` | Max time to wait for a VM to become SSH-ready. |
140
+ | `ANKA_CONTROLLER_SSH_PROBE` | `on` | When enabled, `controller_request_vm` runs an SSH auth probe with the generated key before returning (set to `0` to skip). |
141
+
142
+ ### Local backend
143
+
144
+ | Variable | Default | Description |
145
+ | -------------------- | ------- | ------------------------------------------------------------ |
146
+ | `ANKA_LOCAL` | `auto`* | `auto` (detect the binary), `on`, or `off`. \*Defaults to `off` when `ANKA_CONTROLLER_URL` is set. |
147
+ | `ANKA_BIN` | `anka` | Path to (or name of) the anka binary. |
148
+ | `ANKA_TIMEOUT_MS` | `300000`| Max time a single anka invocation may run. |
149
+ | `ANKA_LOCAL_MAX_VMS` | `2` | Max running VMs allowed before start is refused. |
150
+ | `ANKA_LOCAL_POLL_INTERVAL_MS` | `2000` | Interval between status polls while waiting for a VM's IP. |
151
+ | `ANKA_LOCAL_IP_TIMEOUT_MS` | `120000` | Max time `local_start_vm`/`local_ssh_access` wait for an IP. |
152
+
153
+ ### SSH connection details (returned to the agent)
154
+
155
+ | Variable | Default | Description |
156
+ | --------------------- | ------- | ---------------------------------------------------- |
157
+ | `ANKA_VM_SSH_USER` | `anka` | Username returned for SSHing into a VM. |
158
+ | `ANKA_VM_SSH_PASSWORD`| `admin` | Password returned for SSHing into a VM. |
159
+ | `ANKA_VM_SSH_GUEST_PORT` | `22` | Guest port that maps to SSH (matched in port forwarding). |
160
+
161
+ Returned `ssh` commands include `-o IdentitiesOnly=yes` so a local ssh-agent does not offer other keys and cause auth failures. If you build your own command, use that flag or prefix with `SSH_AUTH_SOCK=` to disable the agent.
162
+
163
+ For production, terminate TLS in front of this server so the bearer token and any returned credentials are not sent in cleartext.
164
+
165
+ See [SECURITY.md](SECURITY.md) for the full operator security guide.
166
+
167
+ ### Remote deployment
168
+
169
+ By default the server binds to **localhost only** (`127.0.0.1`). To expose it on a network:
170
+
171
+ 1. Set `MCP_HTTP_HOST=0.0.0.0` (or a specific interface).
172
+ 2. Terminate **TLS** in a reverse proxy in front of anka-mcp.
173
+ 3. Firewall to trusted clients only.
174
+ 4. Issue **per-client tokens** via the admin API — avoid sharing `MCP_AUTH_TOKEN`.
175
+ 5. Never use `MCP_ALLOW_NO_AUTH` on non-localhost hosts.
176
+
177
+ The server prints a warning at startup when bound to a non-loopback address.
178
+
179
+ ### Multi-client tokens and VM isolation
180
+
181
+ When `MCP_ADMIN_TOKEN` is set, the server exposes an admin API to create and revoke per-client MCP bearer tokens. Tokens are stored in SQLite (`MCP_DB_PATH`); only hashed secrets are persisted.
182
+
183
+ | Method | Path | Auth | Purpose |
184
+ | -------- | -------------------- | ----------------------- | -------------------------------- |
185
+ | `POST` | `/admin/tokens` | `Bearer MCP_ADMIN_TOKEN` | Create a client token |
186
+ | `GET` | `/admin/tokens` | admin bearer | List tokens (no secrets) |
187
+ | `DELETE` | `/admin/tokens/:id` | admin bearer | Revoke token and clean up VMs |
188
+
189
+ Admin and MCP tokens are separate: the admin token never works on `/mcp`, and client tokens never work on `/admin/*`.
190
+
191
+ **Controller VM isolation:** each client token can only `controller_get_vm` / `controller_terminate_vm` instances it created via `controller_request_vm`. Revoking a token blocks MCP access immediately and, by default (`MCP_REVOKE_CLEANUP=on`), best-effort terminates all controller instances owned by that token. The revoke response includes `cleanup.terminated` and `cleanup.failed` arrays.
192
+
193
+ The legacy `MCP_AUTH_TOKEN` still works as a single shared client identity (`legacy`); all controller VMs created under it share one ownership bucket.
194
+
195
+ Back up `anka-mcp.db` for disaster recovery; it is created automatically on first start.
196
+
197
+ ## Tools
198
+
199
+ ### Controller
200
+
201
+ - `controller_list_templates` - list registry templates (`id`, `name`, `arch`) to find a `vmid`.
202
+ - `controller_request_vm` `{ vmid, tag?, name?, externalId?, addSshPortForward? }` - start one VM, install a temporary SSH key via the controller `startup_script`, wait until SSH auth succeeds over the forwarded port, return `{ instance_id, ssh: { host, port, username, private_key_path, command } }`. The controller `external_id` is auto-filled with MCP client, IP, user-agent, session, and credential id; pass `externalId` to append a custom `ref`.
203
+ - `controller_get_vm` `{ instance_id }` - current state and SSH details for an existing instance (must be owned by the caller's token).
204
+ - `controller_terminate_vm` `{ instance_id }` - terminate an instance (must be owned by the caller's token).
205
+
206
+ ### Local
207
+
208
+ - `local_list_templates` - list the local VM library to find a template to clone from.
209
+ - `local_start_vm` `{ template, name?, wait?, timeoutSeconds? }` - clone the given template into a fresh, disposable VM and start it. The original template is never started or modified. `name` defaults to an auto-generated name (`mcp-<id>`). Subject to the running-VM limit. By default waits for the new VM to boot and obtain an IP, then returns `{ ok, name, source, ip }`; pass `wait: false` to return immediately. Delete with `local_delete_vm` when done.
210
+ - `local_show_vm` `{ name }` - get a VM's IP address.
211
+ - `local_ssh_access` `{ name }` - install a temporary SSH key into a running VM (waiting for a pending IP if needed); returns `{ ip, port, user, private_key_path, command }`.
212
+ - `local_delete_vm` `{ name }` - delete one specific VM.
213
+
214
+ VM/template names that start with `-` are rejected so a name can never be reinterpreted as a CLI flag.
215
+
216
+ ## Connecting a client
217
+
218
+ Point any MCP client that supports streamable HTTP at `http://<host>:<port>/mcp` with the bearer token. For example, Cursor's `mcp.json`:
219
+
220
+ ```json
221
+ {
222
+ "mcpServers": {
223
+ "anka": {
224
+ "url": "http://your-host:9111/mcp",
225
+ "headers": {
226
+ "Authorization": "Bearer YOUR_TOKEN"
227
+ }
228
+ }
229
+ }
230
+ }
231
+ ```
232
+
233
+ ## Publishing (maintainers)
234
+
235
+ Releases are published to npm by the [Publish](.github/workflows/publish.yml) workflow when a `v*` tag is pushed. Auth uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) — no long-lived npm tokens in GitHub secrets.
236
+
237
+ ### First release (bootstrap)
238
+
239
+ Trusted publishing only works once `@veertu/anka-mcp` **already exists** on npm. The first version must be published once with token or interactive auth that satisfies the `@veertu` org's 2FA policy.
240
+
241
+ **Option A — interactive login (simplest):**
242
+
243
+ ```bash
244
+ npm login # use an account with publish access to @veertu; complete 2FA when prompted
245
+ npm ci
246
+ npm run build
247
+ npm test
248
+ npm publish --access public
249
+ ```
250
+
251
+ **Option B — granular access token (for CI-style bootstrap):**
252
+
253
+ 1. On [npmjs.com](https://www.npmjs.com) → **Access Tokens** → **Generate New Token** → **Granular Access Token**
254
+ 2. Permissions: **Read and write** for the `@veertu` scope (or this package)
255
+ 3. Enable **Bypass two-factor authentication for automation** (required when the org mandates 2FA for publish)
256
+ 4. Publish:
257
+
258
+ ```bash
259
+ export NODE_AUTH_TOKEN="npm_..."
260
+ npm ci && npm run build && npm test
261
+ npm publish --access public
262
+ ```
263
+
264
+ If you see `403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required`, your current login/token does not meet the org policy — use one of the options above.
265
+
266
+ Then on [npmjs.com](https://www.npmjs.com/package/@veertu/anka-mcp) → **Settings** → **Trusted publishing**, add a GitHub Actions publisher:
267
+
268
+ | Field | Value |
269
+ | ----- | ----- |
270
+ | Organization or user | `veertuinc` |
271
+ | Repository | `anka-mcp` |
272
+ | Workflow filename | `publish.yml` |
273
+ | Allowed actions | `npm publish` |
274
+
275
+ After the package exists and the trusted publisher is configured, push tags (e.g. `v0.1.1`) and CI publishes via OIDC. Provenance is generated automatically.
276
+
277
+ If publish fails with `404 Not Found` on PUT, check: (1) package bootstrapped on npm, (2) trusted publisher fields match exactly, (3) workflow uses Node 24+ (npm 11.5.1+).
278
+
279
+ ## Testing
280
+
281
+ ```bash
282
+ npm test # run the suite once
283
+ npm run test:watch # watch mode
284
+ ```
285
+
286
+ The suite (Vitest) is hermetic - it needs neither a real Anka install nor a controller:
287
+
288
+ - Unit tests cover config parsing, token store, the controller client + `extractSsh`/`isSshReady` (against an in-process mock controller), and input validation / output shaping.
289
+ - End-to-end tests spawn the real server over HTTP and exercise tools through the MCP protocol, using a fake `anka` binary ([test/fixtures/fake-anka.mjs](test/fixtures/fake-anka.mjs)) and a mock controller ([test/helpers/controllerMock.ts](test/helpers/controllerMock.ts)). They verify auth, admin token API, per-token controller VM isolation, revoke cleanup, backend gating, per-backend tool exposure, the controller request->SSH flow, the local 2-VM guard, flag-injection rejection, and the SSH key-injection flow.
290
+
291
+ ## Adding new tools
292
+
293
+ 1. Create a file under `src/tools/controller/` or `src/tools/local/` exporting a tool via `defineTool({ name, config, handler })`.
294
+ 2. Add it to the array in that backend's `index.ts` (`controllerTools` or `localTools`). It is then auto-registered whenever that backend is enabled.
295
+
296
+ ## Project layout
297
+
298
+ ```
299
+ src/
300
+ index.ts # entry: starts the HTTP server
301
+ auth.ts # MCP bearer token resolution
302
+ config.ts # env-driven config + backend detection
303
+ anka.ts # runAnka(): execFile wrapper + JSON-envelope parsing
304
+ controller.ts # Anka Build Cloud Controller API client
305
+ server.ts # createServer(): McpServer + registerTools
306
+ tokens/
307
+ store.ts # SQLite token + instance ownership store
308
+ schema.ts # DB migrations
309
+ ownership.ts # controller instance access helpers
310
+ cleanup.ts # revoke-time controller VM termination
311
+ tools/
312
+ define-tool.ts # defineTool helper + jsonResult
313
+ index.ts # registers enabled backends' tools
314
+ controller/ # controller_* tools
315
+ local/ # local_* tools (+ vms.ts: list/count/guard/name schema)
316
+ transports/
317
+ http.ts # streamable HTTP transport + auth/origin middleware
318
+ admin.ts # /admin/tokens routes
319
+ test/
320
+ fixtures/fake-anka.mjs # fake anka CLI for hermetic local-backend tests
321
+ helpers/ # mock controller + MCP-over-HTTP client
322
+ unit/ # config, controller client, validation/shaping
323
+ e2e/ # full server over HTTP (auth, controller, local)
324
+ ```
package/dist/anka.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The JSON envelope every `anka -j <command>` call returns, e.g.
3
+ * `{"status":"OK","body":[...]}` or `{"status":"ERROR","message":"..."}`.
4
+ */
5
+ export interface AnkaEnvelope {
6
+ status: "OK" | "ERROR" | string;
7
+ body?: unknown;
8
+ message?: string;
9
+ code?: number;
10
+ exception_type?: string;
11
+ }
12
+ export interface RunAnkaOptions {
13
+ /** Prepend `-j` so anka emits its JSON envelope and we parse it. Default true. */
14
+ machineReadable?: boolean;
15
+ /** Override the per-call timeout in ms. */
16
+ timeoutMs?: number;
17
+ }
18
+ /** Normalized result returned by {@link runAnka}. */
19
+ export interface AnkaResult {
20
+ /** True when the process exited 0 and (if parsed) the envelope status was OK. */
21
+ ok: boolean;
22
+ /** Parsed envelope status, when machine-readable output was parsed. */
23
+ status?: string;
24
+ /** Parsed `body` field from the envelope, when present. */
25
+ body?: unknown;
26
+ /** Parsed `message` field from the envelope, when present. */
27
+ message?: string;
28
+ /** Process exit code (0 on success). */
29
+ exitCode: number;
30
+ /** The exact argument vector passed to the anka binary. */
31
+ args: string[];
32
+ /** Raw stdout. */
33
+ stdout: string;
34
+ /** Raw stderr. */
35
+ stderr: string;
36
+ }
37
+ /**
38
+ * Run the anka CLI with the given argument vector. Uses execFile (no shell) so
39
+ * arguments are passed verbatim and are not subject to shell injection.
40
+ */
41
+ export declare function runAnka(args: string[], options?: RunAnkaOptions): Promise<AnkaResult>;
package/dist/anka.js ADDED
@@ -0,0 +1,65 @@
1
+ import { execFile } from "node:child_process";
2
+ import { config } from "./config.js";
3
+ import { logAnkaCommand } from "./log.js";
4
+ function isExecFailure(error) {
5
+ return typeof error === "object" && error !== null && "message" in error;
6
+ }
7
+ function tryParseEnvelope(stdout) {
8
+ const trimmed = stdout.trim();
9
+ if (!trimmed)
10
+ return undefined;
11
+ try {
12
+ const parsed = JSON.parse(trimmed);
13
+ if (parsed && typeof parsed === "object")
14
+ return parsed;
15
+ }
16
+ catch {
17
+ // Not JSON (e.g. a non machine-readable command); leave undefined.
18
+ }
19
+ return undefined;
20
+ }
21
+ /**
22
+ * Run the anka CLI with the given argument vector. Uses execFile (no shell) so
23
+ * arguments are passed verbatim and are not subject to shell injection.
24
+ */
25
+ export function runAnka(args, options = {}) {
26
+ const { machineReadable = true, timeoutMs = config.ankaTimeoutMs } = options;
27
+ const finalArgs = machineReadable ? ["-j", ...args] : [...args];
28
+ return new Promise((resolve) => {
29
+ execFile(config.ankaBin, finalArgs, { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
30
+ const envelope = machineReadable ? tryParseEnvelope(stdout) : undefined;
31
+ if (error) {
32
+ const failure = isExecFailure(error)
33
+ ? error
34
+ : { message: String(error) };
35
+ const result = {
36
+ ok: false,
37
+ status: envelope?.status,
38
+ body: envelope?.body,
39
+ message: envelope?.message ?? failure.message,
40
+ exitCode: typeof failure.code === "number" ? failure.code : 1,
41
+ args: finalArgs,
42
+ stdout,
43
+ stderr
44
+ };
45
+ logAnkaCommand(finalArgs, { ok: false, message: result.message });
46
+ resolve(result);
47
+ return;
48
+ }
49
+ const status = envelope?.status;
50
+ const ok = status ? status === "OK" : true;
51
+ logAnkaCommand(finalArgs, { ok, message: envelope?.message });
52
+ resolve({
53
+ ok,
54
+ status,
55
+ body: envelope?.body,
56
+ message: envelope?.message,
57
+ exitCode: 0,
58
+ args: finalArgs,
59
+ stdout,
60
+ stderr
61
+ });
62
+ });
63
+ });
64
+ }
65
+ //# sourceMappingURL=anka.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anka.js","sourceRoot":"","sources":["../src/anka.ts"],"names":[],"mappings":"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"}
package/dist/auth.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface ResolvedMcpCredential {
2
+ credentialId: string;
3
+ credentialLabel?: string;
4
+ }
5
+ /** Constant-time string comparison that tolerates differing lengths. */
6
+ export declare function safeEqual(a: string, b: string): boolean;
7
+ /** Resolve a bearer token to an MCP client credential identity, or null when invalid. */
8
+ export declare function resolveMcpCredential(bearerToken: string): ResolvedMcpCredential | null;
9
+ /** Whether the server has any configured MCP client authentication path. */
10
+ export declare function isMcpAuthConfigured(): boolean;
package/dist/auth.js ADDED
@@ -0,0 +1,43 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { config } from "./config.js";
3
+ import { getTokenStore, LEGACY_CREDENTIAL_ID } from "./tokens/store.js";
4
+ /** Constant-time string comparison that tolerates differing lengths. */
5
+ export function safeEqual(a, b) {
6
+ const bufA = Buffer.from(a);
7
+ const bufB = Buffer.from(b);
8
+ if (bufA.length !== bufB.length)
9
+ return false;
10
+ return timingSafeEqual(bufA, bufB);
11
+ }
12
+ /** Resolve a bearer token to an MCP client credential identity, or null when invalid. */
13
+ export function resolveMcpCredential(bearerToken) {
14
+ if (config.allowNoAuth) {
15
+ return { credentialId: "anonymous" };
16
+ }
17
+ if (!bearerToken)
18
+ return null;
19
+ if (config.authToken && safeEqual(bearerToken, config.authToken)) {
20
+ return { credentialId: LEGACY_CREDENTIAL_ID, credentialLabel: "legacy" };
21
+ }
22
+ const validated = getTokenStore().validateToken(bearerToken);
23
+ if (validated) {
24
+ return { credentialId: validated.id, credentialLabel: validated.label || undefined };
25
+ }
26
+ return null;
27
+ }
28
+ /** Whether the server has any configured MCP client authentication path. */
29
+ export function isMcpAuthConfigured() {
30
+ if (config.allowNoAuth)
31
+ return true;
32
+ if (config.authToken)
33
+ return true;
34
+ if (config.adminToken)
35
+ return true;
36
+ try {
37
+ return getTokenStore().hasActiveTokens();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAOxE,wEAAwE;AACxE,MAAM,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC9C,OAAO,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;AACrC,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,oBAAoB,CAAC,WAAmB;IACtD,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QACvB,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,CAAC;IACvC,CAAC;IAED,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAC;IAE9B,IAAI,MAAM,CAAC,SAAS,IAAI,SAAS,CAAC,WAAW,EAAE,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;QACjE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,QAAQ,EAAE,CAAC;IAC3E,CAAC;IAED,MAAM,SAAS,GAAG,aAAa,EAAE,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC;IAC7D,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,EAAE,eAAe,EAAE,SAAS,CAAC,KAAK,IAAI,SAAS,EAAE,CAAC;IACvF,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,mBAAmB;IACjC,IAAI,MAAM,CAAC,WAAW;QAAE,OAAO,IAAI,CAAC;IACpC,IAAI,MAAM,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IAClC,IAAI,MAAM,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC;QACH,OAAO,aAAa,EAAE,CAAC,eAAe,EAAE,CAAC;IAC3C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
@@ -0,0 +1,69 @@
1
+ export interface AnkaMcpConfig {
2
+ /** Port the HTTP server listens on. */
3
+ httpPort: number;
4
+ /** Host/interface the HTTP server binds to. */
5
+ httpHost: string;
6
+ /** Bearer token clients must present. Empty only when auth is explicitly disabled. */
7
+ authToken: string;
8
+ /** Admin bearer token for /admin/* routes. Empty disables the admin API. */
9
+ adminToken: string;
10
+ /** SQLite database path for client tokens and instance ownership. */
11
+ dbPath: string;
12
+ /** When true, revoking a token terminates its owned controller instances. */
13
+ revokeCleanupEnabled: boolean;
14
+ /** When true, the server runs without authentication (local dev only). */
15
+ allowNoAuth: boolean;
16
+ /** Allowed Origin header values for DNS-rebinding protection. Empty = unrestricted. */
17
+ allowedOrigins: string[];
18
+ /** When false, request/tool/backend logging to stderr is suppressed. */
19
+ logEnabled: boolean;
20
+ /** Optional append-only audit log file path. Empty = stderr only. */
21
+ auditLogPath: string;
22
+ /** Max JSON request body size in bytes. */
23
+ maxBodyBytes: number;
24
+ /** Max requests per client IP per minute (0 = disabled). */
25
+ rateLimitRpm: number;
26
+ /** Idle MCP session eviction threshold in ms. */
27
+ sessionIdleMs: number;
28
+ /** Max concurrent MCP sessions. */
29
+ maxSessions: number;
30
+ /** Max serialized tool response size in characters. */
31
+ maxResponseChars: number;
32
+ /** True when the local anka CLI tool set should be exposed. */
33
+ localEnabled: boolean;
34
+ /** Path to (or name of) the anka CLI binary. */
35
+ ankaBin: string;
36
+ /** Max time in ms a single anka invocation may run before being killed. */
37
+ ankaTimeoutMs: number;
38
+ /** Max number of running VMs the local backend will allow. */
39
+ localMaxVms: number;
40
+ /** Interval between local VM status polls (waiting for an IP), in ms. */
41
+ localPollIntervalMs: number;
42
+ /** Max time to wait for a local VM to obtain an IP after starting, in ms. */
43
+ localIpTimeoutMs: number;
44
+ /** True when the controller tool set should be exposed. */
45
+ controllerEnabled: boolean;
46
+ /** Base URL of the Anka Build Cloud Controller (e.g. http://anka.controller:8090). */
47
+ controllerUrl: string;
48
+ /** Raw value for the Authorization header sent to the controller (optional). */
49
+ controllerAuth: string;
50
+ /** When true, TLS certificate verification against the controller is skipped. */
51
+ controllerTlsInsecure: boolean;
52
+ /** Interval between instance-status polls, in ms. */
53
+ controllerPollIntervalMs: number;
54
+ /** Max time to wait for a requested VM to become SSH-ready, in ms. */
55
+ controllerStartTimeoutMs: number;
56
+ /** When true, controller_request_vm verifies SSH key auth before returning. */
57
+ controllerSshProbeEnabled: boolean;
58
+ /** Username the agent should use to SSH into a VM. */
59
+ vmSshUser: string;
60
+ /** Password the agent should use to SSH into a VM. */
61
+ vmSshPassword: string;
62
+ /** Guest port that maps to SSH inside the VM (forwarded on the host). */
63
+ vmSshGuestPort: number;
64
+ serverName: string;
65
+ serverVersion: string;
66
+ }
67
+ /** Build the runtime config from environment variables (read once at startup). */
68
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): AnkaMcpConfig;
69
+ export declare const config: AnkaMcpConfig;