diff --git a/mcp_server/auth.py b/mcp_server/auth.py index b9e6b18..e4d0222 100644 --- a/mcp_server/auth.py +++ b/mcp_server/auth.py @@ -125,6 +125,15 @@ def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Secur ) token = credentials.credentials + + # Service token: lets trusted local integrations (e.g. the code-forge + # stdio MCP server) call the API without a user login. Only relevant + # once auth is actually enabled - with no CLAUDE_OS_EMAIL set, everyone + # already gets the guest bypass above regardless of this. + service_token = os.getenv("CLAUDE_OS_SERVICE_TOKEN") + if service_token and secrets.compare_digest(token, service_token): + return {"email": "service", "service_account": True} + payload = decode_access_token(token) if payload is None: diff --git a/mcp_server/claude_code_mcp.py b/mcp_server/claude_code_mcp.py index e5b6c1a..62e23b9 100644 --- a/mcp_server/claude_code_mcp.py +++ b/mcp_server/claude_code_mcp.py @@ -32,12 +32,27 @@ from pathlib import Path from typing import Any import httpx +from dotenv import load_dotenv from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent +# This process is launched directly (e.g. over SSH as a stdio MCP server), +# not necessarily with claude-os's own .env sourced into its environment or +# its working directory set to the project root - so load it explicitly by +# path rather than relying on load_dotenv()'s cwd-relative default search. +load_dotenv(Path(__file__).resolve().parent.parent / ".env") + # Configuration API_BASE = os.environ.get("CLAUDE_OS_API", "http://localhost:8051") +SERVICE_TOKEN = os.environ.get("CLAUDE_OS_SERVICE_TOKEN") + + +def _auth_headers() -> dict: + """Auth header for calling claude-os's own API. No-op unless a service + token is configured, which only matters once CLAUDE_OS_EMAIL/PASSWORD + are set - the API allows all requests through as a guest otherwise.""" + return {"Authorization": f"Bearer {SERVICE_TOKEN}"} if SERVICE_TOKEN else {} # Health cache — tracks last health check time per KB and any warnings HEALTH_CACHE: dict[str, dict] = {} @@ -78,7 +93,7 @@ async def _maybe_check_health(kb_name: str) -> list[str]: # Run health check try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=_auth_headers()) as client: resp = await client.get(api_url(f"/api/kb/{kb_name}/lifecycle/health"), timeout=15.0) resp.raise_for_status() health = resp.json() @@ -109,7 +124,7 @@ def api_url(path: str) -> str: async def api_get(path: str) -> dict: """Make GET request to API.""" - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=_auth_headers()) as client: response = await client.get(api_url(path), timeout=30.0) response.raise_for_status() return response.json() @@ -117,7 +132,7 @@ async def api_get(path: str) -> dict: async def api_post(path: str, data: dict = None) -> dict: """Make POST request to API.""" - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=_auth_headers()) as client: response = await client.post(api_url(path), json=data or {}, timeout=60.0) response.raise_for_status() return response.json() @@ -125,7 +140,7 @@ async def api_post(path: str, data: dict = None) -> dict: async def api_delete(path: str) -> dict: """Make DELETE request to API.""" - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=_auth_headers()) as client: response = await client.delete(api_url(path), timeout=30.0) response.raise_for_status() return response.json() @@ -133,7 +148,7 @@ async def api_delete(path: str) -> dict: async def api_put(path: str, data: dict = None) -> dict: """Make PUT request to API.""" - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=_auth_headers()) as client: response = await client.put(api_url(path), json=data or {}, timeout=30.0) response.raise_for_status() return response.json()