orbitx 0.3.5 → 0.3.6

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.
@@ -1,214 +1,216 @@
1
- # MCP Architecture
2
-
3
- OrbitX's tool execution layer is built around a small client/server protocol (called "MCP" in this codebase — not to be confused with Anthropic's separate Model Context Protocol spec; it's an internal naming choice). It exists to decouple *where tools run* from *where the agent loop runs* — the same `MCPTool` definitions work whether they execute in the same process, in a separate process over a Unix socket / named pipe, over a WebSocket, or inside a sandboxed Docker container.
4
-
5
- `SimpleAgent` hides all of this behind an in-process connection. Reach for these primitives directly only when you need one of the other transports.
6
-
7
- ## The three pieces
8
-
9
- ```
10
- MCPServer — owns the registered tools, actually executes them
11
- MCPClient — what BaseAgent calls; sends tool invocations to a server, returns results
12
- MCPConnection — the transport wiring a client to a server (in-process, IPC, or WS)
13
- ```
14
-
15
- ### `MCPServer`
16
-
17
- ```ts
18
- new MCPServer(connection: MCPConnection, storage?: MCPStorage, rng?: MCPRNG);
19
- mcpServer.registerTool(tool: MCPTool<any>): void;
20
- ```
21
-
22
- Registers tools and listens on its `connection` for incoming tool-call requests, executing them and sending results back. Defaults to `MCPFSStorage` (filesystem-backed key/value storage — see below) if no `storage` is given.
23
-
24
- ### `MCPClient`
25
-
26
- ```ts
27
- new MCPClient(envID: string, connection: MCPConnection | MCPConnection[], storage?: MCPStorage, rng?: MCPRNG, mcpFilter?: MCPOutputFilter, executionPolicy?: MCPExecutionPolicy);
28
- mcpClient.getTools(): Promise<ToolSchema[]>;
29
- mcpClient.callTool(name: string, inputs: Record<string, any>, toolCallId?: string): Promise<MCPToolOutput>;
30
- mcpClient.setExecuteProviderHandler(handler: (toolCallId: string, type: ProviderType, input: Record<string, any>) => Promise<{ output: Record<string, any> }>): void;
31
- ```
32
-
33
- `toolCallId` on `callTool()` and the handler installed via `setExecuteProviderHandler()` are what `BaseAgent` wires up automatically so `mcp.executeProvider(...)` works inside a tool's `execute()` — see [Custom `MCP` subclasses](#custom-mcp-subclasses) below. You don't need to call either yourself unless you're driving `MCPClient` outside of `BaseAgent`. A `SwarmBase` takes that handler back after building each agent, so in a swarm the provider call is answered by the swarm — see [Swarm](./swarm.md#providers-a-tool-reaches-for).
34
-
35
- This is what you pass into `BaseAgent({ mcpClient, ... })`. `envID` scopes storage/RNG state per logical environment/session — pass a stable id (e.g. a user or conversation id) if you want isolated tool state per agent instance sharing a server. Accepting an array of connections lets one client fan out across multiple servers.
36
-
37
- `mcpFilter` (an `MCPOutputFilter`) redacts sensitive substrings/patterns from tool output before it reaches the model — see [`MCPOutputFilter`](#mcpoutputfilter-redacting-tool-output) below.
38
-
39
- `executionPolicy` (an `MCPExecutionPolicy`) decides whether a tool call is allowed to run at all, *before* it's dispatched — see [`MCPExecutionPolicy`](#mcpexecutionpolicy-gating-tool-calls) below.
40
-
41
- #### Registering tools directly on the client (bypassing the connection)
42
-
43
- `MCPClient` also has its own `registerTool(tool: MCPTool<any>)`, separate from `MCPServer.registerTool()`. A tool registered on the client executes locally, on whatever machine/process the client itself is running in — it never crosses the connection to the remote server at all. `callTool()` checks the client's own tool list first, and only falls back to sending the call across the connection if no local match is found; `getTools()` merges both lists the same way (a client-registered tool shadows a same-named server tool).
44
-
45
- This matters when you're using `MCPIPCConnection`/`MCPWSConnection`/`MCPComputer` to run most tools inside a separate process or sandbox: routing every single call across that boundary adds latency for tools that don't need the isolation. If a tool is cheap and can't do any harm (e.g. `GetCurrentTimeTool`, a pure computation like the `SumTool` example in [Tools](./tools.md#writing-a-custom-tool)), register it directly on the `MCPClient` instead of the sandboxed `MCPServer` — it still shows up to the model exactly like any other tool, it just answers immediately from the host process instead of round-tripping through IPC/WS/Docker. Keep anything that touches the filesystem, shell, or network on the sandboxed server side, where `MCPComputer`'s isolation actually protects you.
46
-
47
- ### `MCPConnection` and its variants
48
-
49
- - **`MCPConnection`** (base/in-process) — tools run in the same process as the agent. What `SimpleAgent` uses internally; you'd construct one directly only when composing `BaseAgent` by hand (see the README's "Building from the Base Agent" example).
50
- - **`MCPIPCConnection`** — connects client and server across two processes over a named pipe (Windows) or Unix domain socket (`socketPath`), with `mode: "server" | "client"` on each side:
51
-
52
- ```ts
53
- import os from "os";
54
- import { MCPIPCConnection } from "orbitx";
55
-
56
- const path = os.platform() === "win32" ? `\\\\.\\pipe\\mcp_test` : `/tmp/mcp_test.sock`;
57
- const ipcConnection = new MCPIPCConnection({ mode: "server", socketPath: path });
58
- ```
59
-
60
- - **`MCPWSConnection`** — connects over a WebSocket, optionally with a shared `token` for auth and `tls: { key, cert, ca? }` for WSS:
61
-
62
- ```ts
63
- import { MCPWSConnection } from "orbitx";
64
-
65
- const wsConnection = new MCPWSConnection({ mode: "server", host: "0.0.0.0", port: 9257, token: "1234" });
66
- ```
67
-
68
- Client-mode options use `url` instead of `host`/`port`. Both IPC and WS auto-reconnect on disconnect (`reconnectDelay`, default 500ms).
69
-
70
- ## `MCPComputer` — sandboxed execution *(experimental)*
71
-
72
- Spins up a Docker container running an MCP server and hands you back a ready-to-use connection — no manual socket/port/token wiring. This is the recommended way to let an agent run filesystem/bash tools without risking the host machine.
73
-
74
- ```ts
75
- import { MCPComputer } from "orbitx";
76
-
77
- const computer = new MCPComputer("/path/to/mount", [3000, 8080]);
78
- await computer.start();
79
-
80
- const connection = computer.getConnection();
81
- // ... build MCPClient/MCPServer/BaseAgent around `connection` as usual ...
82
- await computer.stop();
83
- ```
84
-
85
- ```ts
86
- new MCPComputer(mountPath: string, ports: number[] | "*", image?: string, options?: MCPComputerOptions);
87
- ```
88
-
89
- - `mountPath` — host directory mounted into the container (this is what filesystem tools inside the sandbox actually touch). Four subdirectories are created under it and mounted: `workspace`, `mcp-server-storage`, `presents`, `user-inputs`.
90
- - `ports` — ports to publish from the container, or `"*"` for host network mode. **`"*"` is Linux-only**: on Windows and macOS the container joins a VM's network namespace, so nothing it binds is reachable from your machine and published ports are ignored — the constructor throws rather than letting that surface later as a connection timeout.
91
- - `image` — defaults to `aliafsordeh/orbitx-sandbox:0.2`.
92
- - `start()` — verifies the Docker daemon is reachable, prepares the mount, launches the container, waits for its ready log, and then waits for the connection endpoint to actually accept traffic. It resolves only when the sandbox is genuinely reachable, and rejects with the container's own output when it is not.
93
- - `getConnection(wsHost?)` — returns a ready `MCPConnection`, cached (repeated calls hand back the same object). In `"ws"` mode it must be called after `start()`, since the port is only chosen there.
94
- - `waitUntilConnected(timeoutMs?)` — optional; resolves once the transport is carrying traffic, if you want the MCP handshake up before the first tool call.
95
- - `getPresentsHostPath()`, `getConnectionPort()`, `getContainerName()`, `isRunning()` — host-side accessors.
96
- - `stop()` — removes the container, closes the connection, and cleans up the socket directory. Idempotent.
97
-
98
- `MCPComputerOptions`:
99
-
100
- | Option | Default | Purpose |
101
- | --- | --- | --- |
102
- | `connectionMode` | `"ipc"` on Linux, `"ws"` elsewhere | Transport between your process and the sandbox. |
103
- | `readyTimeoutMs` | `60_000` | Wait for the sandbox's ready log once the container is running. |
104
- | `pullTimeoutMs` | `300_000` | Inactivity budget while docker pulls the image, so a first run isn't killed mid-download. |
105
- | `handshakeTimeoutMs` | `20_000` | Wait for the endpoint to accept traffic after the ready log. |
106
- | `onLog` | — | `(line, "stdout" \| "stderr") => void`; receives everything the container writes. |
107
- | `dockerBin` | `"docker"` | Override the executable (e.g. `"podman"`, or an absolute path). |
108
-
109
- The transport default is not a style choice: Docker Desktop on Windows and macOS runs the daemon inside a VM and shares the mount over a network filesystem, which cannot carry a unix-domain socket. IPC therefore only works when the daemon and your process share a kernel, and WebSocket (loopback-published, with an auto-generated auth token) is used everywhere else.
110
-
111
- Requires the `aliafsordeh/orbitx-sandbox:0.2` image (pull it, or build it yourself from the `Dockerfile.sandbox` at the repo root). Note that the sandbox's own stdout/stderr are drained continuously for the life of the container — a piped-but-unread stream fills its OS buffer after ~64KB and blocks the sandbox process on its next write, which surfaces much later as tool calls that never return.
112
-
113
- Servers started *inside* the sandbox must bind `0.0.0.0`, not `localhost`, to be reachable through a published port; `getInstructions()` tells agents this.
114
-
115
- ## Storage and RNG
116
-
117
- - **`MCPStorage`** — abstract `{ get(key): Promise<string>; set(key, value): Promise<void> }`. `MCPFSStorage` is the default filesystem-backed implementation (`new MCPFSStorage(path?)`, defaults to a randomly-named folder under the OS temp directory). `MCPStorage` and `MCPFSStorage` are both exported if you want to implement your own backend (Redis, a database, etc.) or point the default one at a specific path.
118
- - **`MCPRNG`** — deterministic-ish id generator backed by an `MCPStorage` instance (used internally for things like `generateRefId()`, the helper behind ref ids in `BrowserReadTool`'s output).
119
-
120
- ## `MCPOutputFilter` — redacting tool output
121
-
122
- ```ts
123
- new MCPOutputFilter(values: (string | RegExp)[]);
124
- mcpFilter.filter(input: any): any; // replaces every match with "FILTERED_OUTPUT"
125
- ```
126
-
127
- Pass literal strings (e.g. an API key you never want echoed back to the model) or regexes (e.g. `/sk-[a-zA-Z0-9]{20,}/`) as the `mcpFilter` argument to `MCPClient` to scrub tool output before it's returned — useful when a tool might read a file or environment variable containing a secret.
128
-
129
- > `MCPOutputFilter` was named `MCPFilter` before. The old name is still exported as a deprecated alias (`export const MCPFilter = MCPOutputFilter`) and will be removed in `1.0.0` — switch to `MCPOutputFilter` in new code.
130
-
131
- ## `MCPExecutionPolicy` — gating tool calls
132
-
133
- ```ts
134
- abstract class MCPExecutionPolicy {
135
- abstract authorize(request: MCPToolCallRequest): Promise<boolean> | boolean;
136
- }
137
- // MCPToolCallRequest = { toolName: string; inputs: Record<string, any>; envID: string; toolCallId?: string };
138
- ```
139
-
140
- Checked by `MCPClient.callTool()` before every tool call is dispatched — whether the tool is registered locally on the client or routed across a connection to a remote `MCPServer`. Returning (or resolving to) `false` throws before the call reaches the tool, and that error surfaces to the model as a normal tool-error result (`BaseAgent`'s dispatch loop catches it the same way it catches any other `callTool()` rejection).
141
-
142
- `MCPClient` defaults to `MCPBypassExecutionPolicy`, a built-in no-op that authorizes every call — so this is entirely opt-in. Implement your own subclass of `MCPExecutionPolicy` to add an allowlist, rate limiting, per-tool confirmation, or any other trust policy, and pass it as the `executionPolicy` argument to `MCPClient`'s constructor:
143
-
144
- ```ts
145
- import { MCPClient, MCPExecutionPolicy, type MCPToolCallRequest } from "orbitx";
146
-
147
- class AllowlistPolicy extends MCPExecutionPolicy {
148
- #allowed: Set<string>;
149
- constructor(allowed: string[]) {
150
- super();
151
- this.#allowed = new Set(allowed);
152
- }
153
- authorize({ toolName }: MCPToolCallRequest) {
154
- return this.#allowed.has(toolName);
155
- }
156
- }
157
-
158
- const mcpClient = new MCPClient(envID, connection, storage, rng, undefined, new AllowlistPolicy(["get_current_time"]));
159
- ```
160
-
161
- ### `MCPAutoExecutionPolicy` — an AI-judged policy
162
-
163
- `MCPAutoExecutionPolicy` (`src/core/mcp-auto-execution-policy.ts`) is a built-in `MCPExecutionPolicy` that asks a model, via [`AIASK`](./agents.md#aiask), whether each call is safe — an alternative to a hand-written allowlist/denylist for cases where "safe" isn't a fixed rule. It's built to keep the expensive part (an actual model call) as rare as possible rather than reviewing every single tool call:
164
-
165
- ```ts
166
- new MCPAutoExecutionPolicy({
167
- aiModel: AIProvider | AgentProviderEntry[];
168
- instruction?: string; // extra context appended after the built-in default instruction — e.g. what the user actually asked the agent to do
169
- onVerdict?: (request: MCPToolCallRequest, verdict: { harmful: boolean; reason: string }) => void;
170
- watchedTools?: string[]; // default: ["bash-run", "fs-write-file", "fs-edit-file", "fs-delete", "fs-move"]
171
- scopeDir?: string; // default: process.cwd() at construction time
172
- });
173
- ```
174
-
175
- Three cost-cutting layers, checked in order inside `authorize(request)`:
176
-
177
- 1. **Not in `watchedTools`?** Authorized immediately — no model call, no verdict recorded at all. Most tools (`get-current-time`, `todo-*`, `fs-read-file`, ...) have nothing dangerous to weigh, so the default watch list is only the destructive/execution tools: `bash-run` and the fs tools that write, edit, delete, or move something. Pass your own `watchedTools` to widen or narrow that set.
178
- 2. **A watched *fs* call whose path(s) all resolve inside `scopeDir`?** Also authorized immediately, no model call — a write/edit/delete confined to the expected working directory doesn't need judgment. Paths are resolved the same way the fs tools themselves resolve them (relative to `process.cwd()`); `fs-move` needs *both* `from` and `to` inside scope to skip the model — one path escaping scope is enough to escalate. `bash-run` has no path field to check here, so it always falls through to the model.
179
- 3. **Otherwise, ask the model** — via `AIASK`, on `{ toolName, inputs }`. `authorize()` denies whenever the verdict comes back `harmful: true`, or whenever `AIASK` itself couldn't reach a valid verdict after its own retries (fails closed, not open).
180
-
181
- Whenever a call *does* reach the model, file content is stripped out first — `fs-write-file`/`fs-edit-file`'s `content` field is replaced with `<omitted, N bytes>` before it's ever sent, since the file's contents aren't relevant to judging whether the *call* is safe, and including it would make every write/edit review needlessly expensive. The model judges those calls by path and other inputs alone.
182
-
183
- `authorize()`'s boolean return only tells `MCPClient` whether to proceed — it doesn't surface *why* a call was judged (or skipped as) harmful/safe, which callers usually want (to log it, or show the user what got blocked). Three ways to get that — all populated for both a model-reviewed call and one that was authorized locally via the path-scope check, but never for a call outside `watchedTools`, which is never reviewed at all:
184
-
185
- - `policy.getVerdict(toolCallId)` — the verdict recorded for one specific call, if it carried a `toolCallId`.
186
- - `policy.getLastVerdict()` — the most recent verdict of any reviewed call, useful when only one policy instance is ever in flight at a time.
187
- - `onVerdict` — called synchronously right after every reviewed `authorize()` decision, with both the request and the verdict.
188
-
189
- ```ts
190
- const policy = new MCPAutoExecutionPolicy({
191
- aiModel,
192
- scopeDir: "/home/me/project", // writes/edits/deletes/moves confined here skip the model entirely
193
- onVerdict: (request, verdict) => {
194
- if (verdict.harmful) console.warn(`blocked ${request.toolName}: ${verdict.reason}`);
195
- },
196
- });
197
-
198
- const mcpClient = new MCPClient(envID, connection, storage, rng, undefined, policy);
199
- ```
200
-
201
- Even with those shortcuts, a call that does reach the model costs at least one real call (more if `AIASK`'s own retry loop kicks in — see [`AIASK`](./agents.md#aiask)) — this still trades latency/cost for judgment a fixed rule can't express, just as rarely as the watch list and scope check allow. Reach for a plain `MCPExecutionPolicy` subclass first when a simple allowlist/denylist actually covers the case.
202
-
203
- ## Custom `MCP` subclasses
204
-
205
- Both `MCPServer` and `MCPClient` extend the abstract `MCP` class — `getStorage()`, `getRNG()`, and `executeProvider(toolCallId, type, input)`. Tool `execute` functions receive the calling `MCP` instance directly, as their 4th argument (after a `toolCallId` — see [Tools](./tools.md#the-mcptool-shape)), so a tool can reach storage/RNG scoped to whichever client dispatched the call.
206
-
207
- `executeProvider` is where the two subclasses genuinely differ:
208
-
209
- - **`MCPClient`** answers it in-process, by delegating to a handler `BaseAgent` installs on the client via `setExecuteProviderHandler(...)` at construction time. `MCPClient` itself never imports anything from `ai-provider.ts` — it just holds an opaque callback.
210
- - **`MCPServer`** has no such handler — it never touches a provider directly. It proxies the request over its `MCPConnection` (an `executeProvider`/`executeProviderResponse` message pair, mirroring `toolCall`/`toolCallCallback` but initiated by the server instead of the client) to whichever `MCPClient` is attached, which runs the same installed handler and answers back.
211
-
212
- So a **server-registered** tool — the kind meant to run inside `MCPComputer`'s sandbox, or in a genuinely separate process over IPC/WS — can still call `mcp.executeProvider(...)` and get a real result, without a provider instance (or the credentials it holds) ever crossing into that process. Only a scoped request/response for one call does. This is deliberate: filesystem/browser-touching tools like `ReadImageTool`/`BrowserScreenshotTool` are exactly the kind this doc already recommends sandboxing (see above), and they both need a provider to do their job.
213
-
214
- That accessor also carries provider access (`agent.getProvider(type)`/`getProviders(type)`) — but **only for a tool dispatched by `MCPClient.callTool`'s local branch** (a tool registered directly on the client, per the section above). `MCPServer`'s dispatch (both the `toolCall` handler above and, transitively, anything a remote/sandboxed tool triggers) builds an accessor with no provider registry behind it at all, so `getProvider`/`getProviders` there always resolve `undefined`. This is deliberate, not a gap to work around: provider instances hold API keys, and `MCPServer` is precisely the thing that runs inside a separate process or an `MCPComputer` sandbox — credentials never need to, and never do, cross that boundary. A tool that needs to call a provider directly (e.g. to describe an image it just read) has to be registered on the `MCPClient`, in the same process as the agent that owns those providers.
1
+ # MCP Architecture
2
+
3
+ OrbitX's tool execution layer is built around a small client/server protocol (called "MCP" in this codebase — not to be confused with Anthropic's separate Model Context Protocol spec; it's an internal naming choice). It exists to decouple *where tools run* from *where the agent loop runs* — the same `MCPTool` definitions work whether they execute in the same process, in a separate process over a Unix socket / named pipe, over a WebSocket, or inside a sandboxed Docker container.
4
+
5
+ `SimpleAgent` hides all of this behind an in-process connection. Reach for these primitives directly only when you need one of the other transports.
6
+
7
+ ## The three pieces
8
+
9
+ ```
10
+ MCPServer — owns the registered tools, actually executes them
11
+ MCPClient — what BaseAgent calls; sends tool invocations to a server, returns results
12
+ MCPConnection — the transport wiring a client to a server (in-process, IPC, or WS)
13
+ ```
14
+
15
+ ### `MCPServer`
16
+
17
+ ```ts
18
+ new MCPServer(connection: MCPConnection, storage?: MCPStorage, rng?: MCPRNG);
19
+ mcpServer.registerTool(tool: MCPTool<any>): void;
20
+ ```
21
+
22
+ Registers tools and listens on its `connection` for incoming tool-call requests, executing them and sending results back. Defaults to `MCPFSStorage` (filesystem-backed key/value storage — see below) if no `storage` is given.
23
+
24
+ ### `MCPClient`
25
+
26
+ ```ts
27
+ new MCPClient(envID: string, connection: MCPConnection | MCPConnection[], storage?: MCPStorage, rng?: MCPRNG, mcpFilter?: MCPOutputFilter, executionPolicy?: MCPExecutionPolicy);
28
+ mcpClient.getTools(): Promise<ToolSchema[]>;
29
+ mcpClient.callTool(name: string, inputs: Record<string, any>, toolCallId?: string): Promise<MCPToolOutput>;
30
+ mcpClient.setExecuteProviderHandler(handler: (toolCallId: string, type: ProviderType, input: Record<string, any>) => Promise<{ output: Record<string, any> }>): void;
31
+ ```
32
+
33
+ `toolCallId` on `callTool()` and the handler installed via `setExecuteProviderHandler()` are what `BaseAgent` wires up automatically so `mcp.executeProvider(...)` works inside a tool's `execute()` — see [Custom `MCP` subclasses](#custom-mcp-subclasses) below. You don't need to call either yourself unless you're driving `MCPClient` outside of `BaseAgent`. A `SwarmBase` takes that handler back after building each agent, so in a swarm the provider call is answered by the swarm — see [Swarm](./swarm.md#providers-a-tool-reaches-for).
34
+
35
+ This is what you pass into `BaseAgent({ mcpClient, ... })`. `envID` scopes storage/RNG state per logical environment/session — pass a stable id (e.g. a user or conversation id) if you want isolated tool state per agent instance sharing a server. Accepting an array of connections lets one client fan out across multiple servers.
36
+
37
+ `mcpFilter` (an `MCPOutputFilter`) redacts sensitive substrings/patterns from tool output before it reaches the model — see [`MCPOutputFilter`](#mcpoutputfilter-redacting-tool-output) below.
38
+
39
+ `executionPolicy` (an `MCPExecutionPolicy`) decides whether a tool call is allowed to run at all, *before* it's dispatched — see [`MCPExecutionPolicy`](#mcpexecutionpolicy-gating-tool-calls) below.
40
+
41
+ #### Registering tools directly on the client (bypassing the connection)
42
+
43
+ `MCPClient` also has its own `registerTool(tool: MCPTool<any>)`, separate from `MCPServer.registerTool()`. A tool registered on the client executes locally, on whatever machine/process the client itself is running in — it never crosses the connection to the remote server at all. `callTool()` checks the client's own tool list first, and only falls back to sending the call across the connection if no local match is found; `getTools()` merges both lists the same way (a client-registered tool shadows a same-named server tool).
44
+
45
+ This matters when you're using `MCPIPCConnection`/`MCPWSConnection`/`MCPComputer` to run most tools inside a separate process or sandbox: routing every single call across that boundary adds latency for tools that don't need the isolation. If a tool is cheap and can't do any harm (e.g. `GetCurrentTimeTool`, a pure computation like the `SumTool` example in [Tools](./tools.md#writing-a-custom-tool)), register it directly on the `MCPClient` instead of the sandboxed `MCPServer` — it still shows up to the model exactly like any other tool, it just answers immediately from the host process instead of round-tripping through IPC/WS/Docker. Keep anything that touches the filesystem, shell, or network on the sandboxed server side, where `MCPComputer`'s isolation actually protects you.
46
+
47
+ ### `MCPConnection` and its variants
48
+
49
+ - **`MCPConnection`** (base/in-process) — tools run in the same process as the agent. What `SimpleAgent` uses internally; you'd construct one directly only when composing `BaseAgent` by hand (see the README's "Building from the Base Agent" example).
50
+
51
+ It extends the library's own dependency-free `EventEmitter` (`src/core/event-emitter.ts`) rather than Node's `events`, so the base class and its in-process loopback also run in browsers. The emitter keeps Node's semantics for what connections use: `on`/`once`/`off`/`emit`/`removeAllListeners`, synchronous calls in registration order, `off()` removing a `once()` listener, and an unhandled `"error"` event throwing. Custom transports for the browser (e.g. one built on the native `WebSocket`) can subclass `MCPConnection` with `overrided = true` the same way the variants below do. `MCPIPCConnection` and `MCPWSConnection` themselves stay Node-only, since they use `net`/`http`/`ws`.
52
+ - **`MCPIPCConnection`** — connects client and server across two processes over a named pipe (Windows) or Unix domain socket (`socketPath`), with `mode: "server" | "client"` on each side:
53
+
54
+ ```ts
55
+ import os from "os";
56
+ import { MCPIPCConnection } from "orbitx";
57
+
58
+ const path = os.platform() === "win32" ? `\\\\.\\pipe\\mcp_test` : `/tmp/mcp_test.sock`;
59
+ const ipcConnection = new MCPIPCConnection({ mode: "server", socketPath: path });
60
+ ```
61
+
62
+ - **`MCPWSConnection`** — connects over a WebSocket, optionally with a shared `token` for auth and `tls: { key, cert, ca? }` for WSS:
63
+
64
+ ```ts
65
+ import { MCPWSConnection } from "orbitx";
66
+
67
+ const wsConnection = new MCPWSConnection({ mode: "server", host: "0.0.0.0", port: 9257, token: "1234" });
68
+ ```
69
+
70
+ Client-mode options use `url` instead of `host`/`port`. Both IPC and WS auto-reconnect on disconnect (`reconnectDelay`, default 500ms).
71
+
72
+ ## `MCPComputer` — sandboxed execution *(experimental)*
73
+
74
+ Spins up a Docker container running an MCP server and hands you back a ready-to-use connection — no manual socket/port/token wiring. This is the recommended way to let an agent run filesystem/bash tools without risking the host machine.
75
+
76
+ ```ts
77
+ import { MCPComputer } from "orbitx";
78
+
79
+ const computer = new MCPComputer("/path/to/mount", [3000, 8080]);
80
+ await computer.start();
81
+
82
+ const connection = computer.getConnection();
83
+ // ... build MCPClient/MCPServer/BaseAgent around `connection` as usual ...
84
+ await computer.stop();
85
+ ```
86
+
87
+ ```ts
88
+ new MCPComputer(mountPath: string, ports: number[] | "*", image?: string, options?: MCPComputerOptions);
89
+ ```
90
+
91
+ - `mountPath` — host directory mounted into the container (this is what filesystem tools inside the sandbox actually touch). Four subdirectories are created under it and mounted: `workspace`, `mcp-server-storage`, `presents`, `user-inputs`.
92
+ - `ports` — ports to publish from the container, or `"*"` for host network mode. **`"*"` is Linux-only**: on Windows and macOS the container joins a VM's network namespace, so nothing it binds is reachable from your machine and published ports are ignored — the constructor throws rather than letting that surface later as a connection timeout.
93
+ - `image` — defaults to `aliafsordeh/orbitx-sandbox:0.2`.
94
+ - `start()` — verifies the Docker daemon is reachable, prepares the mount, launches the container, waits for its ready log, and then waits for the connection endpoint to actually accept traffic. It resolves only when the sandbox is genuinely reachable, and rejects with the container's own output when it is not.
95
+ - `getConnection(wsHost?)` — returns a ready `MCPConnection`, cached (repeated calls hand back the same object). In `"ws"` mode it must be called after `start()`, since the port is only chosen there.
96
+ - `waitUntilConnected(timeoutMs?)` — optional; resolves once the transport is carrying traffic, if you want the MCP handshake up before the first tool call.
97
+ - `getPresentsHostPath()`, `getConnectionPort()`, `getContainerName()`, `isRunning()` — host-side accessors.
98
+ - `stop()` — removes the container, closes the connection, and cleans up the socket directory. Idempotent.
99
+
100
+ `MCPComputerOptions`:
101
+
102
+ | Option | Default | Purpose |
103
+ | --- | --- | --- |
104
+ | `connectionMode` | `"ipc"` on Linux, `"ws"` elsewhere | Transport between your process and the sandbox. |
105
+ | `readyTimeoutMs` | `60_000` | Wait for the sandbox's ready log once the container is running. |
106
+ | `pullTimeoutMs` | `300_000` | Inactivity budget while docker pulls the image, so a first run isn't killed mid-download. |
107
+ | `handshakeTimeoutMs` | `20_000` | Wait for the endpoint to accept traffic after the ready log. |
108
+ | `onLog` | — | `(line, "stdout" \| "stderr") => void`; receives everything the container writes. |
109
+ | `dockerBin` | `"docker"` | Override the executable (e.g. `"podman"`, or an absolute path). |
110
+
111
+ The transport default is not a style choice: Docker Desktop on Windows and macOS runs the daemon inside a VM and shares the mount over a network filesystem, which cannot carry a unix-domain socket. IPC therefore only works when the daemon and your process share a kernel, and WebSocket (loopback-published, with an auto-generated auth token) is used everywhere else.
112
+
113
+ Requires the `aliafsordeh/orbitx-sandbox:0.2` image (pull it, or build it yourself from the `Dockerfile.sandbox` at the repo root). Note that the sandbox's own stdout/stderr are drained continuously for the life of the container — a piped-but-unread stream fills its OS buffer after ~64KB and blocks the sandbox process on its next write, which surfaces much later as tool calls that never return.
114
+
115
+ Servers started *inside* the sandbox must bind `0.0.0.0`, not `localhost`, to be reachable through a published port; `getInstructions()` tells agents this.
116
+
117
+ ## Storage and RNG
118
+
119
+ - **`MCPStorage`** — abstract `{ get(key): Promise<string>; set(key, value): Promise<void> }`. `MCPFSStorage` is the default filesystem-backed implementation (`new MCPFSStorage(path?)`, defaults to a randomly-named folder under the OS temp directory). `MCPStorage` and `MCPFSStorage` are both exported if you want to implement your own backend (Redis, a database, etc.) or point the default one at a specific path.
120
+ - **`MCPRNG`** — deterministic-ish id generator backed by an `MCPStorage` instance (used internally for things like `generateRefId()`, the helper behind ref ids in `BrowserReadTool`'s output).
121
+
122
+ ## `MCPOutputFilter` — redacting tool output
123
+
124
+ ```ts
125
+ new MCPOutputFilter(values: (string | RegExp)[]);
126
+ mcpFilter.filter(input: any): any; // replaces every match with "FILTERED_OUTPUT"
127
+ ```
128
+
129
+ Pass literal strings (e.g. an API key you never want echoed back to the model) or regexes (e.g. `/sk-[a-zA-Z0-9]{20,}/`) as the `mcpFilter` argument to `MCPClient` to scrub tool output before it's returned — useful when a tool might read a file or environment variable containing a secret.
130
+
131
+ > `MCPOutputFilter` was named `MCPFilter` before. The old name is still exported as a deprecated alias (`export const MCPFilter = MCPOutputFilter`) and will be removed in `1.0.0` — switch to `MCPOutputFilter` in new code.
132
+
133
+ ## `MCPExecutionPolicy` — gating tool calls
134
+
135
+ ```ts
136
+ abstract class MCPExecutionPolicy {
137
+ abstract authorize(request: MCPToolCallRequest): Promise<boolean> | boolean;
138
+ }
139
+ // MCPToolCallRequest = { toolName: string; inputs: Record<string, any>; envID: string; toolCallId?: string };
140
+ ```
141
+
142
+ Checked by `MCPClient.callTool()` before every tool call is dispatched — whether the tool is registered locally on the client or routed across a connection to a remote `MCPServer`. Returning (or resolving to) `false` throws before the call reaches the tool, and that error surfaces to the model as a normal tool-error result (`BaseAgent`'s dispatch loop catches it the same way it catches any other `callTool()` rejection).
143
+
144
+ `MCPClient` defaults to `MCPBypassExecutionPolicy`, a built-in no-op that authorizes every call — so this is entirely opt-in. Implement your own subclass of `MCPExecutionPolicy` to add an allowlist, rate limiting, per-tool confirmation, or any other trust policy, and pass it as the `executionPolicy` argument to `MCPClient`'s constructor:
145
+
146
+ ```ts
147
+ import { MCPClient, MCPExecutionPolicy, type MCPToolCallRequest } from "orbitx";
148
+
149
+ class AllowlistPolicy extends MCPExecutionPolicy {
150
+ #allowed: Set<string>;
151
+ constructor(allowed: string[]) {
152
+ super();
153
+ this.#allowed = new Set(allowed);
154
+ }
155
+ authorize({ toolName }: MCPToolCallRequest) {
156
+ return this.#allowed.has(toolName);
157
+ }
158
+ }
159
+
160
+ const mcpClient = new MCPClient(envID, connection, storage, rng, undefined, new AllowlistPolicy(["get_current_time"]));
161
+ ```
162
+
163
+ ### `MCPAutoExecutionPolicy` — an AI-judged policy
164
+
165
+ `MCPAutoExecutionPolicy` (`src/core/mcp-auto-execution-policy.ts`) is a built-in `MCPExecutionPolicy` that asks a model, via [`AIASK`](./agents.md#aiask), whether each call is safe — an alternative to a hand-written allowlist/denylist for cases where "safe" isn't a fixed rule. It's built to keep the expensive part (an actual model call) as rare as possible rather than reviewing every single tool call:
166
+
167
+ ```ts
168
+ new MCPAutoExecutionPolicy({
169
+ aiModel: AIProvider | AgentProviderEntry[];
170
+ instruction?: string; // extra context appended after the built-in default instruction — e.g. what the user actually asked the agent to do
171
+ onVerdict?: (request: MCPToolCallRequest, verdict: { harmful: boolean; reason: string }) => void;
172
+ watchedTools?: string[]; // default: ["bash-run", "fs-write-file", "fs-edit-file", "fs-delete", "fs-move"]
173
+ scopeDir?: string; // default: process.cwd() at construction time
174
+ });
175
+ ```
176
+
177
+ Three cost-cutting layers, checked in order inside `authorize(request)`:
178
+
179
+ 1. **Not in `watchedTools`?** Authorized immediately — no model call, no verdict recorded at all. Most tools (`get-current-time`, `todo-*`, `fs-read-file`, ...) have nothing dangerous to weigh, so the default watch list is only the destructive/execution tools: `bash-run` and the fs tools that write, edit, delete, or move something. Pass your own `watchedTools` to widen or narrow that set.
180
+ 2. **A watched *fs* call whose path(s) all resolve inside `scopeDir`?** Also authorized immediately, no model call — a write/edit/delete confined to the expected working directory doesn't need judgment. Paths are resolved the same way the fs tools themselves resolve them (relative to `process.cwd()`); `fs-move` needs *both* `from` and `to` inside scope to skip the model — one path escaping scope is enough to escalate. `bash-run` has no path field to check here, so it always falls through to the model.
181
+ 3. **Otherwise, ask the model** — via `AIASK`, on `{ toolName, inputs }`. `authorize()` denies whenever the verdict comes back `harmful: true`, or whenever `AIASK` itself couldn't reach a valid verdict after its own retries (fails closed, not open).
182
+
183
+ Whenever a call *does* reach the model, file content is stripped out first — `fs-write-file`/`fs-edit-file`'s `content` field is replaced with `<omitted, N bytes>` before it's ever sent, since the file's contents aren't relevant to judging whether the *call* is safe, and including it would make every write/edit review needlessly expensive. The model judges those calls by path and other inputs alone.
184
+
185
+ `authorize()`'s boolean return only tells `MCPClient` whether to proceed — it doesn't surface *why* a call was judged (or skipped as) harmful/safe, which callers usually want (to log it, or show the user what got blocked). Three ways to get that — all populated for both a model-reviewed call and one that was authorized locally via the path-scope check, but never for a call outside `watchedTools`, which is never reviewed at all:
186
+
187
+ - `policy.getVerdict(toolCallId)` — the verdict recorded for one specific call, if it carried a `toolCallId`.
188
+ - `policy.getLastVerdict()` — the most recent verdict of any reviewed call, useful when only one policy instance is ever in flight at a time.
189
+ - `onVerdict` — called synchronously right after every reviewed `authorize()` decision, with both the request and the verdict.
190
+
191
+ ```ts
192
+ const policy = new MCPAutoExecutionPolicy({
193
+ aiModel,
194
+ scopeDir: "/home/me/project", // writes/edits/deletes/moves confined here skip the model entirely
195
+ onVerdict: (request, verdict) => {
196
+ if (verdict.harmful) console.warn(`blocked ${request.toolName}: ${verdict.reason}`);
197
+ },
198
+ });
199
+
200
+ const mcpClient = new MCPClient(envID, connection, storage, rng, undefined, policy);
201
+ ```
202
+
203
+ Even with those shortcuts, a call that does reach the model costs at least one real call (more if `AIASK`'s own retry loop kicks in — see [`AIASK`](./agents.md#aiask)) — this still trades latency/cost for judgment a fixed rule can't express, just as rarely as the watch list and scope check allow. Reach for a plain `MCPExecutionPolicy` subclass first when a simple allowlist/denylist actually covers the case.
204
+
205
+ ## Custom `MCP` subclasses
206
+
207
+ Both `MCPServer` and `MCPClient` extend the abstract `MCP` class — `getStorage()`, `getRNG()`, and `executeProvider(toolCallId, type, input)`. Tool `execute` functions receive the calling `MCP` instance directly, as their 4th argument (after a `toolCallId` — see [Tools](./tools.md#the-mcptool-shape)), so a tool can reach storage/RNG scoped to whichever client dispatched the call.
208
+
209
+ `executeProvider` is where the two subclasses genuinely differ:
210
+
211
+ - **`MCPClient`** answers it in-process, by delegating to a handler `BaseAgent` installs on the client via `setExecuteProviderHandler(...)` at construction time. `MCPClient` itself never imports anything from `ai-provider.ts` — it just holds an opaque callback.
212
+ - **`MCPServer`** has no such handler — it never touches a provider directly. It proxies the request over its `MCPConnection` (an `executeProvider`/`executeProviderResponse` message pair, mirroring `toolCall`/`toolCallCallback` but initiated by the server instead of the client) to whichever `MCPClient` is attached, which runs the same installed handler and answers back.
213
+
214
+ So a **server-registered** tool — the kind meant to run inside `MCPComputer`'s sandbox, or in a genuinely separate process over IPC/WS — can still call `mcp.executeProvider(...)` and get a real result, without a provider instance (or the credentials it holds) ever crossing into that process. Only a scoped request/response for one call does. This is deliberate: filesystem/browser-touching tools like `ReadImageTool`/`BrowserScreenshotTool` are exactly the kind this doc already recommends sandboxing (see above), and they both need a provider to do their job.
215
+
216
+ That accessor also carries provider access (`agent.getProvider(type)`/`getProviders(type)`) — but **only for a tool dispatched by `MCPClient.callTool`'s local branch** (a tool registered directly on the client, per the section above). `MCPServer`'s dispatch (both the `toolCall` handler above and, transitively, anything a remote/sandboxed tool triggers) builds an accessor with no provider registry behind it at all, so `getProvider`/`getProviders` there always resolve `undefined`. This is deliberate, not a gap to work around: provider instances hold API keys, and `MCPServer` is precisely the thing that runs inside a separate process or an `MCPComputer` sandbox — credentials never need to, and never do, cross that boundary. A tool that needs to call a provider directly (e.g. to describe an image it just read) has to be registered on the `MCPClient`, in the same process as the agent that owns those providers.