Call agents over MCP

MCP security and limits

Review credential revocation, request parsing and unsupported features.

For: application developers.

Review credential revocation, request parsing and unsupported features.

Keys, revocation and loops

  • A scoped key (oz0 apikey create --scope agent:<name>) lists and calls only the agents and flows its scopes name; any other is an unknown tool, and a task or conversation of another agent is not found, with the answer an ID that does not exist gets.
  • When access ends, a waiting call ends. A tools/call that waits for its job, with or without an event stream, and a subscriptions/listen stream end at once when the key is revoked, or the person loses the tenant, with a permission_denied error that names the job. The answer's end does not stop the job, but the revocation does when the job ran on that access: a revoked key's or person's jobs stop, and the revocation's receipt lists them.
  • One answer at a time. A tasks/update, or a retry with inputResponses, that answers a task while another answer to it is being handled is refused; read the task and answer what it still asks. A question or an approval whose deadline has passed is no longer asked: the job denies it then.

Delegation loops

A call may name the job it comes from in _meta["ai.oz0/origin-job"], as a relay or an agent that calls this server from a job should. A call whose agent is already on that job's chain (its job tree, and the jobs earlier calls came from, at most eight hops) is a tool error, and so is a chain deeper than eight. Only calls that carry the marker are checked.

The node runtime sends it on a plugin tool's call to a remote MCP server (url:) at one of this cluster's own addresses, which the edge tells the nodes (see calls back into this cluster), so a tool that points back at this cluster's /mcp/<tenant> cannot loop an agent into itself; it never sends a job's ID to any other server. A loop through an address the cluster does not know as its own, such as a proxy you did not list with --public-origin, is bounded by the tenant's budget and quotas only.

Which clients were run against this server, and what each did: the interop matrix.

The AI label

Each result, task and elicitation carries the tenant's AI label in _meta["ai.oz0/ai-label"], such as "AI agent", and an elicitation's message, which the client shows a person, starts with [AI agent]. MCP has no field of its own for who wrote a result, and the result's text stays the agent's: a client that shows the answer to a person shows the label with it. With the label turned off, none of these carry it.

Requests with one reading

Every request needs a caller first, before its body is read: a request without credentials, a notification included, gets 401 with WWW-Authenticate: Bearer and the tenant's resource_metadata. A body that two JSON parsers could read differently is refused with -32700 and HTTP 400: one that starts with a byte order mark, holds bytes that are not UTF-8 or a lone surrogate escape (\ud800), names a member twice or twice but for case ("name" and "Name"), or writes one of JSON-RPC's members in another case ("Method").

These are the classes of ToolHive's and Docker MCP Gateway's advisories (such as GHSA-hxx3-f8cg-r974 and GHSA-9v4w-3mqh-6vmm); the operators' endpoint on the management port takes requests by the same rules.

What it does not do

  • No task notifications: poll tasks/get at pollIntervalMs.
  • No resources or prompts, and no Mcp-Session-Id for older clients.
  • No OpenID Connect for MCP clients (no ID tokens), no client secrets or confidential clients, no implicit or password grants, and no client_credentials: an app or a script uses an API key.

Every call is checked, started and audited as StartJob does it: an input that does not fit the schema, a kill switch or a spent quota comes back as a tool error the model can read. A refusal for spend (a quota at the start, or a budget, quota or tree limit that ended the job) also carries its numbers for the client: the tool error's _meta holds ai.oz0/refusal, {"type", "message", "oz0"} (see Spend refusals). For a tool without an outputSchema, the same object is the tool error's structuredContent too, as {"error": ...}.

A tool with an outputSchema leaves structuredContent out of a tool error, because clients check structured content against the declared schema even when isError is set, and an error never fits it.

Copyright © 2026