Skip to main content

MCP Gateway

The Model Context Protocol (MCP) lets agents call tools on MCP servers. KnoxCall puts those calls behind the same governance as the rest of your AI egress — so an agent presents a short-lived, sender-constrained KnoxCall token instead of a long-lived server credential, and every tools/call is screened before it reaches the upstream. Point an MCP client at https://<your-slug>.knoxcall.com/v1/mcp/<server-slug>.

What it governs

For a request through an MCP server, the gateway:
  • initialize / ping — pass through to the upstream. They carry protocol negotiation, not tenant data.
  • tools/list — returns only the tools your allowlist permits (intersected with the token’s tool scope). Tools you haven’t allowed are never advertised.
  • tools/call
    1. Authorizes the tool against the allowlist — a call to a tool that isn’t allowed is rejected with a JSON-RPC error and never forwarded.
    2. Screens the arguments through the prompt firewall — an injection attempt in a text argument is blocked.
    3. Redacts PII in the arguments before forwarding, so sensitive data never reaches the upstream MCP server.
    4. Forwards the (redacted) call to the upstream.
    5. Redacts PII in the result on the way back to the client.
Every governed call is recorded in ai_gateway_mcp_calls with its outcome (ok / blocked_tool / blocked_firewall / error), its tool name, its PII-redaction count and its latency. A refusal additionally writes an audit row under the single action ai_gateway.request_denied, with the specific cause in the row’s details — mcp_tool_blocked, mcp_firewall_block, mcp_upstream_refused, firewall_policy_unavailable, firewall_policy_missing. One action name for every denial is deliberate: a new refusal reason cannot silently invent an action nobody is alerting on. Alert on ai_gateway.request_denied and read the reason from the row. Your system-prompt canary is watched for in both directions. The canary the prompt firewall injects into your agents’ system prompts has no reason to appear in tool traffic — nothing on this plane injects one — so if it does, an agent has extracted a system prompt and is moving it through a tool. The most direct route is the call’s arguments, out to an upstream an attacker controls. Wherever it appears, the gateway strips it ([REDACTED:CANARY]) before it leaves and raises an ai_gateway.canary_leak audit entry at critical severity against the MCP server — one per call, however many times the marker appeared, and carrying a count rather than the marker itself. That alert is not an ai_gateway.request_denied row, because the call is not refused: the marker never reaches the upstream, and no policy on this plane asks for more. Alert on ai_gateway.canary_leak separately. If none of your agents has ever run with a canary, there is nothing to watch for and nothing is scanned.

resources/* and prompts/* are refused

The gateway governs tools. resources/read, resources/list, prompts/get and prompts/list are answered with JSON-RPC -32601 (method not found) and are never proxied to your upstream. This is a decision, not an oversight: resources/read returns file and data content and prompts/get returns prompt text — exactly the payloads the tool-call controls exist to screen — so proxying them would be a way to pull that content through the gateway with none of those controls applied. An MCP server whose value is in its resources is not usable through KnoxCall today; say so to us if that is your case.

The credential

MCP tool access uses a tool-bound phantom token (kind tool), DPoP-bound by default — sender-constrained, short-lived, and revocable. This is the credential the MCP spec recommends and that almost nobody productizes: your agent never holds the upstream server’s key, and a leaked token is useless without its DPoP key.

How the server authenticates upstream

Three modes, set per server on auth.mode.

secret (default)

The server dials its upstream with one credential resolved from your secret store: { "headers": { "Authorization": "Bearer {{secret_id:<uuid>}}" } }. The credential is envelope-encrypted at rest, decrypted per connection, and never stored in the server row — a literal value there is refused at write time and again at connect time. Every user of the server reaches the upstream as the same principal. That is fine for a read-only internal service, and it is the wrong shape for anything where the upstream has its own idea of who may do what: a shared Jira credential sees every project any of your users can, regardless of which of them asked.

passthrough — each user acts as themselves

The caller supplies their own upstream credential per request:
KnoxCall forwards that value to the upstream as Authorization and stores nothing. The upstream applies its own authorization to the real person, so least privilege is whatever your upstream already enforces — not whatever a shared service account happens to be able to do. What the gateway still does on every call: the tool allowlist, the token’s own tools scope, the prompt-injection firewall on tool arguments, and PII redaction of arguments and results. What it stops doing is holding a credential. Rules this mode enforces, and why:
  • The token has to name a person — and only the token does. The credential belongs to a human and the upstream will act as that human; the only record connecting the two is the one we write. So the identity comes from the subject bound into your tool token, which is covered by the token’s capability signature and fixed at mint. A token that names nobody is refused on a passthrough server, whatever headers it sends. X-KC-User is not an identity here and is ignored for binding. It used to be one, checked only against your directory — which meant anyone holding a credential could record the action against any provisioned employee. If you send it and it disagrees with the token, the call is still attributed to the token’s subject and the disagreement is logged. Two ways to get a subject-bound token, both of which prove the person before minting: Set subject_claim on the binding to the claim that names the person (for example email). Its value is resolved against your directory (ai_gateway_user_attribution) at exchange time; a claim naming someone you have not provisioned is refused rather than minted unbound. Leave it null for ordinary CI — a build job is not a person, and a token with no subject keeps working everywhere except a passthrough server.
  • No stored headers. A passthrough server cannot also carry auth.headers — sending both would put the shared credential and the user’s on the same request and let the upstream pick.
  • Missing credential = refused, never anonymous. There is nothing to fall back to, and dialling unauthenticated would look like the feature working.
  • A KnoxCall token is refused. If the header value looks like a KnoxCall credential — tk_ (tenant API key), kc_ (OAuth 2.1 access token), kr_ (OAuth 2.1 refresh token), kp_ (AI data-plane phantom token) or AKE (enterprise access key) — the call is rejected: that header goes to a third-party server, which has no use for it. The list is KNOXCALL_CREDENTIAL_PREFIXES in src/proxy/passthrough.ts, and every authenticator’s prefix has to be on it.
  • Connections are never shared between users. Upstream connections are pooled for reuse; the pool key includes the token’s bound subject and a non-reversible tag derived from the credential, so two people can never end up on one connection — including when they hold the same token and send the same Mcp-Session-Id.
The credential is not written to logs, audit rows, traces, or usage records, and it is never echoed in an error message.

delegated — each user acts as themselves, without carrying a credential

Passthrough asks every caller to carry their own upstream credential. Delegation is for when they should not have to: the person consents once through the upstream’s own OAuth, KnoxCall envelope-encrypts their refresh token under your tenant key, and mints a short-lived access token per call. The upstream still applies its own authorization to the real person — so least privilege is still whatever your upstream already enforces — and nothing in the MCP client’s configuration holds a secret. How a person connects. From the MCP server’s page in the KnoxCall console: Connect my account → the provider’s consent screen → back to KnoxCall. That is the only way, and the reason is the same one that decides identity above:
Nobody can connect on your behalf, and there is no API for it. Consent has to be given by the person whose credential it is. If an administrator could start a flow naming a colleague and then finish it with their own upstream account, the stored connection would hold the administrator’s credential under the colleague’s name — which is exactly the defect that made X-KC-User stop being an identity. /v1 can list and revoke connections; it cannot create one.
Set up your OAuth app with redirect URI https://oauth.knoxcall.com/callback — the one KnoxCall publishes, the same for every tenant and every server, because providers match redirect URIs exactly. Ask for the narrowest scopes that do the job: an empty scope list is refused, because most providers read it as “give this app your default grant”, which is the widest one they offer. Rules this mode enforces, and why:
  • The token has to name a person, exactly as in passthrough. A call whose token names nobody is refused; a person with no connection is refused with a message telling them to connect, not with an upstream error.
  • client_secret_ref is a secret reference, never a literal. auth is not encrypted and is returned by every read path, so a literal client secret would sit in cleartext in the control plane. A literal is refused with 422.
  • Both endpoints must be public https://. Refused at write time, and the connection is dialled through the gateway’s resolve-and-pin egress, which validates every DNS answer and refuses redirects — the request carries a live credential.
  • PKCE (S256) is always sent, whatever the client type. The authorization code comes back through a redirect the user’s browser follows, and an intercepted code with no verifier is that person’s upstream account.
  • Refresh is single-flight across the fleet. Four API workers refreshing one connection at once is how a provider that rotates refresh tokens invalidates three of them and locks the user out of their own account.
  • Only the person who started a connection can finish it. The callback requires a KnoxCall session and refuses if it is not the same user. Without that, someone could start a connection as themselves, send you the link, and have your upstream credential stored under their name — the refusal is audited, and nothing is stored.
  • Changing auth revokes every connection on the server, and so does archiving it. The stored tokens were issued for one app, one set of scopes and one upstream; keeping them across a change would mean dialling with tokens minted under settings nobody can see any more.
  • Revoking destroys the stored tokens, it does not set a flag. It does not revoke KnoxCall’s authorization at the provider — do that in the provider’s own account settings if you need it gone there too.
  • Connections are never shared between users, on the same key as passthrough: the pool key carries the token’s bound subject and a non-reversible tag of the minted access token, so a refresh opens a new connection rather than leaving an expired token in service.
Nobody at KnoxCall, and no administrator in your tenant, can read a stored refresh token: it is decrypted only in the worker that is about to use it, and no API response carries it in any form.

Getting a token as a person

A tool token can be minted two ways, and only these two — both prove who you are before minting, because the token speaks for a person once it exists:
  • the OIDC exchange, for a workload (a CI job presenting an id_token from a bound issuer). Set subject_claim on the binding when that workload acts for a named user;
  • the device flow, for a human at a laptop. See Connect Claude Code, Claude Desktop and Cursor.

Discovery & audience-bound tokens (OAuth 2.1)

A standards-compliant MCP client (Claude Code, Claude Desktop, Cursor) auths without any KnoxCall-specific wiring:
  1. Protected-resource metadataRFC 9728. On a 401, the client fetches:
    which returns the authorization server to use and the DPoP algorithms this resource accepts:
    dpop_bound_access_tokens_required is false because DPoP binding is set per token, not per resource: a token minted with dpop_required must always be presented with a proof, and one minted without it may not use the DPoP scheme at all. Sender-constraining a token is strongly recommended and is what the flow below does — but a resource that returned true here would be promising that every token it accepts is bound, which is not something this server enforces.
  2. Authorization-server metadataRFC 8414. The client reads https://<your-slug>.knoxcall.com/.well-known/oauth-authorization-server to find the token endpoint.
  3. Resource-indicated tokenRFC 8707. When exchanging a workload identity for a token at /v1/oauth/token, the client passes a resource naming the exact MCP server:
    The minted token is audience-confined to that resource — bound into its capability HMAC, so a token issued for /v1/mcp/weather cannot be replayed against /v1/mcp/payments. The resource must name an MCP server on your own domain; a foreign or malformed resource is rejected. Multiple resource indicators are not supported — pass exactly one.

Registering a server

Create one under the gateway’s MCP tab, or through the API:
The response carries two URLs, and they are not the same string:
  • connect_url — where an MCP client points.
  • resource — the RFC 8707 value a token for this server must be bound to (see above).
Three rules the API enforces, so they are worth knowing before you hit them:
  • upstream_url must be a public https:// address. Private, loopback, link-local and cloud-metadata destinations are refused when you save the server and again when we connect — the request carries your decrypted upstream credential, so it is never sent in cleartext or to an address inside anyone’s network.
  • Upstream auth must reference a KnoxCall secret, e.g. "Bearer {{secret_id:<uuid>}}". A literal credential is rejected: it would sit unencrypted in the control plane, which is the opposite of what you use KnoxCall for.
  • An empty allowed_tools advertises nothing. That is the default, and it is deliberate — a newly registered server exposes no tools until you name them.

Sessions

The gateway keeps an initialized upstream connection alive per session so a client doesn’t re-handshake on every call. Two properties are worth knowing:
  • A session belongs to one token. The pool is keyed on the verified capability token, so two tokens never share an upstream connection — nor the decrypted upstream credential behind it — whatever mcp-session-id they send. Before 2026-08-25 the client-supplied header was the only separator; see the changelog.
  • A token holds at most 4 concurrent sessions per server per environment. Opening a fifth closes that token’s least recently used one. Idle sessions are closed after 5 minutes.
The upstream credential is resolved per connection in the environment the presenting token names (kp_live_ / kp_test_), and a request whose environment cannot be proven is refused rather than assumed to be Live.

Two kinds of MCP server

  • Upstream — proxies an external MCP server (its transport + URL), governed as above. This is what you can create today.
  • Collection — synthesizing MCP tools from a KnoxCall route collection, so each governed route becomes a tool executed through the existing proxy. Not available yet: the data plane does not serve this type, so creating one is refused with 422 unsupported_server_type rather than leaving you with a server that fails on every call.
Every tool result is fully inspected. The gateway talks to your upstream over Streamable HTTP and returns one complete JSON-RPC response per request, so there is no partially-inspected streaming path here — unlike /v1/ai, where mid-stream redaction is a real concern with its own guarantees. Redaction covers the whole result and error envelope, recursively: string values, object keys, and numbers (a card number encoded as a JSON number does not slip through).