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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user