Write a tool
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:
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:
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:
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 inheaders:as it is, and goes with the listing and every call beside the token. The edge'sAuthorizationheader 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 testwrites 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
timeoutallows, 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:
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'shostparam, no client secret and norequires.secrets. - Overrides that tighten the server's hints and declare an output schema, and every write under
requires_approvalwith 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 lintreads a remote tool's functions from it, with the overrides merged, and--verifiedchecks it (OZ-S015). When the vendor's server changes, save itstools/listanswer 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
delegateon the aliasdefault, with an eval whose answer is recorded, sooz0 plugin testreplays 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.
_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: nodeand apackage.json: the node installs its dependencies from yourpackage-lock.jsonand runs it on its own Node.js 24 LTS, TypeScript without a build step.oz0 plugin new --template ts-toolwrites 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
affinity | Runs on | Use for |
|---|---|---|
node (default) | The job's home node | Tools that touch the machine: files, devices, local models, local services |
any | Any node that has the plugin | Stateless 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.
Related tasks
- Describe tool functions: Control what the model sees and document function behaviour.
- Return results and files: Fit results to a model budget and pass large data between tools.
- Handle tool call lifecycle: Handle cancellation, timeouts, idempotency and a busy service.
- Pin tool definitions: Verify tool snapshots and publish a measured function listing.
- Call a model from a tool: Use sampling or a gateway token and report outside service cost.
- Tool labels and approvals: Carry labels and argument origins through tool policy checks.
Previous section links
- Section: a gateway token for what sampling cannot express
- Section: approvals
- Section: cancels time limits and the idempotency key
- Section: files between tools
- Section: its manual
- Section: labels
- Section: large results
- Section: mcp sampling
- Section: progress and retries that resume
- Section: remote endpoints are pinned
- Section: report a busy service never retry it
- Section: report what your own services cost
- Section: results that fit a budget
- Section: retry safe functions
- Section: say what a tool does
- Section: snapshots a tool may not change what it says it does
- Section: the measured listing
- Section: the size budget
- Section: tools that need a model
- Section: what a definition costs
- Section: what the model sees
- Section: what the snapshot shows
- Section: where arguments came from