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