pub struct Client { /* private fields */ }Expand description
A client bound to a negotiated session.
§Examples
use std::sync::Arc;
use protocol_droid::application::client::{Client, ClientConfig, TransportError};
use protocol_droid::domain::ir::JsonObject;
use protocol_droid::domain::{ProtocolVersion, TransportKind};
/// A transport that always refuses, which is enough to show the gate.
struct Dead;
let version = ProtocolVersion::V2024_11_05;
let config = ClientConfig::new(version, TransportKind::Stdio);
let client = Client::connect(
config.negotiated_with(version, protocol_droid::domain::CapabilitySet::all_for(version)),
Arc::new(Dead),
);
// Tools exist in every revision, and both sides declared everything that
// revision defines, so the gate is open and the dead transport is what
// fails the call.
assert!(client.supports(protocol_droid::domain::Feature::Tools));
let outcome = tokio::runtime::Builder::new_current_thread()
.build()
.map_err(|e| e.to_string())?
.block_on(client.list_tools());
assert_eq!(
outcome.map_or_else(|e| e.to_string(), |_| "succeeded".to_string()),
"the request did not complete: no route",
);Implementations§
Source§impl Client
impl Client
Sourcepub fn connect(
negotiation: Negotiation,
transport: Arc<dyn ClientTransport>,
) -> Self
pub fn connect( negotiation: Negotiation, transport: Arc<dyn ClientTransport>, ) -> Self
Binds a transport to a session that has already negotiated.
Takes the whole Negotiation rather than just the version. The
profile is a function of four inputs, and a client knows only two of
them — its own capabilities and the transport. Re-deriving it here
would mean guessing the server’s half, and a guess that leaves the
server’s capabilities empty turns a feature nobody declared into a
feature nobody may use. The transport did the handshake and has the
real answer, so it passes the real answer.
Prefer from_session when a
NegotiatedSession is to hand: it is this constructor minus the copy,
and without the copy’s chance of being wrong. This one exists for a
caller assembling a Negotiation directly, where re-deriving is the
only way to get a profile at all.
Sourcepub fn from_session(
session: &NegotiatedSession,
transport: Arc<dyn ClientTransport>,
) -> Self
pub fn from_session( session: &NegotiatedSession, transport: Arc<dyn ClientTransport>, ) -> Self
Binds a transport to a session that has already negotiated.
The one to reach for. connect takes the four inputs
resolve needs and re-derives a profile from them — but a
NegotiatedSession has already resolved one, from those same four
fields, so a caller going through connect rebuilds a struct literal
and re-runs a pure function to arrive back where it started.
That is worse than redundant. Nothing obliges the four fields a caller
copies to be the four the session was built from, so a caller who
transposes one — or leaves client_caps empty because the transport
made it awkward — gets a client whose profile disagrees with the session
it just negotiated, and the disagreement surfaces much later as a
feature that was advertised and then refused. Reading the profile off
the session removes the copy, and with it the possibility of copying it
wrong.
§Examples
use std::sync::Arc;
use protocol_droid::application::client::{Client, ClientConfig, ClientTransport, handshake};
use protocol_droid::domain::{ProtocolVersion, TransportKind};
async fn connect(transport: Arc<dyn ClientTransport>) -> Result<(), String> {
let config = ClientConfig::speaking(
[ProtocolVersion::V2025_06_18].into_iter(),
TransportKind::Stdio,
)
.map_err(|e| e.to_string())?;
let session = handshake(transport.as_ref(), &config)
.await
.map_err(|e| e.to_string())?;
let client = Client::from_session(&session, transport);
assert!(client.supports(protocol_droid::domain::Feature::Tools));
Ok(())
}Sourcepub async fn connect_to(spec: &TransportSpec) -> Result<Self, ConnectError>
pub async fn connect_to(spec: &TransportSpec) -> Result<Self, ConnectError>
Connects to spec, negotiates, and returns a bound client.
The one-call form, for the common case where the caller has a URL and no opinion about revisions. The four steps it replaces — pick the adapter, choose what to offer, run the handshake, bind the result — are all decisions the crate can make from the spec, and a caller who has to make them is doing the crate’s work.
Offers every revision this build has wire types for. Narrow that with
connect_to_speaking when the server is
known to be older, or when a build is expected to run against something
that answers a narrow offer badly.
§Errors
ConnectError naming the stage that failed. A transport this build
has no adapter for is ConnectError::Unsupported rather than a
handshake error, so “this crate cannot speak that” is never reported as
“that server would not talk”.
§Examples
let spec = TransportSpec::new(
protocol_droid::domain::TransportKind::StreamableHttp,
"http://127.0.0.1:3000/mcp",
)?;
let client = Client::connect_to(&spec).await?;
let tools = client.list_tools().await?;Sourcepub async fn connect_to_speaking(
spec: &TransportSpec,
versions: &SupportedVersions,
) -> Result<Self, ConnectError>
pub async fn connect_to_speaking( spec: &TransportSpec, versions: &SupportedVersions, ) -> Result<Self, ConnectError>
Connects to spec, offering exactly versions.
connect_to for a caller who has an opinion about
what to offer. The narrowing is deliberate: offering a revision a server
does not speak gets the next one it does, so a set that excludes the
right revision is refused by servers that would otherwise have worked.
§Errors
As connect_to, plus
ConnectError::Config when versions is empty.
Sourcepub async fn connect_with(
spec: &TransportSpec,
versions: &SupportedVersions,
options: ConnectOptions,
) -> Result<Self, ConnectError>
pub async fn connect_with( spec: &TransportSpec, versions: &SupportedVersions, options: ConnectOptions, ) -> Result<Self, ConnectError>
Connects to spec, offering versions and applying options.
For a client that can answer the server: a server asks for sampling,
elicitation or roots only when the client declared them, so
connect_to, which declares nothing, can never be
asked. The handler in options answers what the server does ask.
§Errors
Sourcepub async fn connect_stdio(spec: &ChildSpec) -> Result<Self, StdioConnectError>
pub async fn connect_stdio(spec: &ChildSpec) -> Result<Self, StdioConnectError>
Connects to an MCP server over stdio by spawning it as a child.
spec is the program and (optionally) arguments; args is
appended after the program’s own arguments. The function spawns
the child, drains the child’s stderr into
Diagnostics::stderr(), runs the MCP initialize handshake,
and binds the resulting session to a Client.
This is the stdio counterpart of connect_to
for the HTTP transports. The four steps the consumer would
otherwise write by hand — spawn a StdioClient, build a
ClientConfig, run handshake, bind the session with
from_session — are one call here.
Each of those steps is a decision the consumer should not be
making for a use case the library already has an answer to:
which adapter speaks a given spec is a fact about the crate,
the set of revisions to offer is a matter of what this build
has wire types for, and the diagnostics channel is the obvious
one. A consumer who has an opinion on any of those uses the
four steps directly; both paths are public.
§Errors
Returns StdioConnectError on any of the four stages. The
child is killed on failure, so a refused handshake does not
leave a process behind.
§Example
use protocol_droid::adapters::secondary::stdio::ChildSpec;
use protocol_droid::application::client::Client;
let spec = ChildSpec::new("upstream-mcp-server")?;
let client = Client::connect_stdio(&spec).await?;
let tools = client.list_tools().await?;Sourcepub fn negotiated(&self) -> ProtocolVersion
pub fn negotiated(&self) -> ProtocolVersion
The revision this client and its server agreed on.
Sourcepub fn profile(&self) -> FeatureProfile
pub fn profile(&self) -> FeatureProfile
The profile that revision produced.
Sourcepub fn session(&self) -> &NegotiatedSession
pub fn session(&self) -> &NegotiatedSession
The negotiated session behind this client.
Everything the handshake agreed on, including the parts a caller usually wants for reporting rather than for gating: what the server called itself and which revision it answered with, what each side advertised, which transport this is, and whether the profile is recomputed per request with nothing retained.
The accessors above answer the two questions a caller asks before every
call. This answers the one asked once, usually by a log line or a
/healthz body — and it answers it from the session rather than from a
second copy of the parts a caller remembered to keep.
Not const: the session is behind an Arc, and reading through one is
not a constant expression. A caller who needs the version in a const
should negotiate in a function it can run, not reach for one here.
Sourcepub async fn send_unchecked(
&self,
method: &str,
params: &JsonObject,
) -> Result<JsonObject, ClientError>
pub async fn send_unchecked( &self, method: &str, params: &JsonObject, ) -> Result<JsonObject, ClientError>
Sends method exactly as given, skipping the capability gate.
The typed methods refuse what the server did not advertise, which is right for an application and wrong for a tool whose job is to see what a server actually does: an inspector, a prober, a conformance client. A server may serve a method it did not advertise, and the only way to learn that is to ask.
§Errors
ClientError::Transport as for any request.
Sourcepub async fn list_tools(&self) -> Result<JsonObject, ClientError>
pub async fn list_tools(&self) -> Result<JsonObject, ClientError>
Lists the server’s tools.
§Errors
ClientError::FeatureUnavailable when the session has no
Feature::Tools, having written nothing. A 2024-11-05 session always
has one, but a session whose peer advertises nothing still might not,
and a caller learns which from this rather than from an opaque
protocol error.
Sourcepub async fn call_tool(
&self,
name: &str,
arguments: &JsonObject,
) -> Result<JsonObject, ClientError>
pub async fn call_tool( &self, name: &str, arguments: &JsonObject, ) -> Result<JsonObject, ClientError>
Sourcepub async fn call_tool_resolving(
&self,
name: &str,
arguments: &JsonObject,
resolve: &(impl InputResolver + ?Sized),
) -> Result<JsonObject, ClientError>
pub async fn call_tool_resolving( &self, name: &str, arguments: &JsonObject, resolve: &(impl InputResolver + ?Sized), ) -> Result<JsonObject, ClientError>
Calls name, answering any input the server asks for along the way.
See call_resolving for the rules.
§Errors
As call_tool, plus
ClientError::InputUnresolved and ClientError::TooManyRounds.
Sourcepub async fn list_resources(&self) -> Result<JsonObject, ClientError>
pub async fn list_resources(&self) -> Result<JsonObject, ClientError>
Lists the server’s resources.
§Errors
As list_tools, with
Feature::Resources in place of
Feature::Tools.
Sourcepub async fn list_prompts(&self) -> Result<JsonObject, ClientError>
pub async fn list_prompts(&self) -> Result<JsonObject, ClientError>
Lists the server’s prompts.
§Errors
As list_tools, with
Feature::Prompts in place of
Feature::Tools.
Sourcepub async fn list_resource_templates(&self) -> Result<JsonObject, ClientError>
pub async fn list_resource_templates(&self) -> Result<JsonObject, ClientError>
Sourcepub async fn read_resource(&self, uri: &str) -> Result<JsonObject, ClientError>
pub async fn read_resource(&self, uri: &str) -> Result<JsonObject, ClientError>
Sourcepub async fn get_prompt(
&self,
name: &str,
arguments: &JsonObject,
) -> Result<JsonObject, ClientError>
pub async fn get_prompt( &self, name: &str, arguments: &JsonObject, ) -> Result<JsonObject, ClientError>
Trait Implementations§
Source§impl Debug for Client
impl Debug for Client
Source§fn fmt(&self, f: &mut Formatter<'_>) -> Result
fn fmt(&self, f: &mut Formatter<'_>) -> Result
Prints the negotiation, not the transport.
For the same reason PeerSession does: a transport is a socket and a
vtable pointer, and neither survives being printed at a call site.
The session is printed as its version and feature count rather than in
full because it carries both peers’ PeerInfo, which is caller-supplied
text and does not belong in a log line nobody asked for.