Fixing Claude Code's SIGINT Problem: How I Built MCP Session Manager

Fixing Claude Code's SIGINT Problem: How I Built MCP Session Manager

Source: Dev.to

Introduction ## The Problem: SIGINT vs Database Lock ## The Default Architecture (Problematic) ## The Solution: 3-Layer Architecture ## Design Principles ## Implementation Details ## 1. SIGINT Handler in Proxy ## 2. Transport Support ## 3. Streamable-HTTP Transport ## 4. SSE Transport (Deprecated Format) ## 5. stdin Close Handling ## 6. Auto-start Daemon ## 7. Per-Project Memory Database ## Solution: Propagate Project Path via HTTP Header ## memory-mcp-sqlite Side ## Store Caching ## DB Path Priority ## Install ## Generate Config ## Restart Claude Code ## Verify ## Troubleshooting ## Check daemon status ## View daemon logs ## Remove stale lock files ## Summary ## Bonus: Auto-start Daemons on Terminal Launch (Windows) ## Add to PowerShell Profile ## Key Points ## Resources ## Read on Other Platforms ## About the Author In my previous article, I implemented a WAL-mode SQLite backend for Memory MCP to solve database locking issues. But that wasn't the end of the story. Every time I opened a new Claude Code session, the existing session's MCPs would disconnect. The WAL mode solved database contention, but there was a completely different problem lurking underneath. Root cause: Claude Code sends SIGINT to existing MCP processes when starting new sessions. This article explains how I built mcp-session-manager to solve this problem. Let me clarify the distinction between the two issues: Even with WAL mode enabled, if the MCP process itself dies, there's nothing to access the database. I needed to rethink the architecture fundamentally. Here's how Claude Code handles MCPs by default: When Session B starts: You might think "just handle SIGINT with process.on('SIGINT', ...)", but that's not enough. Even if the process survives, resource conflicts (like FileWatcher) remain unsolved. The solution is straightforward: "Each session gets a lightweight proxy; actual processing happens in a shared daemon." The most critical part. Set the handler at the very top of the file: MCP uses different transport formats. I had to support all of them: Based on MCP 2025-03-26 specification: The ast-grep-mcp uses FastMCP, which implements the deprecated SSE format: FastMCP's /sse endpoint returns session ID like this: Notice the /messages/?session_id=... format instead of the typical /messages/<session_id>. This cost me 2 hours of debugging. Another gotcha: if the proxy exits immediately when stdin closes, it might interrupt in-flight requests. The proxy automatically starts the daemon if it's not running: After solving the SIGINT problem, I discovered another issue: memory was being shared across different projects. Content memorized in Project A was visible in Project B. Not good. Using AsyncLocalStorage to manage per-request context: Efficiently manage DB connections for multiple projects: This creates ~/.claude/mcp.json: Restart to apply the new configuration. Open multiple Claude Code sessions - they should all work simultaneously without disconnections. I built mcp-session-manager to solve Claude Code's SIGINT problem. Combined with the previous WAL mode implementation, Claude Code's multi-session and multi-project operation is now fully stable. Manually starting daemons every time is tedious. I added an auto-start script to my PowerShell profile. Add to $PROFILE (usually ~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1): Now daemons start automatically when you open a terminal. I hope this helps anyone struggling with multi-session Claude Code operation. Issues and PRs welcome! Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: [MCP Disconnected] memory Connection to MCP server 'memory' was lost Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: [MCP Disconnected] memory Connection to MCP server 'memory' was lost CODE_BLOCK: [MCP Disconnected] memory Connection to MCP server 'memory' was lost CODE_BLOCK: Session A (Claude Code Window 1) Session B (Claude Code Window 2) | | v v [MCP Process A-1] [MCP Process B-1] [MCP Process A-2] [MCP Process B-2] [MCP Process A-3] [MCP Process B-3] | | +-------- RESOURCE CONFLICT ---------+ | [SQLite DB File] [File Watchers] [In-memory State] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: Session A (Claude Code Window 1) Session B (Claude Code Window 2) | | v v [MCP Process A-1] [MCP Process B-1] [MCP Process A-2] [MCP Process B-2] [MCP Process A-3] [MCP Process B-3] | | +-------- RESOURCE CONFLICT ---------+ | [SQLite DB File] [File Watchers] [In-memory State] CODE_BLOCK: Session A (Claude Code Window 1) Session B (Claude Code Window 2) | | v v [MCP Process A-1] [MCP Process B-1] [MCP Process A-2] [MCP Process B-2] [MCP Process A-3] [MCP Process B-3] | | +-------- RESOURCE CONFLICT ---------+ | [SQLite DB File] [File Watchers] [In-memory State] CODE_BLOCK: Session A Session B | | v v [Proxy A] -------- HTTP -------- [MCP Daemon] (stdio) shared (HTTP/SSE) | | [Claude A] [Claude B] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: Session A Session B | | v v [Proxy A] -------- HTTP -------- [MCP Daemon] (stdio) shared (HTTP/SSE) | | [Claude A] [Claude B] CODE_BLOCK: Session A Session B | | v v [Proxy A] -------- HTTP -------- [MCP Daemon] (stdio) shared (HTTP/SSE) | | [Claude A] [Claude B] COMMAND_BLOCK: // proxy/index.ts - at the very top process.on("SIGINT", () => { // Ignore SIGINT - let the session continue console.error("[Proxy] Received SIGINT - ignoring for multi-session stability"); }); // Imports come after import { Command } from "commander"; // ... Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // proxy/index.ts - at the very top process.on("SIGINT", () => { // Ignore SIGINT - let the session continue console.error("[Proxy] Received SIGINT - ignoring for multi-session stability"); }); // Imports come after import { Command } from "commander"; // ... COMMAND_BLOCK: // proxy/index.ts - at the very top process.on("SIGINT", () => { // Ignore SIGINT - let the session continue console.error("[Proxy] Received SIGINT - ignoring for multi-session stability"); }); // Imports come after import { Command } from "commander"; // ... COMMAND_BLOCK: // proxy/client.ts export async function sendRequest( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { switch (client.transport) { case "sse": return sendRequestSSE(client, message); case "streamable-http": return sendRequestStreamableHttp(client, message); case "http": default: return sendRequestHttp(client, message); } } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // proxy/client.ts export async function sendRequest( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { switch (client.transport) { case "sse": return sendRequestSSE(client, message); case "streamable-http": return sendRequestStreamableHttp(client, message); case "http": default: return sendRequestHttp(client, message); } } COMMAND_BLOCK: // proxy/client.ts export async function sendRequest( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { switch (client.transport) { case "sse": return sendRequestSSE(client, message); case "streamable-http": return sendRequestStreamableHttp(client, message); case "http": default: return sendRequestHttp(client, message); } } COMMAND_BLOCK: async function sendRequestStreamableHttp( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" // This is crucial }; if (client.sessionId) { headers["Mcp-Session-Id"] = client.sessionId; } const response = await fetch(`${client.baseUrl}/mcp`, { method: "POST", headers, body: JSON.stringify(message), signal: AbortSignal.timeout(60000) }); // Capture session ID const sessionId = response.headers.get("Mcp-Session-Id"); if (sessionId) { client.sessionId = sessionId; } const contentType = response.headers.get("Content-Type") || ""; // Handle SSE response if (contentType.includes("text/event-stream")) { return await handleSSEResponse(response, message.id); } // Handle JSON response return await response.json() as JsonRpcResponse; } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: async function sendRequestStreamableHttp( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" // This is crucial }; if (client.sessionId) { headers["Mcp-Session-Id"] = client.sessionId; } const response = await fetch(`${client.baseUrl}/mcp`, { method: "POST", headers, body: JSON.stringify(message), signal: AbortSignal.timeout(60000) }); // Capture session ID const sessionId = response.headers.get("Mcp-Session-Id"); if (sessionId) { client.sessionId = sessionId; } const contentType = response.headers.get("Content-Type") || ""; // Handle SSE response if (contentType.includes("text/event-stream")) { return await handleSSEResponse(response, message.id); } // Handle JSON response return await response.json() as JsonRpcResponse; } COMMAND_BLOCK: async function sendRequestStreamableHttp( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" // This is crucial }; if (client.sessionId) { headers["Mcp-Session-Id"] = client.sessionId; } const response = await fetch(`${client.baseUrl}/mcp`, { method: "POST", headers, body: JSON.stringify(message), signal: AbortSignal.timeout(60000) }); // Capture session ID const sessionId = response.headers.get("Mcp-Session-Id"); if (sessionId) { client.sessionId = sessionId; } const contentType = response.headers.get("Content-Type") || ""; // Handle SSE response if (contentType.includes("text/event-stream")) { return await handleSSEResponse(response, message.id); } // Handle JSON response return await response.json() as JsonRpcResponse; } COMMAND_BLOCK: async function sendRequestSSE( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { // Initialize SSE session if (!client.sseSessionId || !client.sseReader) { const sessionResult = await initializeSSESession(client); if (!sessionResult.success) { return createErrorResponse(message.id, -32603, sessionResult.error); } } // Handle URL format (FastMCP gotcha!) let messagesUrl: string; if (client.sseSessionId!.startsWith("?")) { // Query parameter format: /messages?session_id=... messagesUrl = `${client.baseUrl}/messages${client.sseSessionId}`; } else { // Path format: /messages/<session_id> messagesUrl = `${client.baseUrl}/messages/${client.sseSessionId}`; } // Send request const response = await fetch(messagesUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message) }); // Response comes from SSE stream return await waitForSSEResponse(client, message.id); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: async function sendRequestSSE( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { // Initialize SSE session if (!client.sseSessionId || !client.sseReader) { const sessionResult = await initializeSSESession(client); if (!sessionResult.success) { return createErrorResponse(message.id, -32603, sessionResult.error); } } // Handle URL format (FastMCP gotcha!) let messagesUrl: string; if (client.sseSessionId!.startsWith("?")) { // Query parameter format: /messages?session_id=... messagesUrl = `${client.baseUrl}/messages${client.sseSessionId}`; } else { // Path format: /messages/<session_id> messagesUrl = `${client.baseUrl}/messages/${client.sseSessionId}`; } // Send request const response = await fetch(messagesUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message) }); // Response comes from SSE stream return await waitForSSEResponse(client, message.id); } COMMAND_BLOCK: async function sendRequestSSE( client: DaemonClient, message: JsonRpcRequest ): Promise<JsonRpcResponse> { // Initialize SSE session if (!client.sseSessionId || !client.sseReader) { const sessionResult = await initializeSSESession(client); if (!sessionResult.success) { return createErrorResponse(message.id, -32603, sessionResult.error); } } // Handle URL format (FastMCP gotcha!) let messagesUrl: string; if (client.sseSessionId!.startsWith("?")) { // Query parameter format: /messages?session_id=... messagesUrl = `${client.baseUrl}/messages${client.sseSessionId}`; } else { // Path format: /messages/<session_id> messagesUrl = `${client.baseUrl}/messages/${client.sseSessionId}`; } // Send request const response = await fetch(messagesUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message) }); // Response comes from SSE stream return await waitForSSEResponse(client, message.id); } CODE_BLOCK: event: endpoint data: /messages/?session_id=abc123 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: event: endpoint data: /messages/?session_id=abc123 CODE_BLOCK: event: endpoint data: /messages/?session_id=abc123 COMMAND_BLOCK: let pendingRequests = 0; let stdinClosed = false; const checkExit = async () => { // Only exit when stdin is closed AND all requests are complete if (stdinClosed && pendingRequests === 0) { log("All requests completed, cleaning up..."); await closeSession(client); process.exit(0); } }; rl.on("line", async (line) => { const message = JSON.parse(line) as JsonRpcRequest; if (message.id !== undefined) { pendingRequests++; try { const response = await sendRequest(client, message); process.stdout.write(JSON.stringify(response) + "\n"); } finally { pendingRequests--; await checkExit(); } } }); rl.on("close", async () => { stdinClosed = true; await checkExit(); }); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: let pendingRequests = 0; let stdinClosed = false; const checkExit = async () => { // Only exit when stdin is closed AND all requests are complete if (stdinClosed && pendingRequests === 0) { log("All requests completed, cleaning up..."); await closeSession(client); process.exit(0); } }; rl.on("line", async (line) => { const message = JSON.parse(line) as JsonRpcRequest; if (message.id !== undefined) { pendingRequests++; try { const response = await sendRequest(client, message); process.stdout.write(JSON.stringify(response) + "\n"); } finally { pendingRequests--; await checkExit(); } } }); rl.on("close", async () => { stdinClosed = true; await checkExit(); }); COMMAND_BLOCK: let pendingRequests = 0; let stdinClosed = false; const checkExit = async () => { // Only exit when stdin is closed AND all requests are complete if (stdinClosed && pendingRequests === 0) { log("All requests completed, cleaning up..."); await closeSession(client); process.exit(0); } }; rl.on("line", async (line) => { const message = JSON.parse(line) as JsonRpcRequest; if (message.id !== undefined) { pendingRequests++; try { const response = await sendRequest(client, message); process.stdout.write(JSON.stringify(response) + "\n"); } finally { pendingRequests--; await checkExit(); } } }); rl.on("close", async () => { stdinClosed = true; await checkExit(); }); COMMAND_BLOCK: async function getDaemonInfo(name: string): Promise<{ port: number; transport: TransportType } | null> { const config = getDaemonConfig(name); if (!config) return null; // 1. Ping port to detect existing daemon const isAliveOnPort = await pingDaemon(config.port, config.transport); if (isAliveOnPort) { return { port: config.port, transport: config.transport }; } // 2. Check lock file const lockData = readLockFile(name); if (lockData) { const isAlive = await pingDaemon(lockData.port, lockData.transport); if (isAlive) { return { port: lockData.port, transport: lockData.transport }; } } // 3. Try via Manager API const managerResult = await ensureDaemonViaManager(name); if (managerResult) return managerResult; // 4. Fallback: start directly return startDaemonDirectly(name); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: async function getDaemonInfo(name: string): Promise<{ port: number; transport: TransportType } | null> { const config = getDaemonConfig(name); if (!config) return null; // 1. Ping port to detect existing daemon const isAliveOnPort = await pingDaemon(config.port, config.transport); if (isAliveOnPort) { return { port: config.port, transport: config.transport }; } // 2. Check lock file const lockData = readLockFile(name); if (lockData) { const isAlive = await pingDaemon(lockData.port, lockData.transport); if (isAlive) { return { port: lockData.port, transport: lockData.transport }; } } // 3. Try via Manager API const managerResult = await ensureDaemonViaManager(name); if (managerResult) return managerResult; // 4. Fallback: start directly return startDaemonDirectly(name); } COMMAND_BLOCK: async function getDaemonInfo(name: string): Promise<{ port: number; transport: TransportType } | null> { const config = getDaemonConfig(name); if (!config) return null; // 1. Ping port to detect existing daemon const isAliveOnPort = await pingDaemon(config.port, config.transport); if (isAliveOnPort) { return { port: config.port, transport: config.transport }; } // 2. Check lock file const lockData = readLockFile(name); if (lockData) { const isAlive = await pingDaemon(lockData.port, lockData.transport); if (isAlive) { return { port: lockData.port, transport: lockData.transport }; } } // 3. Try via Manager API const managerResult = await ensureDaemonViaManager(name); if (managerResult) return managerResult; // 4. Fallback: start directly return startDaemonDirectly(name); } COMMAND_BLOCK: // proxy/index.ts - detect and send project path const projectPath = process.cwd(); const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Mcp-Project-Path": projectPath // Send project path }; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // proxy/index.ts - detect and send project path const projectPath = process.cwd(); const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Mcp-Project-Path": projectPath // Send project path }; COMMAND_BLOCK: // proxy/index.ts - detect and send project path const projectPath = process.cwd(); const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Mcp-Project-Path": projectPath // Send project path }; COMMAND_BLOCK: import { AsyncLocalStorage } from "node:async_hooks"; interface RequestContext { projectPath?: string; } const asyncLocalStorage = new AsyncLocalStorage<RequestContext>(); // Set context in request handler app.use((req, res, next) => { const projectPath = req.headers["mcp-project-path"] as string | undefined; asyncLocalStorage.run({ projectPath }, () => next()); }); // Get DB path from context function getDbPath(): string { const context = asyncLocalStorage.getStore(); if (context?.projectPath) { const projectDbPath = path.join(context.projectPath, ".claude", "memory.db"); if (canWriteTo(projectDbPath)) { return projectDbPath; } } return path.join(os.homedir(), ".claude", "memory.db"); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: import { AsyncLocalStorage } from "node:async_hooks"; interface RequestContext { projectPath?: string; } const asyncLocalStorage = new AsyncLocalStorage<RequestContext>(); // Set context in request handler app.use((req, res, next) => { const projectPath = req.headers["mcp-project-path"] as string | undefined; asyncLocalStorage.run({ projectPath }, () => next()); }); // Get DB path from context function getDbPath(): string { const context = asyncLocalStorage.getStore(); if (context?.projectPath) { const projectDbPath = path.join(context.projectPath, ".claude", "memory.db"); if (canWriteTo(projectDbPath)) { return projectDbPath; } } return path.join(os.homedir(), ".claude", "memory.db"); } COMMAND_BLOCK: import { AsyncLocalStorage } from "node:async_hooks"; interface RequestContext { projectPath?: string; } const asyncLocalStorage = new AsyncLocalStorage<RequestContext>(); // Set context in request handler app.use((req, res, next) => { const projectPath = req.headers["mcp-project-path"] as string | undefined; asyncLocalStorage.run({ projectPath }, () => next()); }); // Get DB path from context function getDbPath(): string { const context = asyncLocalStorage.getStore(); if (context?.projectPath) { const projectDbPath = path.join(context.projectPath, ".claude", "memory.db"); if (canWriteTo(projectDbPath)) { return projectDbPath; } } return path.join(os.homedir(), ".claude", "memory.db"); } CODE_BLOCK: const storeCache = new Map<string, KnowledgeGraphStore>(); function getStore(dbPath: string): KnowledgeGraphStore { if (!storeCache.has(dbPath)) { storeCache.set(dbPath, new KnowledgeGraphStore(dbPath)); } return storeCache.get(dbPath)!; } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: const storeCache = new Map<string, KnowledgeGraphStore>(); function getStore(dbPath: string): KnowledgeGraphStore { if (!storeCache.has(dbPath)) { storeCache.set(dbPath, new KnowledgeGraphStore(dbPath)); } return storeCache.get(dbPath)!; } CODE_BLOCK: const storeCache = new Map<string, KnowledgeGraphStore>(); function getStore(dbPath: string): KnowledgeGraphStore { if (!storeCache.has(dbPath)) { storeCache.set(dbPath, new KnowledgeGraphStore(dbPath)); } return storeCache.get(dbPath)!; } COMMAND_BLOCK: npm install -g mcp-session-manager Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: npm install -g mcp-session-manager COMMAND_BLOCK: npm install -g mcp-session-manager CODE_BLOCK: mcp-manager generate-config Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: mcp-manager generate-config CODE_BLOCK: mcp-manager generate-config CODE_BLOCK: { "mcpServers": { "memory": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "memory"] }, "code-index": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "code-index"] }, "ast-grep": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "ast-grep"] } } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: { "mcpServers": { "memory": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "memory"] }, "code-index": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "code-index"] }, "ast-grep": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "ast-grep"] } } } CODE_BLOCK: { "mcpServers": { "memory": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "memory"] }, "code-index": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "code-index"] }, "ast-grep": { "command": "node", "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "ast-grep"] } } } COMMAND_BLOCK: curl http://localhost:3199/status Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: curl http://localhost:3199/status COMMAND_BLOCK: curl http://localhost:3199/status COMMAND_BLOCK: type %USERPROFILE%\.mcp-session-manager\memory.log # macOS/Linux cat ~/.mcp-session-manager/memory.log Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: type %USERPROFILE%\.mcp-session-manager\memory.log # macOS/Linux cat ~/.mcp-session-manager/memory.log COMMAND_BLOCK: type %USERPROFILE%\.mcp-session-manager\memory.log # macOS/Linux cat ~/.mcp-session-manager/memory.log COMMAND_BLOCK: # Windows del %USERPROFILE%\.mcp-session-manager\*.lock # macOS/Linux rm ~/.mcp-session-manager/*.lock Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # Windows del %USERPROFILE%\.mcp-session-manager\*.lock # macOS/Linux rm ~/.mcp-session-manager/*.lock COMMAND_BLOCK: # Windows del %USERPROFILE%\.mcp-session-manager\*.lock # macOS/Linux rm ~/.mcp-session-manager/*.lock COMMAND_BLOCK: function Start-McpDaemonsIfNeeded { $mcpDir = "C:\path\to\mcp-session-manager" $lockFile = "$env:TEMP\mcp-daemons-starting.lock" $lockTimeout = 120 # seconds # Check if ports are already listening try { $port3101 = Get-NetTCPConnection -LocalPort 3101 -State Listen -ErrorAction SilentlyContinue $port3102 = Get-NetTCPConnection -LocalPort 3102 -State Listen -ErrorAction SilentlyContinue } catch {} # If both ports are listening, daemons are running if ($port3101 -and $port3102) { Write-Host "[MCP] Daemons already running" -ForegroundColor Green return } # Lock file prevents duplicate startup if (Test-Path $lockFile) { $elapsed = (Get-Date) - (Get-Item $lockFile).LastWriteTime if ($elapsed.TotalSeconds -lt $lockTimeout) { Write-Host "[MCP] Daemons starting..." -ForegroundColor Yellow return } Remove-Item $lockFile -Force -ErrorAction SilentlyContinue } # Create lock file and start in new terminal New-Item -ItemType File -Path $lockFile -Force | Out-Null Start-Process pwsh -ArgumentList "-NoExit", "-Command", "cd '$mcpDir'; .\start-daemons.ps1" } Start-McpDaemonsIfNeeded Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: function Start-McpDaemonsIfNeeded { $mcpDir = "C:\path\to\mcp-session-manager" $lockFile = "$env:TEMP\mcp-daemons-starting.lock" $lockTimeout = 120 # seconds # Check if ports are already listening try { $port3101 = Get-NetTCPConnection -LocalPort 3101 -State Listen -ErrorAction SilentlyContinue $port3102 = Get-NetTCPConnection -LocalPort 3102 -State Listen -ErrorAction SilentlyContinue } catch {} # If both ports are listening, daemons are running if ($port3101 -and $port3102) { Write-Host "[MCP] Daemons already running" -ForegroundColor Green return } # Lock file prevents duplicate startup if (Test-Path $lockFile) { $elapsed = (Get-Date) - (Get-Item $lockFile).LastWriteTime if ($elapsed.TotalSeconds -lt $lockTimeout) { Write-Host "[MCP] Daemons starting..." -ForegroundColor Yellow return } Remove-Item $lockFile -Force -ErrorAction SilentlyContinue } # Create lock file and start in new terminal New-Item -ItemType File -Path $lockFile -Force | Out-Null Start-Process pwsh -ArgumentList "-NoExit", "-Command", "cd '$mcpDir'; .\start-daemons.ps1" } Start-McpDaemonsIfNeeded COMMAND_BLOCK: function Start-McpDaemonsIfNeeded { $mcpDir = "C:\path\to\mcp-session-manager" $lockFile = "$env:TEMP\mcp-daemons-starting.lock" $lockTimeout = 120 # seconds # Check if ports are already listening try { $port3101 = Get-NetTCPConnection -LocalPort 3101 -State Listen -ErrorAction SilentlyContinue $port3102 = Get-NetTCPConnection -LocalPort 3102 -State Listen -ErrorAction SilentlyContinue } catch {} # If both ports are listening, daemons are running if ($port3101 -and $port3102) { Write-Host "[MCP] Daemons already running" -ForegroundColor Green return } # Lock file prevents duplicate startup if (Test-Path $lockFile) { $elapsed = (Get-Date) - (Get-Item $lockFile).LastWriteTime if ($elapsed.TotalSeconds -lt $lockTimeout) { Write-Host "[MCP] Daemons starting..." -ForegroundColor Yellow return } Remove-Item $lockFile -Force -ErrorAction SilentlyContinue } # Create lock file and start in new terminal New-Item -ItemType File -Path $lockFile -Force | Out-Null Start-Process pwsh -ArgumentList "-NoExit", "-Command", "cd '$mcpDir'; .\start-daemons.ps1" } Start-McpDaemonsIfNeeded - Claude Code spawns new MCP processes for Session B - Sends SIGINT to existing MCP processes (for some reason) - Session A's MCPs die - Session A shows "MCP Disconnected" error - Singleton Daemons: Each MCP type runs as a single daemon process - Lightweight Proxies: Convert Claude's stdio to HTTP and forward to daemon - SIGINT Immunity: Proxies ignore SIGINT, protecting the shared daemon - Auto-start: Daemons start automatically on first request - Set before any imports (as early as possible) - Log to stderr (stdout is for MCP protocol) - Do nothing - just ignore it - Memory doesn't mix between projects - Backward compatible with existing global DB - Users automatically get project isolation - Proxy layer that ignores SIGINT - Singleton daemon shared by all sessions - Multiple transport support (HTTP, Streamable-HTTP, SSE) - Auto-start with lock file exclusion - Per-project memory DB: AsyncLocalStorage for per-request DB switching - Port check: Skip if daemons are already running - Lock file: Prevents duplicate startup when opening multiple terminals simultaneously - Timeout: 120-second lock expiration for crash recovery - Separate terminal: Start-Process pwsh opens daemons in a new window - npm: mcp-session-manager - GitHub: Daichi-Kudo/mcp-session-manager - Previous article: Fixing Claude Code's Concurrent Session Problem - MCP Transports Spec: MCP Specification - Qiita (Japanese): Technical details in Japanese - Zenn (Japanese): Technical details in Japanese - note (Japanese): Development story in Japanese - Cognisant LLC CEO - Building the future where humans and AI create together - M16 LLC CTO - AI, Creative, and Engineering