Skip to content

Multi-upstream aggregation

A single tunnel can front more than one MCP server. The CLI runs an MCP-native aggregator that fans every request out to the right backend and presents all of them to the agent as one coherent server. Tools, prompts and resources are namespaced by the upstream they came from, so two backends can both expose a read_file tool without colliding.

Use this when you want one public URL — and one set of credentials, one IP allowlist, one auth policy — to cover a filesystem server, a database server, a search server, and so on.

The aggregator prefixes every identifier with the upstream’s name:

  • Tools and prompts become <upstream>__<original> — for example filesystem__read_file. The separator is a double underscore (__), chosen because a single _ is common inside MCP tool names. De-prefixing splits on the first __, so an original name that itself contains __ is preserved verbatim in the tail.
  • Resource URIs are wrapped in their own scheme, mcppipe://<upstream>/<base64url(original-uri)>. The rewritten URI is a valid URI the agent can round-trip back, so there is no side-table to keep in sync.

tools/list, prompts/list and resources/list are fanned out and merged; tools/call, prompts/get and resources/read are routed back to the single upstream named in the prefix. initialize is fanned out and the upstreams’ capabilities are merged, negotiating the lowest common protocol version.

Upstream names follow the same shape rule as tunnel subdomains: ^[a-z][a-z0-9_-]{0,31}$ — lowercase ASCII, must start with a letter, up to 32 characters, hyphens and underscores allowed. The reserved separator __ may not appear in a name. Names must be unique within a tunnel; a duplicate is rejected loudly (otherwise one upstream’s tools would silently shadow the other’s).

Pass --upstream or --stdio more than once, and give each one a name= prefix. You can mix HTTP and stdio backends freely:

Terminal window
mcppipe up \
--upstream search=http://127.0.0.1:3001 \
--stdio filesystem="npx -y @modelcontextprotocol/server-filesystem ." \
--api-key <your-tunnel-api-key>

Rules the CLI enforces:

  • A single bare --upstream <url> (or --stdio <cmd>) keeps the single-upstream hot path and needs no name. A single --upstream and a single --stdio together is still rejected as ambiguous.
  • As soon as you pass two or more sources, every one must use name=... syntax — a bare value with no name errors out.
  • --upstream and --stdio may each be repeated and combined; the total set becomes the aggregator layout.

See the CLI reference for the full flag list, including --metrics-addr (Prometheus counters for recoveries and fan-out).

For larger layouts, keep the list in a file and pass --upstreams-file. It conflicts with --upstream / --stdio — use one form or the other.

Terminal window
mcppipe up --upstreams-file ./tunnel.toml --api-key <your-tunnel-api-key>
# tunnel.toml — one [upstreams.<name>] section per backend.
# Each section sets exactly one of `http` or `stdio`.
[upstreams.filesystem]
stdio = "npx -y @modelcontextprotocol/server-filesystem ."
[upstreams.search]
http = "http://127.0.0.1:3001"

The section key is the upstream name (it must validate against the name rules above). Setting both http and stdio — or neither — in a section is an error, as is an empty file with no [upstreams.*] sections. A file with a single upstream collapses to the single-upstream path automatically.

If you run mcppipe up with no source flags, the CLI detects MCP servers configured in your local clients. When several are found:

  • In a terminal (TTY), it asks whether to expose one or aggregate several, then lets you multi-select which backends to include.
  • Headless (CI, no TTY), it brings up all detected servers under the aggregator and prints a warning so you can pin an explicit set with --upstream name=url if the auto-picked list is wrong.

Detected client keys are sanitized into valid upstream names automatically (lowercased, illegal characters replaced, __ collapsed, prefixed with srv- if they would otherwise start with a non-letter).

Each upstream has an independent state machine, so one failing backend never takes down the others — calls to a degraded upstream fail fast while the rest keep serving. The four phases are:

PhaseMeaning
connectingThe first initialize hasn’t completed yet.
availableLast call succeeded; the aggregator routes to it.
recoveringA burst of errors tripped recovery; calls to this upstream short-circuit with a -32000 upstream_unavailable JSON-RPC error while a background loop retries with backoff.
failedRecovery was exhausted; auto-retry stops. A fresh initialize from the agent (or a dashboard reconnect) moves it back to connecting.

An upstream trips into recovering after a short burst of consecutive errors (3 within 30s). The recovery loop retries with doubling backoff up to 30s between attempts, and gives up — moving to failed — once it hits either limit: MCPPIPE_UPSTREAM_MAX_ATTEMPTS (default 20 retries) or MCPPIPE_UPSTREAM_FAILED_AFTER_SECS (default 300, i.e. 5 minutes of downtime), whichever comes first. When recovery succeeds, the upstream returns to available and the transition records how long it was down.

Every transition is published on the tunnel’s upstream.state_changed event, which the dashboard streams live and which you can subscribe to as a webhook.

A tunnel’s Settings tab (one of the four tabs: Live, Inspector, Tools, Settings) shows an Upstreams section listing every backend the CLI is multiplexing. It is hidden for single-upstream tunnels — there is nothing to aggregate. Each row shows the upstream name, its current phase, the time of the last change, and the last error (if any), and it updates live over SSE as the aggregator flips an upstream between recovering and available.

Dashboard Upstreams section listing each aggregated MCP server with its current phase. The Upstreams section in a tunnel’s Settings tab — one row per backend, with live phase badges.

The read surface is GET /api/tunnels/:id/upstreams for the current snapshot and GET /api/tunnels/:id/upstreams/stream for the SSE live tail. Both are owner-scoped and return an empty list for single-upstream tunnels.

Because tool names carry the <upstream>__<tool> prefix on the wire, the Live tab and Inspector let you filter traffic by upstream, and the Tools tab groups per-tool stats and pins by their originating upstream.

Inspector showing aggregated tool calls with their upstream__tool prefixes. Aggregated calls appear with their upstream__tool names, so you can tell at a glance which backend served each request.

  • The public URL is unchanged by aggregation: agents still connect to https://<subdomain>--<account-slug>.<zone>/mcp over Streamable HTTP.
  • Governance applies across the merged surface: a tool policy or rate limit targets the prefixed name (filesystem__read_file), and auth and IP allowlists are enforced once by mcppipe, in front of all upstreams.
  • Zero-knowledge mode routes raw bytes and disables JSON-RPC parsing, so it cannot prefix or fan out — it is not compatible with the aggregator. Use it on a single-upstream tunnel.