API Gateway Toolkit¶
The API gateway toolkit (kind: api) proxies arbitrary REST/HTTP APIs through the platform's auth, persona, and audit pipeline. It is the HTTP/JSON sibling of the MCP Gateway Toolkit, which proxies upstream MCP servers.
The toolkit exposes three MCP tools — api_discover, api_invoke_endpoint, api_export — that handle every operation on every configured API. Operators register the upstream as a connection of kind api; the model uses api_discover to walk the connection's catalog at the depth it needs (the sections of a multi-spec catalog, the operations of one section or of a query, one operation's precise parameter shape), api_invoke_endpoint to make the call, and api_export to stream a response into a portal asset. No tools are generated per endpoint, so adding ten APIs does not inflate the tool catalog by a thousand entries.
Discovering an operation¶
api_discover takes a connection and answers at the depth its other arguments select. Every response carries level naming the shape it returned and next naming the argument that goes one level deeper, so the path from a bare call to api_invoke_endpoint reads out of the responses themselves.
- Specs (
level: specs): a bare call on a connection whose catalog bundles more than one component spec returns one summary per spec (name,title,description,operation_count,base_path).nextsays to passspec=<name>for one spec's operations orquery=<text>to rank operations across every spec. - Operations (
level: operations): a call withspec,query, or both returns the matching operations (operation_id,method,path,summary,tags,spec), ranked byqueryunder therankingmode (hybridby default when the connection has an embedding index,lexicalotherwise, with anotewhen a semantic mode fell back to lexical). A bare call on a single-spec catalog is this level too: a connection with one section needs no section-selection step.limitcaps the list (default 50).nextsays to passoperation_id=<id>.
Where relevance ends. A ranked row carries score (the mode's score in [0,1]) and lexical_match (whether the operation contains every token of the query), and the result carries matched_lexical and shown_semantic: how many of the operations returned matched, and how many were added after them as neighbors by intent. A call with no query ranked nothing and carries none of the four.
Lexical ranking is an AND filter and returns exactly what matched. Hybrid and semantic score every operation the persona can invoke, so the result is cut rather than merely capped: the matches first, then at most five operations that matched no token but score at or above the relevance floor. limit still caps the total, but it is no longer what decides where relevance ends -- before this, a query on a large catalog returned a 50-row page whose tail had nothing to do with it, and a query matching nothing at all returned the head of the catalog instead of saying so. When the only rows are neighbors the note says no operation contains every token of the query; the N shown are the closest by intent, and when nothing clears the floor the answer is the same no operations match query "..." the lexical path gives.
- Operation (level: operation): a call with operation_id returns that operation's parameters, request body, and per-status responses under operation, with any requests promoted on it in saved_examples. When more than one component spec defines the id, the refusal names the candidate specs and the call is repeated with spec. next is the api_invoke_endpoint call, with the connection, the id, the spec if one was needed, and a reminder that placeholder values go in path_params.
A connection with no catalog answers with a note at every depth: there is nothing to discover, and it is called with api_invoke_endpoint by method and path. A spec the catalog does not have is refused with the names it does have. Operations the persona's route rules deny are absent from the operations level and reported as not found at the operation level, and persona policy still applies at invoke time.
OpenAPI specs that describe each upstream are stored separately in API catalogs — versioned, globally-owned bundles that many connections can reference. See API Catalogs for the full surface.
Addressing an operation¶
api_invoke_endpoint (and api_export, which mirrors its input) address an operation in one of two ways:
- By
operation_id: the stable identifierapi_discoverreturns (for examplegetUser). This is the natural continuation of the discovery flow: read a schema byoperation_id, then invoke the sameoperation_id. The platform resolves it to the method and path template from the connection's catalog. For a templated path, pass the placeholder values inpath_paramsand the platform substitutes and URL-escapes them, so you never hand-build/v1/users/123from/v1/users/{id}:
A path segment may carry more than one placeholder, or mix a placeholder with literal text. Name each placeholder separately and the platform substitutes each in place, escaping the values but leaving the literal text between them alone:
{
"connection": "weather",
"operation_id": "resolvePoint",
"path_params": { "latitude": "37.41", "longitude": "-94.70" }
}
resolves /points/{latitude},{longitude} to /points/37.41,-94.70. The same applies to /gridpoints/{office}/{gridX},{gridY}/forecast and to a literal suffix such as /files/{name}.json.
When the same operation_id is defined by more than one component spec in the catalog, pass spec to disambiguate; the error names the candidate specs.
The response carries resolved_path: the concrete path the call was addressed to, after the catalog's base-path prefix and the path_params substitution. It is reported only for operation_id addressing, and it is the fastest way to tell a wrong path from a genuinely failing upstream when a call returns an unexpected 4xx.
- By
method+path: the raw addressing for uncataloged calls or connections with no spec. You substitute any path parameters yourself:
Supply one form or the other, not both. path_params is only valid alongside operation_id.
Pagination¶
api_invoke_endpoint recognizes the common pagination signals on a response and reports them as pagination: {has_more, next_cursor, next_url, source}: an RFC 5988 Link: rel="next" header, @odata.nextLink, next_cursor, nextCursor, next_page_token, nextPageToken, or next in the top-level body. A URL-valued signal is reported as next_url, a token as next_cursor, and source names which one was found. Without a paginate block the signal is reported and not followed: each page is one call, one audit row, and one model turn, which keeps a short loop observable in the conversation.
Walking a paginated operation¶
api_invoke_endpoint and api_export take the same optional paginate block. With it, the gateway walks the pages itself inside the one call: api_export streams the merged array into one asset, api_invoke_endpoint returns it inline.
{
"connection": "vendor",
"operation_id": "listChangelog",
"query_params": {"per_page": 100},
"paginate": {
"items": "data",
"cursor_param": "cursor",
"page_param": "page",
"max_pages": 500
},
"name": "changelog.json"
}
| Field | Required | Description |
|---|---|---|
items |
yes | The key of the array merged across pages (data, items, results, value), a dotted path to a nested one (result.items), or $ when the page body itself is the array. It is required because guessing the key is how a merged result silently becomes a list of envelopes. |
cursor_param |
no | The query parameter a body cursor is sent back as (cursor, page_token, starting_after). |
page_param |
no | The query parameter advanced when a page carries no next signal (page, offset). Its starting value must be present in query_params; the first page is requested exactly as given. |
page_step |
no | What page_param is advanced by per page. Defaults to 1; set the page size for an offset parameter. |
max_pages |
no | Upper bound on pages walked. Defaults to 100, at most 10000. |
How the next page is reached is decided per page, from the signal that page carries:
- A
next_url(aLinkheader,@odata.nextLink, or a URL-valuednext) is followed. It is pinned to the connection's host and must fall under itsbase_urlpath (the link's scheme is not compared, because the page is requested through the connection'sbase_urlwhatever the link says, and an API behind a TLS-terminating proxy writes its links with the scheme it sees inside); the path is then validated and checked against the persona's route policy exactly as the first page was, so a next link cannot move the walk to another host or to an operation the persona is not allowed. Both refusals fail the call before any request is sent to the link. - A
next_cursoris sent back as the query parametercursor_paramnames. A cursor on a page that names nocursor_paramand nopage_paramfails the walk: the gateway has no way to send it. - With neither signal, and
page_paramnamed, that parameter is advanced bypage_step. A body cursor is ignored in this mode, so an API that pages by number but writesnext: truestill walks.
The walk stops at the first page with no next signal or no items, at max_pages, or at the byte cap, and reports where it stopped on the output of both tools: pages_fetched, items_merged, and stopped_by (end, max_pages, max_bytes). A page-numbered walk has no signal to end on, so it ends on the first empty page, which counts as fetched. A walk stopped at max_pages also reports pagination with the signal for the page it would have requested next, so the caller can resume from it.
Pacing is the gateway's. An upstream that answers a page with 429 or 503 carrying Retry-After pauses the walk for that interval, bounded by the call's timeout (api_export defaults to 5 minutes and caps at 30; api_invoke_endpoint uses the connection's call_timeout), and requests the same page again; an interval the remaining timeout cannot contain fails the call naming it, and a page refused more than ten times in a row fails rather than being polled until the timeout. A page that fails for any other reason (a 500, a 429 with no Retry-After, a transport error, a body that is not JSON, a body whose items is not an array) fails the call with the page number in the error, and no asset is written.
Each page is read under the connection's max_response_bytes, the upstream read cap; a page past it fails the walk with a steer to ask the upstream for a smaller page. Beyond that the two tools differ:
api_exportstreams: the merged array is opened, each page's items are written to storage as they arrive, copied byte for byte from the page, and the array is closed, so memory holds one page whatever the page count. The asset is oneapplication/jsondocument whose content is the merged array. Its provenance records thepaginateblock,pages_fetched,items_merged,stopped_by, and the cursor or link that addressed the last page. A walk whose merged output would passportal.export.max_bytesfails all-or-nothing, as a single oversize response does. A walk stopped atmax_pageswith another page still to fetch is a cut file: withmax_pagesunset (the default bound of 100) it is refused and nothing is written, and withmax_pagesset it is written and flagged withtruncated,limit_applied,limit_sourceandlimit_unit, and the asset is tagged_sys-truncated.on_truncation,expect_rowsandexpect_min_rowschange that; see Exports cut at a limit. The idempotency key covers the whole walk.api_invoke_endpointcalled by a model merges inline under the platform's context budget (tools.result_budget). A REST gateway client or a managed script carries no context budget, and its walk merges under the connection'smax_response_bytesinstead, stopping there the same way with whole pages and the signal to resume from. The merge measures the array as the result renders it and keeps headroom under the budget for the envelope around it, so a page that would take the rendered result past the budget is not merged: the call returns the pages that fit withstopped_by: "max_bytes",body_truncated: true,body_bytesholding the merged size, a hint steering toapi_export,export_argumentscarrying the same call andpaginateblock for it, andpaginationholding the signal for the unmerged page.statusandheadersare the last page's.
A walk from a model client runs in the background (#2212): api_export answers at once with run_id, status: running and an idempotency_key, and the walk finishes on the replica that took the call, under the deployment's export maximum when the call named no timeout_seconds. async: true sends any export to the background and async: false keeps a walk inline; a script's and a REST caller's exports run inline unless they ask. Calling api_export again with the key reads the run, pages_fetched and items_merged while it runs and the export's own answer once it is done; a key whose export is still running is joined rather than walked twice, and a failed one is reported and started again. A finished or failed one is listed under background_exports in its caller's next platform_info. See Long exports run in the background.
One tool call is one rate-limit token, one audit row, and, from a managed script, one platform.call. The audit row for the call records the walk under parameters.result (pages_fetched, items_merged, stopped_by) beside the paginate block it was called with, which is the observability the per-call loop was keeping.
On the built-in util connection, a POST /util/fetch walk pages the document named by the url in the request body: a next link (the fetched response's Link header is relayed, as are body signals) is pinned to that document's scheme and host, since the page is requested at the link itself, and a cursor or page parameter is added to that URL's query. The REST shim's raw passthrough route streams one body and refuses paginate.
Landing a response in a managed resource¶
api_export's default destination is a portal asset, and every call makes a new one. Pass resource instead and the response lands as a managed resource at a path you name, created the first time and recorded as the next version of that same file every time after:
{
"connection": "acme",
"operation_id": "listOrders",
"name": "ACME orders",
"resource": { "path": "datasets/acme", "filename": "orders.csv" }
}
The result carries the file rather than an asset, and it carries it NESTED under
resource: resource.reference (the mcp:resource:<id> to hand to the next
call), resource.uri, resource.version, resource.created, and
resource.table_changes, one sentence per table registered over the file saying
whether it followed onto the new version. An asset destination's fields sit at
the top level instead; one or the other is set, never both.
{
"message": "...",
"size_bytes": 51172,
"upstream_status": 200,
"content_type": "text/csv",
"resource": {
"reference": "mcp:resource:res_...",
"uri": "mcp://user/datasets/acme/orders.csv",
"version": 4,
"created": false
}
}
Read result.resource.reference, not result.reference. The shorter reading
returns an empty string, and a script that guards on it fails its run AFTER the
export has already happened, leaving a real resource version behind.
That is the destination for a recurring pull of one source. The file's id and URI do not move, so an asset that references it, a citation that names it, and a manage_table registration that follows it all keep resolving across every refresh, and nothing has to be re-pointed. The response streams from the upstream into the file's storage exactly as it streams into an asset's, so a 200 MB CSV is an ordinary call rather than something that has to be cut up to pass through a model or a script.
Rules worth knowing before you wire one up:
pathandfilenameare both required: a managed resource is filed in a folder, and the two together are the address. Give the file the extension its content deserves, since a.csvlanded here is registerable as a table even when the upstream labels its responsetext/plain.scopedefaults to the caller's own library. Apersonaorgloballibrary is named explicitly and takes the matching administrator role, checked before the upstream is called.nameis the file's display name, anddescriptionandtagslabel it. They are recorded when the file is created and left alone by later landings, so a file people have since renamed or re-tagged in the portal is not re-labelled by tonight's refresh. A call with no description gets one naming the connection and operation it came from.idempotency_keyandcreate_public_linkare refused with this destination: the first answers a repeat call with the asset the first one made, which is the opposite of re-versioning one file, and a public share link is a portal asset's.- An upstream that answers anything but a 2xx is not landed, and the file keeps serving the version it had. The answer is the result rather than an error (#1859):
upstream_status, the upstream'supstream_headers,resource_unchanged: trueand a message naming the status and the file, so a caller -- a managed script especially, which cannot catch an error -- reads the status and decides what to do. An asset destination still keeps a failed response, where a new file every time makes it evidence rather than a corrupted dataset. - The size ceiling is the library's own,
resources.managed.max_upload_bytes, notportal.export.max_bytes: a file this platform would refuse at its upload form is one it refuses here, at the same size. Nothing is written when the ceiling is passed.
paginate works the same way at this destination: the merged array is streamed into the file as pages arrive, and the result reports both what the walk did and what the write did to the file.
trino_export and graphql_export take the same resource block for a query result and a GraphQL document's result, on the same terms with one difference: both build their result in memory before any destination is chosen, so portal.export.max_bytes still bounds what they produce, and the library's ceiling applies on top of it. A managed script reaches the same destination through platform.export(..., destination="resources", key="datasets/orders.csv"); see Running a script.
SOAP upstreams and WSDL¶
An API catalog spec entry carries a spec_format, which is openapi unless it says otherwise. Setting it to wsdl lets a connection be described by the WSDL its upstream already publishes, so a SOAP service gets everything the catalog gives every other connection: api_discover finds its operations, each operation carries the schema its XSD declares, semantic ranking has something to index, and per-operation route rules can be written.
spec_format is orthogonal to source_kind. A WSDL can be pasted (inline), uploaded, or fetched from its ?wsdl URL and refreshed from there, exactly like an OpenAPI document.
curl -X PUT "$PLATFORM/api/v1/admin/api-catalogs/orders/specs/default" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"source_kind":"url","spec_format":"wsdl","source_url":"https://erp.example.org/Orders.svc?wsdl"}'
The WSDL is converted to an OpenAPI document at save time and that document is what the gateway serves. The WSDL itself is kept beside it, so reading the spec back returns what was written rather than a generated document, and a refresh re-fetches and re-imports. A WSDL that cannot be imported fails the save, naming what was wrong, rather than registering a connection with no operations.
Calling a SOAP operation¶
The caller sends the operation's fields. The gateway writes the envelope:
From the WSDL the gateway knows which envelope version to write, which media type to send (text/xml for SOAP 1.1, application/soap+xml for 1.2), where to announce the action (a quoted SOAPAction header for 1.1, a action= Content-Type parameter for 1.2), which namespace the body element is in, whether the schema qualifies its child elements, which fields are XML attributes rather than child elements, and what order an xsd:sequence requires them in. None of that is the caller's to get right.
A string body is still sent verbatim, so a caller holding an envelope of their own — one a vendor's documentation supplies, one whose shape the schema cannot express — keeps that way through. A body that is neither an object nor a string is refused by name.
Faults¶
A soap:Fault is reported as the upstream's own error: error carries the fault's code and its text, in either version's spelling (faultcode/faultstring, or Code/Value and Reason/Text). Without it the call reports only the HTTP status the fault is carried by, which is 500 Internal Server Error while the sentence that explains the failure sits in the body. The full response body is returned as well, decoded as an XML tree, because the generated document declares the envelope media type on the operation's success response.
Route rules on a SOAP connection¶
Every operation of a SOAP service is a POST to one address, which one OpenAPI path item cannot hold. The generated document therefore keys each operation under the service path followed by the operation name — /Orders.svc/GetOrder — and carries the real address separately. That key is what api_discover lists as the operation's path and what a persona's api_routes path glob matches, so a path rule names one operation exactly:
api_routes:
- connection: orders
paths: ["/**"]
- connection: orders
paths: ["/Orders.svc/DeleteOrder"]
action: deny
The request still reaches /Orders.svc; only the addressing is per-operation.
What is not imported¶
Document/literal bindings over SOAP 1.1 and 1.2, which is the overwhelming majority of what is deployed. RPC and encoded bindings are refused by name rather than imported wrongly — their body element is assembled from operation and part names rather than declared by a schema, so importing them as document/literal would describe a request shape that does not exist. Also out of scope: WSDL 2.0, MTOM attachments, and WS-Security headers beyond what static_headers or the connection's own auth mode already provide.
References inside a WSDL are resolved by the local part of their name, within the document. A WSDL whose messages or types live in a schema it imports is refused, naming what could not be resolved, rather than silently importing an operation with an empty body shape.
Request bodies¶
The body argument is a JSON value, and the connection's catalog decides how it reaches the upstream. The resolved operation's declared requestBody media type drives the encoding, so a caller passes the data and never the framing:
| Declared media type | body value |
On the wire |
|---|---|---|
application/json |
object or array | JSON, Content-Type: application/json |
application/json |
string that parses as JSON | verbatim, Content-Type: application/json |
application/json |
string that does not parse as JSON | verbatim, Content-Type: text/plain; charset=utf-8 |
multipart/form-data |
object | multipart, Content-Type: multipart/form-data; boundary=... |
| any other single type | string | verbatim, with that type |
| none matched | object or array | JSON; a string goes out as text/plain |
An explicit Content-Type in headers overrides catalog negotiation and sends the bytes as typed — the exception is multipart, below.
Answers worth asking for again¶
An upstream that refuses a request for its rate, or is briefly unavailable to a
read, is expected to admit the same request later. api_invoke_endpoint and a
resource-destination api_export say so on the result (#1859):
upstream_retryable: true on a 429, whatever the method, and on a 503 to a GET
or HEAD, with retry_after_seconds when the upstream sent Retry-After (in
either form RFC 9110 allows). A 503 to a write is not marked: the service may
have acted on part of it before it answered. A caller can wait the interval and
repeat the call; a managed script's host does exactly that, up to three times,
before handing the script the last answer
(Running a script).
An upstream the gateway could not reach, or that did not answer within the
call's timeout, is a tool error whose structured error.code is
upstream_unavailable and category upstream, which tells a caller the same
call made later is expected to succeed.
One request is bounded by the connection's call_timeout whatever the caller
asks: the connection's client enforces it. A timeout_seconds above it is
reported on the result as capped (timeout_applied_seconds,
timeout_capped_by: "connection call_timeout", timeout_requested_seconds),
and a request that runs out of time says which limit stopped it:
timed out after 120s (the connection's call_timeout; timeout_seconds 240 was capped).
api_export reports a timeout_seconds above portal.export.max_timeout the
same way, capped by export max_timeout (#2210).
Upstreams that take one call at a time¶
max_concurrent_calls holds a connection to that many calls in flight at once,
across every replica, for every path that reaches it: api_invoke_endpoint,
api_export, a page walk, and a script's platform.call. A call over it waits
for a slot under its own timeout instead of failing (#2209). It is the same key,
with the same rules, as on a GraphQL connection; see
Upstreams that take one call at a time.
Response bodies¶
A response is returned as JSON when it declares JSON, and as a string otherwise. The decode argument changes that:
decode |
What comes back in body |
|---|---|
auto (default) |
JSON when the response declares JSON; a parsed XML tree when the catalog declares an XML media type on the operation's success response; raw text otherwise |
xml |
a parsed XML tree, whatever the response declares |
json |
parsed JSON, whatever the response declares |
text |
the raw string, parsing nothing |
An XML tree is nested objects of {tag, ns, attrs, text, children}: tag and the attribute names are local names, so the upstream's namespace prefix does not matter and ns carries the namespace URI for a caller that needs it; text is the element's own character data, trimmed; children are the child elements in document order. A response a caller's decode cannot parse is returned as text with the reason in hint, so an HTML error page from a SOAP endpoint is readable rather than lost. The reason is carried on a decode the caller passed, json and xml alike; auto falls back to text silently, because a response that declares JSON and is not JSON has come back as a string since the gateway shipped and nothing asked it to parse.
auto never decodes XML on the response Content-Type alone. A connection with no catalog — and every WebDAV route, whose PROPFIND multistatus is XML by definition — returns the same string it always has until an operator declares the media type in the catalog or a caller passes decode.
decode is refused together with paginate: a walk merges the pages it collects, so there is no single response to decode.
The same tree, from the same parser, is what a managed script's xml module produces (internal/xmltree), so a SOAP response reads identically whether it arrives through a tool call or inside a script.
Multipart form data¶
An operation declaring multipart/form-data takes an object of form fields, and the platform assembles the parts:
{
"connection": "census",
"operation_id": "geocodeBatch",
"body": {
"addressFile": {
"filename": "batch.csv",
"content_type": "text/csv",
"content": "1,123 Main St,Springfield,IL,62701\n"
},
"benchmark": "<benchmark name>",
"vintage": "<vintage name>"
}
}
Each key is one field:
- a scalar (string, number, boolean) becomes a text field;
- an array becomes one part per element under the same field name, which is how an upstream taking several files under one name expects them;
- an object carrying
filename,content,content_base64, orcontent_typebecomes a part:contentis sent as UTF-8 text,content_base64is decoded to raw bytes first, and a part naming afilenamedefaults toapplication/octet-streamwhen it declares no type. A part may setcontent_typewithout afilename— that is the typed metadata field several upstreams require alongside a file. Those four are the only attributes a part may carry; any other key is refused by name, so a misspelling fails loudly instead of travelling as a field that was silently dropped; - any other object is JSON-encoded into a text field.
The platform generates the multipart boundary. Do not assemble a multipart body by hand and do not set Content-Type for one: a boundary that does not match the bytes yields a body the upstream parses as zero parts, which surfaces as a confusing upstream 400 blaming the caller. An object body sent under a caller-supplied multipart/form-data header is encoded by the platform, and the platform's boundary replaces the caller's.
A body that is not an object on a multipart operation is refused at the platform, before the request goes out, with a message naming the shape it wants — an honest local failure rather than a malformed request.
Bulk operations are the usual reason an upstream asks for multipart, and they are the ones worth automating: the US Census batch geocoder takes 10,000 addresses in one file part, where the single-address endpoint would take 10,000 calls.
The platform's own catalog-spec upload (PUT /api/v1/admin/api-catalogs/{id}/specs/{spec}/upload, a multipart/form-data route with a file part) is reachable this way through the built-in platform-admin connection. Registering spec text is still simpler through the sibling inline route, with {"source_kind": "inline", "content": "..."}.
When to use¶
Use the API gateway for upstreams that expose a REST API and authenticate with a bearer token, an API key, or OAuth 2.1. Common targets:
- Salesforce REST API
- Google APIs (Drive, Calendar, BigQuery REST surface)
- GitHub REST API
- Stripe API
- Internal HTTP services that should ride the platform's audit pipeline
For upstream MCP servers, use the MCP gateway (kind: mcp) instead.
Configuring a connection¶
API connections are stored in the database, not in platform.yaml. Enable the kind, then author connections through the admin portal or the admin REST API.
Minimal connection config (bearer auth):
curl -X PUT \
-H "X-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"config": {
"base_url": "https://api.vendor.example.com",
"auth_mode": "bearer",
"credential": "your-vendor-token"
},
"description": "Vendor REST API"
}' \
https://platform.example.com/api/v1/admin/connection-instances/api/vendor
A saved connection is served by every replica at once¶
The connection store is what a replica answers from, not the announcement that follows a save (#1746). A connection saved through the admin API is applied on the replica that served the request and reaches every other replica over the reload bus; until that message is delivered, a replica that answered only from what the bus had given it told the caller the connection did not exist. Nothing distinguished that from a genuine misconfiguration, so the natural response was to go and check whether the connection had really saved.
On a connection name a replica does not hold, it now reads the connection store before answering "not found", and serves what the store holds. A connection the store does not hold is answered exactly as before. The read is the exception rather than the path: a connection the replica already holds is answered from memory, and the requests that arrive for one name while a read is in flight share that read. A connection deleted on another replica while this one was reading it is taken back out rather than served until a restart.
Nothing about a connection is derived per replica, so there is nothing to reconcile beyond the read: a connection's specs and operation embeddings are keyed on its catalog, not on the connection, and every replica reads the same catalog rows. This is the same resolution the graphql kind has had since #1714, in one shared implementation rather than two. Each replica holds those embeddings in memory, read when it builds the connection, and an embed pass runs on one replica: when it finishes, that replica reloads the catalog's connections and announces it over the reload bus, so every replica ranks semantically from then on rather than only the one that ran the pass (#2276).
Auth modes¶
auth_mode |
What it sends |
|---|---|
none |
No outbound auth header |
bearer |
Authorization: Bearer <credential> |
api_key |
<api_key_header>: <credential> (header) or ?<api_key_param>=<credential> (query) |
basic |
Authorization: Basic base64(username:password) per RFC 7617. For legacy APIs (Jenkins, on-prem Jira / Confluence Server / DC, internal apps) that never moved to bearer or OAuth. password may be empty for the token: pattern some APIs use. |
oauth |
OAuth 2.1. The grant is set separately in oauth_grant (client_credentials, authorization_code or jwt_bearer). client_credentials fetches a token at oauth_token_url and applies Authorization: Bearer ...; authorization_code adds a one-time browser sign-in with a persisted (encrypted) refresh token and silent refresh; jwt_bearer signs a short-lived assertion with a registered key and exchanges it at oauth_token_url (RFC 7523), described below. |
signed_jwt |
Authorization: Bearer <jwt the platform minted>. For upstreams that issue an identifier and a signing key and expect the client to mint its own short-lived assertion — Sage X3 connected applications, Snowflake key-pair authentication, Apple App Store Connect and APNs, and internal services built the same way. There is no token endpoint and nothing is exchanged. See Signed JWT upstreams. |
hmac |
<hmac_signature_header>: <prefix><HMAC of the request>, with the timestamp and delivery id headers the scheme signs. For webhook receivers that verify the sender by an HMAC of the body: Standard Webhooks, GitHub, Stripe, and the platform's own inbound webhook sources. The credential is the signing secret. Described below. |
session_login |
<session_token_header>: <session_token_prefix><token>, where the token is the one a sign-in with a stored credential returned. For upstreams that exchange a personal access token, an API key or a password for a short-lived session: Tableau, MicroStrategy, Veeva Vault. Described below. |
mtls |
No header. Authentication happens at the TLS handshake (RFC 5246 / 8446) via the configured client certificate. Used by upstreams that map the cert's subject DN to an internal user identity (service mesh peers, PKI-fronted internal APIs, healthcare integration engines, financial messaging endpoints, FedRAMP services, etc.). |
The OAuth config keys (oauth_grant, oauth_token_url, oauth_authorization_url, oauth_client_id, oauth_client_secret, oauth_scope, oauth_prompt, oauth_endpoint_auth_style) are shared with every other toolkit kind, so an OAuth connection is configured the same way regardless of kind. oauth_scope is a single space-delimited string (the OAuth 2.0 wire form).
The OAuth 2.1 authorization-code grant completes via the platform's shared /api/v1/admin/oauth/callback endpoint, the same path the MCP gateway uses. Register that exact callback URL with the upstream IdP.
An access token from client_credentials or jwt_bearer is cached in memory per replica and obtained again when it expires. expires_in is RECOMMENDED rather than required by RFC 6749 section 5.1, and a token with no expiry would otherwise be presented for the life of the process, so a response that omits it is treated as valid for oauth_token_lifetime (a duration string or seconds, default 15 minutes, #2218): an upstream that ends the session behind the token costs at most that long, and a connection nobody calls costs no token requests at all. An expires_in the upstream does send is always honored, however long or short.
The signed_jwt config keys (jwt_algorithm, jwt_client_secret, jwt_private_key_pem, jwt_key_id, jwt_issuer, jwt_subject, jwt_audience, jwt_token_lifetime, jwt_issued_at_skew) are likewise shared with every HTTP-based kind. The secret and the private key are encrypted at rest and returned as [REDACTED]. Signed JWT upstreams documents them with worked examples.
HMAC signing¶
auth_mode: hmac signs every request the way a webhook receiver verifies its sender, so a managed script can deliver to any webhook receiver through platform.call("api_invoke_endpoint", ...). A script cannot sign a request itself: the dialect has no hash builtin, and the secret would sit in its source and in every recording of its runs. The connection holds the secret, encrypted at rest under credential and never returned.
| Key | Meaning | Default |
|---|---|---|
hmac_preset |
standard_webhooks, github, stripe, platform, slack, zoom, teams, shopify, linear, gitlab or jira: a receiver's whole convention in one setting, the same conventions an inbound source's presets verify. A key set beside it overrides the preset's value for that key. |
none |
hmac_algorithm |
sha256, sha1, sha512 |
sha256 |
hmac_encoding |
hex, base64 |
hex |
hmac_signature_header |
The header the signature is written to | X-Signature |
hmac_prefix |
Written before the signature, such as sha256= or v1, |
empty |
hmac_signed |
body; timestamp.body (the timestamp, a ., the body); id.timestamp.body (the delivery id, a ., the timestamp, a ., the body, the Standard Webhooks form); or a template over {id}, {timestamp} and {body} with the text between them, such as v0:{timestamp}:{body} |
body |
hmac_header_format |
Empty, or stripe, which writes t=<timestamp>,v1=<signature> into the signature header and signs the timestamp and the body |
empty |
hmac_header_pattern |
The signature header's layout over {signature} and {timestamp}, such as {timestamp}\|{signature} or HMAC {signature}. Replaces hmac_prefix and hmac_header_format. |
empty |
hmac_secret_encoding |
Empty for the secret as written, or base64 for the bytes it decodes to (a Teams security token) |
empty |
hmac_timestamp_header |
The header the timestamp is written to; required when hmac_signed includes the timestamp, unless hmac_header_pattern names {timestamp}, and refused with stripe |
empty |
hmac_timestamp_unit |
seconds, milliseconds |
seconds |
hmac_id_header |
The header carrying the delivery id; required for id.timestamp.body |
empty |
The presets set:
| Preset | Signature header | Prefix | Encoding | Signs | Timestamp header | ID header |
|---|---|---|---|---|---|---|
standard_webhooks |
webhook-signature |
v1, |
base64 | id.timestamp.body |
webhook-timestamp |
webhook-id |
github |
X-Hub-Signature-256 |
sha256= |
hex | body |
||
stripe |
Stripe-Signature, format stripe |
hex | timestamp.body |
(in the signature header) | ||
platform |
X-Signature |
sha256= |
hex | timestamp.body |
X-Timestamp |
The keys mirror an inbound webhook source's auth settings, and both are built on one implementation of the signing string, so a connection and a source given the same values agree. platform is the configuration the inbound webhook documentation creates a source with.
- The signature covers the exact bytes sent, after the body is encoded.
- The timestamp is taken as each request is sent, so a call made again is signed afresh rather than refused as stale.
- The delivery id is the caller's when the call sets the
hmac_id_headerheader, and a randommsg_...otherwise. A script that wants one id across runs sets it from data it controls, such asrun.run_idand an index, or the source row's key. The signature and timestamp headers are reserved: a call cannot set them, and neither canstatic_headers, which cannot fix the id header either. - A Standard Webhooks secret (
whsec_and base64) is used as that standard reads it, the bytes the base64 decodes to, when the scheme signsid.timestamp.body; any other secret is used as written. - A
GETorHEADwith no body is refused when only the body is signed. Signing an empty body is allowed when the timestamp is signed too. - Rotating the secret is the connection's credential update. The sending side holds no overlap; the receiver does, as an inbound source does with
rotation_overlap_seconds.
Schemes that sign headers, the URL or the query string (AWS SigV4, HTTP Message Signatures, RFC 9421) are a different shape and are not covered by this mode.
Session sign-in¶
auth_mode: session_login is for an upstream that authenticates with a sign-in exchange: the client sends a long-lived credential to a login endpoint, the answer carries a short-lived session token, and every later call sends that token in a vendor-chosen header. Tableau is the clearest case (a personal access token to /api/{version}/auth/signin, the token back at credentials.token, and X-Tableau-Auth on every REST and Metadata API call); MicroStrategy (X-MSTR-AuthToken) and Veeva Vault (sessionId sent back as Authorization) work the same way. No other mode reaches these: a pasted session token works until it expires, and the login endpoints take vendor-shaped bodies and answer the token somewhere other than an OAuth access_token.
| Key | Meaning | Default |
|---|---|---|
session_login_url |
The sign-in endpoint: an absolute URL, or a path resolved against the connection's base URL. Required. | none |
session_login_method |
The sign-in request's method | POST |
session_login_body |
The sign-in body, with {{secret}} where session_login_secret is written. It is not secret, so it reads back as written. |
empty |
session_login_content_type |
The body's media type: application/json, application/xml or application/x-www-form-urlencoded. The secret is escaped for it. |
application/json |
session_login_secret |
The credential written into the body. Encrypted at rest and read back as [REDACTED]. A body or a secret is required, and each needs the other when the body names {{secret}}. |
empty |
session_login_headers |
Headers sent on the sign-in request only. The sign-in sends Accept: application/json unless this sets another. Their values are encrypted at rest and redacted when the connection is read back, like static_headers (#2359); a connection saved before keeps them in clear until its next save. |
none |
session_token_source |
Where the token is in the sign-in answer: body:<dotted json path> (a numeric segment indexes a list) or header:<name>. Required. |
none |
session_token_header |
The header the token is sent in on every call | Authorization |
session_token_prefix |
Written before the token. With this and session_token_header both blank the call carries Authorization: Bearer <token>; naming Authorization with no prefix sends the raw token, as Veeva Vault reads it. |
empty |
session_ttl |
How long a session is used before the platform signs in again without waiting to be rejected, as a duration (2h) or seconds |
until rejected |
session_logout_url |
Called with the token when the connection is removed, replaced or shut down, so the upstream does not keep orphaned sessions | none |
session_logout_method |
The sign-out request's method | POST |
session_capture |
Further values the sign-in answer carries, by name, each read from a source in session_token_source's form. A call writes {session.<name>} in its path to have the value put there. |
none |
session_expired_statuses |
Statuses that mean the session is no longer accepted, as a list or comma-separated text | [401] |
session_expired_marker |
Text in a response body that means the session is no longer accepted, for an upstream that answers an expired session with 200 or 403 and an error body |
none |
How a session is held:
- The platform signs in on the first call (a
graphqlconnection reads its schema when it is saved, so it signs in then), and every call through the connection shares the session: api tool calls, page walks and exports, a graphql connection's queries and introspection, and a notification channel's deliveries. Calls that arrive during a sign-in wait for it, each within its own call timeout, so a burst of calls produces one sign-in. - A response with an expiry status (or the marker) ends the session. The platform signs in once more and sends the call again, with the same body. A second rejection is not retried: the call fails with
the upstream rejected a fresh session; check the connection's sign-in credentialand a short quote of the upstream's error, with the secret and the token taken out of it. For the next 30 seconds an expiry answer is returned to the caller as the upstream sent it, without signing in again, since the credential is what it refuses. - A sign-in that is refused, unreachable, or answered without a token where
session_token_sourcesays one is fails the call withsession sign-in failedand the status and quote. Calls in the next 30 seconds get the same failure without another sign-in, so a wrong secret is not tried at the upstream on every call, which some vendors answer by locking the account. The connection test (POST /api/v1/admin/connection-instances/{kind}/{name}/test) performs a real sign-in and reports such a failure ascould not sign in; a sign-in that returns a session is reported as the credential working, whatever status the probe'sGETthen gets at a route the catalog does not declare (#2368). - The sign-in and sign-out go to their own URLs and do not carry
path_secret. A sign-out is sent in the background, within 5 seconds, both when the connection is removed, replaced or shut down and whensession_ttlretires a session; a process that exits first leaves that session to expire at the upstream. - The session is held in memory per replica. Each replica signs in for itself.
- The token header is reserved: a call cannot set it, and neither can
static_headers. The token, the secret and the body are never logged. - A call that names
{session.<name>}the sign-in does not capture is refused, naming the values it does capture.
Some of these upstreams allow one live session per credential, so a second sign-in with the same personal access token ends the first. Give each connection its own credential, and do not share one between the platform and another client. With several replicas, a credential like that is held by one replica at a time: give the connection a credential the upstream allows concurrent sessions for, or expect a call on another replica to sign in again.
Tableau, with the site id captured so a REST path needs no separate lookup:
kind: api
name: tableau
config:
base_url: https://tableau.example.com
auth_mode: session_login
session_login_url: /api/3.22/auth/signin
session_login_body: '{"credentials":{"personalAccessTokenName":"platform","personalAccessTokenSecret":"{{secret}}","site":{"contentUrl":"acme"}}}'
session_login_secret: ${TABLEAU_PAT_SECRET}
session_token_source: body:credentials.token
session_token_header: X-Tableau-Auth
session_capture:
site_id: body:credentials.site.id
session_logout_url: /api/3.22/auth/signout
A call then reads a site's workbooks with "path": "/api/3.22/sites/{session.site_id}/workbooks", or "path_params": {"site-id": "{session.site_id}"} against a catalog operation; the platform writes the site id in as the call is sent. A graphql connection to Tableau's Metadata API (/api/metadata/graphql) takes the same keys. Name the captured values in the connection's description so a model knows they exist.
A secret in the URL¶
path_secret is appended to every request's path as it is sent, for a receiver that authenticates by a secret in the URL: a chat incoming webhook's token, or an inbound source's path_token. Unlike base_url, which a connection read returns as written, it is encrypted at rest, read back as [REDACTED], and never appears in a call's path, the audit log or an error: the request a call builds carries the path without it, and the copy that is sent carries it. It works with every auth_mode.
A secret in the request¶
Some requests have to carry a credential that is not the connection's own: a password typed into a vendor's login form through a WebDriver connection (POST /session/{id}/element/{id}/value), a key an API wants in a JSON body field. An administrator stores it under Admin > Secrets (or PUT /api/v1/admin/secrets/{name}), and a caller writes {{secret:<name>}} where the value goes:
{
"connection": "selenium-grid",
"method": "POST",
"path": "/session/4b1c/element/9f2e/value",
"body": {"text": "{{secret:portal_password}}"}
}
The placeholder works in body (an object, or a string of JSON or form text, with the value escaped for it), query_params, path_params, path and headers, for api_invoke_endpoint and api_export, and on every page of a paginate walk.
- When it is filled. As the request is sent, on a copy. The call's arguments, the audit row, the call record, a script's recording and the
export_argumentsandnext_argumentsa result hands back all hold the placeholder, so a test that replays a recorded run needs no value. - Where it may go. Each secret names the connections it may be sent through (
allow_connections, required) and, optionally, the personas that may use it (allow_personas; empty means any persona that can reach one of its connections, and the administrator persona may always use it).allow_connectionsholds for every caller, the administrator included: it is where the value may go. A placeholder outside that scope, a name no secret is stored under, and a malformed placeholder are each refused by name before anything is sent. A placeholder is never sent as written. - What comes back. Every occurrence of the value in the upstream's response headers and body, as written or JSON-, query- or path-escaped, is replaced with
[REDACTED:<name>]before the response reaches the caller, so reading the field back (GET .../element/{id}/property/value) does not hand it over. An error message that would carry it (a filled path in a transport error) is redacted the same way. Anapi_exportis redacted as it streams. - Storage. The value is encrypted at rest with the same field encryption as connection credentials, must be at least 6 characters (it is redacted wherever it appears, and a shorter one would rewrite ordinary text), and is never returned by the API or shown in the portal. Changing a secret without a
valuekeeps the stored one. A rotated value is used from the next call, with no reload. - Finding one. The search box above the Admin > Secrets table narrows it as you type, matching a secret's name, description, and the connections and personas it is allowed for (every word typed must match); a persona's name also finds the secrets no persona list restricts, since any persona may use those (#2352). The search is kept in the page's address as
?q=, so a filtered list can be linked and survives a reload. It never reads a value.
Two uses of a stored secret are the platform's own rather than a connection's, and each is named in allow_connections by a reserved name: bot_check for a bot check provider's secret key (see Endpoints), and script_token for the key a script signs tokens with (see Signed tokens), whose allow_personas is checked against the persona the script's author roles resolve to.
A secret in a connection's configuration¶
A connection's own credential can name a stored secret too, so it is held in one place and rotated there. Write {{secret:<name>}} where the value goes, for example in a Tableau sign-in body:
{
"auth_mode": "session_login",
"session_login_url": "auth/signin",
"session_login_body": "{\"credentials\":{\"personalAccessTokenName\":\"plexara-rest\",\"personalAccessTokenSecret\":\"{{secret:tableau-rest}}\",\"site\":{\"contentUrl\":\"acme\"}}}",
"session_token_source": "body:credentials.token",
"session_token_header": "X-Tableau-Auth"
}
- Where it may be written. On an
apiorgraphqlconnection:credential,username,password,path_secret,session_login_body,session_login_secret, the values ofstatic_headersandsession_login_headers, andoauth_client_idandoauth_client_secretunder theclient_credentialsgrant. On anmcpconnection:credential, and the OAuth client id and secret underclient_credentials. On atrinoconnection:password. On ans3connection:access_key_id,secret_access_keyandsession_token. A reference anywhere else, a malformed one, and a{{totp:<name>}}are refused at save. - Saving. The portal and the admin API store the reference as written and read it back as written: a credential field holding only a reference is not shown as
[REDACTED]. A body that names a stored secret needs nosession_login_secret; one carried back from the portal as[REDACTED]is dropped. The built-inplatform-adminconnection setsfill_secrets: false, so a connection saved through it reaches the admin API with the reference intact. Anyapiconnection can set the same key to send a call's placeholders as written. - Scope. The secret's
allow_connectionsmust list the connection and itsallow_personasmust be empty, since a connection's credential serves every persona that uses it. A save that names a missing secret, or one that may not be used by the connection, is refused naming both. - When it is read. On
api,graphqlandmcpconnections, each time the connection sends a request of its own: every call's credential and headers, every sign-in, every client-credentials token exchange. Rotating the secret changes the next request or sign-in, with no connection save. The value is redacted from the response like a call's own secret; on anmcpconnection, from every forwarded tool result (its text, embedded resources and structured content) and error (#2357), and a failed client_credentials exchange reports only the token endpoint's status and error code. Atrinoors3connection builds its client once, so its secret is read when the connection is built: at startup, when the connection is saved, and when another replica reloads it. After rotating the secret, save the connection again. The platform's own clients over the same connection (the portal's bucket, the query provider) read it the same way. A secret that cannot be read is never replaced by an empty credential: the connection is not built, and the reason is logged naming the connection and the field. - Not covered. mTLS keys and signed-JWT private keys are read when the connection is built, so they are written in full.
A one-time code in the request¶
An automation that signs in as a person may meet a second factor: a six-digit code from an authenticator app that changes every 30 seconds. The code is a function of the app's seed and the time (RFC 6238), so the platform can produce it. Store the seed as a secret of kind totp, given as the otpauth://totp/... URI the provider's QR code encodes or as the bare base32 seed, and write {{totp:<name>}} where the code goes:
{
"connection": "selenium-grid",
"method": "POST",
"path": "/session/4b1c/element/a71d/value",
"body": {"text": "{{totp:vendor_portal_mfa}}"}
}
It is filled where {{secret:<name>}} is (body, query_params, path_params, path, headers, in api_invoke_endpoint and api_export), and the call's arguments, audit row, call record and a script's recording hold the placeholder.
- Parameters. Algorithm (SHA1, SHA256, SHA512), digits (6 or 8) and period (default 30 seconds) come from the URI; a bare seed takes SHA1, 6 digits, 30 seconds. HOTP (counter-based) URIs are refused.
- Kinds do not cross.
{{secret:<name>}}naming a totp secret is refused, so the seed itself is never sent, and{{totp:<name>}}naming a value secret is refused. Scope is checked as for any secret. - One code per period. Providers refuse a code they already accepted in its period, so the period a code was issued for is recorded in the secret's row, which every replica shares. A second request in the same period waits for the next period and is sent its code; a third in that period is refused, naming when to try again. The wait is not taken from the connection's
call_timeout, which starts once the request is ready to send. - Redaction. The seed is redacted from responses. The code is not: 6 to 8 digits would rewrite ordinary numbers, and it is useless once its period passes.
- Checking the setup. The secret's page under Admin > Secrets has Current code, which shows the code for this moment and the seconds left in its period (
GET /api/v1/admin/secrets/{name}/code), to compare with the phone app. Showing it does not use the code up.
A totp secret puts both of an account's factors in the platform, limited by allow_connections and allow_personas. That is what an unattended sign-in requires.
OAuth JWT bearer grant (RFC 7523)¶
oauth_grant: jwt_bearer is the unattended server-to-server flow many OAuth providers recommend for integrations: the upstream registers a public key for an application and approves an integration user for it, and the client signs a short-lived assertion with the private key and exchanges it at the token endpoint for an access token. There is no browser, no refresh token, and no client secret on the wire unless the upstream also asks for one. It is available on every HTTP-based kind (api, graphql); an mcp connection saved with it is refused.
{
"config": {
"base_url": "https://crm.example.com/services/data/v61.0",
"auth_mode": "oauth",
"oauth_grant": "jwt_bearer",
"oauth_token_url": "https://login.crm.example.com/services/oauth2/token",
"jwt_algorithm": "RS256",
"jwt_private_key_pem": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
"jwt_issuer": "<the application's client id>",
"jwt_subject": "[email protected]",
"jwt_audience": "https://login.crm.example.com"
}
}
The assertion is signed with the signed_jwt keys, with two defaults of its own: jwt_algorithm defaults to RS256 (ES256 works the same way, and HS256 over jwt_client_secret is accepted for the upstreams that take it), and jwt_audience defaults to oauth_token_url rather than the connection's endpoint URL. Some providers register a different audience, such as the login host rather than the token path, and match it byte for byte. The assertion carries iss, sub, aud, iat, exp and a random jti, so two replicas exchanging in the same second never present the same assertion to an upstream that refuses a replay.
The platform POSTs grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer and assertion=<jwt> to oauth_token_url. Where oauth_scope goes is oauth_scope_placement: param (the default) sends it as the scope form parameter, claim writes it as a scope claim inside the signed assertion and leaves it off the form, and both does both. Google reads scopes only from the claim, and several other RFC 7523 endpoints do too. oauth_client_id and oauth_client_secret are optional: set them only for an upstream that also authenticates the client on the token request, and oauth_endpoint_auth_style places them. With no secret, a client id is sent as a form parameter and nothing else. The access token is cached in memory and a new assertion is signed and exchanged as it nears expiry, under the same expiry rule client_credentials follows. A refresh_token in the response is ignored.
The save is refused, naming the key, when oauth_token_url is missing, jwt_issuer and jwt_subject are both empty (one identifies the client; sub is omitted when jwt_subject is unset, which is what Google means by a service account acting as itself), oauth_scope_placement is not param, claim or both, the key material does not match jwt_algorithm or is not a usable key, oauth_client_secret is set without oauth_client_id, or the lifetime and skew rules of signed_jwt are broken. jwt_token_lifetime is the assertion's lifetime, not the access token's; providers commonly refuse one longer than a few minutes.
When the token endpoint refuses the assertion with invalid_grant, invalid_client, unauthorized_client or invalid_scope, the call fails with the upstream's code and error_description, for example oauth jwt_bearer: the upstream token endpoint rejected the signed assertion: invalid_grant: user hasn't approved this consumer, and the connection's alert is raised: the connection alert recipients, when the operator has named any, are emailed at once, because nobody signed in to authorize the connection. The usual causes are a key or an integration user the upstream has not approved, or clock skew, all fixed at the upstream. Nothing needs reconnecting: the next call signs and exchanges a new assertion, and the first exchange the upstream accepts clears the alert. Any other failure (a network error, a 5xx, another error code) raises no alert and is reported without the upstream's response body, as it is for client_credentials.
Google service accounts¶
A Google service account is how a server reaches Google APIs unattended: BigQuery, Sheets, Drive, Search Console, Cloud Storage and most other Google Cloud and Workspace APIs. No person signs in and no consent screen is involved, so Google's OAuth app verification does not apply, which for Google's sensitive and restricted scopes is often why a service account is the only practical option. The platform takes the JSON key file Google issues as one value and fills the jwt_bearer grant from it.
"config": {
"base_url": "https://sheets.googleapis.com",
"google_service_account_json": "{\"type\": \"service_account\", \"project_id\": \"acme-analytics\", ...}",
"oauth_scope": "https://www.googleapis.com/auth/spreadsheets.readonly"
}
From the file the platform sets auth_mode: oauth and oauth_grant: jwt_bearer, signs with private_key (RS256) under its private_key_id, issues as client_email, exchanges at token_uri (also the assertion's aud), and carries oauth_scope as a claim in the assertion (oauth_scope_placement: claim). Every key the operator sets explicitly wins over the value the file supplies, so an unusual setup stays possible. oauth_scope is required: Google issues no token without one, and each scope is its full URL. Set jwt_subject only under domain-wide delegation, where it names the Workspace user the account acts as; unset, no sub is sent and the token is the service account's own.
The save is refused, naming the field and never a value, when the file is not JSON, its type is not service_account (an OAuth client's file is a different download), or it has no private_key, client_email or token_uri. google_service_account_json is encrypted at rest and reads back as [REDACTED], beside google_service_account_identity: the client_email, project_id and private_key_id the file names. Nothing in those is secret, and they are how an administrator tells which account and which key a connection uses, for example after rotating the key in Google Cloud.
Keeping the key out of the connection. google_service_account_secret names a stored secret whose value is the key file, in place of google_service_account_json. The private key then never enters a tool call, a conversation or the connection's config: an administrator or an agent creates the connection by the secret's name. The secret's allow_connections must list the connection, and it must set no allow_personas, because the token one key mints is cached and served to every persona that uses the connection. The secret is read at every token exchange, so a key rotated in the secret is the key the next token is minted with. With the key in a secret, the token endpoint is https://oauth2.googleapis.com/token unless oauth_token_url says otherwise. Setting both keys is refused.
One account, many connections. One service account can back several connections to different Google APIs. Each connection requests only its own scope, and access is still decided by the grants inside each product. A stored secret listing every one of those connections is how they share the key without it being pasted again.
Setting one up.
- In Google Cloud, create the service account and a JSON key for it (IAM & Admin > Service Accounts > Keys > Add key).
- Enable each API the connections call in the account's Cloud project (APIs & Services > Library). A call to an API that is not enabled is refused with
SERVICE_DISABLED, naming the project and the API. - Grant the account's
client_emailaccess inside each product, as you would a person: a share on a spreadsheet or Drive folder, a Search Console user, a BigQuery or Cloud Storage IAM role. - Create the connection with the key file (or the stored secret's name) and the scopes, then run the connection test (
POST /api/v1/admin/connection-instances/{kind}/{name}/test, or Test connection in the portal). It performs a real token exchange and reports the account the token was issued for and the scopes Google granted, or Google's refusal.
When Google refuses. Most of these are fixed outside the platform, so the error carries the step:
| Google's answer | What it means | What the platform says |
|---|---|---|
Token 400 invalid_scope |
A scope is missing or is not a valid scope URL | check oauth_scope |
Token 400 invalid_grant "Invalid JWT Signature" or "account not found" |
The key was deleted or disabled, or the account was removed | create a new key, or check the account still exists |
Token 400 invalid_grant "Token must be a short-lived token" |
Clock skew | check the host clock and jwt_issued_at_skew |
API 403 with reason SERVICE_DISABLED |
The API is not enabled in the account's Cloud project | enable it there |
API 403 with reason ACCESS_TOKEN_SCOPE_INSUFFICIENT |
The token does not carry the scope this API needs | add the scope to the connection's oauth_scope |
Any other API 401 or 403 in Google's error shape (PERMISSION_DENIED, UNAUTHENTICATED, ...) |
The token is valid but the account has no access in the product | grant the account's email access inside the product |
A token refusal raises the connection's alert like any jwt_bearer refusal. The API refusals arrive on a data call rather than at the token endpoint, so they are the call's hint on an api connection, not an authentication failure.
Pick signed_jwt when the upstream validates the client-minted JWT itself, and jwt_bearer when it exchanges the JWT for a token of its own; Signed JWT upstreams compares the two.
Deprecated (still accepted on read). Earlier api-gateway connections used an
oauth2_*key prefix and encoded the grant in theauth_modevalue (oauth2_client_credentials/oauth2_authorization_code), withoauth2_scopesas an array. Those are still read as a fallback, so a connection a configuration file declares in that spelling keeps working; the fallback is scheduled for removal in a future release.Nothing writes them any more. A write in the earlier spelling is rewritten onto the canonical keys before it is stored, whichever route it arrives on, so one vocabulary is persisted and the upgrade migration is not a one-shot window a later save can undo. A write carrying both spellings with disagreeing values is refused with a 400 naming the pair: the canonical key wins at read time, so accepting it would authenticate with one value while displaying the other beside it as an equal. Equal values are not a conflict and the earlier key is dropped.
A connection stored before that check says so where it is read.
GET /api/v1/admin/connections/{kind}/{name}/oauth-statusreportsconfig_vocabulary(canonical,legacyormixed) andshadowed_config_keys, the keys whose value nothing reads, and the portal strikes those values through in the connection's configuration table. Opening such a connection in the editor and saving it rewrites the row into one vocabulary.
Identity passthrough¶
This connection option supports the built-in self-configuration connection (see Self-Configuration):
| Key | Type | Meaning |
|---|---|---|
identity_passthrough |
bool | Forward the acting caller's inbound bearer token as the outbound Authorization header instead of applying this connection's shared credential. Requires auth_mode: none. A call with no caller token fails rather than calling anonymously; a saved script's run has none, so its call is refused naming the connection (the built-in platform-admin connection serves a script's call as the script's identity instead, see Self-Configuration). Intended for loopback calls to the platform's own API where the change must be attributed to the real user, not a shared identity. |
required_path_prefix |
string | A path, such as /api/v1, that every raw method + path call on the connection must start with. api_invoke_endpoint and api_export refuse a path outside it before sending anything, naming the path with the prefix added and, when the catalog declares an operation there, its operation_id. Calls addressed by operation_id are unaffected, and so is a connection whose base_url path already ends with the prefix, which supplies it. For an upstream whose base_url is a host root that also serves routes the catalog does not describe, where a missing prefix would reach the wrong handler instead of failing. |
probe_path |
string | The authenticated read the connection test asks for, such as /version (Kubernetes) or /api/v4/users/me (Mattermost), with the connection's credential. A 401 or 403 there is the upstream refusing the credential. Without it the test asks for the shortest read the catalog declares that takes no required parameter, or GET /, where a 403 or a 200 says nothing about the credential and records no state (#2443). |
There is no per-connection "admin only" flag. Connections are deny-by-default (Personas): a connection is reachable only by personas whose connections.allow lists it, so restricting a connection to admins is just a matter of not granting it to other personas.
Response size: the read cap and the context budget¶
Two limits bound what a response becomes, and they measure different things for different callers.
max_response_bytes(default 10 MiB), set on the connection, is the upstream read cap: the most the gateway reads of any one response, a page of a walk or an inline call. It is a transfer and buffering limit, and it is the only size limit a REST gateway client or a managed script meets. For those callers a response past it fails with theupstream_body_too_largeerror, which the REST route returns as413, rather than arriving cut under a200. A model's call past it has its body cut at the cap, flagged and steered toapi_export.- The context budget,
tools.result_budget(default 32 KiB), is set once for the platform, not per connection (see Tool result context budget). It is the most of a rendered tool result a model client over MCP is handed, and only a model's call is held to it. It is applied to the result the client receives rather than to the bytes read from the upstream, because the two differ by more than a constant: the JSON envelope and the indentation the result is rendered with sit between them, and a parsed JSON body renders to several times its compact size. Fitting spends the cheapest lever first. A result that fits indented is returned indented; one that does not is returned compact, since indentation is whitespace and dropping it costs nothing where the alternative is dropping content. Only a list is cut past that, and only on whole items: a body that is a JSON array, or an object carrying one array (found through objects that each carry one object, down to three levels). The cut keeps the most leading items that fit, so the body stays valid JSON; it setsbody_truncated, reportsbody_items: {shown, total}, and says inhinthow to read on. Where the operation's spec declares paging parameters,next_argumentsis theapi_invoke_endpointcall that reads the items after the ones shown, named in the hint beforeapi_export, and the response's ownpaginationsignal is dropped from the cut result, since it points past the whole page: withoffset/limit(orskip,$skip/$top,start) it is the same call with the offset advanced past them; withpageand a page size (per_page,page_size,limit, ...) it asks for a page size that divides the items read through the cut and the page that starts right after them, the cut and that size chosen together near what fits; with a cursor (cursor,after,page_token, ...) and a page size it is this page asked for again that many items at a time, because the response's cursor points past the whole page and following it would skip the items cut, and the pagination that smaller response carries continues from there. A page or cursor operation declaring no page size, or an operation declaring no paging, gets nonext_arguments.export_argumentscarries theapi_exportcall that streams the whole response into a portal asset in one call (the caller adds aname).body_bytesstays the size read from the upstream. A body with no list to cut -- a JSON scalar, a text body, an object carrying two arrays or none within reach, or a list whose first item alone is past the budget -- is cut to the longest prefix that fits, as before:body_truncatedis set, the hint names the budget and the upstream's size, andexport_argumentscarries theapi_exportcall. The read cap bounds the read, so a connection whosemax_response_bytesis lower reads at most that. - The budget is measured against the tool result's text, the size a client was measured refusing. The MCP SDK also marshals the same output into the result's
structuredContent; a fitted result replaces that copy with the fitted value, so the message carries the cut result twice rather than the whole body once. - A REST gateway client, a managed script and an admin call are not held to the budget at all. Each parses the response in code, so a cut body is not what it asked for, and the steer to
api_exportis not something a program acts on. They read to the connection'smax_response_bytesand receive the response whole. - A page walk is never cut. Cutting a merged collection would return a broken array whose resume signal points past items the caller never received, so a walk expresses the budget by refusing the page that would cross it, keeping headroom for the envelope around the array. A walk whose first page alone is past the budget merges nothing and says so, rather than naming a
paginationfield it does not carry.
Every api_invoke_endpoint response reports body_bytes, the size of the body it read, so an agent sees what a call cost rather than inferring it. api_export is not subject to the context budget: it streams the whole response into an asset under portal.export.max_bytes. The raw passthrough route streams to its caller and is likewise unaffected. The built-in util connection's fetch_url returns through the same path as every other connection and is held to the same budget. A proxied MCP gateway tool (kind: mcp) has no export path to name, so its result is never cut: it reaches the model as the upstream returned it.
Raise the budget for a tool whose results an agent needs whole, in the platform configuration:
max_inline_bytes, the per-connection budget this replaced, has no effect; a connection that still carries it loads normally and logs a warning naming tools.result_budget.
Private CAs and mTLS¶
mTLS (RFC 5246 / 8446 client certificate authentication at the TLS handshake) is the standard way HTTPS clients authenticate when a header bearer is not enough. The toolkit supports it generically; nothing here is vendor-specific. Common targets:
- Service mesh peering (Istio, Linkerd, Consul Connect) where workload identity is a mesh-issued client cert.
- PKI-fronted enterprise APIs that pre-date OAuth.
- Healthcare integration engines (Mirth Connect, Rhapsody, InterSystems IRIS HealthShare).
- Financial messaging endpoints: SWIFT REST surfaces, Open Banking / FAPI, bank-direct payment APIs.
- FedRAMP / DoD-boundary services (DISA Cloud IL⅘) where a DoD-CA-issued cert is the access gate.
- HashiCorp Vault when the configured auth method is
cert/. - Kubernetes API server, etcd, and other PKI-bootstrapped infra.
- Apache Kafka REST Proxy, Schema Registry, NiFi, and similar Apache projects when deployed with the standard security profile.
- Any HTTPS service signed by a private CA the host does not carry by default (the CA-bundle half of this feature is useful on its own, even when no client cert is required).
Two TLS concerns live on every kind: api connection regardless of auth mode:
- Outbound client certificate (
mtls_client_cert_pem+mtls_client_key_pem). The gateway presents this cert during the TLS handshake. Withauth_mode: mtls, the cert IS the credential; with any other auth mode (bearer, api_key, basic, oauth2_*), the cert is layered on top. - Custom server CA trust (
tls_ca_bundle_pem). A PEM bundle appended to the system root pool when verifying the upstream's TLS certificate. Required when the upstream is signed by a private CA (corporate root, cluster-internal CA) that the host's default cert store does not carry. Public CAs remain trusted; the bundle never substitutes for the system roots.
Both are optional and orthogonal. An internal HTTPS service behind a private CA may only need the bundle; an upstream that requires mTLS but has a public TLS cert needs only the cert + key; an upstream that wants both (signed by a private CA AND requiring client auth) sets all three.
There is no insecure_skip_verify flag. To talk to a self-signed endpoint, paste the endpoint's CA into tls_ca_bundle_pem.
Validation rules¶
mtls_client_cert_pemandmtls_client_key_pemmust be set together (or both empty). The toolkit refuses a connection with only one half of the pair.- The cert and key must parse as PEM and the key must match the cert (
tls.X509KeyPairruns a signature check at write time). - Key strength is enforced: RSA must be at least 2048 bits, ECDSA must use one of P-256 / P-384 / P-521, Ed25519 is accepted. Smaller or non-NIST keys are rejected.
tls_ca_bundle_pem, when set, must contain at least one parseableCERTIFICATEblock. A bundle that contains only PRIVATE KEY blocks is rejected.auth_mode: mtlsrequires both cert and key.
Encryption at rest¶
The private key (mtls_client_key_pem) is encrypted with AES-256-GCM via the platform's FieldEncryptor when ENCRYPTION_KEY is set. The cert and CA bundle are public material and stored in plain text. Admin API responses redact the private key as [REDACTED]; re-submitting the value [REDACTED] on a PUT preserves the existing key.
Cert expiry surfacing¶
GET responses on /api/v1/admin/connection-instances/api/{name} include mtls_cert_not_after as an RFC3339 UTC timestamp parsed from the leaf cert. The portal renders an expiry badge from this field (green at 30 or more days remaining, amber under 30 days, red when expired). The badge is informational only; the toolkit does NOT refuse to make calls with an expired cert because the upstream's TLS layer will reject the handshake on its own and the model's error feedback loop is the right place to learn this.
IdP behind a private CA¶
When auth_mode is oauth (either grant) and the IdP itself is signed by a private CA, set tls_ca_bundle_pem on the connection. The same bundle is honored by the token-exchange and refresh paths so token fetches succeed against private IdPs. Client mTLS material is NOT presented to the IdP; if your IdP requires a client cert at the token endpoint, that's a separate concern from upstream mTLS and is not yet supported.
Configuring an mTLS connection¶
The shape is the same for every upstream: obtain a client cert + private key from the upstream's CA (or the CA that the upstream is configured to trust), give the gateway both PEMs plus the CA's cert, and select the right auth_mode. Below is a generic example using openssl; the curl call is identical for any upstream that wants mTLS.
# 1. Obtain (or mint, for testing) a client cert from the CA the upstream trusts.
# In production this comes from your PKI tooling; for a smoke test, openssl
# can mint a leaf signed by a CA you also control.
openssl req -new -newkey rsa:2048 -nodes \
-keyout gw.key -out gw.csr \
-subj "/CN=mcp-data-platform/OU=service"
openssl x509 -req -in gw.csr -CA upstream-ca.crt -CAkey upstream-ca.key \
-CAcreateserial -out gw.crt -days 365 -sha256
# 2. Register the cert's identity with the upstream.
# The exact step depends on the upstream: an Apache project may map the DN
# in authorizations.xml; a service mesh ingress may bind the SPIFFE ID;
# Vault's cert auth method matches against the cert directly. Whatever the
# upstream's identity-mapping mechanism is, do it now.
# 3. Create the gateway connection.
curl -X PUT \
-H "X-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d "$(jq -n \
--arg cert "$(cat gw.crt)" \
--arg key "$(cat gw.key)" \
--arg ca "$(cat upstream-ca.crt)" '{
config: {
base_url: "https://upstream.example.org",
auth_mode: "mtls",
mtls_client_cert_pem: $cert,
mtls_client_key_pem: $key,
tls_ca_bundle_pem: $ca
},
description: "Internal HTTPS upstream behind private CA"
}')" \
https://platform.example.com/api/v1/admin/connection-instances/api/upstream
# 4. Verify the connection by hitting any path the upstream exposes.
curl -X POST -H "X-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"method":"GET","path":"/healthz"}' \
https://platform.example.com/api/v1/gateway/upstream/invoke
For upstreams that issue their own client certs via tooling (Apache NiFi's tls-toolkit.sh, Vault's PKI engine, cert-manager, your corporate PKI portal), substitute that tool's output for the openssl step. The toolkit only sees the PEM-encoded cert, key, and CA bundle.
Static headers¶
Some APIs require both an OAuth bearer and a separate header on every call. auth_mode is a single value, so the toolkit cannot satisfy that with auth_mode alone. static_headers is the second slot.
Headers listed under static_headers are attached to every outbound request, in addition to whatever auth_mode contributes. They are operator-supplied: the model cannot set, override, or read them, and validation refuses to load a connection whose static_headers would collide with the auth path.
User-Agent¶
Every outbound request presents User-Agent: mcp-data-platform/<version> unless the request already names one. Go's default, Go-http-client/1.1, is a value a web application firewall in front of a public endpoint refuses outright, and the refusal is an HTML page that does not say why. The product string names the platform's traffic in an upstream's access log and gives a firewall rule something to match on. To send a different value, pin it: static_headers: {"User-Agent": "<value>"}. A per-call headers entry naming User-Agent is honored the same way, under the precedence below. The same default applies to the token requests an oauth connection makes to its identity provider.
Trace propagation¶
Every outbound request carries the W3C traceparent and tracestate headers, so an upstream that records its own telemetry joins the trace of the tool call that reached it, and has a client span of its own in the platform's trace (#1895; see Observability). An upstream that rejects unknown headers, or that must not see the deployment's trace ids, turns this off on its connection with trace_propagation: false. The client span and the http_client_requests_total count are recorded either way.
Encryption at rest¶
Header values are encrypted with AES-256-GCM via the platform's FieldEncryptor (same mechanism that protects credential, client_secret, etc.). Set ENCRYPTION_KEY to enable; without it, values are stored in plaintext just like every other sensitive field. The admin API redacts header values to "[REDACTED]" so the portal can edit other fields without ever showing the secret.
Validation rules¶
- Header names must use only the RFC 7230 token character set (no spaces, no colons).
- Values cannot contain CR/LF/NUL (refused as a header-smuggling vector).
- Cannot set
Authorization(useauth_mode). - Cannot set the API-key header chosen by
auth_mode: api_key(already managed). - Cannot set hop-by-hop headers Go's net/http manages itself:
Host,Content-Length,Connection,Transfer-Encoding,Upgrade,Keep-Alive,Proxy-Authenticate,Proxy-Authorization,TE,Trailer.
The model is also blocked at request time from supplying a custom header whose name collides with any static_headers entry — the operator's header is authoritative.
Header precedence¶
For each outbound request, headers are layered in this order (later wins):
- Per-call headers from the tool input (
api_invoke_endpoint.headers). static_headers(operator-configured).auth_modecontribution (Authorization, API-key header, etc.).
Content-Type is the one exception, and only for a multipart body the platform assembled: its boundary is the only one that matches the bytes, so it replaces any Content-Type set at either layer above. Every other encoding yields to a Content-Type already present.
Example: Google APIs¶
For unattended access a Google service account is usually the better fit: the key file and the scopes are the whole connection, and no person's sign-in is involved. The example below is the authorization_code form, which ties the connection to the account that signed in.
Google APIs that bill quota against a separate project use the x-goog-user-project header alongside the OAuth bearer.
"config": {
"base_url": "https://www.googleapis.com",
"auth_mode": "oauth",
"oauth_grant": "authorization_code",
"oauth_authorization_url": "https://accounts.google.com/o/oauth2/v2/auth",
"oauth_token_url": "https://oauth2.googleapis.com/token",
"oauth_client_id": "your-google-client-id",
"oauth_client_secret": "your-google-client-secret",
"oauth_scope": "https://www.googleapis.com/auth/drive.readonly",
"static_headers": {
"x-goog-user-project": "your-quota-project-id"
}
}
Example: Salesforce REST¶
Salesforce's REST API typically does not need a second header, but the same shape works when a Salesforce instance fronts the API with an API gateway that adds a subscription header.
"config": {
"base_url": "https://your-instance.my.salesforce.com",
"auth_mode": "oauth",
"oauth_grant": "authorization_code",
"oauth_authorization_url": "https://login.salesforce.com/services/oauth2/authorize",
"oauth_token_url": "https://login.salesforce.com/services/oauth2/token",
"oauth_client_id": "your-connected-app-consumer-key",
"oauth_client_secret": "your-connected-app-consumer-secret",
"oauth_scope": "api refresh_token"
}
Add refresh_token to oauth_scope so Salesforce issues a refresh token — without it, the platform cannot keep the connection alive across access-token expiry.
Admin portal¶
The admin portal's Connections page surfaces static_headers as a key/value editor under each kind: api connection. Existing values are masked (the portal never sees the cleartext secret after the first save); add or delete to change the set. Names remain visible so an operator can confirm which headers are configured without revealing the values.
Built-in utility connection¶
The platform ships a built-in connection named util whose operations are handled in-process rather than proxied to an upstream base_url. It registers automatically when the API-gateway toolkit and a database-backed catalog store are present, and it is discovered and invoked exactly like any other kind: api connection: api_discover connection=util lists its operations and, with operation_id, returns their parameter shapes, api_invoke_endpoint runs one inline, and api_export streams one to a portal asset. No new MCP tool is introduced; the utility surface grows by adding catalog operations, not tools.
Like every connection, util is deny-by-default: a persona reaches it only when its connection rules allow it (the built-in admin persona's *, or an explicit operator grant). Not granting it is the restriction.
fetch_url (POST /util/fetch)¶
Fetch an arbitrary public URL server-side and return it inline or stream it to a portal asset. This closes a real gap: api_invoke_endpoint and api_export join their path to a registered connection's base_url, so they cannot reach a host you cannot pre-register, most importantly a one-time presigned download URL (S3 / GCS / Azure Blob signed links, report-generation links) whose host and token are dynamic.
Request body:
{
"url": "https://host/path?query",
"method": "GET",
"headers": {},
"follow_redirects": true,
"expected_content_type": "application/json"
}
Behavior:
- Uses the URL exactly as given. No
base_urljoin, and the query string is never re-encoded, so a presigned signature (X-Amz-Signature,sig,se) survives byte-for-byte. - Injects no credentials. A presigned URL carries its own credential in the query string; adding an
Authorizationheader would break or leak it. Headers are opt-in only, and transport-owned headers (Host,Content-Length,Transfer-Encoding,Connection) cannot be set. - Read-only. Only
GETandHEADare accepted. Side-effectful outbound calls are out of scope for this operation. - Reachable inline via
api_invoke_endpoint(a model's result is held to the platform's context budget,tools.result_budget) or streamed to a portal asset viaapi_exportwithpath=/util/fetch(subject toportal.export.max_bytes). The export returns the same asset metadata shape as any otherapi_export.
Typical flow for an async "generate export, download from a signed link" API:
api_invoke_endpoint <source> POST /exports -> job id
api_invoke_endpoint <source> GET /exports/{id} -> signed download_url
api_export util POST /util/fetch url=<signed url> -> portal asset (rows)
SSRF protection¶
fetch_url fetches public destinations only. There is no domain allowlist: constraining public destinations would add friction without a matching benefit, since presigned hosts are dynamic. What is blocked is internal address space the fetch could reach only because it runs inside the platform's pod:
- loopback, RFC 1918 private ranges, and carrier-grade NAT (
100.64.0.0/10) - link-local, including the cloud metadata endpoint (
169.254.169.254), and IPv6 link-local / unique-local - multicast and unspecified addresses
- internal hostnames:
localhost,*.svc.cluster.local,*.cluster.local,*.internal,metadata.google.internal
The hostname is resolved first and only the vetted IP is dialed (resolve-then-pin), so a public DNS name cannot rebind to an internal address between the check and the connection. Every redirect hop is re-checked. A refused destination returns 403. Only http and https URLs are accepted, and a URL carrying embedded userinfo (user:pass@host) is rejected. The signature portion of a URL is redacted from logs and from relayed error text.
An operator whose deployment must fetch from a trusted internal host can exempt specific prefixes:
apigateway:
util_connection:
# enabled: false # opt out of the built-in util connection
allow_private_cidrs: # exempt trusted internal prefixes from the block
- "10.20.0.0/16"
With no util_connection block, the connection is enabled and the default posture applies: public destinations open, internal address space closed.
Memory safety and the in-flight budget¶
The gateway is a single shared process serving every connection and toolkit. api_invoke_endpoint buffers the upstream response into memory (an inline call or a page of a walk reads up to the connection's max_response_bytes, default 10 MiB) so it can parse and envelope it. Per-request caps bound one call, but they do not bound the sum of concurrent calls: a burst of large responses, each under its own cap, can collectively exhaust the heap and get the container OOMKilled (exit 137), taking down every in-flight request on the pod.
The global in-flight memory budget closes that gap. It tracks the bytes committed to response buffering across all connections, and refuses a new buffered read — before allocating the buffer — when granting it would push the total past the ceiling. A refused request returns the structured gateway_memory_budget_exhausted error, which the REST shim maps to a retryable 429.
api_invoke_endpoint additionally refuses binary (non-text) response bodies before buffering them. The tool returns the body through the MCP/JSON channel, where json.Marshal escapes every control and invalid-UTF-8 byte as \uXXXX; a high-entropy body (a zip, image, PDF, or application/octet-stream) inflates several-fold and is held in multiple copies, so inlining even a single ~10 MiB binary can exceed the heap. Such a body is also useless to a model. When the upstream Content-Type is not a text-shaped type (text/*, application/json, +json/+xml, application/xml, form-urlencoded, JavaScript), the call is rejected — without reading the body — with the structured upstream_body_not_inlineable error (REST shim maps it to a non-retryable 415), and the caller is steered to api_export, which streams the body to a portal asset instead. A zero-length response is never refused, so HEAD and empty 204 responses are unaffected. This is content-type-driven, not size-driven: a 3 MB CSV still returns inline, while a 1 KB zip does not.
api_export does not count against this budget: it streams the upstream response directly to S3 (multipart, via the transfer manager) without buffering the whole body, so its memory stays roughly constant regardless of export size. The per-export size cap (portal.export.max_bytes, default 100 MiB) is still enforced — by an up-front Content-Length check for declared-length responses, and during the stream for chunked ones (the incomplete multipart upload is aborted past the cap, so no partial asset is created). The raw passthrough route (below) is the equivalent bounded path for returning a large body to the caller rather than landing it in an asset.
apigateway:
memory:
# Global ceiling on bytes committed to api_invoke_endpoint response
# buffering across all api connections. (api_export streams to S3 and
# is exempt.) 0 = disabled (per-request caps still apply). A buffered
# read that would exceed this is rejected with 429 before allocating.
max_in_flight_bytes: 314572800 # 300 MiB
# All-or-nothing cap for the /invoke-raw streaming route. An upstream
# whose Content-Length exceeds this is rejected with 413 before any
# bytes stream. 0 = no cap (streaming keeps memory bounded anyway).
raw_max_bytes: 1073741824 # 1 GiB
Sizing max_in_flight_bytes: budget roughly 3× the raw body size per concurrent large request (raw body + decoded copy + JSON-escaped envelope copy) and leave headroom for GC and the other toolkits' working set. A safe target keeps
so peak buffering stays well under the heap even at full utilization. Do not set it to the whole container limit or GOMEMLIMIT — that leaves no room for the transient marshalling copies or for GC. The raw passthrough route (below) is the memory-bounded path for legitimately large bodies and is exempt from the budget because it streams instead of buffering.
REST gateway for non-MCP clients¶
api_invoke_endpoint is also reachable over plain HTTP for clients that do not speak MCP (e.g. Apache NiFi, Airflow's HttpOperator, a shell script with curl). The route is connection-scoped:
Auth is the same as every other REST surface on the platform: Authorization: Bearer <token> or X-API-Key: <key>. The credential resolves to a user identity, persona, and audit subject through the same MCP middleware chain the MCP transport uses, so persona allowlists for api_invoke_endpoint and route-policy rules apply identically.
A client composing one of these calls learns which connections exist and what each exposes from the operation browser, which reads with the same credential and hands back the curl for any operation. Both routes below are in the platform's own served API reference under the Gateway tag, alongside every other REST surface and the two auth schemes; see Admin API for where a deployment serves it.
The REST surface is exempt from the agent-oriented session-handle requirement. When the explicit session gate is enabled (session.require), MCP agents must call platform_info to mint a session_id and thread it on every subsequent tool call, or the call is refused with SESSION_REQUIRED. REST callers are stateless automation (NiFi, cronjobs, curl): each request is an independent HTTP call with no way to mint or carry a session handle, so the gate does not apply to them. Authentication, persona authorization, route policy, and audit still apply in full. The exemption is scoped narrowly to the session-handle handshake, not to access control.
Request body (the connection is taken from the URL and overrides any value in the body):
{
"method": "GET",
"path": "/v1/things",
"query_params": { "limit": 50 },
"headers": { "X-Trace": "abc" },
"body": null,
"timeout_seconds": 30,
"paginate": { "items": "data", "cursor_param": "cursor", "max_pages": 10 }
}
paginate is the same block api_invoke_endpoint takes, and it does the same
thing here: the gateway follows the response's pagination signal itself and
returns the merged array, so a REST caller reaching a paginated upstream does
not have to reimplement the follow loop the gateway already owns. It is refused
on /invoke-raw, where there is nothing to merge in a byte stream.
The remaining api_invoke_endpoint parameters are MCP-tool-only and are not
bound on this route: operation_id, path_params, spec and decode. A REST
caller addresses an operation by method and path, which the operation
browser hands it ready-made.
Response: HTTP 200 with the toolkit's InvokeOutput shape. The upstream HTTP status is returned in status, not in the platform's response code:
{
"status": 200,
"headers": { "Content-Type": ["application/json"] },
"body": { "items": [ ... ] },
"duration_ms": 245
}
Platform-level outcomes use HTTP status codes: 400 for a malformed request body, 401 for missing/invalid credentials, 403 for persona or route-policy denial, 404 for an unregistered connection, 413 when a raw-mode body exceeds the configured cap, 415 when an inline api_invoke_endpoint call hits a binary (non-inlineable) response body, 429 when the global in-flight memory budget is momentarily exhausted, 502/504 for an unreachable or timed-out upstream, 500 for an internal failure. The split keeps "the platform refused" distinguishable from "the upstream returned 4xx/5xx" — a NiFi pipeline can route on the platform status and still inspect status inside the body for the upstream outcome.
Retry semantics follow the status: 413 is permanent (the same request cannot succeed) and must not be retried; 429 is transient (the budget drains as concurrent reads finish) and is safe to retry with backoff, with a Retry-After header on the response.
The route is only mounted when at least one kind: api toolkit instance is loaded. When auth.allow_anonymous is false, requests without a credential are rejected at the HTTP layer before the in-memory MCP session is created.
Raw passthrough for large or binary bodies¶
api_invoke_endpoint buffers the upstream response and wraps it in the JSON envelope, which is the wrong shape for a large download or a binary object (and is refused outright for binary content types, as described in Memory safety). For those, the REST shim offers a streaming passthrough on a separate route:
The request body is identical to /invoke. Instead of an InvokeOutput envelope, the gateway streams the upstream body straight to the client (io.Copy) with the upstream status code, still injecting the held upstream credential — the caller never holds it. Memory stays bounded regardless of body size because the body is never buffered.
The response headers are the platform's, not the upstream's. Content-Length, Content-Encoding, Content-Range, ETag and Last-Modified pass through unchanged, since they describe the body rather than how to render it. A caller that puts Range in the call's headers therefore gets a usable partial response: the upstream's 206 reaches it with the Content-Range that names which bytes it holds and how many exist in total. Accept-Ranges is not forwarded, because this route answers a POST and honors no transport-level Range header on its own request. Content-Type and Content-Disposition are derived through the same contract every other byte-serving surface answers under (see Serving raw content): the type is reduced to a parsed, parameter-free media type, a scriptable document type (HTML, XHTML, SVG, JavaScript, XML) is served as an attachment whatever the upstream asked for, a filename recovered from the upstream Content-Disposition is sanitized before it is re-emitted, and every response carries X-Content-Type-Options: nosniff and the sandbox CSP. The multipart boundary of a multipart/byteranges response is the one type parameter kept, since a multi-range body is parseable only through it and no browser renders that type as a document; it is re-emitted only when it is a bare RFC 2046 token. Cache-Control is the one header the upstream can still decide: its directive is forwarded when it sets one, and private is written when it does not, so an authorized response never travels with no directive at all.
Auth, persona authorization, route policy, and audit apply identically to /invoke: the raw request flows through the same in-memory MCP session, so a persona scoped to GET /v1/files/* cannot stream from a denied path.
Size limit (all-or-nothing): when apigateway.memory.raw_max_bytes is set and the upstream's declared Content-Length exceeds it, the request is rejected with 413 before any bytes are streamed, carrying a structured body:
{
"error": "upstream_body_too_large",
"limit_bytes": 1073741824,
"actual_bytes": 2147483648,
"connection": "vendor",
"path": "/v1/files/big.parquet"
}
For chunked responses (no Content-Length) the limit is enforced during the copy; because the status line is already sent it cannot become a 413, so the stream is cut at the limit. Leave raw_max_bytes at 0 to disable the cap — streaming keeps memory bounded either way; the cap is a policy guard, not a memory guard.
Apache NiFi example¶
Wire an InvokeHTTP processor to the gateway:
| Property | Value |
|---|---|
| HTTP Method | POST |
| URL | https://platform.example.com/api/v1/gateway/vendor/invoke |
| Content-Type | application/json |
Set an X-API-Key (or Authorization) attribute on the FlowFile and reference it from an InvokeHTTP dynamic property mapped to the header name. The FlowFile content is the JSON body above; downstream processors can use EvaluateJsonPath to lift $.status and $.body into attributes for the response-code routing relationships.