mcp-gateway
Provides access to GitHub through the MCP Gateway, with tools for operations such as creating issues and merging pull requests, subject to configurable allow/deny policies.
Provides access to Linear through the MCP Gateway, with tools for listing and managing Linear resources, subject to configurable allow/deny policies.
Provides access to Slack through the MCP Gateway, exposing Slack tools via curated profiles with policy controls, rate limits, and audit logging.
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., "@mcp-gatewayshow me the audit log for denied tool calls in 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.
Every MCP client keeps its own list of servers, its own copy of your credentials, and its own spawned subprocesses. Open three clients and you have three GitHub servers running, three copies of the same config to maintain, and 120 tools competing for space in the model's context — with no record of which one ran, or with what arguments.
MCP Gateway is a single local daemon that sits in front of all of them. It owns the backend
connections once and exposes curated subsets of their tools as named profiles. Clients
connect to http://127.0.0.1:8420/mcp/<profile> instead of spawning anything.
github ──┐
linear ──┼──► MCP Gateway ──► Claude Code · Claude Desktop · Cursor
fs ──────┤ 127.0.0.1:8420
slack ───┘ policy · limits · auditWhy
One config, not four. Add a server once. Every client sees it.
One process per backend. Three clients connected still means one GitHub subprocess.
Fewer, better tools. Expose 12 relevant tools instead of 120, renamed to whatever reads clearly to the model.
Nothing runs unlogged. Every call — allowed, denied, or failed — is one line of JSON.
Real brakes. A denied tool is invisible and uncallable. Rate limits are per profile, and per server.
Tools can't change under you. Descriptions are hashed and pinned; a server that quietly rewrites one gets blocked until you review the diff.
Related MCP server: Miftah
Requirements
Node 22.13 or newer. Nothing else — no database, no Redis, no external services. Three runtime
dependencies: the MCP SDK, yaml, and zod.
Install
Not published to npm. Install it straight from GitHub — npm clones the repository and builds it during install:
npm install -g github:vedantnimbarte/mcp-gatewayThat puts mcpgw and mcpgw-bridge on your PATH. The bridge script the client configs below
point at is $(npm root -g)/mcp-gateway/dist/src/bridge.js.
From source
git clone https://github.com/vedantnimbarte/mcp-gateway.git
cd mcp-gateway
npm installnpm install also builds, producing dist/src/cli.js (mcpgw) and dist/src/bridge.js
(mcpgw-bridge); after editing the source, rebuild with npm run build. Link them onto your
PATH with npm link if you want the bare command names.
Configure
Create config.yaml. Declare your servers once, then compose profiles from them:
version: 1
listen:
host: 127.0.0.1
port: 8420
servers:
github:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_TOKEN}
fs:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "~/code"]
linear:
transport: http
url: https://mcp.linear.app/mcp
headers:
Authorization: Bearer ${LINEAR_KEY}
profiles:
default:
servers: ["*"]
readonly:
servers: [github, linear]
allow: ["github__get_*", "github__list_*", "linear__list_*"]
coding:
servers: [github, fs]
deny: ["github__delete_*", "github__merge_pull_request"]
rename:
github__create_issue: file_bug
limits: { rpm: 60, concurrent: 4 }Credentials come from environment variables via ${VAR} — they never live in the config file.
A backend can also carry its own limits, so one busy server cannot starve the rest, and can cache the tools you know are safe to repeat:
servers:
fs:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/vedant/code"]
limits: { concurrent: 2 } # on top of each profile's own
cache:
tools: ["read_*", "list_*"] # opt-in: the gateway cannot know what is idempotent
ttl_ms: 30000Then start it:
mcpgw startConnect your clients
Give each client the profile that fits it — coding for your editor, readonly for anything
you trust less. Every entry below replaces that client's direct server entries; nothing but
the gateway should be spawning backends any more.
Claude Code (~/.claude.json) speaks HTTP, so it points straight at a profile:
{ "mcpServers": { "gateway": { "type": "http", "url": "http://127.0.0.1:8420/mcp/coding" } } }Claude Desktop (%APPDATA%/Claude/claude_desktop_config.json, or
~/Library/Application Support/Claude/ on macOS) speaks only stdio, so it launches the bridge —
a shim that pipes stdin/stdout to the daemon and spawns no backends of its own:
{
"mcpServers": {
"gateway": {
"command": "node",
"args": ["/abs/path/to/mcp-gateway/dist/src/bridge.js",
"--url", "http://127.0.0.1:8420/mcp/readonly"]
}
}
}Cursor (~/.cursor/mcp.json) uses the same bridge, usually on a different profile:
{
"mcpServers": {
"gateway": {
"command": "node",
"args": ["/abs/path/to/mcp-gateway/dist/src/bridge.js",
"--url", "http://127.0.0.1:8420/mcp/coding"]
}
}
}If the daemon is not running, the bridge exits immediately with a readable message instead of hanging on the first tool call.
CLI
Command | What it does |
| Run the daemon |
| Check the config without starting; exits non-zero on any error |
| Backend health, uptime, restart counts, active sessions, pending drift |
| Every tool the profile exposes, plus the rule behind each decision |
| Show changed tool descriptions as diffs; |
| Authorize an |
| Re-read the config in the running daemon (what SIGHUP does) |
| Reconnect one backend without touching the others or the daemon |
| Stream the audit log |
| SQL over the audit log, for what one line of |
mcpgw list is the one to reach for when a tool isn't showing up — it prints the decision and
the exact rule that produced it.
mcpgw restart is what you want when a backend has given up: it exhausted its retries, or it
was DOWN waiting for mcpgw auth. reload cannot help there, because it deliberately skips any
server whose definition has not changed — which is exactly those two cases.
SIGHUP reloads the config, restarting only the servers whose definitions actually changed —
live sessions keep working, and a new allow list applies to them without a reconnect. A bad edit
is rejected and the previous config keeps serving. SIGTERM/SIGINT stop accepting new work,
give in-flight calls up to 5 seconds to finish, then shut the backends down.
Remote servers that need OAuth
A remote MCP server that answers 401 with a WWW-Authenticate header wants an OAuth token,
not a static header. Mark it and authorize it once:
servers:
figma:
transport: http
url: https://mcp.figma.com/mcp
auth: oauth
scope: "mcp:connect"mcpgw auth figmaThat opens a browser, completes the authorization-code flow with PKCE, and writes the tokens to
tools.lock.json's neighbour tokens.json (mode 0600, gitignored). Then mcpgw restart figma
brings it up — the daemon notices the new tokens on disk. From then on it connects on its own
and refreshes the access token silently; you only run mcpgw auth again if the refresh token is
revoked.
The daemon never opens a browser by itself. A backend that needs authorizing stays DOWN with
needs authorization: run mcpgw auth <server> and does not retry — retrying an expired
authorization only burns backoff until a human acts.
Servers that refuse dynamic registration. Many commercial servers advertise a registration
endpoint and then reject it, because they expect an OAuth app you created by hand. Figma is one.
Create the app with redirect URI http://127.0.0.1:8419/callback (or another port, set as
listen.oauth_callback_port, if 8419 is taken), then:
servers:
figma:
transport: http
url: https://mcp.figma.com/mcp
auth: oauth
scope: "mcp:connect"
client_id: ${FIGMA_CLIENT_ID}
client_secret: ${FIGMA_CLIENT_SECRET}The credentials come from the environment like every other secret. With client_id set, no
registration is attempted at all.
Machine-to-machine servers. A server that issues tokens to a client ID and secret, with no user in the loop, needs no browser at all:
servers:
internal:
transport: http
url: https://mcp.internal.example/mcp
auth: oauth
oauth_grant: client_credentials
client_id: ${INTERNAL_CLIENT_ID}
client_secret: ${INTERNAL_CLIENT_SECRET}The daemon fetches its own token and fetches another when it expires. A wrong secret leaves the
backend DOWN with authorization failed, check client_id and client_secret, not retrying.
Policy
A profile picks servers, then filters their tools with globs. Deny always beats allow, and
if an allow list is present, anything not matching it is excluded. Filtering applies to both
listing and calling — a tool the model never saw is still refused if it guesses the name.
Tools are namespaced <server>__<tool> so two servers can both have a search without
colliding. Globs always match the canonical name, never the alias, so renaming can never be
used to slip past a deny rule.
Some tools you want reachable, but not unattended. List them under approve, and each call
waits for you to say yes in the client that made it:
profiles:
coding:
servers: [github, fs]
approve: ["github__create_pull_request", "fs__write_file"]The gateway asks through MCP elicitation, showing the tool and its (redacted) arguments. A
decline, a dismissal, or no answer within guard.approval_timeout_ms refuses the call. So does
a client that cannot elicit at all — an approval that cannot be asked for is never assumed.
Resources and prompts
Both are proxied alongside tools, namespaced the same way. Prompts become <server>__<name>;
resources get a scheme, mcpgw://<server>/<original-uri>, so the backend's own URI survives
whole and comes back intact on the way down.
resources/subscribe is reference-counted: however many sessions watch the same resource, the
backend is subscribed once, and notifications/resources/updated is delivered only to the
sessions that asked — closing a session releases whatever it was the last one holding.
The two are filtered differently, and the difference is deliberate:
Filtered by | |
Tools, prompts | The full policy: server membership, then deny globs, then the allow list |
Resources, templates | Server membership only |
Globs are written against names, and a resource is addressed by URI — matching github__get_*
against mcpgw://github/file:///x would be guesswork. So a profile that can reach a server can
read its resources. If that is too broad for a server you are exposing, keep it out of that
profile's servers list rather than trying to express it as a glob.
Cancellation and logging
A client that cancels a call cancels it all the way down: the abort is carried into the backend request, so the backend stops working rather than finishing into a discarded result. Other sessions sharing that backend are unaffected.
logging/setLevel is recorded per session and never pushed down to the backends — they are
shared, so one client asking for debug would turn it on for everyone. A backend's log messages
are routed to the session whose call they arrived during and filtered by that session's own
level (default info). A message that arrives with no call in flight is dropped rather than
broadcast, for the same reason a reverse request in that state gets -32006.
Audit log
One JSON object per line, in audit/YYYY-MM-DD.jsonl:
{"ts":"2026-08-31T10:12:44.812Z","session":"s_7f3a91","profile":"coding",
"client":{"name":"claude-code","version":"2.1.0"},"method":"tools/call",
"server":"github","tool":"create_issue","exposed_as":"file_bug",
"decision":"allow","args_hash":"sha256:7ab1…","dur_ms":412,"status":"ok"}Plain JSONL, so jq is the query engine:
jq 'select(.decision != "allow")' audit/*.jsonlArguments are hashed by default. Set log_args: full to record them, or none to record
nothing — either way, configured regex patterns are redacted before anything is written.
When a question outgrows one line of jq, ask it in SQL:
mcpgw query "select tool, count(*) as refused from audit where decision != 'allow' group by tool"
mcpgw query "select server, count(*) as calls, max(dur_ms) as slowest from audit
where method = 'tools/call' and ts >= date('now', '-7 days') group by server"
mcpgw query "select json_extract(line, '$.error.message') as error, count(*) from audit
where status = 'error' group by error" --jsonIt keeps an index, audit/index.sqlite, built with Node's own SQLite and caught up from the JSONL
on every query. The JSONL stays the record: delete the index and the next query rebuilds it. The
table is audit, with the common fields as columns and the whole original line in line.
Queries run on a read-only connection.
Status page
http://127.0.0.1:8420/dashboard shows the backends, pending pinning changes, and the most recent
refusals and errors, refreshing every five seconds. It shows only what /healthz and the audit
log already show. With listen.token set, it asks for the token and keeps it for that browser
tab only.
Once you have entered the token, the page can also manage the gateway: Reload config,
Restart on each backend, and Stop gateway. These appear only when listen.token is set;
without one the page stays read-only. Every action leaves a manage line in the audit log.
Nothing on the page can start a stopped gateway again — that is mcpgw start.
It can also switch things off without you opening the YAML, and what it switches off is written
to config.yaml, so a later reload or restart keeps it:
Disable / Enable on a backend sets
disabled: true(orfalse) on that server. A disabled server is validated but never started, and its tools disappear from every profile.Tools, per profile, lists every tool with the rule that decided it. Disable adds that exact tool to the profile's
denylist; Enable removes the entry again. A tool covered by a deny glob or missing from an allow list has no button — change those in the YAML.
Each change is spliced into the file where it belongs, so your comments and layout stay as they
were, and the previous file is kept as config.yaml.bak. A change that would make the config
invalid is refused and the file is left alone. Saving reloads the file as it is on disk, so an edit
you made by hand and had not reloaded yet is picked up too.
For everything else — adding a server, writing a profile, changing globs — the page has a
config.yaml editor: the file exactly as written, with Validate and Save & reload. It is
shown only when you open the page on the machine running the gateway; from anywhere else it is
refused even with the token, because editing a server's command means running a program as you.
A save is refused if the file changed on disk since the editor loaded it, and a change under
listen is saved but needs mcpgw start again to take effect.
Screenshots: the official filesystem and memory MCP servers behind two profiles, coding and
readonly.
Tool pinning
The first time a tool or prompt is seen, a hash of what it tells the model — name, description,
and its schema or arguments — goes into tools.lock.json. Every startup and every
list_changed re-checks it. If a server rewrites one after you approved it, it is blocked, the
change is logged, and a diff is printed. mcpgw pin is the only way to accept it, and
mcpgw pin --yes tells a running daemon to reload, so the accepted tool comes back without a
restart.
A hash only notices change. So every description is also scanned for what injected instructions
tend to look like — "ignore previous instructions", "do not tell the user", <IMPORTANT> tags,
invisible characters, credential paths — including on first sight. By default a hit is flagged
in the listing (on_suspicious: warn); block refuses it until mcpgw pin shows you the
findings and you accept them. It is a heuristic: it catches the careless attack, not the
careful one.
Commit tools.lock.json.
Known limits
Deliberate, and each one is marked in the code:
Reverse requests need one session per backend at a time. A backend asking the client to sample is routed to the session with work outstanding on it, however many calls that session has running. With calls from two different sessions in flight on one backend it gets
-32006rather than a guess — guessing would leak one client's prompt to another.Process trees are not cleaned up after a crash. When the gateway stops or restarts a backend it kills whatever that backend's launcher left running, but a launcher that crashed on its own may leave its children behind.
Rate limits survive only a graceful restart. A crash resets the buckets.
Audit writes are best-effort by default. A hard crash can lose the last few lines;
audit.durable: truefsyncs every line, at the cost of a synchronous write per request.Listings are one page by default. Set
listen.page_sizefor a profile with a very large catalog; many clients never follow a cursor, which is why it is not the default.SSE resumption is in memory. A client that drops its stream can resume with
Last-Event-IDfrom the last 256 events (4 MiB) of its session; after a daemon restart, or a longer gap, it re-initializes.Approvals hold their slot. A call waiting on a human keeps its rate-limit slots for up to
approval_timeout_ms.No device-code OAuth. Authorization code (with a browser, once) and client credentials are supported; the device flow is not.
One process. To use more cores, or to keep two sets of credentials apart, run several daemons — see below.
Security
There is no client authentication, by design. The trust boundary is the local machine.
That only holds if the daemon stays local, so the gateway refuses to bind a non-loopback
address unless you explicitly set listen.token. Reaching the port means inheriting every
credential the gateway holds — the interlock is enforced at startup, not left to convention.
Two smaller rules follow from the same reasoning. The Origin check runs before everything
else, and without a token the Host header must be loopback too, so no route — not even
/healthz — answers a browser tab or a DNS-rebound page. And /healthz returns bare liveness to
an unauthorized caller: backend names, pids and error strings are detail, and detail needs the
token. The bridge only ever needed the liveness half.
Reaching it from another machine. Set a token, a non-loopback host, and TLS, so the token is not sent across the network in the clear:
listen:
host: 0.0.0.0
token: ${MCPGW_TOKEN}
tls: { cert: ~/certs/gateway.pem, key: ~/certs/gateway-key.pem }Without tls the daemon still starts, and logs insecure_lan to say so. Every refused token is
written to the audit log with the address it came from. A client that must trust a self-signed
certificate — the bridge, or mcpgw status — can be pointed at it with NODE_EXTRA_CA_CERTS.
Running several daemons
One daemon is one process. To spread load, or to keep one set of credentials away from another,
give each daemon its own directory: its own config.yaml with its own listen.port, and with it
its own tools.lock.json, tokens.json, rate-limit state and audit/, all of which live beside
the config. Point each client at the port of the daemon it belongs to.
mcpgw start --config ~/gateways/work/config.yaml # listen.port: 8420
mcpgw start --config ~/gateways/personal/config.yaml # listen.port: 8430Logo
Lines fan in from the left and converge to a single point inside a rounded gate, leaving as one line on the right. Many backends in, one governed path out — and the gate is the only way through.
File | Use |
Horizontal lockup, mark plus wordmark. README and documentation headers | |
Square mark on its own. Favicon, app icon, social card |
Both are transparent PNGs in slate #34494D, which holds up on light and dark backgrounds
alike.
License
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Authenticated MCP server for ClearPolicy policy and compliance workflows.
The MCP server that vets MCP servers: identity, risk grade and per-tool risk before you install.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceCentralized MCP control plane that proxies multiple upstream MCP servers with tool namespacing, filtering, policy enforcement, audit logging, and health checks.5 npmMIT
- AlicenseNot gradedqualityAmaintenanceA local MCP auth wrapper and credential broker for multi-account workflows, enabling profile switching, secret injection, and policy enforcement for upstream MCP servers.274 npm4MIT
- AlicenseNot gradedqualityBmaintenanceA least-privilege enforcement proxy for MCP servers. It sits between MCP clients and upstream servers, enforcing tool policies, hiding denied tools, requiring human approval for risky actions, and providing a structured audit trail.MIT
- AlicenseNot gradedqualityAmaintenanceAn authorizing reverse proxy for MCP servers that enforces per-call policy rules on tool arguments with audit logging, dry-run, and rate limiting.Apache 2.0