fix(mcp,redact,gateway_tools): mask credentials in tool results, names kept and values gone
This commit is contained in:
@@ -33,6 +33,7 @@ from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
from beaver_gateway.mcp.client_pool import build_http_proxy, build_stdio_proxy
|
||||
from beaver_gateway.mcp.redacting import RedactingMiddleware
|
||||
from beaver_gateway.mcp.types import HttpMcp, PythonToolMcp, StdioMcp
|
||||
from beaver_gateway.mcp.wrap import build_python_tool_server
|
||||
|
||||
@@ -69,6 +70,12 @@ 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())
|
||||
|
||||
child_apps = {
|
||||
name: s.http_app(transport="http", path="/") for name, s in servers.items()
|
||||
@@ -80,6 +87,7 @@ def build_internal_app(
|
||||
all_app = None
|
||||
if servers:
|
||||
all_server = _build_all_server(servers)
|
||||
all_server.add_middleware(RedactingMiddleware())
|
||||
all_app = all_server.http_app(transport="http", path="/")
|
||||
routes.append(Mount(f"/mcp/{ALL_NAMESPACE}", app=all_app))
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Redact credentials on the way out of an MCP tool.
|
||||
|
||||
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.core.policy` and the vault mounts instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import mcp.types as mt
|
||||
from fastmcp.server.middleware import Middleware
|
||||
from fastmcp.tools.base import ToolResult
|
||||
|
||||
from beaver_gateway.core.redact import redact, redact_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.middleware import CallNext, MiddlewareContext
|
||||
|
||||
__all__ = ["RedactingMiddleware"]
|
||||
|
||||
|
||||
def _redact_block(block: mt.ContentBlock) -> mt.ContentBlock:
|
||||
"""Mask a content block's text; binary blocks pass through."""
|
||||
if isinstance(block, mt.TextContent):
|
||||
masked = redact(block.text)
|
||||
return (
|
||||
block if masked == block.text else block.model_copy(update={"text": masked})
|
||||
)
|
||||
if isinstance(block, mt.EmbeddedResource) and isinstance(
|
||||
block.resource, mt.TextResourceContents
|
||||
):
|
||||
masked = redact(block.resource.text)
|
||||
if masked == block.resource.text:
|
||||
return block
|
||||
resource = block.resource.model_copy(update={"text": masked})
|
||||
return block.model_copy(update={"resource": resource})
|
||||
return block
|
||||
|
||||
|
||||
class RedactingMiddleware(Middleware):
|
||||
"""Mask credentials in whatever a tool call returns.
|
||||
|
||||
Idempotent, so mounting a server both on its own namespace and in
|
||||
the ``/mcp/all`` bundle costs a second pass, not a double mask.
|
||||
"""
|
||||
|
||||
async def on_call_tool(
|
||||
self,
|
||||
context: MiddlewareContext[mt.CallToolRequestParams],
|
||||
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
||||
) -> ToolResult:
|
||||
result = await call_next(context)
|
||||
content = [_redact_block(block) for block in result.content]
|
||||
structured = redact_data(result.structured_content)
|
||||
if content == result.content and structured == result.structured_content:
|
||||
return result
|
||||
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 {},
|
||||
)
|
||||
Reference in New Issue
Block a user