diff --git a/.env.example b/.env.example index 8eb0266a..e3779d05 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,28 @@ SPLITTER_TYPE=ast # Whether to use hybrid search mode. If true, it will use both dense vector and BM25; if false, it will use only dense vector search. # HYBRID_MODE=true + +# ============================================================================= +# Docker Compose Configuration (only used by docker-compose.yml) +# ============================================================================= + +# MCP transport protocol +# MCP_TRANSPORT=sse + +# MCP server port +# MCP_PORT=8001 + +# Path to the .context directory (default: $HOME/.context) +# CONTEXT_DIR=${HOME}/.context + +# User/Group IDs for the claude-context container +# UID=1000 +# GID=1000 + +# Milvus exposed ports +# MILVUS_PORT=19530 +# MILVUS_HEALTH_PORT=9091 + +# Background sync configuration (0 = disabled) +# SYNC_INITIAL_DELAY_MS=5000 +# SYNC_INTERVAL_MS=300000 diff --git a/.gitignore b/.gitignore index 30159d04..6ff6e874 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ build/ .env.development.local .env.production.local +# Docker Compose local overrides +docker-compose.override.yml + # Logs npm-debug.log* yarn-debug.log* diff --git a/.npmrc b/.npmrc index 9b21577b..4e2c36e6 100644 --- a/.npmrc +++ b/.npmrc @@ -15,3 +15,18 @@ cache-dir=~/.pnpm-cache # Parallel execution optimization child-concurrency=4 + +# Allow native module build scripts +onlyBuiltDependencies[]=esbuild +onlyBuiltDependencies[]=tree-sitter +onlyBuiltDependencies[]=tree-sitter-c-sharp +onlyBuiltDependencies[]=tree-sitter-cpp +onlyBuiltDependencies[]=tree-sitter-go +onlyBuiltDependencies[]=tree-sitter-java +onlyBuiltDependencies[]=tree-sitter-javascript +onlyBuiltDependencies[]=tree-sitter-python +onlyBuiltDependencies[]=tree-sitter-rust +onlyBuiltDependencies[]=tree-sitter-scala +onlyBuiltDependencies[]=tree-sitter-typescript +onlyBuiltDependencies[]=tree-sitter-cli +onlyBuiltDependencies[]=unrs-resolver diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7001a366 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-slim AS builder +WORKDIR /app +COPY . . +ENV CI=true +RUN npm install -g pnpm && pnpm install --frozen-lockfile +RUN pnpm --filter @zilliz/claude-context-core build \ + && pnpm --filter @zilliz/claude-context-mcp build +RUN pnpm --filter @zilliz/claude-context-mcp deploy --legacy /deploy + +FROM node:20-slim +WORKDIR /app +COPY --from=builder /deploy . + +ENV MCP_TRANSPORT=sse +ENV MCP_PORT=8000 +EXPOSE 8000 + +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index ebacef1d..e9aac965 100644 --- a/README.md +++ b/README.md @@ -716,6 +716,305 @@ Check the `/examples` directory for complete usage examples: --- +## 🐳 Self-Hosted with Docker Compose + +Run everything locally β€” no cloud accounts needed. This sets up Milvus (vector database) and the claude-context MCP server on your machine with a single command. + +### What you get + +- A **private, local** Milvus instance for vector storage (no Zilliz Cloud account required) +- The claude-context MCP server running as a persistent SSE service +- Semantic code search available to **any** MCP client (Claude Code, Copilot, Cursor, etc.) + +### Step 1: Prerequisites + +You need: + +- **Docker Engine 24+** with **Compose v2.23+** ([install guide](https://docs.docker.com/engine/install/)) +- An **embedding API key** (e.g., [OpenAI](https://platform.openai.com/api-keys) β€” starts with `sk-`) +- **~10 GB free disk space** for Milvus data + +Verify Docker is ready: + +```bash +docker compose version # Should show v2.23.0 or higher +``` + +### Step 2: Clone the repository + +```bash +git clone https://github.com/zilliztech/claude-context.git +cd claude-context +``` + +### Step 3: Create your configuration + +Copy the example `.env` and edit it with your API key: + +```bash +mkdir -p ~/.context +cp .env.example ~/.context/.env +``` + +Open `~/.context/.env` in your editor and set **at minimum** these two values: + +```ini +# ~/.context/.env + +OPENAI_API_KEY=sk-your-actual-openai-key-here +MILVUS_ADDRESS=localhost:19530 +``` + +> **Using Ollama instead of OpenAI?** Set `EMBEDDING_PROVIDER=Ollama`, `OLLAMA_HOST=http://host.docker.internal:11434`, and comment out `OPENAI_API_KEY`. See [.env.example](.env.example) for all providers. + +### Step 4: Tell Docker where your code lives + +The MCP server needs read access to your codebases. Create a `docker-compose.override.yml` from the template: + +```bash +cp docker-compose.override.example.yml docker-compose.override.yml +``` + +Edit `docker-compose.override.yml` with your actual project paths. **Important**: source and destination must be the same path (the indexer stores absolute paths): + +```yaml +# docker-compose.override.yml +services: + claude-context: + volumes: + - /home/youruser/projects/myapp:/home/youruser/projects/myapp:ro + - /home/youruser/projects/backend:/home/youruser/projects/backend:ro +``` + +> On **macOS**, paths look like `/Users/youruser/projects/myapp`. + +### Step 5: Start the services + +```bash +docker compose up -d +``` + +The first run will: +1. Build the claude-context image (~2 min) +2. Pull the Milvus image (~1 GB download) +3. Start both services + +Wait for Milvus to become healthy (~30–90 seconds): + +```bash +docker compose ps +``` + +You should see: + +``` +NAME STATUS +milvus-standalone Up (healthy) +claude-context-mcp Up +``` + +### Step 6: Verify the MCP server is reachable + +```bash +curl -s http://localhost:8001/sse +``` + +If it hangs waiting for events β€” that's correct! The SSE endpoint is streaming. Press `Ctrl+C` to stop. + +### Step 7: Connect your AI coding tool + +The self-hosted server uses **SSE transport** on `http://localhost:8001/sse`. Configure your tool: + +
+Claude Code + +```bash +claude mcp add --transport sse claude-context http://localhost:8001/sse +``` + +Verify it was added: + +```bash +claude mcp list +``` + +
+ +
+VS Code (Copilot Chat / GitHub Copilot) + +Add to your VS Code settings (`.vscode/mcp.json` in your project, or user settings): + +```json +{ + "servers": { + "claude-context": { + "type": "sse", + "url": "http://localhost:8001/sse" + } + } +} +``` + +Restart VS Code. You should see claude-context tools available in Copilot Chat (agent mode). + +
+ +
+Cursor + +Add to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "claude-context": { + "type": "sse", + "url": "http://localhost:8001/sse" + } + } +} +``` + +
+ +
+Claude Desktop + +Add to your Claude Desktop config (`claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "claude-context": { + "type": "sse", + "url": "http://localhost:8001/sse" + } + } +} +``` + +
+ +
+Any other MCP client + +Use SSE transport pointed at: + +``` +http://localhost:8001/sse +``` + +No API keys or environment variables needed on the client side β€” all configuration lives in `~/.context/.env`. + +
+ +### Step 8: Index your codebase + +Open your AI tool and ask it to index your project: + +``` +Index the codebase at /home/youruser/projects/myapp +``` + +> **Note**: Use the **absolute path** that matches the volume mount from Step 4. + +The indexer will parse your code into an AST, generate embeddings, and store them in Milvus. Progress is reported in real time. A typical 100K-line codebase takes 2–5 minutes. + +### Step 9: Test it + +Once indexing completes, try a semantic search: + +``` +Search for functions that handle authentication in /home/youruser/projects/myapp +``` + +You should get back relevant code snippets with file paths, line numbers, and similarity scores. + +Check the indexing status at any time with: + +``` +Check the indexing status +``` + +### Troubleshooting + +| Problem | Solution | +|---------|----------| +| `docker compose ps` shows Milvus unhealthy | Wait 90 seconds for startup. Check `docker compose logs milvus` | +| `curl localhost:8001/sse` connection refused | Check `docker compose logs claude-context` β€” likely a missing `.env` | +| Indexing fails with "connection refused" | Verify `MILVUS_ADDRESS=localhost:19530` in `~/.context/.env` | +| "No such file or directory" during index | The path must match a volume mount in `docker-compose.override.yml` | +| Permission denied errors | Set `UID` and `GID` in a `.env` file next to `docker-compose.yml`: `UID=1000` and `GID=1000` (use `id -u` and `id -g` to find yours) | + +### Configuration reference + +All variables have sensible defaults. Override in a `.env` file next to `docker-compose.yml`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_PORT` | `8001` | SSE server port | +| `MCP_TRANSPORT` | `sse` | Transport protocol | +| `CONTEXT_DIR` | `$HOME/.context` | Path to .context directory | +| `UID` / `GID` | `1000` | Container user/group IDs | +| `MILVUS_PORT` | `19530` | Milvus gRPC port | +| `MILVUS_HEALTH_PORT` | `9091` | Milvus health check port | + +### Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Docker Compose β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” bridge β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Milvus │◄───network────►│ claude- β”‚ β”‚ +β”‚ β”‚ (vector β”‚ milvus:19530 β”‚ context β”‚ β”‚ +β”‚ β”‚ db) β”‚ β”‚ (MCP) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ :19530 :9091 :8001 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + host ports host port ◄── Claude Code + (optional) (SSE) ◄── Copilot + ◄── Cursor +``` + +- **Bridge network**: Services communicate internally via DNS. No `network_mode: host` needed. +- **Milvus configs**: Defined inline in `docker-compose.yml` via `configs:`. No external config files. +- **Data**: Named volume `milvus_data` (Docker-managed, persists across restarts). +- **Runtime state**: `~/.context/` bind-mounted into claude-context. + +### Daily usage + +```bash +docker compose up -d # Start all services +docker compose down # Stop all (data preserved in volume) +docker compose restart claude-context # Restart MCP only +docker compose build claude-context # Rebuild image (after git pull) +docker compose up -d --build claude-context # Rebuild + restart +docker compose logs -f # Follow all logs +``` + +### Migrating from standalone containers + +If you previously ran Milvus via `standalone_embed.sh`: + +```bash +# 1. Stop old containers +docker stop claude-context-mcp milvus-standalone +docker rm claude-context-mcp milvus-standalone + +# 2. Migrate data to named volume (idempotent β€” skips if volume already has data) +MILVUS_LEGACY_DATA=/path/to/old/volumes/milvus \ + docker compose --profile migrate run --rm milvus-migrate + +# 3. Start +docker compose up -d +``` + +--- + ## 🀝 Contributing We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get started. diff --git a/docker-compose.override.example.yml b/docker-compose.override.example.yml new file mode 100644 index 00000000..5113f105 --- /dev/null +++ b/docker-compose.override.example.yml @@ -0,0 +1,11 @@ +# Copy to docker-compose.override.yml and adjust paths. +# Each codebase mount must use the SAME path as source and destination +# because the indexer stores absolute paths. +# +# Example: +# - /home/user/projects/myapp:/home/user/projects/myapp:ro +services: + claude-context: + volumes: + - /path/to/codebase1:/path/to/codebase1:ro + - /path/to/codebase2:/path/to/codebase2:ro diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6dc16c2c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +configs: + embed_etcd: + content: | + listen-client-urls: http://0.0.0.0:2379 + advertise-client-urls: http://0.0.0.0:2379 + quota-backend-bytes: 4294967296 + auto-compaction-mode: revision + auto-compaction-retention: '1000' + milvus_user: + content: | + # Extra config to override default milvus.yaml + +services: + milvus: + container_name: milvus-standalone + image: milvusdb/milvus:v2.6.0 + command: milvus run standalone + security_opt: + - seccomp:unconfined + environment: + ETCD_USE_EMBED: "true" + ETCD_DATA_DIR: /var/lib/milvus/etcd + ETCD_CONFIG_PATH: /milvus/configs/embedEtcd.yaml + COMMON_STORAGETYPE: local + DEPLOY_MODE: STANDALONE + configs: + - source: embed_etcd + target: /milvus/configs/embedEtcd.yaml + - source: milvus_user + target: /milvus/configs/user.yaml + volumes: + - milvus_data:/var/lib/milvus + ports: + - "${MILVUS_PORT:-19530}:19530" + - "${MILVUS_HEALTH_PORT:-9091}:9091" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] + interval: 30s + start_period: 90s + timeout: 20s + retries: 3 + restart: unless-stopped + + claude-context: + container_name: claude-context-mcp + image: claude-context-mcp + build: . + user: "${UID:-1000}:${GID:-1000}" + env_file: + - ${CONTEXT_DIR:-${HOME}/.context}/.env + environment: + HOME: /home/node + MCP_TRANSPORT: ${MCP_TRANSPORT:-sse} + MCP_PORT: "${MCP_PORT:-8001}" + MILVUS_ADDRESS: milvus:19530 + volumes: + - ${CONTEXT_DIR:-${HOME}/.context}:/home/node/.context + ports: + - "${MCP_PORT:-8001}:${MCP_PORT:-8001}" + depends_on: + milvus: + condition: service_healthy + restart: unless-stopped + + milvus-migrate: + image: alpine + volumes: + - ${MILVUS_LEGACY_DATA:-/tmp}:/src:ro + - milvus_data:/dest + entrypoint: ["sh", "-c"] + command: + - | + if [ -n "$$(ls -A /dest 2>/dev/null)" ]; then + echo "Volume already has data, skipping." + exit 0 + fi + if [ ! -d /src/etcd ]; then + echo "ERROR: No Milvus data found in /src." + echo "Usage: MILVUS_LEGACY_DATA=/path/to/old/milvus docker compose --profile migrate run --rm milvus-migrate" + exit 1 + fi + echo "Migrating Milvus data from /src..." + cp -a /src/. /dest/ + echo "Migration complete." + profiles: + - migrate + +volumes: + milvus_data: diff --git a/package.json b/package.json index 4fe5a792..dd504ca4 100644 --- a/package.json +++ b/package.json @@ -47,5 +47,22 @@ "url": "https://github.com/zilliztech/claude-context.git" }, "license": "MIT", - "author": "Cheney Zhang <277584121@qq.com>" + "author": "Cheney Zhang <277584121@qq.com>", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "tree-sitter", + "tree-sitter-c-sharp", + "tree-sitter-cli", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-typescript", + "unrs-resolver" + ] + } } \ No newline at end of file diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 4a562af5..f6b7604a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -608,7 +608,7 @@ For LangChain/LangGraph integration examples, see [this example](https://github.
Other MCP Clients -The server uses stdio transport and follows the standard MCP protocol. It can be integrated with any MCP-compatible client by running: +The server uses stdio transport by default and follows the standard MCP protocol. It can be integrated with any MCP-compatible client by running: ```bash npx @zilliz/claude-context-mcp@latest @@ -616,6 +616,54 @@ npx @zilliz/claude-context-mcp@latest
+### SSE Transport (Shared Server) + +By default, each MCP client spawns its own `claude-context-mcp` process via stdio. When using multiple clients (e.g., multiple IDE tabs), this leads to duplicated processes, each consuming CPU and memory independently. + +**SSE transport** lets you run a **single shared server** that multiple clients connect to over HTTP, eliminating resource duplication. + +#### Start the SSE server + +```bash +# Via CLI flags +npx @zilliz/claude-context-mcp@latest --transport sse --port 8000 + +# Via environment variables +MCP_TRANSPORT=sse MCP_PORT=8000 npx @zilliz/claude-context-mcp@latest +``` + +#### Connect clients via SSE + +Configure your MCP client to connect to the running SSE server instead of spawning a new process: + +```json +{ + "mcpServers": { + "claude-context": { + "type": "sse", + "url": "http://localhost:8000/sse" + } + } +} +``` + +#### Docker + +```bash +docker build -t claude-context-mcp . +docker run -d --name claude-context-mcp \ + -p 8000:8000 \ + -e EMBEDDING_PROVIDER=OpenAI \ + -e OPENAI_API_KEY=your-key \ + -e MILVUS_TOKEN=your-token \ + claude-context-mcp +``` + +| Environment Variable | Description | Default | +| -------------------- | -------------------------------- | ------- | +| `MCP_TRANSPORT` | Transport mode: `stdio` or `sse` | `stdio` | +| `MCP_PORT` | Port for SSE server | `8000` | + ## Features - πŸ”Œ **MCP Protocol Compliance**: Full compatibility with MCP-enabled AI assistants and agents diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts index 428f9474..e2d1098e 100644 --- a/packages/mcp/src/config.ts +++ b/packages/mcp/src/config.ts @@ -3,6 +3,9 @@ import { envManager } from "@zilliz/claude-context-core"; export interface ContextMcpConfig { name: string; version: string; + // Transport configuration + transport: 'stdio' | 'sse'; + port: number; // Embedding provider configuration embeddingProvider: 'OpenAI' | 'VoyageAI' | 'Gemini' | 'Ollama'; embeddingModel: string; @@ -102,6 +105,16 @@ export function getEmbeddingModelForProvider(provider: string): string { } } +// Helper to parse CLI arguments (--key value pairs) +function getCliArg(name: string): string | undefined { + const args = process.argv.slice(2); + const idx = args.indexOf(`--${name}`); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +} + export function createMcpConfig(): ContextMcpConfig { // Debug: Print all environment variables related to Context console.log(`[DEBUG] πŸ” Environment Variables Debug:`); @@ -113,9 +126,16 @@ export function createMcpConfig(): ContextMcpConfig { console.log(`[DEBUG] MILVUS_ADDRESS: ${envManager.get('MILVUS_ADDRESS') || 'NOT SET'}`); console.log(`[DEBUG] NODE_ENV: ${envManager.get('NODE_ENV') || 'NOT SET'}`); + // Transport config: CLI args take priority over env vars + const transportArg = getCliArg('transport') || envManager.get('MCP_TRANSPORT') || 'stdio'; + const portArg = getCliArg('port') || envManager.get('MCP_PORT') || '8000'; + const config: ContextMcpConfig = { name: envManager.get('MCP_SERVER_NAME') || "Context MCP Server", version: envManager.get('MCP_SERVER_VERSION') || "1.0.0", + // Transport configuration + transport: transportArg === 'sse' ? 'sse' : 'stdio', + port: parseInt(portArg, 10) || 8000, // Embedding provider configuration embeddingProvider: (envManager.get('EMBEDDING_PROVIDER') as 'OpenAI' | 'VoyageAI' | 'Gemini' | 'Ollama') || 'OpenAI', embeddingModel: getEmbeddingModelForProvider(envManager.get('EMBEDDING_PROVIDER') || 'OpenAI'), @@ -141,6 +161,7 @@ export function logConfigurationSummary(config: ContextMcpConfig): void { console.log(`[MCP] πŸš€ Starting Context MCP Server`); console.log(`[MCP] Configuration Summary:`); console.log(`[MCP] Server: ${config.name} v${config.version}`); + console.log(`[MCP] Transport: ${config.transport}${config.transport === 'sse' ? ` (port ${config.port})` : ''}`); console.log(`[MCP] Embedding Provider: ${config.embeddingProvider}`); console.log(`[MCP] Embedding Model: ${config.embeddingModel}`); console.log(`[MCP] Milvus Address: ${config.milvusAddress || (config.milvusToken ? '[Auto-resolve from token]' : '[Not configured]')}`); @@ -179,8 +200,13 @@ Usage: npx @zilliz/claude-context-mcp@latest [options] Options: --help, -h Show this help message + --transport Transport mode (default: stdio) + --port Port for SSE transport (default: 8000) Environment Variables: + MCP_TRANSPORT Transport mode: stdio or sse (default: stdio) + MCP_PORT Port for SSE transport (default: 8000) + MCP_SERVER_NAME Server name MCP_SERVER_VERSION Server version @@ -221,5 +247,11 @@ Examples: # Start MCP server with Ollama and specific model (using EMBEDDING_MODEL) EMBEDDING_PROVIDER=Ollama EMBEDDING_MODEL=nomic-embed-text MILVUS_TOKEN=your-token npx @zilliz/claude-context-mcp@latest + + # Start MCP server with SSE transport (shared server for multiple clients) + npx @zilliz/claude-context-mcp@latest --transport sse --port 8000 + + # SSE transport via environment variables + MCP_TRANSPORT=sse MCP_PORT=8000 npx @zilliz/claude-context-mcp@latest `); } \ No newline at end of file diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8c4c3b28..8b5976a5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -17,10 +17,15 @@ console.warn = (...args: any[]) => { import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { ListToolsRequestSchema, - CallToolRequestSchema + CallToolRequestSchema, + isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js"; +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; import { Context } from "@zilliz/claude-context-core"; import { MilvusVectorDatabase } from "@zilliz/claude-context-core"; @@ -31,26 +36,31 @@ import { SnapshotManager } from "./snapshot.js"; import { SyncManager } from "./sync.js"; import { ToolHandlers } from "./handlers.js"; +/** Read and JSON-parse the request body from an IncomingMessage. */ +function readBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString())); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + class ContextMcpServer { - private server: Server; + private config: ContextMcpConfig; private context: Context; private snapshotManager: SnapshotManager; private syncManager: SyncManager; private toolHandlers: ToolHandlers; constructor(config: ContextMcpConfig) { - // Initialize MCP server - this.server = new Server( - { - name: config.name, - version: config.version - }, - { - capabilities: { - tools: {} - } - } - ); + this.config = config; // Initialize embedding provider console.log(`[EMBEDDING] Initializing embedding provider: ${config.embeddingProvider}`); @@ -78,11 +88,30 @@ class ContextMcpServer { // Load existing codebase snapshot on startup this.snapshotManager.loadCodebaseSnapshot(); + } - this.setupTools(); + /** + * Creates a new Server instance with tool handlers registered. + * Each client session (SSE or Streamable HTTP) needs its own Server + * instance because Protocol.connect() only supports one transport at a time. + */ + private createServer(): Server { + const server = new Server( + { + name: this.config.name, + version: this.config.version + }, + { + capabilities: { + tools: {} + } + } + ); + this.setupTools(server); + return server; } - private setupTools() { + private setupTools(server: Server) { const index_description = ` Index a codebase directory to enable semantic search using a configurable code splitter. @@ -117,7 +146,7 @@ This tool is versatile and can be used before completing various tasks to retrie `; // Define available tools - this.server.setRequestHandler(ListToolsRequestSchema, async () => { + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { @@ -226,7 +255,7 @@ This tool is versatile and can be used before completing various tasks to retrie }); // Handle tool execution - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const { name, arguments: args } = request.params; switch (name) { @@ -247,14 +276,175 @@ This tool is versatile and can be used before completing various tasks to retrie async start() { console.log('[SYNC-DEBUG] MCP server start() method called'); - console.log('Starting Context MCP server...'); - - const transport = new StdioServerTransport(); - console.log('[SYNC-DEBUG] StdioServerTransport created, attempting server connection...'); - - await this.server.connect(transport); - console.log("MCP server started and listening on stdio."); - console.log('[SYNC-DEBUG] Server connection established successfully'); + console.log(`Starting Context MCP server (transport: ${this.config.transport})...`); + + if (this.config.transport === 'sse') { + type TransportEntry = SSEServerTransport | StreamableHTTPServerTransport; + const sessions = new Map(); + const sseResponses = new Map(); // raw res refs for heartbeat + + const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || '', `http://localhost:${this.config.port}`); + const pathname = url.pathname; + + try { + // ── Streamable HTTP (protocol 2025-03-26) ───────────── + if (pathname === '/mcp') { + if (req.method === 'POST') { + let body: Record; + try { + body = await readBody(req); + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Invalid JSON'); + return; + } + + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport = sessionId ? sessions.get(sessionId) : undefined; + + if (transport && !(transport instanceof StreamableHTTPServerTransport)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Session uses a different transport protocol' }, + id: null, + })); + return; + } + + if (!transport && isInitializeRequest(body)) { + const newTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid: string) => { + console.log(`[HTTP] Streamable HTTP session initialized: ${sid}`); + sessions.set(sid, newTransport); + }, + }); + newTransport.onclose = () => { + const sid = newTransport.sessionId; + if (sid) { + console.log(`[HTTP] Streamable HTTP session closed: ${sid}`); + sessions.delete(sid); + } + }; + const server = this.createServer(); + await server.connect(newTransport); + transport = newTransport; + } + + if (transport instanceof StreamableHTTPServerTransport) { + await transport.handleRequest(req, res, body); + } else { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session' }, + id: null, + })); + } + } else if (req.method === 'GET' || req.method === 'DELETE') { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + const transport = sessionId ? sessions.get(sessionId) : undefined; + if (transport instanceof StreamableHTTPServerTransport) { + await transport.handleRequest(req, res); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Session not found'); + } + } else { + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method not allowed'); + } + } + + // ── Legacy SSE (deprecated protocol 2024-11-05) ─────── + else if (req.method === 'GET' && pathname === '/sse') { + console.log(`[SSE] New client connection`); + const transport = new SSEServerTransport('/message', res); + sessions.set(transport.sessionId, transport); + sseResponses.set(transport.sessionId, res); + + const server = this.createServer(); + + transport.onclose = () => { + console.log(`[SSE] Client disconnected (session: ${transport.sessionId})`); + sessions.delete(transport.sessionId); + sseResponses.delete(transport.sessionId); + }; + + // Redundant close handler on raw response for extra safety + res.on('close', () => { + sessions.delete(transport.sessionId); + sseResponses.delete(transport.sessionId); + }); + + await server.connect(transport); + console.log(`[SSE] Client connected (session: ${transport.sessionId}, active: ${sessions.size})`); + } + + else if (req.method === 'POST' && pathname === '/message') { + const sessionId = url.searchParams.get('sessionId'); + const transport = sessionId ? sessions.get(sessionId) : undefined; + if (transport instanceof SSEServerTransport) { + await transport.handlePostMessage(req, res); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Session not found'); + } + } + + // ── Catch-all ───────────────────────────────────────── + else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + } + } catch (error) { + console.error('[HTTP] Error handling request:', error); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal server error'); + } + } + }); + + // ── Heartbeat: detect zombie SSE sessions ───────────────── + const HEARTBEAT_INTERVAL = 30_000; + setInterval(() => { + for (const [sessionId, sseRes] of sseResponses) { + if (sseRes.writableEnded || sseRes.destroyed) { + console.log(`[SSE] Stale response detected, removing session: ${sessionId}`); + sessions.delete(sessionId); + sseResponses.delete(sessionId); + continue; + } + try { + sseRes.write(':ping\n\n'); + } catch { + console.log(`[SSE] Heartbeat failed, removing zombie session: ${sessionId}`); + sessions.delete(sessionId); + sseResponses.delete(sessionId); + } + } + if (sessions.size > 0) { + const sseCount = [...sessions.values()].filter(t => t instanceof SSEServerTransport).length; + const httpCount = [...sessions.values()].filter(t => t instanceof StreamableHTTPServerTransport).length; + console.log(`[SESSIONS] Active: ${sessions.size} (SSE: ${sseCount}, HTTP: ${httpCount})`); + } + }, HEARTBEAT_INTERVAL); + + httpServer.listen(this.config.port, () => { + console.log(`MCP server listening on http://localhost:${this.config.port}`); + console.log(` Streamable HTTP: POST|GET|DELETE /mcp`); + console.log(` Legacy SSE: GET /sse + POST /message`); + }); + } else { + // Default: stdio transport (existing behavior, unchanged) + const server = this.createServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.log("MCP server started and listening on stdio."); + } // Start background sync after server is connected console.log('[SYNC-DEBUG] Initializing background sync...'); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 75930e8b..6caea54a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,7 @@ packages: - packages/* - examples/* +approveBuildsPackages: esbuild tree-sitter tree-sitter-c-sharp tree-sitter-cli tree-sitter-cpp tree-sitter-go tree-sitter-java tree-sitter-javascript tree-sitter-python tree-sitter-rust tree-sitter-scala tree-sitter-typescript unrs-resolver + ignoredBuiltDependencies: - faiss-node