MCP Integration
What is MCP?
The Model Context Protocol (MCP) is a JSON-RPC 2.0 protocol that lets AI agents discover and call tools from external servers. It defines a standard way for agents to connect to tool providers over two transports:
- Stdio — spawn a child process, communicate via stdin/stdout (newline-delimited JSON)
- HTTP — POST JSON-RPC requests to an HTTP endpoint, including the request/response subset of Streamable HTTP
Connecting to MCP Servers
Stdio Transport
Needs the default
nativefeature: not available withdefault-features = falseor on wasm32.
Use with_mcp_server_stdio() to spawn an MCP server process and register its tools:
use yoagent::Agent; use yoagent::provider::ModelConfig; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are a helpful assistant with file access.") .with_mcp_server_stdio( "npx", &["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], None, ) .await?; let rx = agent.prompt("List files in /tmp").await; // handle events... Ok(()) }
You can pass environment variables to the server process:
#![allow(unused)] fn main() { use std::collections::HashMap; let mut env = HashMap::new(); env.insert("API_TOKEN".into(), "secret".into()); let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_mcp_server_stdio("my-mcp-server", &["--port", "0"], Some(env)) .await?; }
How the stdio transport behaves:
- Each request waits for the response carrying its own id. Messages the server
sends in between are handled: notifications (
notifications/message, progress,list_changed) are skipped, apingis answered, and any other server request (sampling, roots) is declined with "method not found" (logged atWARN) so the server does not wait. A response for another id (a call that timed out earlier) is dropped. Server messages are only read while a call is in progress, so apingsent between calls is answered with the next call. - A line on stdout that is not JSON (a banner, a debug print) is skipped and
logged at
WARN; if the server then closes, the error mentions it. notifications/initializedis sent as a notification, without an id, and not awaited. If it cannot be delivered the connect fails. The whole handshake is bounded at 60 seconds.- The server's stderr is read continuously, whatever its encoding, and logged
at
DEBUG(targetyoagent::mcp::stderr), so a server that logs heavily cannot block. The last few KB are kept: when the server exits, the error carries its exit status and that stderr, which is usually where it says why. - Dropping the client ends the server process.
HTTP Transport
For remote MCP servers exposed over HTTP. This works on every target,
including wasm32 / Cloudflare Workers (over the host's fetch):
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_mcp_server_http("http://localhost:8080/mcp") .await?; }
Servers that need headers
A server that wants a token, an API key or a User-Agent gets them from
HttpTransport::with_header, then connects with
Agent::with_mcp_server_http_transport (or McpClient::connect_http_with):
#![allow(unused)] fn main() { use yoagent::mcp::HttpTransport; let token = std::env::var("MCP_TOKEN")?; let transport = HttpTransport::new("https://mcp.example.com/mcp")? .with_header("authorization", format!("Bearer {token}"))? .with_header("user-agent", "my-app/1.0")?; let agent = agent.with_mcp_server_http_transport(transport).await?; }
- The headers go on every request of the session:
initialize, theinitializednotification, tool calls and the closingDELETE. - Setting a name again replaces its value. Values are marked sensitive, so they stay out of debug output, and a refused value is never echoed in the error.
- An invalid name, or a value that is not visible ASCII, is an error from
with_header, before anything is sent. So is a header the transport or the HTTP client sets itself:Accept,Content-Type,Mcp-Session-Id,Content-Length,Transfer-Encoding,Connection,Host. - On wasm32 the headers ride on the host's
fetch. Cloudflare Workers send them; a browser may drop some, such asUser-Agent. - The headers are fixed for the transport's lifetime. A token that expires during a session (OAuth refresh) needs a new transport and client.
HttpTransport handles both the plain JSON-RPC-over-POST shape and the
request/response subset of Streamable HTTP — servers that answer a POST with
an SSE-framed response, whether or not they then close the stream:
- Responses framed as
text/event-streamare parsed out of their SSE frames, joining each event'sdata:lines as the SSE spec requires. - A server may interleave
notifications/progressandnotifications/messageframes ahead of the result — that is how it reports progress during atools/call. Those are skipped: a frame is this request's response only if it carries nomethod, carries aresultor anerror, and its id matches. - Requests advertise
Accept: application/json, text/event-stream, letting the server pick its framing. - An
Mcp-Session-Idreturned by the server is captured and replayed on every later request, and released with aDELETEonMcpClient::close(). Servers that rejectDELETEare tolerated — teardown is best-effort. A404on a session-bearing request clears the session and reports that it expired, so a caller can rebuild the client. 202 Accepted(or204) with an empty body — how a notification is acknowledged — is a success, not a parse failure. Any other empty 2xx is reported as an error, since it usually means a proxy answered instead of the MCP server.- The body is parsed incrementally, so a call returns at the blank-line-terminated frame carrying its response rather than at end-of-stream. A server that holds the POST stream open after answering does not block it. Two trade-offs come with that: returning mid-body forgoes connection reuse on native targets (a fresh connection, and TLS handshake, on the next call to such a server), and a plain JSON-RPC body has no frames to return early at, so it is read to the end as before.
- A stalled server — one that accepts the POST then sends nothing — is bounded
by an idle read timeout (120s) rather than hanging. The timer resets on every
read, so a long
tools/callstreaming progress frames is never cut off. (Native targets only. On wasm32 the fetch-based client has no read timeout; the host's own request limits apply.)
Not supported: the GET server→client stream and Last-Event-ID
resumability. McpTransport is send/close only, with nowhere to deliver a
server-initiated message — supporting them would mean growing the trait an
inbound channel. Notifications arriving on the POST stream before the response
are read and skipped; any that trail it are not, since the call has already
returned — so a server that blocks awaiting a reply to a sampling/createMessage
it sent on this stream will time out rather than be answered. Note also that the
handshake still negotiates protocolVersion: 2024-11-05 (the revision predating
Streamable HTTP), which servers generally accept.
McpClient::close() is what sends the DELETE. Agent::with_mcp_server_http
does not call it, so sessions opened that way are released by the server's own
timeout rather than explicitly.
How MCP Tools Work
When you call with_mcp_server_stdio() or with_mcp_server_http(), yoagent:
- Connects to the MCP server and performs the
initializehandshake - Calls
tools/listto discover available tools - Wraps each MCP tool as an
AgentToolviaMcpToolAdapter - Adds them to the agent's tool list
MCP tools appear alongside built-in tools. The LLM sees them with their original names, descriptions, and JSON Schema parameters — it can call them just like any other tool.
Two servers with the same tool name. The agent's tools are not de-duplicated, and a provider rejects two tools with one name — so two servers that both offer search, or a server tool named like a built-in (bash, read_file), break the request. Give such a server's tools a prefix by wrapping them yourself (the with_mcp_server_* helpers add none):
#![allow(unused)] fn main() { use std::sync::Arc; use tokio::sync::Mutex; use yoagent::mcp::{McpClient, McpToolAdapter}; use yoagent::AgentTool; let docs = Arc::new(Mutex::new(McpClient::connect_stdio("docs-server", &[], None).await?)); let mut tools = yoagent::tools::default_tools(); for tool in McpToolAdapter::from_client_with_prefix(docs, "docs").await? { // The model sees `docs__search`; the server is still called with `search`. tools.push(Box::new(tool) as Box<dyn AgentTool>); } // `with_tools` replaces the agent's list: pass every tool in one call. let agent = agent.with_tools(tools); }
Each call ends when the run is cancelled. Over stdio it is also bounded at
five minutes (McpToolAdapter::DEFAULT_CALL_TIMEOUT), counted from when the
call is sent, not while it waits behind another call to the same server. Over
HTTP there is no whole-call bound — the transport's idle timeout already
ends a stalled call, and a long one that keeps streaming must not be cut off.
For a different bound, build the client yourself:
#![allow(unused)] fn main() { use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use yoagent::mcp::{McpClient, McpToolAdapter}; use yoagent::tools::default_tools; use yoagent::AgentTool; let client = McpClient::connect_stdio("my-server", &[], None) .await? .with_call_timeout(Some(Duration::from_secs(30 * 60))); let mcp_tools = McpToolAdapter::from_client(Arc::new(Mutex::new(client))).await?; // `with_tools` replaces the agent's tools, so pass everything it should have. let mut tools = default_tools(); tools.extend(mcp_tools.into_iter().map(|t| Box::new(t) as Box<dyn AgentTool>)); let agent = agent.with_tools(tools); }
A call that times out may still be carried out by the server; the error says
so. Content kinds this crate does not model reach the model as text: a text
resource as its own text, anything else (audio, a blob resource, …) as
its JSON with large base64 payloads replaced by their size.
Mixing Built-in and MCP Tools
#![allow(unused)] fn main() { use yoagent::tools::default_tools; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_tools(default_tools()) // bash, read, write, edit, list, search .with_mcp_server_stdio("my-db-server", &[], None) .await?; // Agent now has both built-in coding tools AND MCP database tools }
If a server's tools are named like the built-ins (bash, read_file, search, …), prefix them (see above).
Using the MCP Client Directly
For lower-level control, use McpClient directly:
#![allow(unused)] fn main() { use yoagent::mcp::{McpClient, McpToolAdapter}; use std::sync::Arc; use tokio::sync::Mutex; let client = McpClient::connect_stdio("my-server", &[], None).await?; let tools = client.list_tools().await?; for tool in &tools { println!("{}: {}", tool.name, tool.description.as_deref().unwrap_or("")); } // Call a tool directly let result = client.call_tool("read_file", serde_json::json!({"path": "/tmp/test.txt"})).await?; // Or wrap as AgentTool adapters let client = Arc::new(Mutex::new(client)); let adapters = McpToolAdapter::from_client(client).await?; }
Error Handling
MCP operations return McpError:
McpError::Transport— connection or I/O failureMcpError::Protocol— unexpected response formatMcpError::JsonRpc— server returned a JSON-RPC errorMcpError::Serialization— a message could not be (de)serialized as JSONMcpError::Io— an I/O error, e.g. on the stdio pipesMcpError::ConnectionClosed— the connection ended (the stdio transport now reports a server that exited asMcpError::Transport("Connection closed: MCP server '…' exited with …; last stderr: …"), carrying the reason)
When an MCP tool returns isError: true, the adapter converts it to a ToolError::Failed, which the agent loop sends back to the LLM with is_error: true so it can self-correct.