Call agents over MCP
For: application developers.
Connect an MCP client to POST https://<edge>:7443/mcp/<tenant> to call the tenant's agents and flows as tools. An assistant or application can list them and delegate work through that endpoint.
Each call creates a job with its quotas, budget, approvals, trace and cost.
The server speaks MCP 2026-07-28 over Streamable HTTP, with the Tasks extension for long jobs. Clients of 2025-11-25 and earlier, which start with initialize, can list and call tools too, without tasks.
Connect
A client connects in one of two ways:
- As a person, with OAuth. Give the client the URL,
https://<edge>:7443/mcp/<tenant>. It finds the tenant's authorization server by itself, sends you to the console to sign in and approve it, and then acts as you, with your own permissions, until you revoke it. This is how IDEs and desktop assistants connect: see Connect a client with OAuth. - As an app or a script, with a tenant API key as the bearer token: an operator key runs agents, a reader key sees no tools.
The edge's certificate comes from the cluster's CA, so the client needs to trust ca.crt (or put the edge behind a certificate it already trusts, with --public-edge-url).
import ssl, httpx2
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
http = httpx2.AsyncClient(
headers={"Authorization": f"Bearer {key}"}, verify=ssl.create_default_context(cafile="ca.crt")
)
async with Client(streamable_http_client("https://edge.example.com:7443/mcp/acme", http_client=http)) as c:
tools = await c.list_tools()
result = await c.call_tool("acme-reviewer", {"repo": "acme/api", "pr": 42})
Every request is authenticated, server/discover too. Without credentials the edge answers 401 with WWW-Authenticate: Bearer realm="orchestrator-zero", resource_metadata="https://<edge>/.well-known/oauth-protected-resource/mcp/<tenant>", scope="mcp", which is where an OAuth client starts.
Tools
tools/list lists the agents and flows the caller may run, in name order, in pages of at most 1,000 tools: when more follow, the result has nextCursor, which the next tools/list sends as cursor. The cursor is opaque and valid only for this tenant's list; any other is refused with -32602 (Invalid params), as MCP's pagination says. A tenant of up to 1,000 agents gets one page. In each tool:
inputSchemais the agent's input schema from its contract. An agent that takes text gets an object with one string,input: call it with{"input": "..."}. MCP's schemas are objects, so a contract schema whose root is not an object (a list, say, which a contract allows with a warning) becomes the one propertyinputof an object: call it with{"input": [...]}.outputSchemais its output schema, when it has one. The answer then comes asstructuredContenttoo, next to the JSON as text. An output schema whose root is not an object is wrapped the same way, asresult, andstructuredContentis{"result": ...}.- Annotations say what an agent is: not read-only, possibly destructive, not idempotent, and open-world. Treat them as hints.
- An agent that a kill switch stops is left out, and a reader's key gets an empty list.
The list carries the catalog's hash in _meta["ai.oz0/catalog"], and ttlMs of two seconds, as often as edges reload their catalog. Its HTTP ETag is the version of the list itself, the catalog's hash and the names on it (and the page's cursor), so it changes when a plugin install changes the catalog and also when a kill switch takes a tool off the list or puts it back. A client that opens subscriptions/listen with toolsListChanged gets notifications/tools/list_changed whenever that version changes.
Conversations
Every result's _meta names the call's conversation in ai.oz0/conversation: the job's own ID, or its conversation once it is a turn of one. A tools/call that sends that value back in its _meta is the conversation's next turn, and the agent gets the earlier calls' inputs and answers.
MCP 2026-07-28 has no sessions, so the conversation travels with each call; clients of earlier revisions may send the same key. For an agent that takes no part in conversations, or a key without the content permission, the value starts a new conversation instead. A value the server never gave is a tool error.
Only the caller that started a conversation continues it: the same key, operator or person, or a person its chat is shared with. Anyone else who sends the value back, another key of the same tenant included, gets the same tool error as for a value the server never gave, so a conversation's ID is no way into someone else's history.
Calls that wait, and calls that are tasks
Without the Tasks extension, tools/call starts the job and waits for it. The answer is the agent's output as text, or isError: true with why the job failed. Clients that accept an event stream get one, with a comment every 15 seconds while the job runs. If the client closes the call, the job is canceled (clients of 2025-11-25 and before leave it running, as their revision says).
A server that stops or restarts while a call waits does not cancel the job: the call ends with an error that names the job, which goes on, and which you can follow with GetJob in the runtime API. When the job asks a person something and the client declared the elicitation capability, the call answers with an InputRequiredResult: the client shows the question, and retries the call with the answer and the requestState it got, which the SDKs do for you.
With the Tasks extension declared in the request's capabilities, tools/call answers at once with a task in working (every task begins there, even when the job asks a person at once), whose taskId is the job's ID; tasks/get then says where the job is:
| Task status | The job |
|---|---|
working | runs |
input_required | waits for an approval or an answer to a question, its own or a delegated job's; inputRequests asks for them. A client that calls with a tenant API key answers, here, the questions of the task it started, and approves only where the key is named (keys that decide) |
completed | ended; result is what a waiting call would have answered, with isError when the job failed |
cancelled | was canceled |
{
"resultType": "complete", "taskId": "job-3c9...", "status": "input_required", "pollIntervalMs": 1000,
"inputRequests": {
"a-7f2c": {"method": "elicitation/create", "params": {"mode": "form", "message": "Which colour should the report use?",
"requestedSchema": {"type": "object", "properties": {"answer": {"type": "string", "enum": ["red", "blue"]}}, "required": ["answer"]}}}
}
}
Each input request is keyed by the approval's ID. Answer with tasks/update:
- A question:
{"action": "accept", "content": {"answer": "blue"}}. An answer that is not one of its choices is refused. - An approval of a tool call or a hook:
{"action": "accept", "content": {"approve": true, "comment": "..."}}, or"approve": falseto deny it. declineorcanceldenies either.
The decision goes through the runtime API's DecideApproval with the caller's rights: an operator key may decide, and a question takes the content permission. tasks/cancel cancels the job. A client that did not declare elicitation sees a job that waits as working, with a statusMessage; someone answers it in the console or with oz0 approval.
Related tasks
- MCP client OAuth: Authorize an MCP client through a person's sign-in.
- MCP security and limits: Review credential revocation, request parsing and unsupported features.
Previous section links
- Section: connect a client with oauth
- Section: keys revocation and loops
- Section: requests with one reading
- Section: see and revoke your clients
- Section: the ai label
- Section: what it does not do
- Section: who may connect