objectiveai_sdk/mcp/client.rs
1//! MCP client for creating connections to MCP servers.
2
3use std::time::Duration;
4
5use indexmap::IndexMap;
6
7/// Client for creating MCP connections.
8///
9/// Holds shared configuration (HTTP client, headers, backoff parameters)
10/// and creates [`Connection`](super::Connection) instances via
11/// [`connect`](Client::connect).
12#[derive(Debug, Clone)]
13pub struct Client {
14 /// HTTP client for making requests.
15 pub http_client: reqwest::Client,
16 /// User-Agent header value.
17 pub user_agent: String,
18 /// X-Title header value.
19 pub x_title: String,
20 /// Referer header value.
21 pub http_referer: String,
22 /// Timeout for the initial connection (initialize request).
23 /// `None` = no timeout — the caller waits as long as the upstream
24 /// takes (e.g. the daemon, which never bounds its own MCP calls).
25 pub connect_timeout: Option<Duration>,
26
27 /// Current backoff interval for retry logic.
28 pub backoff_current_interval: Duration,
29 /// Initial backoff interval for retry logic.
30 pub backoff_initial_interval: Duration,
31 /// Randomization factor for backoff jitter.
32 pub backoff_randomization_factor: f64,
33 /// Multiplier for exponential backoff growth.
34 pub backoff_multiplier: f64,
35 /// Maximum backoff interval.
36 pub backoff_max_interval: Duration,
37 /// Maximum total time to spend on retries.
38 pub backoff_max_elapsed_time: Duration,
39 /// Timeout for individual RPC calls after connection is established.
40 /// `None` = no timeout (see [`Client::connect_timeout`]).
41 pub call_timeout: Option<Duration>,
42}
43
44/// Apply an optional per-request timeout to a builder: `None` leaves the
45/// request unbounded (reqwest applies no default request timeout).
46pub(crate) fn apply_timeout(
47 request: reqwest::RequestBuilder,
48 timeout: Option<Duration>,
49) -> reqwest::RequestBuilder {
50 match timeout {
51 Some(timeout) => request.timeout(timeout),
52 None => request,
53 }
54}
55
56impl Client {
57 /// Creates a new MCP client.
58 pub fn new(
59 http_client: reqwest::Client,
60 user_agent: String,
61 x_title: String,
62 http_referer: String,
63 connect_timeout: Option<Duration>,
64 backoff_current_interval: Duration,
65 backoff_initial_interval: Duration,
66 backoff_randomization_factor: f64,
67 backoff_multiplier: f64,
68 backoff_max_interval: Duration,
69 backoff_max_elapsed_time: Duration,
70 call_timeout: Option<Duration>,
71 ) -> Self {
72 Self {
73 http_client,
74 user_agent,
75 x_title,
76 http_referer,
77 connect_timeout,
78 backoff_current_interval,
79 backoff_initial_interval,
80 backoff_randomization_factor,
81 backoff_multiplier,
82 backoff_max_interval,
83 backoff_max_elapsed_time,
84 call_timeout,
85 }
86 }
87
88 /// Build the canonical header map that the client stamps on every
89 /// request opened under this client / connection. The supplied
90 /// caller map (if any) wins on conflict — defaults are inserted
91 /// only when the caller didn't already provide them. The merged
92 /// map is computed once at the top of `connect_once` and reused
93 /// across all three handshake requests + handed to the resulting
94 /// [`Connection`] for every later RPC.
95 fn headers(
96 &self,
97 supplied: Option<IndexMap<String, String>>,
98 ) -> IndexMap<String, String> {
99 let mut out = supplied.unwrap_or_default();
100 out.entry("User-Agent".to_string())
101 .or_insert_with(|| self.user_agent.clone());
102 out.entry("X-Title".to_string())
103 .or_insert_with(|| self.x_title.clone());
104 out.entry("Referer".to_string())
105 .or_insert_with(|| self.http_referer.clone());
106 out.entry("HTTP-Referer".to_string())
107 .or_insert_with(|| self.http_referer.clone());
108 out
109 }
110
111 /// Connects to an MCP server using the Streamable HTTP transport.
112 ///
113 /// Sends an `initialize` JSON-RPC request to the server and extracts
114 /// the `Mcp-Session-Id` from the response. Returns a [`Connection`]
115 /// that can be used to list/call tools and list/read resources.
116 ///
117 /// `headers` are forwarded on every request this connection makes
118 /// to the upstream — both the initial `initialize` POST and every
119 /// subsequent RPC. The client merges its own defaults
120 /// (`User-Agent`, `X-Title`, `Referer`, `HTTP-Referer`) into this
121 /// map, but caller-supplied values for any of those win on
122 /// conflict. `Authorization` (when needed) is just another entry
123 /// in `headers`. The `Mcp-Session-Id` header is reserved — pass it
124 /// via `session_id` instead so the explicit argument can never be
125 /// clobbered by the headers map.
126 ///
127 /// ## SSE handoff
128 ///
129 /// `Accept` is `text/event-stream, application/json` — stream first
130 /// — so the server is encouraged to keep the underlying connection
131 /// open. If the response comes back as SSE we read the initialize
132 /// event off the stream and hand the *still-open* line reader to the
133 /// returned [`Connection`]'s list-changed listener. The listener
134 /// starts reading from that pre-opened stream immediately, which
135 /// closes the race where a peer (e.g. an in-process rmcp upstream)
136 /// would broadcast `notifications/tools/list_changed` before our
137 /// listener had managed to open its own GET `/` SSE.
138 ///
139 /// If the response is unary JSON and the server advertises either
140 /// `tools.list_changed` or `resources.list_changed`, we proactively
141 /// open a GET `/` SSE stream *before returning* and hand it to the
142 /// listener for the same reason. If neither capability is set, no
143 /// listener is needed and we return without touching SSE.
144 pub async fn connect(
145 &self,
146 url: String,
147 session_id: Option<String>,
148 headers: Option<IndexMap<String, String>>,
149 ) -> Result<super::Connection, super::Error> {
150 // Merge the caller's headers with the client's defaults once,
151 // then reuse the same merged map across every retry of the
152 // handshake AND hand it to the resulting Connection for every
153 // later RPC. Caller-supplied headers always win over defaults.
154 let headers = self.headers(headers);
155
156 // One outer backoff retry around all three handshake steps —
157 // initialize POST, notifications/initialized POST, GET / SSE
158 // (when capabilities require it). On a failure of any step we
159 // restart from scratch: a partial handshake leaves server-side
160 // session state we can't reuse, so retrying just the failed
161 // step would reference a session the server already discarded.
162 // Every error is treated as transient — the loop only gives up
163 // when the backoff's `max_elapsed_time` is exceeded.
164 let mut backoff = backoff::ExponentialBackoff {
165 current_interval: self.backoff_current_interval,
166 initial_interval: self.backoff_initial_interval,
167 randomization_factor: self.backoff_randomization_factor,
168 multiplier: self.backoff_multiplier,
169 max_interval: self.backoff_max_interval,
170 start_time: std::time::Instant::now(),
171 max_elapsed_time: Some(self.backoff_max_elapsed_time),
172 clock: backoff::SystemClock::default(),
173 };
174
175 loop {
176 match self
177 .connect_once(&url, session_id.as_deref(), &headers)
178 .await
179 {
180 Ok(conn) => return Ok(conn),
181 Err(e) => {
182 use backoff::backoff::Backoff;
183 match backoff.next_backoff() {
184 Some(d) => tokio::time::sleep(d).await,
185 None => return Err(e),
186 }
187 }
188 }
189 }
190 }
191
192 /// Issue a stateless HTTP `DELETE /` to one upstream MCP server,
193 /// telling it to terminate the session identified by `session_id`.
194 ///
195 /// This is the low-level primitive — no backoff, no listener
196 /// teardown, no connection state. Caller is responsible for any
197 /// retry semantics. For the stateful tear-down path that also
198 /// cancels the connection's own active streams and absorbs
199 /// upstream `404 / 401 / 403` as success, use
200 /// [`Connection::delete`](super::Connection::delete) instead.
201 ///
202 /// `headers` is merged with the same defaults `connect` applies
203 /// (`User-Agent`, `X-Title`, `Referer`, `HTTP-Referer`). The
204 /// explicit `session_id` argument always wins over any
205 /// `Mcp-Session-Id` entry that happens to appear in `headers` — the
206 /// shape mirrors `connect`'s argument split for the same reason.
207 ///
208 /// Returns `Ok(())` on any 2xx status. Any non-2xx (including
209 /// `404 Not Found`) surfaces as [`Error::BadStatus`]. Network /
210 /// transport failures surface as [`Error::Request`].
211 pub async fn delete(
212 &self,
213 url: String,
214 session_id: String,
215 headers: Option<IndexMap<String, String>>,
216 ) -> Result<(), super::Error> {
217 let headers = self.headers(headers);
218 let mut request = apply_timeout(
219 self.http_client.delete(&url),
220 self.call_timeout,
221 )
222 .header("Mcp-Session-Id", &session_id);
223 for (name, value) in &headers {
224 // Explicit `session_id` arg always wins.
225 if name.eq_ignore_ascii_case("Mcp-Session-Id") {
226 continue;
227 }
228 request = request.header(name, value);
229 }
230 let response = request.send().await.map_err(|source| {
231 super::Error::Request {
232 url: url.clone(),
233 source,
234 }
235 })?;
236 if !response.status().is_success() {
237 let code = response.status();
238 let body = response.text().await.unwrap_or_default();
239 return Err(super::Error::BadStatus {
240 url,
241 code,
242 body: body.chars().take(800).collect(),
243 });
244 }
245 Ok(())
246 }
247
248 /// One pass through the full Streamable-HTTP handshake. Caller
249 /// applies the outer backoff retry loop in [`Self::connect`].
250 /// `headers` is the already-merged map (defaults + caller overrides
251 /// from [`Self::headers`]), reused on every request without further
252 /// processing. `Mcp-Session-Id` is applied AFTER the headers loop
253 /// so it always wins over any same-named entry in `headers`.
254 async fn connect_once(
255 &self,
256 url: &str,
257 session_id: Option<&str>,
258 headers: &IndexMap<String, String>,
259 ) -> Result<super::Connection, super::Error> {
260 let init_request = serde_json::json!({
261 "jsonrpc": "2.0",
262 "id": 1,
263 "method": "initialize",
264 "params": {
265 "protocolVersion": "2025-06-18",
266 "capabilities": {},
267 "clientInfo": {
268 "name": "objectiveai",
269 "version": env!("CARGO_PKG_VERSION"),
270 }
271 }
272 });
273
274 let mut request = apply_timeout(
275 self.http_client.post(url),
276 self.connect_timeout,
277 )
278 .header("Content-Type", "application/json")
279 .header("Accept", "text/event-stream, application/json")
280 .json(&init_request);
281
282 for (name, value) in headers {
283 request = request.header(name, value);
284 }
285 // Mcp-Session-Id is applied last so the explicit `session_id`
286 // argument always wins over any same-named entry in `headers`.
287 if let Some(sid) = session_id {
288 request = request.header("Mcp-Session-Id", sid);
289 }
290
291 let response = request.send().await.map_err(|source| {
292 super::Error::Connection {
293 url: url.to_string(),
294 source,
295 }
296 })?;
297
298 if !response.status().is_success() {
299 let code = response.status();
300 let body = response.text().await.unwrap_or_default();
301 return Err(super::Error::BadStatus {
302 url: url.to_string(),
303 code,
304 body,
305 });
306 }
307
308 // Extract session ID from response header.
309 //
310 // For *new* sessions the server must mint and return a session
311 // id. For *existing* sessions (caller passed `session_id` in)
312 // many servers — including rmcp's `StreamableHttpService` on
313 // its existing-session branch — don't echo the header back
314 // because nothing changed. When the caller already knew the
315 // session id, fall back to it instead of erroring; that's the
316 // value we'll be stamping on every subsequent request anyway.
317 let resolved_session_id = match response
318 .headers()
319 .get("Mcp-Session-Id")
320 .and_then(|v| v.to_str().ok())
321 .map(String::from)
322 {
323 Some(s) => s,
324 None => match session_id {
325 Some(provided) => provided.to_string(),
326 None => {
327 let body = response.text().await.unwrap_or_default();
328 return Err(super::Error::NoSessionId {
329 url: url.to_string(),
330 body: body.chars().take(800).collect(),
331 });
332 }
333 },
334 };
335
336 // Did the server return SSE or unary JSON? rmcp's
337 // `StreamableHttpService` always returns SSE; many other servers
338 // reply with bare JSON.
339 let is_sse = response
340 .headers()
341 .get(reqwest::header::CONTENT_TYPE)
342 .and_then(|v| v.to_str().ok())
343 .map(|v| v.starts_with("text/event-stream"))
344 .unwrap_or(false);
345
346 // Parse the initialize response. SSE path consumes one event
347 // from the stream and keeps the rest of the stream alive for
348 // the listener; unary path consumes the whole body.
349 let (initialize_result, mut initial_sse_lines) = if is_sse {
350 let mut lines = super::lines_from_response(response);
351 let rpc_response: super::JsonRpcResponse<
352 super::initialize_result::InitializeResult,
353 > = super::read_next_sse_event(url, &mut lines).await?;
354 let result = match rpc_response {
355 super::JsonRpcResponse::Success { result, .. } => result,
356 super::JsonRpcResponse::Error { error, .. } => {
357 return Err(super::Error::JsonRpc {
358 url: url.to_string(),
359 code: error.code,
360 message: error.message,
361 data: error.data,
362 });
363 }
364 };
365 (result, Some(lines))
366 } else {
367 let rpc_response: super::JsonRpcResponse<
368 super::initialize_result::InitializeResult,
369 > = super::parse_streamable_http_response(url, response).await?;
370 let result = match rpc_response {
371 super::JsonRpcResponse::Success { result, .. } => result,
372 super::JsonRpcResponse::Error { error, .. } => {
373 return Err(super::Error::JsonRpc {
374 url: url.to_string(),
375 code: error.code,
376 message: error.message,
377 data: error.data,
378 });
379 }
380 };
381 (result, None)
382 };
383
384 // Whether we need a notification SSE channel at all.
385 let needs_sse = initialize_result
386 .capabilities
387 .tools
388 .as_ref()
389 .and_then(|t| t.list_changed)
390 .unwrap_or(false)
391 || initialize_result
392 .capabilities
393 .resources
394 .as_ref()
395 .and_then(|r| r.list_changed)
396 .unwrap_or(false);
397
398 // Send `notifications/initialized` BEFORE any other request.
399 // rmcp's per-session worker is in `expect_notification("initialized")`
400 // at this point — anything else (a `tools/list`, an opportunistic
401 // GET `/`) that lands during that window pushes a non-notification
402 // through the worker, makes `serve_server_with_ct_inner` return
403 // `Err(ExpectedInitializedNotification(...))`, drops the
404 // WorkerTransport, cancels the worker via its drop_guard, and
405 // tears the whole session down. Every later POST then 500s with
406 // "Session service terminated."
407 //
408 // We don't have a `Connection` yet — building one would spawn
409 // `refresh_tools` / `refresh_resources` background tasks that
410 // race with this notification, which is exactly the bug we're
411 // avoiding. We therefore POST inline here.
412 let init_notification_body = serde_json::json!({
413 "jsonrpc": "2.0",
414 "method": "notifications/initialized",
415 "params": {},
416 });
417 let mut notify_request = apply_timeout(
418 self.http_client.post(url),
419 self.call_timeout,
420 )
421 .header("Content-Type", "application/json")
422 .header("Accept", "application/json, text/event-stream");
423 for (name, value) in headers {
424 notify_request = notify_request.header(name, value);
425 }
426 notify_request =
427 notify_request.header("Mcp-Session-Id", &resolved_session_id);
428 let notify_response = notify_request
429 .json(&init_notification_body)
430 .send()
431 .await
432 .map_err(|source| super::Error::Request {
433 url: url.to_string(),
434 source,
435 })?;
436 if !notify_response.status().is_success() {
437 let code = notify_response.status();
438 let body = notify_response.text().await.unwrap_or_default();
439 return Err(super::Error::BadStatus {
440 url: url.to_string(),
441 code,
442 body,
443 });
444 }
445
446 // Now safe to drop the init-side SSE stream; rmcp's session
447 // worker is past the init handshake and we have no further use
448 // for it (notifications come on the GET / stream below).
449 drop(initial_sse_lines.take());
450
451 // Now that the server's session worker is past the
452 // `expect_notification` gate, it's safe to open the proactive
453 // GET `/` SSE stream the listener will read
454 // `notifications/{tools,resources}/list_changed` from. Capability
455 // inspection only gates whether we open this stream; the
456 // `Connection` itself is naive about capabilities.
457 let initial_sse_lines: Option<super::LinesStream> = if needs_sse {
458 let mut get_request = apply_timeout(
459 self.http_client.get(url),
460 self.connect_timeout,
461 )
462 .header("Accept", "text/event-stream");
463 for (name, value) in headers {
464 get_request = get_request.header(name, value);
465 }
466 get_request =
467 get_request.header("Mcp-Session-Id", &resolved_session_id);
468 let get_response = get_request.send().await.map_err(|source| {
469 super::Error::Connection {
470 url: url.to_string(),
471 source,
472 }
473 })?;
474 if !get_response.status().is_success() {
475 let code = get_response.status();
476 let body = get_response.text().await.unwrap_or_default();
477 return Err(super::Error::BadStatus {
478 url: url.to_string(),
479 code,
480 body,
481 });
482 }
483 Some(super::lines_from_response(get_response))
484 } else {
485 None
486 };
487
488 // Construct the Connection at the very end. This is the only
489 // place in `connect` where the listener task and the
490 // `refresh_tools` / `refresh_resources` background tasks get
491 // spawned — by now the upstream is fully past its init
492 // handshake, so any of those POSTs land safely.
493 let connection = super::Connection::new(
494 self.http_client.clone(),
495 url.to_string(),
496 resolved_session_id,
497 headers.clone(),
498 self.backoff_current_interval,
499 self.backoff_initial_interval,
500 self.backoff_randomization_factor,
501 self.backoff_multiplier,
502 self.backoff_max_interval,
503 self.backoff_max_elapsed_time,
504 self.call_timeout,
505 initialize_result,
506 initial_sse_lines,
507 )
508 .await;
509
510 Ok(connection)
511 }
512}