Skip to main content

Basics

Let’s use the Neva MCP client to connect to your MCP servers (and others too).

Create an app​

Create a new binary-based app:

cargo new neva-mcp-client
cd neva-mcp-client

Add the following dependencies in your Cargo.toml:

[dependencies]
neva = { version = "...", features = "client-full" }
tokio = { version = "1", features = ["full"] }

Call a tool​

First, let’s call the tool we created in the server basics.

use neva::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Error> {
let mut client = Client::new()
.with_options(|opt| opt
.with_stdio(
"cargo",
["run", "--manifest-path", "./neva-mcp-server/Cargo.toml"]));

client.connect().await?;

let args = ("name", "John");
let result = client.tools().call("hello", args).await?;

println!("{:?}", result.content);

client.disconnect().await
}

Here we configure the MCP Client to connect to the server via stdio.
Once connected, you can call tools, get prompts, or read resources until you disconnect (or the client is dropped).

The server's primitives are grouped the way MCP names its methods, one namespace per prefix:

NamespaceMethods
client.tools()tools/list, tools/call
client.resources()resources/list, resources/templates/list, resources/read
client.prompts()prompts/list, prompts/get
client.tasks()tasks/get, tasks/update, tasks/cancel — see Tasks

Each namespace is a Copy view borrowed from the client, so any number of them can be held at once: let (tools, prompts) = (client.tools(), client.prompts());.

disconnect() is local

It shuts down the transport and sends nothing on the wire. There is no goodbye message in the protocol — the param-less notifications/cancelled neva used to send does not validate against the spec's own schema, and a server learns the client is gone from the closed connection anyway.

When connect fails​

connect() starts the transport, and a transport that will not start says so there — a stdio command that cannot be spawned, a rejected OAuth or TLS configuration, a bind that fails. It is not reported later as a request that timed out, and a client that was never given a transport is told at connect rather than at its first call.

A connect that failed on the way up leaves the client intact: the configured transport is still there, so the same client can try again, or be pointed somewhere else.

use neva::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Error> {
let mut client = Client::new()
.with_options(|opt| opt.with_stdio("weather-mcp", ["--stdio"]));

if let Err(err) = client.connect().await {
eprintln!("the installed server did not start: {err}");

// The same client, pointed at a locally built copy instead.
client = client.with_options(|opt| opt
.with_stdio("cargo", ["run", "-p", "weather-mcp"]));
client.connect().await?;
}

let tools = client.tools().list(None).await?;
println!("{} tools", tools.tools.len());

client.disconnect().await
}
What is retryable, and what is not

Anything the transport refuses as a whole — nothing has been consumed, so the next attempt is a real attempt. Once the transport is running, a later failure (a server that answers discovery with an error) is not undone by retrying: that needs a fresh Client, because a stdio server needs a fresh child process anyway.

Get a Prompt​

Next, let’s fetch a prompt to see how prompts work from the client side.

let args = ("lang", "Rust");
let prompt = client.prompts().get("hello_world_code", args).await?;

Read a Resource​

Then, let's read a resource that we declared here.

let resource = client.resources().read("res://resource-1").await?;

List Of Tools, Prompts, and Resources​

Finally, here’s how to explore all available tools, prompts, and resources dynamically.

// Every tool, every page of them
let tools = client.tools().list_all().await?;

// Every resource
let resources = client.resources().list_all().await?;

// The first page of resource templates
let templates = client.resources().templates(None).await?;

// Every prompt
let prompts = client.prompts().list_all().await?;

list_all() walks the listing page by page and returns the items together. A server still paging after 64 pages is an error rather than a partial list, since a listing cut short would look complete.

Pagination​

Large lists are returned in pages of 10 items by default.
To walk them yourself, list(cursor) asks for one page: None for the first, and the previous page's next_cursor for the one after it:

// First 10
let resources = client.resources().list(None).await?;

// Next 10
let resources = client.resources().list(resources.next_cursor).await?;

// Next 10
let resources = client.resources().list(resources.next_cursor).await?;

Listings are ordered deterministically by name on the server side, so paging can no longer skip or repeat an entry. See Listing Order.

Sharing a Client​

Every request method takes &self, so a connected client can be shared across tasks — put it in an Arc and clone the handle:

use std::sync::Arc;
use neva::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Error> {
let mut client = Client::new()
.with_options(|opt| opt.with_default_http());
client.connect().await?;

let client = Arc::new(client);
let calls = ["London", "Paris", "Tokyo"].map(|city| {
let client = client.clone();
tokio::spawn(async move {
client.tools().call("get_weather", ("city", city)).await
})
});

for call in calls {
println!("{:?}", call.await);
}
Ok(())
}

Setup keeps &mut self: connect, the map_* and on_* handlers and the roots are configured before the client is shared.

Timeouts and Cancellation​

A request the client stops waiting for is cancelled — when it outlives the timeout (with_timeout, 10 seconds unless set), and when its future is dropped, for example by tokio::time::timeout or the losing branch of a select!:

use std::time::Duration;

// Dropped after a second: the server is told to stop working on it.
let result = tokio::time::timeout(
Duration::from_secs(1),
client.tools().call("slow_report", ()),
).await;

How the server is told depends on the transport. Over Streamable HTTP under MCP 2026-07-28, the client closes the request's response stream — that is the cancellation there, and no notifications/cancelled is sent. Over stdio, and to a legacy peer, it sends notifications/cancelled. Either way the request's pending slot is released at once, and a late answer to it is dropped. The requests of an abandoned batch are cancelled too; the legacy initialize, which the spec does not allow to be cancelled, never is.

Caching​

Every list result — and server/discover and resources/read — carries ttlMs and cacheScope. Both are mandatory members under MCP 2026-07-28 rather than optional hints, so a client can always tell how long a result stays fresh and who may share it:

cacheScopeMeaning
privateCacheable only for this client (the default)
publicShareable across clients

Learn By Example​

Here you may find the full example