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.
- package/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- 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.
|