One config file for all your MCP servers. mcp-ctl fills in secrets from your password manager, signs in to remote servers that want OAuth, runs a single server on stdio for a local editor, or serves everything you choose over one authenticated HTTP endpoint for remote agents.
| Dependency | Needed for |
|---|---|
rbw, op, or any command |
Secrets from Bitwarden, 1Password, pass or a script. |
mcp-auth-proxy |
OAuth 2.1 / PKCE / password sign-in for remote clients. Not needed when clients can send a bearer token; mcp-ctl starts and supervises it. |
tailscale or cloudflared |
An HTTPS tunnel to a public URL. |
Linux and macOS, with the install script (it verifies the release checksum before writing anything):
curl -fsSL https://raw.githubusercontent.com/erikenz/mcp-ctl/main/install/install.sh | shWindows:
irm https://raw.githubusercontent.com/erikenz/mcp-ctl/main/install/install.ps1 | iexOr with Homebrew on macOS or Linux:
brew install erikenz/tap/mcp-ctlOr take the archive for your platform from the latest release, check it against the .sha256 beside it, and put mcp-ctl somewhere on your PATH. The same release carries a .deb and a .rpm for Debian/Ubuntu and Fedora/RHEL.
With Rust 1.88 or newer (edition 2024):
cargo install mcp-ctlmcp-ctl init # write ~/.config/mcp-ctl/config.json and an owner-only .env
mcp-ctl check # validate it, resolve every secret, flag busy ports
mcp-ctl up # serve the servers marked "autostart": trueinit writes a single profile file holding both the servers and mcp-ctl's own settings:
{
"$schema": "https://raw.githubusercontent.com/erikenz/mcp-ctl/main/schema/config.schema.json",
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--browser-url=http://127.0.0.1:9222"],
"autostart": true
},
"github": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": { "source": "1password", "reference": "op://dev/github/token" }
}
}
},
"server": { "listen_port": 37373 },
"auth": { "password": { "source": "env", "name": "MCP_CTL_AUTH_PASSWORD" } }
}schema/config.schema.json gives editors completion and catches typos.
mcp-ctl list # what is configured
mcp-ctl check # validate config, resolve secrets, flag busy ports
mcp-ctl run <name> # run one server on stdio, for a local editor
mcp-ctl auth login <name> # sign in to a remote server that wants OAuth
mcp-ctl auth status # which remote servers have a usable token
mcp-ctl up # serve over HTTP; add -d for the background daemon
mcp-ctl server start <name> # start one server in the running hub, without a restart
mcp-ctl server stop <name> # stop it again; every other server keeps serving
mcp-ctl server restart <name> # stop, then start: what a crashed or wedged one needs
mcp-ctl status # what the service is doing (--json for scripts)
mcp-ctl logs -f # follow the daemon log
mcp-ctl restart # pick up config changes
mcp-ctl stop # stop the service and every backend
mcp-ctl update --check # is there a newer release?
mcp-ctl update # update in place, when the install script owns the binarycheck is the pre-flight step: it resolves every secret, reports where each value came from (never the value), and exits non-zero on a problem, which makes it usable in a script. --config <path> points one run at another profile file; -v logs requests with credentials redacted, and --verbose-bodies also logs payloads (debugging only).
| Key | Default | Meaning |
|---|---|---|
command / args / env |
— | A local stdio server. An env value may be a secret source, or a plain string for a non-secret setting. |
url |
— | A remote MCP server over Streamable HTTP. Served like a local one; OAuth is used when the server asks for it. |
headers |
— | Headers for a remote server, keyed by name. Values are secret sources. An Authorization header is used as given and turns OAuth off for that server. |
auth |
OAuth | {"type": "oauth", "scopes": [...], "client_id": ..., "client_secret": ...} or {"type": "none"}. |
serve |
false |
Whether mcp-ctl up starts this server without it being named on the command line. A server that is not started is not reachable at all: nothing is published until up, --all, serve: true or mcp-ctl server start brings it in. |
enabled |
true |
Set to false to keep an entry but ignore it everywhere. |
restart |
never |
on-failure restarts this server when it exits on its own, with backoff and a five-attempt cap. |
prefix |
— | Prefix this server's tool names, e.g. github_create_issue. |
timeout_secs |
— | Overrides server.tool_timeout_secs for this server; 0 waits forever, the maximum is 86400 (24 hours). |
tools.include / tools.exclude |
— | Publish only some of the server's tools. A pattern is a tool name or a prefix ending in *. |
env_clear |
false |
Start the server with an empty environment instead of inheriting mcp-ctl's. |
cwd |
— | Working directory for a stdio server. |
Keys mcp-ctl does not know are ignored inside a server entry, because the same file is also read by other MCP clients, so a snippet copied from a server's README works as it is. Top-level keys are strict, so "proxy_portt" is reported instead of silently ignored.
An entry with a url is served over Streamable HTTP, exactly like a local one: its
tools appear on the same endpoint, with the same prefixes and filters. There is nothing
provider-specific about it, and mcp-ctl has no allowlist of servers: anything that
speaks MCP over Streamable HTTP works.
"some-remote-server": { "url": "https://mcp.example.com/mcp", "autostart": true }Signing in is local. When a remote server answers 401, it advertises where to
authenticate. mcp-ctl auth login <name> follows that chain (Protected Resource
Metadata, then the authorization server's metadata), registers a client with dynamic
client registration, and runs the authorization code flow with PKCE and the resource
parameter. The redirect URI is a loopback listener on 127.0.0.1, so nothing about
your machine, profile or hub has to be reachable from the internet. Tokens land in the
state directory, owner-only; auth status shows what is stored, auth logout forgets
it.
mcp-ctl auth login some-remote-server # opens a browser, waits for the callback
mcp-ctl auth statusA refresh token is used automatically before an expired access token, and once more
after a 401.
auth.scopes and --scopes are optional and provider-neutral: they are sent as the
OAuth scope parameter, so the names are whatever that server publishes, not something
mcp-ctl defines. With neither, no scope is requested and the server decides.
"some-remote-server": {
"url": "https://mcp.example.com/mcp",
"auth": { "type": "oauth", "scopes": ["read:things"] }
}A server that only takes an API key needs no sign-in. Give it a headers entry
whose value comes from a vault or .env — a literal key in the profile file is refused
for auth and warned about here, for the same reason. The value is sent verbatim, so
it keeps the scheme the server documents:
"some-keyed-server": {
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": { "source": "env", "name": "EXAMPLE_AUTH_HEADER" } }
}# .env, next to the profile file
EXAMPLE_AUTH_HEADER=Bearer your-api-keyScopes are the server's limit, not a local filter: mcp-ctl publishes every tool
the server lists, whether or not the granted token could actually use it. A read-only
consent is only as strong as the provider's enforcement — one provider tested here
lists write tools and accepts a write call with a read-only token. To bind the agent
locally, filter the tools:
"some-remote-server": {
"url": "https://mcp.example.com/mcp",
"tools": { "exclude": ["create_*", "delete_*", "update_*"] }
}Remote servers also work on stdio: mcp-ctl run some-remote-server bridges one to
stdin/stdout, for editors that do not speak HTTP.
Every key is optional. Out of the box mcp-ctl serves "autostart": true entries on 127.0.0.1, with no tunnel and no authentication. ("serve" is accepted as a deprecated synonym for "autostart".)
| Key | Default | Meaning |
|---|---|---|
server.bind |
127.0.0.1 |
Address the hub binds to. It has no authentication of its own; auth.bearer_token is the only thing that protects this port. A non-loopback bind with proxy-only auth is refused. |
server.listen_port |
37373 |
Hub port. 0 asks the operating system for a free one. |
server.external_url |
— | Public URL clients use, for OAuth callbacks and browser origins. Required once mcp-auth-proxy is enabled; Tailscale can supply it. Must be https:// unless it is a loopback URL for local testing. |
server.tool_timeout_secs |
300 |
How long one backend tool call may take; 0 waits forever, the maximum is 86400 (24 hours). |
server.secret_timeout_secs |
30 |
How long one external secret lookup may take while starting; a timeout skips that server. 0 waits forever, the maximum is 86400 (24 hours). |
server.allowed_origins |
[] |
Extra browser origins allowed to call the hub. Origins on a host mcp-ctl is served on are allowed without being listed. |
proxy.enabled |
auto |
Whether mcp-auth-proxy is enabled. By default (auto), it runs if password or OAuth authentication is configured in auth. Set to false to disable the auth proxy even if credentials exist, or true to require it. |
proxy.port |
8080 |
Port the auth proxy listens on. 0 is not supported: the tunnel target and status need the real port. (server.proxy_port is accepted as a deprecated synonym.) |
proxy.path |
— | Where mcp-auth-proxy lives, if it is not on PATH or in ~/.local/bin. (server.auth_proxy_path is accepted as a deprecated synonym.) |
proxy.external_url |
— | Public URL clients use to reach the auth proxy. (Can also be set as server.external_url.) |
tunnel.provider |
none |
none, tailscale or cloudflared. |
tunnel.auto_start |
true |
Start the tunnel when a provider is configured. |
tunnel.port |
— | Local port the tunnel forwards to. Defaults to the auth proxy's port, or the hub's. With proxy auth it must be the proxy port; a tunnel aimed at the unauthenticated hub is refused. |
auth.password |
— | Password for the auth proxy. Any secret source works. |
auth.github_client_id / _secret |
— | GitHub OAuth credentials for the auth proxy. Must be set together. |
auth.google_client_id / _secret |
— | Google OAuth credentials for the auth proxy. Must be set together. |
auth.bearer_token |
— | Static Authorization: Bearer ... token checked by the hub. No proxy and no external_url, but no sign-in either; cannot be combined with password or the OAuth settings. Use at least 16 characters of random data. |
tray.enabled |
false |
Show the tray icon while mcp-ctl up runs. |
A secret is resolved when a server starts and handed to that server's environment. It is never written to disk and never logged. An env value is a plain string when the value is not secret, or a source when it is:
{
"env": {
"LOG_LEVEL": "info",
"GITHUB_TOKEN": { "source": "env", "name": "GITHUB_TOKEN" }
}
}| Provider | Example | Notes |
|---|---|---|
| Bitwarden | {"source": "bitwarden", "item": "sentry", "field": "access_token"} |
Uses rbw. Falls back to KEY=value lines in the item's notes. |
| 1Password | {"source": "1password", "reference": "op://dev/github/token"} |
Uses op read. |
| Command | {"source": "command", "run": "pass", "args": ["show", "api/key"]} |
Uses the command's trimmed stdout. |
| Environment | {"source": "env", "name": "GITHUB_TOKEN"} |
Reads the environment, or .env next to the profile (the shell wins). |
Secrets that live in a file belong in .env next to the profile, which mcp-ctl reads itself and never exports to the servers it runs. init creates it 0600, and on Unix mcp-ctl refuses to read a .env that other local users can read:
# ~/.config/mcp-ctl/.env
MCP_CTL_AUTH_PASSWORD=choose-a-strong-passwordA password or token must not be written out in the profile file. That file is one copy-paste away from being shared or committed, so mcp-ctl refuses a literal for auth and points at .env, a vault or the environment. There is no {"source": "literal"}. A plain string in a server's env is for settings that are not secret; mcp-ctl cannot tell whether a value is a credential, and check warns when the name looks like one. A secret placed in a server's args is part of the command line and is shown by list and check; use env instead.
Five rules worth knowing:
- What the config says wins. A variable exported in your shell does not shadow a configured source; use
{"source": "env"}to read the environment on purpose. - An empty result is an error. An empty API key is almost always a locked vault or a mistyped command; a plain value is taken as it is.
- Resolved secrets are never printed.
-vredacts credential-like headers and query strings;--verbose-bodiesis for debugging only. A secret placed inargsis not redacted. - Backends inherit the daemon's environment unless
"env_clear": trueis set. A value read from.envis not exported; a value you exported in the shell is visible to every backend. Use.env(orenv_clear) when only one server should see it. - One unavailable secret skips one server. If
upcannot resolve a server's secret (a locked vault, a missing CLI, or a lookup that does not finish withinserver.secret_timeout_secs, 30 seconds by default), it logs a warning and serves the rest. An interactivemcp-ctl restartresolves secrets first and waits as long as the unlock takes.mcp-ctl checkreports every problem before you start.
Environment overrides, useful in scripts and editors: MCP_CTL_CONFIG_DIR (where profiles and .env live), MCP_CTL_PROFILE (which profile to use), MCP_CTL_STATE_DIR (where the daemon state and log live).
A profile is a separate installation: its own file, settings, daemon and ports. One for work, one for personal servers, either stopped without touching the other.
mcp-ctl --profile work init # writes ~/.config/mcp-ctl/config.work.json
mcp-ctl --profile work up -d # serve it in the background
mcp-ctl status # every profile: running or not
mcp-ctl status work # just that one, in detail
mcp-ctl --profile work stop # stop it; other profiles keep runningThe default profile keeps the paths above, so a single-profile installation is unchanged. A named profile keeps its daemon state under profiles/<name>/, with its own lock and log, which is what lets two daemons run at once. Give each profile its own listen_port and proxy_port, or start it with --port 0. MCP_CTL_PROFILE=work does the same for editors that cannot pass flags, and a profile name is one path-safe segment (../escape is rejected).
A server with "autostart": false (or without "autostart") is configured but not started by up. Start it when you
need it, and stop it again when you are done:
mcp-ctl server start oncall # its tools appear on the hub
mcp-ctl server stop oncall # gone again; the other servers never noticed
mcp-ctl server restart oncall # stop, then start: what a wedged server needsOnly that backend changes. The hub keeps its port and the servers it already had, so
their clients keep working and no tool call in flight is dropped (mcp-ctl status
confirms it: the hub PID is unchanged). This is the cheap alternative to flipping
"enabled" and restarting the profile, which stops everything it serves.
One thing to be clear about: there is no "running but hidden" state. A server is either
not started (so it is not reachable through the hub at all) or started and published on
/mcp — publishing is what the hub is for. Keeping a dozen entries in the profile with
"autostart": false exposes none of them; mcp-ctl server start is the switch that brings
one in. Reaching a single server without the hub is mcp-ctl run <name> on stdio, and
reaching different sets of servers with different authentication is a separate profile.
Two limits worth knowing:
- A client that already listed tools will not see the new ones until it lists again.
mcp-ctl has no session and no event stream, so
notifications/tools/list_changedis never sent: reconnect or re-list on the client side. server startre-reads the profile file, so a server added or edited sinceupstarts with its new definition. Everything else — ports, timeouts, auth — is settled by the daemon at startup and still needsmcp-ctl restart..envis read once at startup too, so a new secret there also needs a restart (mcp-ctl checkreports it first).
A backend that crashes on its own already behaves like server stop: its tools are
withdrawn and the rest keep serving. "restart": "on-failure" brings it back without
help — one second, then doubling, at most five attempts in a row, and the count resets
once it has stayed up for a minute. Without that key the server stays down until
mcp-ctl server start <name>. mcp-ctl status lists a stopped server as (stopped), a
crashed one as (exited), and one that was never started under Idle:.
mcp-ctl up # every server with "autostart": true, in the foreground
mcp-ctl up chrome-devtools sentry # only these
mcp-ctl up --all # every enabled server
mcp-ctl up --no-tunnel --no-auth # local only
mcp-ctl up --port 0 # let the OS pick a free portFour rules are enforced rather than warned about:
- Publishing an unauthenticated hub, through a tunnel or a non-loopback
server.bind, is refused unless you pass--allow-unauthenticated-exposure. The auth proxy does not count as hub authentication: it listens on loopback and protects the tunnel in front of it, while the hub port itself stays open. Withauth.passwordor OAuth, keepserver.bindloopback and let the tunnel target the proxy port. Onlyauth.bearer_tokenauthenticates the hub itself. mcp-auth-proxy(password or OAuth) withoutserver.external_urlis refused: sign-in callbacks would point at localhost.auth.bearer_tokenneeds noexternal_url, because there is no callback. A publicexternal_urlmust behttps://;http://is accepted only for loopback testing.- A tunnel with proxy auth must forward to the proxy port. Setting
tunnel.portto the hub port (or anything else) is refused; withauth.bearer_tokenthe hub itself is authenticated and may be the target, and--allow-unauthenticated-exposureoverrides the refusal. --configand--profileare remembered by a detached daemon, somcp-ctl --profile work up -dand a laterrestartkeep serving the work profile.
A background service logs to ~/.local/state/mcp-ctl/mcp-ctl.log on Linux, ~/Library/Application Support/mcp-ctl/ on macOS and %LOCALAPPDATA%\mcp-ctl\ on Windows; mcp-ctl status prints the exact path. The log rotates at 5 MiB when a daemon starts, so a service that runs for months should also be rotated by the system. mcp-ctl stop --force kills a daemon that answers the control channel but will not exit; mcp-ctl never signals a process it cannot identify.
On Linux, up -d starts the daemon in its own transient systemd scope (systemd-run --user --scope), so it is not part of the terminal's cgroup. Closing that terminal — or systemd-oomd killing its scope under memory pressure — no longer stops the service. The scope inherits your environment and working directory, so PATH and relative server paths behave exactly as before; systemctl --user list-units 'mcpctl-*' shows it while it runs. Without a usable systemd user manager, mcp-ctl falls back to a plain detached process and prints a warning. The systemd unit below is still the way to start the service on login and have it restarted automatically.
A tunnel forwards to tunnel.port. With proxy auth, leave it unset so the tunnel reaches the proxy; pointing it at the hub would publish the unauthenticated hub and is refused. Tailscale can name its own public URL, which mcp-ctl reads from tailscale status --json; a Cloudflare quick tunnel picks a random hostname, which mcp-ctl reads from its output and status keeps showing.
# ~/.config/systemd/user/mcp-ctl.service
[Unit]
Description=mcp-ctl MCP hub
After=network-online.target
[Service]
ExecStart=%h/.local/bin/mcp-ctl up
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.targetsystemctl --user enable --now mcp-ctl.serviceEditors that speak MCP over stdio can run one server through mcp-ctl run:
{ "command": "mcp-ctl", "args": ["run", "github"] }mcp-ctl run <name> works for a remote server too: it bridges the HTTP endpoint to
stdio, with the same OAuth token the hub uses. Or take the whole hub through
mcp-remote:
{ "command": "npx", "args": ["-y", "mcp-remote", "http://127.0.0.1:37373/mcp"] }The key differs per editor (context_servers in Zed, mcpServers in Cursor and Claude Desktop). To pin a profile, put MCP_CTL_PROFILE in that entry's environment.
To reach the hub from another machine, give clients server.external_url with /mcp on the end and pick a way to sign in:
auth setting |
What a client does |
|---|---|
bearer_token |
Sends Authorization: Bearer .... No proxy and no external_url, but no sign-in either. |
password |
Signs in with a password through mcp-auth-proxy. |
github_client_id / _secret, google_client_id / _secret |
Signs in with a GitHub or Google account through mcp-auth-proxy. |
The auth proxy receives its secrets through the environment rather than its command line, and keeps its state in the mcp-ctl state directory.
mcp-ctl update --check # prints "mcp-ctl 0.1.0 → 0.2.0 is available", exits 1
mcp-ctl update # installs itupdate replaces the binary only when the install script put it there — ~/.local/bin/mcp-ctl. Every other copy is left alone and told to be updated the way it was installed: a cargo install, a .deb/.rpm or a binary you moved by hand belongs to another tool. On Windows it points at install.ps1, because replacing a running .exe is not reliable.
When a daemon is running, an interactive mcp-ctl update asks whether to restart it (or every running profile) on the new version. The prompt is skipped when stdin is not a terminal; run mcp-ctl restart in that case. The restart keeps the profile and profile file, but not extra up flags — repeat those with mcp-ctl restart if you used them.
An interactive mcp-ctl restart resolves the selected servers' secrets before stopping the old daemon, so a locked vault prompts while the service is still up. Without a terminal, startup keeps its server.secret_timeout_secs bound and skips a server that does not answer in time.
The download is checked against the .sha256 published beside it. That catches corruption and a network attacker who cannot alter both files, but it is not a signature: an attacker who can replace the release assets can replace the checksum too. For a stronger check, verify the build provenance the release workflow attaches:
gh attestation verify mcp-ctl-<version>-<target>.tar.gz --repo erikenz/mcp-ctlNothing checks for updates in the background, and status and check never touch the network.
With "tray": { "enabled": true } an icon appears while mcp-ctl up runs: a line per server, the hub and public URLs, Restart service and Shut down. On Linux it is a StatusNotifierItem, so it needs a tray host (KDE Plasma, waybar, Quickshell, XFCE, GNOME with the AppIndicator extension); with none listening the icon stays hidden and the service keeps running. macOS and Windows use the platform's own status item.
- The hub binds loopback and has no authentication of its own.
auth.bearer_tokenis the only thing that authenticates the hub port; the auth proxy protects the tunnel in front of it, not the hub. A non-loopback bind with proxy-only auth, or a tunnel aimed at the hub, is refused. - Browser requests are checked against their
Origin. TheHostheader is not trusted, so a hostname that resolves to127.0.0.1cannot call your tools. The port is not compared, but the public URL's scheme is enforced at every port. - Remote sign-in tokens live in
oauth.jsonin the state directory, owner-only, and are redacted inDebugoutput. They are the same bearer credentials your browser holds, so treat that file as you would a password. - Secrets are resolved when a server starts and handed to that child. They are redacted in
Debugoutput and never logged; the auth proxy receives them through the environment rather than the command line. A backend also inherits the daemon's environment unlessenv_clearis set, so prefer.envfor a value only one server should see. - Only
authliterals are refused in the profile file. A plain string in a server'senvis for non-secret settings; a secret placed inargsis part of the command line and is shown bylist/check. Use.env, the environment or a vault. .envis checked for owner-only permissions on Unix, and the state directory, log and filesinitwrites are owner-only.- The hub caps concurrent connections and gives each request a deadline.
auth.bearer_tokenmust be at least 16 characters, and failed attempts are rate-limited. updateverifies the release checksum but not a signature; see Updating for the attestation check.
SECURITY.md has the threat model and how to report a vulnerability privately.
Linux, macOS and Windows build and test in CI. Linux is the reference platform; macOS and Windows are beta, and have not been exercised on real hardware yet — please report what breaks.
- Tools are exposed; resources and prompts are not.
POST /mcpis the only method on the endpoint: no sessions, no server-sent stream.- Tool-call progress notifications are not relayed yet.
- A granted OAuth scope is not enforced by
mcp-ctl: every tool the server lists is published. Whether a write really happens is the provider's decision. Usetools.excludeto cap what the agent can call. - OAuth sign-in needs a browser on the same machine as
mcp-ctl, because the callback is a loopback listener. Device flow is not implemented yet, so a headless box signs in with aheaderscredential instead. - Servers that require a client ID metadata document and offer no dynamic registration cannot be used yet; register a client yourself and set
auth.client_id(andclient_secret). - The daemon and a
mcp-ctl authcommand share the token store without a lock. Each replaces it atomically and re-reads it just before writing, but running both at the same instant can still lose one update.
Issues and pull requests are welcome — see CONTRIBUTING.md.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --lockedmcp-ctl was inspired by mcp-hub — the single endpoint in front of many servers, and the default port 37373, come from there. Sign-in for your own clients runs on mcp-auth-proxy, which mcp-ctl supervises rather than reimplements. Signing in to remote servers is mcp-ctl's own OAuth 2.1 client (src/oauth.rs), so a remote server never needs a public callback URL.