Aggregating MCP proxy

snodo_proxy serves one MCP endpoint backed by several MCP servers. It connects to each backend with Snodo.Client, merges their tools, resources, resource templates, and prompts, and routes requests to the backend that owns each item. It runs alongside snodo without changing the core router.

Install

{:snodo_proxy, "~> 0.4.4"}

Start and serve

Start the proxy under your application supervisor, then create a runtime for the transport you want to expose:

children = [
  {Snodo.Proxy,
   backends: [
     [id: "search", target: {:http, "http://127.0.0.1:4001/mcp"}],
     [id: "files", target: {:stdio, "file-server", []}]
   ],
   name: MyApp.Proxy}
]

{:ok, _supervisor} = Supervisor.start_link(children, strategy: :one_for_one)
proxy = Process.whereis(MyApp.Proxy)

runtime = Snodo.Proxy.runtime(proxy, authorization: MyApp.Policy)
{:ok, listener} = Snodo.Transport.StreamableHTTP.Server.start_link(runtime: runtime, port: 4000)

Each target accepts the same HTTP, stdio, or custom transport form as Snodo.Client.connect/2. {:direct, runtime} is available for an in-process server. client_options: passes options to the backend client. The proxy serves MCP 2026-07-28 to frontends; its backend clients negotiate the versions their targets support. The frontend listener and its authentication are application owned. If your application restarts the proxy supervisor itself, rebuild the runtime and restart its frontend listener with the new proxy PID. Internal manager or hub restarts keep the same proxy PID.

Backend IDs contain letters, digits, _, or - and start with a letter. Names default to <id>.<backend-name>; set prefix: on a backend to change that. The proxy rejects duplicate tool and prompt names and exact URI collisions when a backend is added; resources and templates are identified by URI, so their names may repeat. Resource URIs are always exposed as mcp-proxy://<id>/<original-uri> to preserve ownership, including for resource templates. A caller must use the advertised URI when reading through the proxy. The prefix does not change the backend's own name or URI.

Runtime changes and status

:ok = Snodo.Proxy.add_backend(proxy, id: "notes", target: {:http, notes_url})
:ok = Snodo.Proxy.refresh_backend(proxy, "notes")
:ok = Snodo.Proxy.remove_backend(proxy, "notes")
status = Snodo.Proxy.health(proxy)

add_backend/2 connects and validates the new catalog before publishing it. refresh_backend/2 checks one backend immediately. The proxy also refreshes on backend list change notifications and probes every backend every 30 seconds by default. A disconnected backend is retried every second. health/1 gives each backend an :up, :degraded, or :down status and its latest error. Clients can request an aggregate proxy/health status if they negotiate the dev.snodo/proxy extension; backend IDs and error details stay in the local health/1 API. A failed catalog refresh keeps the last accepted snapshot and marks the backend degraded.

The proxy forwards tool progress and multi round-trip input responses in the original request. Each request to a 2026-07-28 backend carries the frontend caller's client capabilities, so a backend input request reaches a caller that declared the matching capability and is refused for one that did not. An initialize-era backend fixes its capabilities at initialize from client_options:, so callers' capabilities are not forwarded to it.

The proxy advertises "logging" and forwards a backend's notifications/message to the caller in the original request, before its result. A request that sets _meta["io.modelcontextprotocol/logLevel"] passes that level to a 2026-07-28 backend, and the proxy drops any message below it; a request without a level gets no messages. An initialize-era backend sets its level per connection with logging/setLevel, which the proxy does not send, so its messages are filtered by the caller's level only. List results are never cached (ttlMs: 0, cacheScope: "private"). With an authorization policy configured, forwarded reads are always private, because the proxy filters by caller. It forwards backend list changes and resource update notifications through its subscription hub. Resource links in tool and prompt content are rewritten when the target is in the merged catalog. Resource subscriptions are admitted only for exact catalog entries accepted by the upstream listener and allowed by the configured authorization policy. Both conditions are checked again before each update is delivered. Authorization also filters discovery and checks calls, reads, and gets. The backend's own policy still applies when a request is forwarded.

Stdio backend calls, reads, and prompt requests run one at a time per connection so log messages stay with their request. Waiting requests acquire the connection in FIFO order as soon as the previous caller releases it. Health probes and catalog refreshes join this queue only when the backend's client_options: includes on_log:. Otherwise they run concurrently and cannot capture forwarded logs. While a background request waits, the backend process also defers handling catalog refresh messages. A health probe whose queue wait expires treats the connection as busy and schedules another probe without closing it. A background catalog refresh whose queue wait expires keeps the last snapshot without marking the backend degraded. Explicit refresh requests still return the queue timeout error. Direct and HTTP backend requests can run concurrently.

The stdio queue wait is bounded to 30,000 ms. Expiry returns a -32603 error with data.reason set to "stdio_queue_timeout" without sending the request to the backend. A closed connection returns "backend_unavailable" in the same error field. A queued request starts its separate backend timeout when it acquires the connection. Exited callers are removed from the queue, and an exited holder releases the connection to the next waiter.

Limits and errors

max_backends: defaults to 32 and max_catalog_items: to 1,000 across all backends. health_interval_ms: defaults to 30,000. All three are positive integer options to start_link/1; invalid values raise ArgumentError at startup. Adding beyond the backend limit returns {:error, {:backend_limit, limit}}; a catalog beyond the item limit returns {:error, {:catalog_limit, limit}}. Duplicate IDs and catalog names return explicit errors. A backend that fails to connect or fetch its initial catalog cannot be added. A request for a removed or unknown component gets a protocol invalid-params error.

Resource templates must use the URI template subset supported by Snodo.Resource.Template; an unsupported backend template is rejected with {:unsupported_template, template, reason}. Resource update forwarding covers exact resources discovered in the backend catalog. Requests to subscribe to concrete template instances are not acknowledged because the proxy does not open demand-driven upstream listeners. Backend subscriptions may be unavailable on older protocol versions or servers without listen support. Subscription delivery is bounded by the hub's queue and is not durable. The proxy does not route completion requests in this package version. Different templates from one backend can still match the same concrete URI; that read returns an invalid-params ambiguity error. The proxy does not try to choose one template's authorization component on the backend's behalf.

Verify

From this package directory, run mix deps.get, mix quality, and mix quality.types. mix example.proxy runs examples/32_proxy.exs, which merges two backends and routes calls and reads through the proxy.