Existing MCP packages
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
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:
- id: acme_git
package: pypi:mcp-server-git==<version>
mcp: ["mcp-server-git", "--repository", "/srv/repo"]
From npm
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
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'sintegrity, 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:
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'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:[email protected]/...): 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).
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.
Installing offline
A cluster with no way out installs plugins' packages from an offline folder. 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:
$ oz0 plugin install hub:com.example/[email protected]
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:
| What the lock pins | On a node with an age |
|---|---|
| A file from an index or a registry | A package younger than the configured age, or with no date, is refused. See age checks. |
A path, a directory, a workspace member, or a file: entry | Passes with nothing asked. It came with the plugin, whose digest covers it, and it has no release to be young. |
A package npm's lock nests in another with inBundle and no address | 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. |
| A URL or a git repository | Refused, in words that say so: it gives no release date, so no age can be asked of it. For npm that is any resolved other than a registry's <name>/-/<base>-<version>.tgz, a forge's archive included. For uv's registry, it is anything but an address on the web, a path or file:. Take it from an index, or ship it in the plugin's folder. |
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. 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:
$ 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)
| Project | Fix |
|---|---|
pyproject.toml without uv.lock | uv lock, and commit uv.lock |
package.json without package-lock.json | npm install --package-lock-only, and commit package-lock.json |
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) 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.
From a container image
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.
binary: packages (a downloaded executable) are accepted by the manifest but not run yet; the node skips them with a warning.