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
+86 -2
View File
@@ -6,6 +6,12 @@ each frontend with a ``GatewayRuntime``, and run all
``frontend.serve()`` coroutines concurrently. Without any frontends we
still print the Phase 0 DoD line and exit cleanly so the bare skeleton
keeps working.
Phase 2.1 — when the user declares any ``McpServer``, we additionally
build the internal MCP aggregator app and run it on
``127.0.0.1:INTERNAL_MCP_PORT`` as another task inside the same
TaskGroup. URLs are surfaced through ``GatewayRuntime.mcp_internal_urls``
so Phase 2.2's ClaudeCode adapter can find them.
"""
from __future__ import annotations
@@ -15,20 +21,27 @@ import logging
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING
import uvicorn
import uvloop
from raycast_api import Client as RaycastClient
from raycast_api.config import Config as RaycastConfig
from beaver_gateway import config_loader
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.claude_code import ClaudeCodeBackendAdapter
from beaver_gateway.backends.raycast import RaycastBackend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.settings import Settings
if TYPE_CHECKING:
from starlette.applications import Starlette
from beaver_gateway.backends.base import Backend
from beaver_gateway.mcp.types import McpServerT
_log = logging.getLogger("beaver_gateway.cli")
@@ -51,8 +64,18 @@ async def _async_main() -> None:
token_store = TokenStore.from_env(settings.bootstrap_tokens)
async with AsyncExitStack() as stack:
# Internal MCP URLs must exist before we construct any
# ClaudeCodeBackendAdapter — adapters bake the URLs into their
# ``BackendOptions.mcp_servers`` at construction time.
internal_app, internal_urls = _build_internal_mcp(
gateway.mcps, settings=settings
)
backends: dict[str, Backend] = await _build_backends(
settings=settings, agents=agents, stack=stack
settings=settings,
agents=agents,
stack=stack,
mcp_internal_urls=internal_urls,
)
runtime = GatewayRuntime(
@@ -60,6 +83,7 @@ async def _async_main() -> None:
mcps=mcps,
backends=backends,
token_store=token_store,
mcp_internal_urls=internal_urls,
)
for fe in gateway.frontends:
@@ -80,18 +104,66 @@ async def _async_main() -> None:
)
if not gateway.frontends:
# No external listeners → nothing to serve. The internal
# MCP app has no consumer on its own, so we skip running
# it in this path and exit cleanly (Phase 0 DoD).
return
async with asyncio.TaskGroup() as tg:
if internal_app is not None:
tg.create_task(
_serve_internal_mcp(internal_app, settings=settings)
)
for fe in gateway.frontends:
tg.create_task(fe.serve())
def _build_internal_mcp(
mcps: list[McpServerT], *, settings: Settings
) -> tuple[Starlette | None, dict[str, str]]:
"""Build the aggregator app + URL map, or return ``(None, {})``.
The URL map is always handed out (frontends may still introspect
``runtime.mcp_internal_urls`` even if nothing is configured); the
app is ``None`` when there are no MCPs to mount, so the caller
skips the uvicorn task entirely.
"""
if not mcps:
return None, {}
app, urls = build_internal_app(
mcps, host="127.0.0.1", port=settings.internal_mcp_port
)
return app, urls
async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
"""Run the internal MCP aggregator on loopback.
Bound to ``127.0.0.1`` (never EXPOSE'd) — only the in-process
ClaudeCode subprocess reaches it. Logged at ``warning`` level so
we don't drown the gateway's own logs in per-request noise.
"""
config = uvicorn.Config(
app,
host="127.0.0.1",
port=settings.internal_mcp_port,
log_level="warning",
loop="uvloop",
)
server = uvicorn.Server(config)
_log.info(
"internal MCP aggregator on http://127.0.0.1:%d/mcp/<name>",
settings.internal_mcp_port,
)
await server.serve()
async def _build_backends(
*,
settings: Settings,
agents: AgentRegistry,
stack: AsyncExitStack,
mcp_internal_urls: dict[str, str],
) -> dict[str, Backend]:
"""Construct one backend per agent name.
@@ -99,6 +171,12 @@ async def _build_backends(
(bearer + device-id are process-wide), so we open it lazily — only
when at least one ``RaycastAgent`` is present — and close it via
the caller's exit stack.
Each :class:`ClaudeAgent` gets its own
:class:`ClaudeCodeBackendAdapter`: ``BackendOptions`` pins
``cwd`` / ``model`` / ``system_prompt`` / ``mcp_servers`` for the
lifetime of the underlying ``ClaudeCodeBackend``, so different
agents can't share one.
"""
backends: dict[str, Backend] = {}
@@ -110,7 +188,13 @@ async def _build_backends(
for a in raycast_agents:
backends[a.name] = raycast_backend
# Phase 2: ClaudeAgent → ClaudeCodeBackendAdapter goes here.
for a in agents:
if isinstance(a, ClaudeAgent):
adapter = ClaudeCodeBackendAdapter(
agent=a, mcp_internal_urls=mcp_internal_urls
)
await stack.enter_async_context(adapter)
backends[a.name] = adapter
return backends