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 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]
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 (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]
Design Principles
- 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
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:
- 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);
}
}
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;
}
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);
}
FastMCP Gotcha:
FastMCP's /sse endpoint returns session ID like this:
event: endpoint
data: /messages/?session_id=abc123
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();
});
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);
}
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
};
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");
}
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 & 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
Generate Config
mcp-manager generate-config
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"]
}
}
}
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
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:
AsyncLocalStoragefor 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
Key Points
- 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 pwshopens daemons in a new window
Now daemons start automatically when you open a terminal.
Resources
- npm: mcp-session-manager
- GitHub: Daichi-Kudo/mcp-session-manager
- Previous article: Fixing Claude Code's Concurrent Session Problem
- MCP Transports Spec: MCP Specification
Read on Other Platforms
- Qiita (Japanese): Technical details in Japanese
- Zenn (Japanese): Technical details in Japanese
- note (Japanese): Development story in Japanese
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)