Build plugins

Tools

Tools are MCP servers. Write your own in Python, or use any existing server.

A tool is an MCP server. Each of its functions is callable by agents as <tool>.<function>, where <tool> is the tool's id in the manifest.

Three kinds

oz0-plugin.yaml
tools:
  # Your own code, started in the plugin's environment
  - id: github
    mcp: ["python", "-m", "github_tools.mcp"]

  # An existing package, in an environment of its own
  - id: fetch
    package: pypi:mcp-server-fetch==2026.8.18
    affinity: any

  # A remote server over Streamable HTTP
  - id: search
    url: https://mcp.example.com/mcp

Set exactly one of mcp (your own code), package (an existing package, with mcp naming the command when it is not the package's own name) or url.

Your own code in Python

With runtime: python and a pyproject.toml, the node builds the plugin's own environment with uv sync --frozen from your uv.lock, so every node runs exactly the versions you locked. A command starting with python runs that environment's Python; any other command is looked up in the environment first.

The templates use the official MCP SDK (mcp==2.2.0):

from mcp.server import MCPServer

server = MCPServer("github-tools")


@server.tool()
def get_pr(repo: str, number: int) -> dict:
    """Title, author, state and changed files of a pull request."""
    ...

Write good docstrings and type hints: they are what the model sees when it decides whether to call a function.

Other languages

Tools speak MCP, so any language with an MCP SDK works:

  • Publish it to npm and use package: npm:<name>@<version>. TypeScript and JavaScript servers run on Node.js 24 LTS on the node.
  • Run it as a remote server and use url:. That works for Go, Rust or anything else.

Building your own Node, Go or binary code on the node, from the plugin's repository, is planned; the manifest's runtime: node | go | binary field is there for it.

Affinity: where a tool runs

affinityRuns onUse for
node (default)The job's home nodeTools that touch the machine: files, devices, local models, local services
anyAny node that has the pluginStateless tools: web fetchers, API clients

Environment

Tool processes get a minimal environment, not the runtime's, so they cannot read the node's local proxy tokens. Add variables with env:

  - id: github
    mcp: ["python", "-m", "github_tools.mcp"]
    env:
      GITHUB_API_URL: https://api.github.com

Secrets do not belong in env. Declare them in requires.secrets, and the tool gets each one as an environment variable of the same name, from the tenant's secrets:

requires:
  secrets: [JEV_API_KEY]
import os

api_key = os.environ["JEV_API_KEY"]

Only this plugin's processes get them, on the nodes that run it, and never from disk. See Secrets.

What the model sees

Providers do not allow dots in tool names, so the model sees <tool>__<function>, for example github__get_pr, and gets the function's text content as the result. Servers that return structured content also send it as text.

A tool call runs once. If it fails, the error goes back to the model as feedback, so it can try something else; it is not retried automatically, because tools can have side effects.

A function whose server marks it read-only or idempotent, with MCP's readOnlyHint or idempotentHint annotations, is the exception: a call that fails, because its server crashed or timed out, runs again, up to three times. An error the tool answers with still goes to the model. In Python's MCP SDK:

src/acme_tools/server.py
from mcp.types import ToolAnnotations


@server.tool(annotations=ToolAnnotations(read_only_hint=True))
def get_pr(repo: str, number: int) -> dict: ...

Snapshots: a tool may not change what it says it does

The first node that runs a plugin version lists its tools and takes a snapshot of each function: its description, its input and output schemas, and its annotations, with a hash. For an update with evals, that is the quality gate's test node. The catalog, and the Tools and skills page, show the functions as their snapshot has them.

From then on, nodes call a function only while it lists itself the same way. A tool that starts to describe itself differently, such as a remote MCP server whose owner changed a description or a schema, is refused:

team.hello lists itself differently than when its plugin's tools were approved, so it is not called; check the
change and approve it with: orchestrator-zero plugin approve-tools team

The plugin's other functions keep working. orchestrator-zero plugin list and the plugin's page in the web UI show which functions changed and on which node, and orchestrator-zero catalog --json has the new listings. Approve a change once you have looked at it:

Terminal
orchestrator-zero plugin approve-tools team

Nodes call the functions again within seconds, without restarting. The audit log records the snapshot, each change and each approval (plugin.tools.snapshot, plugin.tools.changed and plugin.tools.approve). A new version of the plugin gets a snapshot of its own.

The snapshot catches change, not a tool that misdescribes itself from the start: read a tool's listing before you install it.

Report what your own services cost

When a function calls a paid service of your own, such as a classifier, a search API or a small hosted model, put what it cost in the result's _meta under oz0/usage. The cost lands on the job that made the call, next to its model calls, and counts toward the job's budget:

import json

from mcp.server import MCPServer
from mcp.types import CallToolResult, TextContent

server = MCPServer("jev")


@server.tool()
def decide(state: str, questions: dict) -> CallToolResult:
    """Answer typed questions about a state, with a confidence for each answer."""
    answers = ask_the_service(state, questions)
    usage = {"service": "jev", "unit": "decision", "units": 1, "cost_usd": 0.0004}
    return CallToolResult(
        content=[TextContent(type="text", text=json.dumps(answers))],
        structured_content=answers,
        _meta={"oz0/usage": usage},
    )

service and cost_usd are required; unit and units say what you paid for. A list reports several services at once, up to 20. One report may claim at most $100, and names are letters, digits and . _ / : -, at most 64 characters. Hooks report the same way.

job get, usage and the web UI show these costs as reported by your plugin, and leave them out of model calls and tokens. The platform takes your plugin's word for them.

Approvals

requires_approval lists functions that pause the job until a person approves:

  - id: github
    mcp: ["python", "-m", "github_tools.mcp"]
    requires_approval: [github.merge]

Every call to them waits for an admin to approve or deny it in the web UI or with orchestrator-zero approval; a denied call is not made, and the model is told why. See Approvals.

Copyright © 2026