Back to Mcpproxy Go

Upstream Servers

docs/configuration/upstream-servers.md

0.64.014.1 KB
Original Source

Upstream Servers

MCPProxy can connect to multiple MCP servers simultaneously, providing unified access through a single endpoint.

Server Types

MCPProxy supports three types of upstream connections:

stdio Servers

Local servers that communicate via standard input/output:

json
{
  "name": "filesystem",
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"],
  "protocol": "stdio",
  "enabled": true
}

HTTP Servers

Remote servers accessible via HTTP/HTTPS:

json
{
  "name": "remote-server",
  "url": "https://api.example.com/mcp",
  "protocol": "http",
  "enabled": true
}

OAuth Servers

Servers requiring OAuth 2.1 authentication:

json
{
  "name": "github-server",
  "url": "https://api.github.com/mcp",
  "protocol": "http",
  "oauth": {
    "client_id": "your-client-id",
    "scopes": ["repo", "user"]
  },
  "enabled": true
}

Pinning the callback port with redirect_uri

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

json
{
  "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.

Configuration Options

OptionTypeRequiredDescription
namestringYesUnique identifier for the server
commandstringFor stdioCommand to execute
argsarrayNoCommand arguments
urlstringFor HTTPServer URL
protocolstringYesstdio or http
enabledbooleanNoWhether server is active (default: true)
working_dirstringNoWorking directory for stdio servers
envobjectNoEnvironment variables to pass
oauthobjectNoOAuth configuration
auth_brokerobjectNoServer-edition per-user token brokering. See Auth Broker. The authorization_endpoint key is required when auth_broker.mode is oauth_connect.
health_check_intervaldurationNoPer-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_intervaldurationNoPer-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.

Headers, Environment Variables, and Secrets

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.

How the API displays them

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:

FieldBound by
env_json / headers_jsonthe map key
urlthe stored scheme and host:port (per query parameter, and the userinfo username)
oauth_jsonclient_secret, client_id, redirect_urithe field
oauth_jsonextra_paramsthe parameter name
oauth_jsonscopes, and any future oauth fieldnothing — refused, never reverted
args_jsonnothing — 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.

How you edit them

Web UI / macOS tray

The Server Detail page → Configuration tab has dedicated Headers and Environment Variables cards with per-row affordances:

  • Add+ Add header / + Add variable button at the top.
  • Edit — pencil icon turns the value cell into an input. Save / Cancel.
  • Delete — trash icon. Confirms first.
  • Convert to secret — lock icon. Opens a modal asking for a name, then atomically moves the value into the OS keyring and replaces the config field with ${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.

CLI

bash
# 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.

REST API

PATCH /api/v1/servers/{name} follows JSON Merge Patch (RFC 7396):

bash
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
  • non-null string value → upsert
  • JSON null value → delete
  • key absent from body → preserve (this is how the UI sends a diff against the masked view without overwriting unchanged values)

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

Secret references

headers and env values support two reference shapes that get resolved at request time rather than stored in plaintext:

ReferenceSourceLifetime
${keyring:NAME}OS keyring entry called NAMEPersists across restarts; managed via the Secrets page or mcpproxy secrets CLI
${env:VAR}The VAR environment variable on the mcpproxy processTied 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.

Docker Isolation

For enhanced security, stdio servers can run in Docker containers:

json
{
  "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.

Process Lifecycle

Startup

When MCPProxy starts, it:

  1. Loads server configurations from mcp_config.json
  2. Creates MCP clients for each enabled, non-quarantined server
  3. Connects to servers in the background (async)
  4. Indexes tools once connections are established

Shutdown

When MCPProxy stops, it performs graceful shutdown of all subprocesses:

  1. Graceful Close (10s): Close MCP connection, wait for process to exit
  2. Force Kill (9s): If still running, SIGTERM → poll → SIGKILL

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.

Quarantine System

New servers added via AI clients are automatically quarantined for security review. See Security Quarantine for details.