---
title: "MCP servers from container images"
description: "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."
canonical_url: "https://docs.oz0.ai/build/plugins/container-images"
---
# 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.

```yaml [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`.

```bash [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:

<table>
<thead>
  <tr>
    <th>
      Setting
    </th>
    
    <th>
      Value
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Network
    </td>
    
    <td>
      none; with the plugin's <code>
        egress
      </code>
      
      , 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
    </td>
  </tr>
  
  <tr>
    <td>
      Root file system
    </td>
    
    <td>
      read-only; <code>
        /tmp
      </code>
      
       is a small private tmpfs, and <code>
        HOME
      </code>
      
       is <code>
        /tmp
      </code>
      
      ; the server starts in the image's own <code>
        WORKDIR
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Files
    </td>
    
    <td>
      one folder of the runtime's, read-only at <code>
        /oz0/files
      </code>
      
      : a <a href="https://docs.oz0.ai/reference/plugin-manifest/tools#files-between-tools">
        file a call is given
      </a>
      
       is there, and the call's <code>
        _meta["oz0/files"]
      </code>
      
       names it by that path
    </td>
  </tr>
  
  <tr>
    <td>
      Privileges
    </td>
    
    <td>
      every capability dropped, <code>
        no-new-privileges
      </code>
      
      , the node's own user and group
    </td>
  </tr>
  
  <tr>
    <td>
      Limits
    </td>
    
    <td>
      <code>
        container:
      </code>
      
       <code>
        memory_mb
      </code>
      
       (512), <code>
        cpus
      </code>
      
       (1), <code>
        pids
      </code>
      
       (256)
    </td>
  </tr>
  
  <tr>
    <td>
      Environment
    </td>
    
    <td>
      the tool's <code>
        env
      </code>
      
       and the plugin's secrets, passed by name; nothing of the node's, and no name its container runtime reads on the host (<code>
        DOCKER_HOST
      </code>
      
      , <code>
        CONTAINERS_*
      </code>
      
      , <code>
        HOME
      </code>
      
      , <code>
        XDG_*
      </code>
      
       and the like), which the node refuses
    </td>
  </tr>
  
  <tr>
    <td>
      Labels
    </td>
    
    <td>
      <code>
        org.oz0.mcp=1
      </code>
      
       and <code>
        org.oz0.node=&lt;node ID>
      </code>
      
      , to tell one node's containers from another's on one machine
    </td>
  </tr>
</tbody>
</table>

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](https://docs.oz0.ai/fleet/sandbox-tier-2/gvisor#gvisor).

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](https://docs.oz0.ai/build/parts/harnesses) 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](https://docs.oz0.ai/build/plugins/catalog)), 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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
