protocol_droid/application/client/
connect.rs1use 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#[derive(Debug, thiserror::Error)]
37pub enum ConnectError {
38 #[error("this build has no client adapter for `{kind}`: {reason}")]
44 Unsupported {
45 kind: TransportKind,
47 reason: &'static str,
49 },
50
51 #[error("the transport spec is not usable: {0}")]
53 Spec(#[from] SpecError),
54
55 #[error("the offered revisions are not usable: {0}")]
57 Config(#[from] ClientError),
58
59 #[error("this build's own revision table is unusable: {0}")]
61 Versions(#[from] SupportedVersionsError),
62
63 #[error("the handshake did not complete: {0}")]
65 Handshake(#[from] HandshakeError),
66
67 #[error("the client and the server share no revision: {0}")]
69 NoCommonVersion(#[from] NegotiationError),
70
71 #[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 #[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#[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#[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 #[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 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 Ok((
195 Client::from_session(&session, Arc::clone(&transport)),
196 transport,
197 ))
198}
199
200#[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#[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#[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 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 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
318const 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 _ => "no client adapter for this transport is compiled in",
338 }
339}
340
341pub 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 #[derive(Debug)]
369 enum Failure {
370 Expectation(&'static str),
372 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 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 #[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 #[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 #[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 #[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}