Claude Code의 SIGINT 문제 해결: MCP Session Manager를 구축한 방법
Source: Dev.to
번역할 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.
Introduction
이전 글(Fixing Claude Codes Concurrent Session Problem – Implementing Memory MCP with SQLite WAL‑Mode)에서 Memory MCP를 위한 WAL‑mode SQLite 백엔드를 구현하여 데이터베이스 잠금 문제를 해결했습니다.
하지만 이야기는 여기서 끝나지 않았습니다.
[MCP Disconnected] memory
Connection to MCP server 'memory' was lost
새로운 Claude Code 세션을 열 때마다 기존 세션의 MCP가 연결이 끊겼습니다. WAL 모드는 데이터베이스 경합을 해결했지만, 전혀 다른 문제가 그 밑에 숨어 있었습니다.
근본 원인: Claude Code가 새로운 세션을 시작할 때 기존 MCP 프로세스에 SIGINT를 보냅니다.
이 글에서는 이 문제를 해결하기 위해 **mcp-session-manager**를 어떻게 구축했는지 설명합니다.
문제: SIGINT vs. 데이터베이스 잠금
| 문제 | 원인 | 해결책 |
|---|---|---|
database is locked | SQLite에 접근하는 다중 프로세스 | WAL mode + busy_timeout |
MCP Disconnected | 새 세션이 기존 MCP에 SIGINT를 보냄 | This article |
WAL 모드를 활성화했음에도 불구하고, MCP 프로세스 자체가 죽으면 데이터베이스에 접근할 수 있는 것이 없습니다. 근본적으로 아키텍처를 재고해야 했습니다.
기본 아키텍처 (문제점)
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]
Session B가 시작될 때:
- Claude Code가 Session B를 위해 새로운 MCP 프로세스를 생성합니다.
- 기존 MCP 프로세스에 SIGINT를 보냅니다 (이유는 알 수 없습니다).
- Session A의 MCP가 종료됩니다.
- Session A에 “MCP Disconnected” 오류가 표시됩니다.
‘process.on('SIGINT', …) 로 SIGINT만 처리하면 된다’고 생각할 수도 있지만, 그것만으로는 충분하지 않습니다. 프로세스가 살아 있더라도 파일 워처와 같은 리소스 충돌은 해결되지 않습니다.
솔루션: 3‑계층 아키텍처
“각 세션마다 경량 프록시가 할당되고, 실제 처리는 공유 데몬에서 수행됩니다.”
Session A Session B
| |
v v
[Proxy A] ---- HTTP ---- [MCP Daemon]
(stdio) shared (HTTP / SSE)
| |
[Claude A] [Claude B]
디자인 원칙
| 원칙 | 설명 |
|---|---|
| 단일 데몬 | 각 MCP 유형은 단일 데몬 프로세스로 실행됩니다. |
| 경량 프록시 | Claude의 stdio를 HTTP로 변환하고 데몬으로 전달합니다. |
| SIGINT 면역 | 프록시는 SIGINT를 무시하여 공유 데몬을 보호합니다. |
| 자동 시작 | 데몬은 첫 번째 요청 시 자동으로 시작됩니다. |
Implementation Details
1. SIGINT Handler in Proxy
가장 중요한 부분입니다. 파일 최상단에 핸들러를 설정하세요:
// 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";
// …
핵심 포인트
- 가능한 한 일찍, import 이전에 핸들러를 등록합니다.
- stderr에 로그를 남깁니다 (stdout은 MCP 프로토콜에 사용됩니다).
- 다른 작업은 하지 말고, 신호만 무시합니다.
2. Transport Support
MCP는 여러 전송 형식을 사용합니다. 프록시는 이 모두를 지원해야 합니다:
| 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
MCP 2025‑03‑26 사양을 기반으로 합니다:
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는 FastMCP를 사용하며, 이는 폐기된 SSE 형식을 구현합니다:
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);
}
(parseSSEResponse 구현은 간결성을 위해 생략되었습니다.)
5. stdin Close Handling
또 다른 함정: stdin이 닫히면 프록시가 바로 종료되면 진행 중인 요청이 중단될 수 있습니다.
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...");
Source: …
await closeSession(client);
process.exit(0);
6. 자동 시작 데몬
프록시는 데몬이 실행 중이 아니면 자동으로 시작합니다:
async function getDaemonInfo(name: string): Promise<DaemonInfo | null> {
const config = getDaemonConfig(name);
if (!config) return null;
// 1️⃣ 기존 데몬을 감지하기 위해 포트에 ping
const isAliveOnPort = await pingDaemon(config.port, config.transport);
if (isAliveOnPort) {
return { port: config.port, transport: config.transport };
}
// 2️⃣ 락 파일 확인
const lockData = readLockFile(name);
if (lockData) {
const isAlive = await pingDaemon(lockData.port, lockData.transport);
if (isAlive) {
return { port: lockData.port, transport: lockData.transport };
}
}
// 3️⃣ 매니저 API를 통해 확인
const managerResult = await ensureDaemonViaManager(name);
if (managerResult) return managerResult;
// 4️⃣ 폴백: 직접 시작
return startDaemonDirectly(name);
}
7. 프로젝트별 메모리 데이터베이스
SIGINT 문제를 해결한 뒤, 또 다른 문제가 나타났습니다: 메모리가 서로 다른 프로젝트 간에 공유되고 있었습니다. 프로젝트 A에서 기억한 내용이 프로젝트 B에서도 보이는 상황이었죠—원하지 않는 동작이었습니다.
해결책: HTTP 헤더를 통해 프로젝트 경로 전파
프록시 (proxy/index.ts) – 프로젝트 경로 감지 및 전송
const projectPath = process.cwd();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Mcp-Project-Path": projectPath // 프로젝트 경로 전송
};
memory-mcp-sqlite 측 – AsyncLocalStorage를 사용해 요청별 컨텍스트 관리
import { AsyncLocalStorage } from "node:async_hooks";
interface RequestContext {
projectPath?: string;
}
const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
// 요청 핸들러에서 컨텍스트 설정
app.use((req, res, next) => {
const projectPath = req.headers["mcp-project-path"] as string | undefined;
asyncLocalStorage.run({ projectPath }, () => next());
});
// 컨텍스트에서 DB 경로 가져오기
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");
}
스토어 캐싱 – 여러 프로젝트에 대한 DB 연결을 효율적으로 관리
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 경로 우선순위
| 조건 | DB 경로 |
|---|---|
| 프로젝트 경로가 존재하고 쓰기 가능 | /.claude/memory.db |
| 그 외 | ~/.claude/memory.db |
이점
- 프로젝트 간에 메모리가 섞이지 않음.
- 기존 전역 DB와의 하위 호환성을 유지.
- 사용자는 별도 설정 없이 프로젝트 격리를 자동으로 얻음.
사용법
설치
npm install -g mcp-session-manager
구성 생성
mcp-manager generate-config
다음과 같이 ~/.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"
]
}
}
}
(필요에 따라 추가 서버 정의를 추가하세요.)
구성 예시
{
"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"
]
}
}
}
Claude Code 재시작
새 구성을 적용하려면 재시작하세요.
확인
여러 개의 Claude Code 세션을 열어보세요 – 모두 동시에 연결이 끊기지 않고 작동해야 합니다.
문제 해결
데몬 상태 확인
curl http://localhost:3199/status
데몬 로그 보기
# Windows
type %USERPROFILE%\.mcp-session-manager\memory.log
# macOS / Linux
cat ~/.mcp-session-manager/memory.log
오래된 잠금 파일 제거
# Windows
del %USERPROFILE%\.mcp-session-manager\*.lock
# macOS / Linux
rm ~/.mcp-session-manager/*.lock
요약
Claude Code의 SIGINT 문제를 해결하기 위해 **mcp-session-manager**를 만들었습니다.
핵심 포인트
- SIGINT를 무시하는 프록시 레이어
- 모든 세션이 공유하는 싱글톤 데몬
- 다중 전송 지원 (HTTP, Streamable‑HTTP, SSE)
- 잠금 파일을 이용한 자동 시작 및 제외
프로젝트별 메모리 DB – AsyncLocalStorage를 사용하여 요청별 DB 전환을 수행합니다. 이전 WAL‑mode 구현과 결합하여, Claude Code의 다중 세션 및 다중 프로젝트 운영이 이제 완전히 안정되었습니다.
보너스: 터미널 실행 시 데몬 자동 시작 (Windows)
데몬을 매번 수동으로 시작하는 것은 번거롭습니다. PowerShell 프로필에 자동 시작 스크립트를 추가했습니다.
PowerShell 프로필에 추가
다음 내용을 $PROFILE에 추가하세요 (보통 ~/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
}
}