MCP security and limits
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/callthat waits for its job, with or without an event stream, and asubscriptions/listenstream end at once when the key is revoked, or the person loses the tenant, with apermission_deniederror 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 withinputResponses, 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/getatpollIntervalMs. - No resources or prompts, and no
Mcp-Session-Idfor 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.