1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
//! High-level MCP client.
use super::transport::{ExitWatch, HttpTransport, McpTransport, ServerNotifications};
#[cfg(feature = "native")]
use super::transport::{StdioOptions, StdioTransport};
use super::types::*;
#[cfg(feature = "native")]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
/// How long `connect_*` waits for the `initialize` handshake.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// High-level MCP client that manages connection lifecycle and protocol.
pub struct McpClient {
transport: Arc<Mutex<Box<dyn McpTransport>>>,
server_info: Option<ServerInfo>,
capabilities: Option<ServerCapabilities>,
call_timeout: Option<Duration>,
/// The server notifications the transport has read.
notifications: ServerNotifications,
/// Whether the transport reports them at all.
reports_notifications: bool,
/// Tells that the server has gone, without the transport's lock.
exit: Option<ExitWatch>,
}
impl McpClient {
/// The one place a client is built: hands the transport its
/// [`ServerNotifications`] handle.
fn build(mut transport: Box<dyn McpTransport>, call_timeout: Option<Duration>) -> Self {
let notifications = ServerNotifications::new();
let reports_notifications = transport.report_notifications(notifications.clone());
let exit = transport.exit_watch();
Self {
transport: Arc::new(Mutex::new(transport)),
server_info: None,
capabilities: None,
call_timeout,
notifications,
reports_notifications,
exit,
}
}
/// Connect to an MCP server via stdio (spawn a child process). Native hosts only.
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
pub async fn connect_stdio(
command: &str,
args: &[&str],
env: Option<HashMap<String, String>>,
) -> Result<Self, McpError> {
Self::connect_stdio_with(command, args, env, StdioOptions::default()).await
}
/// [`connect_stdio`](Self::connect_stdio), started as `options` says: a
/// process group of its own ([`StdioOptions::own_process_group`]), say.
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
pub async fn connect_stdio_with(
command: &str,
args: &[&str],
env: Option<HashMap<String, String>>,
options: StdioOptions,
) -> Result<Self, McpError> {
let transport = StdioTransport::with_options(command, args, env, options).await?;
// Nothing else bounds a stdio call: a server that stops answering
// would hang the agent.
let mut client = Self::build(
Box::new(transport),
Some(crate::mcp::McpToolAdapter::DEFAULT_CALL_TIMEOUT),
);
client.handshake().await?;
Ok(client)
}
/// Connect to an MCP server via HTTP.
pub async fn connect_http(url: &str) -> Result<Self, McpError> {
Self::connect_http_with(HttpTransport::new(url)?).await
}
/// Connect over a configured [`HttpTransport`] — with request headers
/// ([`HttpTransport::with_header`]), say. The handshake and timeouts are
/// those of [`connect_http`](Self::connect_http).
pub async fn connect_http_with(transport: HttpTransport) -> Result<Self, McpError> {
// The HTTP transport's idle read timeout already ends a stalled call;
// a long one that keeps streaming must not be cut off.
let mut client = Self::build(Box::new(transport), None);
client.handshake().await?;
Ok(client)
}
/// Create from an existing transport (useful for testing).
pub fn from_transport(transport: Box<dyn McpTransport>) -> Self {
Self::build(transport, None)
}
/// The bound tool adapters built from this client apply to each call
/// ([`McpToolAdapter::from_client`](crate::mcp::McpToolAdapter::from_client)):
/// five minutes over stdio, none over HTTP (its idle timeout already ends
/// a stalled call) or a custom transport.
pub fn call_timeout(&self) -> Option<Duration> {
self.call_timeout
}
/// Change the bound adapters built from this client apply to each call
/// (`None`: no bound; the run's cancellation still ends a call).
pub fn with_call_timeout(mut self, timeout: Option<Duration>) -> Self {
self.call_timeout = timeout;
self
}
/// `initialize`, bounded: a server that never answers fails the connect
/// instead of hanging it.
async fn handshake(&mut self) -> Result<ServerInfo, McpError> {
crate::rt::timeout(HANDSHAKE_TIMEOUT, self.initialize())
.await
.unwrap_or_else(|_| {
Err(McpError::Transport(format!(
"MCP server did not complete the initialize handshake within {}s",
HANDSHAKE_TIMEOUT.as_secs()
)))
})
}
/// Initialize the MCP connection (handshake).
pub async fn initialize(&mut self) -> Result<ServerInfo, McpError> {
let params = serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": ClientInfo::default()
});
let request = JsonRpcRequest::new("initialize", Some(params));
let response = self.send_request(request).await?;
let result: InitializeResult = serde_json::from_value(response)?;
self.server_info = Some(result.server_info.clone());
self.capabilities = Some(result.capabilities);
// The handshake ends with a notification, which gets no response.
// (It used to be sent as a request and awaited: a spec-compliant
// server never answers it, so connecting over stdio could hang.)
// A server that never got it has not finished the handshake, so a
// failure here fails the connect (over stdio it means the server is
// gone) instead of surfacing later as a puzzling tools/list error.
let initialized = JsonRpcNotification::new("notifications/initialized", None);
self.transport.lock().await.notify(initialized).await?;
Ok(result.server_info)
}
/// List available tools from the server.
pub async fn list_tools(&self) -> Result<Vec<McpToolInfo>, McpError> {
let request = JsonRpcRequest::new("tools/list", Some(serde_json::json!({})));
let response = self.send_request(request).await?;
let result: ToolsListResult = serde_json::from_value(response)?;
Ok(result.tools)
}
/// Call a tool on the server.
pub async fn call_tool(
&self,
name: &str,
arguments: serde_json::Value,
) -> Result<McpToolCallResult, McpError> {
let params = serde_json::json!({
"name": name,
"arguments": arguments
});
let request = JsonRpcRequest::new("tools/call", Some(params));
let response = self.send_request(request).await?;
let result: McpToolCallResult = serde_json::from_value(response)?;
Ok(result)
}
/// Close the connection.
pub async fn close(&self) -> Result<(), McpError> {
self.transport.lock().await.close().await
}
/// How many times the server has announced that its tool list changed
/// (`notifications/tools/list_changed`), as read so far. The transport
/// reads the server's notifications while it waits for a call's answer:
/// over stdio, one sent while no call is open is counted at the next call;
/// over HTTP, only one on a call's SSE response ahead of its answer is
/// counted (no standalone `GET` stream); a custom transport counts only
/// if it implements [`McpTransport::report_notifications`]. Also the
/// generation of a [`McpToolSource`](crate::mcp::McpToolSource) on this
/// client.
pub fn tools_list_changes(&self) -> u64 {
self.notifications.tools_list_changed()
}
/// Whether the transport reports the server's notifications — `false`
/// for a custom transport that does not implement
/// [`McpTransport::report_notifications`], whose tool-list changes are
/// then never seen.
pub fn reports_notifications(&self) -> bool {
self.reports_notifications
}
/// Whether the server has gone, without waiting: `None` while it runs —
/// and always over HTTP or a custom transport without an
/// [`exit_watch`](McpTransport::exit_watch) — else why, from the first
/// poll that sees it. Over stdio the text settles within 0.5 s of being
/// first seen (it gains the last of the server's stderr), then stays the
/// same:
/// - `… exited with <status>` (a code, or the signal that killed it),
/// then the last of its stderr;
/// - `… closed its output (its process is still running)`: a call found
/// its output closed, and the process still running half a second
/// later (a wrapper outliving the server);
/// - `… no longer reads its input (its process is still running)`: a
/// write to its input failed (it closed its stdin);
/// - `… has gone (exit status unknown: …)`: the process cannot be waited
/// on (`SIGCHLD` ignored, or reaped elsewhere);
/// - `… was closed (`close`)` after [`close`](Self::close): not a crash,
/// so a host that restarts crashed servers can tell;
/// - `… was closed: its client was dropped`, for a watch kept past its
/// client.
///
/// It never waits behind a call in flight, so a host can poll it — to
/// tell its user a server stopped between calls, or to restart it.
pub fn exited(&self) -> Option<String> {
self.exit.as_ref()?.exited()
}
pub(crate) fn exit_watch(&self) -> Option<ExitWatch> {
self.exit.clone()
}
pub(crate) fn notifications(&self) -> ServerNotifications {
self.notifications.clone()
}
/// Get server info (available after initialize).
pub fn server_info(&self) -> Option<&ServerInfo> {
self.server_info.as_ref()
}
/// Send a request and extract the result, handling errors.
async fn send_request(&self, request: JsonRpcRequest) -> Result<serde_json::Value, McpError> {
let transport = self.transport.lock().await;
let response = transport.send(request).await?;
if let Some(error) = response.error {
return Err(McpError::JsonRpc {
code: error.code,
message: error.message,
});
}
response
.result
.ok_or_else(|| McpError::Protocol("Response has neither result nor error".into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
// Integration test would require a running MCP server.
// Unit tests for the client logic are covered via mock transport in tool_adapter tests.
#[test]
fn test_client_info_default() {
let info = ClientInfo::default();
assert_eq!(info.name, "yoagent");
assert!(!info.version.is_empty());
}
}