feat: add streaming to markdown, fix raycast mcps exposing

This commit is contained in:
hh
2026-05-21 13:52:48 +02:00
parent 7fc0c9c0b1
commit 11f061070f
6 changed files with 557 additions and 99 deletions
+46 -11
View File
@@ -44,6 +44,8 @@ from beaver_gateway.settings import Settings
from beaver_gateway.storage import Database
if TYPE_CHECKING:
from fastmcp import FastMCP
from fastmcp.tools.base import Tool as FastMCPTool
from starlette.applications import Starlette
from beaver_gateway.backends.base import Backend
@@ -95,16 +97,26 @@ async def _async_main() -> None:
stack.push_async_callback(token_store.stop)
# 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(
# ``BackendOptions.mcp_servers`` at construction time. The
# ``mcp_servers`` map is used by the Raycast backend, which
# needs in-process ``list_tools`` / ``call_tool`` access (the
# Raycast wire has no native MCP concept).
internal_app, internal_urls, mcp_servers = _build_internal_mcp(
gateway.mcps, settings=settings
)
# Prefetch tool catalogs for every MCP so RaycastAgent requests
# don't pay a per-turn list_tools roundtrip and so a broken MCP
# surfaces at startup instead of mid-conversation.
mcp_tools = await _prefetch_mcp_tools(mcp_servers)
backends: dict[str, Backend] = await _build_backends(
settings=settings,
agents=agents,
stack=stack,
mcp_internal_urls=internal_urls,
mcp_servers=mcp_servers,
mcp_tools=mcp_tools,
)
runtime = GatewayRuntime(
@@ -152,20 +164,39 @@ async def _async_main() -> None:
def _build_internal_mcp(
mcps: list[McpServerT], *, settings: Settings
) -> tuple[Starlette | None, dict[str, str]]:
"""Build the aggregator app + URL map, or return ``(None, {})``.
) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]:
"""Build the aggregator app + URL map + server map, or empty equivalents.
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.
skips the uvicorn task entirely. The server map is the in-process
handle the Raycast backend needs to splice MCP tools into its
requests — empty when no MCPs are configured.
"""
if not mcps:
return None, {}
app, urls = build_internal_app(
mcps, host="127.0.0.1", port=settings.internal_mcp_port
)
return app, urls
return None, {}, {}
return build_internal_app(mcps, host="127.0.0.1", port=settings.internal_mcp_port)
async def _prefetch_mcp_tools(
servers: dict[str, FastMCP],
) -> dict[str, list[FastMCPTool]]:
"""Eagerly enumerate tools per MCP so the Raycast loop has a static catalog.
Each underlying proxy is allowed to fail independently — a broken
MCP shouldn't take down the whole gateway. The result has one entry
per MCP that responded; agents that ``expose_mcps`` a missing entry
will simply expose no tools from it (logged once per request).
"""
out: dict[str, list[FastMCPTool]] = {}
for name, server in servers.items():
try:
out[name] = list(await server.list_tools())
except Exception: # noqa: BLE001 — proxy can raise any transport error; we degrade per-MCP rather than fail the whole gateway
_log.exception("failed to list tools for MCP %r — skipping", name)
out[name] = []
return out
async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
@@ -196,6 +227,8 @@ async def _build_backends(
agents: AgentRegistry,
stack: AsyncExitStack,
mcp_internal_urls: dict[str, str],
mcp_servers: dict[str, FastMCP],
mcp_tools: dict[str, list[FastMCPTool]],
) -> dict[str, Backend]:
"""Construct one backend per agent name.
@@ -216,7 +249,9 @@ async def _build_backends(
if raycast_agents:
client = await _try_open_raycast_client(settings, stack)
if client is not None:
raycast_backend = RaycastBackend(client)
raycast_backend = RaycastBackend(
client, mcp_servers=mcp_servers, mcp_tools=mcp_tools
)
for a in raycast_agents:
backends[a.name] = raycast_backend