---
title: "Write a tool"
description: "Expose functions through an MCP server or a flow."
canonical_url: "https://docs.oz0.ai/build/parts/tools"
---
# 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](https://docs.oz0.ai/getting-started/concepts) for the terms used throughout the build guides.

<span id="three-kinds">



</span>



## MCP servers and flows

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

```yaml [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](https://docs.oz0.ai/build/parts/flows/flow-tools#flows-as-tools), 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:

```yaml [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](https://docs.oz0.ai/admin/connections) bound to the server's host gets its token into the binding's header the same way, call by call ([Credential injection](https://docs.oz0.ai/admin/credential-injection#remote-tools)).

### Remote servers on connections

A remote server that takes OAuth, such as a vendor's official MCP server, signs in through a [connection](https://docs.oz0.ai/build/parts/connections): 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:

```yaml [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](https://docs.oz0.ai/build/parts/tools/snapshots#snapshots-a-tool-may-not-change-what-it-says-it-does) 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](https://docs.oz0.ai/govern/tool-policy) as a local server's do, but only while the server's listing is its [snapshot](https://docs.oz0.ai/build/parts/tools/snapshots#snapshots-a-tool-may-not-change-what-it-says-it-does): 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](https://docs.oz0.ai/build/parts/connections/token-lifecycle#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](https://docs.oz0.ai/build/parts/tools/model-calls#report-what-your-own-services-cost) 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:

```bash [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](https://docs.oz0.ai/build/parts/tools/snapshots#snapshots-a-tool-may-not-change-what-it-says-it-does) 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](https://docs.oz0.ai/build/plugins/structure/lint-rules#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.

<note>

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_comment` `ai_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.

</note>

## 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`):

```python
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](https://docs.oz0.ai/build/parts/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](https://docs.oz0.ai/build/parts/openapi-import).

## Affinity: where a tool runs

<table>
<thead>
  <tr>
    <th>
      <code>
        affinity
      </code>
    </th>
    
    <th>
      Runs on
    </th>
    
    <th>
      Use for
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        node
      </code>
      
       (default)
    </td>
    
    <td>
      The job's home node
    </td>
    
    <td>
      Tools that touch the machine: files, devices, local models, local services
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        any
      </code>
    </td>
    
    <td>
      Any node that has the plugin
    </td>
    
    <td>
      Stateless tools: web fetchers, API clients
    </td>
  </tr>
</tbody>
</table>

## 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`:

```yaml
- 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:

```yaml
requires:
  secrets: [JEV_API_KEY]
```

```python
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](https://docs.oz0.ai/admin/secrets/plugin-secrets#secrets-for-plugins).

## Related tasks

- [Describe tool functions](https://docs.oz0.ai/build/parts/tools/definitions): Control what the model sees and document function behaviour.
- [Return results and files](https://docs.oz0.ai/build/parts/tools/results-and-files): Fit results to a model budget and pass large data between tools.
- [Handle tool call lifecycle](https://docs.oz0.ai/build/parts/tools/call-lifecycle): Handle cancellation, timeouts, idempotency and a busy service.
- [Pin tool definitions](https://docs.oz0.ai/build/parts/tools/snapshots): Verify tool snapshots and publish a measured function listing.
- [Call a model from a tool](https://docs.oz0.ai/build/parts/tools/model-calls): Use sampling or a gateway token and report outside service cost.
- [Tool labels and approvals](https://docs.oz0.ai/build/parts/tools/policy): Carry labels and argument origins through tool policy checks.

<legacy-sections>

- <span id="a-gateway-token-for-what-sampling-cannot-express">

Section

</span>

: [a gateway token for what sampling cannot express](https://docs.oz0.ai/build/parts/tools/model-calls#a-gateway-token-for-what-sampling-cannot-express)
- <span id="approvals">

Section

</span>

: [approvals](https://docs.oz0.ai/build/parts/tools/policy#approvals)
- <span id="cancels-time-limits-and-the-idempotency-key">

Section

</span>

: [cancels time limits and the idempotency key](https://docs.oz0.ai/build/parts/tools/call-lifecycle#cancels-time-limits-and-the-idempotency-key)
- <span id="files-between-tools">

Section

</span>

: [files between tools](https://docs.oz0.ai/build/parts/tools/results-and-files#files-between-tools)
- <span id="its-manual">

Section

</span>

: [its manual](https://docs.oz0.ai/build/parts/tools/definitions#its-manual)
- <span id="labels">

Section

</span>

: [labels](https://docs.oz0.ai/build/parts/tools/policy#labels)
- <span id="large-results">

Section

</span>

: [large results](https://docs.oz0.ai/build/parts/tools/results-and-files#large-results)
- <span id="mcp-sampling">

Section

</span>

: [mcp sampling](https://docs.oz0.ai/build/parts/tools/model-calls#mcp-sampling)
- <span id="progress-and-retries-that-resume">

Section

</span>

: [progress and retries that resume](https://docs.oz0.ai/build/parts/tools/call-lifecycle#progress-and-retries-that-resume)
- <span id="remote-endpoints-are-pinned">

Section

</span>

: [remote endpoints are pinned](https://docs.oz0.ai/build/parts/tools/snapshots#remote-endpoints-are-pinned)
- <span id="report-a-busy-service-never-retry-it">

Section

</span>

: [report a busy service never retry it](https://docs.oz0.ai/build/parts/tools/call-lifecycle#report-a-busy-service-never-retry-it)
- <span id="report-what-your-own-services-cost">

Section

</span>

: [report what your own services cost](https://docs.oz0.ai/build/parts/tools/model-calls#report-what-your-own-services-cost)
- <span id="results-that-fit-a-budget">

Section

</span>

: [results that fit a budget](https://docs.oz0.ai/build/parts/tools/results-and-files#results-that-fit-a-budget)
- <span id="retry-safe-functions">

Section

</span>

: [retry safe functions](https://docs.oz0.ai/build/parts/tools/definitions#retry-safe-functions)
- <span id="say-what-a-tool-does">

Section

</span>

: [say what a tool does](https://docs.oz0.ai/build/parts/tools/definitions#say-what-a-tool-does)
- <span id="snapshots-a-tool-may-not-change-what-it-says-it-does">

Section

</span>

: [snapshots a tool may not change what it says it does](https://docs.oz0.ai/build/parts/tools/snapshots#snapshots-a-tool-may-not-change-what-it-says-it-does)
- <span id="the-measured-listing">

Section

</span>

: [the measured listing](https://docs.oz0.ai/build/parts/tools/snapshots#the-measured-listing)
- <span id="the-size-budget">

Section

</span>

: [the size budget](https://docs.oz0.ai/build/parts/tools/definitions#the-size-budget)
- <span id="tools-that-need-a-model">

Section

</span>

: [tools that need a model](https://docs.oz0.ai/build/parts/tools/model-calls#tools-that-need-a-model)
- <span id="what-a-definition-costs">

Section

</span>

: [what a definition costs](https://docs.oz0.ai/build/parts/tools/definitions#what-a-definition-costs)
- <span id="what-the-model-sees">

Section

</span>

: [what the model sees](https://docs.oz0.ai/build/parts/tools/definitions#what-the-model-sees)
- <span id="what-the-snapshot-shows">

Section

</span>

: [what the snapshot shows](https://docs.oz0.ai/build/parts/tools/snapshots#what-the-snapshot-shows)
- <span id="where-arguments-came-from">

Section

</span>

: [where arguments came from](https://docs.oz0.ai/build/parts/tools/policy#where-arguments-came-from)

</legacy-sections>


## Sitemap

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