Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

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). next says to pass spec=<name> for one spec's operations or query=<text> to rank operations across every spec.
  • Operations (level: operations): a call with spec, query, or both returns the matching operations (operation_id, method, path, summary, tags, spec), ranked by query under the ranking mode (hybrid by default when the connection has an embedding index, lexical otherwise, with a note when 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. limit caps the list (default 50). next says to pass operation_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 identifier api_discover returns (for example getUser). This is the natural continuation of the discovery flow: read a schema by operation_id, then invoke the same operation_id. The platform resolves it to the method and path template from the connection's catalog. For a templated path, pass the placeholder values in path_params and the platform substitutes and URL-escapes them, so you never hand-build /v1/users/123 from /v1/users/{id}:
{
  "connection": "vendor",
  "operation_id": "getUser",
  "path_params": { "id": "123" }
}

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:
{
  "connection": "vendor",
  "method": "GET",
  "path": "/v1/users/123"
}

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:

  1. A next_url (a Link header, @odata.nextLink, or a URL-valued next) is followed. It is pinned to the connection's host and must fall under its base_url path (the link's scheme is not compared, because the page is requested through the connection's base_url whatever 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.
  2. A next_cursor is sent back as the query parameter cursor_param names. A cursor on a page that names no cursor_param and no page_param fails the walk: the gateway has no way to send it.
  3. With neither signal, and page_param named, that parameter is advanced by page_step. A body cursor is ignored in this mode, so an API that pages by number but writes next: true still 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_export streams: 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 one application/json document whose content is the merged array. Its provenance records the paginate block, pages_fetched, items_merged, stopped_by, and the cursor or link that addressed the last page. A walk whose merged output would pass portal.export.max_bytes fails all-or-nothing, as a single oversize response does. The idempotency key covers the whole walk.
  • api_invoke_endpoint merges inline under max_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 with stopped_by: "max_bytes", body_truncated: true, body_bytes holding the merged size, a hint steering to api_export, export_arguments carrying the same call and paginate block for it, and pagination holding the signal for the unmerged page. status and headers are 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, or content_type becomes a part: content is sent as UTF-8 text, content_base64 is decoded to raw bytes first, and a part naming a filename defaults to application/octet-stream when it declares no type. A part may set content_type without a filename — 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.

toolkits:
  api:
    enabled: true
    # No instances here — connections are managed via the admin portal.

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 the auth_mode value (oauth2_client_credentials / oauth2_authorization_code), with oauth2_scopes as 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 rendered api_invoke_endpoint tool 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 sets body_truncated, puts the budget and the upstream's declared length in hint, and carries export_arguments for the api_export call that streams the same call into a portal asset (the caller adds a name). body_bytes stays the size read from the upstream. The read cap bounds the read, so a connection whose max_response_bytes is 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_export is not something a run can act on mid-script. A run reads to the connection's max_response_bytes and 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 pagination field 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. With auth_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_pem and mtls_client_key_pem must 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.X509KeyPair runs 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 parseable CERTIFICATE block. A bundle that contains only PRIVATE KEY blocks is rejected.
  • auth_mode: mtls requires 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 (use auth_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):

  1. Per-call headers from the tool input (api_invoke_endpoint.headers).
  2. static_headers (operator-configured).
  3. auth_mode contribution (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_url join, 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 Authorization header 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 GET and HEAD are 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 via api_export with path=/util/fetch (subject to portal.export.max_bytes). The export returns the same asset metadata shape as any other api_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

max_in_flight_bytes ≈ (container_memory_limit × 0.6) / 3

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:

POST /api/v1/gateway/{connection}/invoke

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:

POST /api/v1/gateway/{connection}/invoke-raw

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.