通过 MCP 连接外部能力
连接 MCP Server、调用并导入远程 Tool,或通过 stdio 与 Streamable HTTP 暴露 Runifold Tool。
先确认你的方向
Runifold 的 MCP 支持是双向的。开始写代码前,先确认自己需要哪一条路径:
| 目标 | 你使用的核心类型 | 从哪里开始 |
|---|---|---|
| 直接调用远程 MCP Tool | McpClient、CallToolParams | 连接 HTTP Server |
| 让 Agent 自动选择远程 Tool | McpRemoteTool、RemoteToolPolicy | 导入 Agent |
| 启动本地 MCP 子进程 | StdioTransport | 连接 stdio |
| 把 Runifold Tool 暴露给其他 MCP Client | McpServer、serve_stdio | 构建 Server |
| 通过 HTTP 提供 MCP Endpoint | McpHttpServer | 暴露 HTTP |
| 使用 Resources、Prompts、Sampling | 对应 Registry 与 Client API | 其他 MCP 能力 |
| 使用长时间运行的 MCP Task | CallToolOutcome、Task Client API | MCP Tasks |
无论选择哪一条路径,MCP 只是协议和集成边界。Runifold 仍然负责 Run 身份、预算、 Deadline、取消、Capability、Effect 与可观测性。
安装与 Feature
Client 与 Server 都通过 mcp Feature 提供:
cargo add runifold@=0.9.0 --features mcp
cargo add tokio --features macros,rt-multi-thread,process,net
cargo add serde_json如果要定义类型化 Tool,再加入:
cargo add serde --features derive
cargo add schemarsStreamable HTTP Bearer Token 示例还需要:
cargo add secrecy
cargo add axum所有 Runifold Workspace Crate 都应保持
0.9.0。如果私有 Registry Mirror 暂时报runifold-mcp 0.9.0不存在,请刷新或等待 Mirror 同步,不要混用不同版本的 Workspace Crate。
所有 MCP 类型都位于 runifold::mcp:
use runifold::mcp::{
CallToolParams, Implementation, McpClient, McpClientConfig,
StreamableHttpTransport,
};五分钟连接远程 MCP Server
下面是一个带 Bearer Token、15 秒超时和分页上限的 Streamable HTTP Client:
use std::{sync::Arc, time::Duration};
use runifold::mcp::{
Implementation, McpClient, McpClientConfig, StaticBearerAuth,
StreamableHttpTransport,
};
use secrecy::SecretString;
let token = SecretString::from(std::env::var("MCP_TOKEN")?);
let auth = Arc::new(StaticBearerAuth::new(token));
let transport = Arc::new(
StreamableHttpTransport::new("https://mcp.example.com/mcp")?
.with_auth(auth),
);
let client = McpClient::new(
transport,
McpClientConfig::new(Implementation::new("my-app", "0.1.0"))
.with_request_timeout(Duration::from_secs(15))
.with_max_pagination_pages(16),
);
let mode = client.connect().await?;
println!("MCP protocol mode: {mode:?}");优先调用 connect():它先执行无状态 Discovery,然后选择双方支持的最新模式。现代
Server 使用无状态协议;只支持 2025-11-25 的 Server 会自动走 Legacy Initialize。
只有在你明确只支持旧协议时,才直接调用 initialize()。
McpClient 应该作为长生命周期基础设施对象复用。不要在每次 Agent Turn 前重新连接。
Client 可以安全 Clone;Clone 共享同一个协议状态、缓存与 Transport。
连接后可以检查 Server 真正协商出的信息:
let server = client
.server_info()
.await
.ok_or("MCP client is not active")?;
println!("server: {} {}", server.server_info.name, server.server_info.version);
println!("protocol: {}", server.protocol_version);
println!("tools: {}", server.capabilities.tools.is_some());Server 名称与描述属于远端自我声明信息,适合显示与诊断,不能用于授权。
发现并检查 Tool
list_tools() 会自动跟随 Cursor,并受到 with_max_pagination_pages 限制:
let tools = client.list_tools().await?;
for tool in &tools {
println!("name: {}", tool.name);
println!("description: {}", tool.description.as_deref().unwrap_or(""));
println!("input schema: {}", tool.input_schema);
println!("output schema: {:?}", tool.output_schema);
}不要把远端 Tool List 直接全部交给 Agent。生产应用至少要检查:
- Tool Name 是否在本地 Allowlist 中;
- Input/Output JSON Schema 是否符合预期;
- 描述与 Annotation 是否发生了未经审核的变化;
- 当前用户或租户是否确实有权使用该 Tool;
- Tool 是否读取、写入、付款、发消息或删除数据;
- 远端返回体与分页数量是否在本地上限内。
需要自己控制 Cursor 或缓存行为时,使用 list_tools_page() 或
list_tools_page_with_cache()。list_tools() 更适合大多数应用初始化流程。
直接调用 MCP Tool
MCP Tool 参数必须是 JSON Object:
use runifold::mcp::CallToolParams;
use serde_json::json;
let arguments = serde_json::Map::from_iter([
("city".to_owned(), json!("Shanghai")),
]);
let result = client
.call_tool(CallToolParams {
name: "current_weather".to_owned(),
arguments: Some(arguments),
})
.await?;正确处理 CallToolResult 的三个通道:
if result.is_error {
// Tool 已经成功执行到应用边界,但业务请求失败;不要当作 Transport Error。
return Err("remote Tool returned an application error".into());
}
if let Some(value) = &result.structured_content {
println!("structured: {value}");
}
for block in &result.content {
if let Some(text) = block.as_text() {
println!("text: {text}");
} else {
println!("rich block type: {}", block.kind);
}
}content 是有序的模型可见内容,可能包含文本、图片、音频、文档或 Resource Link;
structured_content 是独立的结构化结果;is_error 是应用级失败。Transport、协议、
Deadline 或 Session 失败则由 McpError 返回,不会伪装成 is_error。
call_tool() 使用 Client 配置的超时。它不会自动重试 HTTP Tool Call;对于可能产生副作用
的调用,这可以避免不明确响应导致的重复执行。
把远程 Tool 导入 Agent
McpRemoteTool 把远端 MCP Descriptor 适配成 Runifold 的标准 Tool。关键点是:Effect
与 Risk 必须由本地主机决定,不能信任远端 Annotation 自动授权。
use std::sync::Arc;
use runifold::{AgentBuilder, core::{EffectClass, RiskLevel}};
use runifold::mcp::{McpRemoteTool, RemoteToolPolicy};
let remote = client
.list_tools()
.await?
.into_iter()
.find(|tool| tool.name == "current_weather")
.ok_or("MCP server did not advertise current_weather")?;
let weather = Arc::new(McpRemoteTool::new(
client.clone(),
remote,
RemoteToolPolicy::new(EffectClass::ReadOnly, RiskLevel::Low),
)?);
let agent = agent_builder
.shared_tool(weather)
.max_turns(6)
.build()?;适配完成后,远端 Tool 会走普通 Runifold Tool 路径:输入 Schema 校验、Capability Policy、
Budget、Deadline、取消与 Tool Result 上限都仍然生效。Agent 的 RunContext Deadline 会
缩短远端请求超时;取消 Run 也会取消正在等待的 MCP 调用。
推荐逐个导入:
for descriptor in client.list_tools().await? {
let Some(policy) = approved_policy_for(&descriptor.name) else {
continue;
};
let remote = McpRemoteTool::new(client.clone(), descriptor, policy)?;
agent_builder = agent_builder.shared_tool(Arc::new(remote));
}这里的 approved_policy_for 应来自你的静态配置或策略系统,而不是远端返回值。
连接本地 stdio MCP Server
stdio 适合由当前应用监督的本地子进程,例如文件搜索器或开发工具:
use std::sync::Arc;
use runifold::mcp::{
Implementation, McpClient, McpClientConfig, StdioTransport,
};
use tokio::process::Command;
let command = Command::new("weather-mcp-server");
let transport = Arc::new(StdioTransport::spawn(command)?);
let client = McpClient::new(
transport.clone(),
McpClientConfig::new(Implementation::new("local-app", "0.1.0")),
);
client.connect().await?;
let tools = client.list_tools().await?;
println!("{} tools discovered", tools.len());
drop(client);
transport.shutdown().await?;Server 的 stdout 专用于逐行 JSON-RPC Frame。诊断日志必须写入 stderr,否则会破坏
协议。StdioTransport 支持并发请求;shutdown() 会先关闭 stdin,再在超时后终止没有
退出的子进程。不要把 Secret 放进命令行参数,优先使用受控环境或 IPC。
把 Runifold Tool 构建成 MCP Server
先定义一个普通 Runifold Tool:
use runifold::{JsonSchema, ToolContext, ToolError, tool};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, JsonSchema)]
struct WeatherInput {
city: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct WeatherOutput {
city: String,
celsius: i32,
}
#[tool(description = "Read the current temperature for one city")]
async fn current_weather(
input: WeatherInput,
_context: ToolContext,
) -> Result<WeatherOutput, ToolError> {
Ok(WeatherOutput { city: input.city, celsius: 21 })
}然后注册 Tool,并显式授予 MCP Server 的 Authority:
use std::sync::Arc;
use runifold::{
Budget, BudgetTracker, CapabilitySet, RunContext, Tool, ToolRegistry,
};
use runifold::mcp::{Implementation, McpServer};
let weather = Arc::new(current_weather_tool());
let mut tools = ToolRegistry::new();
tools.register(weather.clone())?;
let mut capabilities = CapabilitySet::new();
capabilities.grant(weather.descriptor().capability());
let authority = RunContext::root(
BudgetTracker::new(Budget::default()),
capabilities,
);
let server = McpServer::new(
Arc::new(tools),
authority,
Implementation::new("weather-server", "0.1.0"),
)
.with_instructions("Use current_weather only for weather questions.");注册和授权是两个不同的门:
ToolRegistry::register决定 Server 知道哪些 Tool;CapabilitySet::grant决定这个 MCP Authority 可以列出和调用哪些 Tool。
只注册、不授权的 Tool 不会出现在 tools/list 中,也不能通过名称绕过调用。这是
Runifold MCP Server 最重要的安全边界。
通过 stdio 暴露 Server
一个最小 MCP Server Binary 的入口只有几行:
use runifold::mcp::serve_stdio;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let server = build_mcp_server()?;
serve_stdio(server.session()).await?;
Ok(())
}每条 stdio 连接使用独立的 McpSession。不要在多个不可信 Client 之间复用同一个
Session;为每个连接创建新的 server.session()。
通过 Streamable HTTP 暴露 Server
McpHttpServer 返回一个可以合并到现有 Axum 应用的 Router:
use std::sync::Arc;
use runifold::mcp::{
McpHttpServer, McpHttpServerConfig, StaticBearerAuth,
};
use secrecy::SecretString;
use tokio::net::TcpListener;
let token = SecretString::from(std::env::var("MCP_TOKEN")?);
let auth = Arc::new(StaticBearerAuth::new(token));
let config = McpHttpServerConfig::new()
.with_authorizer(auth)
.with_allowed_origin("https://app.example.com")
.with_max_body_bytes(1024 * 1024);
let router = McpHttpServer::new(build_mcp_server()?, config)
.router("/mcp");
let listener = TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, router).await?;同一个 /mcp Endpoint 会处理 POST、GET 与 DELETE。默认响应使用 JSON;需要
SSE Response 时可配置 HttpResponseMode::Sse。
生产环境至少要做到:
- 所有方法都认证,而不只是
POST; - 浏览器请求只允许精确 Origin,不使用通配符;
- 限制 Request Body、通知缓冲区、Replay 数量与 Session 数;
- Token 从 Secret Store 读取,并支持轮换;
- TLS 在可信反向代理或应用边界终止;
- Session 丢失返回
McpError::SessionExpired后,由应用明确决定是否重连; - 不自动重试可能产生副作用的 Tool Call。
StaticBearerAuth 适合最小示例与单 Token 部署。多租户生产系统应实现自己的
HttpAuthProvider 和 HttpAuthorizer,把身份映射到租户级 Capability 与审计上下文。
Resources、Prompts、Sampling 与 Tasks
MCP 不只有 Tool:
client.list_resources()/read_resource():读取远端证据;内容仍是不可信上下文;client.list_prompts()/get_prompt():取得模板;模板不会自动成为 System Policy;SamplingService:允许 Server 请求 Client 侧模型 Sampling,但必须经过本地审批与预算;McpTaskAPI:保留协议级长任务句柄、轮询、取消与通知;ResourceRegistry、PromptRegistry、CompletionRegistry:在 Server 端暴露对应能力。
完整的 Resource、Prompt、Completion、Sampling 与缓存策略见 MCP 上下文与 Sampling;长任务见 MCP Tasks。需要 Checkpoint、Lease、Signal、恢复与 Effect 语义时,应把 MCP Task 接到 Runifold 持久工作流,而不是只依赖远端轮询。
错误与排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
list_tools 报 Lifecycle Error | 尚未连接 | 先调用一次 client.connect().await? |
Cargo 找不到 runifold-mcp 0.9.0 | 私有 Registry Mirror 尚未同步完整 Workspace Release | 刷新或等待 Mirror;所有 Runifold Workspace Crate 保持 0.9.0 |
| Tool 不在列表中 | Server 注册了但没有 Capability Grant | 检查 capabilities.grant(descriptor.capability()) |
-32601 / Method Not Found | 名称错误、能力未暴露或协议能力未协商 | 重新检查 Tool List 与 Server Capability |
-32602 / Invalid Params | 参数不是 Object 或不符合 Input Schema | 对照 tool.input_schema 生成参数 |
is_error = true | Tool 的应用级错误 | 读取安全的 Content,按业务决定是否让模型修正 |
SessionExpired | HTTP Session 被删除或服务端重启 | 不要重放写操作;按 Effect Class 决定重连或人工确认 |
DeadlineExceeded | Client Timeout 或 Run Deadline 到期 | 增大合理上限,或把长任务改为 MCP Task |
| stdio 出现 JSON Parse Error | Server 把日志写到了 stdout | 把所有日志移到 stderr |
| HTTP 401 / 403 | Token 无效或 Origin 未加入 Allowlist | 检查认证提供器与精确 Origin |
| 媒体结果无法转发 | 当前 Provider 不支持该 Content Block | 使用支持的 Provider 协议,或让 Tool 提供安全文本回退 |
上线前最后检查:只导入 Allowlist Tool;本地指定 Effect/Risk;设置超时和分页上限; 限制 Body 与结果大小;认证远程 HTTP;关闭 stdio 子进程;测试 Session 丢失、取消、 应用错误与富媒体结果;确认日志和 Journal 不包含 Secret 或完整敏感 Tool Payload。