Plugins

MCP servers from container images

Run an MCP server that ships as an OCI image, pinned by digest, mirrored by the edge and confined on nodes with Docker or Podman.

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.

oz0-plugin.yaml
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.

Terminal
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:

SettingValue
Networknone; 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 systemread-only; /tmp is a small private tmpfs, and HOME is /tmp; the server starts in the image's own WORKDIR
Filesone 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
Privilegesevery capability dropped, no-new-privileges, the node's own user and group
Limitscontainer: memory_mb (512), cpus (1), pids (256)
Environmentthe 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
Labelsorg.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 prune or docker 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.

Copyright © 2026