MCP proxy endpoint
POST /v1/{tenant}/{gateway}/mcp
An external MCP client (an IDE, another agent runtime) can connect to the gateway as if it were an MCP server. The endpoint speaks MCP Streamable HTTP (JSON-RPC 2.0), stateless, with JSON responses only (no SSE stream, no session id). It exposes the tools one agent is allowed to use: the agent's owner consent intersected with the admin cap, exactly as on an agent run.
Tool calls run on the same path as an agent run: the same consent and admin cap, the same egress and personal-data protection, the same tool policy and project lock, and the same access decision record. There is no separate, weaker route for external clients.
Authentication
The endpoint requires a per-agent gateway token (a token bound to an agent_id, see
Users & tokens) whose scopes explicitly contain mcp_proxy. Only external
agents are served, and a token for an external agent is always exactly ["mcp_proxy"], bound to the agent's owner.
Create it with POST /admin/v1/gateways/{gateway}/agents/{id}/mcp-tokens (see External agents):
curl -X POST "$ADMIN/gateways/$GW/agents/$AGENT/mcp-tokens" -H 'Content-Type: application/json' \
-d '{"label":"ide"}'
The generic POST /admin/v1/gateways/{gateway}/tokens with an external agent's agent_id accepts only
"scopes":["mcp_proxy"] and user_id equal to the agent's owner; anything else is 400 external_agent_token_scope.
Unlike the other scopes, mcp_proxy is strict: a legacy token with no scopes (normally
"unrestricted") does not reach this endpoint. The reverse also holds: a token that carries
mcp_proxy alone grants nothing outside this endpoint: it is refused (403) on inference and agent-invoke routes.
An OAuth grant token (mcp_oauth, minted by the MCP proxy OAuth flow when an MCP client
connects) is accepted as well. Unlike a per-agent token it can belong to any active member of the agent's tenant,
not only the agent's owner. The call acts for the person who granted it: the grant user is the caller (the
decision record's acting user, the per-user credential and approval checks), and the agent's owner stays the owner.
The grant must be active and match this agent, gateway and resource; the grant user's account, role (author or
higher) and department routing are re-checked on every call, so a disabled user, a role downgrade or a revoked
grant stops access on the next request. A grant token with a missing, revoked or non-matching grant is 401.
Send the token as Authorization: Bearer <token>. The gateway must have authentication required.
| Condition | Response |
|---|---|
| unknown tenant or gateway, or a slug not spelled exactly as stored (case, accents, padding) | 404 tenant_not_found (answered before authentication) |
no / unknown token, or a grant token whose grant is revoked, expired or for another agent, gateway or resource, or a grant of an unverified (dcr_) client while the workspace's mcp_dcr_enabled switch is off |
401 with WWW-Authenticate: Bearer realm="mcp", resource_metadata="<PRM URL>" (see Protected resource metadata) |
| grant token whose user is blocked, or no longer allowed to author or to route via this gateway (department) | 401 (blocked), 403 forbidden (role), 403 department_gateway_not_allowed (the user's department does not allow this gateway), 403 invalid_identity (the user's identity could not be verified), 503 department_policy_unavailable (the department policy could not be read; fail-closed) |
token without mcp_proxy, a token not bound to an agent, or a legacy token |
403 mcp_proxy_scope_required |
gateway with auth_required: false |
403 mcp_proxy_auth_required |
| token bound to an agent that is not external | 403 mcp_proxy_agent_not_external (register the agent as external) |
| token bound to a user of another tenant | 403 forbidden |
| token not bound to a user | 403 mcp_proxy_user_binding_required |
| agents disabled for the workspace | 403 agents_disabled |
| agent paused by automatic protection | 409 agent_protection_paused (the same refusal as an agent run) |
| agent handed over and not yet confirmed by its new owner | 409 agent_handover_pending |
| agent in review | 404 agent_not_found (the same answer as an unknown agent) |
| agent's owner blocked, deleted or without an active account | 403 agent_owner_inactive |
| agent's owner processing-restricted (GDPR Art. 18) | 403 processing_restricted |
| token's own user is disabled or deleted / processing-restricted / a viewer | 401 unauthorized / 403 processing_restricted / 403 forbidden |
| agent's owner's department does not allow this gateway | 403 department_gateway_not_allowed (or 503 department_policy_unavailable when the policy cannot be read) |
| agent's monthly spending limit reached, or its stored limit is unreadable | 403 agent_spend_cap_reached |
| the agent's spending cannot be verified | 503 agent_spend_unverified |
| gateway configuration corrupt | 500 configuration_error |
| agent does not exist, or the token's user is not the agent's owner | 404 agent_not_found |
| client IP not in the gateway's IP allowlist | 403 forbidden (a malformed allowlist refuses every client with configuration_error) |
| too many attempts before authentication (60 per minute per client IP, 600 per minute per node), or the gateway / token rate limit exceeded. For IPv6 clients the per-IP limit counts per /64 — see How the public rate limits behave. | 429 rate_limited |
| trial ended / subscription inactive | 402 trial_expired / 402 subscription_inactive |
Methods
| Method | Result |
|---|---|
initialize |
200, the negotiated protocol version, capabilities.tools (listChanged: false), serverInfo (name: ai-gateway, version: 1.0). params.protocolVersion is required (-32602 otherwise) |
ping |
200, empty result |
any request without id (a notification, for example notifications/initialized) |
202, empty body |
tools/list |
200, the agent's allowed tools (namespaced by connector, e.g. <connector>__<tool>); empty tools: [] if none are consented. cursor is not supported (-32602) |
tools/call |
200, the tool result (see Calling a tool) or a JSON-RPC error |
| anything else | 200, error -32601 |
Supported protocol versions: 2025-06-18 and 2025-03-26. MCP-Protocol-Version is optional; any
other value is 400. initialize with an unsupported protocolVersion string is answered with the
latest supported version.
Calling a tool
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "<connector>__<tool>", "arguments": { "key": "value" } } }
name is the exposed name returned by tools/list. arguments may be omitted (it then means {}); if present it
must be a JSON object.
The gateway re-discovers the agent's tools on every call, so a tool whose consent was withdrawn is no longer
callable, and the first call after a change sees it. Discovery is allowed 10 seconds and the connector call 90
seconds, so one tools/call can take up to about 100 seconds; set the client timeout accordingly.
The call runs as the agent's owner, with the agent's consent and the admin cap. With an OAuth grant token the grant user is the acting user and the holder of per-user connector credentials. It does not run with the permissions of whoever holds the token beyond that.
Result. A successful call returns 200 with result.content holding one text block and isError: false. The
tool output is wrapped in an untrusted-data frame, the same as in an agent run, so the client's model can tell
tool output from instructions. A tool that reports its own failure returns the same shape with isError: true. The text block joins the tool's text blocks; when the tool returns anything else (images, audio, resource links, structuredContent), the block holds the tool's result as JSON text instead. A connector JSON-RPC error or an empty answer is also returned with isError: true. Every response carries Cache-Control: no-store.
Agent constraints. The safety constraints of the agent the token is bound to apply on the proxy. never refuses a matching call. always_ask and ask_once_per_run cannot be held here (there is no agent run to suspend), so the call is refused with agent_constraint. An unreadable stored value refuses every gated call (agent_constraint_malformed). The proxy's own approval hold above is a separate control.
Errors. Every message is a fixed gateway text; connector error text is never copied into the response.
| Situation | JSON-RPC error | data |
|---|---|---|
| tool not offered to this agent (unknown name, no consent, consent removed, or the connector's tool policy already hides the tool from discovery) | -32602 unknown tool |
reason: tool_not_offered |
arguments rejected by the connector's argument bounds |
-32602 arguments rejected |
reason: arg_rejected, optional arg |
| a write or destructive tool with no approval yet (the call is held) | -32003 approval required |
reason: approval_required, approval_id, expires_at (unix seconds). Nothing ran |
| a retry whose approval is still undecided and inside its window, or the identical call again without an approval id while its hold is pending | -32003 approval required |
reason: approval_pending, the same approval_id and expires_at |
a call without an approval id while this token (grant) already has the maximum number of open pending holds (setting mcp_proxy_max_pending_holds, default 20) |
-32001 too many pending approvals |
reason: approval_pending_cap, limit. No hold is created |
| the agent is busy with another request while the hold is opened | -32603 temporarily unavailable, retry |
reason: busy. Nothing was held; repeat the call |
_meta present but not an object, or ai.myra/approval_id present but not a lowercase UUID (null included) |
-32602 invalid approval id |
none |
| a retry whose approval was rejected, expired, already used, or does not match this call | -32001 access denied |
reason: approval_retry_rejected, approval_retry_expired, approval_retry_used, approval_retry_mismatch or approval_retry_unknown |
| consent or capability refuses the call | -32001 access denied |
reason (for example consent_tool_not_granted, agent_capability) |
| the connector's tool policy blocks the tool after it was offered (the policy changed while a cached tool list was still served) | -32001 access denied |
reason: tool_blocked (the decision record carries tool_policy) |
| the call's arguments break a parameter rule the admin set on the connector's tool, name an argument the tool schema does not declare, or the stored rules are unreadable (same rules as for agents) | -32001 access denied |
reason: param_rule, param_schema_drift or param_rules_malformed (nothing is sent upstream; the decision record carries the same reason) |
| the project is locked | -32001 access denied |
reason: project_lock (recorded as a deny decision) |
| the connector's outbound tool calls are blocked by policy | -32001 access denied |
reason: egress_blocked (recorded as a deny decision) |
| personal data in the arguments on a PII-guarded gateway | -32001 access denied |
reason: pii_egress_blocked |
| connector needs the user's credential or it expired | -32001 connector not connected |
reason: credential_required or credential_expired, connector_id |
upstream connector failed, timed out or answered badly (also when a member-owned connector's address points to a private or blocked network, recorded as a deny connector_url_blocked) |
-32002 upstream tool call failed |
kind |
| the approval was valid but the gateway could not write its approved decision record (the call does not run) | -32001 access denied |
reason: record_write_failed |
| internal fault, including a failed or timed-out tool discovery | -32603 internal error |
none |
An argument or credential refusal happens after the access layer allowed the call, so its decision record is the allow record; the refusal is visible in the response only.
Records. Every well-formed tools/call writes one request-log row. It also writes one access decision record
(read it through the Access decisions API), with one exception: a call that ends in an
internal error (-32603) after the agent was bound writes the request-log row but no decision record. A refused call is otherwise recorded as a deny with the reasons above; a
tool that was never offered is recorded as tool_not_offered. initialize, ping, notifications and
tools/list write no request-log row. A request rejected before it is parsed (for example a body over 64 KiB,
answered 413) writes none either.
Calls that need approval
A tool the admin classed as write or destructive is held until the credential holder (the person who owns the agent's token) approves it. The held call does not run:
{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32003, "message": "approval required",
"data": { "reason": "approval_required", "approval_id": "<uuid>", "expires_at": 1792370840 } } }
- The holder approves (or denies) the call under Approvals in the workspace sidebar of the admin console, where it carries an External agent badge (with the platform, for example Cursor, when known), or with
POST /admin/v1/me/tool-approvals/{approval_id}/approve(/deny). This route needs the holder's signed-in session; themcp_proxytoken is refused there (401), and another user gets403 not_credential_holder. - The client retries the same call and adds the id in the request metadata:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "<connector>__<tool>", "arguments": { "key": "value" },
"_meta": { "ai.myra/approval_id": "<uuid>" } } }
The retry runs the call exactly once. It must come from the same token, name the same tool and carry
byte-identical arguments; anything else is refused with approval_retry_mismatch (a tool the agent is not offered is refused earlier, as tool_not_offered) and the approval stays
usable. A second retry gets approval_retry_used. The window is the agent's approval time-to-live
(tool_config.approval.ttl_seconds, otherwise 7 days (604800 s); a missing or malformed stored value falls back to the 1-hour minimum); after expires_at the retry gets
approval_retry_expired.
Notes:
- Who approves. For an owner-bound token the holder is the agent's owner. For an OAuth grant token the holder is
the grant user, and only that user can approve, deny or retry (the owner cannot approve a call a grant user
made, and a grant user cannot approve the owner's). The retry must come from the same grant (a refreshed token of
the same grant counts as the same token); a different grant, or the owner's token, gets
approval_retry_mismatch. - Notification. The credential holder is notified by mail and push when the hold is created (same notification, skip rules and per-agent 15-minute coalescing as agent-run holds, including that a coalesced hold is never notified later and the pending queue is the durable surface, see Agents); the mail names the external agent and the tool, never the arguments. A repeat of a pending call notifies no one.
- Clients that cannot send
params._meta(standard MCP clients) just repeat the identical call. A call without an approval id is matched on exactly this token (for an OAuth grant token: this grant, across refreshes), agent, tenant, tool and canonical arguments: while the hold is pending it is answeredapproval_pendingwith that hold's id (no second hold, no second notification); once the hold is approved, unexpired and unused, the call runs once. The next identical call opens a new hold. If several identical approved holds exist they are consumed oldest first. Two identical id-less calls sent right after the approval race for it: the one that loses getsapproval_retry_used. - A call that carries an approval id is never matched by content: if that approval is spent, the call gets
approval_retry_usedand no fresh hold is opened. - Cap. A token (an OAuth grant counts as one) may have at most
mcp_proxy_max_pending_holdsopen pending holds (default 20, range 1 to 1000; see Settings). Beyond it a new call is refused withapproval_pending_cap(the gate'sdenyapproval_requiredrecord, then onedenyapproval_pending_caprecord); repeating a call whose hold is already pending is still answeredapproval_pending. - A connector that requires four-eyes approval needs two different people to approve before the retry can run. While the second approval is outstanding the retry is answered like a pending one (
approval_pending, same id, nothing consumed; the poll writes onedenyapproval_requiredrecord). - Polling with
params._meta(a retry whose approval is still pending) creates no hold and no notification; it writes onedenyapproval_requiredrecord per poll. An id-less repeat of a pending call is different: the gate refuses it each time, so every repeat writes adenyapproval_requiredrecord (and no new hold, no new notification). Theseapproval_requiredrecords do not count toward the automatic pause after repeated refusals; repeating a pending call is the approval flow, not misbehaviour. - After the call ran or the window passed, the stored arguments are cleared. A different token of the same agent then cannot tell used from expired; the token that made the hold gets the exact reason.
- Purge guarantee. The stored arguments of a decided hold are erased by the scheduler's expire-reviews sweep (every tick) once the hold is past its expiry and a 5-minute grace after its decision; a pending hold is never touched. The sweep erases at most 200 holds per tick; a larger backlog drains on the following ticks. After each tick that did not hit that cap, it counts the due holds that still carry their arguments (over the last 24 hours) on a separate index path and exports the count as the gauge
aig_held_call_purge_residual; a value above 0 is also logged atERRand should alert (alert on a value above 0 on two consecutive sweeps, because a hold that becomes due between the purge's read and the count can show up as a one-tick false positive). A failed purge or check incrementsaig_held_call_purge_errors_totaland is retried on the next tick; the purge requires database migration 0540 (indexesidx_review_held_call,idx_review_expires) to be applied and to stay applied, otherwise every purge fails and this counter rises. - Each refused retry writes one decision record with the reason above. See Access decisions.
Request validation
| Request | Response |
|---|---|
| method other than POST | 405, Allow: POST |
any Origin header |
403 (browsers are not supported clients) |
Content-Type not application/json |
415 |
| body larger than 64 KiB | 413, JSON-RPC error -32600 request body too large (id: null) |
| not JSON | 400, -32700 |
a repeated object key anywhere in the body (including inside tools/call arguments) |
400, -32600 (id: null); nothing runs |
a number that is not finite (NaN, Infinity, or one such as 1e400 that overflows) |
400, -32700 (id: null) |
| a body within 64 KiB but with more than 262144 JSON values and keys in total | 413, -32600 (id: null) |
| JSON batch, or a non-object | 400, -32600 |
MCP-Protocol-Version header with an unsupported value |
400, -32600 |
bad jsonrpc (must be "2.0"), id (must be a string of at most 256 bytes of valid UTF-8, or a whole number written without fraction or exponent; null, booleans, fractions and objects are rejected), or method (a non-empty string of at most 128 bytes) |
400, -32600 |
params not an object, bad initialize params |
200, -32602 |
tools/call with params._meta that is present but not an object (including null), or _meta["ai.myra/approval_id"] that is present but not a lowercase UUID string |
200, -32602 |
tools/call with a missing or non-string name, or arguments that are present but not a JSON object |
200, -32602 |
Client configuration example
{
"mcpServers": {
"gateway": {
"type": "http",
"url": "https://<host>/v1/<tenant>/<gateway>/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
A JSON-RPC id that is an integer up to 9007199254740991 is accepted and echoed back exactly (a larger id, or one written with a fraction or an exponent such as 1.0 or 1e3, is rejected with HTTP 400 / -32600). If a response cannot be encoded, the proxy answers a JSON-RPC -32603 error at HTTP 200 carrying the request id; see Error codes.