mandala-computer-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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. package/package.json +59 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mandala Computer
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,544 @@
1
+ # mandala-computer-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for
4
+ [Mandala Computer](https://mandala.computer) — cloud desktops for AI agents.
5
+
6
+ Point Claude Code, Claude Desktop, or anything else that speaks MCP at a real
7
+ Linux desktop it can **see and drive**. Screenshots come back as images, so the
8
+ model looks at the screen and clicks what it sees.
9
+
10
+ > **Status: alpha.** The tool surface is settling; expect breaking changes
11
+ > before 1.0. Tracks the platform's `/api/v1`, which is itself still moving.
12
+
13
+ ## Install
14
+
15
+ You need an API key from the dashboard — **Settings → API keys**, a `com_…`
16
+ string. It is scoped to your account and it is every computer on it, so treat it
17
+ the way you would treat a password.
18
+
19
+ Node 20.3 or newer. There is nothing else to install: `npx` fetches the server
20
+ the first time a client starts it.
21
+
22
+ **Claude Code**
23
+
24
+ ```sh
25
+ claude mcp add mandala -e MANDALA_API_KEY=com_… -- npx -y mandala-computer-mcp
26
+ ```
27
+
28
+ **Claude Desktop** — in `claude_desktop_config.json`:
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "mandala": {
34
+ "command": "npx",
35
+ "args": ["-y", "mandala-computer-mcp"],
36
+ "env": { "MANDALA_API_KEY": "com_…" }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ **Cursor, Windsurf and the rest** take the same three fields — `command`,
43
+ `args`, `env` — in whichever file they keep their MCP servers in.
44
+
45
+ Nothing is hosted and nothing is operated: your MCP client starts this as a
46
+ subprocess, and it talks to `https://app.mandala.computer/api/v1` with your key.
47
+
48
+ ## Use
49
+
50
+ Ask for what you want. A first session usually goes:
51
+
52
+ > Create a computer from the base template, open example.com, and show me what's
53
+ > on the screen.
54
+
55
+ Under that, the model is doing roughly this:
56
+
57
+ ```
58
+ create_computer(template="base") → builds it and selects it for the session
59
+ wait_for_computer(until="guest") → the desktop inside is answering, not just the VM
60
+ open_url(url="https://example.com") → puts the page on the screen
61
+ screenshot() → an image the model can point at
62
+ click(x=640, y=400) → clicks what it saw
63
+ screenshot() → looks again
64
+ ```
65
+
66
+ `create_computer(size="large")` is the fast path: the named shapes from
67
+ `list_sizes` are the ones the platform keeps pre-booted, so one of those is
68
+ usually answered in about a second where a custom `cpu`/`ram_mb`/`disk_gb`
69
+ shape boots cold. A `size` sets the template and the numbers together, so send
70
+ it alone or the explicit fields alone.
71
+
72
+ `use_computer` binds a machine to the session, so every later call can leave
73
+ `computer_id` out. Pass `computer_id` explicitly on any call to override it
74
+ without changing the binding, which is how you drive two machines at once.
75
+
76
+ Set `MANDALA_COMPUTER_ID` to bind one at startup and skip `use_computer`
77
+ entirely.
78
+
79
+ ## The tools
80
+
81
+ **Choosing a machine** — `list_templates`, `list_sizes`, `list_computers`, `get_computer`,
82
+ `use_computer`, `wait_for_computer`, `get_desktop_url`
83
+
84
+ **Lifecycle** — `create_computer`, `start_computer`, `stop_computer`,
85
+ `suspend_computer`, `restart_computer`, `update_computer`, `clone_computer`,
86
+ `delete_computer`, `move_computer`, `list_moves`
87
+
88
+ **Driving the desktop** — `screenshot`, `click`, `type_text`, `press_key`,
89
+ `scroll`, `drag`, `move_mouse`, `mouse_button`, `cursor_position`, `wait`
90
+
91
+ **Inside the guest** — `exec`, `exec_poll`, `exec_kill`, `open_url`,
92
+ `list_windows`, `window_action`, `read_clipboard`, `write_clipboard`,
93
+ `read_file`, `write_file`
94
+
95
+ **Being told rather than asking** — `wait_for_event`, `poll_events`,
96
+ `wait_for_file_change`
97
+
98
+ **Snapshots** — `list_snapshots`, `snapshot_holdings`, `create_snapshot`,
99
+ `restore_snapshot`, `clone_snapshot`, `snapshot_schedule`, `get_retention`,
100
+ `delete_snapshot`
101
+
102
+ **Your own templates** — `get_template_schema`, `check_template`,
103
+ `publish_template`, `get_template`, `retire_template`
104
+
105
+ **Building one** — `build_template`, `list_builds`, `get_build`, `watch_build`
106
+
107
+ **Spending** — `get_usage`
108
+
109
+ **Being told somewhere else** — `list_webhooks`, `create_webhook`,
110
+ `get_webhook`, `update_webhook`, `rotate_webhook_secret`, `test_webhook`,
111
+ `list_webhook_deliveries`, `delete_webhook`
112
+
113
+ **Delegating** — `run_agent`, registered only when a model key is present:
114
+ `MANDALA_MODEL_KEY` on stdio, or the caller's own `X-Model-Key` header over HTTP.
115
+
116
+ ## Things worth knowing
117
+
118
+ **Every computer is a Linux desktop today.** Windows guests are not offered on any
119
+ plan; where this README mentions Windows it is describing behaviour the client
120
+ already supports for when they are.
121
+
122
+ **A running computer costs money, and a forgotten one keeps costing it.**
123
+ `create_computer` says so in its own description, and so does everything that
124
+ starts a machine by a side door — `restore_snapshot` boots a stopped computer,
125
+ `write_clipboard` resumes a suspended one, and each of those is charged like
126
+ any other start. When a stretch of work is over, `suspend_computer` (a pause:
127
+ `start_computer` brings the same session back in about a second) or
128
+ `stop_computer` (a shutdown: the disk is kept, the session is not). Idle
129
+ suspend catches the ones a model forgets, but only after 30 minutes untouched.
130
+ `get_usage` is what says what any of it cost.
131
+
132
+ **Ten clicks need not be ten screenshots.** Driving the desktop one tool at a
133
+ time — `screenshot`, `click`, `screenshot` — puts an image in the calling
134
+ model's context for every step. `run_agent` hands a task in plain language to
135
+ the platform's own loop instead, which screenshots, decides and clicks inside
136
+ the platform and answers with a sentence and the list of what it did. It is
137
+ registered only when a model key is present (see [Configuration](#configuration)),
138
+ bills that key for every step, and `max_steps` is the spending cap as much as
139
+ the loop bound.
140
+
141
+ **A screenshot is how you find out what the screen looks like.** A click that
142
+ landed and a click that did nothing produce the same tool result, so a model
143
+ that acts without looking is the single most common way one of these sessions
144
+ goes wrong. The tools say so in their own descriptions and the server's
145
+ instructions say it once more.
146
+
147
+ **But a screenshot is no longer how you find out whether anything happened.**
148
+ The platform's computers report what they do — a window opening, closing or
149
+ taking focus, the clipboard changing hands, a background command exiting, the
150
+ desktop coming up, the machine going idle, every power transition — and
151
+ `wait_for_event` blocks until one of those arrives instead of screenshotting in
152
+ a loop to discover that nothing has.
153
+
154
+ The part worth understanding is where the socket lives. A model takes turns; it
155
+ is not sitting in a loop reading a stream, and between two of its turns there is
156
+ nobody here to read one. So **this server holds the connection**, one per
157
+ computer, opened the first time a tool asks about it and kept across turns. What
158
+ arrives while the model is doing something else is buffered, and the next
159
+ `wait_for_event` or `poll_events` is handed it in order. The model holds a
160
+ cursor and never learns that a socket exists.
161
+
162
+ Three consequences, and they are the whole of the design:
163
+
164
+ - **A wait that times out has missed nothing.** The stream stayed open while the
165
+ tool call was not running. That is why the timeout is capped at 55 seconds
166
+ rather than the fifteen minutes `wait_for_computer` allows — a short wait
167
+ costs nothing, because calling again picks up exactly where it left off. A
168
+ timeout is a normal answer here, not an error.
169
+ - **An event that already happened still ends a wait.** `computer.ready` fires
170
+ once per desktop session, so a machine that has been up for an hour will never
171
+ send it again; attach to one and you are handed a `computer.ready` marked
172
+ `synthesized` rather than waiting forever for an event that cannot arrive.
173
+ - **A hole in the history is answered, not forwarded.** When the platform cannot
174
+ replay from where this server had got to, the events that survived come back
175
+ with a count of what did not **and** with the state the missing ones would
176
+ have reported — the window listing and the computer's own record. The `gap`
177
+ frame itself never reaches the model, because a model handed one would invent
178
+ a recovery procedure.
179
+
180
+ **A file being written is an event, once you ask for one.** `file.changed` is
181
+ the one thing on this stream nobody is sent unasked: a directory has to be
182
+ nominated on the connection, and without one the platform sends no file events
183
+ at all. `wait_for_file_change` is that nomination and the wait in one call —
184
+ give it an absolute directory in the guest and it blocks until something under
185
+ it is created, modified or deleted. Use it for a build writing its output, a
186
+ download landing, a script producing a file; the alternative is running `ls` in
187
+ a loop, which is the file-shaped version of the screenshot loop.
188
+
189
+ Three things about it are worth knowing before you use it, because each is a way
190
+ to read an answer wrongly:
191
+
192
+ - **A nomination is not a watch.** The guest has to be asked, and on a computer
193
+ nobody has opened a terminal on the watcher is installed into the guest first
194
+ — seconds, not milliseconds. inotify reports changes and not state, so nothing
195
+ that happens before a tree is armed is ever reported. This tool never returns
196
+ "nothing changed" from inside that window: until the tree is genuinely being
197
+ watched it says so, in as many words, and tells you to call again.
198
+ - **`lost` is not an error.** A tree that changes faster than the stream reports
199
+ it comes back as one marker rather than thousands of events — which is what
200
+ makes a watch under a build usable at all. The watch is still on and the tree
201
+ is still being watched; what you have lost is your picture of it, so list the
202
+ directory and carry on. The one exception is `unwatchable`, which means the
203
+ tree is not being watched: the path is not there yet, is not a directory,
204
+ cannot be read, or is a symlink, and symlinks are refused rather than followed.
205
+ - **Nominate the narrowest tree you care about.** A home directory under a build
206
+ is thousands of changes a second, and what you get for it is a flood marker
207
+ rather than the changes. Your session holds four trees at once per computer —
208
+ a fifth evicts the one you asked about longest ago, and you are told which one
209
+ went — while the computer itself watches at most 32 across every client
210
+ connected to it. A nomination past that limit is refused where a websocket
211
+ client is told nothing at all, so this server works it out by elimination: it
212
+ drops the tree, the rest of the stream comes back, and it says which of the
213
+ two it was. Adding a watch can never cost you the window and process events
214
+ you already had.
215
+
216
+ Not everything else is an event, though. A click landing and a page painting are
217
+ not, and no amount of waiting will produce one — `screenshot`, `list_windows`
218
+ and `exec_poll` are still the answers there. `wait_for_event` refuses at once,
219
+ naming what the computer *can* emit, when asked for something this guest will
220
+ never produce.
221
+
222
+ **The guest half is not one capability.** A Windows guest has no event stream at
223
+ all. A Linux one whose hardware carries no terminal channel produces nothing the
224
+ guest reports about itself. But `file.changed` runs against libc's own inotify
225
+ calls and needs only that channel, while `window.*`, the clipboard and readiness
226
+ also need the X bindings their desktop watcher is written against — so an older
227
+ Linux image reports every file change and no window event whatever, and a host
228
+ old enough to predate file watches reports the reverse. The refusals name which
229
+ shape it is, because the three want different things done about them: a stop and
230
+ a start gets a channel, nothing gets an image its bindings, and a host that
231
+ predates the feature is not something a caller can act on at all.
232
+
233
+ **`running` does not mean ready.** A computer reports running when the
234
+ hypervisor has started the VM; the desktop inside comes up seconds later.
235
+ `wait_for_computer(until="guest")` waits for the software to answer, which is
236
+ what `exec`, files and a painted screen actually need.
237
+
238
+ **A resize can be refused with an offer rather than a no.** Growing a computer
239
+ past what the host it is on can run comes back as a refusal that says another
240
+ host in the region could run it. That one does not clear by waiting — retrying
241
+ the same resize gets the same answer for as long as the computer is on that
242
+ host. `move_computer` is how you take the offer up: it moves the machine to
243
+ different hardware, copying its disk to get there, and applies the size on
244
+ arrival. Tell whoever you are working for what it costs before you call it, and
245
+ read `list_moves` if the wait runs out. A move that ends `moved` rather than
246
+ `done` is the one to read carefully — the computer **is** on another host, at
247
+ its old size, and an ordinary `update_computer` finishes the job.
248
+
249
+ **A webhook is the same events, delivered to somebody who is not here.**
250
+ `wait_for_event` is for the model, which takes turns and can afford to ask.
251
+ A CI job or a queue worker cannot, and `create_webhook` is how it gets woken
252
+ instead: an HTTPS endpoint the platform POSTs each event to, byte for byte the
253
+ object the socket frames, signed with the three Standard Webhooks headers. The
254
+ secret that signs them is in the create answer **once** and never readable
255
+ again, which is why the first line of that answer says so; `rotate_webhook_secret`
256
+ is the only way to another. This server only sets webhooks up and reads how
257
+ they are doing — it does not receive them and has no `verify`, because a
258
+ server with no endpoint has nothing to verify. `list_webhook_deliveries` is
259
+ where a delivery that ran out of retries shows up; nothing is dropped silently.
260
+
261
+ **A schedule says when, not how long.** `snapshot_schedule` sets the window a
262
+ computer's automatic snapshot is taken in; `get_retention` is what says how many
263
+ of them survive, and it takes no computer because the window belongs to the
264
+ account. Only automatic snapshots are ever aged out, so taking one with
265
+ `create_snapshot` is how a model keeps something past it.
266
+
267
+ **A usage total that is short does not look short.** `get_usage` answers what
268
+ the account has spent — the read to make before and after a batch of computers,
269
+ and the one to make when somebody asks what anything cost. Every figure in it is
270
+ a sum across the hypervisors the account's computers are on, so a host that could
271
+ not be reached does not leave a gap: it leaves a total that is quietly too small.
272
+ The answer says so in its FIRST line when that has happened, ahead of the
273
+ numbers, because a caveat under a figure is a caveat that has already been acted
274
+ on. Two kinds, and only one of them clears by retrying.
275
+
276
+ One window at a time, at most 62 days of it, reaching back 399 — every
277
+ hypervisor replays its ledger a day at a time to answer, so an older period is
278
+ read by naming both `from` and `to` rather than by widening one of them. `to` on
279
+ its own is measured from the current billing period and is refused.
280
+
281
+ **`exec` runs as root with no display.** A GUI application started without
282
+ `desktop: true` cannot draw. `open_url` is the reliable way to put a web page on
283
+ the screen — and it returns before the browser paints, sometimes by ten seconds.
284
+
285
+ **A variable belongs in `env`, not in front of the command.** `exec` takes an
286
+ `env` object, and `FOO=bar cmd` is a different thing: it is shell syntax, so a
287
+ value with a space or a quote in it is yours to quote and is silently cut in
288
+ half when you get it wrong. It also puts the value in the guest's `ps` for
289
+ anyone logged into the machine, and a background command's command line comes
290
+ back inside every `exec_poll` answer.
291
+
292
+ **Anything slow wants `background: true`.** A build or an install run in the
293
+ foreground comes back as a timeout, with the work still going inside the guest
294
+ and its output unreadable. With a handle you get the exit code and the output,
295
+ and `exec_kill` stops it.
296
+
297
+ Past about **two minutes** it does not even come back as a timeout. A proxy in
298
+ front of the platform abandons a request that has produced no response for
299
+ roughly that long and answers 524, which arrives as `GatewayTimeoutError` —
300
+ whatever `timeout_s` said, because the hop that gives up never saw it. Measured
301
+ against `app.mandala.computer`: `sleep 130` failed at 125.2s with
302
+ `timeout_s: 300` and at 125.3s with `timeout_s: 3600`. Raising `timeout_s` buys
303
+ nothing; `background: true` is the only thing that works. The abandoned command
304
+ keeps running, so the call after one of these often reports the guest agent as
305
+ busy — that is the first failure continuing, not a second one.
306
+
307
+ The ceiling belongs to that proxy rather than to the platform, which is why
308
+ `timeout_s` still accepts up to 300: a `MANDALA_BASE_URL` pointed at an origin
309
+ reached without the proxy in front of it does not have one.
310
+
311
+ **`list_windows` sees what a screenshot cannot.** It is how you tell an
312
+ application that failed to start from one that has not painted yet. Match on
313
+ `class` (the application), not `title` (whatever page it is showing).
314
+
315
+ **The clipboard is two tools, not a shell recipe.** `read_clipboard` and
316
+ `write_clipboard` reach the desktop's `CLIPBOARD` selection — what Ctrl-C writes
317
+ and Ctrl-V pastes — on Linux computers whose desktop image includes `xclip`.
318
+ An older or custom image without `xclip` gets a permanent 400 from both tools;
319
+ changing the computer's runtime state or retrying cannot fix that image dependency.
320
+ Pair `write_clipboard` with `press_key` and `keys: ["ctrl","v"]` — two key
321
+ names, not the string `"ctrl+v"` — to get the text into whatever has focus. Do not reach for `xclip` through `exec` instead:
322
+ `exec` runs a login shell, so the guest user's profile prints onto the same
323
+ output your command does and corrupts a read you are trying to parse, and a
324
+ write that way needs a resident holder, a redirect, base64 and a polling loop.
325
+ The write here is confirmed by the platform reading the selection back before it
326
+ answers. 64 KiB in, 128 KiB out, and the read is refused rather than truncated
327
+ past its cap. `write_clipboard` resumes a suspended computer; `read_clipboard`
328
+ does not.
329
+
330
+ **Computers suspend themselves.** After 30 minutes untouched, by default. Input,
331
+ `exec` and file transfers count as use and resume it automatically;
332
+ **screenshots deliberately do not**, so a loop that only watches can see its own
333
+ machine go down under it.
334
+
335
+ **A stop is a request, and can be refused.** `stop_computer` asks the guest to
336
+ shut down and gives it time to do it. A hung X session, a modal "unsaved
337
+ changes" dialog or a service that ignores its own shutdown will refuse that
338
+ identically every time it is asked. `force: true` pulls the power instead — the
339
+ equivalent of holding the button in — and whatever the guest had not written to
340
+ disk goes with it, so it is the second attempt rather than the first.
341
+
342
+ **Purging snapshots is bound to the set you were shown.** Deleting a computer
343
+ keeps its snapshots by default. To destroy them too, read `snapshot_holdings`
344
+ first — a count, a byte total and a fingerprint — and pass that fingerprint to
345
+ `delete_computer` as `expect`. The purge is then refused if the set has changed
346
+ since you looked, so a capture that finished in between cannot be swept up by a
347
+ decision that was never about it. `delete_computer` will not purge without one,
348
+ and the platform makes `expect` optional only for callers that had no way to
349
+ read the holdings.
350
+
351
+ **A short list is refused, not silently served.** `list_computers`,
352
+ `list_snapshots` and `list_builds` fan out across hypervisors, and if one cannot
353
+ be reached the platform answers 503 rather than a list that is quietly missing
354
+ things. `allow_partial: true` accepts the incomplete answer instead — and when
355
+ it does, the result opens with an `INCOMPLETE:` line saying so, because a short
356
+ list reads exactly like the missing things were deleted.
357
+
358
+ `list_builds` is the one where that line is always all you get. The platform
359
+ keeps no record of which hypervisor ran which build, so a short build listing
360
+ simply has fewer rows, an unknown number missing and nothing marking the gap.
361
+
362
+ The other two append a row marked `unreachable` for each thing they could not
363
+ reach — but only for a key that spans the account. A WORKSPACE-SCOPED key gets
364
+ no marked rows either, because naming the missing ids would mean reading them
365
+ out of a placement cache that has no workspace column, and handing a confined
366
+ credential ids from the workspaces it is confined away from. For such a key all
367
+ three listings are the `INCOMPLETE:` line and nothing else, which is why that
368
+ line is written first and in prose.
369
+
370
+ **Snapshots mid-deletion are billed but hidden.** A deletion that began and did
371
+ not finish still holds objects and still counts against storage, and the default
372
+ listing leaves it out — every ordinary caller is asking "what can I restore".
373
+ `list_snapshots(include_unfinished: true)` is the flag for when the question is
374
+ about storage instead.
375
+
376
+ **A 409 usually clears; a 400 never does.** A guest still booting or a busy
377
+ guest agent answers 409. The platform's own error messages come through
378
+ unedited, because they are written to be acted on.
379
+
380
+ **Desktop links are credentials.** `get_desktop_url` returns the watch-only URL
381
+ by default — the platform drops input on that socket, so it is safe to hand to
382
+ somebody. `control: true` returns the full-control one, which is root-equivalent
383
+ on that machine. Neither appears in any other tool's output, deliberately: a
384
+ tool result lands in a model's context and from there in whatever captured it.
385
+
386
+ **Retiring a template cannot be undone, and takes more than it looks.**
387
+ `retire_template` without a `version` retires **every** version of the name —
388
+ that is what retiring a template means, and it is deliberately not
389
+ `get_template`'s "the newest". A retired ref is then refused for ever, identical
390
+ bytes included, so the version you retire can never be published again. What it
391
+ does *not* touch is any computer: a computer is built from the image the ref
392
+ resolved to and holds no reference to the document, so anything already running,
393
+ stopped or suspended keeps working. The tool says all of this in its own
394
+ description, carries `destructiveHint`, and requires `confirm: true` — the same
395
+ gate `delete_computer`, `restore_snapshot` and `delete_snapshot` take. It is
396
+ strictly less recoverable than any of them: a deleted snapshot's name can be
397
+ used again, a retired ref never can.
398
+
399
+ An empty `version` is refused here rather than sent. That spelling — which a
400
+ model is more likely than a program to produce for an optional argument — read as
401
+ "no version was named" on the platform and retired an entire template. The
402
+ platform answers `400` for it now; this server will not send one at all.
403
+
404
+ **A build is minutes, and `watch_build` is how you follow one.** `build_template`
405
+ returns immediately with a job; watching it streams the platform's own progress,
406
+ emitting both a progress notification and a log line for each step, so a long
407
+ build is visibly alive rather than indistinguishable from a hang.
408
+
409
+ **Set `resetTimeoutOnProgress` if you intend to watch a real build.** The MCP
410
+ default request timeout is 60 seconds and only a progress notification can reset
411
+ it — but the SDK resets it only for a caller that passed that option, so a client
412
+ which merely accepts progress is still cancelled a minute into a fifteen-minute
413
+ build. `get_build` is the answer for a client that cannot hold a request open:
414
+ it reads once and returns. A build that *failed* is a normal answer from
415
+ `watch_build`, not an error — it names the step that stopped it, which is the
416
+ thing to fix. An `error` event is the *stream* failing and says nothing about the
417
+ build, and the tool says so rather than letting a model rewrite a document that
418
+ is fine.
419
+
420
+ What you build is **not launchable yet**: the fleet does not advertise a family
421
+ it built rather than shipped, so a create naming such a ref is still refused.
422
+ `publish_template` says the same thing where it matters — publishing and being
423
+ launchable are different questions, and its result no longer ends on a flat
424
+ "launch it with `create_computer`" that a document declaring `spec.build` would
425
+ have led straight into a refusal on.
426
+
427
+ ## Running it as a service
428
+
429
+ The same server speaks streamable HTTP, for clients that cannot spawn a
430
+ subprocess — claude.ai, mobile, a shared team endpoint:
431
+
432
+ ```sh
433
+ MANDALA_ALLOWED_HOSTS=mcp.example.com npx mandala-computer-mcp --http --port 3000
434
+ ```
435
+
436
+ ```sh
437
+ claude mcp add --transport http mandala https://mcp.example.com/mcp \
438
+ --header "Authorization: Bearer com_…"
439
+ ```
440
+
441
+ The MCP endpoint is `/mcp`; `/healthz` answers a JSON `{ ok, name, version }`
442
+ for whatever is checking that the process is up.
443
+
444
+ **It holds no credential of its own.** Each caller's key arrives as their own
445
+ bearer token and is used only for their session; there is no store, and nothing
446
+ outlives a session but a digest of the key — kept so that a later request can be
447
+ shown to come from the same holder, which means a leaked session id on its own
448
+ is not enough to drive somebody else's desktop.
449
+
450
+ That is also why anyone can run their own: point the same container at the same
451
+ API and it works, with no secret to provision.
452
+
453
+ Bound to loopback — the default — it answers only to `127.0.0.1`, `localhost`
454
+ and `[::1]`, so a page the user happens to be visiting cannot reach it by
455
+ resolving its own name there. Served under a name, or bound to `0.0.0.0`, that
456
+ default cannot be guessed and `MANDALA_ALLOWED_HOSTS` is what turns the check
457
+ back on.
458
+
459
+ Which matters most in the arrangement that looks like neither: bound to
460
+ `127.0.0.1:3000` **behind a proxy** — nginx, Caddy, cloudflared, ngrok. The
461
+ proxy forwards the original `Host: mcp.example.com`, the loopback default does
462
+ not list it, and every request is refused with a 403. Set
463
+ `MANDALA_ALLOWED_HOSTS` to the name it is served under. Startup says which list
464
+ is in force, so a `403` on a deployment that worked before has a line above it
465
+ naming the fix.
466
+
467
+ ### Configuration
468
+
469
+ | Variable | Meaning |
470
+ | --- | --- |
471
+ | `MANDALA_API_KEY` | `com_…` from Settings → API keys. Required on stdio; over HTTP each caller sends their own. |
472
+ | `MANDALA_BASE_URL` | Defaults to `https://app.mandala.computer/api/v1`. |
473
+ | `MANDALA_COMPUTER_ID` | Bind a computer at startup, so `use_computer` is not needed. **stdio only** — under `--http` it is ignored rather than bound into every caller's session, since it names a machine on the operator's account. |
474
+ | `MANDALA_MODEL_KEY` | An Anthropic key. Enables `run_agent`, which runs the platform's own loop on that key. **stdio only** — under `--http` each caller sends their own as `X-Model-Key`, and this variable is ignored. |
475
+ | `MANDALA_NO_LIFECYCLE` | `1` withholds `create_computer`, `clone_computer`, `clone_snapshot`, `delete_computer` and `delete_snapshot` — every tool that makes a computer or destroys one. |
476
+ | `PORT`, `HOST` | For `--http`. Default `3000`, `127.0.0.1`. |
477
+ | `MANDALA_ALLOWED_HOSTS`, `MANDALA_ALLOWED_ORIGINS` | Comma-separated. Which `Host` and `Origin` values this server answers to. On a loopback bind the host list defaults to the address it was given, so DNS-rebinding protection is on without configuration; set this when serving under a name. |
478
+
479
+ Every one of these but the model key has a flag as well, and a flag overrides
480
+ the environment: `--http`, `--port`, `--host`, `--base-url`, `--computer`,
481
+ `--allowed-hosts`, `--allowed-origins`, `--no-lifecycle`, plus `--help` and
482
+ `--version`. `--key` exists for a caller launching several servers under
483
+ different keys, and warns when used, because an argument vector is readable by
484
+ `ps`, lands in shell history and is recorded verbatim by any exec audit —
485
+ none of which is true of `MANDALA_API_KEY`.
486
+
487
+ `run_agent` deserves a note. It hands a task to the platform's own agent loop,
488
+ which drives the computer inside the platform and answers with a sentence. Worth
489
+ it when a stretch of pixel work would otherwise cost the calling model a
490
+ screenshot per step — ten clicks stop being ten images. It bills your Anthropic
491
+ key, and the platform never stores that key.
492
+
493
+ ## Development
494
+
495
+ ```sh
496
+ npm install
497
+ npm test # vitest, plus the surface check below
498
+ npm run build
499
+ npm run lint
500
+ ```
501
+
502
+ CI runs the suite on Node 20, 22, 24 and 26 — the floor `package.json`
503
+ declares and the ceiling a current `npx` will actually use.
504
+
505
+ ### Where the platform's rules live
506
+
507
+ This server gets no privileged access. Everything it does goes through the same
508
+ curated `/api/v1` surface the Python SDK uses, owner-scoped to the key's account
509
+ and audited against it. Anything it needs that `/api/v1` does not expose is a
510
+ change to the platform's route table, not a wider pass-through here.
511
+
512
+ ### Maintainers: the surface check
513
+
514
+ The platform allowlists every route `/api/v1` will answer and 404s the rest.
515
+ `test/allowlist.ts` mirrors that table, and the tests assert two things: that
516
+ every call this server can make lands on an allowlisted route, and that the gap
517
+ between the platform's surface and this server's coverage is *exactly* the set
518
+ written down in `UNIMPLEMENTED`. A route added upstream becomes a failing test
519
+ here rather than a feature nobody noticed.
520
+
521
+ `npm run check:surface` goes further and diffs the mirror against the platform's
522
+ own route table, whenever the platform repository happens to be checked out next
523
+ door — or wherever `MANDALA_PLATFORM_REPO` points. Without it the script says
524
+ it is skipping and exits 0, which is what it does for anyone outside the
525
+ platform team; the diff is enforced from the platform's own CI, which checks
526
+ this repository out beside itself and runs the same script.
527
+
528
+ ```
529
+ check:surface — the mirror matches the platform (56 routes, 89 parameters, from …/mandala-computer).
530
+ ```
531
+
532
+ ## See also
533
+
534
+ - [python-sdk](https://github.com/mandalacomputer/python-sdk) — the Python SDK,
535
+ for writing code against the same API rather than driving it from a model.
536
+
537
+ ## Security
538
+
539
+ Please report anything security-sensitive privately — see
540
+ [SECURITY.md](SECURITY.md) rather than opening an issue.
541
+
542
+ ## Licence
543
+
544
+ MIT.