Skip to content

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 } } }
  1. 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; the mcp_proxy token is refused there (401), and another user gets 403 not_credential_holder.
  2. 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 answered approval_pending with 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 gets approval_retry_used.
  • A call that carries an approval id is never matched by content: if that approval is spent, the call gets approval_retry_used and no fresh hold is opened.
  • Cap. A token (an OAuth grant counts as one) may have at most mcp_proxy_max_pending_holds open pending holds (default 20, range 1 to 1000; see Settings). Beyond it a new call is refused with approval_pending_cap (the gate's deny approval_required record, then one deny approval_pending_cap record); repeating a call whose hold is already pending is still answered approval_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 one deny approval_required record).
  • Polling with params._meta (a retry whose approval is still pending) creates no hold and no notification; it writes one deny approval_required record per poll. An id-less repeat of a pending call is different: the gate refuses it each time, so every repeat writes a deny approval_required record (and no new hold, no new notification). These approval_required records 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 at ERR and 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 increments aig_held_call_purge_errors_total and is retried on the next tick; the purge requires database migration 0540 (indexes idx_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.