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. The idempotency key covers the whole walk.api_invoke_endpointmerges inline undermax_inline_bytes, the inline budget. 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.
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.
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.
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
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 or authorization_code). 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. |
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.
Deprecated (still accepted). 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 read as a fallback and rewritten to the canonical keys automatically by a database migration on upgrade; no reconnect is required. The fallback is scheduled for removal in a future release.
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. Intended for loopback calls to the platform's own API where the change must be attributed to the real user, not a shared identity. |
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 inline budget¶
Two settings on a connection bound what a response becomes, and they measure different things.
max_response_bytes(default 10 MiB) 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, the ceiling an operator sets on what is read at all.max_inline_bytes(default 32 KiB) is the inline budget: the most a renderedapi_invoke_endpointtool result may hold. It is a model-context budget, and 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 result that fits neither way has its body cut, and that is the case that setsbody_truncated, puts the budget and the upstream's declared length inhint, and carriesexport_argumentsfor theapi_exportcall that streams the same call into a portal asset (the caller adds aname).body_bytesstays the size read from the upstream. 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, so the wire message carries the body a second time, compactly. That copy is not removable (a managed script reads the structured output, and the platform's call reference is mirrored into it), so it sits outside the budget; a client that counts the whole message rather than the text it renders sees roughly twice the number configured. - A managed script is not held to the budget at all. It parses the response in code, so a body cut to a prefix is not parseable, and the steer to
api_exportis not something a run can act on mid-script. A run reads to the connection'smax_response_bytesand receives its response whole, the same exemption enrichment makes for a script caller. - 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 returned, so an agent sees what a call cost rather than inferring it. api_export is not subject to the inline 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. The MCP gateway (kind: mcp) is not covered: it forwards a proxied tool's result as the upstream returned it, a proxied tool result has no export path to name, and cutting it would change the upstream tool's contract.
Raise the budget on a connection whose responses an agent needs whole, through the admin portal (Max inline bytes) or the admin API:
{"config": {"base_url": "https://api.vendor.example.com", "auth_mode": "bearer", "credential": "...", "max_inline_bytes": 1048576}}
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.
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¶
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(held to the connection's inline budget,max_inline_bytes) 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 reads up to the connection's max_inline_bytes, default 32 KiB; a page of a walk up to 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.
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
}
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.