A drop-in, spec-aligned gateway that fronts any unmodified MCP server and secures the whole path: introspect, tier gate, RFC 8693 token exchange, RFC 9396 RAR, human-in-the-loop step-up, and Vault ephemeral credentials. Keeping agent calls compliant without changing a line of upstream code.
When an agent can call tools across a growing set of MCP servers, the hard problem is no longer connecting the tools. It is enforcing who may take which action, under what policy, with what approval, and with what evidence afterward.
This independent project explores a drop-in MCP gateway that adds those controls in front of an existing MCP server without changing its tool contract. It uses IBM Verify for identity, risk evaluation, policy decisioning, and human-in-the-loop step-up, with HashiCorp Vault issuing scoped, ephemeral credentials for supported database-backed flows.
By default, MCP standardizes tool interaction; deployments still need to supply identity, authorization, credential, and audit controls. It does not natively define who is allowed to call which tool, under what conditions, or with whose privileges. A security-naive MCP deployment may execute a request without independently applying the identity and authorization controls described here. If that tool needs to write to a repository or read a database, we usually hand the server a broad, standing credential to hold indefinitely.
That means an autonomous agent borrows a credential that can inherit every privilege attached to the standing credential, working at any hour, for any action. It is the enterprise equivalent of handing your car keys to a teenager, going to bed, and hoping for the best.
The problem in one image
Handing a security-naive agent a standing API token or database password creates unnecessary standing-credential exposure. If you have a dozen different agents hitting a database MCP, a GitHub MCP, and local file systems, trying to manage identity and audit compliance inside each individual server becomes an administrative nightmare. And if it's a vendor-supplied MCP server that you don't own, you can't modify it anyway.
Instead of trying to inject authorization and credential lifecycle management into every server, we can place a single, spec-aligned gateway in front of them. The naive MCP servers behind the gateway stay completely unmodified. They never know the security chain ran. We proved this architecture live using two standard, out-of-the-box integrations: a GitHub MCP and a PostgreSQL database MCP.
The goal is simple: How can we enforce that every single agent call stays in compliance, gets verified by policy, and uses ephemeral credentials, without changing a single line of the tools we're securing?
The answer is a gateway that terminates MCP. It sits between the agent and the naive MCP server, speaks the protocol on both sides, and supplies everything the protocol left out. Four responsibilities, one hard constraint.
Every request carries the real user’s bearer token. The gateway introspects it, "who is this", "is the session still alive" and treats a dead IdP exactly like a revoked session. No identity, no call.
Not “can this token reach the server” but “can this user perform this action on this record right now.” That means a per-call token exchange carrying rich authorization details, and a policy engine, IBM Verify that can answer with an allow, a deny, or a demand for a human to approve.
The credential the tool actually runs with is minted for that one call and dies minutes later. No standing database password anywhere. HashiCorp Vault issues a scoped, short-lived one keyed to the exact action Verify just authorized.
Every call is audited. Abuse, a suspicious verdict, a run of denials, each emits a CAEP event that flows through the Antenna transmitter to Verify and kills the session tenant-wide. Authorization is not a one-time gate; it is a live signal.
The hard constraint
Change nothing about the MCP contract. The tool schemas the agent sees, and the tool schemas the upstream exposes, stay byte-for-byte identical. The gateway is the only thing that knows the security chain ran. The naive MCP behind it never learns any of it happened.
One request. One choke point. Zero standing privilege. Every tool call, whether it arrives on the real MCP face
or the REST convenience face, funnels through a single function, pipeline.ts::runPipeline, and its
HITL-resume sibling completePending. That is where the whole chain is sequenced.
Walk it in order. This is not a diagram of an idealized flow, it is the sequence the code actually runs, per call.
Before anything, requireBearer() checks that a token is even present on every user route.
Then, once introspection resolves the caller, the pipeline checks isSessionKilled(verifyUserId) - a
local shield that covers the 30 to 75 second window it takes an Antenna revoke to propagate through to Verify. A
killed session gets a 401 immediately, not eventually.
introspectUser() calls GET /oauth2/userinfo to resolve who the caller is and whether
the session is active. If the response is not active or if the IdP is simply unreachable, the pipeline returns
inactive_session. A dead identity provider is treated exactly like a revoked session.
There is no fail-open path.
gateTool(toolName) reads config/tools.json. A tier-4 tool is
policy_deny; an unknown tool is denied too. Both are refused with a 403 before
Verify is invoked, you do not spend a token exchange to reject a call you were never going to allow.
Tiers 1 through 3 pass through, owing a token exchange.
The four action tiers
Tier 1 - read. Token exchange only, no push. get_record,
list_records, history. A harmless lookup is deliberately excluded from the deny counter so it can
never clear a strike.
Tier 2 - write. Token exchange + RAR with one Verify-policy push. update_record
→ phone push → second leg.
Tier 3 - sensitive. Push on every call. The policy rule fires
ACTION_MFA_ALWAYS each invocation.
Tier 4 - blocked. Policy deny at the gate, before Verify. Startup validation enforces that only tier-4 tools may map to a blocked action.
For configured discovery tools, the gateway runs a cheap, non-elevated probe read purely to learn the
record’s classification. Then shouldStepUp() evaluates the elevateWhen rule, a
notIn safe-list that fails closed. The caller never supplies elevation. The agent cannot ask to
skip the push, because it is the gateway, not the agent, that decides a record is sensitive.
exchangeToken() POSTs grant_type=token-exchange to /oauth2/token: the
subject_token is the user’s access token, the actor_token is the gateway’s SPIFFE
JWT-SVID, and the request carries authorization_details from resolveRar() - the single
source of truth for both the RAR and the Vault credential path, so the two can never drift.
RFC 9396 · authorization_detailsPOST /oauth2/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token=<user access_token> # who actor_token=<gateway SPIFFE JWT-SVID> # which service is acting authorization_details: [{ type: "urn:example:agent:records", action: "record_write", record: "r-8842" }]
When Verify’s policy demands a human, the token endpoint answers scope=mfa_challenge - and that is
a 200 OK, not an error. The pipeline parks a PendingCtx with a txId,
triggerOAuthMfaPush() looks up the user’s presence factor and POSTs to
/v1.0/authenticators/{id}/verifications, and the gateway returns
202 {pending, txId, pushInfo}. The record is withheld. Nothing crosses until a human
approves on their phone.
completePending() polls the verification with returnJwt=true, and on
VERIFY_SUCCESS exchanges the assertion as grant_type=jwt-bearer (RFC 7523) -
re-sending authorization_details, because Verify does not propagate them across the leg by
default. A single-attempt, narrow-invalidation retry handles the stale-secret CSIAQ0155E signal.
Never pass the challenge token to a downstream API.
mintCred() POSTs the OBO as an X-Vault-Token to /v1/<credsPath>
with {claims:{jti, authorization_details}}. Vault’s OAuth resource-server profile validates the OBO
against the tenant JWKS, resolves the agent entity, matches the RAR against the role’s
rar_mappings, and returns a roughly five-minute Postgres username, password, and lease id. In no-DB
mode this leg is skipped entirely.
callUpstreamTool() speaks the real MCP protocol to the naive upstream. the SDK Client
over a streamable-HTTP transport injecting the OBO plus X-DB-Username /
X-DB-Password. So the naive MCP connects to Postgres as the ephemeral role for that one call.
The tool arguments pass through verbatim.
revokeLease() runs in a finally{}
block PUT /v1/sys/leases/revoke So a lease never outlives its one call. It is
best-effort, because the five-minute TTL is the real boundary; the MCP client is always closed afterward. Then
appendAudit() records the per-call chain, and a suspicious verdict or a three-strike deny threshold
fires emitSessionRevoked() → Antenna CAEP → Verify DELETE /v1.0/auth/sessions, a
tenant-wide kill.
Here is what makes it drop in rather than rip out: the gateway is a transparent MCP proxy. It is a real MCP server on the north face and a real MCP client on the south face, built on the same official SDK the rest of the ecosystem uses. It does not reimplement the protocol, and it does not rewrite the envelope.
It depends on the official SDK. @modelcontextprotocol/sdk ^1.29.0 is in the
gateway’s dependencies, used on both faces. Nothing here is a hand-rolled reimplementation of MCP.
It exposes a real MCP server. The gateway imports McpServer and
StreamableHTTPServerTransport; buildMcpServer() registers its tools with
server.registerTool(name, {title, description, inputSchema}, handler), and POST /mcp
does server.connect(transport) plus transport.handleRequest. To the agent it is an
ordinary MCP endpoint - tools/list, tools/call, exactly what you connect any MCP
client to.
It re-speaks MCP to the upstream, verbatim. The south side uses the SDK Client
and streamable-HTTP transport to call the naive upstream’s tools/call {name, arguments}. The
gateway is a server north and a client south, and the identity chain is injected between the two
faces. The OBO and the ephemeral DB credentials ride in extra HTTP headers, so the tool schema and
arguments are untouched. That is why you can point it at GitHub’s own MCP server, which you cannot modify, and
it just works.
It is stateless. The /mcp endpoint builds a fresh McpServer and
transport per request sessionIdGenerator: undefined and closes them when the
response ends, precisely to sidestep the known MCP transport-desync bug from sharing one server across
concurrent requests. The south-side client is likewise constructed per call.
One dispatcher, two faces
The gateway also exposes a plain REST convenience face at POST /tool that accepts the same
{name, arguments}. Both the real MCP transport and the REST face call the same
dispatchTool() → runPipeline, and share statusCodeFor + envelope
mapping - so /mcp and /tool can never drift on status or body semantics. And
unwrapToolData() faithfully parses the real SDK CallToolResult envelope, falling
back verbatim for non-JSON replies rather than ever throwing.
With the Model Context Protocol spec Release Candidate scheduled to go final on July 28, 2026, we recently completed a deep-dive study on where the protocol is heading. The first question we had to address is: Are these specs in our build yet?
No - and they can't be yet. The gateway runs on @modelcontextprotocol/sdk version
1.29.0, whose LATEST_PROTOCOL_VERSION is 2025-11-25 (supporting back to
2024-10-07). The RC is dated 2026-07-28. It goes final on July 28, and Tier-1 SDKs
will get a ~10-week validation window after that to ship final support. So there is nothing to "already have":
the SDK we depend on hasn't implemented it, and won't for weeks. Nobody's build is on it yet.
The important part: our review suggests the direction of these proposals is compatible with and may simplify this gateway architecture. The spec authors are moving to standardize stateless and input elicitation patterns that conceptually align with what we've spent the last month proving out.
Stateless by design
The headline change in the upcoming spec is statelessness - the traditional
initialize/initialized handshake is gone (SEP-2575) and
Mcp-Session-Id is eliminated (SEP-2567). The spec's own framing states: "a remote MCP server that previously
needed sticky sessions, a shared session store, and deep packet inspection at the gateway can now run behind
a plain round-robin load balancer." That is what our gateway already does. Stateless, fresh-connect per
request, no session store, no sticky routing. Since the SDK owns the handshake, this lands as a simple SDK
upgrade for us, not a rearchitecture.
More striking, SEP-2322
(multi-round-trip requests) replaces SSE elicitation with an InputRequiredResult carrying
inputRequests and requestState, which the client retries with
inputResponses. The proposed pattern is conceptually similar to the gateway’s current
parked-request and approval-resume flow. Phone-push step-up becomes expressible natively in-protocol instead of
via a custom envelope. It is a migration path for us, not a redesign, and represents strong validation of the
model.
Two more upcoming updates land as pure upgrades for our topology:
Mcp-Method and Mcp-Name headers, allowing
gateways to route calls without inspecting the request body. This directly improves our two-gateway routing
(which currently inspects the body to read the tool name).ttlMs and cacheScope to
tools/list, giving us a spec-standard cache for the per-request tool list we already proved
benefits from caching.While the overall direction is highly favorable, our audit highlighted three areas that require active tracking:
inputSchema to full JSON Schema 2020-12 (adding
support for oneOf, anyOf, $ref, and $defs). Our current
converter spreads the schema and force-sets type: 'object' + properties. A composed or referenced
upstream schema could get mangled, this is a concrete audit item for our schema engine.-32002 is moving to -32602. We need
to check if our code matches this literal code or handles it generically.Because the core is generic, retargeting the gateway to a new domain is not a code change - it is a
configuration change across five well-defined surfaces. The exchange · mint · HITL · SSF · audit
core stays byte-identical.
The five surfaces, each one line and each one file or env var:
UPSTREAM_MCP_URL in proxy/upstream.ts. Point
the south-face client at any naive MCP’s /mcp endpoint. The records example uses
:3015; the GitHub flavor uses :8082.config/tools.json, loaded by policy/tiers.ts. One JSON
object mapping tool → {tier, rarAction, scope}. Startup assertToolActionsValid
cross-checks every rarAction against rar.json. Data, not code.config/rar.json, consumed by rar/build-rar.ts.
The business rarType, the idField, the action → credsPath map, the
elevatedFrom step-up target, and the stepUp.elevateWhen classification rule.bootstrap/verify.ts. A per-domain token-exchange
OIDC app plus per-tier step-up policy and CELX attributes on your tenant. Remember the nav invariant: CELX
walks requestContext.authorizationDetails[*].operationDetails.<field>.bootstrap/vault.ts. One verify-rar role per
credsPath with its rar_mappings, Postgres grants, and OAuth resource-server entity
and actor entities.There is a sixth knob worth calling out because it is what makes the gateway work in front of a SaaS MCP it
does not own: per-upstream auth mode. The default obo mode puts the OBO on
Authorization: Bearer for a gateway-aware MCP. Setting
UPSTREAM_AUTH_MODE=upstream_token relocates the OBO to X-Verify-OBO and puts the
upstream’s own credential - say a GitHub PAT - on Authorization, so Verify’s OBO never 401s a SaaS
API and never leaks to it. And UPSTREAM_DB_BACKED=false skips the entire Vault mint-and-revoke leg
for a non-database upstream.
Two gateways, one agent, one login
Because PORT, GATEWAY_CONFIG_DIR, UPSTREAM_MCP_URL,
UPSTREAM_DB_BACKED, and UPSTREAM_AUTH_MODE are all per-process env, you run gateway
#1 on :3014 fronting a GitHub MCP (no Vault leg, PAT auth) and gateway #2 on :3016
fronting a records DB MCP (verify-rar ephemeral Postgres creds). A single agent, with a single Verify OIDC
login, presents the same user bearer to both and routes each tool to the gateway that owns it. Each
independently runs the identical runPipeline against the same tenant. That is
interchangeability you can see.
None of the above is a whiteboard. Every leg has run end-to-end against real infrastructure.
Proven live on 2026-07-14: a tier-1 get_me read and a tier-2 write - the latter parked on a
real phone-push HITL that a human approved - through the actual
github/github-mcp-server, on a real IBM Verify tenant. This ran in the no-DB flavor
(UPSTREAM_DB_BACKED=false) with upstream_token mode relocating the OBO to
X-Verify-OBO and a GitHub PAT on Authorization. The GitHub MCP was unmodified.
Proven and benchmarked locally on 2026-07-15: a fresh alpha Vault (v2.0.0-verify-alpha+ent,
config-mode with license_path) plus verify-rar minting five-minute ephemeral Postgres
credentials. get_record ran end-to-end, an A/B cache comparison confirmed the userinfo-cache
win, and zero lease leak - revokeLease in the finally{} block verified.
A restricted record, classification notIn the public/internal safe-list forced a server-side
push. The caller never requested elevation, and the record stayed withheld until the human approved:
202 pending → /hitl/complete on approval. The agent had no lever to bypass it.
One Verify login routed tool calls to a GitHub-MCP gateway and a DB-MCP gateway at the same time, each
running the identical runPipeline against the same tenant. The topology from section 05,
live.
The credential that ran the tool did not exist before the call
and does not exist after.
That is the whole idea, stated as narrowly as it will go. There is no standing database password to steal,
because there is no standing database password. There is no broad token the agent can reuse at 2am, because the
token was scoped to one action, authorized by policy, minted for one call, and revoked in a finally
block. The naive MCP behind the gateway never learned any of it happened and that is exactly why you can
drop this in front of an MCP you did not write.
MCP gave agents a way to act. This gives them a way to act that you can live with.