Skip to content

Environment variables ​

Everything mcp-hub reads from its own environment. Variables referenced as ${VAR} from mcp.json are separate — those are your servers' secrets and are described under configuration.

Required ​

VariableDescription
EXTERNAL_URLThe public base URL exactly as clients see it, e.g. https://mcp.example.net. No trailing path. Every OAuth metadata document, redirect and resource identifier is derived from it. Missing → the process exits at startup.
PASSWORD_HASH or PASSWORDThe login secret. PASSWORD_HASH takes precedence and is what you should use. With neither set — or with a PASSWORD_HASH that is not a bcrypt hash — the hub starts with its login disabled: a startup warning, 503 on the sign-in page, and no way to approve a client.

Generate the hash with:

sh
htpasswd -bnBC 10 "" 'yourpassword' | tr -d ':\n'

PASSWORD compares in constant time, but it puts the plain-text secret in the container's environment where every child process and docker inspect can see it. Use it only for a throwaway test.

VariableDefaultDescription
TRUSTED_PROXIES(unset)Comma-separated IPs/CIDRs allowed to set X-Forwarded-*. Decides what req.ip is, and therefore what the login rate limiter counts. Unset → a startup warning and per-IP limiting degrades to one global counter. See Security.
RESOURCE_BOUND_TOKENStrueRFC 8707 resource binding: a token is valid only for /hub (which covers /health) or the one /<name>/mcp it was issued for. false/0 restores the pre-0.5 behaviour where unbound tokens reach every path — a migration mode that logs a warning on every start.
DEFAULT_RESOURCE(unset)Server name (or hub) to bind a token to when the OAuth client sends no resource parameter at all (older Codex logins, Google ADK, Gemini Enterprise). Unset → such requests are refused with invalid_target. The token is still bound either way — never global.

Client registration ​

See Client registration for what these do.

VariableDefaultDescription
CLIENT_REGISTRATIONcimd,dcrWhich mechanisms a client may use to obtain a client_id, comma-separated. cimd = Client ID Metadata Documents, dcr = RFC 7591 dynamic registration. Dropping dcr removes registration_endpoint from the discovery document and makes /register answer 404; dropping cimd removes client_id_metadata_document_supported and treats a URL client_id as unknown. An unknown value exits at startup.
CIMD_ALLOWED_ORIGINS(unset)Comma-separated bare https origins whose metadata documents are accepted, e.g. https://chatgpt.com,https://vscode.dev. Unset → every https origin is admitted and the consent page is the gate. Only origins can be pinned: ChatGPT's per-connector document path is random. An entry that is not a bare origin exits at startup.
CIMD_ALLOW_PRIVATE_ADDRESSESfalseLocal development only. Lets metadata documents be fetched from private, loopback and link-local addresses. Logs a warning on every start — leaving it on in production is what a client_id aimed at your internal network or a cloud metadata endpoint needs to succeed.
DCR_MAX_CLIENTS500Ceiling on stored dynamic registrations. When it is reached the hub evicts the oldest never-approved ones; if every registration under the ceiling has been approved, a new registration is refused rather than a working connector being dropped. Only applies to dynamic registration — metadata documents are never stored. Independently of this, never-approved registrations are capped at 100 (the oldest are evicted first), so an open /register cannot grow the state file without bound.
DCR_PENDING_TTL_HOURS24How long a registration may sit without ever being approved before it is removed. Opening the authorization page counts as use and starts the window again, so a slow login is not cut short.
DCR_INACTIVE_DAYS90How long an approved registration may sit unused before it is removed together with its approval and refresh tokens. Use means an authorization or a token exchange. Approvals for metadata-document clients are left alone.

Sandboxed servers ​

Only relevant with type: "docker" entries — see sandboxing.

VariableDefaultDescription
DOCKER_HOST(required with Docker servers)The policy proxy's socket, e.g. unix:///run/proxy/docker.sock. Missing values and direct /var/run/docker.sock access fail closed; other endpoints must pass the versioned proxy handshake.

The proxy image (ghcr.io/ni-c/mcp-hub-docker-proxy) reads its own set:

VariableDefaultDescription
CONFIG_PATH/config/mcp.jsonThe same file the hub reads — mount its directory read-only (./config:/config:ro), like the hub does. It is the policy. Parsed without ${VAR} expansion — the proxy holds none of the hub's secrets.
LISTEN_SOCKET/run/proxy/docker.sockUnix socket the hub connects to. Shared with the hub through a volume.
DOCKER_SOCKET/var/run/docker.sockThe real daemon.
SANDBOX_SECRETS_DIR/run/secretsWhere "secretsFrom": "x" looks for x.env. Files must be regular, non-symlink, at most 64 KiB, mode 640 or stricter, with at most 100 unique non-NUL entries.
SANDBOX_SECRETS_WATCHtrueWatch referenced secret files and recreate the affected sandbox when their content changes. Set to false to apply secret changes only on the next container create.
SOCKET_MODE0660Permissions of LISTEN_SOCKET. Group access is how the hub gets in; world-writable would hand the policy to anyone on the host.
LOG_FILE(unset)Same mirroring as the hub's, useful because refusals are logged as DENY.

Limits and timeouts ​

VariableDefaultDescription
MCP_BODY_LIMIT1mbMaximum JSON body for authenticated MCP requests. Any Express/bytes size string.
MCP_REQUESTS_PER_MINUTE120MCP requests per minute per OAuth client. Positive integer.
MCP_MAX_CONCURRENT_REQUESTS4In-flight MCP requests per OAuth client — POSTs carrying JSON-RPC, so this is the work a child server is doing at once. Positive integer.
MCP_MAX_CONCURRENT_STREAMS32Open listening streams per OAuth client: a 2025-era GET, or a 2026-07-28 subscriptions/listen POST whose response stays open. Both are the standing channel rather than work in progress, so neither is charged to the budget above. Bounds how many sessions one client may hold open, not how much work it may cause. Positive integer.
MCP_CALL_TIMEOUT_MS300000Deadline for one forwarded tool call or request. Raise it only for a deployment that genuinely runs long tools; a stuck call holds one of the concurrency slots above.
MCP_RESET_TIMEOUT_ON_PROGRESSfalseWhether a progress notification restarts that deadline. true is convenient for long tools and gives up the absolute bound: a child that emits progress forever keeps the call open forever.
IDLE_TIMEOUT_MINUTES60Minutes of inactivity before an on-demand server is put to sleep. 0 disables on-demand lifecycling entirely — every server starts at boot and keeps running, the pre-0.9 behaviour. Per-server idleMinutes overrides it.
HTTP_HEADERS_TIMEOUT_MS10000Node's header timeout.
MCP_PING_INTERVAL_MS, MCP_PING_TIMEOUT_MS, MCP_BACKOFF_INITIAL_MS, MCP_BACKOFF_MAX_MS, MCP_BACKOFF_RESET_AFTER_MS, MCP_WAKE_TIMEOUT_MS, MCP_MAX_UNUSED_RESTARTS, MCP_IDLE_SWEEP_INTERVAL_MS, MCP_CONFIG_POLL_INTERVAL_MS, IDLE_TIMEOUT_MS60000, 30000, 1000, 300000, 300000, 120000, 5, 60000, 3000, 0The supervisor's clocks, in milliseconds (MCP_MAX_UNUSED_RESTARTS is a count). They exist so a test can watch a restart or an idle sweep in a second instead of a minute; a deployment has no reason to set them. IDLE_TIMEOUT_MS is the sub-minute sibling of IDLE_TIMEOUT_MINUTES and wins over it when set; 0 means unset. A bad value warns and keeps the default.
HTTP_REQUEST_TIMEOUT_MS310000Complete request timeout — slightly above the default tool-call timeout. Your reverse proxy must allow at least as long, and raising MCP_CALL_TIMEOUT_MS means raising this and the proxy with it. A subscriptions/listen stream is exempt: it is idle by design and would otherwise be cut every few minutes, which looks exactly like a flaky upstream and is not. Its lifetime is bounded by MCP_SUBSCRIPTION_MAX_MS instead — and your reverse proxy needs a matching read timeout.

Elicitation ​

What the hub will carry when a server asks the person at the far end a question. Full behaviour: Elicitation.

VariableDefaultMeaning
MCP_ELICITATIONtrueThe whole feature. false is the emergency brake for every server at once; per server, use "passthrough": "off" in mcp.json.
MCP_ELICITATION_MAX_ROUNDS8How often one tool call may come back for more input. The hub keeps nothing between requests, so the count travels inside the sealed state. Each round is a fresh call the caller pays for.
MCP_ELICITATION_STATE_TTL_MS900000How long a half-finished call stays resumable.
MCP_ELICITATION_MAX_MESSAGE_BYTES4096One prompt, measured in bytes. It is read by a person; anything longer is not a prompt. Oversized text is truncated, not refused.
MCP_ELICITATION_MAX_PAYLOAD_BYTES131072The whole question including its schemas. Over this the call is refused rather than trimmed — there is no way to shorten a schema safely.

These five are read by the request path, so an unusable value logs and keeps the default.

Diagnostics ​

VariableDefaultMeaning
MCP_DIAGNOSTICSfalseAdds describe_connection to /hub, a seventh meta-tool that reports how the caller is connected. Off because every tool a client can see costs context in every conversation it has, and most deployments never need to ask. Read per request.

The switch is about context cost, not safety. The tool reports only what the caller's own request already carried and says nothing about any other client — which is why it exists in place of a tool that hands out the hub's log. That log carries upstream URLs, internal hostnames and text written by the children; serving it through /hub would let any registered connector read what every other connector is doing.

Subscriptions ​

What a client may watch, and how much of it the hub will hold open. See Subscriptions.

VariableDefaultMeaning
MCP_SUBSCRIPTIONStrueThe whole feature. false is the emergency brake for every server at once; per server, use "subscriptions": "off" in mcp.json.
MCP_MAX_SUBSCRIPTIONS1024Open subscriptions/listen streams one route will serve before refusing the next.
MCP_SUBSCRIPTION_MAX_URIS64Resource URIs one filter may name. Every URI is an upstream subscription the hub holds and reconciles, so an unbounded list is a way to make it do unbounded work on one request. Over this the call is refused with -32602.
MCP_SUBSCRIPTION_KEEPALIVE_MS15000SSE keepalive on a listen stream; 0 disables it.
MCP_SUBSCRIPTION_DEBOUNCE_MS250Coalescing window. Within it, repeats of the same event collapse to one — which is what the notification means anyway: read it again, not here is what changed. 0 delivers each event as it arrives.
MCP_SUBSCRIPTION_MAX_MS1800000How long one stream may stay open before the hub closes it; 0 means as long as the socket lives. A reaper for clients that go away without closing anything.
MCP_SUBSCRIPTION_MAX_URI_BYTES8192Longest URI a resources/updated notification may carry. A URI names what to re-read; a longer one is dropped with a warning (at most one a minute per route) instead of being held and forwarded.

These six are read by the request path as well, with the same fallback behaviour. A listen stream is charged to MCP_MAX_CONCURRENT_STREAMS, not MCP_MAX_CONCURRENT_REQUESTS.

An invalid value for any of the integer variables read at startup aborts with a clear message rather than silently falling back. The two call-timeout variables are the exception: they are read by the request path itself, so an unusable value logs and keeps the hardened default instead of taking the hub down.

Paths ​

VariableDefaultDescription
CONFIG_PATH/config/mcp.jsonThe mcpServers config file. Watched for changes — mount its directory (./config:/config:ro), not the file: a single-file bind mount misses rename-style editor saves and logs a startup warning.
DATA_PATH/dataJWT key, OAuth clients, approvals, refresh tokens. Must be persistent.
TOOL_CACHE_PATH<DATA_PATH>/tool-cache.jsonSnapshots (identity, capabilities, tool list) of on-demand servers, so they can boot into sleeping instead of warm-starting. Not writable → a startup warning and on-demand servers warm-start at every boot.
LOG_FILE(unset)Mirror every hub log line into this file with an ISO-8601 UTC prefix, in addition to the console. See fail2ban.
PORT80 in the image, 3000 otherwiseListen port.

DATA_PATH is also read by mcp-hub-admin, so the admin CLI needs it set to the same directory when run outside the container.

In stdio mode ​

--stdio (local clients) starts no listener and no authorization server, so most of the table above does not apply. What it reads:

VariableDefaultDescription
CONFIG_PATHmcp.json in the working directorySame file, same hot reload. A missing file starts an empty hub instead of failing — the client that spawned the process has nowhere to show a startup error.
IDLE_TIMEOUT_MINUTES60As above. Worth keeping on: a hub spawned per client session would otherwise start every configured server at every launch.
TOOL_CACHE_PATH.mcp-hub/tool-cache.json beside the configAs above, but there is no DATA_PATH here to derive it from.
DATA_PATH(unset)Optional here, unlike over HTTP. Point it at an HTTP hub's /data to reuse and refresh an upstream OAuth token authorized there. Without it, a server with an oauth block is skipped — there is no listener for a browser to return to.
LOG_FILE(unset)Same as above. Logging otherwise goes to stderr: in stdio mode stdout carries the protocol, so console.log output is moved out of the way.

Everything else — EXTERNAL_URL, PASSWORD*, TRUSTED_PROXIES, RESOURCE_BOUND_TOKENS, DEFAULT_RESOURCE, PORT, the rate limits and the HTTP timeouts — is HTTP-only and ignored. The call timeouts (MCP_CALL_TIMEOUT_MS and friends) apply, since they belong to the proxying path.

Full Compose example ​

yaml
environment:
  EXTERNAL_URL: "https://mcp.example.net"
  PASSWORD_HASH: "${PASSWORD_HASH}"
  TRUSTED_PROXIES: "192.168.1.0/24"

  MCP_BODY_LIMIT: "1mb"
  MCP_REQUESTS_PER_MINUTE: "120"
  MCP_MAX_CONCURRENT_REQUESTS: "4"
  MCP_MAX_CONCURRENT_STREAMS: "32"

  LOG_FILE: "/data/mcp-hub.log"

  # Referenced as ${…} inside mcp.json:
  PAPERLESS_API_TOKEN: "${PAPERLESS_API_TOKEN}"

Pass every ${VAR} your config references

A variable referenced in mcp.json may be empty, but if it is undefined the whole config fails to parse and no server starts. Declaring it in the Compose environment: block — with ${VAR:-} if it may be absent — avoids that.

Released under the MIT License. Not affiliated with Anthropic; “Claude” is a trademark of Anthropic PBC.