refactor: no comments left - one-line module docstrings, contracts on public fields only; jobs/job.py; example config and README

This commit is contained in:
hh
2026-09-02 00:33:04 +02:00
parent 3915ab48f9
commit 0e07e8409d
65 changed files with 795 additions and 1891 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
"""MCP server definitions and (later) the internal aggregator app."""
"""MCP server definitions and the internal aggregator app."""
from __future__ import annotations
+2 -6
View File
@@ -1,11 +1,7 @@
"""Proxy ``FastMCP`` servers for user-declared external MCPs (``stdio``/``http``).
Both flavours end up as a ``FastMCPProxy`` instance, built via
``fastmcp.server.create_proxy``. The proxy lazily opens the underlying
client transport when the first MCP request arrives, so we don't pay
for connections that nothing routes to. From the aggregator app's
point of view a proxy is indistinguishable from a regular ``FastMCP``
namespace — same ``http_app`` surface, same mount semantics.
Built via ``fastmcp.server.create_proxy``; the proxy lazily opens its
transport on first request, so unused MCPs cost nothing until routed to.
"""
from __future__ import annotations
+19 -43
View File
@@ -1,26 +1,7 @@
"""Internal MCP aggregator — one ASGI app, N FastMCP namespaces + ``all``.
Each ``McpServer`` declared in the user's config becomes its own
``FastMCP`` instance (regular for ``python_tool``, ``FastMCPProxy`` for
``stdio``/``http``) and is mounted under ``/mcp/<name>`` on a single
Starlette app. This app runs on ``127.0.0.1:INTERNAL_MCP_PORT`` (not
EXPOSE'd in Docker) so the ClaudeCode subprocess can reach each
namespace via loopback as a distinct MCP server URL — preserving
per-domain framing while costing only one process worth of RAM
(PRD §6).
Phase 3 adds ``/mcp/all/``: a single FastMCP whose tools are the union
of every namespace's tools, prefixed by FastMCP's ``namespace_<tool>``
convention (e.g. ``time_current_time``). It's the escape-hatch for
clients that can only configure one MCP server — discouraged for tool-
heavy setups (PRD §6 cites the ~95%→~71% tool-selection drop on flat
namespaces) but real and reachable.
The aggregator returns both the app and a ``{name: url}`` map; Phase
``ClaudeSdkBackend`` plugs the map directly into
``BackendOptions.mcp_servers``. ``/mcp/all/`` is NOT included in that
map — claude-code-agents always get per-domain framing; only the
external MCP frontend (Phase 3.1) reverse-proxies the flat endpoint.
Each ``McpServer`` in the user's config is mounted under ``/mcp/<name>``;
``/mcp/all/`` unions every namespace's tools for single-MCP clients.
"""
from __future__ import annotations
@@ -52,11 +33,13 @@ def build_internal_app(
) -> tuple[Starlette, dict[str, str], dict[str, FastMCP]]:
"""Build the aggregator ``Starlette`` app, per-namespace URL map, and server map.
``host``/``port`` only flavour the URL strings handed back — actually
listening on them is the caller's job (``cli.main`` runs a uvicorn
server in a TaskGroup). We accept the address here so callers don't
have to format the URLs themselves and risk drifting from the
``/mcp/<name>`` convention.
``host``/``port`` only flavour the URL strings handed back; the
caller (``cli.main``) is what actually listens on them. Published
URLs carry a trailing slash so ``Mount`` doesn't 307-redirect the
first request. Each
namespace gets its own :class:`RedactingMiddleware` so a direct
``call_tool`` on a child (e.g. the Raycast backend does that) is
filtered too, not just requests through the mounted app.
Returns:
* Starlette app to serve via uvicorn.
@@ -70,10 +53,6 @@ def build_internal_app(
MCP tools into the Raycast wire).
"""
servers: dict[str, FastMCP] = {spec.name: _build_server(spec) for spec in mcps}
# The one place every tool result passes through on its way to the
# model. Attached per namespace rather than once at the top so that
# a direct ``call_tool`` on a child (the Raycast backend does that)
# is filtered too.
for server in servers.values():
server.add_middleware(RedactingMiddleware())
@@ -82,8 +61,6 @@ def build_internal_app(
}
routes = [Mount(f"/mcp/{name}", app=app) for name, app in child_apps.items()]
# /mcp/all — flat-namespace bundle. Skip when there's nothing to
# bundle so we don't pay for an empty session manager lifecycle.
all_app = None
if servers:
all_server = _build_all_server(servers)
@@ -93,11 +70,11 @@ def build_internal_app(
@asynccontextmanager
async def lifespan(_parent: Starlette) -> AsyncIterator[None]:
# Each FastMCP http_app stores its session manager init in its
# own lifespan. Without entering them the streamable-HTTP layer
# 500s on every request. AsyncExitStack composes them so all
# children come up together and unwind in reverse order on
# shutdown.
"""Enter every child app's lifespan.
Without it the streamable-HTTP layer 500s on every request;
children unwind in reverse order.
"""
async with AsyncExitStack() as stack:
for child in child_apps.values():
await stack.enter_async_context(child.router.lifespan_context(child))
@@ -108,23 +85,22 @@ def build_internal_app(
yield
app = Starlette(routes=routes, lifespan=lifespan)
# Trailing slash on the published URL skips Starlette's
# 307 redirect from ``/mcp/<name>`` to ``/mcp/<name>/`` that
# ``Mount`` produces when a child route lives at ``/``.
urls = {name: f"http://{host}:{port}/mcp/{name}/" for name in servers}
return app, urls, servers
def _build_server(spec: McpServerT) -> FastMCP:
"""Dispatch on the discriminated union to the matching builder."""
"""Dispatch on the discriminated union to the matching builder.
The final branch is unreachable while ``McpServerT`` stays closed;
it exists to keep type-narrowing honest if a variant is ever added.
"""
if isinstance(spec, PythonToolMcp):
return build_python_tool_server(spec)
if isinstance(spec, StdioMcp):
return build_stdio_proxy(spec)
if isinstance(spec, HttpMcp):
return build_http_proxy(spec)
# `McpServerT` is a closed union; this is unreachable but keeps
# type-narrowing honest if a new variant lands without updates here.
msg = f"unsupported McpServer variant: {type(spec).__name__}"
raise TypeError(msg)
+12 -25
View File
@@ -1,22 +1,8 @@
"""Tolerant transports for upstream MCPs that don't strictly speak JSON-RPC.
Some real-world MCP servers print non-JSON chatter to stdout before / between
their actual JSON-RPC frames (``Processing...``, banners, dependency-load
messages, etc.). The reference ``mcp.client.stdio.stdio_client`` parses every
stdout line as JSON-RPC and ships any parse failure downstream as an
exception, which the MCP ``ClientSession`` then logs as a warning that bleeds
into client UIs (Cursor, Cline) when they connect through us.
``LenientStdioTransport`` re-implements the stdio-client wiring with one
behavioural change: lines that don't parse as JSON-RPC are *silently
dropped* (one ``DEBUG`` log entry, no exception forwarded). Downstream
consumers see only valid messages. We keep the rest of the contract identical
to the reference client, including the spec-mandated graceful shutdown
sequence (close stdin → wait → SIGTERM → SIGKILL).
The transport plugs into ``fastmcp.server.create_proxy`` just like
``StdioTransport`` does, so the rest of the aggregator doesn't need to
know which flavour it got.
Some MCP servers print non-JSON chatter to stdout (banners, dependency-load
messages) that the reference client forwards as exceptions, which bleed into
client UIs as warnings. This transport drops those lines instead.
"""
from __future__ import annotations
@@ -70,13 +56,15 @@ class LenientStdioTransport(ClientTransport):
cwd: str | None = None,
log_file: TextIO | None = None,
) -> None:
"""``log_file`` takes an already-open ``TextIO``.
Unlike the upstream transport, this one does not open a ``Path``
for you.
"""
self.command = command
self.args = args
self.env = env
self.cwd = cwd
# TextIO only — pre-open Path callers themselves. The upstream
# ``StdioTransport`` opens Path for you, but we keep this thin so
# the type contract stays narrow and easy to validate.
self.log_file = log_file
@contextlib.asynccontextmanager
@@ -100,7 +88,7 @@ class LenientStdioTransport(ClientTransport):
@contextlib.asynccontextmanager
async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.stdio_client
async def _lenient_stdio_client( # noqa: PLR0915
server: StdioServerParameters, errlog: TextIO = sys.stderr
) -> AsyncIterator[
tuple[
@@ -112,8 +100,8 @@ async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.s
All differences from upstream live in ``stdout_reader``: lines that fail
``JSONRPCMessage.model_validate_json`` are logged at DEBUG and skipped,
never forwarded as exceptions. This is what makes warning-noisy MCPs
quiet from the consumer's point of view.
never forwarded as exceptions. Shutdown still follows the MCP spec
sequence: close stdin, wait, SIGTERM, then SIGKILL.
"""
read_stream_writer, read_stream = anyio.create_memory_object_stream[
SessionMessage | Exception
@@ -159,7 +147,7 @@ async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.s
continue
try:
message = types.JSONRPCMessage.model_validate_json(stripped)
except Exception: # noqa: BLE001 — by design, see module doc
except Exception: # noqa: BLE001
_log.debug(
"lenient stdio: dropped non-JSON line: %r",
stripped[:200],
@@ -192,7 +180,6 @@ async def _lenient_stdio_client( # noqa: PLR0915 — mirrors mcp.client.stdio.s
try:
yield read_stream, write_stream
finally:
# MCP spec stdio shutdown: close stdin → wait → SIGTERM → SIGKILL.
if process.stdin:
with contextlib.suppress(Exception):
await process.stdin.aclose()
+9 -26
View File
@@ -1,26 +1,7 @@
"""Redact credentials on the way out of an MCP tool.
"""Redact credentials in MCP tool output before it reaches the model.
A tool result is a wider channel than the log. It goes straight into the
model's context, from there into the turn record in Postgres, and from
there into the markdown transcript in ``💬 чаты`` — which Obsidian Sync
carries off the machine. One ``komodo`` deploy returns the resolved
compose file, ``environment:`` block and all, so a single call can put
every secret of a stack into all four places at once.
One filter, not a list of exceptions: every MCP the model can reach is
built into a ``FastMCP`` by :mod:`beaver_gateway.mcp.internal_app` —
``python_tool`` bundles like komodo, stdio subprocesses, remote HTTP
servers — and every route into one of them runs its middleware chain.
That covers the per-namespace ``/mcp/<name>/`` mounts claude-code talks
to, the ``/mcp/all`` bundle, the external MCP frontend reverse-proxying
into both, and the Raycast backend's direct ``call_tool``. A new MCP in
``config.py`` is covered the day it is added, without anyone
remembering to list it here.
What this does not reach: tools that never touch a FastMCP server — the
gateway's own ``gateway`` tools, and everything claude-code runs inside
its own process (``Bash``, ``Read``). Those are guarded by
:mod:`beaver_gateway.agents.policy` and the vault mounts instead.
Covers every MCP route; in-process tools like ``Bash``/``Read`` are
guarded separately by ``agents.policy``.
"""
from __future__ import annotations
@@ -69,6 +50,12 @@ class RedactingMiddleware(Middleware):
context: MiddlewareContext[mt.CallToolRequestParams],
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
) -> ToolResult:
"""Mask the result.
A non-``None`` ``meta`` on the masked copy takes the
``CallToolResult`` path that skips output-schema validation,
since masked structured content may no longer match it.
"""
result = await call_next(context)
content = [_redact_block(block) for block in result.content]
structured = redact_data(result.structured_content)
@@ -77,9 +64,5 @@ class RedactingMiddleware(Middleware):
return ToolResult(
content=content,
structured_content=structured,
# Masked structured content no longer has to satisfy the
# tool's output schema; a non-None meta takes the
# ``CallToolResult`` path that skips that validation, the
# same trick ``ResponseLimitingMiddleware`` uses.
meta=result.meta if result.meta is not None else {},
)
+18 -18
View File
@@ -6,7 +6,7 @@ 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 collections.abc import Callable # noqa: TC003
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Literal
@@ -20,39 +20,35 @@ class _BaseMcp(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
name: str
"""The namespace this MCP server is mounted under."""
class StdioMcp(_BaseMcp):
"""Subprocess MCP server we spawn and connect to over stdio.
``lenient`` switches the upstream stdio reader to a tolerant variant
that silently drops non-JSON-RPC lines from the subprocess's stdout
(``Processing...``-style chatter, banners, dependency-load messages).
The reference ``mcp.client.stdio.stdio_client`` forwards those parse
failures as exceptions, which bleed into Cursor/Cline UIs as warnings
when they connect through us. Default ``False`` keeps the strict
contract — flip it on per-namespace for known-noisy upstreams.
"""
"""Subprocess MCP server spawned and connected to over stdio."""
kind: Literal["stdio"] = "stdio"
command: tuple[str, ...]
"""Argv used to spawn the subprocess."""
env: dict[str, str] | None = None
"""Extra environment variables for the subprocess."""
cwd: Path | None = None
"""Working directory to spawn the subprocess in."""
lenient: bool = False
"""Tolerate non-JSON-RPC stdout lines instead of raising (the strict
reader's parse errors surface as warnings in Cursor/Cline). Off by default."""
class HttpMcp(_BaseMcp):
"""Remote MCP server reached over streamable HTTP.
``headers`` are forwarded on every request to the upstream MCP — handy
for upstreams that authenticate via custom header rather than a Bearer
token (``auth``).
"""
"""Remote MCP server reached over streamable HTTP."""
kind: Literal["http"] = "http"
url: str
"""Streamable-HTTP endpoint URL."""
auth: str | None = None
"""Bearer token sent as the upstream's ``Authorization`` header."""
headers: dict[str, str] | None = None
"""Extra headers forwarded on every request; for upstreams that
authenticate via a custom header instead of ``auth``."""
class PythonToolMcp(_BaseMcp):
@@ -60,13 +56,14 @@ class PythonToolMcp(_BaseMcp):
kind: Literal["python_tool"] = "python_tool"
tools: tuple[Callable[..., object], ...]
"""The callables to expose as MCP tools."""
McpServerT = Annotated[StdioMcp | HttpMcp | PythonToolMcp, Field(discriminator="kind")]
class McpServer:
"""Factory facade matching the PRD-documented config surface."""
"""Factory facade for declaring MCP servers in config."""
@classmethod
def stdio(
@@ -78,6 +75,7 @@ class McpServer:
cwd: Path | str | None = None,
lenient: bool = False,
) -> StdioMcp:
"""Declare a subprocess MCP server spawned over stdio."""
return StdioMcp(
name=name,
command=tuple(command),
@@ -95,10 +93,12 @@ class McpServer:
auth: str | None = None,
headers: dict[str, str] | None = None,
) -> HttpMcp:
"""Declare a remote MCP server reached over streamable HTTP."""
return HttpMcp(name=name, url=url, auth=auth, headers=headers)
@classmethod
def python_tool(
cls, *, name: str, tools: Iterable[Callable[..., object]]
) -> PythonToolMcp:
"""Declare a namespace of Python callables exposed as MCP tools."""
return PythonToolMcp(name=name, tools=tuple(tools))
+2 -3
View File
@@ -1,8 +1,7 @@
"""Wrap a ``PythonToolMcp`` spec into a mountable ``FastMCP`` instance.
Each ``python_tool`` McpServer in the user's config becomes a separate
``FastMCP`` namespace — one domain, one server URL — so models keep the
per-domain framing they were trained on (see PRD §6).
Each ``python_tool`` McpServer becomes its own ``FastMCP`` namespace so
models keep the per-domain tool framing they were trained on.
"""
from __future__ import annotations