Skip to main content

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}