← back to articles
Agentic AI MCP IBM Verify HashiCorp Vault Token Exchange RFC 9396 Gateway

A Drop-In MCP Gateway
for Agent Compliance

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.

Drop-in & interchangeable Keep the core generic. Swap five surfaces → secure a new domain, zero code change. Gateway core (generic) exchange · mint · HITL · SSF · audit pipeline.ts::runPipeline - byte-identical across domains 1 · Upstream MCP URL UPSTREAM_MCP_URL south face · any /mcp 2 · Tier map config/tools.json data, not code 3 · RAR vocabulary config/rar.json action → credsPath 4 · Verify TE app bootstrap/verify.ts policy + CELX attrs 5 · Vault verify-rar bootstrap/vault.ts roles + DB grants swap these → secure a new domain, zero code change Two gateways · one agent · one login Agent one Verify OIDC login routes each tool Gateway #1 · :3014 UPSTREAM_DB_BACKED=false upstream_token (PAT) · no Vault leg GitHub MCP SaaS · flavor B Gateway #2 · :3016 UPSTREAM_DB_BACKED=true verify-rar ephemeral Postgres cred Database MCP records · flavor A same runPipeline same tenant only 5 surfaces + auth mode change
Five swappable surfaces feed one generic core; below, two gateways behind one agent and one login
Robert Graham Global Product Architect, IBM Verify July 17, 2026
Disclaimer: This is an independent personal project. It is not created, endorsed, maintained, or supported by IBM.

00 Enforcing compliance at the tool barrier

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?

01 What a gateway has to do

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.

1

Identity, fail-closed

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.

2

Fine-grained authorization

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.

3

Ephemeral credentials

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.

4

Continuous evaluation

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.

02 The security chain

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.

The security chain, in motion One request · one choke point · zero standing privilege - pipeline.ts::runPipeline Agent user bearer MCP Agent Gateway terminates MCP · injects the chain · re-speaks MCP 1 Introspect /userinfo fail-closed 2 Tier gate pre-Verify t4 = deny 3 Token Exch. RFC 8693 + RAR 9396 4 Vault mint verify-rar ephemeral 5 Call up- stream inject OBO 6 Revoke lease + CAEP phone push HITL approve re-send RAR SSF / IBM Verify CAEP · session-kill Upstream MCP naive · unmodified Postgres / API ephemeral role EPHEMERAL CRED TTL 5:00 request HITL step-up (RAR re-sent) ephemeral cred (5-min TTL, then vanishes) CAEP event → SSF session-kill
DIAGRAM 1 - runPipeline: introspect → tier gate → token exchange + RAR → HITL → Vault mint → upstream → revoke + CAEP

Walk it in order. This is not a diagram of an idealized flow, it is the sequence the code actually runs, per call.

Step 0 - bearer gate and the local kill-shield

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.

Step 1 - introspect, fail-closed

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.

Step 2 - tier gate, before Verify is ever contacted

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.

Step 2.5 - gateway-derived step-up the agent cannot skip

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.

Step 3 - token exchange + RAR

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"
}]

Step 3a - the mfa_challenge push (tier 2 / 3)

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.

Step 3b - the jwt_bearer second leg, re-sending the RAR

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.

Step 4 - Vault mints the ephemeral credential

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.

Step 5 - call the upstream MCP

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.

Step 6 - revoke the lease, emit the event

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.

03 We spoke MCP, we didn’t fork it

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.

We spoke MCP, we didn’t fork it MCP server north, MCP client south - the envelope shape is unchanged; only an identity seal is added. MCP: tools/list · tools/call Streamable HTTP → POST /mcp MCP: tools/call {name, arguments} re-spoken verbatim, schema untouched Agent MCP client Gateway (transparent proxy) MCP server north · MCP client south POST /mcp McpServer (SDK) SDK Client Streamable HTTP stateless, per-request transport · dispatchTool → runPipeline unwrapToolData() preserves the real CallToolResult envelope Upstream MCP naive · unmodified never learns tools/call 🔑 identity + authz seal injected OBO / RAR in HTTP headers · body unchanged Built on the official @modelcontextprotocol/sdk ^1.29.0 on both faces - not a hand-rolled protocol. Tool schemas pass through verbatim (client.callTool {name, arguments}); the seal rides in headers, so the contract is untouched.
DIAGRAM 2 - MCP server north, MCP client south; the envelope shape is unchanged, only an identity seal is added

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.

04 The upcoming MCP spec validates this architecture

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:

Our Spec Watch List

While the overall direction is highly favorable, our audit highlighted three areas that require active tracking:

05 Drop-in: swap five things

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.

Drop-in & interchangeable Keep the core generic. Swap five surfaces → secure a new domain, zero code change. Gateway core (generic) exchange · mint · HITL · SSF · audit pipeline.ts::runPipeline - byte-identical across domains 1 · Upstream MCP URL UPSTREAM_MCP_URL south face · any /mcp 2 · Tier map config/tools.json data, not code 3 · RAR vocabulary config/rar.json action → credsPath 4 · Verify TE app bootstrap/verify.ts policy + CELX attrs 5 · Vault verify-rar bootstrap/vault.ts roles + DB grants swap these → secure a new domain, zero code change Two gateways · one agent · one login Agent one Verify OIDC login routes each tool Gateway #1 · :3014 UPSTREAM_DB_BACKED=false upstream_token (PAT) · no Vault leg GitHub MCP SaaS · flavor B Gateway #2 · :3016 UPSTREAM_DB_BACKED=true verify-rar ephemeral Postgres cred Database MCP records · flavor A same runPipeline same tenant only 5 surfaces + auth mode change
DIAGRAM 3 - five swappable surfaces feed one generic core; below, two gateways behind one agent and one login

The five surfaces, each one line and each one file or env var:

  1. Upstream MCP URL - 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.
  2. Tier map - 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.
  3. RAR vocabulary - 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.
  4. Verify token-exchange app - 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>.
  5. Vault verify-rar roles - 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.

06 Proven, not theoretical

None of the above is a whiteboard. Every leg has run end-to-end against real infrastructure.

A

Tier-1 read and tier-2 write through a real GitHub MCP

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.

B

A DB-backed flavor minting real 5-minute Postgres creds, zero lease leak

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.

C

Gateway-derived step-up the agent could not skip

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.

D

Two gateways, one agent, one login - concurrently

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.

Product · drop-in gateway
The MCP Agent Gateway, using IBM Verify
github.com/iamidentity-ai
Config-driven, compliance-focused, spec-aligned gateway fronting any naive MCP server using IBM Verify + HashiCorp Vault. (An independent personal project. It is not created, endorsed, maintained, or supported by IBM).