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