1use std::collections::{BTreeMap, BTreeSet};
9use std::error::Error;
10use std::fmt;
11
12use serde::{
13 Deserialize, Deserializer, Serialize, Serializer,
14 de::Error as _,
15 ser::{Error as _, SerializeMap},
16};
17use serde_json::{Value, value::RawValue};
18
19use crate::common_types::{Implementation, OpenMetadata};
20use crate::result::{
21 CacheTtl, ExactJsonObject, FinalResultMetadataRole, encode_exact_object,
22 parse_exact_result_object, validate_final_result_metadata_entries,
23};
24use crate::{
25 ExtensionId, FINAL_CLIENT_CAPABILITIES_META_KEY, FINAL_PROTOCOL_VERSION_META_KEY,
26 ResultPeerDiagnostic, ServerInfo, protocol_version::FINAL_PROTOCOL_VERSION,
27};
28
29pub const SERVER_DISCOVER_METHOD: &str = "server/discover";
31
32pub const SERVER_DISCOVER_SUPPORTED_VERSIONS: &[&str] = &[FINAL_PROTOCOL_VERSION];
34
35pub const MAX_SERVER_INSTRUCTIONS_BYTES: usize = 16 * 1024;
37
38pub const MAX_DISCOVERY_EXTENSION_SETTINGS: usize = 64;
40
41pub const MAX_DISCOVERY_EXTENSION_NAME_BYTES: usize = 256;
43
44pub const MAX_DISCOVERY_EXTENSION_VALUE_BYTES: usize = 16 * 1024;
46
47pub const SERVER_DISCOVER_SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
49
50#[derive(Clone, Debug, PartialEq, Serialize)]
56pub struct ServerDiscoverRequest {
57 #[serde(rename = "_meta")]
58 metadata: OpenMetadata,
59 #[serde(flatten)]
60 extras: BTreeMap<String, Value>,
61}
62
63impl Default for ServerDiscoverRequest {
64 fn default() -> Self {
65 let metadata = OpenMetadata::try_from_entries([
66 (
67 FINAL_PROTOCOL_VERSION_META_KEY.to_owned(),
68 Value::String(FINAL_PROTOCOL_VERSION.to_owned()),
69 ),
70 (
71 FINAL_CLIENT_CAPABILITIES_META_KEY.to_owned(),
72 Value::Object(serde_json::Map::new()),
73 ),
74 ])
75 .expect("the fixed final discovery request metadata is valid");
76 Self {
77 metadata,
78 extras: BTreeMap::new(),
79 }
80 }
81}
82
83impl ServerDiscoverRequest {
84 #[must_use]
87 pub fn metadata(&self) -> &OpenMetadata {
88 &self.metadata
89 }
90}
91
92#[derive(Deserialize)]
93struct ServerDiscoverRequestWire {
94 #[serde(rename = "_meta")]
95 metadata: OpenMetadata,
96 #[serde(flatten)]
97 extras: BTreeMap<String, Value>,
98}
99
100impl<'de> Deserialize<'de> for ServerDiscoverRequest {
101 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
102 where
103 D: Deserializer<'de>,
104 {
105 let wire = ServerDiscoverRequestWire::deserialize(deserializer)?;
106 let protocol_version = wire.metadata.protocol_version().map_err(D::Error::custom)?;
107 let client_capabilities = wire
108 .metadata
109 .client_capabilities()
110 .map_err(D::Error::custom)?;
111 if protocol_version != Some(FINAL_PROTOCOL_VERSION) || client_capabilities.is_none() {
112 return Err(D::Error::custom(
113 ServerDiscoveryError::InvalidRequestMetadata,
114 ));
115 }
116 Ok(Self {
117 metadata: wire.metadata,
118 extras: wire.extras,
119 })
120 }
121}
122
123#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
125pub enum ServerBehavior {
126 LoggingRequestEmitter,
128 CompletionComplete,
130 ToolsList,
132 ToolsListChangedNotification,
134 ResourcesList,
136 ResourcesListChangedNotification,
138 ResourcesSubscribe,
140 SubscriptionsListen,
142 ResourceUpdateDelivery,
144 PromptsList,
146 PromptsListChangedNotification,
148}
149
150#[derive(Clone, Debug, Default, Eq, PartialEq)]
152pub struct ServerBehaviorRegistry {
153 installed: BTreeSet<ServerBehavior>,
154}
155
156impl ServerBehaviorRegistry {
157 #[must_use]
159 pub fn from_behaviors(behaviors: impl IntoIterator<Item = ServerBehavior>) -> Self {
160 Self {
161 installed: behaviors.into_iter().collect(),
162 }
163 }
164
165 #[must_use]
167 pub fn contains(&self, behavior: ServerBehavior) -> bool {
168 self.installed.contains(&behavior)
169 }
170}
171
172#[derive(Clone, Debug, Eq, PartialEq)]
174pub struct ServerInstructions(String);
175
176impl ServerInstructions {
177 pub fn new(value: impl Into<String>) -> Result<Self, ServerInstructionError> {
179 let value = value.into();
180 if value.len() > MAX_SERVER_INSTRUCTIONS_BYTES {
181 return Err(ServerInstructionError::TooLarge {
182 actual: value.len(),
183 maximum: MAX_SERVER_INSTRUCTIONS_BYTES,
184 });
185 }
186 Ok(Self(value))
187 }
188
189 #[must_use]
191 pub fn as_str(&self) -> &str {
192 &self.0
193 }
194}
195
196impl Serialize for ServerInstructions {
197 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
198 where
199 S: Serializer,
200 {
201 serializer.serialize_str(&self.0)
202 }
203}
204
205impl<'de> Deserialize<'de> for ServerInstructions {
206 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
207 where
208 D: Deserializer<'de>,
209 {
210 let value = String::deserialize(deserializer)?;
211 Self::new(value).map_err(D::Error::custom)
212 }
213}
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq)]
217pub enum ServerInstructionError {
218 TooLarge {
220 actual: usize,
222 maximum: usize,
224 },
225}
226
227impl fmt::Display for ServerInstructionError {
228 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229 match self {
230 Self::TooLarge { actual, maximum } => {
231 write!(
232 formatter,
233 "server instructions are {actual} bytes; maximum is {maximum}"
234 )
235 }
236 }
237 }
238}
239
240impl Error for ServerInstructionError {}
241
242#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
244#[serde(rename_all = "lowercase")]
245enum DiscoveryCacheScope {
246 Public,
247 Private,
248}
249
250impl<'de> Deserialize<'de> for DiscoveryCacheScope {
251 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252 where
253 D: Deserializer<'de>,
254 {
255 match String::deserialize(deserializer)?.as_str() {
256 "public" => Ok(Self::Public),
257 "private" => Ok(Self::Private),
258 _ => Err(D::Error::custom("cacheScope must be `public` or `private`")),
259 }
260 }
261}
262
263const COMPLETE_DISCOVERY_RESULT_TYPE: &str = "complete";
267
268const DISCOVERY_CONTRADICTORY_RESULT_MEMBERS: [&str; 13] = [
274 "serverInfo",
275 "input",
276 "inputRequests",
277 "request",
278 "requestState",
279 "taskId",
280 "status",
281 "statusMessage",
282 "createdAt",
283 "lastUpdatedAt",
284 "pollIntervalMs",
285 "result",
286 "error",
287];
288
289#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
295pub struct DiscoveryCacheHints {
296 #[serde(rename = "ttlMs")]
297 ttl_ms: CacheTtl,
298 #[serde(rename = "cacheScope")]
299 scope: DiscoveryCacheScope,
300}
301
302impl DiscoveryCacheHints {
303 #[must_use]
306 pub fn private_ttl_ms(ttl_ms: u64) -> Self {
307 Self {
308 ttl_ms: CacheTtl::milliseconds(ttl_ms),
309 scope: DiscoveryCacheScope::Private,
310 }
311 }
312
313 #[must_use]
315 pub fn ttl_ms(&self) -> &CacheTtl {
316 &self.ttl_ms
317 }
318
319 #[must_use]
321 pub const fn is_public(&self) -> bool {
322 matches!(self.scope, DiscoveryCacheScope::Public)
323 }
324
325 const fn from_peer_wire(ttl_ms: CacheTtl, scope: DiscoveryCacheScope) -> Self {
326 Self { ttl_ms, scope }
327 }
328}
329
330#[derive(Clone, Debug, Eq, PartialEq)]
336pub struct ServerDiscoverCapabilities {
337 members: BTreeMap<String, Value>,
338}
339
340impl Serialize for ServerDiscoverCapabilities {
341 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
342 where
343 S: Serializer,
344 {
345 self.members.serialize(serializer)
346 }
347}
348
349impl<'de> Deserialize<'de> for ServerDiscoverCapabilities {
350 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
351 where
352 D: Deserializer<'de>,
353 {
354 let members = BTreeMap::<String, Value>::deserialize(deserializer)?;
355 validate_capability_members(&members).map_err(D::Error::custom)?;
356 Ok(Self { members })
357 }
358}
359
360impl ServerDiscoverCapabilities {
361 pub fn from_registry(
363 registry: &ServerBehaviorRegistry,
364 extensions: BTreeMap<String, Value>,
365 ) -> Result<Self, ServerDiscoveryError> {
366 validate_extensions(&extensions)?;
367
368 let tools_list = registry.contains(ServerBehavior::ToolsList);
369 let resources_list = registry.contains(ServerBehavior::ResourcesList);
370 let prompts_list = registry.contains(ServerBehavior::PromptsList);
371 let resources_subscribe = registry.contains(ServerBehavior::ResourcesSubscribe)
372 && registry.contains(ServerBehavior::SubscriptionsListen)
373 && registry.contains(ServerBehavior::ResourceUpdateDelivery);
374 let mut members = BTreeMap::new();
375
376 if registry.contains(ServerBehavior::LoggingRequestEmitter) {
377 members.insert("logging".to_owned(), Value::Object(serde_json::Map::new()));
378 }
379 if registry.contains(ServerBehavior::CompletionComplete) {
380 members.insert(
381 "completions".to_owned(),
382 Value::Object(serde_json::Map::new()),
383 );
384 }
385 if tools_list {
386 let mut tools = serde_json::Map::new();
387 if registry.contains(ServerBehavior::ToolsListChangedNotification) {
388 tools.insert("listChanged".to_owned(), Value::Bool(true));
389 }
390 members.insert("tools".to_owned(), Value::Object(tools));
391 }
392 if resources_list {
393 let mut resources = serde_json::Map::new();
394 if resources_subscribe {
395 resources.insert("subscribe".to_owned(), Value::Bool(true));
396 }
397 if registry.contains(ServerBehavior::ResourcesListChangedNotification) {
398 resources.insert("listChanged".to_owned(), Value::Bool(true));
399 }
400 members.insert("resources".to_owned(), Value::Object(resources));
401 }
402 if prompts_list {
403 let mut prompts = serde_json::Map::new();
404 if registry.contains(ServerBehavior::PromptsListChangedNotification) {
405 prompts.insert("listChanged".to_owned(), Value::Bool(true));
406 }
407 members.insert("prompts".to_owned(), Value::Object(prompts));
408 }
409 if !extensions.is_empty() {
410 members.insert(
411 "extensions".to_owned(),
412 Value::Object(extensions.into_iter().collect()),
413 );
414 }
415
416 Ok(Self { members })
417 }
418}
419
420fn validate_capability_members(
421 members: &BTreeMap<String, Value>,
422) -> Result<(), ServerDiscoveryError> {
423 for capability in ["logging", "completions"] {
424 if members
425 .get(capability)
426 .is_some_and(|value| !value.is_object())
427 {
428 return Err(ServerDiscoveryError::InvalidCapabilityShape);
429 }
430 }
431
432 for capability in ["tools", "prompts"] {
433 if let Some(Value::Object(settings)) = members.get(capability) {
434 if settings
435 .get("listChanged")
436 .is_some_and(|value| !value.is_boolean())
437 {
438 return Err(ServerDiscoveryError::InvalidCapabilityShape);
439 }
440 } else if members.contains_key(capability) {
441 return Err(ServerDiscoveryError::InvalidCapabilityShape);
442 }
443 }
444
445 if let Some(Value::Object(settings)) = members.get("resources") {
446 for field in ["listChanged", "subscribe"] {
447 if settings.get(field).is_some_and(|value| !value.is_boolean()) {
448 return Err(ServerDiscoveryError::InvalidCapabilityShape);
449 }
450 }
451 } else if members.contains_key("resources") {
452 return Err(ServerDiscoveryError::InvalidCapabilityShape);
453 }
454
455 if let Some(Value::Object(settings)) = members.get("experimental") {
456 if settings.values().any(|value| !value.is_object()) {
457 return Err(ServerDiscoveryError::InvalidCapabilityShape);
458 }
459 } else if members.contains_key("experimental") {
460 return Err(ServerDiscoveryError::InvalidCapabilityShape);
461 }
462
463 if let Some(Value::Object(settings)) = members.get("extensions") {
464 let extensions = settings
465 .iter()
466 .map(|(name, value)| (name.clone(), value.clone()))
467 .collect();
468 validate_extensions(&extensions)?;
469 } else if members.contains_key("extensions") {
470 return Err(ServerDiscoveryError::InvalidCapabilityShape);
471 }
472
473 Ok(())
474}
475
476fn validate_extensions(extensions: &BTreeMap<String, Value>) -> Result<(), ServerDiscoveryError> {
477 if extensions.len() > MAX_DISCOVERY_EXTENSION_SETTINGS {
478 return Err(ServerDiscoveryError::TooManyExtensionSettings {
479 actual: extensions.len(),
480 maximum: MAX_DISCOVERY_EXTENSION_SETTINGS,
481 });
482 }
483
484 for (name, value) in extensions {
485 if name.is_empty() || name.len() > MAX_DISCOVERY_EXTENSION_NAME_BYTES {
486 return Err(ServerDiscoveryError::InvalidExtensionName {
487 length: name.len(),
488 maximum: MAX_DISCOVERY_EXTENSION_NAME_BYTES,
489 });
490 }
491 ExtensionId::parse(name.clone()).map_err(|_| {
492 ServerDiscoveryError::InvalidExtensionName {
493 length: name.len(),
494 maximum: MAX_DISCOVERY_EXTENSION_NAME_BYTES,
495 }
496 })?;
497 if !value.is_object() {
498 return Err(ServerDiscoveryError::InvalidCapabilityShape);
499 }
500 let encoded_len = serde_json::to_vec(value)
501 .map_err(|_| ServerDiscoveryError::ExtensionValueEncoding)?
502 .len();
503 if encoded_len > MAX_DISCOVERY_EXTENSION_VALUE_BYTES {
504 return Err(ServerDiscoveryError::ExtensionValueTooLarge {
505 actual: encoded_len,
506 maximum: MAX_DISCOVERY_EXTENSION_VALUE_BYTES,
507 });
508 }
509 }
510 Ok(())
511}
512
513#[derive(Clone, Debug, Default)]
519struct ServerDiscoverResultMetadata {
520 server_info: Option<ServerInfo>,
521 implementation: Option<Implementation>,
522 extras: BTreeMap<String, Value>,
523}
524
525impl ServerDiscoverResultMetadata {
526 fn server_generated(server_info: ServerInfo) -> Self {
527 Self {
528 server_info: Some(server_info),
529 implementation: None,
530 extras: BTreeMap::new(),
531 }
532 }
533
534 fn with_implementation(mut self, implementation: Implementation) -> Self {
535 self.implementation = Some(implementation);
536 self
537 }
538
539 fn is_empty(&self) -> bool {
540 self.server_info.is_none() && self.implementation.is_none() && self.extras.is_empty()
541 }
542}
543
544impl Serialize for ServerDiscoverResultMetadata {
545 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
546 where
547 S: Serializer,
548 {
549 let mut map = serializer.serialize_map(Some(
550 self.extras.len()
551 + usize::from(self.implementation.is_some() || self.server_info.is_some()),
552 ))?;
553 if let Some(implementation) = &self.implementation {
554 map.serialize_entry(SERVER_DISCOVER_SERVER_INFO_META_KEY, implementation)?;
555 } else if let Some(server_info) = &self.server_info {
556 map.serialize_entry(SERVER_DISCOVER_SERVER_INFO_META_KEY, server_info)?;
557 }
558 for (name, value) in &self.extras {
559 map.serialize_entry(name, value)?;
560 }
561 map.end()
562 }
563}
564
565impl<'de> Deserialize<'de> for ServerDiscoverResultMetadata {
566 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
567 where
568 D: Deserializer<'de>,
569 {
570 let mut members = BTreeMap::<String, Value>::deserialize(deserializer)?;
571 validate_final_result_metadata_entries(&members, FinalResultMetadataRole::Ordinary)
572 .map_err(D::Error::custom)?;
573 let identity = members.remove(SERVER_DISCOVER_SERVER_INFO_META_KEY);
574 let implementation = identity
575 .as_ref()
576 .and_then(|value| serde_json::from_value::<Implementation>(value.clone()).ok())
577 .filter(|implementation| {
578 implementation.title.is_some()
579 || implementation.description.is_some()
580 || implementation.website_url.is_some()
581 || !implementation.icons.is_empty()
582 || !implementation.additional.is_empty()
583 });
584 let server_info = identity
585 .map(serde_json::from_value)
586 .transpose()
587 .map_err(D::Error::custom)?;
588 Ok(Self {
589 server_info,
590 implementation,
591 extras: members,
592 })
593 }
594}
595
596#[derive(Default)]
601struct OptionalServerInstructions(Option<ServerInstructions>);
602
603impl<'de> Deserialize<'de> for OptionalServerInstructions {
604 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
605 where
606 D: Deserializer<'de>,
607 {
608 ServerInstructions::deserialize(deserializer).map(|instructions| Self(Some(instructions)))
609 }
610}
611
612#[derive(Clone, Debug)]
614pub struct ServerDiscoverResult {
615 result_type: String,
616 peer_missing_result_type: bool,
617 supported_versions: Vec<String>,
618 capabilities: ServerDiscoverCapabilities,
619 metadata: ServerDiscoverResultMetadata,
620 instructions: Option<ServerInstructions>,
621 cache_hints: DiscoveryCacheHints,
622 extras: BTreeMap<String, Value>,
623 exact_peer_result: Option<ExactJsonObject>,
629}
630
631impl ServerDiscoverResult {
632 #[must_use]
635 pub fn new(
636 capabilities: ServerDiscoverCapabilities,
637 server_info: ServerInfo,
638 instructions: Option<ServerInstructions>,
639 cache_hints: DiscoveryCacheHints,
640 ) -> Self {
641 Self {
642 result_type: COMPLETE_DISCOVERY_RESULT_TYPE.to_owned(),
643 peer_missing_result_type: false,
644 supported_versions: SERVER_DISCOVER_SUPPORTED_VERSIONS
645 .iter()
646 .map(|version| (*version).to_owned())
647 .collect(),
648 capabilities,
649 metadata: ServerDiscoverResultMetadata::server_generated(server_info),
650 instructions,
651 cache_hints,
652 extras: BTreeMap::new(),
653 exact_peer_result: None,
654 }
655 }
656
657 #[must_use]
662 pub fn with_implementation(mut self, implementation: Implementation) -> Self {
663 self.metadata = self.metadata.with_implementation(implementation);
664 self
665 }
666
667 #[must_use]
669 pub fn implementation(&self) -> Option<&Implementation> {
670 self.metadata.implementation.as_ref()
671 }
672
673 #[must_use]
675 pub fn supported_versions(&self) -> &[String] {
676 &self.supported_versions
677 }
678
679 #[must_use]
685 pub fn result_type(&self) -> &str {
686 &self.result_type
687 }
688
689 #[must_use]
696 pub const fn peer_diagnostic(&self) -> Option<ResultPeerDiagnostic> {
697 if self.peer_missing_result_type {
698 Some(ResultPeerDiagnostic::ModernMissingResultType)
699 } else {
700 None
701 }
702 }
703
704 #[must_use]
706 pub fn capabilities(&self) -> &ServerDiscoverCapabilities {
707 &self.capabilities
708 }
709
710 #[must_use]
712 pub fn server_info(&self) -> Option<&ServerInfo> {
713 self.metadata.server_info.as_ref()
714 }
715
716 #[must_use]
718 pub fn instructions(&self) -> Option<&ServerInstructions> {
719 self.instructions.as_ref()
720 }
721
722 #[must_use]
724 pub const fn cache_hints(&self) -> &DiscoveryCacheHints {
725 &self.cache_hints
726 }
727}
728
729impl Serialize for ServerDiscoverResult {
730 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
731 where
732 S: Serializer,
733 {
734 if let Some(exact_peer_result) = &self.exact_peer_result {
735 let raw = RawValue::from_string(encode_exact_object(exact_peer_result))
736 .map_err(S::Error::custom)?;
737 return raw.serialize(serializer);
738 }
739
740 ServerDiscoverResultCanonical {
741 result_type: &self.result_type,
742 supported_versions: &self.supported_versions,
743 capabilities: &self.capabilities,
744 metadata: &self.metadata,
745 instructions: self.instructions.as_ref(),
746 cache_hints: &self.cache_hints,
747 extras: &self.extras,
748 }
749 .serialize(serializer)
750 }
751}
752
753#[derive(Serialize)]
754#[serde(rename_all = "camelCase")]
755struct ServerDiscoverResultCanonical<'a> {
756 #[serde(rename = "resultType")]
757 result_type: &'a str,
758 #[serde(rename = "supportedVersions")]
759 supported_versions: &'a [String],
760 capabilities: &'a ServerDiscoverCapabilities,
761 #[serde(
762 rename = "_meta",
763 skip_serializing_if = "ServerDiscoverResultMetadata::is_empty"
764 )]
765 metadata: &'a ServerDiscoverResultMetadata,
766 #[serde(skip_serializing_if = "Option::is_none")]
767 instructions: Option<&'a ServerInstructions>,
768 #[serde(flatten)]
769 cache_hints: &'a DiscoveryCacheHints,
770 #[serde(flatten)]
771 extras: &'a BTreeMap<String, Value>,
772}
773
774#[derive(Deserialize)]
775#[serde(rename_all = "camelCase")]
776struct ServerDiscoverResultWire {
777 #[serde(rename = "resultType", default)]
778 result_type: OptionalDiscoveryResultType,
779 #[serde(rename = "supportedVersions")]
780 supported_versions: Vec<String>,
781 capabilities: ServerDiscoverCapabilities,
782 #[serde(rename = "_meta", default)]
783 metadata: ServerDiscoverResultMetadata,
784 #[serde(default)]
785 instructions: OptionalServerInstructions,
786 #[serde(rename = "ttlMs")]
787 ttl_ms: CacheTtl,
788 #[serde(rename = "cacheScope")]
789 cache_scope: DiscoveryCacheScope,
790 #[serde(flatten)]
791 extras: BTreeMap<String, Value>,
792}
793
794#[derive(Default)]
801struct OptionalDiscoveryResultType(Option<String>);
802
803impl<'de> Deserialize<'de> for OptionalDiscoveryResultType {
804 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
805 where
806 D: Deserializer<'de>,
807 {
808 String::deserialize(deserializer).map(|result_type| Self(Some(result_type)))
809 }
810}
811
812impl<'de> Deserialize<'de> for ServerDiscoverResult {
813 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
814 where
815 D: Deserializer<'de>,
816 {
817 let raw = Box::<RawValue>::deserialize(deserializer)?;
818 let exact_peer_result = parse_exact_result_object(raw.get()).map_err(D::Error::custom)?;
819 let wire = serde_json::from_str::<ServerDiscoverResultWire>(raw.get())
820 .map_err(D::Error::custom)?;
821 let peer_missing_result_type = wire.result_type.0.is_none();
822 if wire
823 .result_type
824 .0
825 .as_deref()
826 .is_some_and(|result_type| result_type != COMPLETE_DISCOVERY_RESULT_TYPE)
827 {
828 return Err(D::Error::custom(
829 "server/discover resultType must be `complete`",
830 ));
831 }
832 if wire.extras.keys().any(|name| {
833 DISCOVERY_CONTRADICTORY_RESULT_MEMBERS
834 .iter()
835 .any(|forbidden| name == forbidden)
836 }) {
837 return Err(D::Error::custom(
838 "server/discover result contains a contradictory final result member",
839 ));
840 }
841 Ok(Self {
842 result_type: COMPLETE_DISCOVERY_RESULT_TYPE.to_owned(),
843 peer_missing_result_type,
844 supported_versions: wire.supported_versions,
845 capabilities: wire.capabilities,
846 metadata: wire.metadata,
847 instructions: wire.instructions.0,
848 cache_hints: DiscoveryCacheHints::from_peer_wire(wire.ttl_ms, wire.cache_scope),
849 extras: wire.extras,
850 exact_peer_result: (!peer_missing_result_type).then_some(exact_peer_result),
851 })
852 }
853}
854
855#[derive(Clone, Copy, Debug, Eq, PartialEq)]
857pub enum ServerDiscoveryError {
858 InvalidRequestMetadata,
860 InvalidCapabilityShape,
862 TooManyExtensionSettings {
864 actual: usize,
866 maximum: usize,
868 },
869 InvalidExtensionName {
871 length: usize,
873 maximum: usize,
875 },
876 ExtensionValueTooLarge {
878 actual: usize,
880 maximum: usize,
882 },
883 ExtensionValueEncoding,
885}
886
887impl fmt::Display for ServerDiscoveryError {
888 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
889 match self {
890 Self::InvalidRequestMetadata => write!(
891 formatter,
892 "server/discover requires final protocol version and client capabilities metadata"
893 ),
894 Self::InvalidCapabilityShape => {
895 write!(
896 formatter,
897 "server/discover capability has an invalid schema shape"
898 )
899 }
900 Self::TooManyExtensionSettings { actual, maximum } => {
901 write!(
902 formatter,
903 "{actual} extension settings exceed the maximum {maximum}"
904 )
905 }
906 Self::InvalidExtensionName { length, maximum } => {
907 write!(
908 formatter,
909 "extension name is {length} bytes; maximum is {maximum}"
910 )
911 }
912 Self::ExtensionValueTooLarge { actual, maximum } => write!(
913 formatter,
914 "extension setting is {actual} JSON bytes; maximum is {maximum}"
915 ),
916 Self::ExtensionValueEncoding => {
917 write!(formatter, "extension setting could not be encoded")
918 }
919 }
920 }
921}
922
923impl Error for ServerDiscoveryError {}
924
925#[cfg(test)]
926mod tests {
927 use std::collections::BTreeMap;
928
929 use serde_json::{Value, json};
930
931 use crate::{
932 DiscoveryCacheHints, ResultPeerDiagnostic, SERVER_DISCOVER_METHOD,
933 SERVER_DISCOVER_SERVER_INFO_META_KEY, SERVER_DISCOVER_SUPPORTED_VERSIONS, ServerBehavior,
934 ServerBehaviorRegistry, ServerDiscoverCapabilities, ServerDiscoverRequest,
935 ServerDiscoverResult, ServerDiscoveryError, ServerInfo, ServerInstructions,
936 common_types::Implementation,
937 };
938
939 fn fully_installed_capabilities() -> ServerDiscoverCapabilities {
940 ServerDiscoverCapabilities::from_registry(
941 &ServerBehaviorRegistry::from_behaviors([
942 ServerBehavior::LoggingRequestEmitter,
943 ServerBehavior::CompletionComplete,
944 ServerBehavior::ToolsList,
945 ServerBehavior::ToolsListChangedNotification,
946 ServerBehavior::ResourcesList,
947 ServerBehavior::ResourcesListChangedNotification,
948 ServerBehavior::ResourcesSubscribe,
949 ServerBehavior::SubscriptionsListen,
950 ServerBehavior::ResourceUpdateDelivery,
951 ServerBehavior::PromptsList,
952 ServerBehavior::PromptsListChangedNotification,
953 ]),
954 BTreeMap::from([("io.fastmcp/example".to_owned(), json!({"enabled": true}))]),
955 )
956 .expect("the bounded installed behavior registry is discoverable")
957 }
958
959 #[test]
960 fn srv_02_b_positive() {
961 let result = ServerDiscoverResult::new(
962 fully_installed_capabilities(),
963 ServerInfo {
964 name: "contract-server".to_owned(),
965 version: "1.0.0".to_owned(),
966 },
967 Some(ServerInstructions::new("").expect("empty guidance is present guidance")),
968 DiscoveryCacheHints::private_ttl_ms(60_000),
969 );
970
971 let request = serde_json::to_value(ServerDiscoverRequest::default())
972 .expect("the typed request encodes through the public API");
973 let wire =
974 serde_json::to_value(&result).expect("the typed result encodes through the public API");
975
976 assert_eq!(SERVER_DISCOVER_METHOD, "server/discover");
977 assert_eq!(
978 request,
979 json!({
980 "_meta": {
981 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
982 "io.modelcontextprotocol/clientCapabilities": {},
983 },
984 })
985 );
986 assert_eq!(wire["resultType"], json!("complete"));
987 assert_eq!(
988 wire["supportedVersions"],
989 json!(SERVER_DISCOVER_SUPPORTED_VERSIONS)
990 );
991 assert!(wire.get("protocolVersions").is_none());
992 assert!(wire.get("serverInfo").is_none());
993 assert!(wire.get("cacheHints").is_none());
994 assert_eq!(
995 wire["_meta"]["io.modelcontextprotocol/serverInfo"],
996 json!({"name": "contract-server", "version": "1.0.0"})
997 );
998 assert_eq!(wire["instructions"], json!(""));
999 assert_eq!(wire["ttlMs"], json!(60_000));
1000 assert_eq!(wire["cacheScope"], json!("private"));
1001 assert_eq!(wire["capabilities"]["tools"]["listChanged"], json!(true));
1002 assert_eq!(wire["capabilities"]["resources"]["subscribe"], json!(true));
1003 assert_eq!(
1004 wire["capabilities"]["resources"]["listChanged"],
1005 json!(true)
1006 );
1007 assert_eq!(wire["capabilities"]["prompts"]["listChanged"], json!(true));
1008 assert!(wire["capabilities"].get("subscriptions").is_none());
1009 assert_eq!(
1010 wire["capabilities"]["extensions"]["io.fastmcp/example"]["enabled"],
1011 json!(true)
1012 );
1013
1014 let decoded: ServerDiscoverResult = serde_json::from_value(wire)
1015 .expect("the final server/discover vocabulary decodes deterministically");
1016 assert_eq!(
1017 decoded
1018 .supported_versions()
1019 .iter()
1020 .map(String::as_str)
1021 .collect::<Vec<_>>(),
1022 ["2026-07-28"],
1023 "only the final protocol version is advertised"
1024 );
1025 assert_eq!(
1026 decoded
1027 .server_info()
1028 .map(|server_info| server_info.name.as_str()),
1029 Some("contract-server")
1030 );
1031 assert_eq!(
1032 decoded.instructions().map(ServerInstructions::as_str),
1033 Some("")
1034 );
1035 assert_eq!(
1036 decoded
1037 .cache_hints()
1038 .ttl_ms()
1039 .try_as_millis()
1040 .expect("local TTL fits the runtime domain"),
1041 60_000
1042 );
1043 assert!(!decoded.cache_hints().is_public());
1044 }
1045
1046 #[test]
1047 fn server_discover_ttl_ms_preserves_an_unbounded_wire_integer() {
1048 let admitted = ServerDiscoverResult::new(
1049 fully_installed_capabilities(),
1050 ServerInfo {
1051 name: "contract-server".to_owned(),
1052 version: "1.0.0".to_owned(),
1053 },
1054 None,
1055 DiscoveryCacheHints::private_ttl_ms(u64::MAX),
1056 );
1057 let mut accepted = serde_json::to_value(&admitted).expect("local discovery result encodes");
1058 accepted["ttlMs"] = serde_json::from_str("18446744073709551616")
1059 .expect("the one-over-u64 TTL is valid JSON");
1060
1061 let decoded: ServerDiscoverResult = serde_json::from_value(accepted.clone())
1062 .expect("the unbounded nonnegative discovery TTL is admitted");
1063 assert_eq!(
1064 decoded.cache_hints().ttl_ms().as_str(),
1065 "18446744073709551616"
1066 );
1067 assert_eq!(
1068 decoded.cache_hints().ttl_ms().try_as_millis(),
1069 Err(crate::result::CacheTtlConversionError::RuntimeOutOfRange),
1070 "only the runtime conversion rejects the one-over-u64 TTL"
1071 );
1072 assert_eq!(
1073 serde_json::to_value(&decoded).expect("unbounded discovery TTL re-encodes"),
1074 accepted
1075 );
1076
1077 let mut fractional = accepted;
1078 fractional["ttlMs"] = serde_json::from_str("18446744073709551616.5")
1079 .expect("the fractional mutation is valid JSON");
1080 assert!(
1081 serde_json::from_value::<ServerDiscoverResult>(fractional).is_err(),
1082 "changing only ttlMs from an unbounded integer to a fraction violates the final cache schema"
1083 );
1084 }
1085
1086 #[test]
1087 fn server_discover_retains_exact_admitted_source_for_open_members() {
1088 let source = r#"{"com.example/top":{"second":1.20e+4,"first":0e0},"cacheScope":"private","_meta":{"io.modelcontextprotocol/futureResultMetadata":{"later":1.20e+4,"earlier":0e0},"io.modelcontextprotocol/serverInfo":{"version":"1.0.0","name":"contract-server"}},"capabilities":{"tools":{"listChanged":true}},"ttlMs":0,"supportedVersions":["2026-07-28"],"resultType":"complete"}"#;
1089
1090 let decoded = serde_json::from_str::<ServerDiscoverResult>(source)
1091 .expect("typed final discovery fields admit the exact peer source");
1092 assert_eq!(
1093 decoded
1094 .server_info()
1095 .map(|server_info| (server_info.name.as_str(), server_info.version.as_str())),
1096 Some(("contract-server", "1.0.0")),
1097 "exact retention does not bypass serverInfo validation"
1098 );
1099 assert_eq!(
1100 serde_json::to_string(&decoded).expect("admitted discovery result replays"),
1101 source,
1102 "top-level and nested schema-open member order and number lexemes replay exactly"
1103 );
1104 }
1105
1106 #[test]
1107 fn server_discover_accepts_schema_valid_supported_versions() {
1108 let admitted = ServerDiscoverResult::new(
1109 fully_installed_capabilities(),
1110 ServerInfo {
1111 name: "contract-server".to_owned(),
1112 version: "1.0.0".to_owned(),
1113 },
1114 None,
1115 DiscoveryCacheHints::private_ttl_ms(0),
1116 );
1117 let mut peer_wire: Value =
1118 serde_json::to_value(&admitted).expect("the admitted result encodes");
1119 peer_wire["supportedVersions"] = json!(["2024-11-05", "2026-07-28"]);
1120
1121 let decoded: ServerDiscoverResult = serde_json::from_value(peer_wire)
1122 .expect("the final schema permits any string version advertisement");
1123 assert_eq!(
1124 decoded
1125 .supported_versions()
1126 .iter()
1127 .map(String::as_str)
1128 .collect::<Vec<_>>(),
1129 ["2024-11-05", "2026-07-28"],
1130 "version selection remains a negotiation-layer concern"
1131 );
1132 }
1133
1134 #[test]
1135 fn discovery_extensions_require_final_identifiers_on_peer_and_local_paths() {
1136 let registry = ServerBehaviorRegistry::default();
1137 let valid = BTreeMap::from([("com.example/".to_owned(), json!({}))]);
1138 assert!(ServerDiscoverCapabilities::from_registry(®istry, valid).is_ok());
1139
1140 let invalid_names = [
1141 "com.example", "com.example//name", "org.modelcontextprotocol/x", "1com.example/name", ];
1146 for name in invalid_names {
1147 let extensions = BTreeMap::from([(name.to_owned(), json!({}))]);
1148 assert!(
1149 matches!(
1150 ServerDiscoverCapabilities::from_registry(®istry, extensions),
1151 Err(ServerDiscoveryError::InvalidExtensionName { .. })
1152 ),
1153 "local discovery must reject invalid extension identifier {name:?}"
1154 );
1155 }
1156
1157 let admitted = ServerDiscoverResult::new(
1158 fully_installed_capabilities(),
1159 ServerInfo {
1160 name: "contract-server".to_owned(),
1161 version: "1.0.0".to_owned(),
1162 },
1163 None,
1164 DiscoveryCacheHints::private_ttl_ms(0),
1165 );
1166 let unchanged_before = serde_json::to_value(&admitted).expect("admitted discovery encodes");
1167 for name in invalid_names {
1168 let mut peer_wire = unchanged_before.clone();
1169 let mut extensions = serde_json::Map::new();
1170 extensions.insert(name.to_owned(), json!({}));
1171 peer_wire["capabilities"]["extensions"] = Value::Object(extensions);
1172 assert!(
1173 serde_json::from_value::<ServerDiscoverResult>(peer_wire).is_err(),
1174 "peer discovery must reject invalid extension identifier {name:?}"
1175 );
1176 assert_eq!(
1177 serde_json::to_value(&admitted).expect("admitted discovery remains unchanged"),
1178 unchanged_before,
1179 "rejected peer extension identifiers cannot mutate locally admitted discovery state"
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn server_discover_rejects_non_complete_result_types_and_contradictory_shapes() {
1186 let admitted = ServerDiscoverResult::new(
1187 fully_installed_capabilities(),
1188 ServerInfo {
1189 name: "contract-server".to_owned(),
1190 version: "1.0.0".to_owned(),
1191 },
1192 None,
1193 DiscoveryCacheHints::private_ttl_ms(0),
1194 );
1195 let unchanged_before =
1196 serde_json::to_vec(&admitted).expect("the admitted result has a stable wire image");
1197 let peer_wire: Value =
1198 serde_json::to_value(&admitted).expect("the admitted result encodes");
1199
1200 for result_type in [
1201 json!("input_required"),
1202 json!("task"),
1203 json!("com.example/deferred-discovery"),
1204 Value::Null,
1205 json!(false),
1206 json!({"complete": true}),
1207 ] {
1208 let mut planted = peer_wire.clone();
1209 planted["resultType"] = result_type;
1210 assert!(
1211 serde_json::from_value::<ServerDiscoverResult>(planted).is_err(),
1212 "only complete or absence can establish discovery"
1213 );
1214 }
1215
1216 for (name, value) in [
1217 ("inputRequests", json!({"roots": {"method": "roots/list"}})),
1218 ("requestState", json!("retry-1")),
1219 ("taskId", json!("task-1")),
1220 (
1221 "serverInfo",
1222 json!({"name": "wrong-location", "version": "1.0"}),
1223 ),
1224 ] {
1225 let mut planted = peer_wire.clone();
1226 planted[name] = value;
1227 assert!(
1228 serde_json::from_value::<ServerDiscoverResult>(planted).is_err(),
1229 "complete discovery cannot carry the {name} result branch member"
1230 );
1231 }
1232 assert_eq!(
1233 serde_json::to_vec(&admitted).expect("the admitted result still encodes"),
1234 unchanged_before,
1235 "rejecting a contradictory result cannot mutate locally admitted state"
1236 );
1237 }
1238
1239 #[test]
1240 fn server_discover_matches_ordinary_result_metadata_roles_without_mutating_admission() {
1241 let admitted = ServerDiscoverResult::new(
1242 fully_installed_capabilities(),
1243 ServerInfo {
1244 name: "contract-server".to_owned(),
1245 version: "1.0.0".to_owned(),
1246 },
1247 None,
1248 DiscoveryCacheHints::private_ttl_ms(0),
1249 );
1250 let admitted_wire = serde_json::to_value(&admitted).expect("admitted discovery encodes");
1251 let baseline = serde_json::from_value::<ServerDiscoverResult>(admitted_wire.clone())
1252 .expect("response-only discovery metadata is admitted");
1253 let baseline_wire = serde_json::to_value(&baseline).expect("baseline re-encodes");
1254
1255 for (member, value) in [
1256 (
1257 "io.modelcontextprotocol/protocolVersion",
1258 json!("2026-07-28"),
1259 ),
1260 ("io.modelcontextprotocol/clientCapabilities", json!({})),
1261 (
1262 "io.modelcontextprotocol/clientInfo",
1263 json!({"name": "client", "version": "1"}),
1264 ),
1265 ("io.modelcontextprotocol/logLevel", json!("notice")),
1266 (
1267 "io.modelcontextprotocol/subscriptionId",
1268 json!("subscription-7"),
1269 ),
1270 ] {
1271 let mut planted = admitted_wire.clone();
1272 planted["_meta"][member] = value;
1273 assert!(
1274 serde_json::from_value::<ServerDiscoverResult>(planted).is_err(),
1275 "adding only {member} rejects the ordinary discovery response"
1276 );
1277 }
1278
1279 let mut invalid_server_info = admitted_wire.clone();
1280 invalid_server_info["_meta"][SERVER_DISCOVER_SERVER_INFO_META_KEY] = Value::Null;
1281 assert!(
1282 serde_json::from_value::<ServerDiscoverResult>(invalid_server_info).is_err(),
1283 "only a null reserved serverInfo type rejects discovery"
1284 );
1285
1286 let mut schema_open = admitted_wire.clone();
1287 schema_open["_meta"]["io.modelcontextprotocol/futureResultMetadata"] =
1288 json!({"future": true});
1289 let accepted_open = serde_json::from_value::<ServerDiscoverResult>(schema_open.clone())
1290 .expect("unknown reserved result metadata remains inert and admitted");
1291 assert_eq!(
1292 serde_json::to_value(accepted_open).expect("schema-open discovery re-encodes"),
1293 schema_open,
1294 "direct discovery retains inert reserved metadata"
1295 );
1296
1297 let reaccepted = serde_json::from_value::<ServerDiscoverResult>(admitted_wire)
1298 .expect("rejection does not mutate subsequent discovery admission");
1299 assert_eq!(
1300 serde_json::to_value(reaccepted).expect("reaccepted discovery re-encodes"),
1301 baseline_wire
1302 );
1303 }
1304
1305 #[test]
1306 fn server_discover_missing_result_type_defaults_complete_with_diagnostic() {
1307 let admitted = ServerDiscoverResult::new(
1308 fully_installed_capabilities(),
1309 ServerInfo {
1310 name: "contract-server".to_owned(),
1311 version: "1.0.0".to_owned(),
1312 },
1313 None,
1314 DiscoveryCacheHints::private_ttl_ms(0),
1315 );
1316 let unchanged_before =
1317 serde_json::to_vec(&admitted).expect("the admitted result has a stable wire image");
1318 let mut missing_result_type: Value =
1319 serde_json::to_value(&admitted).expect("the admitted result encodes");
1320 missing_result_type
1321 .as_object_mut()
1322 .expect("the discovery result is an object")
1323 .remove("resultType");
1324
1325 let decoded = serde_json::from_value::<ServerDiscoverResult>(missing_result_type.clone())
1326 .expect("an otherwise-valid omitted discriminator uses the client compatibility rule");
1327 assert_eq!(decoded.result_type(), "complete");
1328 assert_eq!(
1329 decoded.peer_diagnostic(),
1330 Some(ResultPeerDiagnostic::ModernMissingResultType)
1331 );
1332 assert_eq!(
1333 serde_json::to_value(decoded).expect("compatibility discovery re-encodes"),
1334 serde_json::to_value(&admitted).expect("local discovery result re-encodes"),
1335 "peer compatibility input is canonicalized before local emission"
1336 );
1337 assert_eq!(
1338 serde_json::to_vec(&admitted).expect("the admitted result still encodes"),
1339 unchanged_before,
1340 "rejecting the one-field variant cannot mutate locally admitted state"
1341 );
1342 }
1343
1344 #[test]
1345 fn srv_02_b_planted_negative() {
1346 let admitted = ServerDiscoverResult::new(
1347 fully_installed_capabilities(),
1348 ServerInfo {
1349 name: "contract-server".to_owned(),
1350 version: "1.0.0".to_owned(),
1351 },
1352 None,
1353 DiscoveryCacheHints::private_ttl_ms(0),
1354 );
1355 let unchanged_before =
1356 serde_json::to_vec(&admitted).expect("the admitted result has a stable wire image");
1357 let admitted_wire = serde_json::to_value(&admitted).expect("the admitted result encodes");
1358
1359 let mut planted = admitted_wire.clone();
1360 planted["resultType"] = Value::Null;
1361 assert!(
1362 serde_json::from_value::<ServerDiscoverResult>(planted).is_err(),
1363 "explicit null never uses the absence compatibility rule"
1364 );
1365 assert_eq!(
1366 serde_json::to_vec(&admitted).expect("the admitted result still encodes"),
1367 unchanged_before,
1368 "rejecting one-field variants cannot mutate locally admitted state"
1369 );
1370 }
1371
1372 #[test]
1373 fn server_discover_instructions_preserve_presence_and_reject_null() {
1374 let absent = ServerDiscoverResult::new(
1375 fully_installed_capabilities(),
1376 ServerInfo {
1377 name: "contract-server".to_owned(),
1378 version: "1.0.0".to_owned(),
1379 },
1380 None,
1381 DiscoveryCacheHints::private_ttl_ms(0),
1382 );
1383 let absent_wire = serde_json::to_value(&absent).expect("absent instructions encode");
1384 assert!(absent_wire.get("instructions").is_none());
1385
1386 let mut explicit_null = absent_wire.clone();
1387 explicit_null["instructions"] = Value::Null;
1388 assert!(
1389 serde_json::from_value::<ServerDiscoverResult>(explicit_null).is_err(),
1390 "explicit null is not interchangeable with absent instructions"
1391 );
1392 assert_eq!(
1393 serde_json::to_value(&absent).expect("the admitted result remains unchanged"),
1394 absent_wire,
1395 "the rejected instruction value cannot mutate the admitted result"
1396 );
1397 }
1398
1399 #[test]
1400 fn server_discover_retains_implementation_identity_only_when_extras_are_present() {
1401 let bare = ServerDiscoverResult::new(
1402 fully_installed_capabilities(),
1403 ServerInfo {
1404 name: "contract-server".to_owned(),
1405 version: "1.0.0".to_owned(),
1406 },
1407 None,
1408 DiscoveryCacheHints::private_ttl_ms(0),
1409 );
1410 let bare_wire = serde_json::to_value(&bare).expect("bare discovery encodes");
1411 let bare_decoded: ServerDiscoverResult =
1412 serde_json::from_value(bare_wire).expect("bare discovery decodes");
1413 assert!(
1414 bare_decoded.implementation().is_none(),
1415 "name/version-only serverInfo must stay Implementation-absent: {:?}",
1416 bare_decoded.implementation()
1417 );
1418 assert_eq!(
1419 bare_decoded
1420 .server_info()
1421 .map(|info| (info.name.as_str(), info.version.as_str())),
1422 Some(("contract-server", "1.0.0"))
1423 );
1424
1425 let mut implementation = Implementation::try_new("contract-server", "1.0.0")
1426 .expect("the identity name and version are nonempty");
1427 implementation.title = Some("Identity Title".to_owned());
1428 implementation.description = Some("Identity description".to_owned());
1429 implementation.website_url = Some(
1430 crate::common_types::AbsoluteUri::parse("https://example.test/fastmcp")
1431 .expect("the identity website is an absolute URI"),
1432 );
1433 implementation.icons = vec![
1434 crate::common_types::RawIcon::try_new("https://example.test/e2e-icon.png")
1435 .expect("the identity icon source is an absolute URI"),
1436 ];
1437 let identified = bare.with_implementation(implementation);
1438 let identified_wire =
1439 serde_json::to_value(&identified).expect("identified discovery encodes");
1440 let identified_decoded: ServerDiscoverResult =
1441 serde_json::from_value(identified_wire).expect("identified discovery decodes");
1442 let identified_impl = identified_decoded
1443 .implementation()
1444 .expect("title/description/website/icons must retain Implementation");
1445 assert_eq!(identified_impl.title.as_deref(), Some("Identity Title"));
1446 assert_eq!(
1447 identified_impl.description.as_deref(),
1448 Some("Identity description")
1449 );
1450 assert_eq!(
1451 identified_impl.website_url.as_ref().map(|uri| uri.as_str()),
1452 Some("https://example.test/fastmcp")
1453 );
1454 assert_eq!(
1455 identified_impl.icons.first().map(|icon| icon.src.as_str()),
1456 Some("https://example.test/e2e-icon.png")
1457 );
1458 }
1459}