代码模式:使用MCP的更好方法
结果证明,我们一直都在错误地使用MCP。
如今大多数代理通过直接向LLM暴露“工具”来使用MCP。
我们尝试了不同的方法:将MCP工具转换为TypeScript API,然后让LLM编写调用该API的代码。
结果非常显著:
- 我们发现当这些工具以TypeScript API的形式呈现时,代理能够处理更多的工具和更复杂的工具。也许这是因为LLM在其训练集中有大量的真实世界TypeScript代码,但只有少量构造的工具调用示例。
- 当代理需要串联多个调用时,这种方法特别有效。传统的做法中,每个工具调用的输出必须输入到LLM的神经网络中,然后再复制到下一个调用的输入中,这会浪费时间、能量和令牌。当LLM可以编写代码时,它可以跳过所有这些步骤,只需读取它需要的最终结果即可。
简而言之,LLM在编写调用MCP的代码方面比直接调用MCP更擅长。
1、什么是MCP?
对于不熟悉的人来说:Model Context Protocol 是一种标准协议,用于给AI代理访问外部工具,这样它们就可以直接执行工作,而不仅仅是与您聊天。
从另一个角度来看,MCP是一种统一的方式:
- 暴露一个用于做某事的API,
- 以及LLM理解它所需的文档,
- 并通过带外方式处理授权。
MCP在2025年引起了广泛关注,因为它突然大大扩展了AI代理的能力。
MCP服务器暴露的“API”是一组“工具”。每个工具基本上是一个远程过程调用(RPC)函数——它带有某些参数并返回响应。大多数现代LLM都有使用“工具”(有时称为“函数调用”)的能力,这意味着它们被训练成在想要调用工具时输出特定格式的文本。调用LLM的程序看到这种格式后,会按照指定调用工具,然后将结果作为输入反馈给LLM。
2、工具调用的结构
在内部,LLM生成一系列代表其输出的“标记”。一个标记可能代表一个单词、一个音节、某种标点符号或其他文本组件。
然而,工具调用涉及一个没有文本等效的标记。LLM被训练(或更常见的是微调)以理解一个特殊的标记,它可以输出表示“以下内容应解释为工具调用”,另一个特殊标记表示“这是工具调用的结束”。在这两个标记之间,LLM通常会写入对应于某种JSON消息的标记,描述调用。
例如,假设您已将代理连接到提供天气信息的MCP服务器,然后询问代理德克萨斯州奥斯汀的天气情况。在内部,LLM可能会生成如下输出。请注意,这里我们使用<|和|>中的单词来表示我们的特殊标记,但实际上这些标记并不表示文本;这只是为了说明。
我会使用天气MCP服务器来查找德克萨斯州奥斯汀的天气。
我会使用天气MCP服务器来查找德克萨西州奥斯汀的天气。
<|tool_call|>
{
"name": "get_current_weather",
"arguments": {
"location": "Austin, TX, USA"
}
}
<|end_tool_call|>
当看到输出中的这些特殊标记时,LLM的框架会将其解释为工具调用。在看到结束标记后,框架暂停LLM的执行。它解析JSON消息,并将其作为结构化API结果的独立组件返回。调用LLM API的代理会看到工具调用,调用相关的MCP服务器,然后将结果返回给LLM API。LLM的框架随后使用另一组特殊标记将结果反馈给LLM:
<|tool_result|>
{
"location": "Austin, TX, USA",
"temperature": 93,
"unit": "fahrenheit",
"conditions": "sunny"
}
<|end_tool_result|>
LLM以与读取用户输入相同的方式读取这些标记——只是用户无法生成这些特殊标记,因此LLM知道这是工具调用的结果。然后LLM像正常一样继续生成输出。
不同的LLM可能使用不同的工具调用格式,但这是基本概念。
3、这有什么问题吗?
工具调用中使用的特殊标记是LLM在现实中从未见过的东西。它们必须基于合成训练数据专门训练来使用工具。它们并不总是那么好。如果你向LLM展示太多工具,或者过于复杂的工具,它可能会难以选择正确的工具或正确使用它。因此,MCP服务器设计者被鼓励相比他们通常向开发者暴露的传统API,提供大大简化后的API。
同时,LLM在编写代码方面变得非常出色。事实上,要求LLM针对通常向开发者暴露的完整、复杂API编写代码似乎并没有太大的困难。那么为什么MCP接口必须“简化”呢?编写代码和调用工具几乎是同一件事,但看起来LLM在一个方面做得比另一个好得多?
答案很简单:LLM看到了很多代码。它们没有看到很多“工具调用”。事实上,它们看到的工具调用可能仅限于由LLM自己的开发人员构建的构造性训练集,以尝试训练它。而它们却看到了数百万开源项目的实际代码。
让LLM通过工具调用来完成任务就像把莎士比亚送进一个月的中文课程,然后让他用中文写一部戏剧。这绝不会是他的最佳作品。
4、但MCP仍然有用,因为它具有统一性
MCP是为工具调用设计的,但它实际上不一定要以这种方式使用。
MCP服务器暴露的“工具”实际上只是一个带有附加文档的RPC接口。我们并不真的必须将它们作为工具呈现。我们可以将这些工具转换为编程语言API。
但是为什么当我们已经有独立的编程语言API时还要这样做呢?几乎每个MCP服务器只是对现有传统API的包装——为什么不直接暴露这些API?
嗯,事实证明MCP还做了另一件很有用的事情:它提供了一种统一的方式来连接和了解API。
即使代理的开发人员从未听说过特定的MCP服务器,代理也可以使用MCP服务器。同样,MCP服务器的开发人员也从未听说过特定的代理。过去这种情况很少见。通常,客户端开发人员总是确切知道他们正在编码的API是什么。因此,每个API都能以稍微不同的方式实现基本的连接、授权和文档。
即使在AI代理编写代码时,这种统一性也很有用。我们希望AI代理运行在一个沙箱中,只能访问我们提供的工具。MCP使代理框架能够通过以标准方式处理连接和授权来实现这一点,与AI代码无关。我们也不希望AI代理搜索互联网寻找文档;MCP在协议中直接提供它。
5、那么它是如何工作的?
我们已经扩展了Cloudflare Agents SDK以支持这种新模式!
例如,假设你有一个使用ai-sdk构建的应用程序,如下所示:
const stream = streamText({
model: openai("gpt-5"),
system: "You are a helpful assistant",
messages: [
{ role: "user", content: "Write a function that adds two numbers" }
],
tools: {
// tool definitions
}
})
你可以用codemode助手包装工具和提示,并在你的应用程序中使用它们:
import { codemode } from "agents/codemode/ai";
const {system, tools} = codemode({
system: "You are a helpful assistant",
tools: {
// tool definitions
},
// ...config
})
const stream = streamText({
model: openai("gpt-5"),
system,
tools,
messages: [
{ role: "user", content: "Write a function that adds two numbers" }
]
})
通过这个更改,你的应用程序现在开始生成并运行代码,这些代码本身将调用你定义的工具,包括MCP服务器。我们将在不久的将来推出其他库的变体。阅读文档获取更多细节和示例。
6、将MCP转换为TypeScript
当你在“代码模式”下连接到MCP服务器时,Agents SDK会获取MCP服务器的Schema,然后将其转换为TypeScript API,包括基于模式的文档注释。
例如,连接到MCP服务器https://gitmcp.io/cloudflare/agents,将生成类似这样的TypeScript定义:
interface FetchAgentsDocumentationInput {
[k: string]: unknown;
}
interface FetchAgentsDocumentationOutput {
[key: string]: any;
}
interface SearchAgentsDocumentationInput {
/**
* The search query to find relevant documentation
*/
query: string;
}
interface SearchAgentsDocumentationOutput {
[key: string]: any;
}
interface SearchAgentsCodeInput {
/**
* The search query to find relevant code files
*/
query: string;
/**
* Page number to retrieve (starting from 1). Each page contains 30
* results.
*/
page?: number;
}
interface SearchAgentsCodeOutput {
[key: string]: any;
}
interface FetchGenericUrlContentInput {
/**
* The URL of the document or page to fetch
*/
url: string;
}
interface FetchGenericUrlContentOutput {
[key: string]: any;
}
declare const codemode: {
/**
* Fetch entire documentation file from GitHub repository:
* cloudflare/agents. Useful for general questions. Always call
* this tool first if asked about cloudflare/agents.
*/
fetch_agents_documentation: (
input: FetchAgentsDocumentationInput
) => Promise<FetchAgentsDocumentationOutput>;
/**
* Semantically search within the fetched documentation from
* GitHub repository: cloudflare/agents. Useful for specific queries.
*/
search_agents_documentation: (
input: SearchAgentsDocumentationInput
) => Promise<SearchAgentsDocumentationOutput>;
/**
* Search for code within the GitHub repository: "cloudflare/agents"
* using the GitHub Search API (exact match). Returns matching files
* for you to query further if relevant.
*/
search_agents_code: (
input: SearchAgentsCodeInput
) => Promise<SearchAgentsCodeOutput>;
/**
* Generic tool to fetch content from any absolute URL, respecting
* robots.txt rules. Use this to retrieve referenced urls (absolute
* urls) that were mentioned in previously fetched documentation.
*/
fetch_generic_url_content: (
input: FetchGenericUrlContentInput
) => Promise<FetchGenericUrlContentOutput>;
};
然后将此TypeScript加载到代理的上下文中。目前,整个API都被加载,但未来的改进可以使代理更动态地搜索和浏览API——就像一个代理编码助手那样。
7、在沙箱中运行代码
我们的代理只被呈现一个工具,而不是被呈现所有连接的MCP服务器的所有工具,它只是执行一些TypeScript代码。
然后代码在安全的沙箱中执行。沙箱完全隔离于互联网。它对外界的唯一访问是通过代表其连接的MCP服务器的TypeScript API。
这些API由RPC调用支持,调用回调到代理循环。在那里,Agents SDK将调用分派到适当的MCP服务器。
沙箱代码通过调用console.log()将结果返回给代理。当脚本完成后,所有输出日志都会返回给代理。
8、动态Worker加载:这里没有容器
这种新方法需要访问一个安全的沙箱,其中可以运行任意代码。那么我们从哪里找到这样的沙箱?我们需要运行容器吗?那很昂贵吗?
不需要。这里没有容器。我们有更好的东西:隔离。
Cloudflare Workers平台始终基于V8隔离,即由V8 JavaScript引擎驱动的隔离JavaScript运行时。
隔离比容器轻量得多。 一个隔离可以在仅使用几兆字节内存的情况下,在几十毫秒内启动。
隔离如此快速,我们可以为代理运行的每一段代码创建一个新的隔离。不需要重复使用它们。不需要预热它们。只需按需创建,运行代码,然后丢弃。这一切发生得如此迅速,开销可以忽略不计;几乎就像你直接eval()代码一样。但有安全性。
9、Worker加载器API
到目前为止,还没有办法让Worker直接加载包含任意代码的隔离。所有的Worker代码都必须通过Cloudflare API上传,然后部署到全球,以便它可以运行在任何地方。这不是我们想要的!我们希望代码就在代理所在的地方运行。
为此,我们为Workers平台添加了一个新的API:Worker加载器API。借助它,您可以按需加载Worker代码。以下是它的样子:
// 获取具有给定ID的Worker,如果尚未存在,则创建它。
let worker = env.LOADER.get(id, async () => {
// 如果Worker尚不存在,将调用此回调来获取其代码。
return {
compatibilityDate: "2025-06-01",
// 指定Worker的代码(模块文件)。
mainModule: "foo.js",
modules: {
"foo.js":
"export default {\n" +
" fetch(req, env, ctx) { return new Response('Hello'); }\n" +
"}\n",
},
// 指定动态Worker的环境(`env`)。
env: {
// 它可以包含基本的可序列化数据类型...
SOME_NUMBER: 123,
// ...以及绑定回父Worker的导出RPC接口,使用新的`ctx.exports`回环绑定API。
SOME_RPC_BINDING: ctx.exports.MyBindingImpl({props})
},
// 将Worker的`fetch()`和`connect()`重定向到通过父Worker代理,以监控或过滤所有互联网访问。您还可以通过传递`null`完全阻止互联网访问。
globalOutbound: ctx.exports.OutboundProxy({props}),
};
});
// 现在您可以获取Worker的入口点并将其请求发送给它。
let defaultEntrypoint = worker.getEntrypoint();
await defaultEntrypoint.fetch("http://example.com");
// 您也可以获取非默认入口点,并指定`ctx.props`值传递给入口点。
let someEntrypoint = worker.getEntrypoint("SomeEntrypointClass", {
props: {someProp: 123}
});
您现在可以在本地运行workerd时使用这个API(查看文档),并且可以注册参加测试以在生产环境中使用它。
10、Workers是更好的沙箱
Workers的设计使其特别适合沙箱化,尤其是对于这种用例,原因有几个:
更快、更便宜、可丢弃的沙箱
Workers平台使用隔离而不是容器。 隔离轻量级且启动速度快。启动一个新的隔离只需要几毫秒,而且成本很低,我们可以为代理生成的每一段代码都创建一个新的隔离。不需要担心隔离池的复用、预热等。
我们尚未最终确定Worker加载器API的定价,但由于它基于隔离,我们将能够以显著低于基于容器的解决方案的成本提供它。
默认隔离,但通过绑定连接
Workers在处理隔离方面表现得更好。
在代码模式下,我们禁止沙箱化的Worker与互联网通信。全局的fetch()和connect()函数会抛出错误。
但在大多数平台上,这会成为一个问题。在大多数平台上,获得对私有资源的访问权限的方法是,你一开始就有通用的网络访问权限。然后,使用这个网络访问权限,你向特定服务发送请求,传递某种API密钥以授权私有访问。
但Workers一直有一个更好的答案。在Workers中,“环境”(env对象)不仅包含字符串,它包含实时对象,也称为“绑定”。这些对象可以直接访问私有资源,而不涉及通用的网络请求。
在代码模式下,我们给予沙箱对它连接的MCP服务器的绑定访问权限。因此,代理可以具体访问这些MCP服务器,而无需一般性的网络访问。
通过绑定限制访问比通过网络级过滤或HTTP代理更干净。过滤对LLM和监督者来说都很困难,因为边界往往不清楚:监督者可能很难准确识别哪些流量是合法必要的与API通信。同时,LLM可能很难猜测哪些请求会被阻止。通过绑定的方法,界限是明确的:绑定提供了一个JavaScript接口,允许使用该接口。这种方式更好。
没有泄露API密钥的风险
绑定的另一个好处是隐藏API密钥。绑定本身提供了一个已授权的客户端接口给MCP服务器。所有对其的调用首先由代理监督者处理,该监督者持有访问令牌并将它们添加到发送到MCP的请求中。
这意味着AI不可能写出泄露任何密钥的代码,解决了当今AI编写的代码中常见的安全问题。
原文链接:Code Mode: the better way to use MCP
汇智网翻译整理,转载请标明出处