Skip to main content

protocol_droid/application/client/
connect.rs

1//! Opening a session with an MCP server, from a [`TransportSpec`].
2//!
3//! The four steps a caller used to write by hand — build the adapter, choose
4//! the revisions to offer, run the handshake, bind the result to a [`Client`] —
5//! are one step here. None of them is a decision the caller should be making:
6//! which adapter speaks a given spec is a fact about the crate, and the set of
7//! revisions to offer is a matter of what this build has wire types for.
8//!
9//! This is the client counterpart of [`serve_http`](crate::application::server::serve::StdioBuilder)
10//! and [`serve_stdio`](crate::application::server::serve::StdioBuilder), and it
11//! exists for the same reason: a consumer that has to assemble a transport by
12//! hand is doing the crate's work, and doing it once per project.
13//!
14//! [`ClientTransport`]: super::ClientTransport
15//! [`Client`]: super::Client
16
17use std::sync::Arc;
18
19use crate::domain::{
20    NegotiationError, SpecError, SupportedVersions, SupportedVersionsError, TransportKind,
21    TransportSpec,
22};
23
24use super::handler::ConnectOptions;
25use super::{Client, ClientError, ClientTransport, HandshakeError};
26#[cfg(any(feature = "http", feature = "legacy-sse"))]
27use super::{ClientConfig, handshake};
28
29/// Why a session could not be opened.
30///
31/// Collapses five failure sources into one so a caller can handle "could not
32/// connect" as one arm, and read *which* of the four stages failed by matching
33/// the variant. The inner errors are kept rather than flattened to strings,
34/// because a caller retrying on a transient transport fault needs to tell that
35/// apart from a server that will never speak a common revision.
36#[derive(Debug, thiserror::Error)]
37pub enum ConnectError {
38    /// The spec names a transport this crate has no client adapter for.
39    ///
40    /// A refusal rather than a half-open connection: a caller told only that
41    /// connecting failed cannot tell "wrong URL" from "this build has no such
42    /// adapter", and retries the first forever.
43    #[error("this build has no client adapter for `{kind}`: {reason}")]
44    Unsupported {
45        /// The transport that was asked for.
46        kind: TransportKind,
47        /// Why it cannot be opened.
48        reason: &'static str,
49    },
50
51    /// The spec itself was unusable.
52    #[error("the transport spec is not usable: {0}")]
53    Spec(#[from] SpecError),
54
55    /// The offered revisions were not a usable set.
56    #[error("the offered revisions are not usable: {0}")]
57    Config(#[from] ClientError),
58
59    /// The default offered revisions could not be built.
60    #[error("this build's own revision table is unusable: {0}")]
61    Versions(#[from] SupportedVersionsError),
62
63    /// The handshake did not complete.
64    #[error("the handshake did not complete: {0}")]
65    Handshake(#[from] HandshakeError),
66
67    /// The two sides share no revision.
68    #[error("the client and the server share no revision: {0}")]
69    NoCommonVersion(#[from] NegotiationError),
70
71    /// A Streamable HTTP endpoint could not be prepared.
72    #[cfg(feature = "http")]
73    #[error("the Streamable HTTP endpoint could not be prepared: {0}")]
74    HttpEndpoint(#[from] crate::adapters::secondary::transport::http::HttpClientError),
75
76    /// A legacy HTTP+SSE endpoint could not be opened.
77    #[cfg(feature = "legacy-sse")]
78    #[error("the legacy HTTP+SSE endpoint could not be opened: {0}")]
79    LegacySse(#[from] crate::adapters::secondary::transport::legacy_sse::LegacySseError),
80}
81
82/// Opens `spec`, negotiates, and binds the result.
83///
84/// Returns the transport alongside the client because several callers need it
85/// for something the client does not do — the bridge wraps it as a peer and
86/// forwards frames with it, where reading tools over it would be the wrong
87/// shape entirely. Handing back the same `Arc` the client holds is what keeps
88/// there from being two connections that could disagree about what was
89/// negotiated.
90///
91/// # Errors
92///
93/// [`ConnectError`] naming the stage that failed.
94#[cfg(any(feature = "http", feature = "legacy-sse"))]
95pub async fn open(
96    spec: &TransportSpec,
97    versions: &SupportedVersions,
98) -> Result<(Client, Arc<dyn ClientTransport>), ConnectError> {
99    open_with(spec, versions, ConnectOptions::new()).await
100}
101
102/// [`open`], applying `options`.
103///
104/// What a client that can answer sampling, elicitation or roots needs: a
105/// server only asks for what the client said it can do, and a client that
106/// declared nothing is never asked.
107///
108/// # Errors
109///
110/// As [`open`].
111#[cfg(any(feature = "http", feature = "legacy-sse"))]
112pub(super) async fn open_with(
113    spec: &TransportSpec,
114    versions: &SupportedVersions,
115    options: ConnectOptions,
116) -> Result<(Client, Arc<dyn ClientTransport>), ConnectError> {
117    let capabilities = options.capabilities;
118    // One match, so there is exactly one place in the crate that knows which
119    // transports have client adapters. A second copy of this table is a second
120    // opinion on that question, and it is the copy that has to be remembered
121    // when a kind is added.
122    // The concrete HTTP client, kept when a handler is wired so its standalone
123    // stream can be opened once the handshake has given it a session.
124    #[cfg(feature = "http")]
125    let listener: Option<Arc<crate::adapters::secondary::transport::http::HttpClient>>;
126    let (transport, kind): (Arc<dyn ClientTransport>, TransportKind) = match spec.kind() {
127        #[cfg(feature = "http")]
128        TransportKind::StreamableHttp => {
129            use crate::adapters::secondary::transport::http::{HttpClient, HttpClientConfig};
130            let config = HttpClientConfig::new(spec.target())?;
131            let mut client = HttpClient::new(config)?;
132            if let Some(handler) = options.handler.clone() {
133                client = client.answering(handler);
134            }
135            if let Some(auth) = options.auth.clone() {
136                client = client.authorizing(auth);
137            }
138            let client = Arc::new(client);
139            listener = options.handler.is_some().then(|| Arc::clone(&client));
140            (client, TransportKind::StreamableHttp)
141        }
142        #[cfg(feature = "http")]
143        TransportKind::StatelessHttp => {
144            return open_stateless(spec, versions, options).await;
145        }
146        #[cfg(feature = "legacy-sse")]
147        TransportKind::LegacySse => {
148            use crate::adapters::secondary::transport::legacy_sse::{
149                LegacySseClient, LegacySseConfig,
150            };
151            let config = LegacySseConfig::new(spec.target())?;
152            let client = LegacySseClient::connect(config).await?;
153            listener = None;
154            (Arc::new(client), TransportKind::LegacySse)
155        }
156        kind => {
157            return Err(ConnectError::Unsupported {
158                kind,
159                reason: missing_reason(kind),
160            });
161        }
162    };
163
164    // Newest first, because the first revision is the one offered: a set sorted
165    // oldest-first would open every connection by asking for 2024-11-05. Only
166    // revisions that have a handshake: a stateful transport that offered
167    // `2026-07-28` would send `server/discover` to a server expecting
168    // `initialize`. The stateless transport has its own path above.
169    let mut offered: Vec<crate::domain::ProtocolVersion> = versions
170        .as_slice()
171        .iter()
172        .copied()
173        .filter(|version| crate::domain::handshake_defined_in(*version))
174        .collect();
175    offered.sort_by(|left, right| right.cmp(left));
176    if offered.is_empty() {
177        return Err(ConnectError::Unsupported {
178            kind,
179            reason: "none of the offered revisions has an `initialize` handshake; \
180                     use an `http-stateless:` spec for 2026-07-28",
181        });
182    }
183    let config = ClientConfig::speaking(offered, kind)?.with_capabilities(capabilities);
184    let session = handshake(transport.as_ref(), &config).await?;
185    #[cfg(feature = "http")]
186    if let Some(listener) = &listener {
187        listener.spawn_listener();
188    }
189
190    // The profile is the session's, not re-resolved from the two capability
191    // sets. `resolve` is pure and would land in the same place, but reading it
192    // off the session is the claim that this is what the server agreed to
193    // rather than a second derivation of it that could drift.
194    Ok((
195        Client::from_session(&session, Arc::clone(&transport)),
196        transport,
197    ))
198}
199
200/// Opens `spec` in a build that compiled no client adapter at all.
201///
202/// A separate definition rather than one match whose arms were all removed,
203/// because a match with nothing left in it does not type — and because "this
204/// build has no client adapter for any transport" is a different thing to tell
205/// a caller than "that transport has no adapter", and worth saying plainly.
206///
207/// # Errors
208///
209/// Always [`ConnectError::Unsupported`].
210#[cfg(not(any(feature = "http", feature = "legacy-sse")))]
211pub async fn open(
212    spec: &TransportSpec,
213    versions: &SupportedVersions,
214) -> Result<(Client, Arc<dyn ClientTransport>), ConnectError> {
215    open_with(spec, versions, ConnectOptions::new()).await
216}
217
218/// [`open_with`] in a build with no client adapter: always refuses.
219///
220/// # Errors
221///
222/// Always [`ConnectError::Unsupported`].
223#[cfg(not(any(feature = "http", feature = "legacy-sse")))]
224pub(super) async fn open_with(
225    spec: &TransportSpec,
226    versions: &SupportedVersions,
227    options: ConnectOptions,
228) -> Result<(Client, Arc<dyn ClientTransport>), ConnectError> {
229    let _ = (versions, options);
230    Err(ConnectError::Unsupported {
231        kind: spec.kind(),
232        reason: missing_reason(spec.kind()),
233    })
234}
235
236/// Opens a `2026-07-28` session: no `initialize`, one revision per attempt.
237///
238/// Separate from [`open_with`] because it is a different lifecycle rather than
239/// a different transport: it negotiates by trying and being refused, where the
240/// stateful path sends a handshake and reads the answer.
241#[cfg(feature = "http")]
242async fn open_stateless(
243    spec: &TransportSpec,
244    versions: &SupportedVersions,
245    options: ConnectOptions,
246) -> Result<(Client, Arc<dyn ClientTransport>), ConnectError> {
247    use crate::adapters::secondary::transport::http::{HttpClientConfig, StatelessHttpClient};
248    let capabilities = options.capabilities;
249    // Only the revisions with no handshake can be spoken statelessly,
250    // and the handshake below sends `initialize` for any other — so
251    // the offer is narrowed here, newest first.
252    let mut stateless: Vec<_> = versions
253        .as_slice()
254        .iter()
255        .copied()
256        .filter(|version| !crate::domain::handshake_defined_in(*version))
257        .collect();
258    stateless.sort_by(|left, right| right.cmp(left));
259    if stateless.is_empty() {
260        return Err(ConnectError::Unsupported {
261            kind: TransportKind::StatelessHttp,
262            reason: "none of the offered revisions has a stateless lifecycle",
263        });
264    }
265    // SEP-2575: a server that does not speak the revision a request
266    // names answers `-32022` listing the ones it does, and the client
267    // retries with the newest it shares. A server may list the very
268    // revision it just refused (a transient rejection, or a balancer
269    // in front of mixed deployments), so a revision it names is tried
270    // up to twice before it is given up on.
271    let mut attempts: Vec<(crate::domain::ProtocolVersion, u8)> =
272        stateless.iter().map(|version| (*version, 0)).collect();
273    let mut candidate = stateless.first().copied();
274    loop {
275        let Some(current) = candidate else {
276            return Err(ConnectError::Unsupported {
277                kind: TransportKind::StatelessHttp,
278                reason: "the server shares no stateless revision with this client",
279            });
280        };
281        if let Some((_, tried)) = attempts.iter_mut().find(|(version, _)| *version == current) {
282            *tried = tried.saturating_add(1);
283        }
284        let config = ClientConfig::speaking(vec![current], TransportKind::StatelessHttp)?
285            .with_capabilities(capabilities);
286        let capabilities_json =
287            serde_json::Value::Object(super::handshake::client_capabilities(&config));
288        let mut client = StatelessHttpClient::new(
289            HttpClientConfig::new(spec.target())?,
290            current,
291            capabilities_json,
292        )?;
293        if let Some(auth) = options.auth.clone() {
294            client = client.authorizing(auth);
295        }
296        let transport: Arc<dyn ClientTransport> = Arc::new(client);
297        match handshake(transport.as_ref(), &config).await {
298            Ok(session) => {
299                return Ok((
300                    Client::from_session(&session, Arc::clone(&transport)),
301                    transport,
302                ));
303            }
304            Err(HandshakeError::Transport(refused)) if refused.supported_versions().is_some() => {
305                let supported = refused.supported_versions().unwrap_or_default();
306                candidate = attempts
307                    .iter()
308                    .find(|(version, tried)| {
309                        *tried < 2 && supported.iter().any(|name| *name == version.as_wire())
310                    })
311                    .map(|(version, _)| *version);
312            }
313            Err(other) => return Err(other.into()),
314        }
315    }
316}
317
318/// Why `kind` has no client adapter here, in the words of someone who has to
319/// decide what to do about it.
320///
321/// Split from the dispatch above so the explanation and the match cannot drift:
322/// the arm that refuses is the arm that knows why. Always compiled (not gated
323/// on `http` or `legacy-sse`) so the no-adapter `open` above can name the
324/// specific kind it is refusing — a stdio spec in a no-features build still
325/// gets the "no command line" reason, not a generic "no adapter" string.
326const fn missing_reason(kind: TransportKind) -> &'static str {
327    match kind {
328        TransportKind::Stdio => {
329            "a stdio upstream needs a program to run, and a spec carries no \
330             command line"
331        }
332        TransportKind::StatelessHttp => {
333            "the stateless HTTP client is compiled in with the `http` feature"
334        }
335        // Reached only when `legacy-sse` is off while `http` is on, which is
336        // the one build where a kind exists in the table and has no adapter.
337        _ => "no client adapter for this transport is compiled in",
338    }
339}
340
341/// The revisions a client offers when the caller expresses no preference.
342///
343/// Every revision this build has wire types for. Narrowing is the caller's
344/// call — a client pinned to one revision is useful, and so is one that will
345/// speak whatever the server prefers — but the *default* should not silently
346/// be a subset, because a client that offers less than it can speak is a
347/// client that gets refused by servers it would have worked with.
348///
349/// # Errors
350///
351/// [`SupportedVersionsError`] only if this build's own table were empty, which
352/// [`SupportedVersions::all`] documents cannot happen. Returned rather than
353/// unwrapped because a function that cannot fail should say so in its type, and
354/// because inventing a one-revision fallback here would silently narrow a
355/// client on a build whose table is broken — which is the exact failure the
356/// default exists to prevent.
357pub fn default_versions() -> Result<SupportedVersions, SupportedVersionsError> {
358    SupportedVersions::all()
359}
360
361#[cfg(test)]
362mod tests {
363    use crate::domain::{ProtocolVersion, TransportKind, TransportSpec};
364
365    use super::{ConnectError, default_versions, open};
366
367    /// Anything a test here can fail at.
368    #[derive(Debug)]
369    enum Failure {
370        /// A test assumption about the crate's own tables did not hold.
371        Expectation(&'static str),
372        /// Opening a session failed in a way the test did not expect.
373        Connect(ConnectError),
374    }
375
376    impl core::fmt::Display for Failure {
377        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
378            match self {
379                Self::Expectation(what) => write!(formatter, "expected {what}"),
380                Self::Connect(error) => write!(formatter, "connecting failed: {error}"),
381            }
382        }
383    }
384
385    impl std::error::Error for Failure {}
386
387    impl From<ConnectError> for Failure {
388        fn from(error: ConnectError) -> Self {
389            Self::Connect(error)
390        }
391    }
392
393    impl From<crate::domain::SupportedVersionsError> for Failure {
394        fn from(error: crate::domain::SupportedVersionsError) -> Self {
395            Self::Expectation(match error {
396                crate::domain::SupportedVersionsError::Empty => "a non-empty default",
397            })
398        }
399    }
400
401    /// A spec of `kind` pointing somewhere that is never dialled, because these
402    /// tests read the refusal before any socket is opened.
403    fn spec(kind: TransportKind) -> Result<TransportSpec, Failure> {
404        TransportSpec::new(kind, "http://127.0.0.1:1/mcp")
405            .map_err(|_| Failure::Expectation("a usable spec"))
406    }
407
408    /// A transport with no client adapter is refused by name, before dialling.
409    ///
410    /// The refusal has to name the kind. A caller told only "could not connect"
411    /// retries a wrong URL forever, and the kind is the one fact that tells them
412    /// to build with a different feature instead.
413    #[cfg(not(feature = "http"))]
414    #[tokio::test]
415    async fn a_kind_with_no_adapter_is_refused_by_name() -> Result<(), Failure> {
416        let refused = open(&spec(TransportKind::StatelessHttp)?, &default_versions()?).await;
417        match refused {
418            Err(ConnectError::Unsupported { kind, reason }) => {
419                if kind != TransportKind::StatelessHttp {
420                    return Err(Failure::Expectation("the refused kind reported back"));
421                }
422                if reason.is_empty() {
423                    return Err(Failure::Expectation("a reason that says what to do"));
424                }
425                Ok(())
426            }
427            Err(other) => Err(Failure::Connect(other)),
428            Ok(_) => Err(Failure::Expectation("a refusal rather than an adapter")),
429        }
430    }
431
432    /// A stateless spec offered only handshake revisions is refused before
433    /// dialling: there is nothing it could speak statelessly.
434    #[cfg(feature = "http")]
435    #[tokio::test]
436    async fn a_stateless_spec_with_no_stateless_revision_is_refused() -> Result<(), Failure> {
437        let versions = crate::domain::SupportedVersions::from_slice(&[
438            crate::domain::ProtocolVersion::V2025_11_25,
439        ])?;
440        match open(&spec(TransportKind::StatelessHttp)?, &versions).await {
441            Err(ConnectError::Unsupported {
442                kind: TransportKind::StatelessHttp,
443                ..
444            }) => Ok(()),
445            Err(other) => Err(Failure::Connect(other)),
446            Ok(_) => Err(Failure::Expectation("a refusal")),
447        }
448    }
449
450    /// A `stdio` spec is refused, and the reason names the missing thing.
451    ///
452    /// `TransportSpec` parses a `stdio` spec happily — `kind:target` is a
453    /// string. What it cannot carry is the command line a stdio upstream needs,
454    /// so the honest answer is a refusal whose reason says so, rather than a
455    /// client that connects to a filename.
456    ///
457    /// This and [`a_kind_with_no_adapter_is_refused_by_name`] are the same call
458    /// with two kinds, kept apart because the reasons they assert on differ: one
459    /// is about *why this kind*, the other about *which kind came back*.
460    #[tokio::test]
461    async fn a_stdio_spec_is_refused_for_wanting_a_command_line() -> Result<(), Failure> {
462        match open(&spec(TransportKind::Stdio)?, &default_versions()?).await {
463            Err(ConnectError::Unsupported { reason, .. }) => {
464                if !reason.contains("command") {
465                    return Err(Failure::Expectation(
466                        "a reason naming the command line that is missing",
467                    ));
468                }
469                Ok(())
470            }
471            Err(other) => Err(Failure::Connect(other)),
472            Ok(_) => Err(Failure::Expectation("a refusal rather than a session")),
473        }
474    }
475
476    /// The default offer is every revision the build knows, not a subset.
477    ///
478    /// A default that quietly offered one revision would connect to old servers
479    /// and refuse new ones, and the caller would have no way to see that the
480    /// narrowing happened.
481    #[test]
482    fn the_default_offers_every_known_revision() -> Result<(), Failure> {
483        let offered = default_versions()?;
484        if offered.len() != ProtocolVersion::KNOWN.len() {
485            return Err(Failure::Expectation(
486                "the default to offer every revision with wire types",
487            ));
488        }
489        for known in ProtocolVersion::KNOWN {
490            if !offered.contains(&known) {
491                return Err(Failure::Expectation("every known revision to be offered"));
492            }
493        }
494        Ok(())
495    }
496}