Drop in-container auth — MetaMCP guards the user-facing edge

The MCP's port 8000 isn't exposed outside the private mcp-servers_mcp
Docker network, so only the MetaMCP gateway can ever reach it. MetaMCP
itself enforces auth at the gateway → MCP-client edge (bearer token in
its UI), which is the right layer for it. In-container Basic/Bearer was
defense-in-depth that turned out to be friction-in-depth.

Removed:
  - ag_bids_mcp/auth.py (HTTP Basic middleware)
  - tests/test_auth.py (3 tests covering the middleware)
  - AG_BIDS_MCP_USER / AG_BIDS_MCP_PASS env vars from .env.example, README,
    docker-compose.snippet.yml, and deploy/README.md

Server.py simplified — direct `mcp.run(transport=...)` like zerto-docs-mcp,
no Starlette wrapping. 21 tests passing.

Live on 192.168.0.2: container recreated, real MCP initialize handshake
returns 200 + capability metadata over the mcp-servers_mcp network with
no auth header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 16:05:41 -04:00
parent 8aa4cc0ef3
commit e78733d55e
7 changed files with 46 additions and 294 deletions
+10 -45
View File
@@ -1,10 +1,11 @@
"""ag-bids MCP server.
Mirrors the zerto-docs-rag layout — FastMCP, ``@mcp.tool()`` decorators
returning markdown strings, dual stdio/streamable-http transport — with one
addition: HTTP Basic auth in front of the streamable-http transport. MetaMCP
upstream config injects ``Authorization: Basic <b64>`` so the proxied call
sails through; direct hits without the header get 401.
returning markdown strings, dual stdio/streamable-http transport.
No in-container auth. The MCP's port 8000 isn't exposed outside the private
``mcp-servers_mcp`` Docker network; the only thing that can reach it is the
MetaMCP gateway, and MetaMCP handles auth at the gateway→client edge.
Run locally (stdio for Claude Desktop):
MCP_TRANSPORT=stdio python -m ag_bids_mcp.server
@@ -25,7 +26,6 @@ from mcp.server.fastmcp import FastMCP
from pydantic import Field
from ag_bids_mcp import client, format as fmt
from ag_bids_mcp.auth import basic_auth_middleware, expected_credentials
from ag_bids_mcp.usage import track
logging.basicConfig(
@@ -134,12 +134,7 @@ def price_history(
window has fewer than ~60 samples."""
cm = commodity.strip().lower()
with track("price_history", commodity=cm, source=source, delivery=delivery, days=days):
# source_id lookup would require an extra call; for now we accept
# source by name and let users filter the markdown output. The
# underlying /api/data/history accepts source_id; pass-through is
# source-agnostic.
payload = client.history(commodity=cm, delivery=delivery, days=days)
# If user asked for a single source, filter rows post-fetch.
if source:
payload["rows"] = [r for r in payload.get("rows") or [] if r.get("source_name") == source]
return fmt.fmt_history(payload)
@@ -198,23 +193,6 @@ def todays_summary() -> str:
# ============================================================================
def _streamable_app_with_auth():
"""Attach Basic-auth middleware directly to FastMCP's Starlette app.
Earlier we tried mounting the FastMCP app under a parent Starlette app to
install middleware. That broke the streamable-HTTP transport because the
parent's lifespan didn't trigger FastMCP's internal session-manager
task-group startup, so requests hit "Task group is not initialized".
Adding middleware on the FastMCP-provided app keeps the original lifespan
intact.
"""
from starlette.middleware.base import BaseHTTPMiddleware
app = mcp.streamable_http_app()
app.add_middleware(BaseHTTPMiddleware, dispatch=basic_auth_middleware)
return app
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument(
@@ -227,15 +205,10 @@ def main() -> None:
args = p.parse_args()
if args.transport == "stdio":
# stdio has no HTTP layer → no Basic auth to enforce. Useful for
# local Claude Desktop development.
log.info("starting ag-bids MCP on stdio (auth bypassed in stdio mode)")
log.info("starting ag-bids MCP on stdio")
mcp.run()
return
# HTTP transports: fail closed if Basic-auth credentials are unset.
expected_credentials()
# Same DNS-rebinding logic as zerto-docs-rag: behind a Docker DNS name
# like "ag-bids-mcp:8000", FastMCP's default localhost-only check would
# 421 every request.
@@ -257,18 +230,10 @@ def main() -> None:
o.strip() for o in allowed_origins.split(",") if o.strip()
]
if args.transport == "sse":
# SSE transport: same direct-add-middleware pattern.
from starlette.middleware.base import BaseHTTPMiddleware
app = mcp.sse_app()
app.add_middleware(BaseHTTPMiddleware, dispatch=basic_auth_middleware)
else:
app = _streamable_app_with_auth()
import uvicorn
log.info("starting ag-bids MCP on %s://%s:%s (Basic auth enforced)",
args.transport, args.host, args.port)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
mcp.settings.host = args.host
mcp.settings.port = args.port
log.info("starting ag-bids MCP on %s://%s:%s", args.transport, args.host, args.port)
mcp.run(transport=args.transport)
if __name__ == "__main__":