diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f0a378..d16d81c 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,9 @@ jobs: - 'railway.json' frontend: - 'frontend/**' + mcp: + - 'mcp-server/**' + - 'supabase/migrations/**' test-backend: name: Backend Tests @@ -104,10 +108,45 @@ 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 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 --cov=. --cov-report=term-missing + 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/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 6ddd573..df6c6f4 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -1,3 +1,14 @@ # 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 + +# 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/.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/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..3addd11 --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,22 @@ +# 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 + +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 9cafd13..bde98a8 100644 --- a/mcp-server/config.py +++ b/mcp-server/config.py @@ -13,4 +13,19 @@ 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_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/requirements.txt b/mcp-server/requirements.txt index 6c415b1..8c5b7fb 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,<2.0.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..6d08040 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,31 +1,55 @@ #!/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, MCP_AUTH_TOKEN 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. +# 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() +@_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 +57,63 @@ 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.""" + 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 + + +_VALID_TRANSPORTS = {"stdio", "streamable-http", "http"} + + +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): + 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 + 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..d654496 --- /dev/null +++ b/supabase/migrations/004_api_keys.sql @@ -0,0 +1,72 @@ +-- 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, + -- 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' CHECK (tier IN ('free', 'pro', 'enterprise')), + 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; + +-- 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; + +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); + +-- 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.