DEV Community

pepk
pepk

Posted on

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

Introduction

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.

[MCP Disconnected] memory
Connection to MCP server 'memory' was lost
Enter fullscreen mode Exit fullscreen mode

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.

The Problem: SIGINT vs Database Lock

Let me clarify the distinction between the two issues:

Problem Cause Solution
database is locked Multiple processes accessing SQLite WAL mode + busy_timeout
MCP Disconnected New session sends SIGINT to existing MCPs This article

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.

The Default Architecture (Problematic)

Here's how Claude Code handles MCPs by default:

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

When Session B starts:

  1. Claude Code spawns new MCP processes for Session B
  2. Sends SIGINT to existing MCP processes (for some reason)
  3. Session A's MCPs die
  4. Session A shows "MCP Disconnected" error

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: 3-Layer Architecture

The solution is straightforward:

"Each session gets a lightweight proxy; actual processing happens in a shared daemon."

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

Design Principles

  1. Singleton Daemons: Each MCP type runs as a single daemon process
  2. Lightweight Proxies: Convert Claude's stdio to HTTP and forward to daemon
  3. SIGINT Immunity: Proxies ignore SIGINT, protecting the shared daemon
  4. Auto-start: Daemons start automatically on first request

Implementation Details

1. SIGINT Handler in Proxy

The most critical part. Set the handler at the very top of the file:

// 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

Key points:

  • Set before any imports (as early as possible)
  • Log to stderr (stdout is for MCP protocol)
  • Do nothing - just ignore it

2. Transport Support

MCP uses different transport formats. I had to support all of them:

MCP Port Transport Notes
memory 3100 streamable-http Requires Accept header
code-index 3101 streamable-http SSE response
ast-grep 3102 sse Deprecated format
// 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

3. Streamable-HTTP Transport

Based on MCP 2025-03-26 specification:

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

4. SSE Transport (Deprecated Format)

The ast-grep-mcp uses FastMCP, which implements the deprecated SSE format:

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

FastMCP Gotcha:

FastMCP's /sse endpoint returns session ID like this:

event: endpoint
data: /messages/?session_id=abc123
Enter fullscreen mode Exit fullscreen mode

Notice the /messages/?session_id=... format instead of the typical /messages/<session_id>. This cost me 2 hours of debugging.

5. stdin Close Handling

Another gotcha: if the proxy exits immediately when stdin closes, it might interrupt in-flight requests.

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

6. Auto-start Daemon

The proxy automatically starts the daemon if it's not running:

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

7. Per-Project Memory Database

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.

Solution: Propagate Project Path via HTTP Header

// 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

memory-mcp-sqlite Side

Using AsyncLocalStorage to manage per-request context:

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

Store Caching

Efficiently manage DB connections for multiple projects:

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

DB Path Priority

Condition DB Path
Project path exists & writable <project>/.claude/memory.db
Otherwise ~/.claude/memory.db

Benefits:

  • Memory doesn't mix between projects
  • Backward compatible with existing global DB
  • Users automatically get project isolation

Usage

Install

npm install -g mcp-session-manager
Enter fullscreen mode Exit fullscreen mode

Generate Config

mcp-manager generate-config
Enter fullscreen mode Exit fullscreen mode

This creates ~/.claude/mcp.json:

{
  "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

Restart Claude Code

Restart to apply the new configuration.

Verify

Open multiple Claude Code sessions - they should all work simultaneously without disconnections.

Troubleshooting

Check daemon status

curl http://localhost:3199/status
Enter fullscreen mode Exit fullscreen mode

View daemon logs


type %USERPROFILE%\.mcp-session-manager\memory.log

# macOS/Linux
cat ~/.mcp-session-manager/memory.log
Enter fullscreen mode Exit fullscreen mode

Remove stale lock files

# Windows
del %USERPROFILE%\.mcp-session-manager\*.lock

# macOS/Linux
rm ~/.mcp-session-manager/*.lock
Enter fullscreen mode Exit fullscreen mode

Summary

I built mcp-session-manager to solve Claude Code's SIGINT problem.

Key points:

  • 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

Combined with the previous WAL mode implementation, Claude Code's multi-session and multi-project operation is now fully stable.

Bonus: Auto-start Daemons on Terminal Launch (Windows)

Manually starting daemons every time is tedious. I added an auto-start script to my PowerShell profile.

Add to PowerShell Profile

Add to $PROFILE (usually ~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1):

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

Key Points

  1. Port check: Skip if daemons are already running
  2. Lock file: Prevents duplicate startup when opening multiple terminals simultaneously
  3. Timeout: 120-second lock expiration for crash recovery
  4. Separate terminal: Start-Process pwsh opens daemons in a new window

Now daemons start automatically when you open a terminal.

Resources

Read on Other Platforms


I hope this helps anyone struggling with multi-session Claude Code operation. Issues and PRs welcome!

About the Author

Daichi Kudo

  • Cognisant LLC CEO - Building the future where humans and AI create together
  • M16 LLC CTO - AI, Creative, and Engineering

Top comments (0)