---
title: "Existing MCP packages"
description: "Give agents a tool from PyPI, npm, a container image or a remote MCP server without writing code."
canonical_url: "https://docs.oz0.ai/build/plugins/packages"
---
# Existing MCP packages

> Give agents a tool from PyPI, npm, a container image or a remote MCP server without writing code.

For: plugin authors.

Wrap an existing MCP server in a plugin by naming its package or remote endpoint. Give each tool an ID prefixed with your plugin's name, such as `acme_fetch` in `acme-web`. An unqualified ID such as `fetch` may already belong to another plugin in the tenant.

## From PyPI

```yaml [oz0-plugin.yaml]
name: acme-web
tools:
  - id: acme_fetch
    package: pypi:mcp-server-fetch==2026.8.18
    affinity: any
```

Each `pypi:` package gets a virtual environment of its own, built with uv on the node. The node starts it with the package's own name as the command; when the command differs, set it with `mcp`:

```yaml
- id: acme_git
    package: pypi:mcp-server-git==<version>
    mcp: ["mcp-server-git", "--repository", "/srv/repo"]
```

## From npm

```yaml [oz0-plugin.yaml]
tools:
  - id: acme_everything
    package: npm:@modelcontextprotocol/server-everything@<version>
```

npm packages run on Node.js 24 LTS, which the node installs on first use. Install scripts do not run, and the package starts as `node <bin script>`, without a shell.

## A remote server

```yaml [oz0-plugin.yaml]
tools:
  - id: acme_search
    url: https://mcp.example.com/mcp
```

Remote MCP servers are reached over Streamable HTTP. Use `https://`; `plugin lint` warns about `http://`.

## Pinning and hashes

Every `pypi:` and `npm:` package is pinned twice: to one version, and to the hashes of its files. When a plugin is installed or updated, the edge resolves the package and every package it depends on against the index, and writes the result into the plugin's bundle:

- PyPI: a requirements file with each package at `==<version>` and the SHA-256 of every file of that version a node may install, worked out for each platform nodes run on (Linux x86-64 and arm64, macOS arm64 and x86-64, Windows x86-64, Python 3.13), with a marker where a package is needed on some platforms only.
- npm: a `package-lock.json` (lockfile version 3) with each package's `integrity`, laid out as npm lays out a tree.

Nodes install only with them: `uv pip install --require-hashes` and `npm ci`. A file whose hash is not the one the edge pinned is refused, and the plugin's status on the node names the package, the hash the edge pinned and the hash it got:

```text [oz0 plugin list --json, the node's error]
tool acme_fetch: a package's hash changed: httpx==0.28.1 (for pypi:mcp-server-fetch==2026.8.18) has hash sha256:6e13f1…,
not the sha256:d13cc1… the edge pinned at install; refused
```

A package version keeps the pins it got when it was first installed. An update that resolves a version the installed plugin already pinned takes the installed files and hashes, never one the index added since: an index does not change the files of a version it published, so a mirror or proxy that does is caught before any node installs from it.

The edge refuses the update, naming the package and both sets of hashes, when a pinned file now has another hash, or when a platform the package installs on is left without a pinned file (for example a pinned wheel that is gone while another file with other bytes is served under a new name). A pinned file the index no longer serves is only a warning while other pinned files cover the platforms it covered: PyPI lets a maintainer delete one file of a release, and a partial mirror serves fewer.

A file the index added under a pinned version is left out, with a warning, which `plugin update` prints even when the plugin is up to date. An npm version has one tarball, so its integrity must stay the same.

The edge also checks what the index says before it writes it into the files nodes run. A hash that is not a SHA-256, an extra or a dependency name of the wrong shape, or an npm integrity that is not SHA-256, SHA-384 or SHA-512 makes the install fail, with the package named.

A package without a version gets the newest one the index lists at install time, so every node runs the same one; `plugin lint` warns about it. For npm, the edge takes only releases at least seven days old, as npm's `before` does, for the package and every package it pulls in: a `latest` that is younger gives way to the newest release that is not, a range takes the newest release of that age it allows, and a version a manifest names exactly that is younger is refused until its week has passed (the error says from when).

It reads the registry's full metadata for the publish dates, and refuses a registry that gives none; an [offline folder](https://docs.oz0.ai/fleet/offline-install)'s packages count as they are. An npm range or tag (`npm:x@^1.2`, `npm:x@latest`) is an error: write the exact version you tested, or none. A version the index marks as yanked is skipped unless the manifest names it.

The resolver takes the newest version that satisfies every constraint it has seen, without backtracking. A package whose dependencies need backtracking, or that depends on a Git URL or a local file, is refused at install with the package named; lock it in a project of the plugin's own instead (below).

The edge asks `pypi.org` and `registry.npmjs.org`, or your mirrors: set `OZ0_PLUGIN_PYPI_URL` and `OZ0_PLUGIN_NPM_URL` in the edge's environment. `OZ0_PLUGIN_NPM_URL` covers every npm request of the edge, including the bin of an imported plugin's `npx` server and a marketplace's npm entry that names no registry. A mirror URL may carry its credentials (`https://user:token@mirror.example/...`): the edge sends them to the mirror's own host only, a package file the mirror lists on that host included, never to another host or a registry a marketplace names, and errors show the URL without them.

Nodes download the files from the index their own environment sets (`UV_INDEX_URL` or `UV_DEFAULT_INDEX`, npm's `registry`), and the hashes hold whichever mirror serves them. A release age in a node's environment (npm's `npm_config_min_release_age` or `npm_config_before`, uv's `UV_EXCLUDE_NEWER` and `UV_EXCLUDE_NEWER_PACKAGE`) does not reach the installs a lock decides (the edge's npm locks, which take only releases seven days old, or a plugin's own `package-lock.json` or `uv.lock`): the lock decided the versions, and that node would otherwise refuse a pinned one. A plugin's own locks are held to it before the install instead ([Lockfiles](https://docs.oz0.ai/build/plugins/packages#lockfiles)).

The edge's `pypi:` locks are the exception: the edge takes PyPI releases however young, so a node with `UV_EXCLUDE_NEWER` keeps it for them, and refuses a pinned file younger than its rule (or one its index gives no upload time, as an offline folder's packages have none) with an error that says so, rather than install past the operator's rule. Pin an older version, or wait until the file is old enough. Every other `npm_config_` and `UV_` setting (a mirror, a proxy, a certificate, a timeout) does.

An install no lock decides, such as a development plugin's package or a project without a lock, resolves versions on the node and keeps the release age too. A node installs the files the edge pinned without uv's configuration files, the plugin's own `[tool.uv]` settings included: they were for resolving, and the edge has resolved. See [Outbound connections](https://docs.oz0.ai/concepts/outbound-connections).

### Installing offline

A cluster with no way out installs plugins' packages from an [offline folder](https://docs.oz0.ai/fleet/offline-install). With `server start --offline-dir`, the edge pins against the folder's PyPI and npm files instead of any index, and offline nodes install from the edge's mirrors of them, with the same hashes. A plugin whose locks, `uv.lock` or `package-lock.json` name a file the folder lacks is refused at install, with what is missing:

```text [Terminal]
$ oz0 plugin install hub:com.example/weather@1.4.0
Error: pluginsrc: the cluster installs from its offline folder, which lacks 1 package(s) this plugin installs:
npm left-pad 1.3.0 (package-lock.json; sha512:2c0b28…); pack them into the folder
```

Hash pins say the bytes are the ones the edge saw. They do not say the package is safe: what a package downloads when it runs is not locked, and vulnerability scanning is a separate step.

## Lockfiles

A plugin's own code locks its dependencies itself: a Python plugin with a `pyproject.toml` commits its `uv.lock`, a Node.js plugin with a `package.json` commits its `package-lock.json`. Nodes install them with `uv sync --frozen` and `npm ci`.

`uv sync --frozen` and `npm ci` install what a lock says and read no release date, and the edge checks only that the lock is there. So a node whose environment sets a release age (`UV_EXCLUDE_NEWER` or `UV_EXCLUDE_NEWER_PACKAGE` for uv; `npm_config_min_release_age` or `npm_config_before` for npm) holds a plugin's own lock to it before the install, by what each kind of entry can answer:

<table>
<thead>
  <tr>
    <th>
      What the lock pins
    </th>
    
    <th>
      On a node with an age
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A file from an index or a registry
    </td>
    
    <td>
      A package younger than the configured age, or with no date, is refused. See <a href="#how-package-age-is-checked">
        age checks
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      A path, a directory, a workspace member, or a <code>
        file:
      </code>
      
       entry
    </td>
    
    <td>
      Passes with nothing asked. It came with the plugin, whose digest covers it, and it has no release to be young.
    </td>
  </tr>
  
  <tr>
    <td>
      A package npm's lock nests in another with <code>
        inBundle
      </code>
      
       and no address
    </td>
    
    <td>
      Asked the same way, because npm goes by what the parent bundles and not by that note, and fetches what no parent bundles. A version the registry does not hold passes: it can only have come in its parent's tarball.
    </td>
  </tr>
  
  <tr>
    <td>
      A URL or a git repository
    </td>
    
    <td>
      Refused, in words that say so: it gives no release date, so no age can be asked of it. For npm that is any <code>
        resolved
      </code>
      
       other than a registry's <code>
        &lt;name>/-/&lt;base>-&lt;version>.tgz
      </code>
      
      , a forge's archive included. For uv's <code>
        registry
      </code>
      
      , it is anything but an address on the web, a path or <code>
        file:
      </code>
      
      . Take it from an index, or ship it in the plugin's folder.
    </td>
  </tr>
</tbody>
</table>

### How package age is checked

A file younger than the configured age, or one that gives no date, is refused on that node, with the age in the reason. uv asks the index named in the lock and uses the lock's hashes.

npm asks its configured registry, whatever host the lock names, because `npm ci` fetches a lock's address as written. Every hash in an entry's `integrity` must identify the file that registry holds for that version. An entry with an address and no hash is refused.

npm is asked once per pinned version, at most eight at a time, with the proxy and credentials `npm ci` would use.

A project with both an `npm-shrinkwrap.json` and a `package-lock.json` has both held to the age, since `npm ci` reads the first. An index or a registry that cannot answer refuses the install too, with its own error: nothing then says the pins are old enough. npm's `dev` packages, which `npm ci --omit=dev` leaves out, are not asked about, and a plugin's `.npmrc` cannot put them back. Without an age nothing is asked first.

The age is read from the node's environment, so one set only in an `.npmrc` file is not held against a lock; and a date comes from the index or registry a lock's author pointed at, so the check keeps a release that is too new away from a plugin, not a plugin that means harm.

The two ecosystems differ on [an offline node](https://docs.oz0.ai/fleet/offline-install#nodes). The folder's npm mirror keeps no publish dates, so on a node using it with an npm age, every registry pin of a plugin's own lock is refused as undated, and such a plugin installs there only without the age. A locked Python project installs from the folder's files with its hashes checked, and the age is not asked at all.

An install or an update of a plugin whose project has no lockfile is refused, by the edge and by nodes, with the fix:

```text [Terminal]
$ oz0 plugin install ./acme-web
Error: pluginsrc: uv.lock: the plugin's Python project has no uv.lock, so its dependencies would be resolved at install
instead of locked; installs and updates refuse it; run `uv lock` in the plugin's folder and commit uv.lock (oz0 dev only warns)
```

<table>
<thead>
  <tr>
    <th>
      Project
    </th>
    
    <th>
      Fix
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        pyproject.toml
      </code>
      
       without <code>
        uv.lock
      </code>
    </td>
    
    <td>
      <code>
        uv lock
      </code>
      
      , and commit <code>
        uv.lock
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        package.json
      </code>
      
       without <code>
        package-lock.json
      </code>
    </td>
    
    <td>
      <code>
        npm install --package-lock-only
      </code>
      
      , and commit <code>
        package-lock.json
      </code>
    </td>
  </tr>
</tbody>
</table>

`oz0 dev` and `plugin lint` only warn, so you can work on a plugin before its first lock. `oz0 plugin new` prints the command that writes the lock as the first step.

The rule covers the projects a node builds. A Claude Code plugin or an Agent Plugin ([imported](https://docs.oz0.ai/build/plugins/claude-code-and-agent-plugins)) with a `package.json` needs no `package-lock.json`, because nodes never run npm for it; npm never publishes one, so a marketplace's npm entries install as they are. An imported plugin with a `pyproject.toml` is built with `uv sync` and needs its `uv.lock`: ask its author to commit one, or install a fork that has it.

### Upgrading

Plugins installed before this rule keep running on every node, lockfile or not. Only their next update is refused until the plugin has its lockfile, and the next update of a `pypi:` or `npm:` tool pins the hashes of its packages. See [Upgrades](https://docs.oz0.ai/fleet/upgrades).

## From a container image

```yaml [oz0-plugin.yaml]
tools:
  - id: acme_fetch
    package: oci:ghcr.io/acme/fetch-mcp@sha256:4f6c…
```

An `oci:` package is an image, always pinned by its digest. The edge mirrors it when the plugin installs, and nodes with Docker or Podman run its MCP server confined, with no network unless the plugin declares egress: see [MCP servers from container images](https://docs.oz0.ai/build/plugins/container-images).

`binary:` packages (a downloaded executable) are accepted by the manifest but not run yet; the node skips them with a warning.


## Sitemap

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