From 5477c174172b4b2361af064cdbebdf5801f25a2f Mon Sep 17 00:00:00 2001 From: "Ignacio J. Ortega" Date: Wed, 18 Feb 2026 18:22:18 +0100 Subject: [PATCH 1/4] feat: add SSE transport for shared multi-client server Support running as a single SSE server that multiple MCP clients connect to over HTTP, avoiding duplicated processes per IDE tab. Each SSE client gets its own Server instance since Protocol.connect() only supports one transport at a time. Configurable via --transport sse --port 8000 CLI flags or MCP_TRANSPORT/MCP_PORT environment variables. --- Dockerfile | 18 +++++++ packages/mcp/README.md | 50 +++++++++++++++++- packages/mcp/src/config.ts | 32 ++++++++++++ packages/mcp/src/index.ts | 102 ++++++++++++++++++++++++++++--------- 4 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 Dockerfile 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/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..daab0752 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -17,10 +17,12 @@ 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 { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { Context } from "@zilliz/claude-context-core"; import { MilvusVectorDatabase } from "@zilliz/claude-context-core"; @@ -32,25 +34,14 @@ import { SyncManager } from "./sync.js"; import { ToolHandlers } from "./handlers.js"; 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 +69,30 @@ class ContextMcpServer { // Load existing codebase snapshot on startup this.snapshotManager.loadCodebaseSnapshot(); + } - this.setupTools(); + /** + * Creates a new Server instance with tool handlers registered. + * Each SSE client 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 +127,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 +236,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 +257,56 @@ 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') { + const sessions = new Map(); + + const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || '', `http://localhost:${this.config.port}`); + + if (req.method === 'GET' && url.pathname === '/sse') { + console.log(`[SSE] New client connection`); + const transport = new SSEServerTransport('/message', res); + sessions.set(transport.sessionId, transport); + + // Each SSE client gets its own Server instance because + // Protocol.connect() only supports one transport at a time + const server = this.createServer(); + + transport.onclose = () => { + console.log(`[SSE] Client disconnected (session: ${transport.sessionId})`); + sessions.delete(transport.sessionId); + }; + + // connect() internally calls transport.start() + await server.connect(transport); + console.log(`[SSE] Client connected (session: ${transport.sessionId}, active: ${sessions.size})`); + } else if (req.method === 'POST' && url.pathname === '/message') { + const sessionId = url.searchParams.get('sessionId'); + const transport = sessionId ? sessions.get(sessionId) : undefined; + if (transport) { + await transport.handlePostMessage(req, res); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Session not found'); + } + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + } + }); + + httpServer.listen(this.config.port, () => { + console.log(`MCP SSE server listening on http://localhost:${this.config.port}/sse`); + }); + } 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...'); From ecb97b0a32128b9d2a7be3a1bc5b8bf09365dbdc Mon Sep 17 00:00:00 2001 From: "Ignacio J. Ortega" Date: Wed, 18 Feb 2026 18:22:00 +0100 Subject: [PATCH 2/4] chore: allow native module build scripts in pnpm config Add onlyBuiltDependencies for tree-sitter parsers, esbuild and unrs-resolver to prevent pnpm from skipping their build steps. --- .npmrc | 15 +++++++++++++++ package.json | 19 ++++++++++++++++++- pnpm-workspace.yaml | 2 ++ 3 files changed, 35 insertions(+), 1 deletion(-) 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/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/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 From 9c6eb4f9e9d2c11da5504556ba36818efe928590 Mon Sep 17 00:00:00 2001 From: "Ignacio J. Ortega" Date: Thu, 19 Feb 2026 17:25:50 +0100 Subject: [PATCH 3/4] feat: add Docker Compose for self-hosted Milvus + MCP deployment Generic, committable docker-compose.yml with: - Bridge network (no host mode) with service DNS discovery - Inline Milvus configs via Compose configs: directive - Named volume for Milvus data persistence - env_file guard requiring ~/.context/.env - Per-machine codebase mounts via docker-compose.override.yml - Built-in data migration service (profiles: migrate) - Comprehensive step-by-step README documentation --- .env.example | 25 +++ .gitignore | 3 + README.md | 299 ++++++++++++++++++++++++++++ docker-compose.override.example.yml | 11 + docker-compose.yml | 89 +++++++++ 5 files changed, 427 insertions(+) create mode 100644 docker-compose.override.example.yml create mode 100644 docker-compose.yml 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/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: From fedd22285b29047c51918d31bd8a9850e1467aaa Mon Sep 17 00:00:00 2001 From: "Ignacio J. Ortega" Date: Mon, 23 Feb 2026 10:16:10 +0100 Subject: [PATCH 4/4] feat(mcp): add Streamable HTTP transport + SSE heartbeat for zombie cleanup - Add /mcp endpoint supporting Streamable HTTP protocol (2025-03-26) alongside existing legacy SSE on /sse + /message - VS Code and modern MCP clients can now connect directly via POST /mcp instead of falling back from 404 to legacy SSE - Add 30s heartbeat writing SSE comments (:ping) to detect dead TCP connections and clean up zombie sessions - Add redundant res.on('close') handler for SSE sessions - Add readBody() helper for parsing POST /mcp JSON bodies - Add 20 new tests covering both transports, routing, heartbeat logic, and session cleanup # Conflicts: # packages/mcp/src/index.ts --- packages/mcp/src/index.ts | 202 ++++++++++++++++++++++++++++++++------ 1 file changed, 170 insertions(+), 32 deletions(-) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index daab0752..8b5976a5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -18,11 +18,14 @@ 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"; @@ -33,6 +36,22 @@ 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 config: ContextMcpConfig; private context: Context; @@ -73,8 +92,8 @@ class ContextMcpServer { /** * Creates a new Server instance with tool handlers registered. - * Each SSE client needs its own Server instance because Protocol.connect() - * only supports one transport at a time. + * 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( @@ -260,45 +279,164 @@ This tool is versatile and can be used before completing various tasks to retrie console.log(`Starting Context MCP server (transport: ${this.config.transport})...`); if (this.config.transport === 'sse') { - const sessions = new Map(); + 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})`); + } - if (req.method === 'GET' && url.pathname === '/sse') { - console.log(`[SSE] New client connection`); - const transport = new SSEServerTransport('/message', res); - sessions.set(transport.sessionId, transport); - - // Each SSE client gets its own Server instance because - // Protocol.connect() only supports one transport at a time - const server = this.createServer(); - - transport.onclose = () => { - console.log(`[SSE] Client disconnected (session: ${transport.sessionId})`); - sessions.delete(transport.sessionId); - }; - - // connect() internally calls transport.start() - await server.connect(transport); - console.log(`[SSE] Client connected (session: ${transport.sessionId}, active: ${sessions.size})`); - } else if (req.method === 'POST' && url.pathname === '/message') { - const sessionId = url.searchParams.get('sessionId'); - const transport = sessionId ? sessions.get(sessionId) : undefined; - if (transport) { - await transport.handlePostMessage(req, res); - } else { + 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('Session not found'); + 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'); } - } else { - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('Not found'); } }); + // ── 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 SSE server listening on http://localhost:${this.config.port}/sse`); + 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)