代码模式:更优的MCP使用方案

本文介绍了一种创新的MCP使用方式——代码模式,通过将MCP工具转换为TypeScript API,让LLM编写代码调用API,显著提升了AI代理处理复杂工具的能力,并详细解析了技术实现架构。

代码模式:更优的MCP使用方案

事实证明,我们一直以来都在错误地使用MCP。

如今大多数代理通过直接向LLM暴露"工具"来使用MCP。我们尝试了不同的方法:将MCP工具转换为TypeScript API,然后要求LLM编写调用该API的代码。

结果令人惊讶:

我们发现,当工具以TypeScript API的形式呈现而非直接暴露时,代理能够处理更多工具和更复杂的工具。这可能是因为LLM的训练集中包含了大量的真实TypeScript代码,但只有少量人为构造的工具调用示例。

当代理需要串联多个调用时,这种方法的优势更加明显。使用传统方法时,每个工具调用的输出都必须输入到LLM的神经网络中,仅仅是为了复制到下一个调用的输入,浪费了时间、精力和令牌。当LLM能够编写代码时,它可以跳过所有这些步骤,只读取所需的最终结果。

简而言之,LLM更擅长编写代码来调用MCP,而不是直接调用MCP。

什么是MCP?

对于不熟悉的人来说:模型上下文协议(Model Context Protocol)是一个标准协议,用于让AI代理访问外部工具,使它们能够直接执行工作,而不仅仅是与您聊天。

从另一个角度看,MCP是一种统一的方式:

  • 暴露执行某项操作的API
  • 提供LLM理解该API所需的文档
  • 通过带外方式处理授权

MCP在2025年引起了巨大轰动,因为它突然极大地扩展了AI代理的能力。

MCP服务器暴露的"API"表示为一组"工具"。每个工具本质上是一个远程过程调用(RPC)函数——它通过一些参数调用并返回响应。大多数现代LLM都具有使用"工具"的能力(有时称为"函数调用"),这意味着它们经过训练,在想要调用工具时能够以特定格式输出文本。调用LLM的程序看到这种格式并按指定调用工具,然后将结果作为输入反馈给LLM。

工具调用的剖析

在底层,LLM生成表示其输出的"令牌"流。一个令牌可能代表一个单词、一个音节、某种标点符号或文本的其他组成部分。

然而,工具调用涉及一个没有任何文本等效项的令牌。LLM经过训练(或更常见的是微调)以理解它可以输出的特殊令牌,这些令牌表示"以下内容应被解释为工具调用",以及另一个表示"工具调用结束"的特殊令牌。在这两个令牌之间,LLM通常会写入与某种描述调用的JSON消息相对应的令牌。

例如,假设您已将代理连接到提供天气信息的MCP服务器,然后询问代理德克萨斯州奥斯汀的天气情况。在底层,LLM可能会生成如下输出。请注意,这里我们使用了<|和|>中的单词来表示我们的特殊令牌,但实际上,这些令牌根本不代表文本;这仅用于说明。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
我将使用Weather MCP服务器查找德克萨斯州奥斯汀的天气情况
我将使用Weather MCP服务器查找德克萨斯州奥斯汀的天气情况

<|tool_call|>
{
  "name": "get_current_weather",
  "arguments": {
    "location": "Austin, TX, USA"
  }
}
<|end_tool_call|>

在输出中看到这些特殊令牌时,LLM的 harness 会将序列解释为工具调用。看到结束令牌后,harness 暂停LLM的执行。它解析JSON消息并将其作为结构化API结果的单独组件返回。调用LLM API的代理看到工具调用,调用相关的MCP服务器,然后将结果发送回LLM API。LLM的 harness 随后将使用另一组特殊令牌将结果反馈给LLM:

1
2
3
4
5
6
7
8
<|tool_result|>
{
  "location": "Austin, TX, USA",
  "temperature": 93,
  "unit": "fahrenheit",
  "conditions": "sunny"
}
<|end_tool_result|>

LLM以与读取用户输入完全相同的方式读取这些令牌——只是用户无法产生这些特殊令牌,因此LLM知道这是工具调用的结果。然后LLM继续正常生成输出。

不同的LLM可能使用不同的工具调用格式,但这是基本思想。

这有什么问题?

工具调用中使用的特殊令牌是LLM在现实世界中从未见过的。它们必须基于合成训练数据进行专门训练才能使用工具。它们在这方面并不总是那么擅长。如果您向LLM呈现过多工具或过于复杂的工具,它可能难以选择正确的工具或正确使用它。因此,鼓励MCP服务器设计者呈现大大简化的API,与可能暴露给开发者的更传统API相比。

与此同时,LLM在编写代码方面变得非常出色。事实上,要求LLM针对通常暴露给开发者的完整、复杂API编写代码似乎并没有太大困难。那么,为什么MCP接口必须"简化"呢?编写代码和调用工具几乎是同一件事,但似乎LLM在一方面比另一方面做得好得多?

答案很简单:LLM见过很多代码。它们没有见过很多"工具调用"。事实上,它们见过的工具调用可能仅限于LLM自己的开发者构建的人为训练集,以尝试训练它。而它们从数百万个开源项目中见过真实世界的代码。

让LLM通过工具调用执行任务就像让莎士比亚参加一个月的普通话课程,然后要求他用普通话写一部戏剧。这不会是他最好的作品。

但MCP仍然有用,因为它是统一的

MCP是为工具调用设计的,但实际上不一定非要这样使用。

MCP服务器暴露的"工具"实际上只是一个带有附加文档的RPC接口。我们不一定非要将其作为工具呈现。我们可以获取这些工具,并将其转换为编程语言API。

但是,当编程语言API已经独立存在时,我们为什么要这样做呢?几乎每个MCP服务器都只是现有传统API的包装器——为什么不暴露这些API呢?

嗯,事实证明MCP还做了其他真正有用的事情:它提供了一种统一的方式来连接和学习API。

即使代理的开发者从未听说过特定的MCP服务器,而MCP服务器的开发者从未听说过特定的代理,AI代理也可以使用MCP服务器。这在过去对于传统API来说很少成立。通常,客户端开发者总是确切地知道他们正在为哪个API编码。因此,每个API都能够以略有不同的方式处理基本连接性、授权和文档等事情。

即使AI代理正在编写代码,这种统一性也是有用的。我们希望AI代理在沙箱中运行,以便它只能访问我们给它的工具。MCP通过以标准方式处理连接性和授权(独立于AI代码),使代理框架能够实现这一点。我们也不希望AI必须搜索互联网以获取文档;MCP直接在协议中提供文档。

好的,它是如何工作的?

我们已经扩展了Cloudflare Agents SDK来支持这种新模型!

例如,假设您有一个使用ai-sdk构建的应用程序,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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: {
    // 工具定义
  }
})

您可以使用codemode助手包装工具和提示,并在应用程序中使用它们:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { codemode } from "agents/codemode/ai";

const {system, tools} = codemode({
  system: "You are a helpful assistant",
  tools: {
    // 工具定义
  },
  // ...配置
})

const stream = streamText({
  model: openai("gpt-5"),
  system,
  tools,
  messages: [
    { role: "user", content: "Write a function that adds two numbers" }
  ]
})

通过此更改,您的应用程序现在将开始生成和运行代码,这些代码本身将调用您定义的工具,包括MCP服务器。我们将在不久的将来为其他库引入变体。请阅读文档以获取更多详细信息和示例。

将MCP转换为TypeScript

当您在"代码模式"下连接到MCP服务器时,Agents SDK将获取MCP服务器的模式,然后将其转换为TypeScript API,包括基于模式的文档注释。

例如,连接到 https://gitmcp.io/cloudflare/agents 的MCP服务器将生成如下TypeScript定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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——很像代理编码助手那样。

在沙箱中运行代码

我们的代理不是呈现所有连接的MCP服务器的所有工具,而是只呈现一个工具,该工具简单地执行一些TypeScript代码。

然后代码在安全沙箱中执行。沙箱与互联网完全隔离。它访问外部世界的唯一途径是通过代表其连接的MCP服务器的TypeScript API。

这些API由RPC调用支持,该调用回调到代理循环。在那里,Agents SDK将调用分派到适当的MCP服务器。

沙箱代码以明显的方式将结果返回给代理:通过调用console.log()。当脚本完成时,所有输出日志都会传递回代理。

动态Worker加载:这里没有容器

这种新方法需要访问可以运行任意代码的安全沙箱。那么我们在哪里找到一个呢?我们必须运行容器吗?那会很昂贵吗?

不。没有容器。我们有更好的东西:isolates。

Cloudflare Workers平台一直基于V8 isolates,即由V8 JavaScript引擎提供支持的隔离JavaScript运行时。

isolates比容器轻量得多。一个isolate可以在几毫秒内启动,仅使用几兆字节的内存。

isolates如此之快,以至于我们可以为代理运行的每段代码创建一个新的isolate。无需重用它们。无需预热它们。只需按需创建它,运行代码,然后丢弃它。这一切发生得如此之快,以至于开销可以忽略不计;几乎就像您直接eval()代码一样。但是具有安全性。

Worker Loader API

但直到现在,Worker还没有办法直接加载包含任意代码的isolate。所有Worker代码都必须通过Cloudflare API上传,然后将其全局部署,以便它可以在任何地方运行。这不是我们想要的Agents!我们希望代码就在代理所在的位置运行。

为此,我们向Workers平台添加了一个新的API:Worker Loader API。使用它,您可以按需加载Worker代码。它看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 获取具有给定ID的Worker,如果尚不存在此类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代理,
    // 以监视或过滤所有Internet访问。您也可以通过传递`null`完全阻止Internet访问。
    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}
});

您现在可以在本地使用Wrangler运行workerd时开始使用此API(查看文档),并且可以注册beta访问权限以在生产环境中使用它。

Workers是更好的沙箱

Workers的设计使其在沙箱化方面异常出色,特别是对于此用例,原因如下:

更快、更便宜、可丢弃的沙箱

Workers平台使用isolates而不是容器。isolates更轻量级且启动更快。启动一个新的isolate仅需几毫秒,而且非常便宜,我们可以为代理生成的每个代码片段创建一个新的isolate。无需担心为重用而池化isolates、预热等。

我们尚未最终确定Worker Loader API的定价,但因为它基于isolates,我们将能够以显著低于基于容器的解决方案的成本提供它。

默认隔离,但通过绑定连接

Workers更擅长处理隔离。

在代码模式下,我们禁止沙箱worker与互联网通信。全局fetch()和connect()函数会抛出错误。

但在大多数平台上,这将是一个问题。在大多数平台上,您访问私有资源的方式是,您从通用网络访问开始。然后,使用该网络访问,您向特定服务发送请求,向它们传递某种API密钥以授权私有访问。

但Workers一直有更好的答案。在Workers中,“环境”(env对象)不仅包含字符串,还包含活动对象,也称为"绑定"。这些对象可以提供对私有资源的直接访问,而无需涉及通用网络请求。

在代码模式下,我们给予沙箱访问代表其连接的MCP服务器的绑定。因此,代理可以专门访问那些MCP服务器,而无需具有通用网络访问权限。

通过绑定限制访问比通过(例如)网络级过滤或HTTP代理更清晰。过滤对LLM和监管者都很困难,因为边界通常不清晰:监管者可能难以准确识别与API通信合法需要什么流量。同时,LLM可能难以猜测哪些类型的请求将被阻止。使用绑定方法,它是明确定义的:绑定提供JavaScript接口,并且允许使用该接口。这样更好。

没有可泄露的API密钥

绑定的一个额外好处是它们隐藏了API密钥。绑定本身向MCP服务器提供已经授权的客户端接口。在其上进行的所有调用首先转到代理监管者,该监管者持有访问令牌并将它们添加到发送到MCP的请求中。

这意味着AI不可能编写泄露任何密钥的代码,解决了当今AI编写代码中常见的安全问题。

立即尝试!

注册生产beta版

动态Worker Loader API处于封闭测试阶段。要在生产中使用它,请立即注册。

或在本地尝试

如果您只是想试用,动态Worker加载在今天完全可用,当使用Wrangler和workerd进行本地开发时——查看动态Worker加载和Agents SDK中代码模式的文档以开始使用。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计