pub struct StdioClient<DW> { /* private fields */ }Expand description
A running conversation with a child MCP server.
Generic over the diagnostics channel for the same reason the server’s
ServeLoop is: “somewhere
that is not the protocol stream” is a decision the caller makes, and a test
needs to be able to read it back.
See the module docs for what this is and is not.
Implementations§
Source§impl<DW> StdioClient<DW>
impl<DW> StdioClient<DW>
Sourcepub fn child_id(&self) -> Option<u32>
pub fn child_id(&self) -> Option<u32>
The child’s process id, while it is alive.
For a log line. It is not proof of anything: a caller that needs to
know the process is gone wants
take_stderr_forwarder, because
end-of-file on the child’s own pipe is the kernel saying so and a
process id is not.
Unbounded deliberately — reading the process id touches the Child
and nothing else, so putting it behind the DW bounds would mean
Debug had to carry them too.
Source§impl<DW> StdioClient<DW>
impl<DW> StdioClient<DW>
Sourcepub async fn spawn(
spec: &ChildSpec,
config: ClientConfig,
diagnostics: Diagnostics<DW>,
) -> Result<Self, SpawnError>
pub async fn spawn( spec: &ChildSpec, config: ClientConfig, diagnostics: Diagnostics<DW>, ) -> Result<Self, SpawnError>
Starts a child server and opens a conversation with it.
The child’s stdout becomes a StdioSource, its stdin a
StdioSink, and its stderr is drained into diagnostics by a task
that lives as long as the child does. All three of those adapters take
the same StdioConfig, which is the point: a client that could be
given one frame ceiling for what it reads and another for what it
writes would be a client whose two halves disagree about what a frame
is.
Dropping the returned client kills the child. That is the
kill_on_drop(true) set in child::spawn, and it is the reason this
function is the only way to get a client.
§Errors
SpawnError::NotStarted when the program could not be started, and
SpawnError::NoPipe when it started without one of the three pipes
the conversation needs. In the second case the child is dropped on the
way out, so the failure does not leave a process behind.
§Examples
use protocol_droid::adapters::primary::stdio::{Diagnostics, StdioConfig};
use protocol_droid::adapters::secondary::stdio::{ChildSpec, ClientConfig, StdioClient};
let spec = ChildSpec::new("my-server")?;
let config = ClientConfig::new(StdioConfig::default());
let client = StdioClient::spawn(&spec, config, Diagnostics::stderr()).await?;
assert!(client.child_id().is_some());Sourcepub const fn config(&self) -> ClientConfig
pub const fn config(&self) -> ClientConfig
The settings this client was built with.
Sourcepub fn diagnostics(&self) -> &Diagnostics<DW>
pub fn diagnostics(&self) -> &Diagnostics<DW>
The child’s stderr channel.
The child does not write to this — child::forward_stderr does, one
line at a time — and neither does anything else this crate knows of.
It is here so a caller can add its own, the same way the server’s
diagnostics channel is.
Sourcepub const fn source(&self) -> &StdioSource<ChildStdout>
pub const fn source(&self) -> &StdioSource<ChildStdout>
The read half, for a caller driving the conversation itself.
Sourcepub const fn sink(&self) -> &StdioSink<ChildStdin>
pub const fn sink(&self) -> &StdioSink<ChildStdin>
The write half, for a caller driving the conversation itself.
Sourcepub const fn take_stderr_forwarder(&mut self) -> Option<JoinHandle<()>>
pub const fn take_stderr_forwarder(&mut self) -> Option<JoinHandle<()>>
Detaches the stderr forwarder and hands back its handle.
Awaiting the handle completes when the child’s stderr reaches end-of-file, which is the point: the kernel closes a process’s descriptors when the process is torn down, so this is a caller being told by the operating system that the child is gone — not a caller observing that a handle was dropped.
Taken rather than merely borrowed because the question it answers is asked after the client, and a borrow of a field would keep the client alive. Detaching the task does not stop it: it holds its own reference to the diagnostics channel and to the child’s stderr, and it ends when the pipe does.
Sourcepub async fn send_document(&self, document: &str) -> Result<(), StdioError>
pub async fn send_document(&self, document: &str) -> Result<(), StdioError>
Writes one already-encoded document to the child.
The framing — the delimiter, the ceiling, the flush — is
StdioSink’s, which is the same code the server writes its replies
with. A caller reaching for this is assembling a document itself; a
caller with a method and some parameters wants call.
§Errors
StdioError::ReplyTooLarge when the document does not fit the
configured ceiling, and StdioError::Transport when the child’s
stdin refused it — which, for a child that has exited, is the normal
way to find out.
Sourcepub async fn next_frame(&self) -> Result<Option<String>, SourceError>
pub async fn next_frame(&self) -> Result<Option<String>, SourceError>
Reads the child’s next frame.
Ok(None) is the child closing, and nothing else: it is
StdioSource’s answer passed through unaltered, because the decoder
underneath it answers Ok(None) for the opposite reason — a line with
no terminator yet — and forwarding that up would turn “read more” into
“the server is gone”. A caller that skips a None here will conclude
the child died on a request that was merely slow, and
call is where that mistake is most expensive.
§Errors
Sourcepub fn next_request_id(&self) -> RequestId
pub fn next_request_id(&self) -> RequestId
A fresh request id from this client’s counter.
A counter and not a random value, for the reason everything else in this crate that needs an identity takes one: the domain does not call a generator, so a client’s ids are as reproducible as its config, and two requests in flight on one client can never collide.
Sourcepub async fn call(
&self,
id: &RequestId,
method: &MethodName,
params: &JsonObject,
) -> Result<JsonObject, StdioClientError>
pub async fn call( &self, id: &RequestId, method: &MethodName, params: &JsonObject, ) -> Result<JsonObject, StdioClientError>
Sends a request and returns the peer’s answer.
The three steps — write the document, read the next frame, match its id
— are a request and a reply, and a caller that wants a different order
wants send_document and
next_frame instead.
§Errors
StdioClientError::Document when the request document could not be
assembled, StdioClientError::Stdio and
StdioClientError::Source when the pipe refused, and
StdioClientError::PeerClosed when the child closed its stdout
before answering. A child that answers with a failure is
StdioClientError::Request, because that is a result of the request
rather than a fault in the transport.
Sourcepub async fn notify(
&self,
method: &MethodName,
params: &JsonObject,
) -> Result<(), StdioClientError>
pub async fn notify( &self, method: &MethodName, params: &JsonObject, ) -> Result<(), StdioClientError>
Sends a notification, which never gets an answer.
§Errors
As send_document, plus
StdioClientError::Document if the document cannot be assembled.
Sourcepub async fn terminate(
&mut self,
) -> Result<Option<ExitStatus>, StdioClientError>
pub async fn terminate( &mut self, ) -> Result<Option<ExitStatus>, StdioClientError>
Kills the child and waits for it to be reaped.
The deliberate counterpart to dropping the client, for a caller that wants the exit status and for the reap to have happened before it moves on. A dropped client kills the child too, and the operating system reaps it; it just does not tell anyone how it ended.
Calling this twice is not a second kill: the second call reports
Ok(None), because there is nothing left to wait for.
§Errors
StdioClientError::Terminate when the child could not be waited for.
Trait Implementations§
Source§impl<DW> ClientTransport for StdioClient<DW>
impl<DW> ClientTransport for StdioClient<DW>
Source§fn send(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>>
fn send( &self, method: &str, params: &JsonObject, ) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>>
Sends method with params under a fresh id and returns the result.
The boxed future because the port asks for one: a client’s transport is
chosen at run time from a configuration, so there is no monomorphisation
to win here and holding a dyn is the honest shape.
The document is assembled before the future is built rather than inside
it. The port elides the return lifetime to &self, so a boxed future
cannot hold the caller’s method or params; assembling first is what
leaves the future owning only the id and the method name.
§Errors
TransportError::Unavailable naming whatever the client refused. The
port is protocol-neutral and must not learn that a stdio client has a
frame ceiling, so the detail is rendered rather than kept.
Source§fn notify(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>>
fn notify( &self, method: &str, params: &JsonObject, ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>>
Writes a notification and returns.
Write-only by construction: there is no id to match a reply against, so
there is nothing to wait for. The document is assembled before the
future is built, for the same reason send does it — the
port elides the return lifetime, so a boxed future cannot borrow the
caller’s method name.
§Errors
TransportError::Unavailable if the document cannot be assembled or
the pipe refuses the write.
Source§impl<DW> Debug for StdioClient<DW>
impl<DW> Debug for StdioClient<DW>
Source§fn fmt(&self, f: &mut Formatter<'_>) -> Result
fn fmt(&self, f: &mut Formatter<'_>) -> Result
Prints the process and the settings, and nothing about the streams.
The same reasoning as the server’s ServeLoop: a child’s buffered
bytes and a pipe’s internals say nothing about which session is
misbehaving, and requiring DW: Debug to print the one field that
would be a bound nobody wants.