feat: implement claude code backend

This commit is contained in:
hh
2026-05-19 23:11:07 +02:00
parent 757065f21c
commit 99a30f256d
8 changed files with 797 additions and 7 deletions
+48
View File
@@ -0,0 +1,48 @@
"""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
if TYPE_CHECKING:
from fastmcp import FastMCP
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.
"""
if not spec.command:
msg = f"stdio MCP {spec.name!r} has empty command"
raise ValueError(msg)
command, *args = spec.command
transport = StdioTransport(
command=command,
args=list(args),
env=spec.env,
cwd=str(spec.cwd) if spec.cwd is not None else None,
)
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``."""
transport = StreamableHttpTransport(url=spec.url, auth=spec.auth)
return create_proxy(Client(transport, name=spec.name))
+87
View File
@@ -0,0 +1,87 @@
"""Internal MCP aggregator — one ASGI app, N FastMCP namespaces.
Each ``McpServer`` declared in the user's config becomes its own
``FastMCP`` instance (regular for ``python_tool``, ``FastMCPProxy`` for
``stdio``/``http``) and is mounted under ``/mcp/<name>`` on a single
Starlette app. This app runs on ``127.0.0.1:INTERNAL_MCP_PORT`` (not
EXPOSE'd in Docker) so the ClaudeCode subprocess can reach each
namespace via loopback as a distinct MCP server URL — preserving
per-domain framing while costing only one process worth of RAM
(PRD §6).
The aggregator returns both the app and a ``{name: url}`` map; Phase
2.2's ``ClaudeCodeBackendAdapter`` plugs the map directly into
``BackendOptions.mcp_servers``.
"""
from __future__ import annotations
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING
from starlette.applications import Starlette
from starlette.routing import Mount
from beaver_gateway.mcp.client_pool import build_http_proxy, build_stdio_proxy
from beaver_gateway.mcp.types import HttpMcp, PythonToolMcp, StdioMcp
from beaver_gateway.mcp.wrap import build_python_tool_server
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from fastmcp import FastMCP
from beaver_gateway.mcp.types import McpServerT
def build_internal_app(
mcps: Iterable[McpServerT], *, host: str, port: int
) -> tuple[Starlette, dict[str, str]]:
"""Build the aggregator ``Starlette`` app and the ``{name: url}`` map.
``host``/``port`` only flavour the URL strings handed back — actually
listening on them is the caller's job (``cli.main`` runs a uvicorn
server in a TaskGroup). We accept the address here so callers don't
have to format the URLs themselves and risk drifting from the
``/mcp/<name>`` convention.
"""
servers: dict[str, FastMCP] = {spec.name: _build_server(spec) for spec in mcps}
child_apps = [s.http_app(transport="http", path="/") for s in servers.values()]
routes = [
Mount(f"/mcp/{name}", app=app)
for name, app in zip(servers, child_apps, strict=True)
]
@asynccontextmanager
async def lifespan(_parent: Starlette) -> AsyncIterator[None]:
# Each FastMCP http_app stores its session manager init in its
# own lifespan. Without entering them the streamable-HTTP layer
# 500s on every request. AsyncExitStack composes them so all
# children come up together and unwind in reverse order on
# shutdown.
async with AsyncExitStack() as stack:
for child in child_apps:
await stack.enter_async_context(child.router.lifespan_context(child))
yield
app = Starlette(routes=routes, lifespan=lifespan)
# Trailing slash on the published URL skips Starlette's
# 307 redirect from ``/mcp/<name>`` to ``/mcp/<name>/`` that
# ``Mount`` produces when a child route lives at ``/``.
urls = {name: f"http://{host}:{port}/mcp/{name}/" for name in servers}
return app, urls
def _build_server(spec: McpServerT) -> FastMCP:
"""Dispatch on the discriminated union to the matching builder."""
if isinstance(spec, PythonToolMcp):
return build_python_tool_server(spec)
if isinstance(spec, StdioMcp):
return build_stdio_proxy(spec)
if isinstance(spec, HttpMcp):
return build_http_proxy(spec)
# `McpServerT` is a closed union; this is unreachable but keeps
# type-narrowing honest if a new variant lands without updates here.
msg = f"unsupported McpServer variant: {type(spec).__name__}"
raise TypeError(msg)
+26
View File
@@ -0,0 +1,26 @@
"""Wrap a ``PythonToolMcp`` spec into a mountable ``FastMCP`` instance.
Each ``python_tool`` McpServer in the user's config becomes a separate
``FastMCP`` namespace — one domain, one server URL — so models keep the
per-domain framing they were trained on (see PRD §6).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastmcp import FastMCP
if TYPE_CHECKING:
from beaver_gateway.mcp.types import PythonToolMcp
def build_python_tool_server(spec: PythonToolMcp) -> FastMCP:
"""Construct a ``FastMCP`` namespace from a ``PythonToolMcp``.
The callables in ``spec.tools`` are registered as MCP tools using
FastMCP's introspection — names, docstrings, and type hints turn
into the tool schema. No decorator wiring is needed: the
``tools=`` constructor argument accepts plain callables.
"""
return FastMCP(name=spec.name, tools=list(spec.tools))