feat: implement claude code backend
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user