Connecting MCP Servers
The Model Context Protocol (MCP) is an open
standard for exposing tools and data to LLM applications. There’s a growing
ecosystem of MCP servers — filesystem access, GitHub, Slack, databases, browser
automation, and more. Kova connects to them and turns their tools into native
Tools: once registered, the agent can’t tell an MCP tool from one you wrote.
The idea
Section titled “The idea”You connect an McpClient to a server, register it with the agent, and Kova
discovers the server’s tools at build time and wraps each one in an McpTool
adapter. At runtime the agent calls execute like any other tool; the adapter
forwards it as a JSON-RPC call to the server.
use std::sync::Arc;use kova_sdk::mcp::{McpClient, McpTransport};
let client = Arc::new(McpClient::connect(McpTransport::Stdio { command: "npx".into(), args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/srv/data".into()], env: Default::default(),}).await?);
let agent = AgentBuilder::new() .provider(provider) .mcp_client(client, "filesystem").await? // discovers + registers its tools .build()?;mcp_client takes a namespace string ("filesystem" here) and is async — it
performs discovery at build time. You can register several servers on one agent.
The three transports
Section titled “The three transports”Choose the transport that matches the server:
Stdio — spawn a subprocess
Section titled “Stdio — spawn a subprocess”The most common for local servers. Kova launches the process and speaks JSON-RPC over its stdin/stdout. You can inject extra environment variables (on top of the inherited parent environment):
use std::collections::HashMap;
let mut env = HashMap::new();env.insert("GITHUB_TOKEN".into(), std::env::var("GITHUB_TOKEN").unwrap());
let client = Arc::new(McpClient::connect(McpTransport::Stdio { command: "npx".into(), args: vec!["-y".into(), "@modelcontextprotocol/server-github".into()], env,}).await?);Stdio child processes are killed on drop, and their stderr is logged via
tracing rather than discarded — so a misbehaving server shows up in your logs.
Streamable HTTP — the modern remote transport (recommended)
Section titled “Streamable HTTP — the modern remote transport (recommended)”The MCP 2025 transport for remote servers. It performs the initialize /
notifications/initialized handshake, tracks the server’s Mcp-Session-Id and
echoes it on every request, and parses both plain-JSON and SSE responses:
let client = Arc::new(McpClient::connect(McpTransport::StreamableHttp { url: "https://mcp.example.com/mcp".into(), headers: Default::default(), auth: None, // or Some(Arc::new(my_token_provider)) — see below}).await?);HTTP+SSE — legacy static-header HTTP
Section titled “HTTP+SSE — legacy static-header HTTP”The older transport: static headers, no initialize handshake. Prefer
StreamableHttp for anything new; use this only for servers that require it:
let mut headers = HashMap::new();headers.insert("Authorization".into(), format!("Bearer {token}"));
let client = Arc::new(McpClient::connect(McpTransport::HttpSse { url: "http://localhost:8080".into(), headers,}).await?);Authenticated servers with OAuth (TokenProvider)
Section titled “Authenticated servers with OAuth (TokenProvider)”StreamableHttp supports a refreshable bearer token. Kova owns no OAuth
logic — your app drives the flow and stores the tokens; you just implement
TokenProvider so the transport can fetch and refresh them:
use kova_sdk::mcp::TokenProvider;use async_trait::async_trait;
struct MyTokenStore { /* wraps your OAuth token cache */ }
#[async_trait]impl TokenProvider for MyTokenStore { async fn token(&self) -> Result<String, KovaError> { // return the current bearer token } async fn refresh(&self) -> Result<String, KovaError> { // run your refresh-token flow, persist, and return the new token }}
let client = Arc::new(McpClient::connect(McpTransport::StreamableHttp { url: "https://mcp.example.com/mcp".into(), headers: Default::default(), auth: Some(Arc::new(MyTokenStore { /* … */ })),}).await?);The transport attaches Authorization: Bearer <token()> to every request and,
on a 401, calls refresh() once and retries — so an expired access token
recovers transparently without failing the turn.
Calling MCP tools directly
Section titled “Calling MCP tools directly”You don’t have to register with an agent — you can drive a client yourself, handy for inspection or non-agent code:
use serde_json::json;use std::time::Duration;
// List what the server offers (cached until the next reconnect)let tools = client.tools_list().await?;for t in &tools { println!("{}: {}", t.name, t.description); }
// Call one directlylet (content, is_error) = client .tools_call("read_file", json!({ "path": "/srv/data/notes.txt" })) .await?;
// Override the client's default timeout for a call you know runs long:let (content, is_error) = client .tools_call_with_timeout("reindex", json!({}), Duration::from_secs(120)) .await?;Resilience: reconnect on a dead transport
Section titled “Resilience: reconnect on a dead transport”A long-lived agent shouldn’t die because an MCP child process crashed or an HTTP
stream dropped. McpClient recovers automatically:
- Auto-reconnect. If a
tools_list()/tools_call()hits a dead transport (surfaced asKovaError::Connection), the client performs one reconnect-and-retry — respawning the stdio child, re-running theinitializehandshake — before giving up. A crashed server no longer bricks the session. - Manual reconnect.
client.reconnect()tears down and re-establishes the connection from the stored transport, and clears the tools/list cache. tools/listcaching. Repeated agent builds against the same client don’t re-query the server; the cache invalidates onreconnect().- Connect retry.
connect()retries transient failures once with a 500ms backoff.
Note the error split: transport-level I/O failures (dead child, broken HTTP
stream) are KovaError::Connection; server-reported JSON-RPC errors are
KovaError::Mcp. Only Connection triggers a reconnect — a JSON-RPC error means
the server is alive and said no, so retrying blindly wouldn’t help.
Timeouts
Section titled “Timeouts”Every MCP call is bounded. McpClient::connect_with_timeout sets a per-request
timeout (default 30s) covering the handshake, tools/list, and every
tools/call; tools_call_with_timeout overrides it for a single call — so a
hung server surfaces as KovaError::Timeout rather than blocking forever.
How it works under the hood
Section titled “How it works under the hood”McpClientserializes all JSON-RPC calls through an internal mutex, because the protocol requires ordered request/response pairs over one connection.- Discovered tools are registered as
McpTooladapters in the agent’sToolRegistry, indistinguishable from native tools at call time. - Approval handlers and lifecycle hooks apply to MCP tools exactly as they do to
native ones — so you can gate a remote
writetool the same way.
Key takeaways
Section titled “Key takeaways”- MCP servers expose tools; Kova registers them as native
Tools viamcp_client(client, "namespace"). - Three transports: Stdio (local subprocess), StreamableHttp (modern remote, recommended), HttpSse (legacy).
StreamableHttpsupports OAuth via aTokenProvideryou implement — Kova attaches the token and refreshes once on401.- A dead transport (
KovaError::Connection) auto-reconnects once; server JSON-RPC errors (KovaError::Mcp) don’t.tools/listis cached untilreconnect(). - Calls are timeout-bounded (
tools_call_with_timeoutper call); you can also drive a client directly withtools_list/tools_call.