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 everytools/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—- 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.
- Screens the arguments through the prompt firewall — an injection attempt in a text argument is blocked.
- Redacts PII in the arguments before forwarding, so sensitive data never reaches the upstream MCP server.
- Forwards the (redacted) call to the upstream.
- Redacts PII in the result on the way back to the client.
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 (kindtool), 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 onauth.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
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
tooltoken, 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-Useris 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:Setsubject_claimon the binding to the claim that names the person (for exampleemail). 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) orAKE(enterprise access key) — the call is rejected: that header goes to a third-party server, which has no use for it. The list isKNOXCALL_CREDENTIAL_PREFIXESinsrc/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.
delegated — each user acts as themselves, without carrying a credential
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_refis a secret reference, never a literal.authis 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
authrevokes 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.
Getting a token as a person
Atool 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_claimon 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:-
Protected-resource metadata — RFC 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_requiredisfalsebecause DPoP binding is set per token, not per resource: a token minted withdpop_requiredmust 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 returnedtruehere would be promising that every token it accepts is bound, which is not something this server enforces. -
Authorization-server metadata — RFC 8414. The client reads
https://<your-slug>.knoxcall.com/.well-known/oauth-authorization-serverto find the token endpoint. -
Resource-indicated token — RFC 8707. When exchanging a workload identity for a token at
/v1/oauth/token, the client passes aresourcenaming 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/weathercannot be replayed against/v1/mcp/payments. Theresourcemust 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:connect_url— where an MCP client points.resource— the RFC 8707 value a token for this server must be bound to (see above).
upstream_urlmust be a publichttps://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_toolsadvertises 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-idthey 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.
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_typerather 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).