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.1"}

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 names and exact URI collisions when a backend is added. 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. 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.

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.