External Unit MCP
Overview
External unit MCP is a separate Space-owned MCP surface that lets external OpenCode sessions develop user units through Space MCP tools instead of direct filesystem edits. The subsystem lives under assets/lua/llm/external-unit-mcp/ with a standalone entrypoint at assets/lua/tools/external-unit-mcp-server.fnl.
external opencode <-> HTTP/SSE <-> ExternalUnitMcpBridge <-> ExternalUnitService
(isolated config) (loader-neutral)The external bridge writes an isolated OpenCode configuration into a temporary directory and prints the environment variables the external OpenCode session must use. It never mutates global ~/.config/opencode.
Separation from Internal Agent MCP
Space has two distinct MCP registries and two separate tool surfaces:
| Surface | Registry | Tools Location | Audience |
|---|---|---|---|
| Internal agent MCP | app.mcp-tools (bootstrap-owned) | llm/presets/builtins/units.fnl, scene, drawing, graph adapters | Space's own agent |
| External unit MCP | Standalone ToolRegistry | llm/external-unit-mcp/tools.fnl | External OpenCode sessions |
The internal registry serves the Space agent's runtime context (scene manipulation, canvas drawing, graph navigation, app-level operations) through app.mcp-tools. Preset-based tools like space_unit_list, space_unit_edit, and space_unit_apply_patch exposed through internal presets (llm/presets/builtins/units.fnl) are internal Space agent tools and expect the full app runtime.
The external registry serves external user-unit development and exposes loader-neutral operations (space_unit_resolve, space_unit_inspect, space_unit_read_source, space_unit_apply_patch, space_unit_create_source, space_unit_run_tests, space_unit_reload, space_unit_read_log, space_unit_snapshot, space_unit_list) through llm/external-unit-mcp/tools.fnl.
Do not add Space-specific user-unit behavior to global ~/.config/opencode.
Starting the Server
SPACE_DISABLE_AUDIO=1 SPACE_ASSETS_PATH="$(pwd)/assets" \
./build/space -m tools.external-unit-mcp-server:mainThe server boots a headless engine, creates a temporary data directory for the isolated OpenCode config, starts a loopback HTTP/SSE MCP server, and prints:
MCP_URL=http://127.0.0.1:<port>/mcp
OPENCODE_XDG_CONFIG_HOME=/tmp/ext-unit-mcp-server-<...>/external-unit-opencode-configThe server prints OPENCODE_XDG_CONFIG_HOME= as a label; the OpenCode process itself reads the standard XDG_CONFIG_HOME environment variable. See the next section for how to connect.
Connecting an External OpenCode Session
The server prints OPENCODE_XDG_CONFIG_HOME=<path> as a label; the actual environment variable OpenCode consumes is XDG_CONFIG_HOME. Point an external OpenCode session at the printed config root without modifying global ~/.config/opencode:
XDG_CONFIG_HOME=<printed-config-root> opencode ...The bridge writes an isolated opencode.json inside that directory that:
- Denies all native tool permissions (filesystem, shell, web, etc.)
- Enables only the
space-unit-devMCP server pointing at the loopback URL
The external session only has access to the space_unit_* MCP tools. It cannot read arbitrary files or run arbitrary commands through the host.
Resolution Workflow
External unit development follows this high-level workflow:
space_unit_list— List all external units with loader-neutral handles.space_unit_resolve— Find a unit by natural-language description; returns ranked candidates.space_unit_inspect— Inspect a unit by id: loader, source artifacts, lifecycle exports (init/drop/snapshot/restore), and edit/test capabilities.- Read source —
space_unit_read_sourcereturns the content of a source artifact and its current hash. - Edit source —
space_unit_apply_patchapplies an edit (exact replacement or unified diff patch) with optional stale-content detection viaexpected_hash.space_unit_create_sourcecreates a new source file inside a directory-based unit. - Test —
space_unit_run_testsruns tests for a unit by executing its test module in a subprocess. - Reload —
space_unit_reloadtriggers a reload of the unit to reflect its current source state and reports active activity reactivation evidence when an activity was active before reload. - Read log —
space_unit_read_logreads recent lines from the application log with optional filtering and pagination. - Snapshot —
space_unit_snapshotcaptures a unit's current state for later restoration.
Safety Model
Loopback-Only Binding
The external MCP server binds to 127.0.0.1 by default. Non-loopback binds are disabled.
Isolated OpenCode Config
The bridge writes an isolated config that denies all native tool permissions (filesystem, shell, web, etc.). External OpenCode sessions cannot read arbitrary files, write outside the bridge-managed config, or run commands.
Stale-Content Protection
space_unit_apply_patch supports an optional expected_hash parameter. When provided, the patch is rejected if the file's current hash no longer matches. This prevents concurrent edits from silently overwriting each other.
Path Containment
All source read/write operations resolve source-ids relative to the unit root and enforce path containment. Source-ids must be relative, must not contain .. segments or NUL bytes, and must not escape the unit directory. Symlink ancestors and targets are rejected.
Loader Capability Checks
Tools fail loudly when a loader lacks a requested capability. For example, non-filesystem loaders cannot read or write source files.
Risk Labels
Each external tool carries a risk label (normal, filesystem-read, filesystem-write, shell, destructive) exported via get-tool-risks.
Common Local Storage Is Not the Contract
app.code-dir — the Space user-data code directory — is common local storage where user units happen to live today. It is an implementation detail, not the external contract. The external tools operate on unit handles and source-ids, not on raw filesystem paths.
External OpenCode sessions must never construct paths into app.code-dir. Future units may be backed by database records, remote hosts, generated stores, or other loaders.
Direct Filesystem Edits
Direct filesystem edits are an escape hatch only. The external MCP tools (space_unit_apply_patch, space_unit_create_source, space_unit_read_source) are the supported editing path. Direct filesystem edits skip stale-content protection, path containment, and reload integration.
If an external OpenCode session must use native filesystem tools, that requires explicit human approval and a Space-reported local filesystem source handle.
Tool Reference
space_unit_list
List all external units with loader-neutral handles. Returns unit-id, loader, source-handle, edit-capabilities, test-capabilities, and commit-capability for each unit. Sorted by unit-id.
space_unit_resolve
Resolve a unit from a natural-language description. Accepts:
description(string, required): natural-language description to matchlimit(integer, optional): max candidates to return (default 20)
Returns ranked candidates with unit-id, confidence (0.0–1.0), and evidence. Matches against unit id, module name, and source file basenames.
space_unit_inspect
Inspect a unit by id. Accepts unit_id (string, required). Returns:
unit-id,loadersource-handlewithsource-id,kind,primary,size,hashlifecyclewithinit,drop,snapshot,restoreexport namessource-artifactslisting all source files with hashes
space_unit_read_source
Read the content of a unit source artifact. Accepts:
unit_id(string, required)source_id(string, required): the source artifact identifier
Returns unit-id, source-id, content (the file text), and hash (sha256-prefixed).
space_unit_apply_patch
Apply an edit to a unit source file. Accepts:
unit_id(string, required)source_id(string, required)patch(string): a unified diff patchold(string): exact text to find and replacenew(string): replacement textexpected_hash(string, optional): reject patch if file hash differs
Exactly one of patch or old+new must be provided. The unit is automatically reloaded after a successful patch. On reload failure, the original source is restored. On patch-apply failure in diff mode, the source is also restored.
space_unit_create_source
Create a new source file inside a directory-based unit. Accepts:
unit_id(string, required)source_id(string, required): new file name relative to unit rootsource(string, required): file content
Rejects overwriting existing files. Creates parent directories as needed. Validates Fennel source before writing. The unit is reloaded after creation. On reload failure, the new file is removed.
space_unit_run_tests
Run tests for a unit. Accepts:
unit_id(string, required)test_name(string, optional): test suffix name (default: "init")
Executes <module-name>.test-<test-name>:main in a subprocess with a headless engine. Returns passed, exit-code, stdout, and stderr.
space_unit_reload
Reload a unit to reflect its current source state. Accepts unit_id (string, required). Returns structured reload evidence:
unit-idreloadedactivity.active-activity-beforeactivity.active-activity-afteractivity.reactivation-attemptedactivity.registered-after?activity.has-active-session-after?activity.errorwhen reactivation fails
Successful space_unit_apply_patch and space_unit_create_source responses also include reload-result and activity fields from the post-write reload.
space_unit_read_log
Read recent lines from the application log. Accepts:
grep(string, optional): filter lines containing this substringlines(integer, optional): number of recent lines (default 100)offset(integer, optional): start from this line numberlimit(integer, optional): max lines when using offset
The response never exposes the raw native log-path through the external API.
space_unit_snapshot
Capture a unit's current state for later restoration. Accepts unit_id (string, required). Returns unit-id, supported (bool), and state if supported.
Architecture
Service Layer (llm/external-unit-mcp/service.fnl)
ExternalUnitService wraps UnitManager and provides loader-neutral operations: unit listing, inspection, resolution (token-based matching), source reading/writing/patching, test execution, log reading, and snapshot capture.
Internal helpers handle:
classify-loader— maps unit source type to loader name ("filesystem" for user units with owned paths)derive-source-artifacts— enumerates files from owned-paths with hashesresolve-source-path— safe file path resolution with containment checks and symlink rejectionvalidate-fnl-source— compiles Fennel source to catch syntax errors early
Tools Layer (llm/external-unit-mcp/tools.fnl)
Wraps the service in MCP tool definitions using ToolRegistry with namespace prefix space_. Each tool carries a risk label.
Bridge Layer (llm/external-unit-mcp/bridge.fnl)
ExternalUnitMcpBridge owns the loopback MCP HTTP server and isolated OpenCode configuration. It:
- Creates a temporary data directory
- Starts an HTTP/SSE MCP server on a random loopback port
- Writes an isolated
opencode.jsondenying all native permissions - Exposes
status()for connection diagnostics andopencode-env()for the environment variables an external session must use
Standalone Entrypoint (tools/external-unit-mcp-server.fnl)
Boots a headless engine, initializes app dirs, loads user code units, creates the service and tool registry, starts the bridge, prints the connection environment, and enters the run loop.
Files
Fennel
assets/lua/llm/external-unit-mcp/service.fnl— loader-neutral unit operationsassets/lua/llm/external-unit-mcp/tools.fnl— MCP tool definitionsassets/lua/llm/external-unit-mcp/bridge.fnl— loopback server and isolated configassets/lua/tools/external-unit-mcp-server.fnl— standalone entrypoint
Tests
assets/lua/tests/test-external-unit-mcp.fnl— service, tools, bridge, and integration tests
Test Command
FENNEL_PATH="$(pwd)/assets/lua/?.fnl;$(pwd)/assets/lua/?/init.fnl" \
FENNEL_MACRO_PATH="$(pwd)/assets/lua/?.fnl;$(pwd)/assets/lua/?/init.fnl" \
SKIP_KEYRING_TESTS=1 XDG_DATA_HOME=/tmp/space/tests/xdg-data \
SPACE_DISABLE_AUDIO=1 SPACE_ASSETS_PATH="$(pwd)/assets" \
./build/space -m tests.test-external-unit-mcp:main