Claude Code의 SIGINT 문제 해결: MCP Session Manager를 구축한 방법

발행: (2026년 1월 2일 오전 03:32 GMT+9)
12 분 소요
원문: Dev.to

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 lockedSQLite에 접근하는 다중 프로세스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가 시작될 때:

  1. Claude Code가 Session B를 위해 새로운 MCP 프로세스를 생성합니다.
  2. 기존 MCP 프로세스에 SIGINT를 보냅니다 (이유는 알 수 없습니다).
  3. Session A의 MCP가 종료됩니다.
  4. 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는 여러 전송 형식을 사용합니다. 프록시는 이 모두를 지원해야 합니다:

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

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-mcpFastMCP를 사용하며, 이는 폐기된 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)
  • 잠금 파일을 이용한 자동 시작 및 제외

프로젝트별 메모리 DBAsyncLocalStorage를 사용하여 요청별 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
    }
}
Back to Blog

관련 글

더 보기 »

RGB LED 사이드퀘스트 💡

markdown !Jennifer Davis https://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: 내가 만드는 이유

소개 안녕하세요 여러분. 오늘은 제가 누구인지, 무엇을 만들고 있는지, 그리고 그 이유를 공유하고 싶습니다. 초기 경력과 번아웃 저는 개발자로서 17년 동안 경력을 시작했습니다.