Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,9 @@ jobs:
- 'railway.json'
frontend:
- 'frontend/**'
mcp:
- 'mcp-server/**'
- 'supabase/migrations/**'

test-backend:
name: Backend Tests
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions backend/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
15 changes: 13 additions & 2 deletions mcp-server/.env.example
Original file line number Diff line number Diff line change
@@ -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 <token>
MCP_AUTH_TOKEN=
17 changes: 17 additions & 0 deletions mcp-server/.flake8
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions mcp-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
17 changes: 16 additions & 1 deletion mcp-server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
DevanshuNEU marked this conversation as resolved.

# Optional auth token for protecting the MCP endpoint in remote mode.
# If set, clients must send Authorization: Bearer <token> to /mcp.
MCP_AUTH_TOKEN = os.getenv("MCP_AUTH_TOKEN", "")
3 changes: 2 additions & 1 deletion mcp-server/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
119 changes: 92 additions & 27 deletions mcp-server/server.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,119 @@
#!/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]:
"""Dispatch tool calls to the handler layer."""
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
Comment thread
DevanshuNEU marked this conversation as resolved.


_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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


if __name__ == "__main__":
asyncio.run(main())
main()
72 changes: 72 additions & 0 deletions supabase/migrations/004_api_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- Migration: Create api_keys table for MCP authentication
-- Run this in Supabase SQL Editor
--
-- API keys use the format ci_<hex> 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
Comment thread
DevanshuNEU marked this conversation as resolved.
);

-- 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

-- 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.