diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..89e67f9b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,61 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs (we rebuild in Docker) +dist/ +**/dist/ +.turbo/ +**/.turbo/ + +# Development files +*.log +*.local +.env +.env.* +!.env.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Test files +coverage/ +**/coverage/ +.nyc_output/ +**/.nyc_output/ + +# Git +.git/ +.gitignore + +# Documentation +docs/ +*.md +!README.md + +# Demo and examples (not needed for server) +demo/ +examples/ + +# Other packages not needed for MCP server +packages/dashboard/ +packages/vscode/ +packages/lsp/ +packages/ai/ +packages/galaxy/ + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# macOS +.DS_Store + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..99317761 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Drift MCP Server - Docker Compose Configuration +# Copy this file to .env and adjust values as needed + +# ============================================================================= +# Project Configuration +# ============================================================================= + +# Path to the project you want to analyze +# This will be mounted into the container at /project +PROJECT_PATH=. + +# Port to expose the MCP HTTP server on +DRIFT_PORT=3000 + +# ============================================================================= +# Server Configuration +# ============================================================================= + +# Enable response caching (recommended for performance) +ENABLE_CACHE=true + +# Enable rate limiting (recommended for shared deployments) +ENABLE_RATE_LIMIT=true + +# Enable verbose logging (useful for debugging) +VERBOSE=false + +# Skip warmup on startup (not recommended, but speeds up cold start) +SKIP_WARMUP=false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..48bdc9f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,88 @@ +# Drift MCP Server Docker Image +# Multi-stage build for minimal production image + +# ============================================================================= +# Stage 1: Build +# ============================================================================= +FROM node:20-slim AS builder + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@8.10.0 --activate + +# Set working directory +WORKDIR /app + +# Copy package files for dependency installation +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY packages/core/package.json ./packages/core/ +COPY packages/mcp/package.json ./packages/mcp/ +COPY packages/detectors/package.json ./packages/detectors/ +COPY packages/cli/package.json ./packages/cli/ + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY packages/core/ ./packages/core/ +COPY packages/mcp/ ./packages/mcp/ +COPY packages/detectors/ ./packages/detectors/ +COPY packages/cli/ ./packages/cli/ +COPY tsconfig.json ./ + +# Build packages (core -> detectors -> mcp) +RUN pnpm --filter driftdetect-core build && \ + pnpm --filter driftdetect-detectors build && \ + pnpm --filter driftdetect-mcp build + +# ============================================================================= +# Stage 2: Production +# ============================================================================= +FROM node:20-slim AS production + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@8.10.0 --activate + +# Create non-root user for security +RUN groupadd --gid 1001 drift && \ + useradd --uid 1001 --gid drift --shell /bin/bash --create-home drift + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/core/package.json ./packages/core/ +COPY packages/mcp/package.json ./packages/mcp/ +COPY packages/detectors/package.json ./packages/detectors/ + +# Install production dependencies only +RUN pnpm install --frozen-lockfile --prod + +# Copy built artifacts from builder +COPY --from=builder /app/packages/core/dist ./packages/core/dist +COPY --from=builder /app/packages/mcp/dist ./packages/mcp/dist +COPY --from=builder /app/packages/detectors/dist ./packages/detectors/dist + +# Create directory for mounting projects +RUN mkdir -p /project && chown drift:drift /project + +# Switch to non-root user +USER drift + +# Environment variables with defaults +ENV PORT=3000 \ + PROJECT_ROOT=/project \ + ENABLE_CACHE=true \ + ENABLE_RATE_LIMIT=true \ + VERBOSE=false \ + NODE_ENV=production + +# Expose HTTP port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://localhost:${PORT}/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + +# Run the HTTP server +CMD ["node", "packages/mcp/dist/bin/http-server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..460ca5dd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +# Drift MCP Server - Docker Compose Configuration +# +# This configuration allows you to run the Drift MCP server as a containerized +# service accessible via HTTP. The MCP protocol is exposed via SSE (Server-Sent Events). +# +# Usage: +# 1. Build and start: +# docker compose up -d +# +# 2. With custom project path: +# PROJECT_PATH=/path/to/your/project docker compose up -d +# +# 3. View logs: +# docker compose logs -f +# +# 4. Stop: +# docker compose down +# +# Endpoints: +# - http://localhost:3000/health - Health check +# - http://localhost:3000/sse - SSE endpoint for MCP +# - http://localhost:3000/message - POST endpoint for MCP messages + +services: + drift-mcp: + build: + context: . + dockerfile: Dockerfile + container_name: drift-mcp + restart: unless-stopped + ports: + - "${DRIFT_PORT:-3000}:3000" + volumes: + # Mount the project directory you want to analyze + - "${PROJECT_PATH:-.}:/project:ro" + # Optional: Mount .drift cache directory for persistence + - drift-cache:/project/.drift + environment: + # Server configuration + - PORT=3000 + - PROJECT_ROOT=/project + # Feature flags + - ENABLE_CACHE=${ENABLE_CACHE:-true} + - ENABLE_RATE_LIMIT=${ENABLE_RATE_LIMIT:-true} + - VERBOSE=${VERBOSE:-false} + - SKIP_WARMUP=${SKIP_WARMUP:-false} + # Node.js configuration + - NODE_ENV=production + - NODE_OPTIONS=--max-old-space-size=4096 + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + # Resource limits (adjust based on project size) + deploy: + resources: + limits: + memory: 4G + reservations: + memory: 1G + +volumes: + drift-cache: + driver: local diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 059ada35..b4b4ee30 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -35,7 +35,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "drift-mcp": "./dist/bin/server.js" + "drift-mcp": "./dist/bin/server.js", + "drift-mcp-http": "./dist/bin/http-server.js" }, "exports": { ".": { diff --git a/packages/mcp/src/bin/http-server.ts b/packages/mcp/src/bin/http-server.ts new file mode 100644 index 00000000..da4580d8 --- /dev/null +++ b/packages/mcp/src/bin/http-server.ts @@ -0,0 +1,290 @@ +#!/usr/bin/env node +/** + * Drift MCP HTTP Server Entry Point + * + * Exposes the MCP server over HTTP using SSE (Server-Sent Events) transport. + * This enables running Drift MCP as a containerized service accessible via HTTP. + * + * Usage: + * drift-mcp-http # Run server on default port 3000 + * drift-mcp-http --port 8080 # Run on custom port + * drift-mcp-http --project /path/to/proj # Analyze specific project + * + * Environment Variables: + * PORT - HTTP server port (default: 3000) + * PROJECT_ROOT - Path to project to analyze (default: /project) + * ENABLE_CACHE - Enable response caching (default: true) + * ENABLE_RATE_LIMIT - Enable rate limiting (default: true) + * VERBOSE - Enable verbose logging (default: false) + * + * Endpoints: + * GET /health - Health check endpoint + * GET /sse - SSE endpoint for MCP communication + * POST /message - Send messages to MCP server + */ + +import { createServer, type IncomingMessage, type ServerResponse } from 'http'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { createEnterpriseMCPServer } from '../enterprise-server.js'; + +// Configuration from environment variables +const PORT = parseInt(process.env['PORT'] ?? '3000', 10); +const PROJECT_ROOT = process.env['PROJECT_ROOT'] ?? '/project'; +const ENABLE_CACHE = process.env['ENABLE_CACHE'] !== 'false'; +const ENABLE_RATE_LIMIT = process.env['ENABLE_RATE_LIMIT'] !== 'false'; +const VERBOSE = process.env['VERBOSE'] === 'true'; +const SKIP_WARMUP = process.env['SKIP_WARMUP'] === 'true'; + +// Parse command line arguments +const args = process.argv.slice(2); +let port: number = PORT; +let projectRoot: string = PROJECT_ROOT; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const nextArg = args[i + 1]; + if (arg === '--port' && nextArg) { + port = parseInt(nextArg, 10); + i++; + } else if (arg === '--project' && nextArg) { + projectRoot = nextArg; + i++; + } +} + +// Track active transports for cleanup +const activeTransports = new Map(); +let transportIdCounter = 0; + +// Create MCP server instance +const mcpServer = createEnterpriseMCPServer({ + projectRoot, + enableCache: ENABLE_CACHE, + enableRateLimiting: ENABLE_RATE_LIMIT, + enableMetrics: true, + verbose: VERBOSE, + skipWarmup: SKIP_WARMUP, +}); + +/** + * Set CORS headers for cross-origin requests + */ +function setCorsHeaders(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept'); + res.setHeader('Access-Control-Expose-Headers', 'X-Transport-Id'); +} + +/** + * Handle health check requests + */ +function handleHealthCheck(res: ServerResponse): void { + setCorsHeaders(res); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + service: 'drift-mcp', + projectRoot, + activeConnections: activeTransports.size, + timestamp: new Date().toISOString(), + })); +} + +/** + * Handle SSE connection requests + */ +async function handleSSE(req: IncomingMessage, res: ServerResponse): Promise { + const transportId = `transport-${++transportIdCounter}`; + + if (VERBOSE) { + console.log(`[drift-mcp-http] New SSE connection: ${transportId}`); + } + + setCorsHeaders(res); + + // Create SSE transport + const transport = new SSEServerTransport('/message', res); + activeTransports.set(transportId, transport); + + // Add transport ID header so client knows which ID to use for messages + res.setHeader('X-Transport-Id', transportId); + + // Clean up on disconnect + req.on('close', () => { + if (VERBOSE) { + console.log(`[drift-mcp-http] SSE connection closed: ${transportId}`); + } + activeTransports.delete(transportId); + }); + + // Connect to MCP server + try { + await mcpServer.connect(transport); + } catch (error) { + console.error(`[drift-mcp-http] Failed to connect transport ${transportId}:`, error); + activeTransports.delete(transportId); + } +} + +/** + * Handle message POST requests + */ +async function handleMessage(req: IncomingMessage, res: ServerResponse): Promise { + setCorsHeaders(res); + + // Read body + let body = ''; + for await (const chunk of req) { + body += chunk; + } + + try { + // Find the transport to use + // The transport ID can be passed in the URL or we use the most recent one + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const transportId = url.searchParams.get('transportId'); + + let transport: SSEServerTransport | undefined; + + if (transportId) { + transport = activeTransports.get(transportId); + } else { + // Use the most recent transport + const entries = Array.from(activeTransports.entries()); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) { + transport = lastEntry[1]; + } + } + + if (!transport) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'No active SSE connection', + hint: 'Connect to /sse first before sending messages', + })); + return; + } + + // Parse and forward the message + const message = JSON.parse(body); + await transport.handlePostMessage(req, res, message); + } catch (error) { + console.error('[drift-mcp-http] Message handling error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + })); + } +} + +/** + * Handle incoming HTTP requests + */ +async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const pathname = url.pathname; + const method = req.method?.toUpperCase(); + + // Handle CORS preflight + if (method === 'OPTIONS') { + setCorsHeaders(res); + res.writeHead(204); + res.end(); + return; + } + + // Route requests + switch (pathname) { + case '/health': + handleHealthCheck(res); + break; + + case '/sse': + if (method === 'GET') { + await handleSSE(req, res); + } else { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + } + break; + + case '/message': + if (method === 'POST') { + await handleMessage(req, res); + } else { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + } + break; + + default: + // Root endpoint - provide API info + if (pathname === '/' && method === 'GET') { + setCorsHeaders(res); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + name: 'Drift MCP Server', + version: '2.0.0', + description: 'MCP server for codebase intelligence', + endpoints: { + '/health': 'Health check endpoint (GET)', + '/sse': 'SSE endpoint for MCP communication (GET)', + '/message': 'Send messages to MCP server (POST)', + }, + projectRoot, + documentation: 'https://github.com/dadbodgeoff/drift', + })); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + } +} + +// Create and start HTTP server +const server = createServer(async (req, res) => { + try { + await handleRequest(req, res); + } catch (error) { + console.error('[drift-mcp-http] Request error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } +}); + +// Graceful shutdown +async function shutdown(): Promise { + console.log('[drift-mcp-http] Shutting down...'); + + // Close all SSE connections + for (const [transportId] of activeTransports) { + if (VERBOSE) { + console.log(`[drift-mcp-http] Closing transport: ${transportId}`); + } + // The transport will be closed when we close the server + } + activeTransports.clear(); + + // Close MCP server + await mcpServer.close(); + + // Close HTTP server + server.close(() => { + console.log('[drift-mcp-http] Server stopped'); + process.exit(0); + }); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// Start server +server.listen(port, '0.0.0.0', () => { + console.log(`[drift-mcp-http] Server running at http://0.0.0.0:${port}`); + console.log(`[drift-mcp-http] Project root: ${projectRoot}`); + console.log(`[drift-mcp-http] Cache: ${ENABLE_CACHE ? 'enabled' : 'disabled'}`); + console.log(`[drift-mcp-http] Rate limiting: ${ENABLE_RATE_LIMIT ? 'enabled' : 'disabled'}`); +});