feat: implement skeleton phase
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.venv
|
||||||
|
.python-version
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
examples
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
**/.ty_cache
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.15.13
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
types_or: [python, pyi]
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
types_or: [python, pyi]
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ty
|
||||||
|
name: ty check
|
||||||
|
entry: uvx ty check
|
||||||
|
language: python
|
||||||
|
types_or: [python, pyi]
|
||||||
|
pass_filenames: false
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Two stages: a uv-based builder that resolves the venv, and a thin
|
||||||
|
# python:3.13-slim runtime with bun + the `claude` CLI baked in.
|
||||||
|
#
|
||||||
|
# Alpine is intentionally avoided: @anthropic-ai/claude-code ≥ 2.1.113
|
||||||
|
# ships a glibc-only native binary (anthropics/claude-code#50270).
|
||||||
|
#
|
||||||
|
# This image is deliberately un-opinionated about ports and command —
|
||||||
|
# the consumer's compose file decides what to publish and how to invoke.
|
||||||
|
|
||||||
|
# ---- Builder: uv + Python deps ----
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_PYTHON_DOWNLOADS=never
|
||||||
|
# `git` is required at build time: the `prod` extra resolves
|
||||||
|
# raycast-api / claude-code-api from git URLs.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --no-dev --no-install-project --extra prod
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev --extra prod
|
||||||
|
|
||||||
|
# ---- Runtime ----
|
||||||
|
FROM python:3.13-slim AS runtime
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Bun native binary (glibc) — fast `npm install` replacement, only used
|
||||||
|
# to drop the claude CLI into the image. Not invoked at runtime.
|
||||||
|
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
|
||||||
|
|
||||||
|
# `--trust` is required: without it bun skips the postinstall step that
|
||||||
|
# fetches claude's native binary (anthropics/claude-code#50203).
|
||||||
|
ENV BUN_INSTALL=/usr/local/bun-global \
|
||||||
|
PATH=/usr/local/bun-global/bin:/app/.venv/bin:$PATH
|
||||||
|
RUN bun install -g --trust @anthropic-ai/claude-code \
|
||||||
|
&& claude --version
|
||||||
|
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "-m"]
|
||||||
|
CMD ["beaver_gateway"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Stub user config for the Phase 0 DoD.
|
||||||
|
#
|
||||||
|
# Loader (beaver_gateway/config_loader.py) execs this file with
|
||||||
|
# ClaudeAgent, RaycastAgent, McpServer, ExposedMcp, Gateway already
|
||||||
|
# bound. The top-level `gateway = Gateway(...)` is what gets picked up.
|
||||||
|
|
||||||
|
gateway = Gateway( # type: ignore[name-defined] # noqa: F821
|
||||||
|
agents=[
|
||||||
|
ClaudeAgent( # type: ignore[name-defined] # noqa: F821
|
||||||
|
name="stub",
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
system_prompt="You are a stub agent used to validate the Phase 0 skeleton.",
|
||||||
|
cwd="/tmp",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
mcps=[],
|
||||||
|
frontends=[],
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal compose for the Phase 0 DoD: build the image, mount a
|
||||||
|
# single-file user config, watch the process print its summary line
|
||||||
|
# and exit cleanly.
|
||||||
|
#
|
||||||
|
# Real deployments add a postgres service, raycast-config.json bind,
|
||||||
|
# ~/.config/claude bind, etc. — Phase 5 lives in a separate example
|
||||||
|
# repo (see PLAN §5.1).
|
||||||
|
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "sqlite:///./gateway.db"
|
||||||
|
ADMIN_USER: "admin"
|
||||||
|
ADMIN_PASS: "change-me"
|
||||||
|
SESSION_SECRET: "dev-secret-change-me"
|
||||||
|
volumes:
|
||||||
|
- ./config.py:/config/config.py:ro
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
name = "beaver-gateway"
|
name = "beaver-gateway"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Agentic registry, gateway and head of operations for Beaver agent"
|
description = "Agentic registry, gateway and head of operations for Beaver agent"
|
||||||
readme = "README.md"
|
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "h", email = "h@kotikot.com" }
|
{ name = "h", email = "h@kotikot.com" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
def main() -> None:
|
"""Beaver Gateway — personal AI agent gateway."""
|
||||||
print("Hello from beaver-gateway!")
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.cli import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Allow ``python -m beaver_gateway``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Agent type definitions exposed to user config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
|
||||||
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
|
from beaver_gateway.agents.raycast import RaycastAgent
|
||||||
|
|
||||||
|
__all__ = ["BaseAgent", "ClaudeAgent", "ExposedMcp", "RaycastAgent"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Shared agent surface.
|
||||||
|
|
||||||
|
``BaseAgent`` is the structural contract every backend-specific agent
|
||||||
|
honours. Subclasses add **capabilities as fields** (``ClaudeAgent.cwd``,
|
||||||
|
``RaycastAgent.streaming``) — backends dispatch on type, not on a runtime
|
||||||
|
matrix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExposedMcp:
|
||||||
|
"""Reference to an ``McpServer`` (by name) exposed to a single agent."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
tools: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgent(BaseModel):
|
||||||
|
"""Common fields. Concrete backends subclass and add capabilities."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
system_prompt: str
|
||||||
|
expose_mcps: tuple[ExposedMcp, ...] = ()
|
||||||
|
accept_client_tools: bool = False
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Claude Code agent definition.
|
||||||
|
|
||||||
|
Notice the absence of a ``streaming`` field — claude-code does not emit
|
||||||
|
token-level deltas, and that fact is encoded in the type, not in a
|
||||||
|
runtime branch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeAgent(BaseAgent):
|
||||||
|
"""Agent backed by ``claude-code-api``."""
|
||||||
|
|
||||||
|
cwd: str
|
||||||
|
available_native_tools: tuple[str, ...] = ()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Raycast agent definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from raycast_api import Source
|
||||||
|
|
||||||
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
|
|
||||||
|
|
||||||
|
class RaycastAgent(BaseAgent):
|
||||||
|
"""Agent backed by ``raycast-api``."""
|
||||||
|
|
||||||
|
streaming: bool = True
|
||||||
|
available_native_tools: tuple[str, ...] = ()
|
||||||
|
source: Source = Source.AI_CHAT
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Process entrypoint.
|
||||||
|
|
||||||
|
Phase 0.3 — load the user's ``/config/config.py`` via
|
||||||
|
``config_loader``, build registries, print the
|
||||||
|
``loaded N agents, M mcps, K frontends`` line from the Phase 0 DoD,
|
||||||
|
exit cleanly. Phase 0.4 will install uvloop and start uvicorn(s) for
|
||||||
|
the frontends and the internal MCP app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway import config_loader
|
||||||
|
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
||||||
|
from beaver_gateway.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
settings = Settings() # ty: ignore[missing-argument]
|
||||||
|
|
||||||
|
gateway = config_loader.load(settings.config_path)
|
||||||
|
|
||||||
|
agents = AgentRegistry(gateway.agents)
|
||||||
|
mcps = McpRegistry(gateway.mcps)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"beaver-gateway: loaded {len(agents)} agents, "
|
||||||
|
f"{len(mcps)} mcps, {len(gateway.frontends)} frontends"
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Load the user's ``/config/config.py``.
|
||||||
|
|
||||||
|
The config file is regular Python. We ``exec`` it in a namespace seeded
|
||||||
|
with the public surface the user is expected to use (``ClaudeAgent``,
|
||||||
|
``RaycastAgent``, ``McpServer``, ``ExposedMcp``, ``Gateway``). The file
|
||||||
|
must assign a top-level ``gateway = Gateway(...)``.
|
||||||
|
|
||||||
|
Element-level validation already happens at construction time — agents
|
||||||
|
and ``McpServer`` factories are pydantic models that reject garbage.
|
||||||
|
What we *can't* validate at construction is the ``Gateway`` container
|
||||||
|
itself (deliberately a plain dataclass per PRD), so we type-check its
|
||||||
|
contents here before handing it back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
|
||||||
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
|
from beaver_gateway.agents.raycast import RaycastAgent
|
||||||
|
from beaver_gateway.core.registry import Gateway
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""User config file is missing, unreadable, or structurally wrong."""
|
||||||
|
|
||||||
|
|
||||||
|
_PUBLIC_NAMES: dict[str, Any] = {
|
||||||
|
"ClaudeAgent": ClaudeAgent,
|
||||||
|
"RaycastAgent": RaycastAgent,
|
||||||
|
"McpServer": McpServer,
|
||||||
|
"ExposedMcp": ExposedMcp,
|
||||||
|
"Gateway": Gateway,
|
||||||
|
}
|
||||||
|
|
||||||
|
_McpInstance = StdioMcp | HttpMcp | PythonToolMcp
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> Gateway:
|
||||||
|
"""Execute ``path`` and return its top-level ``gateway`` object."""
|
||||||
|
try:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
msg = f"config file not found: {path}"
|
||||||
|
raise ConfigError(msg) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"could not read config file {path}: {exc}"
|
||||||
|
raise ConfigError(msg) from exc
|
||||||
|
|
||||||
|
code = compile(source, str(path), "exec")
|
||||||
|
namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES}
|
||||||
|
exec(code, namespace) # noqa: S102 - exec'ing user config is the feature
|
||||||
|
|
||||||
|
try:
|
||||||
|
gw = namespace["gateway"]
|
||||||
|
except KeyError as exc:
|
||||||
|
msg = f"{path}: no top-level `gateway = Gateway(...)` found"
|
||||||
|
raise ConfigError(msg) from exc
|
||||||
|
|
||||||
|
if not isinstance(gw, Gateway):
|
||||||
|
msg = (
|
||||||
|
f"{path}: top-level `gateway` must be a Gateway instance, "
|
||||||
|
f"got {type(gw).__name__}"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
|
|
||||||
|
_validate(gw, path)
|
||||||
|
return gw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(gw: Gateway, path: Path) -> None:
|
||||||
|
for i, a in enumerate(gw.agents):
|
||||||
|
if not isinstance(a, BaseAgent):
|
||||||
|
msg = (
|
||||||
|
f"{path}: gateway.agents[{i}] must be a ClaudeAgent / "
|
||||||
|
f"RaycastAgent instance, got {type(a).__name__}"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
|
for i, m in enumerate(gw.mcps):
|
||||||
|
if not isinstance(m, _McpInstance):
|
||||||
|
msg = (
|
||||||
|
f"{path}: gateway.mcps[{i}] must be built via "
|
||||||
|
f"McpServer.stdio/.http/.python_tool, "
|
||||||
|
f"got {type(m).__name__}"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
|
for i, f in enumerate(gw.frontends):
|
||||||
|
if not isinstance(f, Frontend):
|
||||||
|
msg = (
|
||||||
|
f"{path}: gateway.frontends[{i}] must be a Frontend, "
|
||||||
|
f"got {type(f).__name__}"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Cross-cutting machinery: registries, event protocol, auth, sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry
|
||||||
|
|
||||||
|
__all__ = ["AgentRegistry", "Gateway", "McpRegistry"]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Agent / MCP registries + the user-facing ``Gateway`` collector.
|
||||||
|
|
||||||
|
The user's ``/config/config.py`` ends with::
|
||||||
|
|
||||||
|
gateway = Gateway(agents=[...], mcps=[...], frontends=[...])
|
||||||
|
|
||||||
|
``cli.main`` picks that object up and builds the registries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
|
|
||||||
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
from beaver_gateway.mcp.types import McpServerT
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistry:
|
||||||
|
"""Name → agent lookup with duplicate detection."""
|
||||||
|
|
||||||
|
def __init__(self, agents: Iterable[BaseAgent]) -> None:
|
||||||
|
self._agents: dict[str, BaseAgent] = {}
|
||||||
|
for a in agents:
|
||||||
|
if a.name in self._agents:
|
||||||
|
msg = f"duplicate agent name: {a.name!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self._agents[a.name] = a
|
||||||
|
|
||||||
|
def __getitem__(self, name: str) -> BaseAgent:
|
||||||
|
return self._agents[name]
|
||||||
|
|
||||||
|
def get(self, name: str) -> BaseAgent | None:
|
||||||
|
return self._agents.get(name)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[BaseAgent]:
|
||||||
|
return iter(self._agents.values())
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._agents)
|
||||||
|
|
||||||
|
def __contains__(self, name: object) -> bool:
|
||||||
|
return name in self._agents
|
||||||
|
|
||||||
|
|
||||||
|
class McpRegistry:
|
||||||
|
"""Name → MCP server lookup with duplicate detection."""
|
||||||
|
|
||||||
|
def __init__(self, mcps: Iterable[McpServerT]) -> None:
|
||||||
|
self._mcps: dict[str, McpServerT] = {}
|
||||||
|
for m in mcps:
|
||||||
|
if m.name in self._mcps:
|
||||||
|
msg = f"duplicate mcp name: {m.name!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self._mcps[m.name] = m
|
||||||
|
|
||||||
|
def __getitem__(self, name: str) -> McpServerT:
|
||||||
|
return self._mcps[name]
|
||||||
|
|
||||||
|
def get(self, name: str) -> McpServerT | None:
|
||||||
|
return self._mcps.get(name)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[McpServerT]:
|
||||||
|
return iter(self._mcps.values())
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._mcps)
|
||||||
|
|
||||||
|
def __contains__(self, name: object) -> bool:
|
||||||
|
return name in self._mcps
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Gateway:
|
||||||
|
"""Top-level object the user assembles in ``/config/config.py``."""
|
||||||
|
|
||||||
|
agents: list[BaseAgent] = field(default_factory=list)
|
||||||
|
mcps: list[McpServerT] = field(default_factory=list)
|
||||||
|
frontends: list[Frontend] = field(default_factory=list)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Frontend protocols and (later) concrete app implementations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
|
||||||
|
__all__ = ["Frontend"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Frontend ABC.
|
||||||
|
|
||||||
|
A frontend is anything that listens on a port and routes inbound traffic
|
||||||
|
into the agent/MCP registries. ``configure`` is called once after the
|
||||||
|
``Gateway`` is built; ``serve`` runs the listening loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.core.registry import Gateway
|
||||||
|
|
||||||
|
|
||||||
|
class Frontend(ABC):
|
||||||
|
"""Listens on a port, dispatches into the gateway."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def configure(self, gateway: Gateway) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def serve(self) -> None: ...
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""MCP server definitions and (later) the internal aggregator app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.mcp.types import (
|
||||||
|
HttpMcp,
|
||||||
|
McpServer,
|
||||||
|
McpServerT,
|
||||||
|
PythonToolMcp,
|
||||||
|
StdioMcp,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["HttpMcp", "McpServer", "McpServerT", "PythonToolMcp", "StdioMcp"]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""User-facing MCP server declarations.
|
||||||
|
|
||||||
|
Three flavours, one factory facade (``McpServer``). The factory returns
|
||||||
|
discriminated-union members so downstream code can ``match`` on ``kind``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable # noqa: TC003 — runtime use by pydantic
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
class _BaseMcp(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class StdioMcp(_BaseMcp):
|
||||||
|
"""Subprocess MCP server we spawn and connect to over stdio."""
|
||||||
|
|
||||||
|
kind: Literal["stdio"] = "stdio"
|
||||||
|
command: tuple[str, ...]
|
||||||
|
env: dict[str, str] | None = None
|
||||||
|
cwd: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HttpMcp(_BaseMcp):
|
||||||
|
"""Remote MCP server reached over streamable HTTP."""
|
||||||
|
|
||||||
|
kind: Literal["http"] = "http"
|
||||||
|
url: str
|
||||||
|
auth: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PythonToolMcp(_BaseMcp):
|
||||||
|
"""Bundle of Python callables exposed as one FastMCP namespace."""
|
||||||
|
|
||||||
|
kind: Literal["python_tool"] = "python_tool"
|
||||||
|
tools: tuple[Callable[..., object], ...]
|
||||||
|
|
||||||
|
|
||||||
|
McpServerT = Annotated[
|
||||||
|
StdioMcp | HttpMcp | PythonToolMcp, Field(discriminator="kind")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class McpServer:
|
||||||
|
"""Factory facade matching the PRD-documented config surface."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def stdio(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
command: Iterable[str],
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
cwd: str | None = None,
|
||||||
|
) -> StdioMcp:
|
||||||
|
return StdioMcp(name=name, command=tuple(command), env=env, cwd=cwd)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def http(
|
||||||
|
cls, *, name: str, url: str, auth: str | None = None
|
||||||
|
) -> HttpMcp:
|
||||||
|
return HttpMcp(name=name, url=url, auth=auth)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def python_tool(
|
||||||
|
cls, *, name: str, tools: Iterable[Callable[..., object]]
|
||||||
|
) -> PythonToolMcp:
|
||||||
|
return PythonToolMcp(name=name, tools=tuple(tools))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Process-wide configuration loaded from environment (`.env`).
|
||||||
|
|
||||||
|
Everything that varies between deployments (creds, paths, ports) lives here.
|
||||||
|
User-facing agent/MCP/frontend definitions live in ``/config/config.py``
|
||||||
|
and are loaded by ``config_loader`` (Phase 0.3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Runtime environment for the gateway process."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
database_url: str
|
||||||
|
admin_user: str
|
||||||
|
admin_pass: str
|
||||||
|
session_secret: str
|
||||||
|
|
||||||
|
internal_mcp_port: int = 8765
|
||||||
|
config_path: Path = Path("/config/config.py")
|
||||||
|
|
||||||
|
raycast_bearer: str | None = None
|
||||||
|
raycast_config_path: Path = Path("/config/raycast.json")
|
||||||
Reference in New Issue
Block a user