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 を起動すると:
- Claude Code が新しい MCP プロセスを spawn
- 既存の MCP プロセスに SIGINT を送信(なぜか)
- Session A の MCP が死亡
- 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]
設計原則
- シングルトンデーモン:各 MCP は 1 つのデーモンプロセスとして動作
- 軽量プロキシ:Claude の stdio を HTTP に変換してデーモンに転送
- SIGINT 無視:プロキシが SIGINT を無視し、デーモンを保護
- 自動起動:デーモンが起動していなければプロキシが自動起動
実装の詳細
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);
}
検出の優先順位:
- ポートを直接 ping(ロックファイルがなくても検出可能)
- ロックファイルの情報を使用
- Manager API 経由で起動
- 直接起動(フォールバック)
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)対応
- 自動起動とロックファイルによる排他制御
-
プロジェクト別メモリDB:
AsyncLocalStorageでリクエストごとに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
ポイント
- ポート確認:既にデーモンが動いていれば何もしない
- ロックファイル:デーモン起動に時間がかかるため、複数ターミナルを同時に開いても重複起動しない
- タイムアウト:120秒でロックが古くなったら再起動を許可
-
別ターミナル起動:
Start-Process pwshで新しいウィンドウでデーモンを起動
これで、ターミナルを開くだけで自動的にデーモンが起動します。
リソース
- npm: mcp-session-manager
- GitHub: Daichi-Kudo/mcp-session-manager
- 前回の記事: Claude Code の並行セッション問題を解決する:WAL モード対応 Memory MCP の実装
- MCP Transports 仕様: MCP Specification
他のプラットフォームで読む
- Zenn: 同じ記事を Zenn で読む
- note(体験談): 開発の裏話・苦労話
- dev.to(English): Read in English
この実装が Claude Code のマルチセッション運用に悩む方の助けになれば嬉しいです。Issue や PR もお待ちしてます!
著者について
Daichi Kudo
- Cognisant合同会社 CEO - 人とAIが共に創る未来を目指し、AI開発支援
- M16合同会社 CTO - AI・クリエイティブ・エンジニアリング