Skip to main content
Glama

mcp-gateway

CI License: Apache-2.0 Bun

One MCP endpoint for many MCP servers. Point your AI client (Claude Code, Claude Desktop, Cursor, opencode, Windsurf) at a single URL + API key and get every upstream MCP tool the gateway admin has configured — stdio servers are spawned on the server, so nothing is installed and no credential ever lives on your laptop.

Measured behaviour and the defects found getting here: docs/nfr-report.md. Design notes and the working task list live in .agents/, which is local-only and not published with the repo.

Stack

Bun ≥ 1.2 · SvelteKit 2 + adapter-node (one process; /mcp is mounted by a Bun node:http listener) · bun:sqlite + Drizzle · @modelcontextprotocol/sdk 1.30 · Tailwind 4 · self-host Docker + Caddy.

Related MCP server: Agent Community MCP

Prerequisites

  • Bun ≥ 1.2

  • Whatever runtime your upstreams need — Node ≥ 20 for npx, Python for uvx. The gateway itself only needs Bun; the child processes resolve their runtimes on the host that runs the gateway, so on a VPS that means the container (see "What command can be")

  • Docker (for the production compose stack)

Development

bun install
cp .env.example .env      # set MCPGW_MASTER_KEY + MCPGW_ADMIN_EMAIL/PASSWORD
bun run dev               # dashboard + /mcp at http://localhost:5173

bun run dev migrates the database itself and bootstraps the admin, so a fresh clone runs with one command — bun run db:migrate is never required first. Only the worker needs the schema to exist already, and it waits for it (up to MCPGW_WORKER_WAIT_S, default 60 s) before doing anything, refusing to start rather than sweeping a schema that is not there.

bun run dev         # dashboard + /mcp on http://localhost:5173
bun run worker:dev  # optional: health sweep, session GC, retention, snapshots

worker runs the built file (build/worker.js) because the container image ships only build output; worker:dev runs the TypeScript source so a dev loop needs no build.

The dev server loads .env itself (.env, .env.local, .env.development[.local]), because Vite never copies those files into process.env and Bun only auto-loads them for the entrypoint it runs directly — bun run dev hands off to vite, so relying on that autoloading left the entire configuration silently unset in development. A variable already present in the environment always wins over the file, so a shell, systemd unit or container environment overrides a stale file. Startup prints one line naming the files it read and whether MCPGW_MASTER_KEY came from the file or the environment — "is my .env being read?" should not require debugging to answer. Only VITE_*-prefixed variables are ever exposed to the browser, so MCPGW_* stays server-side.

Production is the opposite on purpose: the web process migrates once at boot and nothing else ever runs DDL, so N replicas can start without racing ALTER TABLE. If the schema is ever missing or behind, /mcp answers 503 naming the one command that fixes it and the dashboard says the same, while /healthz stays 200 with ok: false and schema: "no-schema" — a container healthcheck reports the problem without restart-looping a process that only needs a human.

Blank variables in .env count as unset, so the copied example file boots as-is. Leaving MCPGW_PUBLIC_URL blank makes the dashboard derive the client-snippet URL from the request host, which is what you want locally; set it explicitly in production. MCPGW_ADMIN_PASSWORD shorter than 12 characters is reported at boot and the account is not created — the gateway still starts, and the log tells you why.

Script

Purpose

bun run dev

Vite dev server (dashboard and /mcp, /metrics, /healthz)

bun run build / bun run start

Production build and Bun server

bun run worker

Health sweep, session GC, retention, daily snapshot

bun run worker:dev

Same, from TypeScript source — no build needed

bun run verify

check + lint + test + build (the CI gate)

bun run check

svelte-kit sync + svelte-check

bun run lint / bun run format

ESLint + Prettier

bun run test / test:unit / test:integration

bun:test

bun run db:generate / db:migrate / db:studio

Drizzle Kit

bun run smoke

Drives a running instance end to end with a real MCP client

bun run loadtest

Added-latency, concurrency and rate-limit measurement

smoke and loadtest talk to a built, running server:

MCPGW_DATA_DIR=/tmp/mcpgw MCPGW_ROOT_DIR=$PWD bun build/server.js &
bun run smoke     --base-url http://localhost:8080
bun run loadtest  --base-url http://localhost:8080 --sessions 200 --burst 200

If the instance was started with MCPGW_METRICS_TOKEN, export it for the script too — otherwise /metrics correctly answers 401 and those checks are skipped, not failed.

Deploy (VPS)

Pull-first quickstart

Now that the image is published to ghcr.io, the install path is pull-first. Start the stack:

cd deploy && cp .env.example .env      # required BEFORE compose: env_file: .env
docker compose up -d                    # pulls ghcr.io/jawak/mcp-gateway:0.1 — no local build needed

Edit the Caddyfile's mcp.example.com to your hostname first.

Three services: Caddy (TLS, SSE-safe proxying), the gateway, and a separate worker so maintenance never delays a request. /data holds the SQLite database and snapshots — back that volume up.

Upgrades

To upgrade, run docker compose pull && docker compose up -d. The pull re-tags the service image to the newest 0.1.x and up -d recreates the containers, so this tracks the 0.1 line unless MCPGW_IMAGE is overridden.

Build the image locally (optional)

cd deploy && cp .env.example .env
docker build -f Dockerfile -t ghcr.io/jawak/mcp-gateway:0.1 ..   # optional: build locally if you are ahead of the latest release
docker compose up -d    # runs ghcr.io/jawak/mcp-gateway:0.1 (pulled, or the local build above)

deploy/docker-compose.yml defaults MCPGW_IMAGE to ghcr.io/jawak/mcp-gateway:0.1, which the release workflow tags per release. The optional build above tags your local build with the same name, so compose runs it instead of pulling whenever you are ahead of the latest release.

Run a standalone container (no compose)

Without compose, the gateway is one container:

docker run -d --name mcp-gateway \
  -p 8080:8080 \
  -v mcpgw_data:/data \
  -e MCPGW_MASTER_KEY="$(openssl rand -hex 32)" \
  -e MCPGW_PUBLIC_URL="https://mcp.example.com" \
  -e MCPGW_ADMIN_EMAIL="admin@example.com" \
  -e MCPGW_ADMIN_PASSWORD="<min 12 chars>" \
  -e MCPGW_TRUST_PROXY=false \
  ghcr.io/jawak/mcp-gateway:0.1
curl -i http://127.0.0.1:8080/healthz
  • MCPGW_MASTER_KEY is required: exactly 64 hex characters (openssl rand -hex 32). A container without it dies at boot with a ConfigError.

  • MCPGW_PUBLIC_URL: an https:// domain or IP always works, and the origin is used for Origin checks and client snippets. http://<ip>:<port> works for private and loopback hosts (a VM's local IP over plain http, no extra env). Plain http to a public host is refused at boot, since the image runs NODE_ENV=production; set MCPGW_ALLOW_HTTP=true if that is intentional.

  • MCPGW_ADMIN_EMAIL / MCPGW_ADMIN_PASSWORD bootstrap the first-run admin at boot. A password shorter than 12 characters is logged and skipped, which is not fatal, but it leaves no admin behind. Once you've created a real account, remove the vars and recreate the container (docker update cannot change env).

  • MCPGW_TRUST_PROXY=false is set explicitly because the default is true even when unset. X-Forwarded-For is client-spoofable, which poisons rate limits and the audit trail on a bare, unproxied run.

  • Keep the default port 8080: the in-image HEALTHCHECK hardcodes http://127.0.0.1:8080/healthz. Changing MCPGW_PORT requires also overriding --health-cmd, or Docker marks the container unhealthy while the gateway keeps running on the port it was given.

  • If the container exits immediately, docker logs mcp-gateway shows the config error.

  • A standalone run serving real upstreams over time also wants a persistent /var/lib/mcpgw volume, or npm and uv caches re-download on every recreate.

Version tags

Tag

What it follows

0.1.0

exact pin

0.1

floats on patch updates within the 0.1.x line only; a future 0.2.x publishes 0.2 and never re-tags 0.1

latest

floats to the newest release

After it's up, the dashboard issues the keys; see Client setup below for pointing your clients at the gateway.

Client setup

The dashboard issues a key and shows copy-paste config for each client. The generic shape:

// opencode.json / Cursor's .cursor/mcp.json — any Streamable HTTP client
{
	"mcp": {
		"gateway": {
			"type": "http",
			"url": "https://mcp.example.com/mcp",
			"headers": { "Authorization": "Bearer mcpgw_<your-key>" }
		}
	}
}

Clients that only speak stdio can bridge through npx mcp-remote <url> --header "Authorization: Bearer …".

Two databases, one server type

The same MCP server against a dev and a prod database is the normal way to use this: uniqueness is enforced on the slug, never on the command, so two rows may run the identical binary. The slug becomes the tool namespace, which is what keeps them apart.

Upstream

Command

Reference

Clients see

pg-dev

npx -y @modelcontextprotocol/server-postgres

DATABASE_URL → secret:pg-dev__database_url

pg-dev__query

pg-prod

identical

DATABASE_URL → secret:pg-prod__database_url

pg-prod__query

Each upstream gets its own child process — the pool is keyed by slug, not by command, so identical binaries are never shared or deduplicated, and editing one connection string only reconnects that one.

Isolate them with two profiles rather than one profile with deny rules: create dev-only linked to pg-dev and prod-only linked to pg-prod, then issue one API key per profile. Scope is re-evaluated on every request at both tools/list and tools/call, so a dev key that guesses pg-prod__query is refused rather than merely hidden from the listing.

Slugs must match ^[a-z0-9-]{2,32}$ — pg-dev, not pg_dev. Point the prod connection at a read-only role, and a replica if you have one; the gateway forwards what the model asks for, it does not know that a query was meant to be read-only.

Credentials live in the vault, referenced as secret:<name> (or ${ENV_VAR} to read the gateway's own environment). A credential typed into an env line is refused on every path — dashboard form, template install, manifest import — and renderManifest() will refuse to export a file that contains one, naming the upstream instead of quietly redacting it.

What command can be

Any executable the gateway host can resolve — the field is a free-form string, and the only rule is that it is not empty. npx, uvx, python3, node, bun, docker, /usr/local/bin/my-server: all fine. The bundled template catalogue is all npx, which makes it look narrower than it is.

There is no shell (the process is spawned with shell: false), so a pipe, redirect, glob, && or $HOME in the command or arguments is passed to the child as literal bytes rather than interpreted. Quote arguments that contain spaces — --header "Authorization: Bearer …" arrives as one argument, which the naive space split used to break into three.

Runtimes must exist on the server, not on your laptop: the shipped image has Node + npx + bun, python3 + uv/uvx, git and curl. It deliberately has no docker CLI: an upstream that wants to be a container should run as its own service and be registered over HTTP — see the sidecar example in deploy/docker-compose.yml, which shares the gateway's network namespace so it is reachable on http://localhost:<port>/mcp without TLS and without relaxing the SSRF rules.

Adding or editing an upstream is arbitrary code execution on the gateway host, run as the gateway's user, with whatever credentials the admin maps into it. Anyone who can sign in as admin can therefore run anything on that machine: treat an admin account like SSH access, and remember that a leaked admin session is a host compromise, not a data leak. Every create and connection change records the resulting command line in the audit trail, redacted, so the question "what was installed, by whom, when" is answerable.

Project layout

src/entrypoints/        Bun server + worker scheduler
src/handle.ts           router: /mcp | /metrics | /healthz | SvelteKit
src/middleware/auth.ts  bearer auth, budgets, origin checks for /mcp
src/lib/server/
  mcp/                  gateway server, session registry, catalog, namespacing
  upstream/             stdio + remote transports, pool, health
  registry/             config snapshot cache, CRUD, manifest import/export
  governance/           api keys, profiles, rate limits, users, secrets, RBAC
  security/             vault (AES-256-GCM), SSRF guard, redaction, argon2
  observability/        metrics, structured log, call log, audit trail
src/routes/             login + /admin dashboard (form actions for all mutations)
drizzle/                SQL migrations
scripts/                migrate, backup, smoke, loadtest
deploy/                 Dockerfile, compose, Caddyfile
docs/                   NFR report

Community

Notes for contributors

  • Tests run on bun:test, not Vitest: the data layer uses bun:sqlite, which Node cannot load. Vite runs with csp: { mode: 'nonce' } because the CSP otherwise blocks SvelteKit's bootstrap script — and only in production, where no test looks.

  • The Bun entrypoint bundle and the SvelteKit server bundle are separate module instances. Anything shared between them must go through the database, not process memory — the registry snapshot is a cache keyed on kv.config_version for exactly this reason.

  • A test that migrates in beforeAll cannot see a broken first run. Any 485-test suite once passed while a fresh clone could not start, because nothing ever looked at a database with no tables. When you change startup, readiness, or the registry, add a case that starts from an empty database file.

  • Readiness is a decision, not an exception: schemaIsPresent()/assertMigrated() return a value instead of throwing, readiness is injected into the router so the routing layer never opens a database, and /mcp answers 503 with a hint rather than 500 with a stack. Keep it that way — a missing schema is an expected state that one command fixes.

  • stdio children are spawned in a neutral directory (<dataDir>/workdir), never the gateway's own. A child started in the app directory re-reads the gateway's .env from disk, which hands every upstream package MCPGW_MASTER_KEY no matter how carefully the spawn environment is filtered. Set cwd explicitly when an upstream genuinely needs a project directory.

  • Subprocess tests pass --no-env-file: bun loads .env automatically, so a developer's local .env would otherwise decide whether a test passes.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    Lifts local stdio MCP servers into remote Streamable HTTP endpoints for cloud-hosted AI clients, with bearer-token auth and tool policy filtering.
    9 npm
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI tools to uniformly discover, inspect, and call tools, prompts, and resources from multiple upstream MCP servers through a small set of fixed MCP tools, over stdio or HTTP.
    2
    MIT