修复 Claude Code 的 SIGINT 问题:我如何构建 MCP Session Manager

发布: (2026年1月2日 GMT+8 02:32)
11 分钟阅读
原文: Dev.to

I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source link at the top unchanged and preserve all formatting, markdown, code blocks, and URLs as requested.

介绍

在我之前的文章中,我实现了一个 WAL‑mode SQLite 后端用于 Memory MCP,以解决数据库锁定问题。

但事情并没有就此结束。

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

每次我打开一个新的 Claude Code 会话时,现有会话的 MCP 会断开连接。WAL 模式解决了数据库争用,但下面潜藏着一个完全不同的问题。

根本原因: Claude Code 在启动新会话时会向现有的 MCP 进程发送 SIGINT

本文解释了我如何构建 mcp-session-manager 来解决此问题。

问题:SIGINT 与 数据库锁

问题原因解决方案
database is locked多个进程访问 SQLiteWAL mode + busy_timeout
MCP Disconnected新会话向已有 MCP 发送 SIGINT本文

即使启用了 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”,但这还不够。即使进程存活,资源冲突(例如文件监视器)仍未解决。

解决方案:三层架构

“每个会话都会获得一个轻量级代理;实际处理在共享守护进程中进行。”

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

设计原则

原则描述
单例守护进程每种 MCP 类型作为单个守护进程运行。
轻量代理将 Claude 的标准输入输出转换为 HTTP 并转发给守护进程。
SIGINT 免疫代理忽略 SIGINT,保护共享守护进程。
自动启动守护进程在首次请求时自动启动。

Source:

实现细节

1. 代理中的 SIGINT 处理程序

最关键的部分。在文件最顶部设置处理程序:

// 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. 传输支持

MCP 使用多种传输格式。代理必须全部支持:

MCP端口传输备注
memory3100streamable-http需要 Accept
code-index3101streamable-httpSSE 响应
ast-grep3102sse已废弃的格式
// 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" // 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 传输(已废弃格式)

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 关闭处理

另一个坑:如果代理在 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..." );
    // …后续清理逻辑
  }
};
);
    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. 自动启动守护进程

代理会在守护进程未运行时自动启动它:

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️⃣ 通过 Manager API 尝试
  const managerResult = await ensureDaemonViaManager(name);
  if (managerResult) return managerResult;

  // 4️⃣ 回退:直接启动
  return startDaemonDirectly(name);
}

7. 每项目内存数据库

在解决 SIGINT 问题后,出现了另一个问题:内存会在不同项目之间共享。在项目 A 中记住的内容在项目 B 中也可见——这并不理想。

解决方案:通过 HTTP Header 传播项目路径

代理 (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());
});

// 从上下文获取数据库路径
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");
}

存储缓存 – 高效管理多个项目的数据库连接

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

数据库路径优先级

条件数据库路径
项目路径存在 可写/.claude/memory.db
否则~/.claude/memory.db

优势

  • 内存不会在项目之间混合。
  • 与现有的全局数据库保持向后兼容。
  • 用户会自动获得项目级别的隔离。

用法

安装

npm install -g mcp-session-manager

生成配置

mcp-manager generate-config

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"
      ]
    }
  }
}

(根据需要添加其他服务器定义。)

配置示例

{
  "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

摘要

我构建了 mcp-session-manager 来解决 Claude Code 的 SIGINT 问题。

关键点

  • 忽略 SIGINT 的代理层
  • 所有会话共享的单例守护进程
  • 支持多种传输方式(HTTP、Streamable‑HTTP、SSE)
  • 带锁文件排除的自动启动

每项目内存数据库 – 使用 AsyncLocalStorage 实现每请求的数据库切换。结合之前的 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 Davishttps://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 年前开始我的 developer 生涯……