用 Laravel 构建 MCP 服务器的更好方式
我们为 Laravel Nightwatch 提供了一台 MCP 服务器。它完全按照设计初衷行事。用户喜欢它、使用它,并立刻索取那些它尚未实现的功能。这些需求都合理,但每多一个功能,代理(agent)要筛选项就更多了。
想象一下一个完整的 Laravel Cloud MCP 服务器:其 API 有几百个端点,涵盖部署、环境、数据库、工作进程、域名和日志。把整个功能面交给代理在逻辑上讲得通,因为很多人确实是通过代理使用 Laravel Cloud 的。
问题出现在 MCP 服务器逐渐膨胀之后。每添加一个工具,它的名称、描述和完整的输入 schema 就会预先塞进代理的上下文中——而它还没开始干活。工具一多,这些定义就开始争夺注意力。代理要处理的干扰更多,相似选项容易混淆,留给用户真正需求的关注空间也更小了。为每个端点单独做一个工具在 API 层很合理,但在每一轮都加载所有工具则不然。
大约一年前,Cloudflare 推出了 Code Mode,当时我觉得它就是答案。但最终发布在 Laravel MCP 1.0 中的方案更小、也更怪:一个可搜索的工具目录,无论其中是 10 个工具还是 100 个工具,tools/list 负载的大小都不变。
这是关于我经历过的四个版本的故事——其中三个被我扔掉了,还有一个错误我犯了两次才意识到。
#代理启动前,每个工具的代价
工具调用看起来像 API 请求,但并非如此。它本质上是一层伪装下的文本生成,这也正是这一切之所以重要的原因。
模型生成 token 流,其中大多数映射为单词或词片段。但模型经过微调,会输出几个没有文本含义的特殊 token,其中两个分别表示“工具调用开始”和“工具调用结束”。
在它们之间,模型写入 JSON:
外壳程序监听那个结束 token,解析 JSON,并将其转化为针对你 Laravel 应用的 MCP 请求。你的工具运行,结果通过另一对特殊 token 返回,生成继续,仿佛模型只是刚读完它。
看看那条备注的位置。问题还没读到,你暴露的每个工具的名称、描述和完整 JSON Schema 就已经塞进了上下文窗口。不是正在用的那些,而是全部,每一轮对话都是如此!
数字比我想象的还要夸张。Anthropic 的 advanced tool use 文档指出,一个 50 多个工具的 MCP 配置,光工具定义就要占用约 72,000 个 token,平均每个工具一千个左右,是我自己估计的好几倍。token 开销是显而易见的成本,但更让我在意的是准确率。
在 Anthropic 使用大型工具库做的 MCP 评测中,把工具定义改为按需检索后,Opus 4 的成绩从 49% 提升到 74%,Opus 4.5 从 79.5% 提升到 88.1%。40 条工具描述,就等于 40 次选错工具的机会。
#第一版:把 Tinker 交给 agent
Code Mode 的论点很犀利,而且我认为是对的:“LLM 写代码来调用 MCP,比直接调用 MCP 更擅长。”模型见过海量真实代码示例,却几乎没见过真实的工具调用——因为工具调用的语法基本只存在于合成训练数据里。
Cloudflare 有句话说得比我自己能写的任何东西都好:“让 LLM 通过工具调用来完成任务,就像让莎士比亚上一个月的普通话课,然后让他用普通话写剧本。”
Cloudflare 的做法是把 MCP 工具转换成 TypeScript API,在 V8 isolate 里运行生成的代码。而我什么都不用转换,因为模型本来就会写 PHP,我的应用用的也是 PHP。于是第一版直接根据每个已注册工具的名称、描述和输入 schema,为它生成一个 PHP 函数签名:
agent 拿到的是这些函数签名,而不是 40 条工具定义,然后基于它们写 PHP 代码,我把返回的代码交给 eval() 执行。本质上就是一个 Tinker,只不过敲键盘的换成了语言模型。
这一点我必须说清楚,因为它会影响你怎么读这篇文章的所有内容:这些代码大部分不是我写的,四个版本的实现都是 agent 完成的。
我的工作是提需求、读代码、决定保留什么。版本迭代之所以这么快,原因就在这里。这也是我为什么会把自己忽悠了……两次。
下面是 agent 会写出的一类代码:
两次链式调用就让我们拿到了过滤后的十二个月数据,全程无需任何内容触及模型。Code Mode 的表现完全符合预期,且是用 PHP 实现,耗时仅约一天。我兴奋不已! 我抛出的每个示例都返回了正确结果,这正是让我坚持如此之久的原因。运行成功并不意味着你了解该次运行被允许执行的操作,而我的测试仅覆盖了预期的代码片段。 随后,我审视了该片段可能呈现的其他形式: 我的设计中没有任何机制能阻止这些行为。生成的函数只是一个建议,而非边界,且系统中不存在其他边界。代码在应用程序内部运行,位于同一进程、同一容器、同一环境,并使用与我自己的代码相同的数据库连接。 我知道那就是eval()。
我指定并批准了该差异变更,但让我沉思一天半的是,当编写者是一个语言模型时,eval() 的含义截然不同。
若由人类执行,你距离糟糕的下午或许只差一行粗心代码,且你还可以询问对方当时在想什么。但在这里,PHP 的完整能力面可从模型选择的任何一行代码触及,速度取决于代理的写入频率,且这些操作甚至无需具有恶意。即使仅仅是轻微错误或过度积极,我们也将陷入大麻烦。
那时我才明白,为什么 Code Mode 主要是一种与沙箱化相关的方法,而不仅仅是一种关于代码生成的方法。让模型编写优质代码只是简单的一半,且我已经做到了。所有昂贵且危险的部分都在于决定运行该代码之后的环节。
#版本二:我添加了沙箱
Cloudflare 几乎免费地获得了沙箱。整个 Workers 平台基于 V8 隔离区构建,因此新建一个隔离区只需几毫秒且仅占用几兆内存。他们为每个片段创建一个隔离区,用完即弃,毫不在意。 Laravel 应用不具备这种底层基础设施。你的应用运行在 Cloud、Forge、Docker 或某人的共享主机上。自safe_mode 被移除后,PHP 长期缺乏有效的进程内沙箱机制。因此,每个选项都意味着新增基础设施:
方案 | 意味着什么 |
|---|---|
每次执行一个容器 |
启动只需几秒,需要一个编排器来操作,还得额外保障一个新组件的安全 |
Firecracker microVM | 真正的隔离效果,启动耗时约 125 ms,但为了回答一个天气问题,你得维护一整套 microVM 控制面 |
E2B 或 Daytona 等托管沙箱 | 好用,但你的 MCP server 会多出一个第三方依赖,并且按秒计费 |
通过 Extism 或 php-wasm 运行 WASM | 隔离效果确实出色,但你得打包并解释一整套工具链 |
其中任何一个方案都可行。如果只为单个应用构建,我大概率会选一个然后继续推进。
但这次是把它做成一个包,这意味着失误的承担者变了。在应用里,沙箱略微没调对只是你自己的问题;在包里,它是一个默认行为,会随 composer require 发给每一个使用者,而他们大多会理所当然地假设隔离安全是别人的事。我实际上是在把数以万计的开发者交给数据库旁的代码执行环境,并要求他们信任我的威胁模型——而那一周,它已经出过一次错。
#版本三:模型说不通的 PHP 方言
于是我试图两头兼顾:如果这门语言无法表达任何危险操作,就不需要沙箱。
eval() 放进了 bin,我抓起了 nikic/PHP-Parser。解析 agent 写出的代码,遍历语法树,拒绝任何不在小型白名单内的节点:仅限调用已注册工具、字面量、简单赋值。危险的东西根本无法被表示,自然也就无需隔离。
它起效了,测试通过,而我又一次犯下了与最初如出一辙的错误,且完全没察觉。白名单和测试都源自我写的同一份规范,所以受测的每段代码自然都在子集范围内。它看起来是完整的,因为尚没有人基于它编写过代码,更没人告诉它哪些是被允许的。
直到一个从未读过我白名单的模型开始写入:
对于“查询这三个城市的告警”这个需求,这完全是一个合理的回答。但它同时也意味着四次拒绝:一个 foreach、一个 if、一个我没加入白名单的函数调用,以及一次数组追加。代码是合法的 PHP,模型输出得很有信心,可惜我的解释器拒之门外。
模型根本无从知道我的子集边界在哪。它的训练数据里不可能有我上周二才发明出来的规则。于是它不断失败、读错误信息、然后“纠正”成另一个同样不支持的语法结构。我本来指望代码方案的最大优势——模型对 PHP 的熟悉——结果反而成了负担。
这是四个版本里最糟的一个,偏偏还是我当时自认为最聪明的一个。
残缺的语言还不如没有语言。Code Mode 之所以可行,是因为 V8 isolate 运行的是真正的 JavaScript,而不是一个长得像 JavaScript、随后又拒绝执行其中三分之二语法的方言。
#第四版:从 PHP 切换到 JSON
我彻底放弃了 PHP-Parser,改用执行器能完整支持的 JSON 格式,不再需要模型去猜测任何子集边界。
起初感觉这像是倒退,后来发现恰恰相反。一个模型毫无先验认知的格式,居然胜过了它有强烈但错误先验的格式,因为失败模式变得一目了然。JSON Schema 明确规定了什么可以用,模型在工具定义里就能拿到这份 schema,不存在任何隐藏的坑。
然后我继续做减法。
我们原本支持每个 catalog 独立的流式配置,比如 ToolSearch::for([...])->maxToolCalls(5)->maxOutputBytes(32_000)。写起来很优雅,但这意味着限制可能存在于两处,而当配置文件和流式调用冲突时,我根本说不清该以谁为准。于是删掉了。
针对超出范围的配置值的详细异常也被移除,换成了 max(1, ...)。
每删掉一个能力,就少一件需要向模型解释的事,也少一个模型可能悄悄出错的地方。
在那轮打磨中,目标悄悄变了。我不再执着于移植一套令人惊艳的基础设施,而是开始追求让这个东西用起来像 Laravel 的其他部分——直接用功能就好,不必操心那些你压根不想了解的内部机制。一旦以此为准绳,我剩下的那些点子大多数都明显是错的。
那个我第一天就该问的问题终于浮出水面:这次收益中,有多大比例来自实际执行代码,又有多大比例来自避免在每次请求中都下发 40 个工具定义?
大部分来自后者。节省的开销源于渐进式披露,而渐进式披露并不需要执行任何东西。
#我交付了什么
两个元工具。search_tools 用于搜索工具目录。execute_tools 用于批量执行目录中的调用。你只需在服务器的 $tools 中以 ToolSearch::class 作为键声明一个目录:
CurrentWeatherTool 保持直接通告,因为几乎每个请求都会调用它。其他所有工具变为“可发现”而非“常驻”,这样 tools/list 返回的就只有 3 个工具,而不是 40 个。
下面是针对该服务器的真实会话。Agent 搜索“rainfall”,只返回一个工具:
这个结果让我惊讶的是它的影响远超预期。搜索是确定性的、基于词汇匹配的,而非嵌入向量:精确名称匹配得分最高,其次是名称中的词、描述以及序列化后的输入 Schema。在 station_readings 的名称或描述中并没有出现“rainfall”。它之所以匹配,是因为有一个名为 rainfall_mm 的参数。参数名实际上起到了文档的作用,因此搜索也会对其打分。
随后,它执行了一个批量调用:
#实测成本
我搭建了一个服务器,其中工具结构完全相同,每个工具有四个参数,并分别导出有无目录时 tools/list 的载荷。以下是该测试中实际的 JSON 字节数据:
工具数量 | 直接通告 | 通过目录 | 缩减比例 |
|---|---|---|---|
10 | 5,843 | 1,431 | 75.5% |
20 | 11,703 | 1,431 | 87.8% |
40 | 23,423 | 1,431 | 93.9% |
100 | 58,585 | 1,431 | 97.6% |
右侧那一列才是关键。Catalog 列的开销是固定的,因为无论背后封装了多少工具,传输的载荷始终只有 search_tools 和 execute_tools。这意味着后续添加的任何功能在闲置状态下都不产生额外成本。
不要把表中的绝对数值当作恒定值,而应视为一个快照。我测试用的工具刻意设计得很小,所以远低于真实服务器中平均每千个 token 对应一个工具的成本水平。真正重要的是表格呈现的结构:有一列随 API 增长而增加,另一列则保持不变。
令我惊讶的是,这种优化在很早的时候就开始见效。我原本以为盈亏平衡点会在 30 到 40 个工具左右,结果当只有 10 个工具时,载荷已经减少了四分之三。我现在的大致经验法则是:一旦工具数量超过 10 到 15 个,向 Agent 广播所有工具就不再是默认选择,而是需要额外理由的额外成本。
#在真实应用中的样子
天气类工具是个整洁的例子,但我真正关心的场景是那些需要封装自身一部分 API 表面的应用。
假设你向 Agent 开放了电商前台功能。你拥有订单、客户、退款、库存、物流以及几份报表相关的工具。即使还没太费劲,这已经是 20 个工具了。当 Agent 询问“订单 4021 在哪里”时,它仍然要为退款和库存工具的 schema 买单。把它们拆分开:
那两个被高频调用的工具保持常驻广播状态。其余 18 个工具依然完全可用、依然经过验证和授权,但在没有被请求之前,它们的成本为零。
我最自豪的部分其实没那么炫目。批次中的每次调用都会变成一个真实的 JsonRpcRequest,并经由 ToolInvoker 执行,这正是普通 tools/call 调用的标准路径。条件注册在执行时会被重新检查,而不仅仅是在搜索时,因此搜索结果本身绝不等于能力授予。
在框架而非通用运行时中,有一项优势是真正更易实现的。沙箱需要定义并防御从生成代码经由 Agent 循环回到服务器的每一个环节。通过不构建这些环节,我得以跳过所有这些复杂性。
#这不是我发明的,这让人安心
我经历了四个版本才达到目前的方案。随后我去查了查是否其他人也走上了同一条路,发现业界大多数早就这么做过了。
Cloudflare 是最早、也最接近这个思路的。他们的 Code Mode MCP server 只用两个工具search() 和 execute() 就暴露了 2,500 个 API 端点,token 消耗约 1,000,而不是 117 万。和我得出的两工具方案如出一辙,连命名都一样。
区别在于 execute() 的实现。他们默认在 V8 isolate 中运行 JavaScript,不提供文件系统访问,也不允许对外发起 fetch。他们并没有绕开沙箱,而是把沙箱搬到了服务端,让用户不必自己搭建。作为平台方,你可以这么做;但作为一个安装到别人基础设施里的软件包就不行——这正是我的第四版用 JSON 批处理而他们用编程语言的根本原因。
Anthropic 则以 Tool Search Tool 的形式给出了另一半答案:给工具标记 defer_loading: true,模型就能按需拉取完整定义,据报道可减少 85% 的 token。MCP 社区还有一个关于渐进式披露的开放 SEP,提议标准化一个 searchTools 元工具。Speakeasy 对这一模式做了基准测试,各种网关项目也早已实现动态工具发现。它甚至在模式目录里有个正式名字:渐进式工具发现(progressive tool discovery)。
事后发现这些工作,是这个项目里最让人安心的部分。多个独立团队都收敛到「搜索 + 延迟加载」的方案,比我自己的推理更能证明这个设计是对的。
#我反复听到的质疑
"搜索不就是多几次往返吗?你为了省 token 反而增加了延迟。"
没错。
一次搜索只需要一个往返,返回的也只是少量 schema。在我们的服务器上,agent 一次会话通常只会用到三个工具,所以我只需付出三四个 schema 加上两个元工具描述的成本,而且只付一次;相比之下,10 轮对话每轮都要带 40 个工具定义。这笔账很快就回本,对话越长越划算——因为定义的开销是每轮重复支付,而搜索的成本只付一次。
对于小规模服务器,每个工具都会被用到,此时采用目录方案并不划算。我自己的服务只有六个工具,所以从未将其整合进目录,因为让智能体去搜索它始终需要的东西纯属额外开销。实际上,我采用两者混合的方式运行。
我也认为这并不优于 Code Mode,并且已经不再将其视为竞争关系。这条光谱上有三个位置,我发布的方案恰好位于中间:
直接广播所有工具:无需基础设施,没有间接层,但你必须为每次请求中的每个 schema 买单。在 10 个工具以下时,这样没问题。
搜索与批量处理:无需基础设施,无论目录多大,负载都是扁平的,且没有控制流。智能体可以在一次往返中调用五个工具,但无法循环、分支,或将前一个结果传递给下一个。
在沙箱中生成代码:拥有完整的控制流,中间结果在到达模型前经过过滤,以及一个你可以自行运维或租赁的沙箱。
每一步都换取了能力,同时也付出了代价。我选择了中间的方案,因为在这三个选项中,它是唯一可以作为一个包直接交付、无需安装任何依赖的;而且目前大多数服务器面临的痛点是 schema 膨胀,而非无法编写循环。如果你的智能体真的需要对你的工具进行计算,第三个选项才是正解,我不会假装它不是。
#对我而言的改变
我犯过两次同样的错误。在版本一中,我的测试只运行我预期的代码片段,所以我从未看到设计所允许的其他行为。在版本三中,我的测试只使用我允许列表中的语法结构,因此该子集看起来完整无缺,直到某个未阅读我允许列表的代码开始编写。这两次,我都在用我自己的预期而非模型的视角来验证设计,而这两次测试看起来都像通过了。
这才是我真正学到的东西,比任何架构都重要。当你的 API 用户是语言模型时,你不是代表性用户,你的测试也不是证据。
我本想移植一套令人印象深刻的底层架构,结果删掉了其中绝大部分内容,连我当初颇为得意的解释器也没能幸免。最终上线的功能仅保留排序与分发,没有引入任何新依赖,也没有新增需要运维的组件。事实证明,真正经受住语言模型考验的版本,往往是功能最精简的那一个。因此,我不再默认选择复杂方案作为首要目标。
领域特定语言(DSL)故意设计得很小,小到还有扩展空间。让某次调用引用前一次调用的输出,是显而易见的下一步。我更倾向于在实际使用者遇到瓶颈后再添加此功能,而不是现在盲目猜测。所以,如果你在使用时发现 JSON 成了障碍,那正是我想收到的反馈。
正是阅读这些内容,让我关注到了 Cloudflare 关于 Code Mode 及其 Code Mode MCP 服务器 的文章,以及 Anthropic 关于 使用 MCP 执行代码 和 高级工具使用 的技术文章。