Files
beaver-gateway/src/beaver_gateway/mcp/client_pool.py
T
2026-05-20 09:59:47 +02:00

67 lines
2.5 KiB
Python

"""Proxy ``FastMCP`` servers for user-declared external MCPs (``stdio``/``http``).
Both flavours end up as a ``FastMCPProxy`` instance, built via
``fastmcp.server.create_proxy``. The proxy lazily opens the underlying
client transport when the first MCP request arrives, so we don't pay
for connections that nothing routes to. From the aggregator app's
point of view a proxy is indistinguishable from a regular ``FastMCP``
namespace — same ``http_app`` surface, same mount semantics.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastmcp import Client
from fastmcp.client.transports import StdioTransport, StreamableHttpTransport
from fastmcp.server import create_proxy
from beaver_gateway.mcp.lenient import LenientStdioTransport
if TYPE_CHECKING:
from fastmcp import FastMCP
from fastmcp.client.transports.base import ClientTransport
from beaver_gateway.mcp.types import HttpMcp, StdioMcp
def build_stdio_proxy(spec: StdioMcp) -> FastMCP:
"""Wrap a stdio subprocess MCP into a mountable ``FastMCPProxy``.
``spec.command`` is a non-empty tuple; the first element is the
executable and the rest are CLI args. ``StdioTransport`` keeps the
subprocess alive across calls. When ``spec.lenient`` is set we
swap in :class:`LenientStdioTransport`, which silently drops
non-JSON-RPC stdout lines instead of forwarding them as exceptions
(lets us ingest MCPs that print ``Processing...`` chatter on stdout
without leaking warnings to downstream UIs).
"""
if not spec.command:
msg = f"stdio MCP {spec.name!r} has empty command"
raise ValueError(msg)
command, *args = spec.command
cwd = str(spec.cwd) if spec.cwd is not None else None
transport: ClientTransport
if spec.lenient:
transport = LenientStdioTransport(
command=command, args=list(args), env=spec.env, cwd=cwd
)
else:
transport = StdioTransport(
command=command, args=list(args), env=spec.env, cwd=cwd
)
return create_proxy(Client(transport, name=spec.name))
def build_http_proxy(spec: HttpMcp) -> FastMCP:
"""Wrap a remote streamable-HTTP MCP into a mountable ``FastMCPProxy``.
``spec.headers`` are folded into the upstream client; pass per-call
auth (e.g. ``X-Api-Key``) here rather than as ``auth`` when the
upstream rejects Bearer headers.
"""
transport = StreamableHttpTransport(
url=spec.url, auth=spec.auth, headers=spec.headers
)
return create_proxy(Client(transport, name=spec.name))