进程内 MCP Server 搭建
基于 FC Agent Sandbox 和 Claude Agent SDK 的 MCP 服务搭建
本文是一份从 0 开始的搭建教程:把阿里云函数计算云沙箱(FC Agent Sandbox)的代码执行能力,封装为一组 MCP 工具,注册进 Claude Agent SDK 的 Agent 循环,让大模型在对话中自主调用沙箱执行代码。
读者需要具备 Python 基础;按顺序把各步代码保存到同一文件即可运行。
目标与原理
要解决什么问题
Claude Agent SDK 提供的是 Agent 循环:模型读到用户任务 → 决定调用某个工具 → 拿到工具结果 →决定下一步 → 直到给出最终答复。但模型开箱可用的只有读文件、搜索等本地工具——它没有地方执行代码。
我们的目标是补上这块能力:把「在阿里云沙箱里执行代码 / 传文件 / 取结果」包装成 MCP 工具,注册给模型。模型负责「想做什么」,沙箱负责「安全地做」,业务进程自身不运行任何生成代码。
进程内 MCP Server:最轻的服务形态
传统 MCP 服务是一个独立进程(stdio 或 HTTP/SSE 传输),需要手写 JSON-RPC 消息处理。
Claude Agent SDK 提供了第三种形态——进程内 SDK Server:
1 | server = create_sdk_mcp_server(name="sandbox", version="1.0.0", tools=[...]) |
它返回一个配置对象,直接挂到 Agent 选项里。协议解析、与 CLI 子进程的通信全部由 SDK 完成,你只需要提供「工具清单」。
因此本文搭建的”MCP 服务”= 一组用 @tool 装饰器定义的 async 函数
- 一个
create_sdk_mcp_server调用,不需要独立进程、端口或网络服务。
一次工具调用的完整链路
1 | 用户任务 ─▶ Claude(决策) ─▶ 发起工具调用 mcp__sandbox__run_code |
理解这条链路,后面每一步的职责就清楚了:
[第 1 步](#第 1 步:封装沙箱会话——为工具提供真实执行能力)做最底层的沙箱执行;
[第 2 步](#第 2 步:定义 MCP 工具——把沙箱能力翻译成模型可调用的接口)把执行能力翻译成模型可调用的工具接口;
[第 3 步](#第 3 步:组装 MCP Server——给工具集一个名字)把工具集组装成 Server;
[第 4~5 步](#第 4 步:编写系统提示词——教模型正确使用你的服务)把 Server 接进 Agent 循环并告诉模型怎么用;
[第 6 步](#第 6 步:运行与验证)运行验证。
环境准备
安装依赖
1 | pip install claude-agent-sdk e2b-code-interpreter |
claude-agent-sdk:Agent 循环 + 进程内 MCP Server 机制。官方 wheel 自带 Claude Code CLI二进制(Windows/Linux/macOS x64),无需另装 Node.js。e2b-code-interpreter:阿里云沙箱官方 Python SDK(E2B 兼容),负责创建沙箱、执行代码、读写沙箱文件。安装时会一并装上其依赖e2b。
准备两组凭证
搭建这个服务需要两组互不相关的凭证:
| 组 | 变量 | 用途 |
|---|---|---|
| 沙箱凭证 | E2B_API_KEY / E2B_API_URL / E2B_DOMAIN |
创建与访问阿里云沙箱(在函数计算控制台创建 API Key) |
| 模型凭证 | ANTHROPIC_API_KEY(官方直连)或 ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN(中转网关) |
Claude Agent SDK 调用大模型 |
两组凭证的消费者不同:沙箱凭证给你的 Python 进程用(创建沙箱);模型凭证注入 SDK拉起的 CLI 子进程用。模型凭证绝不能进入沙箱——沙箱里跑的是不可信代码。
写入 .env
在项目根目录创建 .env(并将其加入 .gitignore,凭证永不进代码库与日志):
1 | # 阿里云沙箱三要素(<region> 需与账号开通地域一致) |
CLAUDE_MODEL为什么建议显式设置:CLI 子进程会读取本机~/.claude.json里保存的”上次使用的模型”,SDK 的setting_sources=[]选项管不到这个文件。若本机残留过网关不支持的模型名,请求会直接 400。显式指定模型(经options.model传入)优先级最高,一律显式指定最稳妥。
用一个简单的加载函数把 .env 读进环境变量(SDK 不自动读取 .env):
1 | import os |
第 1 步:封装沙箱会话——为工具提供真实执行能力
这一步的作用:MCP 工具只是”接口”,真正干活的是沙箱。先写一个沙箱会话封装类,解决三个工程问题,后面所有工具都只调它:
- 异步桥接:
e2bSDK 是同步阻塞的(一次 HTTP 请求直到执行完成才返回),而 MCP 工具的handler 是 async 函数。直接调用会卡死整个事件循环——必须用asyncio.to_thread把阻塞调用丢进线程池。 - 操作串行化:一个 Code Context(变量空间)同时只能跑一段代码,e2b 的并发行为也没有承诺。用一把
asyncio.Lock把所有沙箱操作串行化,行为可预期。 - 懒创建与确定性销毁:沙箱按存活时间计费。纯聊天轮不该花钱——首次真正执行代码才创建;结束后必须
kill(),放在finally里保证异常路径也不泄漏。
1 | import asyncio |
再写一个结果格式化函数——e2b 返回的执行对象字段很多,直接 dump 给模型既浪费 token又不利阅读。组装成固定分节文本,模型定位信息最快:
1 | def format_result(execution) -> tuple[str, bool]: |
至此沙箱后端就绪。注意这一步里没有任何 Claude SDK 的东西——它是一块可独立复用的能力,MCP 工具只是它的一层”翻译”。
第 2 步:定义 MCP 工具——把沙箱能力翻译成模型可调用的接口
这一步的作用:用 @tool 装饰器把一个 async 函数登记为 MCP 工具。模型在对话中能”看到”的只有工具名、描述和参数 schema——这三样写得好坏,直接决定模型用不用它、怎么传参、会不会选错工具。
@tool 的四个要素
1 | from claude_agent_sdk import tool |
| 要素 | 作用 | 写法要点 |
|---|---|---|
name |
模型调用时使用的标识 | 动词或动宾结构,语义直白(run_code 而非 rc) |
description |
模型判断”何时该用这个工具”的唯一依据 | 写清用途 + 硬性约束(超时上限、文件路径约定)。约束写进描述,模型才会遵守 |
input_schema |
完整 JSON Schema,控制参数名/类型/必填/取值范围 | 必须用完整 schema 并显式给 required 列表——简单 dict 会被 SDK 视为所有键必填,这是常见坑 |
| handler | 真正执行的 async 函数 | 入参是 dict(模型按 schema 生成的实参);返回 {"content": [...], "is_error": bool} |
完整示例:run_code 工具
1 | RUN_CODE_DESCRIPTION = ( |
错误的两条通道——自我修正闭环的关键
handler 的返回结构里 is_error 是一个语义强烈的开关,务必区分两种失败:
- 代码执行失败(除零、NameError……):这是给模型看的正常业务结果。带上
[error]摘要与[traceback]尾部、is_error=True回传——模型读到确切原因后,Agent 循环会驱动它修正代码自动重试,不需要人工介入。这是整个服务最核心的机制。 - 基础设施异常(沙箱创建失败、网络断开……):由 handler 的
except兜底,同样以is_error文本回传,并在文本里引导模型”这是环境问题,停止重试并告知用户”。
两条通道都回传文本而不是抛异常——抛异常会中断 Agent 循环,模型失去修正机会。
按同样的模式可以继续扩展工具:
read_file(读沙箱文本)、read_image(读图片并以image 块回传,见 9.2)、list_files、download_file(产物落盘本地)等。工具数量没有硬限制,但每个工具的 description 都要写清”何时用我”。
第 3 步:组装 MCP Server——给工具集一个名字
这一步的作用:把工具列表组装成一个命名 Server。名字不是装饰——它决定工具的全局调用名,也决定后面权限配置怎么写。
1 | from claude_agent_sdk import create_sdk_mcp_server |
命名规则:工具在模型眼中的全名是 mcp__{server名}__{工具名}。上面注册的 run_code全名即 mcp__sandbox__run_code。这个前缀在两处会用到:
- 自动批准:
allowed_tools=["mcp__sandbox__*"]一条通配规则放行全部沙箱工具,无需逐个罗列; - 日志审计:Agent 循环打印的每次工具调用都带全名,一眼区分”模型调了沙箱”还是”模型调了本地工具”。
第 4 步:编写系统提示词——教模型正确使用你的服务
这一步的作用:工具描述只回答”单个工具怎么用”,系统提示词回答”整体怎么协作”——执行环境是什么、哪些红线不能碰、产物放哪、失败怎么办。MCP 服务搭建中这步常被忽略,但它直接决定模型的行为质量。最少要写清四件事:
1 | SYSTEM_PROMPT = """\ |
第 5 步:组装 Agent 选项并启动循环
这一步的作用:ClaudeAgentOptions 是服务的总装配台——MCP Server 挂载、权限策略、模型凭证、提示词,全部在这里汇合。逐字段解释:
1 | import claude_agent_sdk as sdk |
各字段的取舍理由:
| 字段 | 作用 | 不设置的后果 |
|---|---|---|
mcp_servers |
挂载进程内 MCP 服务 | 模型没有沙箱工具可用 |
allowed_tools + permission_mode |
沙箱工具自动批准、无人值守 | 每次工具调用挂起等待人工确认,自动化中断 |
setting_sources=[] / strict_mcp_config=True |
隔离本机残留配置与外部 MCP 注入 | 用户级设置/外部 .mcp.json 混入未知工具,行为不可控 |
env |
模型凭证进 CLI 子进程 | CLI 子进程拿不到网关凭证,无法调模型 |
max_turns |
循环轮次上限 | 模型陷入死循环时无限消耗 token 与沙箱时间 |
如果还想给模型本地能力,用
tools=["Read", "Glob", "Grep"]白名单只开放只读内建工具(Bash/Edit/Write 一律不进白名单),必要时再配disallowed_tools=["Read(.env)"]这类 deny 规则禁读凭证文件——deny 规则在包括 bypassPermissions 在内的所有权限模式下都生效。最小化原则:模型不需要的能力就不给它。
消费消息流
query() 返回一个异步生成器,流式产出三类消息。业务侧通常关心两种:
1 | async def run_task(box: SandboxBox, prompt: str) -> None: |
AssistantMessage.content是块列表:TextBlock(模型的话)与ToolUseBlock(工具调用请求:工具名 + 参数)。工具的实际执行发生在 SDK 内部(它调你的 handler),你在循环里看到 ToolUseBlock 只是”记录”这次调用。ResultMessage是整轮任务的终报:subtype(success/error_max_turns等)、最终文本result、成本total_cost_usd、轮次num_turns。
第 6 步:运行与验证
把入口串起来——注意 finally 里关闭沙箱,这是防泄漏的最后防线:
1 | async def main() -> None: |
预期输出形态(模型实际行为,措辞会略有差异):
1 | [Claude] 我先用循环求和计算... |
验收清单(一条不满足就回头查对应步骤):
- 出现
mcp__sandbox__run_code调用 → 第 3~5 步的挂载与批准链路通了 - 工具结果被模型正确引用(如复述 5050)→ 第 4 步的结果格式化有效
- 故意给一个会报错的任务,模型能读 traceback 后自动修正重试 → 第 4.3 节闭环生效
- 运行结束后沙箱列表为空 → 第 3 步的销毁逻辑生效
- 全程日志无明文凭证 → 第 2 步的凭证纪律生效
进阶要点(实测经验)
30 秒网关限制:超时护栏要三处同设
阿里云沙箱的单次 run_code HTTP 请求存在约 30 秒的网关硬限制——超时直接被切断(502 + SDK 抛 TimeoutException),timeout 参数调大也突破不了。护栏要在三处同时设:
- handler 默认
timeout=25(留 5 秒余量); - schema 里
"maximum": 25——让模型从源头就不传超限值; - 系统提示词写明”长任务拆分多次调用,python 变量会保留”——给模型一条可行的出路,而不只是禁令。
image 内容块:让模型”看见”沙箱里生成的图表
MCP 工具结果不只能是文本。返回 base64 图片块,模型即可直接视觉读取:
1 | import base64 |
沙箱环境通常没有显示设备,matplotlib 的 plt.show() 是死路;配合提示词约定”先 savefig('/tmp/x.png') 再 read_image”,就形成了「画图 → 看图 → 修图」的视觉闭环。
上下文保护:一切回传给模型的东西都要截断
工具结果会全部进入模型上下文。一次 print 出 50 万字符,上下文当场被冲爆。原则:文本结果一律截断(如 2 万字符)并附截断标记;图片限大小(如 5MB);traceback 只回传尾部若干行(头部是框架噪声,最后一行才是原因)。
安全边界回顾
- 模型凭证只进 CLI 子进程(
options.env),沙箱凭证只在你的 Python 进程——两者都不进沙箱环境变量、不进日志; - 沙箱里跑的是不可信代码:本地内建工具只开放只读白名单,deny 规则禁读
.env; - 沙箱空闲超时(如 900 秒)作为兜底,
finally主动销毁是主路径。
中转网关的适配细节
用 Anthropic 兼容中转网关(而非官方直连)时,三个实测结论:
- 网关支持的模型集合与官方不同,报
400 model ... is not supported就换模型名(部分网关不提供/v1/models列表,可用最小请求逐个探测); - 必须显式设置
CLAUDE_MODEL(见 2.3 节)覆盖~/.claude.json的残留默认模型; - CLI 后台的会话命名功能可能用网关不支持的小模型,产生
unrecognized_model警告——
不影响主流程,可忽略。
常见问题排查
| 现象 | 原因 | 处理 |
|---|---|---|
400 model ... is not supported |
网关不支持该模型名(常来自 ~/.claude.json 残留) |
显式设置 CLAUDE_MODEL(9.5 节) |
| 工具调用挂起不返回 | permission_mode 未设 bypass,或工具不在 allowed_tools |
补 allowed_tools=["mcp__sandbox__*"] + permission_mode="bypassPermissions" |
| 模型说”我没有执行代码的工具” | Server 未挂载或 strict_mcp_config 把它过滤了 |
检查 mcp_servers 键名与 Server 名一致 |
| 执行约 30 秒后 502 / TimeoutException | 撞上网关硬限制 | 9.1 节三处护栏 + 提示词引导拆分 |
| 上下文爆炸 / 响应变慢 | 工具结果未截断 | 9.3 节 |
| 沙箱越跑越多 / 计费不止 | kill() 未在 finally 中调用 |
第 8 步入口模式 |
| 沙箱创建 401/403 | E2B_API_KEY 无效或 E2B_API_URL/E2B_DOMAIN 地域与账号资源不一致 |
核对三要素与地域 |