MolaGPT 上的 MCP

MolaGPT 上的 MCP

暨 MolaGPT 开发日志 - 其(十)

2024 年底 Anthropic 发布 MCP 时,社区当时对这个新的“标准化协议”有不少讨论,一部分观点认为它只是在 Function Calling 外层做了封装,缺乏本质创新,比如我。

一年多过去了,随着 Cursor、Windsurf、Claude Code 等应用开始接入,以及 GitHub、高德等平台提供官方 MCP Server,这个生态已经初步形成。事实证明我当时的判断确实草率了,在这种趋势下,我认为让 MolaGPT 兼容 MCP 是越来越重要了。

说着简单,但这其中最主要的问题在于 MolaGPT 的后端技术栈是 PHP,而 MCP 的主流实现和官方 SDK 集中在 TypeScript 与 Python。幸运的是,我在 GitHub 上找到了一个 PHP 的实现尝试(logiscape/mcp-sdk-php),这为我提供了一个起点。我结合 MolaGPT 的实际需求,在此基础上进行了较多的改造和功能增强。

MCP 的机制

首先介绍一下 MCP,MCP 的本质是一个基于 JSON 的工具调用协议,其定义了一套标准的握手流程和消息格式,目的是让 AI 应用可以低成本地接入各类工具服务。

协议支持三种传输方式:STDIO(本地执行)、HTTP 和 SSE。一个典型的会话流程如图所示。

协议本身不复杂,MCP 相当于一组元数据,然后 Client 根据这个元数据来获取到具体的 Tools 定义然后提供给模型。优势在于作为开发者,我们不需要自己写一些具体的 Tools 实现逻辑。

但是 MolaGPT 在实现层面的挑战是如何在 PHP 环境下正确处理这三种传输方式,以及如何将 MCP 工具无缝桥接到现有的 Function Calling 体系中。

核心思路

我的目标是让 MCP 工具对模型完全透明。模型调用工具时,不应感知其来源是内部实现还是外部 MCP 服务。

通过在 MolaGPT 上建立 MCP 基础设施,未来接入新的 MCP Server 将主要停留在配置层面,一切新的 MCP 都跑在现有的 MCP Runtime 上,用户无需改动核心逻辑。

STDIO 传输的实现

STDIO 是一种主要用于本地 CLI 工具的传输方式。它需要启动一个子进程,并通过管道与其进行通信。

不过 PHP 并非专门为此场景设计的,但是其提供了 proc_open 函数,所以可以通过 PHP 来启动三方程序来执行 MCP 服务器。

function stdio_execute($command, $args, $method, $params) {
    // 必须显式设置 PATH 环境变量,否则 PHP-FPM 可能找不到 node 等命令
    $env = ['PATH' => getenv('PATH') . ':/path/to/node/bin'];

    $proc = proc_open(
        [$command, ...$args],
        [
            0 => ['pipe', 'r'],  // stdin
            1 => ['pipe', 'w'],  // stdout
            2 => ['pipe', 'w']   // stderr
        ],
        $pipes,
        null,
        $env
    );

    // ... 发送 initialize, initialized, 和 tools/call 请求 ...

    // 发送实际请求
    fwrite($pipes[0], json_encode([
        'jsonrpc' => '2.0',
        'id'      => 2,
        'method'  => $method,
        'params'  => $params
    ]) . "\n");

    // 关闭 stdin 管道,通知子进程输入结束
    fclose($pipes[0]);

    // 读取响应
    $response = json_decode(fgets($pipes[1]), true);

    proc_close($proc);
    return $response;
}

但是其中一个容易忽略的细节:PHP-FPM 的默认 PATH 环境变量比较有限。如果不通过 $env 参数显式传入,proc_open 启动的子进程很可能因找不到命令而执行失败,所以上面的代码我专门传入了 $env 参数。

HTTP 传输实现

HTTP 传输表面上是发送一个 POST 请求,但在对接不同 MCP Server 的过程中,会遇到各种兼容性问题,生态起来了,开发者多了所以就会出现很多不一样的规范。

首先是多样的认证方,有的 MCP Server 要求 Authorization 头(如 GitHub),有的要求在 URL query string 中携带 API Key(如高德地图),还有的则无需认证。客户端实现需要灵活支持这些模式。

不过这都不是最地狱的,除了标准的 JSON 返回,有些 MCP Server 会返回事件流,响应体由多行 data: {...} 组成。这就是 MCP 的两种格式,一种是 HTTP 一种是 Steamable HTTP,也就是 SSE.

逆天的来了:有少数 MCP Server 返回的是非标准的格式,这些 MCP 直接返回 rawtext... 所以,响应解析逻辑需要按顺序尝试多种解析方式。

function parse_response($body) {
    // 优先尝试标准 JSON
    $decoded = json_decode($body, true);
    if (json_last_error() === JSON_ERROR_NONE) return $decoded;

    // 尝试解析 SSE 格式,通常取最后一条有效消息
    if (strpos($body, 'data:') !== false) {
        preg_match_all('/data:\s*(.+)/m', $body, $matches);
        foreach (array_reverse($matches[1]) as $line) {
            $decoded = json_decode(trim($line), true);
            if (json_last_error() === JSON_ERROR_NONE) return $decoded;
        }
    }

    // 兜底处理:尝试从响应体中找到第一个 JSON 结构的起始位置
    $pos = strpos($body, '{');
    if ($pos !== false) {
        $jsonPart = substr($body, $pos);
        $decoded = json_decode($jsonPart, true);
        if (json_last_error() === JSON_ERROR_NONE) return $decoded;
    }

    // 如果都失败,则返回原始响应体
    return $body;
}

转换为 Function (Tool)

这是整个实现的核心部分。MCP 的工具定义与 OpenAI 的 Function Calling 格式在结构上是同构的,上面也提到了,MCP 相当于一组元数据,然后 Client 根据这个元数据来获取到具体的 Tools 定义然后提供给模型。

我在这里面的设计关键在于命名规范。我为每个函数名添加 mcp_{connectorId}__ 前缀,系统可以从模型返回的函数名中反向解析出其来源。例如,GitHub 的 search_repos 工具,转换后的函数名是 mcp_github__search_repos

当模型返回一个 function call 时,逻辑会检查函数名:

  • 如果以 mcp_ 开头,则解析出 connectorIdtoolName,并将请求路由到对应的 MCP Server。
  • 否则,按原有的内置工具逻辑处理。

踩过的一些坑

  1. 管道阻塞:在实现 STDIO 模式时,若发送请求后不使用 fclose($pipes[0]) 关闭 stdin 管道,子进程会因等待更多输入而挂起,导致主进程永久阻塞;
  2. PHP 的空数组编码:PHP 的 json_encode([]) 结果是 [] 而非 {}。当一个工具没有参数时,MCP Server 期望 properties 字段是空对象 {},收到空数组 [] 会导致 schema 校验失败。解决方案是在序列化前,通过 (object)[] 将空数组强制转换为空对象;
  3. 非纯 JSON 输出:部分奇奇怪怪的 MCP Server 启动时会向 stdout 输出欢迎信息或调试日志。这些非协议内容会干扰之前的严格 JSON 解析,故读取逻辑需要增加过滤步骤,只处理有效的 JSON 字符串。

总结 (AI 生成)

在 PHP 中实现 MCP 客户端,核心工作可以总结为三点:

  1. STDIO 传输处理:使用 proc_open 与子进程通信,同时必须处理好 PATH 环境变量和管道关闭时机这两个关键细节。
  2. HTTP/SSE 传输处理:除了基本的 HTTP 请求,还需要编写兼容多种响应格式(JSON, SSE, 私有格式)的解析逻辑。
  3. 桥接机制设计:通过设计一套合理的函数命名规范(如 mcp_{id}__{name}),实现从 Function Calling 到 MCP 工具调用的双向、透明映射。

后记

实现 MCP Runtime 后我又做了些工作,为 MolaGPT 带来了“连接器”(Connector)这项新功能,让用户可以接入外部服务。这一更新我会在之后以一个单独的文章来描述。

说来也有意思,这篇文章的草稿其实早在更新 MolaGPT Projects 的 ACE (Augment Context Engine) 工具集时就躺在我的草稿箱里了。但当时我总感觉这摊子活儿还没干利索,特别是我既然已经初步实现了 MCP,为什么不直接提供给用户自由配置呢?所以我打算等后面彻底实现再说,所以一直没发。

回旋镖了

发表回复

必填项已用 * 标注。