A multi-user management and proxy server for Model Context Protocol servers.
Run one hub, let your users sign in with passkeys, add their own MCP servers, and connect any MCP client (Claude Desktop, Claude Code, Claude iOS) to a single OAuth-protected endpoint that aggregates all of their servers.
- User-defined servers: every user adds their own MCP servers — pick a transport (stdio or http) and fill in the details. No central catalog.
- Per-user config, encrypted at rest: a stdio server is a command line + an environment-variables block; an http server is a URL + env.
- Connector groups: each user defines named groups of their servers, and every group is its own Streamable HTTP endpoint at
/mcp/<slug>exposing its members' tools and prompts namespaced as<server>__<name>and their resources ashub://<server>/<uri>. Splitting the catalog across endpoints keeps every connector under client-side tool caps (claude.ai truncates a connector's registry at 256 tools). - Built-in management interface: the base
/mcpendpoint serves the reservedhub__toolset (and nothing else), so servers and groups can be managed programmatically from any MCP client. - Standards-based auth: passkeys (WebAuthn) authenticate humans; the hub is its own OAuth 2.1 Authorization Server (PKCE, Dynamic Client Registration, ES256 JWTs, JWKS) for MCP clients.
- Sandboxed stdio: each user's stdio subprocesses run as a distinct unprivileged UID, so a user's command cannot read the master key or other users' secrets.
A stdio server runs a command (uvx …, npx …) as a subprocess; it may optionally point at a git repo, which the hub builds once into a cached virtualenv. An http server is a remote endpoint the hub proxies (with an AUTHORIZATION env var sent as the request header).
Claude Desktop / Code / iOS
│ Streamable HTTP MCP + OAuth 2.1 Bearer
▼
reverse proxy (TLS — your responsibility)
▼
┌──────────────── MCP Hub (one binary) ─────────────────┐
│ Web UI (passkey login, add/edit your own servers) │
│ OAuth 2.1 AS: /.well-known/* /authorize /token JWKS │
│ MCP proxy /mcp (hub__ tools) + /mcp/<group>: │
│ token → user → group's backend fan-out │
└───────────┬───────────────────────────┬───────────────┘
▼ stdio subprocess ▼ remote HTTP
zabbix, homeassistant, … memory, …
│
SQLite (users, passkeys, server instances,
encrypted secrets, OAuth state)
TLS is intentionally out of scope — run the hub behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS and forwards to port 8080.
export HUB_BASE_URL="https://hub.example.com" # public URL, no trailing slash
export HUB_MASTER_KEY="$(openssl rand -base64 32)" # 32 bytes, base64 — keep this safe!
export HUB_BOOTSTRAP_ADMIN="yourhandle" # first account must use this handle
docker compose up -dThen open https://hub.example.com/register, create the admin account with a passkey (the first account needs no invite and becomes the admin), and start adding your own servers.
Registration is invite-only: every account after the first must redeem a single-use invite code. As the admin, generate codes under Manage invites on the dashboard (or with hub__create_invite) and hand them out. See Inviting users.
The runtime image bundles Node.js (
npx) and uv (uvx) because stdio backends run as child processes inside the container.
Optional runtime hardening. The entrypoint can hide other users'
/procentries (hidepid=2) and firewall sandbox-UID network egress, but each needs a Linux capability the container isn't granted by default. To enable, add them to your compose service (the hub still starts without them, logging that each was skipped):cap_add: - SYS_ADMIN # HUB_HIDEPID: remount /proc hidepid=2 - NET_ADMIN # HUB_EGRESS_HARDENING: sandbox-UID egress firewall
All configuration is via environment variables:
| Variable | Required | Default | Description |
|---|---|---|---|
HUB_BASE_URL |
yes | — | Public base URL; OAuth issuer, MCP resource id, WebAuthn origin. No trailing slash. |
HUB_MASTER_KEY |
yes | — | base64-encoded 32-byte key. Encrypts secrets at rest and signs cookies. |
HUB_RP_ID |
no | host of base URL | WebAuthn relying-party id (registrable domain). |
HUB_ALLOWED_HOSTS |
no | host of base URL | Comma-separated extra Host authorities accepted on /mcp (anti-DNS-rebinding). The base URL's own host is always allowed; set this only if your reverse proxy forwards a different Host (e.g. hub.internal,hub.example.com:8443). |
HUB_SESSION_IDLE_SECS |
no | 1800 (30 min) |
Browser-session idle timeout: a login expires this long after its last request (each request slides it forward). Sessions also end on browser close. |
HUB_SESSION_ABSOLUTE_SECS |
no | 43200 (12 h) |
Absolute cap on a browser session from login, regardless of activity. Clamped to be ≥ HUB_SESSION_IDLE_SECS. |
HUB_BOOTSTRAP_ADMIN |
no | — | If set, the first registration must use this handle (and becomes admin). |
HUB_ALLOW_OPEN_REGISTRATION |
no | false |
Escape hatch: when true, anyone may self-register without an invite. Leave false to keep registration invite-only. |
HUB_SANDBOX_UID_BASE |
no | 20000 (in image) |
Base UID for the per-user stdio sandbox; each user runs as base + slot. Requires the container to run as root (it does). 0/unset disables it. |
HUB_DB_PATH |
no | /data/hub.db |
SQLite database path. |
HUB_ENV_DIR |
no | /data/envs |
Where prebuilt virtualenvs for git-sourced servers live (keep on the data volume). |
HUB_LISTEN |
no | 0.0.0.0:8080 |
Bind address. |
HUB_HIDEPID |
no | 1 (in image) |
Remount /proc with hidepid=2 so a sandbox UID can't read another user's process cmdline/environ. Needs CAP_SYS_ADMIN; skipped (logged) without it. 0 disables. |
HUB_EGRESS_HARDENING |
no | 1 (in image) |
Install nftables rules dropping sandbox-UID egress to link-local/cloud-metadata and the hub's own loopback port (RFC1918 stays allowed). Needs CAP_NET_ADMIN; skipped (logged) without it. 0 disables. |
HUB_MAX_BACKENDS_PER_USER |
no | 16 |
Max live backends per user (shared across that user's sessions). |
HUB_MAX_BACKENDS_GLOBAL |
no | 128 |
Max concurrent backends across all users. |
HUB_KEEP_WARM |
no | true |
Keep every enabled user's backends pre-spawned: bound at startup and re-touched each minute, so a new connection always finds hot tools and crashed backends respawn on their own. 0/false/no disables. |
HUB_KEEP_WARM_SECS |
no | 300 |
How often the warmer exercises each backend with a real tools/list, keeping the child responsive (not just alive) and respawning one that fails 3 heartbeats in a row. 0 disables the deep heartbeat. |
HUB_BACKEND_IDLE_SECS |
no | 900 |
A user's pooled backends are shut down after this long without an MCP request from them. 0 = never. Effectively inert while HUB_KEEP_WARM is on (the warmer's touch counts as use). |
HUB_BACKEND_CALL_TIMEOUT_SECS |
no | 90 |
Wall-clock cap on one proxied tool call / resource read / prompt get. 0 = unbounded. |
HUB_BACKEND_CONNECT_TIMEOUT_SECS |
no | 20 |
Wall-clock cap on spawning + initialize-ing one backend; a hung handshake is marked failed instead of stalling the bind. 0 = unbounded. |
HUB_BACKEND_LIST_TIMEOUT_SECS |
no | 10 |
Wall-clock cap on one backend's tools/resources/prompts list call; a hung backend is skipped from the aggregate. 0 = unbounded. |
HUB_BIND_BUDGET_SECS |
no | 5 |
How long a bind waits for backends before answering with whatever is ready; late backends keep connecting and announce via tools/list_changed. 0 = wait for all. |
HUB_MAX_RESPONSE_MB |
no | 0 (uncapped) |
Cap on a single backend response's serialized size. |
Backend tools are served on connector group endpoints. Create a group on the
dashboard (or with hub__create_group), tick its member servers, and point your
MCP client at the group's URL, e.g. https://hub.example.com/mcp/monitoring.
The client discovers the authorization server (RFC 9728 / RFC 8414), registers
itself (RFC 7591), and runs the OAuth flow — you log in with your passkey and
approve access in the browser. Each group you add to a client is its own
connector with its own OAuth grant.
Why groups? claude.ai silently truncates a connector's tool registry at 256 tools — alphabetically, so a large backend's tail simply disappears. Keeping each group's total under 256 (the dashboard shows a per-group count and warns past the cap) makes every tool reachable. Other clients (e.g. Claude Code) have no such cap, so a single "everything" group works fine there.
The base endpoint https://hub.example.com/mcp serves only the hub__*
management tools — add it as a connector too if you want to manage the hub from
inside a client.
Claude Code, for example:
claude mcp add --transport http hub https://hub.example.com/mcp # management
claude mcp add --transport http hub-all https://hub.example.com/mcp/all # an "everything" groupOnce connected, a group's tools and prompts appear namespaced
(zabbix__host_get, …) and its resources as hub://zabbix/….
Upgrading from the single-endpoint model: existing connectors pointed at
/mcpkeep authenticating but now see only thehub__*tools. Create groups and re-add each/mcp/<slug>URL as a fresh connector. Personal access tokens are endpoint-independent (no audience) and work on every endpoint; re-point PAT-based scripts at a group URL. OAuth access tokens are audience-bound to the exact endpoint they were minted for.
| Tool | Who | Description |
|---|---|---|
hub__whoami |
user | Current user + configured servers |
hub__list_my_servers |
user | Your servers, their launch command and last connection status |
hub__add_server |
user | Add a server (transport + command/url/repo/env) |
hub__edit_server |
user | Change a server's command / url / repo |
hub__set_env |
user | Replace a server's environment variables |
hub__update_server |
user | (Re)build a git-sourced server from its repo |
hub__enable / hub__disable / hub__remove |
user | Manage your servers |
hub__list_groups |
user | Your connector groups: endpoint URLs, members, tool counts vs the 256 cap |
hub__create_group / hub__update_group / hub__delete_group |
user | Manage connector groups (slugs are immutable — delete and recreate) |
hub__list_users |
admin | List users (with admin/disabled status) |
hub__disable_user / hub__enable_user / hub__delete_user |
admin | Suspend or remove an account |
hub__create_invite / hub__list_invites / hub__revoke_invite |
admin | Manage invite codes |
hub__create_recovery |
admin | Issue a one-time account-recovery code |
Newly added/enabled servers take effect on the next client session.
Every user manages their own servers — there is no shared catalog. Add one from
the dashboard (+ Add a server) or with hub__add_server:
- stdio: a command line (e.g.
uvx zabbix-mcp-server) plus environment variables. Optionally give a git repo; the hub builds it once into a cached virtualenv and runs the command inside it (see below). - http: a remote URL the hub proxies. Put an auth token in the
AUTHORIZATIONenv var and it is sent as the request'sAuthorizationheader.
Environment variables are entered as KEY=VALUE lines, encrypted at rest, and
shown back to you (your own data) when editing.
Registration is closed by default: the first account bootstraps the admin, and every later account must redeem a single-use invite code.
As an admin, create a code one of two ways:
- Web UI: dashboard → Manage invites → Generate invite. The code is shown once — copy it then.
- From an MCP client: call
hub__create_invite(optionally with anote); the returnedcodeis shown once.
Hand the code to the new user. They register at /register, entering the code
alongside their handle and display name. The code is consumed the moment their
account is created, so it cannot be reused.
Only the SHA-256 of each code is stored, so codes can never be recovered from the
database — hub__list_invites and the web list show status and a short id, not
the code. Revoke an unused code with hub__revoke_invite (or the Revoke
button); used codes are kept for audit.
To allow open self-registration instead (no invite needed), set
HUB_ALLOW_OPEN_REGISTRATION=true.
Auth is passkey-only, so a lost device must not mean a lost account. Two safeguards:
- Multiple passkeys. On the Account page (
/account), a signed-in user enrolls additional passkeys — a second device or a hardware key. The hub refuses to remove your last passkey, so you cannot lock yourself out from the UI. Enroll a backup key early. - Admin recovery codes. If a user loses every passkey, an admin issues a
one-time recovery code (Invites page → Recovery code, or
hub__create_recovery). The user enters their handle and the code at/recoverand enrolls a fresh passkey on their existing account. Recovery codes share the invite protections: 128-bit, stored only as a hash, single-use.
A stdio server with a repository is built once into a prebuilt environment on the
data volume — a virtualenv for Python repos, a directory of compiled binaries for Go repos
(detected from pyproject.toml vs go.mod). Connecting then runs that environment directly
— no fetch, no install — so startup stays fast. Updates are explicit: you push to GitHub,
then run an update.
Add it (web form, or hub__add_server):
The command's first word resolves to the built environment's bin/ (so a console
script, python, or a compiled Go binary comes from the repo's environment). Then:
hub__update_server→{ "namespace": "mine" }builds it (the one slow step).- Connect — the cached environment runs directly.
For a Go repo, the first word of command names a main package under cmd/ (its
built binary). For example, Teamwork's MCP server:
{
"namespace": "teamwork",
"transport": "stdio",
"command": "mcp-stdio -toolsets project-manager -read-only", // builds+runs cmd/mcp-stdio
"repo": "https://github.com/Teamwork/mcp",
"git_ref": "main",
"env": {
"TW_MCP_BEARER_TOKEN": "...",
"TW_MCP_API_URL": "https://yoursite.teamwork.com"
}
}Updating after you push: run hub__update_server again (or the "Update from repository"
button on the server's page). It resolves the branch tip; if nothing changed it's a no-op,
otherwise it rebuilds in the background and the next session uses the new code. The previously
built version keeps serving until the rebuild succeeds.
Notes:
- Git sources support Python (uv) — the repo must be
pip-installable (has apyproject.toml) — and Go — the repo must have ago.mod; it is built withCGO_ENABLED=0, so pure-Go servers only. The image shipsgit,uv, andgo; packages needing C build tools may need a customized image. - Public repos only for now — a private repo would need a token, which isn't yet handled cleanly.
- Your connections. The Account page lists the MCP clients you have authorized and your active browser sessions. Disconnect revokes a client's refresh token; Sign out other sessions ends every browser session but the current one.
- Admin user management. The Users page (and
hub__disable_user/hub__enable_user/hub__delete_user) let an admin suspend or remove an account. Disabling ends the user's sessions and revokes their tokens at once; deleting also removes their servers, secrets and passkeys. An admin cannot act on their own account or the last remaining admin.
Because access tokens are short-lived JWTs, a revoked or disabled user keeps any already-issued access token until it expires — but the proxy re-checks the account on every request, so revocation takes effect within seconds, well inside the 15-minute token lifetime.
- Secrets and the OAuth signing key are encrypted at rest with XChaCha20-Poly1305 using
HUB_MASTER_KEY; plaintext only exists in memory while a backend is being launched, and is never logged. A database compromise alone cannot decrypt secrets or forge tokens. - stdio servers run arbitrary commands, so each user's stdio subprocesses are dropped to a
distinct unprivileged UID (
HUB_SANDBOX_UID_BASE + slot). A non-root child cannot read the hub's master key (/proc/1/environ), the secrets DB (locked to root), or another user's subprocess environment. Network and non-secret filesystem reads are not restricted, so keep the hub invite-only and trust your users. The container runs as root on purpose so it can drop children to those UIDs. - Access tokens are short-lived (15 min) ES256 JWTs bound to the exact endpoint they were requested for —
/mcpor one/mcp/<group>— as the resource (audience) and pinned to the active key id; refresh tokens are stored hashed and rotated with reuse detection — replaying a rotated token revokes the whole session. - OAuth uses PKCE (S256, mandatory), exact-match
redirect_uri, and a per-session CSRF token on the consent and management forms. Responses carryX-Frame-Options,nosniff, and a CSP that forbids inline scripts. - Registration is invite-only. Invite codes carry 128 bits of entropy, are stored only as a SHA-256 hash, and are consumed by a single atomic update so a code cannot be redeemed twice.
- Back up
HUB_MASTER_KEY— losing it makes every stored secret, signing key, and session unrecoverable. Run only behind a TLS-terminating reverse proxy, and rate-limit/auth/*,/token, and/registerthere. - Some MCP servers are inherently local to a developer's machine (e.g. IDE bridges, desktop-app tools) and are not meant to be centralized here.
cargo test # unit + integration tests
cargo build --example mock_mcp_server # mock backend used by e2e tests
cargo run # needs HUB_BASE_URL + HUB_MASTER_KEYcargo runwithHUB_BASE_URL=http://localhost:8080and aHUB_MASTER_KEY. (For passkeys over plain HTTP,localhostis treated as a secure context by browsers.)- Register an admin passkey at
http://localhost:8080/register. - Add a server (e.g.
uvx zabbix-mcp-server+ env) via the web UI orhub__add_server. - Connect with the MCP Inspector or a Claude client and confirm the namespaced tools work:
npx @modelcontextprotocol/inspector
MIT OR Apache-2.0
{ "namespace": "mine", "transport": "stdio", "command": "my-mcp", // a console script your package defines, or `python -m pkg.server` "repo": "https://github.com/you/my-mcp", "git_ref": "main", // branch/tag to track; pin a tag for reproducibility "env": { "MY_TOKEN": "..." } }