Skip to content
erikenzPublic

About

One config file for all your MCP servers: vault-backed secrets, stdio for local editors, one authenticated HTTP endpoint for remote agents.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

mcp-ctl

CI License: MIT

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.

Requirements

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.

Install

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 | sh

Windows:

irm https://raw.githubusercontent.com/erikenz/mcp-ctl/main/install/install.ps1 | iex

Or with Homebrew on macOS or Linux:

brew install erikenz/tap/mcp-ctl

Or 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-ctl

Quick start

mcp-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": true

init 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.

Usage

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 binary

check 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).

Configuration

Servers

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.

Remote servers

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 status

A 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-key

Scopes 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.

Settings

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.

Secrets

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-password

A 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:

  1. 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.
  2. 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.
  3. Resolved secrets are never printed. -v redacts credential-like headers and query strings; --verbose-bodies is for debugging only. A secret placed in args is not redacted.
  4. Backends inherit the daemon's environment unless "env_clear": true is set. A value read from .env is not exported; a value you exported in the shell is visible to every backend. Use .env (or env_clear) when only one server should see it.
  5. One unavailable secret skips one server. If up cannot resolve a server's secret (a locked vault, a missing CLI, or a lookup that does not finish within server.secret_timeout_secs, 30 seconds by default), it logs a warning and serves the rest. An interactive mcp-ctl restart resolves secrets first and waits as long as the unlock takes. mcp-ctl check reports 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).

Profiles

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 running

The 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).

Starting and stopping one server

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 needs

Only 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_changed is never sent: reconnect or re-list on the client side.
  • server start re-reads the profile file, so a server added or edited since up starts with its new definition. Everything else — ports, timeouts, auth — is settled by the daemon at startup and still needs mcp-ctl restart. .env is read once at startup too, so a new secret there also needs a restart (mcp-ctl check reports 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:.

Serving over HTTP

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 port

Four 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. With auth.password or OAuth, keep server.bind loopback and let the tunnel target the proxy port. Only auth.bearer_token authenticates the hub itself.
  • mcp-auth-proxy (password or OAuth) without server.external_url is refused: sign-in callbacks would point at localhost. auth.bearer_token needs no external_url, because there is no callback. A public external_url must be https://; http:// is accepted only for loopback testing.
  • A tunnel with proxy auth must forward to the proxy port. Setting tunnel.port to the hub port (or anything else) is refused; with auth.bearer_token the hub itself is authenticated and may be the target, and --allow-unauthenticated-exposure overrides the refusal.
  • --config and --profile are remembered by a detached daemon, so mcp-ctl --profile work up -d and a later restart keep 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.

Starting on login (systemd user service)

# ~/.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.target
systemctl --user enable --now mcp-ctl.service

Connecting a client

Editors 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.

Updating

mcp-ctl update --check   # prints "mcp-ctl 0.1.0 → 0.2.0 is available", exits 1
mcp-ctl update           # installs it

update 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-ctl

Nothing checks for updates in the background, and status and check never touch the network.

Tray icon

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.

Security

  • The hub binds loopback and has no authentication of its own. auth.bearer_token is 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. The Host header is not trusted, so a hostname that resolves to 127.0.0.1 cannot 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.json in the state directory, owner-only, and are redacted in Debug output. 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 Debug output and never logged; the auth proxy receives them through the environment rather than the command line. A backend also inherits the daemon's environment unless env_clear is set, so prefer .env for a value only one server should see.
  • Only auth literals are refused in the profile file. A plain string in a server's env is for non-secret settings; a secret placed in args is part of the command line and is shown by list/check. Use .env, the environment or a vault.
  • .env is checked for owner-only permissions on Unix, and the state directory, log and files init writes are owner-only.
  • The hub caps concurrent connections and gives each request a deadline. auth.bearer_token must be at least 16 characters, and failed attempts are rate-limited.
  • update verifies 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.

Platforms

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.

Limitations

  • Tools are exposed; resources and prompts are not.
  • POST /mcp is 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. Use tools.exclude to 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 a headers credential 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 (and client_secret).
  • The daemon and a mcp-ctl auth command 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.

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md.

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --locked

Acknowledgements

mcp-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.

License

MIT

About

One config file for all your MCP servers: vault-backed secrets, stdio for local editors, one authenticated HTTP endpoint for remote agents.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages