Parts

Write a tool

Expose functions through an MCP server or a flow.

For: plugin authors.

Write a tool to expose actions that agents and flows can call. Configuration names those actions functions and addresses each one as <tool>.<function>, using the tool's manifest id.

A tool call invokes one function with specific arguments. See core terms for the terms used throughout the build guides.

MCP servers and flows

A plugin can connect a tool to an MCP server in three ways:

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

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

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

For a local MCP server, use mcp to name its command. Use package to install an existing package in its own environment; it can also specify mcp when its command differs from the package's name. Use url for a remote MCP server.

A tool can also run without an MCP server: flow: <flow> exposes one of the plugin's flows as a tool, whose one function is <id>.<flow>. An agent decides whether to call it; the flow's steps run as they always do, each through its own policy and approvals, and the tool's annotations are derived from them.

Name tools after your plugin

Agents call a function as <tool>.<function> without naming the plugin, so a tool ID belongs to one plugin per tenant: an install whose tool ID another plugin of the tenant has is refused.

Start every tool ID with the plugin's name, dashes as underscores, or with its first dash-separated segment: the plugin acme-github has acme_github, acme_fetch and acme_search, never github, fetch or search, which another plugin or a verified one would want too.

Agent names follow the same rule with dashes (acme-reviewer). The prefixes oz0_ and oz0- belong to Orchestrator Zero's verified plugins. plugin new writes names that keep the rule.

Headers of a remote server

A remote server that wants a token gets it in a header whose value names a tenant secret the plugin declares:

oz0-plugin.yaml
requires:
  secrets: [SEARCH_TOKEN]
tools:
  - id: acme_search
    url: https://mcp.example.com/mcp
    headers:
      Authorization: "Bearer ${SEARCH_TOKEN}"
      X-Team: platform

Each node fills in ${SEARCH_TOKEN} from the tenant's secret when it connects, and sends the headers with every request; a node without the secret does not start the tool. The manifest never holds the credential itself: plugin lint refuses a header that carries one (Authorization, Cookie, a name with token, key, secret or auth) without a ${SECRET}, a ${...} that names no declared secret, credentials (a header named like one, or any header whose value holds a ${SECRET}) over plain http:// to another machine, and the headers the MCP transport sets itself (Host, Content-Type, Accept, Mcp-Session-Id and the like).

At most 16 headers, each one line of at most 4 KB.

Bind the secret to the server's host (requires.inject, or a tenant admin's binding) and the node sends its placeholder instead, and its credential proxy puts the value on requests to that host only; a connection bound to the server's host gets its token into the binding's header the same way, call by call (Credential injection).

Remote servers on connections

A remote server that takes OAuth, such as a vendor's official MCP server, signs in through a connection: the customer's own OAuth app, connected once by a tenant admin. Name the connection on the tool, and each call gets the token the edge mints for that call:

oz0-plugin.yaml
tools:
  - id: tracker
    url: https://{params.host}/api/v4/mcp   # or a literal https:// URL
    connection: tracker
    functions:                              # overrides of what the server lists, by its function names
      list_issues:
        annotations: {readOnlyHint: true}
      update_issue:
        annotations: {destructiveHint: true}
      get_issue:
        output_schema: {type: object, properties: {id: {type: integer}}, required: [id]}
        usage: {service: tracker-ai, unit: credit, units: 1, cost_usd: 0}
connections:
  - name: tracker
    tools: [tracker]
    params:
      - {name: host, description: "The instance, such as gitlab.example.com", default: gitlab.com}
      - {name: client_id, description: The OAuth app's client ID, required: true}
    # steps, token and token.refresh: see Connections
    scopes:
      - functions: ["tracker.*"]               # every function the server lists

A token per call

Each call opens an MCP session of its own with the call's token in its Authorization: Bearer header, as MCP's authorization asks of every request, and closes it. Two calls never share a session, so two jobs' calls at once never see each other's token. The token is never in the call's _meta, the model's view, the job's history, a log or a trace. The node lists the server's tools with a token of the connection too, so a server that answers its listing only to signed-in clients is listed.

Connect first

Installed before its connection is connected, the tool is not ready (the plugin is), and the node lists it once the connection is; the runtime then starts again to offer its functions. Its connection's scope names its functions, or <tool>.* for every one; the listing gets the token of the first scope that names any of them, so put the scope with the least access (a read-only one) first: the node holds the listing's token whenever its runtime starts.

The connection's token is one the edge has without a call (kind secret, client-credentials or jwt-exchange): lint refuses identity and on_behalf_of, whose tokens are minted for one call, so the tool could never be listed, and federated, which gives a cloud's credentials rather than a bearer token. The tool sends no Authorization header of its own and needs no requires.inject binding.

A host per tenant

https://{params.host}/<path> takes only the host and port from the connection's param, for self-managed instances (GitLab, Jira Data Center, a ServiceNow instance); the path stays literal and the scheme is https. That host is the only place the tool's calls go: the edge resolves it and checks every address, the node connects only to the addresses the edge checked (it never resolves the name again, so a name cannot point elsewhere between the check and the call), and its client follows no redirect and refuses any other origin.

A host that resolves to a private address is refused unless the cluster's admin lists its network (server start --connect-allow-net); one that resolves to an address of the node itself, its link or a cloud's metadata service is refused whatever is listed, except on a dev server. The connection needs steps: they record the host when it is connected, and changing the param gets no token until it is connected again, so a token issued for one host never reaches another. Lint refuses such a URL on a connection without steps, whose token would follow the param anywhere.

  • Headers of the tool's own. A header that is no credential, such as the toolsets a vendor's server lists (GitLab's X-Gitlab-Enabled-Mcp-Server-Toolsets), is written in headers: as it is, and goes with the listing and every call beside the token. The edge's Authorization header always wins; a header named like a credential must come from a tenant secret.
  • Listed late. A remote tool on a connection lists nothing until the connection is connected, so its part of the version's snapshot is set when it first lists, not by the plugin's first report: a plugin with a local tool beside it (an event source's normaliser) does not wait for approval of its first listing. A change after that waits as any tool's does. plugin test writes the local tools' measured listing while the remote one waits for its connection, when its snapshot is committed (snapshots/<tool>.json).

The server's annotations count once approved

readOnlyHint, destructiveHint and openWorldHint count under the tenant's tool policy as a local server's do, but only while the server's listing is its snapshot: a server that changes them waits for approval again. Until the first snapshot reaches a node they are hints only. From then on a failed call of a function the snapshot says is read-only (or idempotent, with a server that honours the key) is made again, as a local server's is. A remote server's results still count as outside text, and its calls as outbound, whatever it says.

  • Long calls hold no slot. A server that speaks MCP's Tasks extension may answer a long call with a task, which the job follows from timers in its history while the node's slot serves other calls, where the tenant names the connection (Long calls as tasks). A call that waits for its answer reads it as long as the tool's timeout allows, however quiet the server is meanwhile.

Overrides

annotations sets a hint the server does not give, and tightens one it gives (readOnlyHint: false, destructiveHint: true, openWorldHint: true; destructiveHint: true also clears the server's readOnlyHint: true, which the policy reads first); only a plugin installed from OZ's own index as an ai.oz0 listing, whose manifest was reviewed, may loosen one (an ai.oz0 name in any other index counts as any other plugin). output_schema (an object schema) is what the node validates the server's structured result against: a result that does not match is an error the model reads, and a result with text only stays text.

Cards of the function's results are drawn from it. usage is what each successful call spends, in the usage report's shape, for a vendor that charges credits per call and reports none. ai_label is a JSON pointer to the string argument people read what the call writes in, such as /body of a comment: a vendor's server never reads the label a local tool gets in _meta, so the node puts the tenant's AI label ([AI agent] ) at the start of that string, once, after the tool policy and any approval; with the label turned off it adds nothing.

Wrap a vendor's remote server

A connector that wraps a vendor's own remote MCP server starts from a template that holds every piece above:

Terminal
oz0 plugin new tracker --template mcp-connector --name tracker \
  --description "An issue system's own MCP server, on the customer's own OAuth app"
cd tracker && uv lock && uv sync && uv run pytest
oz0 plugin lint --verified .
  • One url: tool on a connection with the customer's own public OAuth app: authorization code with PKCE, refresh tokens that rotate, the host from the connection's host param, no client secret and no requires.secrets.
  • Overrides that tighten the server's hints and declare an output schema, and every write under requires_approval with the default deciders a plugin may ship (role: operator, not_starter: true).
  • A pin: snapshots/<tool>.json, the functions the server is expected to list, each with the hash a node's snapshot gives its listing, and "answers": "text" for a function the vendor answers in text only. plugin lint reads a remote tool's functions from it, with the overrides merged, and --verified checks it (OZ-S015). When the vendor's server changes, save its tools/list answer and write the snapshot again: uv run python tests/snapshot.py --write --from listing.json --listed-from "<URL>, read <date>".
  • A fake of the vendor's server (tests/fake_server.py, MCP over HTTP with a bearer token) and contract tests (tests/test_contract.py): the fake lists the snapshot, overrides and approvals name listed functions, every write waits for a person, each result fits its declared schema.
  • A delegate agent tagged delegate on the alias default, with an eval whose answer is recorded, so oz0 plugin test replays it offline while the tool waits for its connection.
  • README.md as the docs page, with a Limits section.

The build kit's oz0-connector skill takes a coding agent through it, from the choice between wrapping the vendor's server and building on its API to the docs page.

A vendor's server never reads the AI label Orchestrator Zero sends in a call's _meta. For every function that posts something people read, name the argument they read with functions.<name>.ai_label (the template gives add_commentai_label: /body): the node puts the tenant's label at its start, after the approval, and plugin lint --verified checks that the snapshot's input schema gives that argument as a string, or as a member of an object that takes any key (such as /fields/description of an issue's open fields). Keep such functions behind approval as well, and say under Limits where the label goes.

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("acme-github")


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

  • TypeScript or JavaScript in the plugin itself, with runtime: node and a package.json: the node installs its dependencies from your package-lock.json and runs it on its own Node.js 24 LTS, TypeScript without a build step. oz0 plugin new --template ts-tool writes one; see TypeScript tools.
  • Publish it to npm and use package: npm:<name>@<version>.
  • Run it as a remote server and use url:. That works for Go, Rust or anything else.

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

From an OpenAPI document

An API with an OpenAPI 3.0 or 3.1 document needs no hand-written server: oz0 plugin import openapi <file|url> --name NAME --out DIR writes a plugin with one function per operation, marked read-only, writing or destructive from its HTTP method, calling only the API's host, with the API's key injected by the node. See Import an OpenAPI document.

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: acme_github
    mcp: ["python", "-m", "acme_github.mcp"]
    env:
      GITHUB_API_URL: https://api.github.com

The dynamic loader's variables (LD_*, DYLD_*, GLIBC_TUNABLES, GCONV_PATH), PATH and the node's own (OZ0_*) cannot be set here, nor can a secret have those names, and OPENAI_BASE_URL and ANTHROPIC_BASE_URL cannot name the node's loopback: they would change what runs outside the sandbox or open the node's own ports to it. A tool that asks a model says uses_model, and the runtime gives it the address. A tool from an OCI image also cannot name what its container runtime reads on the host, such as DOCKER_HOST, CONTAINERS_CONF, HOME or XDG_*.

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.

Previous section links
Copyright © 2026