Connect through MCP
Connect to MCP servers, call and import remote Tools, or expose Runifold Tools over stdio and Streamable HTTP.
Choose your direction
Runifold supports MCP in both directions. Decide which path you need before writing code:
| Goal | Core types | Start here |
|---|---|---|
| Call a remote MCP Tool directly | McpClient, CallToolParams | Connect over HTTP |
| Let an Agent select remote Tools | McpRemoteTool, RemoteToolPolicy | Import into an Agent |
| Supervise a local MCP process | StdioTransport | Connect over stdio |
| Expose Runifold Tools to MCP clients | McpServer, serve_stdio | Build a server |
| Host an MCP HTTP endpoint | McpHttpServer | Serve HTTP |
| Use Resources, Prompts, or Sampling | Registry and client APIs | Other capabilities |
| Coordinate a long-running MCP Task | Task client APIs | MCP Tasks |
MCP is the protocol and integration boundary. Runifold still owns run identity, budgets, deadlines, cancellation, capabilities, effects, and observability.
Install
Enable mcp for both client and server APIs:
cargo add runifold@=0.9.0 --features mcp
cargo add tokio --features macros,rt-multi-thread,process,net
cargo add serde_jsonTyped server Tools also need:
cargo add serde --features derive
cargo add schemarsThe authenticated Streamable HTTP example uses:
cargo add secrecy
cargo add axumKeep all Runifold workspace crates on
0.9.0. If a private registry mirror temporarily reports thatrunifold-mcp 0.9.0is missing, refresh or wait for that mirror to synchronize; do not mix workspace crate versions.
All MCP APIs live under runifold::mcp:
use runifold::mcp::{
CallToolParams, Implementation, McpClient, McpClientConfig,
StreamableHttpTransport,
};Connect to an HTTP server
This client uses bearer authentication, a 15-second timeout, and bounded automatic pagination:
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:?}");Prefer connect(). It discovers the server and selects the newest mutually
supported mode. A modern server uses stateless requests; a server that only
supports 2025-11-25 falls back to legacy initialization. Call initialize()
directly only when your application intentionally supports the legacy flow
alone.
Keep McpClient as long-lived infrastructure. Do not reconnect for every Agent
turn. Its clones share protocol state, cache, and transport.
Inspect the negotiated server information after connecting:
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 identity is self-reported. Use it for display and diagnostics, never as an authorization identity.
Discover and inspect Tools
list_tools() follows cursors automatically and honors the configured page
limit:
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);
}Do not hand the complete remote list to an Agent. At minimum, verify:
- the name is on a local allowlist;
- input and output schemas match the reviewed contract;
- descriptions or annotations have not changed unexpectedly;
- the current user and tenant may invoke the Tool;
- whether it reads, writes, pays, messages, or deletes;
- response size and pagination remain inside local limits.
Use list_tools_page() or list_tools_page_with_cache() when you need manual
cursor or cache control.
Call a Tool directly
MCP Tool arguments are a 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?;Handle all three result channels:
if result.is_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 is ordered model-visible content and may contain text, images, audio,
documents, or resource links. structured_content is an independent structured
result. is_error is a completed application-level failure. Transport,
protocol, deadline, and session failures return McpError instead.
call_tool() uses the client timeout. Streamable HTTP calls are not retried
implicitly, avoiding accidental duplicate side effects after an ambiguous
response.
Import a remote Tool into an Agent
McpRemoteTool adapts a remote MCP descriptor to Runifold's canonical Tool.
Effect and risk must come from the local host; remote annotations never grant
authority:
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()?;The imported Tool now follows the normal Runifold path: schema validation,
capability policy, budget, deadline, cancellation, and result-size limits still
apply. The Agent's RunContext shortens the remote timeout and cancellation is
propagated to the pending MCP request.
Import reviewed Tools one by one:
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 should use static host configuration or your policy
service, not remote metadata.
Connect over stdio
Use stdio for a local process supervised by your application:
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?;The server's stdout is reserved for newline-delimited JSON-RPC frames. Send
diagnostics to stderr. StdioTransport multiplexes concurrent requests;
shutdown() closes stdin and then terminates a child that does not exit within
the shutdown timeout. Avoid command-line secrets.
Build an MCP server
Start with an ordinary typed 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 })
}Register the Tool and grant the server authority explicitly:
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.");Registration and authority are separate gates:
ToolRegistry::registerdetermines which Tools the server knows.CapabilitySet::grantdetermines which Tools this MCP authority may list and call.
A registered but ungranted Tool is absent from tools/list and cannot be
invoked by guessing its name.
Serve over stdio
A minimal MCP server binary is:
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(())
}Create a separate McpSession for every stdio connection. Never share one
session across untrusted clients.
Serve over Streamable HTTP
McpHttpServer produces an Axum Router that can be merged into an existing
application:
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?;The same /mcp endpoint handles POST, GET, and DELETE. JSON responses are
the default; select HttpResponseMode::Sse when request responses need SSE.
For production:
- authenticate every method, not only
POST; - allow exact browser origins rather than a wildcard;
- bound request bodies, notification buffers, replay, and sessions;
- obtain rotating tokens from a secret store;
- terminate TLS at a trusted proxy or application boundary;
- treat
McpError::SessionExpiredas an explicit recovery decision; - never automatically retry a Tool call with possible side effects.
StaticBearerAuth fits small deployments. A multi-tenant service should
implement HttpAuthProvider and HttpAuthorizer, then map identity to a
tenant-scoped capability and audit context.
Other MCP capabilities
MCP extends beyond Tools:
list_resources()andread_resource()retrieve remote evidence that remains untrusted context;list_prompts()andget_prompt()retrieve templates, not trusted system policy;SamplingServicelets a server request client-owned model sampling behind local approval and budget policy;- Task APIs preserve handles, polling, cancellation, and notifications for long-running protocol work;
ResourceRegistry,PromptRegistry, andCompletionRegistryexpose those capabilities from a server.
See MCP context and Sampling for Resources, Prompts, Completion, Sampling, and caching. See MCP Tasks for long-running work. When you need checkpoints, leases, recovery, signals, and effect semantics, bridge the Task to a durable workflow.
Errors and troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
lifecycle error from list_tools | client is not connected | call client.connect().await? once |
Cargo cannot find runifold-mcp 0.9.0 | a private registry mirror has not synchronized the complete workspace release | refresh or wait for the mirror; keep all Runifold workspace crates on 0.9.0 |
| Tool is missing from the list | registered without a capability grant | grant descriptor.capability() |
-32601 method not found | wrong name, hidden capability, or unnegotiated feature | inspect the live Tool list and server capabilities |
-32602 invalid params | arguments are not an object or violate the schema | validate against tool.input_schema |
is_error = true | completed application-level Tool error | inspect safe content and decide whether the model may recover |
SessionExpired | session deletion or server restart | do not replay writes blindly; decide from the Effect Class |
DeadlineExceeded | client timeout or Run deadline | use a justified timeout or an MCP Task |
| stdio JSON parse failures | server logged to stdout | move all diagnostics to stderr |
| HTTP 401 or 403 | bad token or rejected Origin | inspect auth and the exact Origin allowlist |
| rich media cannot be forwarded | target Provider lacks that content form | use a compatible protocol or a safe text fallback |
Before shipping: allowlist Tools, assign Effect and Risk locally, bound time and pagination, authenticate HTTP, limit body and result sizes, shut down stdio children, and test cancellation, lost sessions, application errors, and rich results without leaking secrets into logs or journals.