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

Published: (January 1, 2026 at 01:32 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

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

Every time I opened a new Claude Code session, the existing session’s MCPs would disconnect. The WAL mode solved database contention, but a completely different problem was 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

ProblemCauseSolution
database is lockedMultiple processes accessing SQLiteWAL mode + busy_timeout
MCP DisconnectedNew session sends SIGINT to existing MCPsThis 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)

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]

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 (e.g., file watchers) remain unsolved.

The Solution: 3‑Layer Architecture

“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]

Design Principles

PrincipleDescription
Singleton DaemonsEach MCP type runs as a single daemon process.
Lightweight ProxiesConvert Claude’s stdio to HTTP and forward to the daemon.
SIGINT ImmunityProxies ignore SIGINT, protecting the shared daemon.
Auto‑startDaemons start automatically on the 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";
// …

Key points

  • Register the handler before any imports (as early as possible).
  • Log to stderr (stdout is reserved for the MCP protocol).
  • Do nothing else – just ignore the signal.

2. Transport Support

MCP uses several transport formats. The proxy must support all of them:

MCPPortTransportNotes
memory3100streamable-httpRequires Accept header
code-index3101streamable-httpSSE response
ast-grep3102sseDeprecated 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);
  }
}

3. Streamable‑HTTP Transport

Based on the 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" // 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(60_000) // 60 s timeout
  });

  // Capture session ID for subsequent calls
  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;
}

4. SSE Transport (Deprecated Format)

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

async function sendRequestSSE(
  client: DaemonClient,
  message: JsonRpcRequest
): Promise<JsonRpcResponse> {
  // Initialise SSE session if needed
  if (!client.sseSessionId) {
    const initResp = await fetch(`${client.baseUrl}/sse/init`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ method: "initialize" })
    });
    const initData = await initResp.json();
    client.sseSessionId = initData.sessionId;
  }

  const url = new URL(`${client.baseUrl}/sse`);
  url.searchParams.set("sessionId", client.sseSessionId!);
  url.searchParams.set("id", String(message.id));

  const response = await fetch(url.toString(), {
    method: "GET",
    headers: { Accept: "text/event-stream" },
    signal: AbortSignal.timeout(60_000)
  });

  // Parse the SSE stream and return the JSON‑RPC response
  return await parseSSEResponse(response, message.id);
}

(Implementation of parseSSEResponse is omitted for brevity.)

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();
});

6. Auto‑start Daemon

The proxy automatically starts the daemon if it isn’t running:

async function getDaemonInfo(name: string): Promise<DaemonInfo | 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);
}

7. Per‑Project Memory Database

After fixing the SIGINT problem, another issue surfaced: memory was being shared across different projects. Content memorized in Project A was visible in Project B – not desirable.

Solution: Propagate Project Path via HTTP Header

Proxy (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
};

memory-mcp-sqlite Side – use 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");
}

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)!;
}

DB Path Priority

ConditionDB Path
Project path exists and writable/.claude/memory.db
Otherwise~/.claude/memory.db

Benefits

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

Usage

Install

npm install -g mcp-session-manager

Generate Config

mcp-manager generate-config

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"
      ]
    }
  }
}

(Add any additional server definitions as needed.)

Configuration Example

{
  "targets": {
    "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"
      ]
    }
  }
}

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

View daemon logs

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

# macOS / Linux
cat ~/.mcp-session-manager/memory.log

Remove stale lock files

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

# macOS / Linux
rm ~/.mcp-session-manager/*.lock

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 DBAsyncLocalStorage 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 the following 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) {
        Write-Host "[MCP] Startup already in progress" -ForegroundColor Yellow
        return
    }

    New-Item $lockFile -ItemType File -Force | Out-Null

    try {
        # Start daemons
        Start-Process -FilePath "node" -ArgumentList "$mcpDir/dist/proxy/index.js --target memory" -NoNewWindow
        Start-Process -FilePath "node" -ArgumentList "$mcpDir/dist/proxy/index.js --target code-index" -NoNewWindow
        Write-Host "[MCP] Daemons started" -ForegroundColor Cyan
    } finally {
        Remove-Item $lockFile -Force
    }
}
Back to Blog

Related posts

Read more »

The RGB LED Sidequest 💡

markdown !Jennifer Davishttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%...

Mendex: Why I Build

Introduction Hello everyone. Today I want to share who I am, what I'm building, and why. Early Career and Burnout I started my career as a developer 17 years a...