Skip to content

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.

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.

Choose the transport that matches the server:

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.

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?);

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.

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 directly
let (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?;

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 as KovaError::Connection), the client performs one reconnect-and-retry — respawning the stdio child, re-running the initialize handshake — 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/list caching. Repeated agent builds against the same client don’t re-query the server; the cache invalidates on reconnect().
  • 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.

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.

  • McpClient serializes all JSON-RPC calls through an internal mutex, because the protocol requires ordered request/response pairs over one connection.
  • Discovered tools are registered as McpTool adapters in the agent’s ToolRegistry, 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 write tool the same way.
  • MCP servers expose tools; Kova registers them as native Tools via mcp_client(client, "namespace").
  • Three transports: Stdio (local subprocess), StreamableHttp (modern remote, recommended), HttpSse (legacy).
  • StreamableHttp supports OAuth via a TokenProvider you implement — Kova attaches the token and refreshes once on 401.
  • A dead transport (KovaError::Connection) auto-reconnects once; server JSON-RPC errors (KovaError::Mcp) don’t. tools/list is cached until reconnect().
  • Calls are timeout-bounded (tools_call_with_timeout per call); you can also drive a client directly with tools_list / tools_call.