MCP servers from container images
For: plugin authors.
Run an MCP server from a container image by naming the image in the tool's package field. The MCP Registry calls these oci packages; a Claude Code plugin may start the same image with docker run.
The manifest below declares the tool's image, entrypoint arguments, limits, secret and permitted network host.
apiVersion: oz0/v1
name: acme-fetchers
version: 1.0.0
runtime: binary
requires:
secrets: [FETCH_TOKEN] # reaches the container by name, never on a command line
egress: [docs.example.com] # without egress the container has no network at all
tools:
- id: acme_fetch
package: oci:ghcr.io/acme/fetch-mcp@sha256:4f6c0d1b5e8a…
mcp: ["--user-agent", "oz0"] # arguments to the image's entrypoint; without them, its own command
container: {memory_mb: 256, cpus: 0.5, pids: 64}
agents: [agents/]
Pinned, and mirrored by the edge
An image is named by its digest: oci:<registry>/<repository>@sha256:<digest>. A tag alone is refused, since a tag can be moved to other bytes; Docker Hub's short names (mcp/fetch@sha256:…) work as Docker reads them.
When the plugin installs, the edge fetches the image from its registry, checks every manifest and blob against its digest, and keeps the images for linux/amd64 and linux/arm64 with the plugin's artifact.
Nodes download the blobs from the edge, as they download plugins, and never reach the registry; an image the registry deletes or changes later still runs as it was installed.
An image may weigh 512 MB at most, all its platforms together, and each blob (a layer) 128 MB. plugin preview checks the image's manifests and configs without downloading its layers. Images from registries that need a sign-in do not install yet.
Which nodes run it
A node finds Docker or Podman when it starts (docker version or podman version must answer) and reports it; the edge then labels the node runtime.oci=ready, and runtime.oci.arch with its architecture (amd64 or arm64). The plugin's agents that call a tool from an image require runtime.oci=ready, so their jobs go only to such nodes, and the catalog lists only those nodes for them.
When the images an agent calls are for one architecture only, the agent also requires runtime.oci.arch of it, so a node of the other architecture never takes its jobs; an agent whose images have no architecture in common does not install. A node without a container runtime, or without the image's architecture, runs the plugin without that tool, and says so in its status: plugin list --json shows it under leftOut.
OZ0_OCI_RUNTIME=podman oz0-node run # docker, podman, a program's path, or off
An agent of another plugin that calls the tool, or a flow whose tool step does, must ask for the label itself (requires: {labels: {runtime.oci: ready}}); plugin lint warns about flows. An operator's own runtime.oci label wins over the one the edge sets.
How it runs
The node loads the image once (docker load of the blobs it downloaded, checked against the image's config), and the runtime starts the server as a container of its own, speaking MCP over its stdin and stdout:
| Setting | Value |
|---|---|
| Network | none; with the plugin's egress, only the node's egress proxy, through a socket mounted read-only, which allows the tenant's list and the plugin's hosts and refuses the rest |
| Root file system | read-only; /tmp is a small private tmpfs, and HOME is /tmp; the server starts in the image's own WORKDIR |
| Files | one folder of the runtime's, read-only at /oz0/files: a file a call is given is there, and the call's _meta["oz0/files"] names it by that path |
| Privileges | every capability dropped, no-new-privileges, the node's own user and group |
| Limits | container: memory_mb (512), cpus (1), pids (256) |
| Environment | the tool's env and the plugin's secrets, passed by name; nothing of the node's, and no name its container runtime reads on the host (DOCKER_HOST, CONTAINERS_*, HOME, XDG_* and the like), which the node refuses |
| Labels | org.oz0.mcp=1 and org.oz0.node=<node ID>, to tell one node's containers from another's on one machine |
A tool can ask for gVisor with isolation: gvisor: it then runs only on nodes labelled isolation.gvisor=ready (rootful Docker or Podman with gVisor's runsc registered), under --runtime runsc, and never without it; a tenant can require the same for its outside-started jobs. See Sandbox tier 2.
A job that an outside event started gets a container of its own for its calls, as other tools get a sandboxed server of their own. A harness session that runs the plugin's MCP servers reaches such a server through the runtime, since it cannot start containers in its sandbox; each call still asks the job first.
The tool's functions are snapshotted by the first node that lists them (tool snapshots), whichever node set the rest of the plugin's snapshot: nodes without containers do not list them, and that is not a change.
Limits today
- Linux containers only. Docker Desktop on macOS may not pass the egress proxy's socket into its VM; tested on Linux with Docker 29.
- No mounts (but the files' folder), ports, devices or extra capabilities: a server that needs them does not carry over from a Claude Code plugin or the MCP Registry.
- Loaded images stay in the container runtime's store after the plugin is gone; remove them with
docker image pruneordocker image rm oz0.local/mcp-….
Operator controls
Configure --guarded-servers on the node; the console has no control for that option. Loaded-image cleanup remains an operator task, as described above.