Skip to content

Commit 9e9a0e4

Browse files
committed
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.
1 parent d8a66b5 commit 9e9a0e4

6 files changed

Lines changed: 137 additions & 31 deletions

File tree

mcp-server/.env.example

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
# Backend API Configuration
2-
API_KEY=your-api-key-here
3-
BACKEND_API_URL=http://localhost:8000
2+
BACKEND_API_URL=https://api.opencodeintel.com
3+
API_KEY=ci_your-api-key-here
4+
5+
# Transport: "stdio" for local dev, "streamable-http" for remote
6+
MCP_TRANSPORT=stdio
7+
8+
# Server host/port (only used for streamable-http transport)
9+
MCP_HOST=0.0.0.0
10+
PORT=8080

mcp-server/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# MCP Server Dockerfile - Remote Streamable HTTP deployment
2+
FROM python:3.13-slim
3+
4+
WORKDIR /app
5+
6+
COPY requirements.txt .
7+
RUN pip install --no-cache-dir -r requirements.txt
8+
9+
COPY . .
10+
11+
# Railway sets PORT automatically
12+
ENV MCP_TRANSPORT=streamable-http
13+
ENV MCP_HOST=0.0.0.0
14+
15+
EXPOSE 8080
16+
17+
CMD ["python", "server.py"]

mcp-server/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,9 @@
1313
API_KEY = os.getenv("API_KEY", "")
1414

1515
SERVER_NAME = "codeintel-mcp"
16-
SERVER_VERSION = "0.4.0"
16+
SERVER_VERSION = "0.5.0"
17+
18+
# Transport: "stdio" for local, "streamable-http" for remote deployment
19+
TRANSPORT = os.getenv("MCP_TRANSPORT", "stdio")
20+
HOST = os.getenv("MCP_HOST", "0.0.0.0")
21+
PORT = int(os.getenv("PORT", "8080"))

mcp-server/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# MCP Server Dependencies
2-
mcp>=1.0.0
2+
mcp>=1.25.0
33
httpx>=0.27.0
44
python-dotenv>=1.0.0
55
pydantic>=2.0.0
6+
uvicorn>=0.30.0
67

78
# Test Dependencies
89
pytest>=8.0.0

mcp-server/server.py

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,89 @@
11
#!/usr/bin/env python3
22
"""CodeIntel MCP Server entry point.
33
4-
Provides codebase intelligence tools for LLMs via Model Context Protocol.
5-
All tool definitions, handlers, and formatters are in their own modules.
4+
Supports two transport modes:
5+
stdio - Local dev, Claude Desktop config (default)
6+
streamable-http - Remote deployment, Claude.ai Connectors
7+
8+
Usage:
9+
python server.py # stdio (default)
10+
python server.py --transport http # streamable HTTP on $PORT
611
"""
7-
import asyncio
12+
import sys
813

9-
from mcp.server import Server
10-
from mcp.server.models import InitializationOptions, ServerCapabilities
11-
import mcp.server.stdio
14+
from mcp.server.fastmcp import FastMCP
1215
import mcp.types as types
16+
from starlette.routing import Route
17+
from starlette.responses import JSONResponse
1318

14-
from config import SERVER_NAME, SERVER_VERSION
19+
from config import SERVER_NAME, SERVER_VERSION, TRANSPORT, HOST, PORT
1520
from tools import get_tool_schemas
1621
from handlers import call_tool
17-
from api_client import close_client
1822

19-
server = Server(SERVER_NAME)
23+
mcp = FastMCP(
24+
name=SERVER_NAME,
25+
instructions=(
26+
"CodeIntel provides semantic code search, dependency analysis, "
27+
"and codebase intelligence. Use list_repositories first to find "
28+
"repo IDs, then search_code or get_codebase_dna for context."
29+
),
30+
host=HOST,
31+
port=PORT,
32+
streamable_http_path="/mcp",
33+
stateless_http=True,
34+
log_level="INFO",
35+
)
36+
37+
# Register tools at the low-level MCP server layer.
38+
# FastMCP's @tool decorator infers schemas from function signatures,
39+
# but we have well-tested schemas in tools.py and dispatch in handlers.py.
40+
_server = mcp._mcp_server
2041

2142

22-
@server.list_tools()
43+
@_server.list_tools()
2344
async def handle_list_tools() -> list[types.Tool]:
2445
"""Return all available tool schemas."""
2546
return get_tool_schemas()
2647

2748

28-
@server.call_tool()
49+
@_server.call_tool()
2950
async def handle_call_tool(
3051
name: str, arguments: dict | None
3152
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
3253
"""Dispatch tool calls to the handler layer."""
3354
return await call_tool(name, arguments)
3455

3556

36-
async def main() -> None:
37-
"""Run the MCP server over stdio transport."""
38-
try:
39-
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
40-
await server.run(
41-
read_stream,
42-
write_stream,
43-
InitializationOptions(
44-
server_name=SERVER_NAME,
45-
server_version=SERVER_VERSION,
46-
capabilities=ServerCapabilities(tools={}),
47-
),
48-
)
49-
finally:
50-
await close_client()
57+
# Health check for Railway/load balancers
58+
async def _health(request):
59+
return JSONResponse({"status": "ok", "server": SERVER_NAME, "version": SERVER_VERSION})
60+
61+
62+
def _get_http_app():
63+
"""Build the Starlette app with health check + MCP endpoint."""
64+
app = mcp.streamable_http_app()
65+
app.routes.insert(0, Route("/health", _health, methods=["GET"]))
66+
return app
67+
68+
69+
def main():
70+
"""Run with configured transport."""
71+
transport = TRANSPORT
72+
73+
# CLI override: --transport http
74+
if "--transport" in sys.argv:
75+
idx = sys.argv.index("--transport")
76+
if idx + 1 < len(sys.argv):
77+
arg = sys.argv[idx + 1]
78+
transport = "streamable-http" if arg in ("http", "streamable-http") else arg
79+
80+
if transport == "streamable-http":
81+
import uvicorn
82+
print(f"Starting {SERVER_NAME} v{SERVER_VERSION} on {HOST}:{PORT}/mcp")
83+
uvicorn.run(_get_http_app(), host=HOST, port=PORT)
84+
else:
85+
mcp.run(transport="stdio")
5186

5287

5388
if __name__ == "__main__":
54-
asyncio.run(main())
89+
main()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
-- Migration: Create api_keys table for MCP authentication
2+
-- Run this in Supabase SQL Editor
3+
--
4+
-- API keys use the format ci_<hex> and are stored as SHA-256 hashes.
5+
-- The backend validates keys by hashing the incoming token and looking
6+
-- up the hash in this table.
7+
8+
CREATE TABLE IF NOT EXISTS api_keys (
9+
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
10+
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
11+
name text NOT NULL,
12+
key_hash text NOT NULL UNIQUE,
13+
tier text DEFAULT 'free',
14+
active boolean DEFAULT true,
15+
created_at timestamptz DEFAULT now(),
16+
last_used_at timestamptz
17+
);
18+
19+
-- Index for fast hash lookups during auth
20+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash_active
21+
ON api_keys (key_hash) WHERE active = true;
22+
23+
-- Enable RLS
24+
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
25+
26+
-- Users can only see their own keys
27+
CREATE POLICY "Users can view own keys"
28+
ON api_keys FOR SELECT
29+
USING (auth.uid() = user_id);
30+
31+
CREATE POLICY "Users can create own keys"
32+
ON api_keys FOR INSERT
33+
WITH CHECK (auth.uid() = user_id);
34+
35+
CREATE POLICY "Users can deactivate own keys"
36+
ON api_keys FOR UPDATE
37+
USING (auth.uid() = user_id)
38+
WITH CHECK (auth.uid() = user_id);
39+
40+
-- Service role (backend) has full access via service_role key
41+
-- No explicit policy needed; service_role bypasses RLS

0 commit comments

Comments
 (0)