Skip to main content
A MultiServerClient is what you get when you connect to several servers at once. It consumes the same three primitives as a single-server client — it calls tools, reads resources, fetches prompts — but spreads them across every connected server, namespacing each one by server name and routing each call to the server it came from. From your side it is one object with one interface; underneath it holds a live connection per server.

Connecting to many servers

You never construct a MultiServerClient directly. Client.connect() returns one when you hand it an mcpServers config with more than one entry. A single-entry config returns a plain Client, so the same call covers both cases and you only ever import Client.
Each entry can use any transport — one server over HTTP, another as a stdio subprocess — because each is resolved by the same logic a single connection uses. The config is the only thing that changes.

Namespacing

With several servers behind one interface, names could collide, so the client prefixes everything with the server’s name from the config. A list_repos tool on the github server becomes github_list_repos; a prompt on jira becomes jira_create_issue. The prefix is the key in your mcpServers config, joined to the original name with an underscore. Resource display names are prefixed the same way. Resource URIs are the exception: they are never altered. A URI already identifies a resource globally, and prefixing its scheme — turning data://config into github_data://config — would produce a string that is no longer a valid URI under RFC 3986. So the client namespaces the human-facing name and leaves the URI as the server defined it, which is the same reasoning the server-side NamespaceTransform applies. You read a resource by its original URI regardless of which server holds it.

Routing

List operations aggregate; routed operations dispatch. Calling listTools() fans out to every server, prefixes each result, and returns the merged set — likewise for resources and prompts. Calling a tool goes the other way: the client splits the namespaced name on the first underscore to recover the server name and the local name, then dispatches the call to that one server. The same split routes getPrompt(). Resources route by URI rather than by prefix, since the URI was never namespaced. When you list resources, the client records which server each URI came from, so a later readResource(uri) is a direct dispatch to the right server. If you read a URI before ever listing — so the map is empty — the client falls back to trying each server in turn and returning the first that answers, and reports a clear error if no server has it.

Per-server auth

Authentication is per server, because different servers trust different credentials. Each entry in the config takes its own auth field, using the same strategies a single client uses — a bearer token for one, OAuth for another. An auth set at the top level applies to any entry that doesn’t specify its own, so shared credentials stay in one place while individual servers override as needed.
One flow does not carry over from the single client: MultiServerClient does not drive the interactive OAuth browser handshake. Non-interactive credentials work per server — a bearer token, a client-credentials grant, a JWT-bearer grant (JwtBearerAuth), an enterprise-managed token exchange (EnterpriseManagedAuth), or an OAuth provider whose tokens are already stored and only need refreshing. A server that would send the user through the browser for the first time is not handled here; authorize it once with a single Client, then reuse the stored tokens. Handlers, by contrast, are shared. A single log, sampling, or elicitation handler is registered on every connection, so a notification or request from any server reaches the same place. The sampling capability is advertised to all servers when you provide the handler.

Protocol era

Every sub-connection speaks a protocol era, and a MultiServerClient negotiates one the same way a single client does. The versionNegotiation option applies one mode to every server in the config, and each sub-connection then negotiates its own era against its own server — so one client can end up modern on one server and legacy on another. getProtocolEra(serverName) reports the era a named server settled on.
The era-sensitive utilities route per sub-client, so you call them once and each server gets the right wire. ping() and setLogLevel() fan out across every connection and pick the legacy RPC or its modern replacement for each server independently.

Connect semantics

Connecting is all-or-nothing. The client opens every connection in parallel, and if any one fails, it closes the connections that did succeed and re-throws the error. You never observe a half-connected client where some servers are live and others silently absent — either the whole set is connected or the call fails cleanly. Closing is parallel too, so tearing the client down shuts every connection at once.