# Sample user config — grows alongside the implementation phases. # # Loader (beaver_gateway/config_loader.py) execs this file with # ClaudeAgent, RaycastAgent, McpServer, ExposedMcp, Gateway already # bound — so importing them is optional. We import explicitly here so # IDEs and type-checkers see real symbols instead of free variables. import tempfile from datetime import date from pathlib import Path from beaver_gateway.agents.base import ExposedMcp from beaver_gateway.agents.claude import ClaudeAgent from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences from beaver_gateway.core.registry import Gateway from beaver_gateway.frontends.admin import AdminFrontend from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend from beaver_gateway.frontends.mcp_server import McpServerFrontend from beaver_gateway.mcp.types import McpServer def current_time() -> str: """Return the current local time as an ISO-8601 string. Trivial demo tool for the Phase 2.1 internal MCP aggregator — confirms a ``python_tool`` namespace is reachable on ``http://127.0.0.1:/mcp/time``. """ from datetime import datetime return datetime.now().astimezone().isoformat() gateway = Gateway( agents=[ # Phase 2.2 — ClaudeCodeBackendAdapter routes this agent's # ``/v1/messages`` calls through ``claude-code-api``. The # ``time`` MCP gets exposed as ``mcp__time__current_time`` to # the subscription claude session via # ``BackendOptions.mcp_servers`` pointing at the internal # aggregator on ``127.0.0.1:INTERNAL_MCP_PORT/mcp/time/``. # # Fresh empty tempdir (not a hardcoded ``/tmp``) for two # reasons: claude-code-api derives the JSONL project-key from # ``cwd``, but claude itself writes the JSONL using the cwd's # realpath — on macOS ``/tmp`` and ``/var/folders/...`` are # both ``/private/...`` symlinks, so unresolved cwds make # ``JsonlWatcher`` time out waiting on the wrong path. The # explicit ``.resolve()`` collapses the symlink before claude # ever sees the dir, and ``mkdtemp`` guarantees the directory # is empty so claude does not pick up leftover files. ClaudeAgent( name="stub", model="claude-sonnet-4-6", # ``system_prompt`` is appended to claude's built-in agent # prompt (via ``--append-system-prompt``) — so it adds the # agent's identity on top of claude-code's baseline tool # knowledge, rather than replacing it. Same shape as the # RaycastAgent's ``system_prompt → additional_system_instructions`` # mapping. For full ``BackendOptions`` knobs (timeouts, # extra_args, history mode, etc.) import ``ClaudeCodeOptions`` # and pass ``options=ClaudeCodeOptions(...)``. system_prompt=( "You are a stub agent used to validate the Phase 0 skeleton.\n" "If asked the current time, call the `current_time`" " MCP tool instead of guessing." ), cwd=Path(tempfile.mkdtemp(prefix="beaver-stub-cwd-")).resolve(), expose_mcps=(ExposedMcp(name="time"),), ), # Phase 1.2 — a RaycastAgent the AnthropicMessagesFrontend will # route via RaycastBackend. Phase 1.5 added the per-agent knobs # (`temperature` / `additional_system_instructions` / etc.) — # only `model`, `system_prompt`, and at least one of the others # is mandatory. RaycastAgent( name="research", model="Gemini 3.1 Flash Lite", system_prompt=( "You are a research assistant. " "Reply in the user's language. Cite URLs when you use web search." ), temperature=0.5, available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE), # Lambda so today's date is rebuilt on every request while # locale/timezone stay pinned. ``True`` would give the same # fresh-date behaviour but would also auto-pick host locale # and timezone (``en-US`` / system tz), which isn't what we # want here. user_preferences=lambda: UserPreferences( locale="en-GB", timezone="Europe/Berlin", current_date=date.today().isoformat(), # noqa: DTZ011 — local date is intended ), ), ], mcps=[ # Phase 2.1 — bundle of plain Python callables exposed as one # FastMCP namespace. The internal aggregator mounts it under # ``/mcp/time`` on ``127.0.0.1:INTERNAL_MCP_PORT``; Phase 2.2's # ClaudeCode adapter forwards that URL into # ``BackendOptions.mcp_servers``. Phase 3's ``McpServerFrontend`` # reverse-proxies the same internal URL out to external clients. McpServer.python_tool(name="time", tools=[current_time]) # Phase 3 — illustrates the ``lenient`` flag. Real-world stdio MCPs # sometimes print "Processing..." or other chatter to stdout before # their actual JSON-RPC frames; the default mcp client forwards # those parse failures downstream as warnings (visible in # Cursor/Cline). With ``lenient=True`` we silently drop non-JSON # lines, so downstream UIs see clean JSON-RPC only. The command # below is just a placeholder — replace with whatever stdio MCP # you actually want gateway to ingest (e.g. an obsidian-mcp). # # Commented out by default: example users won't have the binary # installed and an unreachable command makes ``docker compose up`` # surface a confusing "command not found" line at first request. # Uncomment after pointing ``command`` at a real stdio MCP. # # McpServer.stdio( # name="obsidian", # command=["uvx", "mcp-obsidian"], # env={"OBSIDIAN_API_KEY": "..."}, # lenient=True, # ), ], frontends=[ # Phase 1.4 — expose the agents as `model=` on an # Anthropic-compatible Messages endpoint. Auth comes from # `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`). # # Behind a reverse proxy (Caddy / nginx / Cloudflare) pass # `public_base_url=` so the admin dashboard advertises the # outside URL instead of `host:port`. Caddy strips its own # prefix and the frontend's internal paths (`/v1/messages`, # `/v1/models`) get appended: # Caddy: handle_path /ai/* { reverse_proxy localhost:8000 } # Config: AnthropicMessagesFrontend( # port=8000, # public_base_url="https://domain.com/ai") # Result: https://domain.com/ai/v1/messages AnthropicMessagesFrontend(host="0.0.0.0", port=8000), # Phase 3 — re-exposes every declared `McpServer` outside the # gateway with bearer auth + audit log. Each namespace lives # at `//` on this port (the port itself disambiguates # MCP traffic — no extra `/mcp` segment in the route); a flat # bundle is published at `/all/`. Discovery page (HTML, # auth-gated) at `/` with copy-pastable Cursor / Claude # Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`. # # Same `public_base_url=` knob as above. Caddy strips its # prefix; the frontend's `//` segment gets appended: # Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 } # Config: McpServerFrontend( # port=8001, # public_base_url="https://domain.com/mcp") # Result: https://domain.com/mcp// (and /mcp/all/) McpServerFrontend(host="0.0.0.0", port=8001), # Phase 4.3 — browser admin UI. Creds come from # `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with # `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in # the DB), revoke them, and watch the audit log. Scope is # enforced on the bearer frontends: tokens minted with scope # `messages` only work on `/v1/messages`; `mcp` only on # `/mcp/`; `*` works everywhere. AdminFrontend(host="0.0.0.0", port=8002), ], )