Fixing Claude Code's SIGINT Problem: How I Built MCP Session Manager
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
| 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)
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:
- 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.
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
| Principle | Description |
|---|---|
| Singleton Daemons | Each MCP type runs as a single daemon process. |
| Lightweight Proxies | Convert Claude’s stdio to HTTP and forward to the daemon. |
| SIGINT Immunity | Proxies ignore SIGINT, protecting the shared daemon. |
| Auto‑start | Daemons 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:
| 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);
}
}
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
| Condition | DB 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 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 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
}
}