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
+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)