docs/configuration/upstream-servers.md
MCPProxy can connect to multiple MCP servers simultaneously, providing unified access through a single endpoint.
MCPProxy supports three types of upstream connections:
Local servers that communicate via standard input/output:
{
"name": "filesystem",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"],
"protocol": "stdio",
"enabled": true
}
Remote servers accessible via HTTP/HTTPS:
{
"name": "remote-server",
"url": "https://api.example.com/mcp",
"protocol": "http",
"enabled": true
}
Servers requiring OAuth 2.1 authentication:
{
"name": "github-server",
"url": "https://api.github.com/mcp",
"protocol": "http",
"oauth": {
"client_id": "your-client-id",
"scopes": ["repo", "user"]
},
"enabled": true
}
redirect_uriBy default mcpproxy asks the OS for a free loopback port on the first OAuth login, then persists that port and reuses it. If the saved port is taken next time, a different one is allocated — so the callback URL is not guaranteed to stay put.
Some providers require the port to match the registered callback URL exactly.
GitHub OAuth Apps are the common case: even with GitHub's wildcard matching
enabled, that matching covers subdomains and subdirectory paths — the host and
port must still match exactly. Set redirect_uri to pin it:
{
"oauth": {
"client_id": "Iv1.abc123",
"redirect_uri": "http://127.0.0.1:54108/oauth/callback"
}
}
mcpproxy binds that exact port and sends that exact string to the provider. Register the identical URL with the provider.
The value must be an RFC 8252 loopback redirect: http scheme, a loopback host,
an explicit port, and the /oauth/callback path. Prefer 127.0.0.1;
localhost is accepted but the listener binds 127.0.0.1, while
http://[::1]:PORT/oauth/callback binds the IPv6 loopback.
A malformed value, or a pinned port already in use, fails the login with an
error that names oauth.redirect_uri — in the connection error and the
server's health.detail, in mcpproxy upstream logs <name>, and in the main
log. The write surfaces (REST config API, Web UI, the upstream_servers MCP
tool) reject a bad value up front; hand-editing mcp_config.json bypasses that
by design, so a bad value already on disk is reported at connect time rather
than stopping the daemon from booting.
| Option | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique identifier for the server |
command | string | For stdio | Command to execute |
args | array | No | Command arguments |
url | string | For HTTP | Server URL |
protocol | string | Yes | stdio or http |
enabled | boolean | No | Whether server is active (default: true) |
working_dir | string | No | Working directory for stdio servers |
env | object | No | Environment variables to pass |
oauth | object | No | OAuth configuration |
auth_broker | object | No | Server-edition per-user token brokering. See Auth Broker. The authorization_endpoint key is required when auth_broker.mode is oauth_connect. |
health_check_interval | duration | No | Per-server override for the liveness ping cadence (0s disables; falls back to the global value, then the 30s default). No-op for Docker-isolated servers. |
tool_discovery_interval | duration | No | Per-server override for the tools/list re-index sweep (0s disables; falls back to the global value, then the 5m default). |
See Tool Discovery & Health Check Intervals for the global defaults, accepted ranges, and trade-offs.
Both HTTP headers and stdio env are first-class config fields you can
inspect and edit from the Web UI, the macOS tray, the CLI, and the REST
API. The wire format and semantics are identical across surfaces.
The REST API (GET /api/v1/servers), the SSE servers.changed event, and
the MCP upstream_servers list tool all redact sensitive header values
by default. The mask format is:
••••<last2> (<N> chars)
…e.g. a 71-character Bearer token whose last two characters are 59
appears on the wire as ••••59 (71 chars). The mask preserves enough
context to identify which token is in use without leaking the secret.
Values that are already secret references — ${keyring:NAME} or
${env:VAR} — pass through unchanged: they're labels, not secrets.
This redaction was added in PR #425 to close a real exfiltration path —
a prompt-injected agent calling upstream_servers list would otherwise
get back another upstream's Bearer token in plaintext.
To disable redaction (for debugging only), set reveal_secret_headers: true
in mcp_config.json. It's not normally needed: the editing flow
described below works without ever exposing the plaintext to the client.
The flag reveals raw values only to an authenticated admin, and only on
the REST and MCP read doors. It never applies to the /events SSE stream or
to upstream_stats, and GET /api/v1/config is denied to agent tokens
outright (issue #1167).
Since v0.64 (issue #1148) the flag additionally requires an authenticated
caller. /mcp is deliberately unprotected by default
(require_mcp_auth: false), so an unauthenticated MCP client is treated as
admin for backward compatibility — but that is not an identity, and it no
longer satisfies a check that hands back raw credentials. With the flag on,
raw values are returned to a caller presenting the API key or an admin agent
token, to a tray/socket connection (authenticated by OS-level socket
permissions), and over stdio; an unauthenticated TCP /mcp caller gets the
masked values. Nothing else changes: every other operation an unauthenticated
MCP client can perform today still works.
Argument vectors are masked too: a credential passed as --api-key sk-… in
args is masked by flag name, by value shape and — since the review round on
the same issue — by URL shape in every spelling (--flag <url>, --flag=<url>
and a bare positional token all mask ?token=… and https://user:pass@host).
For the keyed fields, a masked value echoed back on the write path is reverted to the stored value rather than persisted over it, and every revert is bound to where the value was read from:
| Field | Bound by |
|---|---|
env_json / headers_json | the map key |
url | the stored scheme and host:port (per query parameter, and the userinfo username) |
oauth_json → client_secret, client_id, redirect_uri | the field |
oauth_json → extra_params | the parameter name |
oauth_json → scopes, and any future oauth field | nothing — refused, never reverted |
args_json | nothing — an argv mask is refused, never reverted |
A mask that none of those bindings can reach is refused rather than written
through. For url that covers a URL whose scheme/host changed and a credential
that does not sit in a query parameter; for oauth_json it covers a scope (its
only context is a position in a caller-supplied slice) and any field added to
the oauth block later, which fails closed instead of silently persisting its own
mask.
args_json is different: an echoed mask is rejected with an error, not
restored. An argv token has no key to bind a secret to — only its index and
its neighbours — and args_json replaces the whole vector while the same patch
also chooses command. Every candidate binding is therefore caller-controlled:
an index can be picked, a preceding flag copied verbatim, and a byte-identical
argv means nothing once command moves from mcp-foo to curl. Any revert
rule would let a caller relocate a stored credential into a command line of its
own choosing.
So a write whose args_json still carries a mask this proxy rendered fails
with:
args_json[2] is a redaction placeholder, not an argument value: credential-shaped
argv tokens are masked on read and are never restored on write, because an argv
slot carries no key to bind the secret to. Resend the real value for that
argument, or omit args_json to leave the stored arguments unchanged
Do not build a read-modify-write loop that feeds a masked args list back in:
resend the real values, or omit args_json entirely when you are not
changing the arguments (omitting it leaves the stored vector untouched).
Rejecting is deliberate rather than silently keeping the stored vector —
args_json replaces the vector, so ignoring it would make the write look
applied when it was not.
The same contract applies on REST. GET /api/v1/servers masks args and
oauth.extra_params with the same rules (they are one shared implementation in
internal/oauth, so the two doors cannot drift), and POST/PATCH /api/v1/servers refuse an echoed args mask with 400 for the same reason the
MCP path does — args replaces the vector there too. The oauth block is
read-only over REST, so no REST write can echo its mask back.
The Server Detail page → Configuration tab has dedicated Headers and Environment Variables cards with per-row affordances:
+ Add header / + Add variable button at the top.${keyring:NAME}.The Convert flow works even on masked headers: the backend has the real
value in mcp_config.json and never needs the client to send it.
${keyring:…} references render as a labeled chip instead of a masked
literal, so converted headers are visually distinct.
# Upsert one or more headers
mcpproxy upstream patch synapbus --header "X-Trace: on"
# Rotate Authorization in place
mcpproxy upstream patch synapbus --header "Authorization: Bearer new-token"
# Delete one
mcpproxy upstream patch synapbus --header-remove "X-Stale"
# Mix set + delete in a single round-trip
mcpproxy upstream patch synapbus --header "X-New: v" --header-remove "X-Old"
# Env vars (stdio servers)
mcpproxy upstream patch obsidian-pilot --env "LOG_LEVEL=debug"
mcpproxy upstream patch obsidian-pilot --env-remove "OBSOLETE"
See Management Commands for the full flag reference.
PATCH /api/v1/servers/{name} follows JSON Merge Patch
(RFC 7396):
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"headers":{"X-New":"value","X-Stale":null}}' \
http://127.0.0.1:8080/api/v1/servers/synapbus
null value → deleteA client that sends the whole map back is safe too: a value that is exactly
the mask the read path rendered for that key is reverted to the stored secret,
bound to the key it was read from. What cannot be bound to a key is refused
with 400 rather than written through — args, oauth.scopes,
isolation.extra_args, a mask moved to a different key, and a URL mask whose
scheme/host changed. Resend the real value, or omit the field.
See REST API › PATCH for the
full reference, including the
/config-to-secret
endpoint that backs the "Convert to secret" affordance.
headers and env values support two reference shapes that get resolved
at request time rather than stored in plaintext:
| Reference | Source | Lifetime |
|---|---|---|
${keyring:NAME} | OS keyring entry called NAME | Persists across restarts; managed via the Secrets page or mcpproxy secrets CLI |
${env:VAR} | The VAR environment variable on the mcpproxy process | Tied to the shell/launcher that started mcpproxy |
When mcpproxy connects to an upstream it substitutes the reference with the actual secret. The reference itself never reaches the upstream MCP server. See Keyring Integration for the full secret-storage story.
For enhanced security, stdio servers can run in Docker containers:
{
"name": "isolated-server",
"command": "npx",
"args": ["-y", "some-mcp-server"],
"protocol": "stdio",
"isolation": {
"enabled": true,
"image": "node:20",
"network_mode": "bridge"
}
}
Note: Memory and CPU limits are configured at the global level in docker_isolation, not per-server.
See Docker Isolation for complete documentation.
When MCPProxy starts, it:
mcp_config.jsonWhen MCPProxy stops, it performs graceful shutdown of all subprocesses:
Process groups: Child processes (spawned by npm/npx/uvx) are placed in a process group, ensuring all related processes are terminated together.
See Shutdown Behavior for detailed documentation.
New servers added via AI clients are automatically quarantined for security review. See Security Quarantine for details.