mcpgateway
Provides access to PostgreSQL databases as an MCP target, with row-level security and user-scoped authorization enforced in the database.
Provides access to Salesforce CRM as an MCP target, allowing agents to call tools such as listing opportunities while restricting results to the caller's permissions.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcpgatewayshow me the audit trail for the last hour"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcpgateway
An enterprise gateway for the Model Context Protocol. Every tool call your agents make carries the identity of the person who made it — not a shared service account — and arrives with rate limits, policy, a tamper-evident audit trail and a distributed trace already applied.
git clone https://github.com/parthksingh1/MCPGateway && cd MCPGateway
make demo # console on :3000, traces on :16686, dashboards on :3001
The console after make demo — traffic, latency percentiles and tool volume, all read from the
audit trail rather than a metrics estimate.
The problem
Most MCP deployments today hand the agent a service-account token. It is the fastest thing that works: one credential, configured once, and every tool call succeeds.
It also quietly removes authorisation from the system. That token holds the union of every permission any user might need, so the moment an agent acts on behalf of a person, that person is operating with the combined privileges of everyone. An analyst asks a question about their own accounts and the CRM answers with the whole company's. Nothing has been breached and no rule has been broken — the agent simply had more authority than the human it was serving, and nothing in the system was in a position to notice.
The usual patch is to filter in the application: the agent knows who is asking, so it adds a WHERE
clause. That works exactly as long as every code path remembers to. It is not a boundary; it is a
convention, and conventions are not what you want between an LLM and a customer database.
mcpgateway moves the boundary back to where it can be enforced. The gateway exchanges the caller's own OAuth token for a downstream token scoped to that user, addressed to one specific MCP server, valid for minutes. The downstream service then authorises the human, using the same identity system it would if the human had called it directly. A tool call from Alice can only reach what Alice could reach, because the credential it carries is Alice's. Around that sits the plumbing any real deployment needs and nobody enjoys writing: atomic rate limits, an append-only audit log you can actually verify, policy evaluation, and end-to-end tracing.
Related MCP server: Secure MCP-gRPC
Architecture
graph LR
Agent["Agent<br/><small>Claude Desktop, internal automation</small>"]
Console["Operations console<br/><small>React</small>"]
subgraph GW["Gateway"]
direction TB
P1["1 · telemetry"] --> P2["2 · auth"] --> P3["3 · tenant context"]
P3 --> P4["4 · rate limit"] --> P5["5 · policy"] --> P6["6 · token exchange"]
P6 --> P7["7 · MCP proxy"] --> P8["8 · audit"]
end
IdP["Identity provider<br/><small>OAuth 2.1 · RFC 8693</small>"]
Policy["policy-engine<br/><small>MCP</small>"]
CRM["salesforce<br/><small>MCP</small>"]
WH["postgres<br/><small>MCP</small>"]
Redis[("Redis<br/><small>buckets, token cache</small>")]
PG[("PostgreSQL<br/><small>control plane, audit</small>")]
Warehouse[("Warehouse<br/><small>row-level security</small>")]
Agent -->|"Bearer: user token"| GW
Console -->|"session cookie"| GW
P2 -.->|"verify against JWKS"| IdP
P6 -.->|"exchange for downstream token"| IdP
P4 <-.-> Redis
P5 -->|"user's own token"| Policy
P7 -->|"user's downstream token"| CRM
P7 -->|"user's downstream token"| WH
WH --> Warehouse
P8 --> PG
classDef store fill:#12151c,stroke:#2e3542,color:#8b93a4
class Redis,PG,Warehouse storeFull detail, including the C4 breakdown and the threat model, is in docs/ARCHITECTURE.md.
What it does
Capability | ||
🔑 | Permission mirroring | RFC 8693 token exchange on every call. The downstream token names the caller, is addressed to one service, and expires in minutes. No service-account fallback exists in the code. |
⚡ | Atomic rate limiting | Token buckets as a single Lua script, so read–refill–decide–write is indivisible. Exact under concurrency across every replica. Hot-reloaded over Redis pub/sub. |
📜 | Tamper-evident audit | Append-only, hash-chained per tenant, written by a role holding |
🛡️ | Policy decisions | A compact rule evaluator over checked-in YAML. Default deny. Rules cover argument content, plan, scope, role and rate-tier override. |
🔍 | End-to-end tracing | One trace per request across gateway → policy → target server → downstream, with |
🏢 | Multi-tenancy | Tenant comes from the token, never a parameter. Row-level security in the warehouse enforces it in the database, not the application. |
🖥️ | Operations console | Live request stream, filterable audit log with one-click chain verification, editable rate limits, policy explorer. |
🔌 | Streamable HTTP | The current MCP transport, stateless, so servers scale horizontally without sticky sessions. |
Quickstart
Requirements: Docker, Node.js 20+, and pnpm (installed automatically if missing).
make demoThat builds the images, starts everything, migrates, loads data — three tenants, twelve users, a 50,000-row warehouse and a week of audit history — and prints the URLs.
Console | |
Gateway API | |
Identity provider | |
Traces (Jaeger) | |
Dashboards (Grafana) | http://localhost:3001 — |
Metrics (Prometheus) |
Sign in with any seeded account; the password is Passw0rd! for all of them.
Account | Role | Sees |
| analyst, West | the records she owns |
| manager, West | the whole West territory |
| admin | the whole tenant |
| analyst, restricted plan | hits policy denials |
If a port is already taken on your machine, put an override in
.env(POSTGRES_PORT=5433,REDIS_PORT=6380, …) and re-run. Only the published host ports change.
Other commands
make check # typecheck, lint, unit tests
make test-integration # Testcontainers: real Postgres and Redis
pnpm oauth:walkthrough # the token exchange, step by step, with decoded tokens
pnpm verify:audit # walk every chain
pnpm bench # measure this machinedocs/DEMO.md is a ten-minute walkthrough that ties these together.
How permission mirroring works
sequenceDiagram
autonumber
participant A as Agent
participant G as Gateway
participant I as Identity provider
participant M as MCP server
A->>G: call sf.list_opportunities<br/>Bearer: Alice's token
G->>G: verify signature against cached JWKS
G->>G: rate limit · policy · scope check
G->>I: POST /token<br/>grant_type=token-exchange<br/>subject_token=Alice's<br/>audience=mcp:salesforce
Note over I: narrow to the intersection of<br/>Alice's entitlements, the client's<br/>registration and the audience's namespace
I-->>G: token: sub=usr_alice, aud=mcp:salesforce,<br/>scope=salesforce:read, act={sub: mcp-gateway}
G->>M: tools/call + traceparent<br/>Bearer: the exchanged token
M->>M: verify independently against JWKS,<br/>require aud=mcp:salesforce
M-->>G: only the records Alice may see
G->>G: append audit row, publish to live stream
G-->>A: resultThree properties do the work:
The subject never changes. The exchanged token still says sub: usr_alice. The CRM server
authorises Alice, and the gateway is recorded in the act claim as the party that performed the
exchange — "Alice, acting through the gateway", which is exactly what happened.
Scopes can only narrow. The granted set is the intersection of what the subject is entitled to, what the client is registered to request, and what the target audience is permitted to receive. Widening is not expressible. Ask for a scope the subject does not hold and the exchange is refused, not silently downgraded.
It fails closed. If the exchange fails, the request fails with a PermissionMirrorError and the
refusal is audited. There is no fallback path, which is the one line of code that would undo the
entire design.
You can watch all of it: pnpm oauth:walkthrough runs the real flow and prints the decoded tokens
at each step, including a deliberate attempt to widen scope so you can see it refused.
The observable consequence: Alice and Bob call the same tool with the same arguments and get 26 and 52 opportunities back respectively, because their tokens differ. Under a service account both would see all 100. Details in docs/PERMISSION_MIRRORING.md.
How the rate limiter works
The whole limiter is one Lua script, evaluated inside Redis:
local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil or ts == nil then
tokens = capacity -- first sighting: start full
ts = now
end
local elapsed_ms = now - ts
if elapsed_ms < 0 then elapsed_ms = 0 end -- a clock jump must not mint tokens
if refill_interval_ms > 0 and refill_tokens > 0 then
-- continuous refill: fractional tokens carry over, so 60/minute admits one
-- call per second rather than 60 at the top of each minute
tokens = tokens + ((elapsed_ms / refill_interval_ms) * refill_tokens)
end
if tokens > capacity then tokens = capacity end
local allowed, retry_after_ms = 0, 0
if tokens >= cost then
allowed = 1
tokens = tokens - cost
else
retry_after_ms = math.ceil(((cost - tokens) / refill_tokens) * refill_interval_ms)
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, ttl_ms)
return { allowed, math.floor(tokens), retry_after_ms, capacity }The reason it is Lua and not three commands: GET / compute / SET from the application is a
textbook race. Two gateway replicas read the same bucket, both conclude there is room, and both
admit the request. Under load that overshoots by roughly the number of replicas — precisely when the
limit matters. Inside a script it is one indivisible step, and one round trip instead of three.
The integration suite fires 500 concurrent requests at a bucket of 100 and asserts exactly 100 are
admitted, then does it again across four independent Redis clients standing in for four replicas.
Full source: packages/rate-limit/token-bucket.lua. More in
docs/RATE_LIMITING.md.
How the audit chain works
Each row commits to the row before it:
row_hash = sha256( prev_hash || canonical_json(row without its hash columns) )Change any historical field and that row's hash changes, which breaks the prev_hash link every
later row depends on. Rewriting one row means rewriting the entire tail, and the verifier reports
the exact row where recomputation first diverges.
Three things make it more than a nice property:
Canonical JSON. Two structurally identical rows must serialise byte-identically, or verification would fail on rows nobody touched. Keys are sorted,
undefinedis omitted,-0is normalised, non-finite numbers are rejected.Append-only privileges. The gateway connects as a role holding
INSERTplus a column-levelSELECTonseq(whichRETURNINGrequires) and nothing else. A guard trigger catches the owner-level case that grants cannot.UPDATE,DELETEandTRUNCATEall fail at the database.Per-tenant chains. A single global chain would funnel every audit write through one lock. Per-tenant chains let tenants append concurrently while keeping tamper-evidence inside the boundary an auditor cares about, and the genesis hash is derived from the tenant id so a row cannot be spliced between chains.
See it work:
pnpm verify:audit # walk every chain
pnpm verify:audit --corrupt-row 42 # alter one row, catch it, restore Target row seq 42 (bbf72ca8-…) in tenant globex
Before VALID (1369 rows)
Altering the row. This first has to disable the guard trigger, which
requires ownership of the table — the gateway's own role cannot do it.
After BROKEN
first break at seq 42
reason row_hash_mismatch
expected 4edf446e59ff54b2…
actual 20f57f37cff0d314…
One field changed by one millisecond, and the chain no longer verifies.More in docs/AUDIT_LOG.md.
Benchmarks
No benchmark number appears in this README. docs/BENCHMARKS.md carries one measured run, next to the machine it came from and an honest reading of what it does and does not show — including a throughput result that gets worse under concurrency, and the two candidate causes it does not yet distinguish between.
scripts/load-test.ts drives real tool calls through the running gateway — the whole enforcement
path, not a microbenchmark — and reports the distribution it actually achieved:
pnpm bench # 30s, 50 connections
pnpm bench -- --duration 60 --connections 100 # heavier
pnpm bench -- --json --out docs/benchmark.json # machine-readableIt discards a warm-up window (JIT, connection setup, the first uncached token exchange, a cold JWKS fetch), computes percentiles from every sample rather than an estimate, and warns you if most requests were rate limited — in which case the latency figures describe refusals rather than work.
Record what you get in docs/BENCHMARKS.md, which has a template and asks for
the hardware alongside the numbers. Two runs are recorded there already, and the second is worth
reading for the method rather than the figures: the first run showed throughput falling as
concurrency rose, and the obvious suspect — the audit chain-head lock — turned out to be the wrong
one. Timing each layer separately put the cost in the MCP initialize handshake, which the gateway
was paying twice per request. Pooling those connections took p50 from 78 ms to 39 ms and tripled
throughput under load.
What's real and what stands in
Every substitution is labelled // MOCK: in the source and listed here. Nothing is hidden;
grep -rn "MOCK:" . finds all of it.
Component | Status | Notes |
OAuth 2.1 + PKCE, RFC 8693 exchange | Real | Real signatures, real JWKS, real scope narrowing. The protocol is the production one. |
Identity provider | Stands in |
|
Redis token bucket | Real | Real Lua on real Redis. Concurrency asserted in tests. |
Audit chain and privileges | Real | Real SHA-256 chain, real Postgres grants, real guard trigger. |
Warehouse MCP server | Real | Real PostgreSQL, real row-level security, real |
Warehouse data | Generated | ~50k orders from a fixed seed with weekly and quarter-end seasonality. |
CRM MCP server | Stands in |
|
CRM data | Generated | 100 accounts, 500 contacts, 200 opportunities from a fixed seed. |
Policy engine | Simplified | A compact evaluator rather than a real OPA sidecar. Rego is the right answer in production; the rejected-alternatives section says why. |
Tracing, metrics, logs | Real | Real OpenTelemetry to a real collector, Jaeger, Prometheus, Loki and Grafana. |
The console
Seven pages. Everything below is a screenshot of the running application, captured by
pnpm screenshots, which drives a real browser against the stack make demo starts.
Live requests

A server-sent-event stream of every call as it lands. Expanding a row shows what actually happened to it: the downstream token's subject and audience, whether it was minted or served from cache, the policy rule that decided, remaining rate-limit tokens, and a link to the trace.
Audit log

Filterable, and Verify chain recomputes every hash from the tenant's genesis value rather than reading a stored flag. Expanding a row shows its position in the chain — the previous hash, its own, and the argument digest.
Policies

The loaded rules in evaluation order, and a form that runs the real evaluator against a hypothetical call without performing it. The result highlights the deciding rule and lists every rule considered.
Rate limits

Per-tenant configuration, editable by an operator holding gateway:admin, and the live bucket state
read straight from Redis — what the limiter is working with, not what was configured.
Tracing

One tool call: the policy evaluation, both token exchanges against the identity provider, the dispatch to the target server, and the audit append.

The OAuth flow terminates in a backend-for-frontend — the browser receives an opaque session cookie and never an access token.

Application roles here are mirrored one-to-one into the Postgres roles the warehouse server assumes per request.
Repository layout
apps/
gateway/ enforcement pipeline, console API, BFF
dashboard/ operations console (React)
mock-idp/ OAuth 2.1 provider with RFC 8693
mcp-servers/
salesforce/ CRM tools, scope-derived record visibility
postgres/ warehouse tools, row-level security
policy-engine/ rule evaluator over YAML bundles
packages/
shared/ errors, canonical JSON, config, Drizzle schema
auth/ JWKS cache, verification, OAuth client, token exchange
telemetry/ OTel bootstrap, pino, trace helpers, metrics
audit/ hash chain, writer, verifier, read API
rate-limit/ Lua script, wrapper, config store
mcp-runtime/ shared MCP server scaffolding and client
infra/ compose stack, migrations, Grafana, collector config
scripts/ seed, reset, migrate, verify chain, walkthrough, load test
docs/ architecture, security, and one document per pillarRoadmap
Honest about what a production deployment would need next:
Real OPA. Swap the built-in evaluator for a sidecar and Rego bundles.
Audit throughput. Chains are per tenant; a very large tenant would want sharded chains or a Merkle tree with periodic anchoring.
Cross-replica live view. The console's stream is per-process. A Redis stream would fix it; the audit table already covers durability.
Token exchange without a round trip. Cache warming or provider-side batch exchange for the cold-start path.
Upstream client reuse. The gateway builds an MCP client and completes an
initializehandshake per call, twice per request. Pooling per destination — with the per-request token supplied at call time rather than transport construction — would remove a fixed cost that shows up clearly in the benchmark.Audit contention under single-tenant load. Per-tenant chains mean a single busy tenant serialises on one lock. See BENCHMARKS.md for how to confirm it and AUDIT_LOG.md for the fix.
Fastify 5. Pinned to 4 to match the stated stack; the upgrade is contained.
Dashboard test depth. One Playwright smoke test today; the console deserves more.
Known issues
Found while capturing the screenshots above, recorded here rather than quietly left in:
Trace context does not reach the MCP servers. All four services export traces, and the gateway-side spans join correctly, but each MCP server's inbound span starts a new trace instead of continuing the caller's. The
traceparentis injected on the outbound call, so the fault is in extraction or propagator setup on the receiving side. The architecture notes describe the intended four-service trace; today it is two.Application metrics were not reaching Prometheus because the package barrel evaluated the instrument module before the SDK started, binding every instrument to a no-op meter. Fixed by deferring instrument creation to first use, but the Grafana dashboards have not been re-verified against a rebuilt image, so they are not screenshotted here.
License
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Enable secure connectivity between Sentry issues and debugging data, and LLM clients, using a Model Context Protocol (MCP) server.
Remote MCP for A2A caller identity, scope policy, verdict receipts, and audit history.
Connect MCP clients to 2,000+ AI models without managing provider API keys.
- FullmaktOAuthai.fullmakt
Credential broker for AI agents: scoped, revocable API access with policy enforcement and audit.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceA production-ready unified entry point for AI agents that implements the Model Context Protocol (MCP). It provides a secure gateway with rate limiting, authentication, and observability for managing and proxying requests to multiple downstream APIs.MIT
- AlicenseNot gradedqualityDmaintenanceProvides a secure gRPC transport layer for the Model Context Protocol (MCP) with mutual TLS, token-based authentication, and fine-grained authorization. Includes comprehensive telemetry and a real-time visualization dashboard for monitoring AI model interactions and security events.1Apache 2.0
- AlicenseNot gradedqualityBmaintenanceEnables authorized employees and AI hosts to perform Pyrus operations over the Model Context Protocol (MCP) with production-grade security and observability.Apache 2.0
- FlicenseNot gradedqualityBmaintenanceA protected Model Context Protocol server that gives AI agents distinct machine identities, enforces least-privilege OAuth permissions, applies contextual authorization policies, and pauses sensitive actions for human approval.-