From 9e9a0e4c5c7f75a418ff333891a83b81ccd2cc9b Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 7 Mar 2026 11:26:34 -0500 Subject: [PATCH 1/4] feat: remote MCP server via Streamable HTTP + api_keys migration (OPE-91) Phase 1 of MCP dogfooding. Converts the MCP server from stdio-only to dual transport: stdio for local dev, streamable-http for remote. Changes: - server.py: Rewritten with FastMCP, dual transport support - stdio mode: same as before (Claude Desktop config) - streamable-http mode: remote deployment for Claude.ai Connectors - /health endpoint for Railway health checks - /mcp endpoint for MCP protocol (Streamable HTTP) - CLI: python server.py --transport http - Env: MCP_TRANSPORT=streamable-http - config.py: Added TRANSPORT, HOST, PORT config from env vars - requirements.txt: Added uvicorn, bumped mcp>=1.25.0 - Dockerfile: New, for Railway deployment of MCP service - .env.example: Updated with transport config - supabase/migrations/004_api_keys.sql: Creates api_keys table for MCP authentication (ci_xxx format, SHA-256 hashed, RLS) Deployment steps in PR description. --- mcp-server/.env.example | 11 +++- mcp-server/Dockerfile | 17 ++++++ mcp-server/config.py | 7 ++- mcp-server/requirements.txt | 3 +- mcp-server/server.py | 89 +++++++++++++++++++--------- supabase/migrations/004_api_keys.sql | 41 +++++++++++++ 6 files changed, 137 insertions(+), 31 deletions(-) create mode 100644 mcp-server/Dockerfile create mode 100644 supabase/migrations/004_api_keys.sql diff --git a/mcp-server/.env.example b/mcp-server/.env.example index 6ddd573..c8d5eab 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -1,3 +1,10 @@ # Backend API Configuration -API_KEY=your-api-key-here -BACKEND_API_URL=http://localhost:8000 +BACKEND_API_URL=https://api.opencodeintel.com +API_KEY=ci_your-api-key-here + +# Transport: "stdio" for local dev, "streamable-http" for remote +MCP_TRANSPORT=stdio + +# Server host/port (only used for streamable-http transport) +MCP_HOST=0.0.0.0 +PORT=8080 diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..67cd535 --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,17 @@ +# MCP Server Dockerfile - Remote Streamable HTTP deployment +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Railway sets PORT automatically +ENV MCP_TRANSPORT=streamable-http +ENV MCP_HOST=0.0.0.0 + +EXPOSE 8080 + +CMD ["python", "server.py"] diff --git a/mcp-server/config.py b/mcp-server/config.py index 9cafd13..e3d57fc 100644 --- a/mcp-server/config.py +++ b/mcp-server/config.py @@ -13,4 +13,9 @@ API_KEY = os.getenv("API_KEY", "") SERVER_NAME = "codeintel-mcp" -SERVER_VERSION = "0.4.0" +SERVER_VERSION = "0.5.0" + +# Transport: "stdio" for local, "streamable-http" for remote deployment +TRANSPORT = os.getenv("MCP_TRANSPORT", "stdio") +HOST = os.getenv("MCP_HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8080")) diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt index 6c415b1..f19066b 100644 --- a/mcp-server/requirements.txt +++ b/mcp-server/requirements.txt @@ -1,8 +1,9 @@ # MCP Server Dependencies -mcp>=1.0.0 +mcp>=1.25.0 httpx>=0.27.0 python-dotenv>=1.0.0 pydantic>=2.0.0 +uvicorn>=0.30.0 # Test Dependencies pytest>=8.0.0 diff --git a/mcp-server/server.py b/mcp-server/server.py index 184e200..19a7096 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,31 +1,52 @@ #!/usr/bin/env python3 """CodeIntel MCP Server entry point. -Provides codebase intelligence tools for LLMs via Model Context Protocol. -All tool definitions, handlers, and formatters are in their own modules. +Supports two transport modes: + stdio - Local dev, Claude Desktop config (default) + streamable-http - Remote deployment, Claude.ai Connectors + +Usage: + python server.py # stdio (default) + python server.py --transport http # streamable HTTP on $PORT """ -import asyncio +import sys -from mcp.server import Server -from mcp.server.models import InitializationOptions, ServerCapabilities -import mcp.server.stdio +from mcp.server.fastmcp import FastMCP import mcp.types as types +from starlette.routing import Route +from starlette.responses import JSONResponse -from config import SERVER_NAME, SERVER_VERSION +from config import SERVER_NAME, SERVER_VERSION, TRANSPORT, HOST, PORT from tools import get_tool_schemas from handlers import call_tool -from api_client import close_client -server = Server(SERVER_NAME) +mcp = FastMCP( + name=SERVER_NAME, + instructions=( + "CodeIntel provides semantic code search, dependency analysis, " + "and codebase intelligence. Use list_repositories first to find " + "repo IDs, then search_code or get_codebase_dna for context." + ), + host=HOST, + port=PORT, + streamable_http_path="/mcp", + stateless_http=True, + log_level="INFO", +) + +# Register tools at the low-level MCP server layer. +# FastMCP's @tool decorator infers schemas from function signatures, +# but we have well-tested schemas in tools.py and dispatch in handlers.py. +_server = mcp._mcp_server -@server.list_tools() +@_server.list_tools() async def handle_list_tools() -> list[types.Tool]: """Return all available tool schemas.""" return get_tool_schemas() -@server.call_tool() +@_server.call_tool() async def handle_call_tool( name: str, arguments: dict | None ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: @@ -33,22 +54,36 @@ async def handle_call_tool( return await call_tool(name, arguments) -async def main() -> None: - """Run the MCP server over stdio transport.""" - try: - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name=SERVER_NAME, - server_version=SERVER_VERSION, - capabilities=ServerCapabilities(tools={}), - ), - ) - finally: - await close_client() +# Health check for Railway/load balancers +async def _health(request): + return JSONResponse({"status": "ok", "server": SERVER_NAME, "version": SERVER_VERSION}) + + +def _get_http_app(): + """Build the Starlette app with health check + MCP endpoint.""" + app = mcp.streamable_http_app() + app.routes.insert(0, Route("/health", _health, methods=["GET"])) + return app + + +def main(): + """Run with configured transport.""" + transport = TRANSPORT + + # CLI override: --transport http + if "--transport" in sys.argv: + idx = sys.argv.index("--transport") + if idx + 1 < len(sys.argv): + arg = sys.argv[idx + 1] + transport = "streamable-http" if arg in ("http", "streamable-http") else arg + + if transport == "streamable-http": + import uvicorn + print(f"Starting {SERVER_NAME} v{SERVER_VERSION} on {HOST}:{PORT}/mcp") + uvicorn.run(_get_http_app(), host=HOST, port=PORT) + else: + mcp.run(transport="stdio") if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/supabase/migrations/004_api_keys.sql b/supabase/migrations/004_api_keys.sql new file mode 100644 index 0000000..accd0e8 --- /dev/null +++ b/supabase/migrations/004_api_keys.sql @@ -0,0 +1,41 @@ +-- Migration: Create api_keys table for MCP authentication +-- Run this in Supabase SQL Editor +-- +-- API keys use the format ci_ and are stored as SHA-256 hashes. +-- The backend validates keys by hashing the incoming token and looking +-- up the hash in this table. + +CREATE TABLE IF NOT EXISTS api_keys ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE, + name text NOT NULL, + key_hash text NOT NULL UNIQUE, + tier text DEFAULT 'free', + active boolean DEFAULT true, + created_at timestamptz DEFAULT now(), + last_used_at timestamptz +); + +-- Index for fast hash lookups during auth +CREATE INDEX IF NOT EXISTS idx_api_keys_hash_active + ON api_keys (key_hash) WHERE active = true; + +-- Enable RLS +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +-- Users can only see their own keys +CREATE POLICY "Users can view own keys" + ON api_keys FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can create own keys" + ON api_keys FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can deactivate own keys" + ON api_keys FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Service role (backend) has full access via service_role key +-- No explicit policy needed; service_role bypasses RLS From dd040c472068bc17a4e178329d6f4704d012a8b8 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 7 Mar 2026 11:45:35 -0500 Subject: [PATCH 2/4] fix: review findings + CI pipeline for MCP server tests Review fixes: - config.py: PORT parsing wrapped in try/except, falls back to 8080 - server.py: pinned mcp<2.0.0, documented _mcp_server private API access - requirements.txt: mcp>=1.25.0,<2.0.0 upper bound - Dockerfile: added HEALTHCHECK with curl to /health endpoint - 004_api_keys.sql: CHECK constraint on tier column, immutable column trigger (blocks key_hash/tier/name changes for non-service-role), documented NULL user_id = system key behavior CI pipeline: - Added mcp-server/** path filter to change detection - New test-mcp job: Python 3.13, runs pytest on mcp-server/tests/ - Security scan now also triggers on MCP changes --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++- mcp-server/Dockerfile | 5 ++++ mcp-server/config.py | 6 ++++- mcp-server/requirements.txt | 2 +- mcp-server/server.py | 5 +++- supabase/migrations/004_api_keys.sql | 33 +++++++++++++++++++++++++-- 6 files changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f0a378..7e68349 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} + mcp: ${{ steps.filter.outputs.mcp }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -28,6 +29,8 @@ jobs: - 'railway.json' frontend: - 'frontend/**' + mcp: + - 'mcp-server/**' test-backend: name: Backend Tests @@ -104,10 +107,39 @@ jobs: working-directory: ./frontend run: bun run test + test-mcp: + name: MCP Server Tests + needs: changes + if: ${{ needs.changes.outputs.mcp == 'true' }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + + - name: Install dependencies + working-directory: ./mcp-server + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + working-directory: ./mcp-server + env: + API_KEY: "test-key" + BACKEND_API_URL: "http://localhost:8000" + run: | + pytest tests/ -v + security-scan: name: Security Scan needs: changes - if: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' }} + if: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' || needs.changes.outputs.mcp == 'true' }} runs-on: ubuntu-latest steps: diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile index 67cd535..3addd11 100644 --- a/mcp-server/Dockerfile +++ b/mcp-server/Dockerfile @@ -14,4 +14,9 @@ ENV MCP_HOST=0.0.0.0 EXPOSE 8080 +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8080}/health || exit 1 + CMD ["python", "server.py"] diff --git a/mcp-server/config.py b/mcp-server/config.py index e3d57fc..30b6d17 100644 --- a/mcp-server/config.py +++ b/mcp-server/config.py @@ -18,4 +18,8 @@ # Transport: "stdio" for local, "streamable-http" for remote deployment TRANSPORT = os.getenv("MCP_TRANSPORT", "stdio") HOST = os.getenv("MCP_HOST", "0.0.0.0") -PORT = int(os.getenv("PORT", "8080")) +_port_raw = os.getenv("PORT", "8080") +try: + PORT = int(_port_raw) +except ValueError: + PORT = 8080 diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt index f19066b..8c5b7fb 100644 --- a/mcp-server/requirements.txt +++ b/mcp-server/requirements.txt @@ -1,5 +1,5 @@ # MCP Server Dependencies -mcp>=1.25.0 +mcp>=1.25.0,<2.0.0 httpx>=0.27.0 python-dotenv>=1.0.0 pydantic>=2.0.0 diff --git a/mcp-server/server.py b/mcp-server/server.py index 19a7096..636bd93 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -37,7 +37,10 @@ # Register tools at the low-level MCP server layer. # FastMCP's @tool decorator infers schemas from function signatures, # but we have well-tested schemas in tools.py and dispatch in handlers.py. -_server = mcp._mcp_server +# We access the private _mcp_server to register custom inputSchema directly. +# TODO: monitor mcp-python for a public API to register tools with custom schemas +# (see: https://github.com/modelcontextprotocol/python-sdk/issues) +_server = mcp._mcp_server # pinned to mcp>=1.25.0,<2.0.0 @_server.list_tools() diff --git a/supabase/migrations/004_api_keys.sql b/supabase/migrations/004_api_keys.sql index accd0e8..87da5e9 100644 --- a/supabase/migrations/004_api_keys.sql +++ b/supabase/migrations/004_api_keys.sql @@ -7,10 +7,13 @@ CREATE TABLE IF NOT EXISTS api_keys ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + -- NULL user_id = system/service key (e.g. dogfood-mcp). + -- RLS policies using auth.uid() = user_id will NOT match these rows; + -- only the service_role (backend) can access system keys. user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE, name text NOT NULL, key_hash text NOT NULL UNIQUE, - tier text DEFAULT 'free', + tier text DEFAULT 'free' CHECK (tier IN ('free', 'pro', 'enterprise')), active boolean DEFAULT true, created_at timestamptz DEFAULT now(), last_used_at timestamptz @@ -20,10 +23,36 @@ CREATE TABLE IF NOT EXISTS api_keys ( CREATE INDEX IF NOT EXISTS idx_api_keys_hash_active ON api_keys (key_hash) WHERE active = true; +-- Prevent users from modifying sensitive columns via UPDATE. +-- Only active and last_used_at can change; key_hash, tier, name are immutable. +-- Service role (backend/admin) bypasses this check. +CREATE OR REPLACE FUNCTION protect_api_key_immutable_cols() +RETURNS TRIGGER AS $fn$ +BEGIN + IF current_setting('role', true) = 'service_role' THEN + RETURN NEW; + END IF; + IF NEW.key_hash IS DISTINCT FROM OLD.key_hash THEN + RAISE EXCEPTION 'Cannot modify key_hash'; + END IF; + IF NEW.tier IS DISTINCT FROM OLD.tier THEN + RAISE EXCEPTION 'Cannot modify tier'; + END IF; + IF NEW.name IS DISTINCT FROM OLD.name THEN + RAISE EXCEPTION 'Cannot modify name'; + END IF; + RETURN NEW; +END; +$fn$ LANGUAGE plpgsql; + +CREATE TRIGGER api_keys_immutable_guard + BEFORE UPDATE ON api_keys + FOR EACH ROW + EXECUTE FUNCTION protect_api_key_immutable_cols(); + -- Enable RLS ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; --- Users can only see their own keys CREATE POLICY "Users can view own keys" ON api_keys FOR SELECT USING (auth.uid() = user_id); From b1edc43a510a8ea384c18a0ac172bb8a5664346b Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 7 Mar 2026 12:06:53 -0500 Subject: [PATCH 3/4] fix: validate transport, add lint+coverage to MCP CI job - server.py: unknown transport now prints error and exits non-zero instead of silently falling back to stdio - .flake8: new config matching backend (bugs-only, 120 char lines) - ci.yml: MCP job now runs flake8 lint + pytest with --cov --- .github/workflows/ci.yml | 8 +++++++- mcp-server/.flake8 | 17 +++++++++++++++++ mcp-server/server.py | 14 ++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 mcp-server/.flake8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e68349..220882e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,15 +126,21 @@ jobs: working-directory: ./mcp-server run: | python -m pip install --upgrade pip + pip install pytest-cov flake8 pip install -r requirements.txt + - name: Lint (flake8) + working-directory: ./mcp-server + run: | + flake8 . + - name: Run tests working-directory: ./mcp-server env: API_KEY: "test-key" BACKEND_API_URL: "http://localhost:8000" run: | - pytest tests/ -v + pytest tests/ -v --cov=. --cov-report=term-missing security-scan: name: Security Scan diff --git a/mcp-server/.flake8 b/mcp-server/.flake8 new file mode 100644 index 0000000..d647714 --- /dev/null +++ b/mcp-server/.flake8 @@ -0,0 +1,17 @@ +[flake8] +max-line-length = 120 + +# Match backend config: only check for real bugs, not style. +select = E9,F,W6 + +exclude = + .git, + __pycache__, + venv, + .venv + +per-file-ignores = + # handlers.py imports formatters after logger (intentional grouping) + handlers.py:E402 + # Test files may import pytest for fixtures/marks even if not directly referenced + tests/*.py:F401 diff --git a/mcp-server/server.py b/mcp-server/server.py index 636bd93..7fbabef 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -69,6 +69,9 @@ def _get_http_app(): return app +_VALID_TRANSPORTS = {"stdio", "streamable-http", "http"} + + def main(): """Run with configured transport.""" transport = TRANSPORT @@ -77,8 +80,15 @@ def main(): if "--transport" in sys.argv: idx = sys.argv.index("--transport") if idx + 1 < len(sys.argv): - arg = sys.argv[idx + 1] - transport = "streamable-http" if arg in ("http", "streamable-http") else arg + transport = sys.argv[idx + 1] + + # Normalize alias + if transport == "http": + transport = "streamable-http" + + if transport not in ("stdio", "streamable-http"): + print(f"Error: unknown transport '{transport}'. Use 'stdio' or 'http'.") + sys.exit(1) if transport == "streamable-http": import uvicorn From 34e33f8319f65330131d8c493e3da9624944d3f9 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 7 Mar 2026 12:25:32 -0500 Subject: [PATCH 4/4] fix: PORT range validation, MCP auth middleware, last_used_at tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.py: PORT now validated 1-65535, falls back to 8080 if out of range - config.py: added MCP_AUTH_TOKEN env var - server.py: Bearer token middleware on /mcp endpoint when MCP_AUTH_TOKEN set; /health remains public; no auth → 401, wrong token → 401 - auth.py: _validate_api_key now updates last_used_at on successful lookup (fire-and-forget, won't block auth on failure) - 004_api_keys.sql: documented DELETE policy intent (soft-delete via active flag) - ci.yml: supabase/migrations/** added to mcp path filter --- .github/workflows/ci.yml | 1 + backend/middleware/auth.py | 10 ++++++++++ mcp-server/.env.example | 4 ++++ mcp-server/config.py | 6 ++++++ mcp-server/server.py | 19 ++++++++++++++++++- supabase/migrations/004_api_keys.sql | 6 ++++-- 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 220882e..d16d81c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: - 'frontend/**' mcp: - 'mcp-server/**' + - 'supabase/migrations/**' test-backend: name: Backend Tests diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py index 68c8964..5ef2ade 100644 --- a/backend/middleware/auth.py +++ b/backend/middleware/auth.py @@ -122,6 +122,16 @@ def _validate_api_key(token: str) -> Optional[AuthContext]: return None key_data = result.data[0] + + # Update last_used_at timestamp (fire-and-forget, don't block auth) + try: + from datetime import datetime, timezone + db.table("api_keys").update( + {"last_used_at": datetime.now(timezone.utc).isoformat()} + ).eq("id", key_data["id"]).execute() + except Exception: + pass # Non-critical; don't fail auth over timestamp update + return AuthContext( api_key_name=key_data.get("name"), user_id=key_data.get("user_id"), diff --git a/mcp-server/.env.example b/mcp-server/.env.example index c8d5eab..df6c6f4 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -8,3 +8,7 @@ MCP_TRANSPORT=stdio # Server host/port (only used for streamable-http transport) MCP_HOST=0.0.0.0 PORT=8080 + +# Auth token for protecting /mcp endpoint (optional, recommended for remote) +# If set, clients must send Authorization: Bearer +MCP_AUTH_TOKEN= diff --git a/mcp-server/config.py b/mcp-server/config.py index 30b6d17..bde98a8 100644 --- a/mcp-server/config.py +++ b/mcp-server/config.py @@ -21,5 +21,11 @@ _port_raw = os.getenv("PORT", "8080") try: PORT = int(_port_raw) + if not (1 <= PORT <= 65535): + PORT = 8080 except ValueError: PORT = 8080 + +# Optional auth token for protecting the MCP endpoint in remote mode. +# If set, clients must send Authorization: Bearer to /mcp. +MCP_AUTH_TOKEN = os.getenv("MCP_AUTH_TOKEN", "") diff --git a/mcp-server/server.py b/mcp-server/server.py index 7fbabef..6d08040 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -16,7 +16,7 @@ from starlette.routing import Route from starlette.responses import JSONResponse -from config import SERVER_NAME, SERVER_VERSION, TRANSPORT, HOST, PORT +from config import SERVER_NAME, SERVER_VERSION, TRANSPORT, HOST, PORT, MCP_AUTH_TOKEN from tools import get_tool_schemas from handlers import call_tool @@ -64,8 +64,25 @@ async def _health(request): def _get_http_app(): """Build the Starlette app with health check + MCP endpoint.""" + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.requests import Request + app = mcp.streamable_http_app() app.routes.insert(0, Route("/health", _health, methods=["GET"])) + + if MCP_AUTH_TOKEN: + class MCPAuthMiddleware(BaseHTTPMiddleware): + """Require Bearer token on /mcp, leave /health public.""" + async def dispatch(self, request: Request, call_next): + if request.url.path == "/health": + return await call_next(request) + auth = request.headers.get("authorization", "") + if not auth.startswith("Bearer ") or auth[7:] != MCP_AUTH_TOKEN: + return JSONResponse({"error": "Unauthorized"}, status_code=401) + return await call_next(request) + + app.add_middleware(MCPAuthMiddleware) + return app diff --git a/supabase/migrations/004_api_keys.sql b/supabase/migrations/004_api_keys.sql index 87da5e9..d654496 100644 --- a/supabase/migrations/004_api_keys.sql +++ b/supabase/migrations/004_api_keys.sql @@ -66,5 +66,7 @@ CREATE POLICY "Users can deactivate own keys" USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); --- Service role (backend) has full access via service_role key --- No explicit policy needed; service_role bypasses RLS +-- No DELETE policy: users cannot hard-delete keys. +-- Deactivation (active=false) is the intended revocation mechanism. +-- Service role (backend) can hard-delete for compliance if needed. +-- Service role has full access via service_role key; bypasses RLS.