diff --git a/.env.example b/.env.example index a47442c..9e4bbe5 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,6 @@ AG_BIDS_API_URL=https://agbids.paul.farm AG_BIDS_API_KEY= AG_BIDS_API_TIMEOUT_SECS=20 -# --- HTTP Basic auth (REQUIRED — server refuses to start without both) --- -# These guard the MCP itself. MetaMCP injects them on every upstream call. -AG_BIDS_MCP_USER= -AG_BIDS_MCP_PASS= - # --- Per-tool-call usage logging --- USAGE_LOG_DIR=/app/var/logs USAGE_LOG_KEEP_DAYS=90 diff --git a/README.md b/README.md index ba0f56c..2b254dd 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,12 @@ for the contract. ## Authentication -This MCP enforces **HTTP Basic** auth in front of the FastMCP HTTP transport. -Set both: +**No in-container auth.** The MCP's port 8000 is never exposed outside the +private `mcp-servers_mcp` Docker network on `.0.2`. The only client that +can reach it is MetaMCP, and MetaMCP enforces auth at the gateway → client +edge (bearer token / OAuth in its UI). -``` -AG_BIDS_MCP_USER= -AG_BIDS_MCP_PASS= -``` - -MetaMCP is configured to inject `Authorization: Basic ` on every upstream -call. Direct access without the header returns `401`. - -If either env var is unset the server refuses to start (fail closed). +This matches the zerto-docs-mcp pattern. ## Local dev (stdio) @@ -61,18 +55,13 @@ Wire into Claude Desktop's `claude_desktop_config.json`: "env": { "MCP_TRANSPORT": "stdio", "AG_BIDS_API_URL": "https://agbids.paul.farm", - "AG_BIDS_API_KEY": "...", - "AG_BIDS_MCP_USER": "x", - "AG_BIDS_MCP_PASS": "y" + "AG_BIDS_API_KEY": "..." } } } } ``` -(The Basic auth check is skipped automatically when `MCP_TRANSPORT=stdio` since -stdio has no HTTP layer.) - ## Deploy (MetaMCP host) See [deploy/README.md](deploy/README.md). Container image is pulled from diff --git a/ag_bids_mcp/auth.py b/ag_bids_mcp/auth.py deleted file mode 100644 index 129585d..0000000 --- a/ag_bids_mcp/auth.py +++ /dev/null @@ -1,76 +0,0 @@ -"""HTTP Basic auth in front of the FastMCP Streamable-HTTP transport. - -MetaMCP can inject ``Authorization: Basic `` on every upstream call, -so this is the simplest robust gate. Two env vars (``AG_BIDS_MCP_USER`` and -``AG_BIDS_MCP_PASS``) are required at process startup; the server fails -closed if either is missing. - -Stdio transport (local dev with Claude Desktop) skips this entirely — no -HTTP layer exists in stdio mode. -""" - -from __future__ import annotations - -import base64 -import logging -import os -import secrets - -from starlette.requests import Request -from starlette.responses import PlainTextResponse, Response - -log = logging.getLogger(__name__) - -REALM = "ag-bids MCP" - - -def expected_credentials() -> tuple[str, str]: - """Return the (user, pass) the server enforces. Raises if missing.""" - u = os.environ.get("AG_BIDS_MCP_USER", "") - p = os.environ.get("AG_BIDS_MCP_PASS", "") - if not u or not p: - raise RuntimeError( - "AG_BIDS_MCP_USER and AG_BIDS_MCP_PASS must both be set for HTTP " - "Basic auth on the ag-bids MCP server." - ) - return u, p - - -def _decode_basic(header: str) -> tuple[str, str] | None: - if not header or not header.lower().startswith("basic "): - return None - try: - decoded = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") - except (ValueError, UnicodeDecodeError): - return None - user, _, pw = decoded.partition(":") - return user, pw - - -def _check(presented_user: str, presented_pass: str) -> bool: - expected_user, expected_pass = expected_credentials() - return ( - secrets.compare_digest(presented_user, expected_user) - and secrets.compare_digest(presented_pass, expected_pass) - ) - - -def _unauthorized() -> Response: - return PlainTextResponse( - "Unauthorized", - status_code=401, - headers={"WWW-Authenticate": f'Basic realm="{REALM}"'}, - ) - - -async def basic_auth_middleware(request: Request, call_next): - """Starlette middleware that 401s anything missing valid Basic creds.""" - creds = _decode_basic(request.headers.get("authorization", "")) - if creds is None: - log.info("auth: missing/malformed Authorization header (path=%s)", request.url.path) - return _unauthorized() - user, pw = creds - if not _check(user, pw): - log.info("auth: bad credentials (user=%r path=%s)", user, request.url.path) - return _unauthorized() - return await call_next(request) diff --git a/ag_bids_mcp/server.py b/ag_bids_mcp/server.py index d9fda54..4f494f2 100644 --- a/ag_bids_mcp/server.py +++ b/ag_bids_mcp/server.py @@ -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 `` 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__": diff --git a/deploy/README.md b/deploy/README.md index d20c398..691d87b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,13 +1,18 @@ # Deploying `ag-bids-mcp` behind MetaMCP This runs on the **MetaMCP host (`192.168.0.2`)** as its **own standalone -docker-compose project** — independent from the zerto-docs-rag stack but -joining the same `mcp-servers_mcp` Docker network so MetaMCP can proxy to it -by container DNS name (`http://ag-bids-mcp:8000/mcp`). +docker-compose project** — independent from the MetaMCP/zerto-docs stack +but joining the same `mcp-servers_mcp` Docker network so MetaMCP can proxy +to it by container DNS name (`http://ag-bids-mcp:8000/mcp`). -The deploy lives in `/home/justin/ag-bids-mcp/` on `.0.2`. Watchtower (already -running on the host) auto-pulls a new image every 5 min whenever a fresh -`git.jpaul.io/justin/ag-bids-mcp:latest` is pushed. +The deploy lives in `/home/justin/ag-bids-mcp/` on `.0.2`. Watchtower +(already running on the host) auto-pulls a new image every 5 min whenever +a fresh `git.jpaul.io/justin/ag-bids-mcp:latest` is pushed. + +**No in-container auth.** Port 8000 is never exposed outside the private +`mcp-servers_mcp` Docker network — only MetaMCP can reach it. MetaMCP +enforces auth at the gateway → MCP-client edge (bearer token in its UI), +which is the right layer for it. ## One-time setup (already done — kept for re-runs) @@ -25,13 +30,6 @@ Create `/home/justin/ag-bids-mcp/.env` (mode 600) on `.0.2` with: AG_BIDS_API_URL=https://agbids.paul.farm AG_BIDS_API_KEY= AG_BIDS_API_TIMEOUT_SECS=20 -AG_BIDS_MCP_USER= -AG_BIDS_MCP_PASS=<32+ random bytes> -``` - -Generate a password locally: -```bash -python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` ### 3. Build + push the image @@ -52,7 +50,7 @@ ssh justin@192.168.0.2 cd ~/ag-bids-mcp docker compose pull docker compose up -d -docker compose logs -f ag-bids-mcp # expect "starting ag-bids MCP on streamable-http://0.0.0.0:8000 (Basic auth enforced)" +docker compose logs -f ag-bids-mcp # expect "starting ag-bids MCP on streamable-http://0.0.0.0:8000" ``` ### 5. Register the namespace in MetaMCP @@ -63,9 +61,8 @@ In the MetaMCP web UI at `https://mcp.jpaul.io`: 2. **Add upstream MCP server** to that namespace: - **Transport:** Streamable HTTP - **URL:** `http://ag-bids-mcp:8000/mcp` - - **Authentication:** Basic - - **Username:** matches `AG_BIDS_MCP_USER` - - **Password:** matches `AG_BIDS_MCP_PASS` + - **Bearer token:** leave blank (the upstream has no auth; MetaMCP + enforces at the user-facing endpoint instead) 3. Save. Public endpoint becomes: @@ -73,27 +70,15 @@ Public endpoint becomes: ## Smoke test -From inside the `mcp-servers_mcp` network (e.g. the `metamcp` container) -the MCP should 401 anonymous, 200 authenticated: +From inside the `mcp-servers_mcp` network the MCP should respond to a real +MCP `initialize` handshake: ```bash -ssh justin@192.168.0.2 'docker exec metamcp wget -qS -O- --tries=1 http://ag-bids-mcp:8000/mcp 2>&1 | head -3' -# expect: HTTP/1.1 401 Unauthorized -``` - -Then, with creds, an MCP `initialize` handshake should return capability -metadata: - -```bash -# from the .0.2 host -USER= PASS= -CREDS=$(printf '%s:%s' "$USER" "$PASS" | base64 -w0) -docker run --rm --network mcp-servers_mcp curlimages/curl:latest \ - -s -H "Authorization: Basic $CREDS" \ - -H 'content-type: application/json' \ - -H 'accept: application/json, text/event-stream' \ +ssh justin@192.168.0.2 'docker run --rm --network mcp-servers_mcp curlimages/curl:latest \ + -s -H "content-type: application/json" \ + -H "accept: application/json, text/event-stream" \ -X POST http://ag-bids-mcp:8000/mcp \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"smoke\",\"version\":\"1\"}}}"' # expect: event: message ... "serverInfo":{"name":"ag-bids", ...} ``` @@ -104,11 +89,7 @@ against `https://mcp.jpaul.io/metamcp/ag-bids/mcp`, try: - **"What's the current price of lime?"** → calls `current_lime_price()` - **"Are any sources down?"** → calls `source_health()` -## Rotating credentials - -To rotate the Basic password: change `AG_BIDS_MCP_PASS` in `~/ag-bids-mcp/.env` -on `.0.2` → `docker compose up -d` to restart with the new value → update -the MetaMCP namespace's upstream Basic password to match. +## Rotating the upstream API key To rotate the upstream API key: change `BRIEF_API_KEY` in ag-monitor's `.env` on `.0.126` + restart `api` there, then update `AG_BIDS_API_KEY` in @@ -118,4 +99,3 @@ on `.0.126` + restart `api` there, then update `AG_BIDS_API_KEY` in - Per-tool-call usage logs: `/home/justin/ag-bids-mcp/ag-bids-mcp-logs/usage-YYYY-MM-DD.jsonl` - Container stdout: `docker compose logs ag-bids-mcp` -- Successful auth → no log line; failed auth → INFO line with the offending path diff --git a/deploy/docker-compose.snippet.yml b/deploy/docker-compose.snippet.yml index 946016f..2c748dd 100644 --- a/deploy/docker-compose.snippet.yml +++ b/deploy/docker-compose.snippet.yml @@ -1,15 +1,16 @@ # Standalone docker-compose for ag-bids-mcp. # -# This file is the ENTIRE compose project — it does NOT belong inside -# zerto-docs-rag's compose. Put it in /home/justin/ag-bids-mcp/ on the -# MetaMCP host (192.168.0.2) alongside a .env file with three secrets: +# This file is the ENTIRE compose project — it does NOT belong inside the +# MetaMCP compose. Put it in /home/justin/ag-bids-mcp/ on the MetaMCP host +# (192.168.0.2) alongside a .env file with ONE secret: # AG_BIDS_API_KEY — copy from ag-monitor's .env (BRIEF_API_KEY) on .0.126 -# AG_BIDS_MCP_USER — username MetaMCP will send in Basic auth -# AG_BIDS_MCP_PASS — password MetaMCP will send in Basic auth # -# Joins the EXISTING `mcp-servers_mcp` network (created by the MetaMCP -# compose project at /home/justin/mcp-servers/) as external, so MetaMCP -# can reach this container by DNS name `ag-bids-mcp`. +# No in-container auth is needed because port 8000 is never exposed outside +# the private `mcp-servers_mcp` Docker network — the only client that can +# reach it is the MetaMCP gateway, which handles auth at the user-facing +# edge. Joins the EXISTING `mcp-servers_mcp` network (created by the +# MetaMCP compose project at /home/justin/mcp-servers/) as external so +# MetaMCP can reach this container by DNS name `ag-bids-mcp`. services: ag-bids-mcp: diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index abf2dba..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,102 +0,0 @@ -"""HTTP Basic middleware tests.""" - -from __future__ import annotations - -import base64 -import importlib -import os -import sys - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - - -def _reload_auth(monkeypatch, user="alice", password="hunter2"): - monkeypatch.setenv("AG_BIDS_MCP_USER", user) - monkeypatch.setenv("AG_BIDS_MCP_PASS", password) - from ag_bids_mcp import auth - importlib.reload(auth) - return auth - - -def _b64(creds: str) -> str: - return base64.b64encode(creds.encode()).decode() - - -def test_expected_credentials_requires_both(monkeypatch): - monkeypatch.delenv("AG_BIDS_MCP_USER", raising=False) - monkeypatch.delenv("AG_BIDS_MCP_PASS", raising=False) - from ag_bids_mcp import auth - importlib.reload(auth) - import pytest - with pytest.raises(RuntimeError): - auth.expected_credentials() - - monkeypatch.setenv("AG_BIDS_MCP_USER", "alice") - monkeypatch.delenv("AG_BIDS_MCP_PASS", raising=False) - importlib.reload(auth) - with pytest.raises(RuntimeError): - auth.expected_credentials() - - -def test_middleware_via_starlette_app(monkeypatch): - """End-to-end: a Starlette app with the middleware returns 401 / 200 correctly.""" - auth = _reload_auth(monkeypatch, "alice", "hunter2") - - from starlette.applications import Starlette - from starlette.middleware import Middleware - from starlette.middleware.base import BaseHTTPMiddleware - from starlette.responses import PlainTextResponse - from starlette.routing import Route - from starlette.testclient import TestClient - - async def hello(request): - return PlainTextResponse("ok") - - app = Starlette( - routes=[Route("/x", endpoint=hello)], - middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth.basic_auth_middleware)], - ) - c = TestClient(app) - - # No header -> 401 + WWW-Authenticate - r = c.get("/x") - assert r.status_code == 401 - assert r.headers.get("www-authenticate", "").startswith("Basic") - - # Wrong creds -> 401 - r = c.get("/x", headers={"Authorization": "Basic " + _b64("alice:wrong")}) - assert r.status_code == 401 - - # Wrong username, right password -> 401 - r = c.get("/x", headers={"Authorization": "Basic " + _b64("eve:hunter2")}) - assert r.status_code == 401 - - # Right creds -> 200 - r = c.get("/x", headers={"Authorization": "Basic " + _b64("alice:hunter2")}) - assert r.status_code == 200 - assert r.text == "ok" - - -def test_malformed_authorization_header(monkeypatch): - auth = _reload_auth(monkeypatch) - from starlette.applications import Starlette - from starlette.middleware import Middleware - from starlette.middleware.base import BaseHTTPMiddleware - from starlette.responses import PlainTextResponse - from starlette.routing import Route - from starlette.testclient import TestClient - - async def hello(request): - return PlainTextResponse("ok") - - app = Starlette( - routes=[Route("/x", endpoint=hello)], - middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth.basic_auth_middleware)], - ) - c = TestClient(app) - # Not even base64 - r = c.get("/x", headers={"Authorization": "Basic !!!not_b64!!!"}) - assert r.status_code == 401 - # Bearer instead of Basic - r = c.get("/x", headers={"Authorization": "Bearer abc"}) - assert r.status_code == 401