0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の SIGINT 問題を解決する:MCP Session Manager の実装

Last updated at Posted at 2026-01-01

Claude Code の SIGINT 問題を解決する:MCP Session Manager の実装

はじめに

前回の記事で、WAL モード対応の Memory MCP を作って SQLite の DB ロック問題を解決しました。

が、それだけでは終わらなかったんですよね。

[MCP Disconnected] memory
Connection to MCP server 'memory' was lost

新しい Claude Code のセッションを開くたびに、既存セッションの MCP が切断される。せっかく WAL モードで並行アクセス可能にしたのに、根本的な問題は別のところにあった。

原因:Claude Code が新セッション起動時に既存 MCP プロセスへ SIGINT を送信していた。

この記事では、その問題を解決する mcp-session-manager の実装について解説します。

対象読者

  • Claude Code を複数セッションで並行運用したい方
  • MCP(Model Context Protocol)のアーキテクチャに興味がある方
  • Node.js でのプロセス管理・IPC に興味がある方
  • 前回の記事を読んで「それだけじゃ解決しなかった」方

問題の整理:DB ロックと SIGINT は別問題

まず、前回の記事で解決した問題と今回の問題を整理します。

問題 原因 解決策
database is locked 複数プロセスが同一 SQLite に同時アクセス WAL モード + busy_timeout
MCP Disconnected 新セッションが既存 MCP に SIGINT 送信 本記事で解決

WAL モードを入れても、MCP プロセス自体が死んでしまったら意味がない。根本的にアーキテクチャを変える必要がありました。

現状のアーキテクチャ(問題あり)

Claude Code のデフォルト構成はこうなってます:

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 が新しい MCP プロセスを spawn
  2. 既存の MCP プロセスに SIGINT を送信(なぜか)
  3. Session A の MCP が死亡
  4. Session A で「MCP Disconnected」エラー

SIGINT を受けても process.on('SIGINT', ...) でハンドリングすればいいじゃん、と思うかもしれませんが、それでは不十分。プロセス自体は生き残っても、リソースの競合問題は解決しない。

解決策:3 層アーキテクチャ

シンプルな発想で解決しました:

「各セッションは軽量なプロキシを持ち、実際の処理は共有デーモンが行う」

Session A                          Session B
    |                                  |
    v                                  v
[Proxy A] -------- HTTP -------- [MCP Daemon]
(stdio)            shared          (HTTP/SSE)
    |                                  |
[Claude A]                        [Claude B]

設計原則

  1. シングルトンデーモン:各 MCP は 1 つのデーモンプロセスとして動作
  2. 軽量プロキシ:Claude の stdio を HTTP に変換してデーモンに転送
  3. SIGINT 無視:プロキシが SIGINT を無視し、デーモンを保護
  4. 自動起動:デーモンが起動していなければプロキシが自動起動

実装の詳細

1. プロキシの SIGINT 無視

最も重要な部分。ファイルの先頭で SIGINT ハンドラを設定します:

// proxy/index.ts - ファイル先頭で設定
process.on("SIGINT", () => {
  // Ignore SIGINT - let the session continue
  // Claude Code sends SIGINT when new sessions start
  console.error("[Proxy] Received SIGINT - ignoring for multi-session stability");
});

// 他のインポートはこの後
import { Command } from "commander";
// ...

ポイント:

  • インポート前に設定(できるだけ早く)
  • ログは stderr へ(stdout は MCP プロトコル用)
  • 何もしない、ただ無視する

2. トランスポート対応

MCP には複数のトランスポート形式があります。各 MCP サーバーが使うトランスポートに対応する必要がありました:

MCP ポート トランスポート 備考
memory 3100 streamable-http Accept ヘッダー必須
code-index 3101 streamable-http SSE レスポンス
ast-grep 3102 sse deprecated 形式
// 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 トランスポート

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"  // これが重要
  };

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

  // セッション ID を保存
  const sessionId = response.headers.get("Mcp-Session-Id");
  if (sessionId) {
    client.sessionId = sessionId;
  }

  const contentType = response.headers.get("Content-Type") || "";

  // SSE レスポンスの場合はストリーミング処理
  if (contentType.includes("text/event-stream")) {
    return await handleSSEResponse(response, message.id);
  }

  // JSON レスポンス
  return await response.json() as JsonRpcResponse;
}

ポイント:

  • Accept ヘッダーに text/event-stream を含める
  • レスポンスの Content-Type で処理を分岐
  • SSE レスポンスはストリーミングで処理

4. SSE トランスポート(deprecated 形式)

FastMCP を使う ast-grep-mcp は deprecated な SSE 形式を使います:

async function sendRequestSSE(
  client: DaemonClient,
  message: JsonRpcRequest
): Promise<JsonRpcResponse> {
  // SSE セッションの初期化
  if (!client.sseSessionId || !client.sseReader) {
    const sessionResult = await initializeSSESession(client);
    if (!sessionResult.success) {
      return createErrorResponse(message.id, -32603, sessionResult.error);
    }
  }

  // URL フォーマットの対応(FastMCP の罠)
  let messagesUrl: string;
  if (client.sseSessionId!.startsWith("?")) {
    // クエリパラメータ形式: /messages?session_id=...
    messagesUrl = `${client.baseUrl}/messages${client.sseSessionId}`;
  } else {
    // パス形式: /messages/<session_id>
    messagesUrl = `${client.baseUrl}/messages/${client.sseSessionId}`;
  }

  // リクエスト送信
  const response = await fetch(messagesUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(message)
  });

  // レスポンスは SSE ストリームから受け取る
  return await waitForSSEResponse(client, message.id);
}

FastMCP の罠:

FastMCP は /sse エンドポイントで以下の形式でセッション ID を返します:

event: endpoint
data: /messages/?session_id=abc123

注目点は /messages/?session_id=... という形式。普通は /messages/<session_id> だと思うじゃないですか。この違いに気づくまで 2 時間溶かしました。

5. stdin 終了の待機処理

もう一つの罠。プロキシが stdin の終了を検知して即座に終了すると、リクエスト処理中でも切断されてしまう:

// リクエストカウンタ
let pendingRequests = 0;
let stdinClosed = false;

const checkExit = async () => {
  // stdin が閉じて、かつ全リクエストが完了したら終了
  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();
});

ポイント:

  • リクエストの数をカウント
  • stdin が閉じても、pending リクエストがあれば待機
  • 全完了後にクリーンアップして終了

6. デーモンの自動起動

プロキシはデーモンが起動していなければ自動起動します:

async function getDaemonInfo(name: string): Promise<{ port: number; transport: TransportType } | 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. Manager API 経由で起動を試みる
  const managerResult = await ensureDaemonViaManager(name);
  if (managerResult) return managerResult;

  // 4. フォールバック:直接起動
  return startDaemonDirectly(name);
}

検出の優先順位:

  1. ポートを直接 ping(ロックファイルがなくても検出可能)
  2. ロックファイルの情報を使用
  3. Manager API 経由で起動
  4. 直接起動(フォールバック)

7. ロックファイルと排他制御

複数プロキシが同時にデーモンを起動しようとする競合を防止:

interface LockFile {
  pid: number;
  port: number;
  transport: TransportType;
  startedAt: string;
}

function writeLockFile(name: string, data: LockFile): void {
  const lockPath = getLockFilePath(name);
  fs.writeFileSync(lockPath, JSON.stringify(data, null, 2));
}

function readLockFile(name: string): LockFile | null {
  const lockPath = getLockFilePath(name);
  try {
    if (!fs.existsSync(lockPath)) return null;
    return JSON.parse(fs.readFileSync(lockPath, "utf-8"));
  } catch {
    return null;
  }
}

ロックファイルは ~/.mcp-session-manager/<daemon>.lock に保存されます。

8. プロジェクト別メモリDB機能

SIGINT 問題を解決したら、次の問題が見つかりました:異なるプロジェクトで memory が共有されてしまう

プロジェクト 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());
});

// Store 取得時にコンテキストからパスを取得
function getDbPath(): string {
  const context = asyncLocalStorage.getStore();
  if (context?.projectPath) {
    const projectDbPath = path.join(context.projectPath, ".claude", "memory.db");
    // 書き込み権限があればプロジェクト別 DB を使用
    if (canWriteTo(projectDbPath)) {
      return projectDbPath;
    }
  }
  // フォールバック:グローバル DB
  return path.join(os.homedir(), ".claude", "memory.db");
}

Store のキャッシュ

複数プロジェクトを同時に扱う場合、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 パス
プロジェクトパスあり & 書き込み可能 <project>/.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"]
    },
    "ast-grep": {
      "command": "node",
      "args": ["/path/to/mcp-session-manager/dist/proxy/index.js", "--target", "ast-grep"]
    }
  }
}

Claude Code を再起動

設定を反映するため Claude Code を再起動してください。

動作確認

複数の Claude Code セッションを開いて、同時に MCP を使ってみてください。片方が切断されることはもうありません。

トラブルシューティング

デーモンのステータス確認

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

ポート確認

# Windows
netstat -ano | findstr :3100

# macOS/Linux
lsof -i :3100

まとめ

Claude Code の SIGINT 問題を解決する mcp-session-manager を実装しました。

主なポイント:

  • SIGINT を無視するプロキシ層を挟む
  • 全セッションで共有するシングルトンデーモン
  • 複数トランスポート(HTTP, Streamable-HTTP, SSE)対応
  • 自動起動とロックファイルによる排他制御
  • プロジェクト別メモリDBAsyncLocalStorage でリクエストごとにDBを切り替え

これで、前回の WAL モード対応と合わせて、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

    # ポートが既にリッスン中か確認
    $port3101 = $null
    $port3102 = $null

    try {
        $port3101 = Get-NetTCPConnection -LocalPort 3101 -State Listen -ErrorAction SilentlyContinue
    } catch {}

    try {
        $port3102 = Get-NetTCPConnection -LocalPort 3102 -State Listen -ErrorAction SilentlyContinue
    } catch {}

    # 両ポートがリッスン中なら既に起動済み
    if ($port3101 -and $port3102) {
        if (Test-Path $lockFile) { Remove-Item $lockFile -Force -ErrorAction SilentlyContinue }
        Write-Host "[MCP] Daemons already running (ports 3101, 3102)" -ForegroundColor Green
        return
    }

    # ロックファイルで重複起動を防止
    if (Test-Path $lockFile) {
        $lockTime = (Get-Item $lockFile).LastWriteTime
        $elapsed = (Get-Date) - $lockTime
        if ($elapsed.TotalSeconds -lt $lockTimeout) {
            Write-Host "[MCP] Daemons starting (started $([int]$elapsed.TotalSeconds)s ago)..." -ForegroundColor Yellow
            return
        }
        Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
    }

    # スクリプト存在確認
    $scriptPath = Join-Path $mcpDir "start-daemons.ps1"
    if (-not (Test-Path $scriptPath)) {
        Write-Host "[MCP] Warning: start-daemons.ps1 not found" -ForegroundColor Yellow
        return
    }

    # ロックファイル作成 → 新ターミナルで起動
    New-Item -ItemType File -Path $lockFile -Force | Out-Null
    Write-Host "[MCP] Starting daemons in new terminal..." -ForegroundColor Cyan
    Start-Process pwsh -ArgumentList "-NoExit", "-Command", "cd '$mcpDir'; .\start-daemons.ps1"
}

# プロファイル読み込み時に実行
Start-McpDaemonsIfNeeded

ポイント

  1. ポート確認:既にデーモンが動いていれば何もしない
  2. ロックファイル:デーモン起動に時間がかかるため、複数ターミナルを同時に開いても重複起動しない
  3. タイムアウト:120秒でロックが古くなったら再起動を許可
  4. 別ターミナル起動Start-Process pwsh で新しいウィンドウでデーモンを起動

これで、ターミナルを開くだけで自動的にデーモンが起動します。

リソース

他のプラットフォームで読む


この実装が Claude Code のマルチセッション運用に悩む方の助けになれば嬉しいです。Issue や PR もお待ちしてます!

著者について

Daichi Kudo

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?