Skip to main content

fastmcp_protocol/
server_discovery.rs

1//! Typed `server/discover` vocabulary for the final MCP discovery surface.
2//!
3//! The registry is deliberately declarative: it records only handlers and
4//! notification delivery paths that the surrounding server has actually
5//! installed. Discovery capabilities are derived from that immutable record,
6//! so a wire claim cannot accidentally advertise an unregistered behavior.
7
8use 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
29/// The exact JSON-RPC method for final server discovery.
30pub const SERVER_DISCOVER_METHOD: &str = "server/discover";
31
32/// The exact protocol-version list advertised by this final-only surface.
33pub const SERVER_DISCOVER_SUPPORTED_VERSIONS: &[&str] = &[FINAL_PROTOCOL_VERSION];
34
35/// Maximum UTF-8 bytes permitted for server-provided discovery instructions.
36pub const MAX_SERVER_INSTRUCTIONS_BYTES: usize = 16 * 1024;
37
38/// Maximum number of enabled extension settings in one discovery result.
39pub const MAX_DISCOVERY_EXTENSION_SETTINGS: usize = 64;
40
41/// Maximum UTF-8 bytes in an enabled extension setting name.
42pub const MAX_DISCOVERY_EXTENSION_NAME_BYTES: usize = 256;
43
44/// Maximum JSON bytes in an enabled extension setting value.
45pub const MAX_DISCOVERY_EXTENSION_VALUE_BYTES: usize = 16 * 1024;
46
47/// Reserved result-metadata key that identifies the responding server.
48pub const SERVER_DISCOVER_SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
49
50/// Typed final `params` for a `server/discover` request.
51///
52/// Final requests always carry the common request metadata. Unknown
53/// method-specific members remain inert and round-trip so a newer peer does
54/// not become undecodable merely by extending this open object.
55#[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    /// Returns the required request metadata without granting its self-reported
85    /// values any authority.
86    #[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/// A server behavior whose installation can be advertised through discovery.
124#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
125pub enum ServerBehavior {
126    /// The deprecated `logging/request` emitter is installed.
127    LoggingRequestEmitter,
128    /// The `completion/complete` dispatch target is installed.
129    CompletionComplete,
130    /// The `tools/list` dispatch target is installed.
131    ToolsList,
132    /// The `notifications/tools/list_changed` producer is installed.
133    ToolsListChangedNotification,
134    /// The `resources/list` dispatch target is installed.
135    ResourcesList,
136    /// The `notifications/resources/list_changed` producer is installed.
137    ResourcesListChangedNotification,
138    /// The `resources/subscribe` dispatch target is installed.
139    ResourcesSubscribe,
140    /// The subscription listener used by resource subscriptions is installed.
141    SubscriptionsListen,
142    /// The resource-update delivery path is installed.
143    ResourceUpdateDelivery,
144    /// The `prompts/list` dispatch target is installed.
145    PromptsList,
146    /// The `notifications/prompts/list_changed` producer is installed.
147    PromptsListChangedNotification,
148}
149
150/// Immutable registry of server behavior actually installed by the runtime.
151#[derive(Clone, Debug, Default, Eq, PartialEq)]
152pub struct ServerBehaviorRegistry {
153    installed: BTreeSet<ServerBehavior>,
154}
155
156impl ServerBehaviorRegistry {
157    /// Creates a registry from the installed behaviors.
158    #[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    /// Returns whether a behavior has been installed.
166    #[must_use]
167    pub fn contains(&self, behavior: ServerBehavior) -> bool {
168        self.installed.contains(&behavior)
169    }
170}
171
172/// A validated server instruction string.
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub struct ServerInstructions(String);
175
176impl ServerInstructions {
177    /// Validates and retains discovery instructions.
178    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    /// Returns the validated instruction text.
190    #[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/// Why a server instruction string was rejected.
216#[derive(Clone, Copy, Debug, Eq, PartialEq)]
217pub enum ServerInstructionError {
218    /// The UTF-8 instruction string exceeded the fixed discovery bound.
219    TooLarge {
220        /// Observed UTF-8 byte length.
221        actual: usize,
222        /// Maximum accepted UTF-8 byte length.
223        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/// A strict cache scope received from or emitted on the discovery wire.
243#[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
263/// The only final `server/discover` discriminator that can establish a
264/// modern session. A missing discriminator keeps the pinned compatibility
265/// path, but no other final result branch is a discovery result.
266const COMPLETE_DISCOVERY_RESULT_TYPE: &str = "complete";
267
268/// Final-result branch members that contradict a complete discovery result.
269///
270/// Discovery retains schema-open extension members, but it must never treat a
271/// continuation, task, or generic result envelope as discovery merely because
272/// it also carries the required discovery fields.
273const 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/// Required final caching hints for a `server/discover` result.
290///
291/// Safe local construction is intentionally limited to the private scope.
292/// A public cache scope is peer provenance admitted only while decoding an
293/// already-received wire result; it is not a general authority grant.
294#[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    /// Creates a server-generated, private cache hint with a nonnegative TTL
304    /// in milliseconds.
305    #[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    /// Returns the lossless cache TTL wire value.
314    #[must_use]
315    pub fn ttl_ms(&self) -> &CacheTtl {
316        &self.ttl_ms
317    }
318
319    /// Returns whether this was an admitted public peer cache hint.
320    #[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/// Typed capability shape derived from an installed behavior registry.
331///
332/// `ServerCapabilities` is deliberately an open object in the final schema.
333/// Retaining its members as JSON preserves both known capability settings and
334/// future peer-defined capabilities without recasting them as local authority.
335#[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    /// Derives discovery capabilities from installed behavior and extensions.
362    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/// Result metadata carried by `server/discover`.
514///
515/// `serverInfo` belongs in the common `_meta` object in final MCP, not in the
516/// method-specific discovery payload. Other admitted metadata is preserved as
517/// inert result metadata instead of being reinterpreted as a capability.
518#[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/// A presence-aware optional instruction field.
597///
598/// Serde's ordinary `Option<T>` accepts explicit `null`; the final discovery
599/// vocabulary permits absence but rejects `null` and every non-string value.
600#[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/// Typed `server/discover` result whose wire vocabulary is fixed to final MCP.
613#[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 source retained after typed discovery admission.
624    ///
625    /// This covers both schema-open top-level siblings and nested `_meta`
626    /// members, whose order and number lexemes are otherwise lost by typed
627    /// `BTreeMap<String, Value>` validation.
628    exact_peer_result: Option<ExactJsonObject>,
629}
630
631impl ServerDiscoverResult {
632    /// Creates a final discovery response with its exact supported-version
633    /// list, server identity in `_meta`, and required cache hints.
634    #[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    /// Replaces discovery `_meta` server identity with a final Implementation.
658    ///
659    /// Exact-2024 initialize still projects name and version only. This richer
660    /// identity is for modern `server/discover`.
661    #[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    /// Returns the final Implementation identity when one was stored.
668    #[must_use]
669    pub fn implementation(&self) -> Option<&Implementation> {
670        self.metadata.implementation.as_ref()
671    }
672
673    /// Returns the protocol versions advertised by this server.
674    #[must_use]
675    pub fn supported_versions(&self) -> &[String] {
676        &self.supported_versions
677    }
678
679    /// Returns the admitted final discovery discriminator.
680    ///
681    /// An absent peer discriminator is normalized to the compatibility default
682    /// `complete`; [`Self::peer_diagnostic`] distinguishes that wire omission
683    /// from an explicitly emitted discriminator.
684    #[must_use]
685    pub fn result_type(&self) -> &str {
686        &self.result_type
687    }
688
689    /// Returns bounded evidence for a final peer whose otherwise-valid
690    /// discovery result omitted its required `resultType` discriminator.
691    ///
692    /// Re-encoding canonicalizes the peer omission to the required
693    /// `resultType: "complete"`, so compatibility evidence cannot cause a
694    /// locally emitted final discovery result to omit its discriminator.
695    #[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    /// Returns the derived capability shape.
705    #[must_use]
706    pub fn capabilities(&self) -> &ServerDiscoverCapabilities {
707        &self.capabilities
708    }
709
710    /// Returns the self-reported server identity when the peer supplied one.
711    #[must_use]
712    pub fn server_info(&self) -> Option<&ServerInfo> {
713        self.metadata.server_info.as_ref()
714    }
715
716    /// Returns optional server guidance without assigning it any authority.
717    #[must_use]
718    pub fn instructions(&self) -> Option<&ServerInstructions> {
719        self.instructions.as_ref()
720    }
721
722    /// Returns the required cache hints attached to this discovery result.
723    #[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/// Presence-aware peer discriminator.
795///
796/// `Option<String>` alone would conflate explicit `null` with absence. This
797/// wrapper is constructed only when the member is present, so `null` and every
798/// non-string JSON value fail `String` deserialization while true absence uses
799/// `Default` and selects the compatibility rule.
800#[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/// Why a server discovery value could not be safely constructed or admitted.
856#[derive(Clone, Copy, Debug, Eq, PartialEq)]
857pub enum ServerDiscoveryError {
858    /// The request did not carry the required final request metadata.
859    InvalidRequestMetadata,
860    /// A known capability field did not use its schema-required object shape.
861    InvalidCapabilityShape,
862    /// The registry attempted to advertise more extension settings than allowed.
863    TooManyExtensionSettings {
864        /// Observed extension setting count.
865        actual: usize,
866        /// Maximum allowed extension setting count.
867        maximum: usize,
868    },
869    /// An extension setting name was empty or exceeded its fixed bound.
870    InvalidExtensionName {
871        /// Observed UTF-8 byte length.
872        length: usize,
873        /// Maximum allowed UTF-8 byte length.
874        maximum: usize,
875    },
876    /// An extension setting value exceeded its exact JSON byte bound.
877    ExtensionValueTooLarge {
878        /// Observed encoded JSON byte length.
879        actual: usize,
880        /// Maximum allowed encoded JSON byte length.
881        maximum: usize,
882    },
883    /// An extension setting value could not be encoded as JSON.
884    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(&registry, valid).is_ok());
1139
1140        let invalid_names = [
1141            "com.example",                // missing mandatory prefix delimiter
1142            "com.example//name",          // more than one delimiter
1143            "org.modelcontextprotocol/x", // reserved namespace misuse
1144            "1com.example/name",          // invalid prefix label
1145        ];
1146        for name in invalid_names {
1147            let extensions = BTreeMap::from([(name.to_owned(), json!({}))]);
1148            assert!(
1149                matches!(
1150                    ServerDiscoverCapabilities::from_registry(&registry, 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}