Skip to main content

fastmcp_server/
builder.rs

1//! Server builder for configuring MCP servers.
2
3use std::collections::HashMap;
4use std::ffi::OsStr;
5use std::fmt;
6use std::sync::{Arc, Mutex};
7
8use fastmcp_console::config::{BannerStyle, ConsoleConfig, TrafficVerbosity};
9use fastmcp_console::stats::ServerStats;
10use fastmcp_core::McpResult;
11use fastmcp_protocol::extensions::ExtensionSettingsCompatibilityResolver;
12#[cfg(feature = "apps")]
13use fastmcp_protocol::extensions::{
14    official_mcp_apps_extension_id, validate_official_mcp_apps_descriptor,
15    validate_official_mcp_apps_server_settings,
16};
17#[cfg(feature = "proxy")]
18use fastmcp_protocol::protocol_policy::ProtocolEra;
19use fastmcp_protocol::protocol_policy::ProtocolPolicy;
20use fastmcp_protocol::{
21    LoggingCapability, PromptsCapability, ResourceTemplate, ResourcesCapability,
22    ServerCapabilities, ServerExtensionDiscovery, ServerInfo, ToolsCapability,
23};
24use log::{Level, LevelFilter};
25
26#[cfg(feature = "tasks")]
27use crate::FinalTaskRuntime;
28use crate::handler::CompletionHandler;
29#[cfg(feature = "proxy")]
30use crate::handler::{
31    FinalProxyPromptHandler, FinalProxyResourceHandler, FinalProxyResourceTemplateHandler,
32};
33use crate::oauth::OAuthHttpRoutes;
34#[cfg(feature = "apps")]
35use crate::providers::McpAppsUiResource;
36#[cfg(all(test, feature = "proxy"))]
37use crate::proxy::ProxyFinalCatalog;
38#[cfg(all(feature = "proxy", feature = "tasks"))]
39use crate::proxy::ProxyFinalTaskRelay;
40#[cfg(feature = "proxy")]
41use crate::proxy::{
42    ProxyCompletionHandler, ProxyPromptCatalog, ProxyPromptHandler, ProxyResourceCatalog,
43    ProxyResourceHandler, ProxyResourceTemplateCatalog, ProxyToolCatalog, ProxyToolHandler,
44    ProxyTypedCatalog,
45};
46#[cfg(feature = "tasks")]
47use crate::tasks::FinalTaskRuntimeConfig;
48#[cfg(all(test, feature = "tasks"))]
49use crate::tasks::SharedTaskManager;
50use crate::{
51    AuthProvider, DuplicateBehavior, ExtensionHandlerRegistry, FinalSubscriptionRegistry,
52    HttpServerConfig, LifespanHooks, LoggingConfig, PromptHandler, ResourceHandler, Router, Server,
53    ServerExtensionConfigurationError, ServerExtensionRuntime, ToolHandler,
54};
55#[cfg(feature = "proxy")]
56use crate::{ProxyCatalog, ProxyClient};
57
58/// Default request timeout in seconds.
59const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
60
61/// Reserved launch setting written by `fastmcp run` for FastMCP server targets.
62const FASTMCP_PROTOCOL_POLICY_ENV: &str = "FASTMCP_PROTOCOL_POLICY";
63
64/// The selected launch policy is malformed, not valid Unicode, or unavailable
65/// in the compiled server feature set.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ServerLaunchPolicyError {
68    /// The reserved launch setting was present but was not valid Unicode.
69    NonUnicode,
70    /// The reserved launch setting was not one of the exact supported values.
71    InvalidValue,
72    /// The selected policy requires a server feature that is not compiled in.
73    FeatureUnavailable,
74}
75
76impl fmt::Display for ServerLaunchPolicyError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::NonUnicode => write!(
80                formatter,
81                "{FASTMCP_PROTOCOL_POLICY_ENV} must be valid Unicode"
82            ),
83            Self::InvalidValue => write!(
84                formatter,
85                "{FASTMCP_PROTOCOL_POLICY_ENV} must be auto, modern-only, or legacy-only"
86            ),
87            Self::FeatureUnavailable => write!(
88                formatter,
89                "{FASTMCP_PROTOCOL_POLICY_ENV}=auto or legacy-only requires the legacy-2024-11-05 feature"
90            ),
91        }
92    }
93}
94
95impl std::error::Error for ServerLaunchPolicyError {}
96
97fn protocol_policy_from_server_launch_value(
98    value: Option<&OsStr>,
99) -> Result<Option<ProtocolPolicy>, ServerLaunchPolicyError> {
100    match value {
101        None => Ok(None),
102        Some(value) => match value.to_str() {
103            Some("auto") => Ok(Some(ProtocolPolicy::Auto)),
104            Some("modern-only") => Ok(Some(ProtocolPolicy::ModernOnly)),
105            Some("legacy-only") => Ok(Some(ProtocolPolicy::LegacyOnly)),
106            Some(_) => Err(ServerLaunchPolicyError::InvalidValue),
107            None => Err(ServerLaunchPolicyError::NonUnicode),
108        },
109    }
110}
111
112fn protocol_policy_from_server_launch_environment()
113-> Result<Option<ProtocolPolicy>, ServerLaunchPolicyError> {
114    protocol_policy_from_server_launch_value(
115        std::env::var_os(FASTMCP_PROTOCOL_POLICY_ENV).as_deref(),
116    )
117}
118
119const fn legacy_protocol_is_available() -> bool {
120    cfg!(feature = "legacy-2024-11-05")
121}
122
123fn resolve_protocol_policy(
124    launch_protocol_policy: Option<ProtocolPolicy>,
125    legacy_protocol_available: bool,
126) -> Result<ProtocolPolicy, ServerLaunchPolicyError> {
127    let protocol_policy = launch_protocol_policy.unwrap_or(if legacy_protocol_available {
128        ProtocolPolicy::Auto
129    } else {
130        ProtocolPolicy::ModernOnly
131    });
132
133    if !legacy_protocol_available && !matches!(protocol_policy, ProtocolPolicy::ModernOnly) {
134        return Err(ServerLaunchPolicyError::FeatureUnavailable);
135    }
136
137    Ok(protocol_policy)
138}
139
140/// Builder for configuring an MCP server.
141pub struct ServerBuilder {
142    info: ServerInfo,
143    capabilities: ServerCapabilities,
144    router: Router,
145    instructions: Option<String>,
146    /// Optional modern discovery title. Exact-2024 initialize stays name/version.
147    title: Option<String>,
148    /// Optional modern discovery description.
149    description: Option<String>,
150    /// Optional modern discovery website identity.
151    website_url: Option<String>,
152    /// Optional modern discovery icons.
153    icons: Vec<fastmcp_protocol::common_types::RawIcon>,
154    /// Request timeout in seconds (0 = no timeout).
155    request_timeout_secs: u64,
156    /// Whether to enable statistics collection.
157    stats_enabled: bool,
158    /// Whether to mask internal error details in responses.
159    mask_error_details: bool,
160    /// Logging configuration.
161    logging: LoggingConfig,
162    /// Console configuration for rich output.
163    console_config: ConsoleConfig,
164    /// Lifecycle hooks for startup/shutdown.
165    lifespan: LifespanHooks,
166    /// Optional authentication provider.
167    auth_provider: Option<Arc<dyn AuthProvider>>,
168    /// Registered middleware.
169    middleware: Vec<Box<dyn crate::Middleware>>,
170    /// Test-only legacy task manager. Production builds have no task-manager
171    /// field or builder edge.
172    #[cfg(all(test, feature = "tasks"))]
173    task_manager: Option<SharedTaskManager>,
174    /// Behavior when registering duplicate component names.
175    on_duplicate: DuplicateBehavior,
176    /// Whether to use strict input validation (reject extra properties).
177    strict_input_validation: bool,
178    /// Per-connection ceiling for concurrent server-to-client requests.
179    max_bidirectional_requests_per_connection: usize,
180    /// Immutable protocol-era admission policy for live stdio/runtime connections.
181    protocol_policy: ProtocolPolicy,
182    /// Reserved policy selected before construction by a launch setting or a
183    /// sealed embedding component.
184    launch_protocol_policy: Option<ProtocolPolicy>,
185    /// Immutable configuration for the live dual-era HTTP endpoint.
186    http_config: HttpServerConfig,
187    /// Optional immutable OAuth-only public HTTP routes.
188    oauth_http_routes: Option<OAuthHttpRoutes>,
189    /// Installed extension handlers and current server discovery settings.
190    extension_runtime: Option<ServerExtensionRuntime>,
191    /// Application-owned state for the configured final Tasks extension.
192    #[cfg(feature = "tasks")]
193    final_task_runtime: Option<FinalTaskRuntime>,
194    /// One route-bound upstream final Tasks relay. The official Task methods
195    /// cannot disambiguate two independent upstream task-ID namespaces.
196    #[cfg(all(feature = "proxy", feature = "tasks"))]
197    final_task_relay: Option<Arc<ProxyFinalTaskRelay>>,
198}
199
200impl ServerBuilder {
201    /// Creates a new server builder.
202    ///
203    /// Statistics collection is enabled by default. Use [`without_stats`](Self::without_stats)
204    /// to disable it for performance-critical scenarios.
205    ///
206    /// Console configuration defaults to environment-based settings. Use
207    /// [`with_console_config`](Self::with_console_config) for programmatic control.
208    #[must_use]
209    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
210        Self::try_new(name, version).unwrap_or_else(|error| {
211            panic!("ServerBuilder::new rejected launch configuration: {error}")
212        })
213    }
214
215    /// Creates a new server builder after validating the reserved launch policy.
216    ///
217    /// This is the typed construction boundary for applications that need to
218    /// report a malformed or unavailable launch policy instead of panicking.
219    pub fn try_new(
220        name: impl Into<String>,
221        version: impl Into<String>,
222    ) -> Result<Self, ServerLaunchPolicyError> {
223        Self::from_launch_protocol_policy(
224            name,
225            version,
226            protocol_policy_from_server_launch_environment(),
227        )
228    }
229
230    /// Creates a builder whose protocol policy is fixed by the embedding
231    /// component rather than the process launch environment.
232    ///
233    /// The selected policy is validated against the compiled feature set, but
234    /// this constructor deliberately does not read `FASTMCP_PROTOCOL_POLICY`.
235    /// It also reserves the selected policy, so a later
236    /// [`protocol_policy`](Self::protocol_policy) call validates its argument
237    /// without changing the fixed selection. This is intended for sealed
238    /// component facades that expose only one protocol era.
239    pub fn try_new_with_fixed_protocol_policy(
240        name: impl Into<String>,
241        version: impl Into<String>,
242        policy: ProtocolPolicy,
243    ) -> Result<Self, ServerLaunchPolicyError> {
244        let policy = resolve_protocol_policy(Some(policy), legacy_protocol_is_available())?;
245        Ok(Self::with_protocol_policy(
246            name,
247            version,
248            policy,
249            Some(policy),
250        ))
251    }
252
253    pub(crate) fn from_launch_protocol_policy(
254        name: impl Into<String>,
255        version: impl Into<String>,
256        launch_protocol_policy: Result<Option<ProtocolPolicy>, ServerLaunchPolicyError>,
257    ) -> Result<Self, ServerLaunchPolicyError> {
258        let launch_protocol_policy = launch_protocol_policy?;
259        let protocol_policy =
260            resolve_protocol_policy(launch_protocol_policy, legacy_protocol_is_available())?;
261        Ok(Self::with_protocol_policy(
262            name,
263            version,
264            protocol_policy,
265            launch_protocol_policy,
266        ))
267    }
268
269    fn with_protocol_policy(
270        name: impl Into<String>,
271        version: impl Into<String>,
272        protocol_policy: ProtocolPolicy,
273        launch_protocol_policy: Option<ProtocolPolicy>,
274    ) -> Self {
275        let console_config = ConsoleConfig::from_env();
276        let logging = LoggingConfig::from(&console_config);
277        Self {
278            info: ServerInfo {
279                name: name.into(),
280                version: version.into(),
281            },
282            capabilities: ServerCapabilities {
283                logging: Some(LoggingCapability::default()),
284                ..ServerCapabilities::default()
285            },
286            router: Router::new(),
287            instructions: None,
288            title: None,
289            description: None,
290            website_url: None,
291            icons: Vec::new(),
292            request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS,
293            stats_enabled: true,
294            mask_error_details: false, // Disabled by default for development
295            logging,
296            console_config,
297            lifespan: LifespanHooks::default(),
298            auth_provider: None,
299            middleware: Vec::new(),
300            #[cfg(all(test, feature = "tasks"))]
301            task_manager: None,
302            on_duplicate: DuplicateBehavior::default(),
303            strict_input_validation: false,
304            max_bidirectional_requests_per_connection:
305                crate::bidirectional::DEFAULT_MAX_IN_FLIGHT_REQUESTS,
306            protocol_policy,
307            launch_protocol_policy,
308            http_config: HttpServerConfig::default(),
309            oauth_http_routes: None,
310            extension_runtime: None,
311            #[cfg(feature = "tasks")]
312            final_task_runtime: None,
313            #[cfg(all(feature = "proxy", feature = "tasks"))]
314            final_task_relay: None,
315        }
316    }
317
318    /// Sets the behavior when registering duplicate component names.
319    ///
320    /// Controls what happens when a tool, resource, resource template, prompt,
321    /// or mounted component is registered with an identifier that already
322    /// exists:
323    ///
324    /// - [`DuplicateBehavior::Error`]: Reject the conflicting registration,
325    ///   log the error, and continue constructing the builder
326    /// - [`DuplicateBehavior::Warn`]: Log warning, keep original (default)
327    /// - [`DuplicateBehavior::Replace`]: Replace with new component
328    /// - [`DuplicateBehavior::Ignore`]: Silently keep original
329    ///
330    /// # Example
331    ///
332    /// ```ignore
333    /// Server::new("demo", "1.0")
334    ///     .on_duplicate(DuplicateBehavior::Error)  // Strict mode
335    ///     .tool(handler1)
336    ///     .tool(handler2)  // Rejected and logged if the name conflicts
337    ///     .build();
338    /// ```
339    #[must_use]
340    pub fn on_duplicate(mut self, behavior: DuplicateBehavior) -> Self {
341        self.on_duplicate = behavior;
342        self
343    }
344
345    /// Sets an authentication provider.
346    #[must_use]
347    pub fn auth_provider<P: AuthProvider + 'static>(mut self, provider: P) -> Self {
348        self.auth_provider = Some(Arc::new(provider));
349        self
350    }
351
352    /// Disables statistics collection.
353    ///
354    /// Use this for performance-critical scenarios where the overhead
355    /// of atomic operations for stats tracking is undesirable.
356    /// The overhead is minimal (typically nanoseconds per request),
357    /// so this is rarely needed.
358    #[must_use]
359    pub fn without_stats(mut self) -> Self {
360        self.stats_enabled = false;
361        self
362    }
363
364    /// Sets the request timeout in seconds.
365    ///
366    /// Set to 0 to omit the server-owned ceiling. Ambient/request and handler
367    /// deadlines are still composed into admission checks, cooperative
368    /// checkpoints, and late-result rejection; this setting cannot relax them.
369    /// A deadline does not preempt blocking synchronous code or imply that
370    /// descendant work has been cancelled and drained. Default is 30 seconds.
371    #[must_use]
372    pub fn request_timeout(mut self, secs: u64) -> Self {
373        self.request_timeout_secs = secs;
374        self
375    }
376
377    /// Sets the maximum number of in-flight server-to-client requests for one
378    /// transport connection.
379    ///
380    /// # Errors
381    ///
382    /// Returns `InvalidParams` when `max` is zero or exceeds the hard safety
383    /// limit enforced by the bidirectional request tracker.
384    pub fn max_bidirectional_requests_per_connection(mut self, max: usize) -> McpResult<Self> {
385        crate::bidirectional::PendingRequests::validate_max_in_flight(max)?;
386        self.max_bidirectional_requests_per_connection = max;
387        Ok(self)
388    }
389
390    /// Sets the pagination page size for list methods.
391    ///
392    /// When set, list methods will return up to `page_size` items and provide an
393    /// opaque `nextCursor` for retrieving the next page. When not set (default),
394    /// list methods return all items in a single response.
395    #[must_use]
396    pub fn list_page_size(mut self, page_size: usize) -> Self {
397        self.router.set_list_page_size(Some(page_size));
398        self
399    }
400
401    /// Enables or disables error detail masking.
402    ///
403    /// When enabled, internal error details are hidden from client responses:
404    /// - Stack traces removed
405    /// - File paths sanitized
406    /// - Internal state not exposed
407    /// - Generic "Internal server error" message returned
408    ///
409    /// Client errors (invalid request, method not found, etc.) are preserved
410    /// since they don't contain sensitive internal details.
411    ///
412    /// Default is `false` (disabled) for development convenience.
413    ///
414    /// # Example
415    ///
416    /// ```ignore
417    /// let server = Server::new("api", "1.0")
418    ///     .mask_error_details(true)  // Always mask in production
419    ///     .build();
420    /// ```
421    #[must_use]
422    pub fn mask_error_details(mut self, enabled: bool) -> Self {
423        self.mask_error_details = enabled;
424        self
425    }
426
427    /// Automatically masks error details based on environment.
428    ///
429    /// Masking is enabled when:
430    /// - `FASTMCP_ENV` is set to "production"
431    /// - `FASTMCP_MASK_ERRORS` is set to "true" or "1"
432    /// - The build is a release build (`cfg!(not(debug_assertions))`)
433    ///
434    /// Masking is explicitly disabled when:
435    /// - `FASTMCP_MASK_ERRORS` is set to "false" or "0"
436    ///
437    /// # Example
438    ///
439    /// ```ignore
440    /// let server = Server::new("api", "1.0")
441    ///     .auto_mask_errors()
442    ///     .build();
443    /// ```
444    #[must_use]
445    pub fn auto_mask_errors(mut self) -> Self {
446        // Check for explicit override first
447        if let Ok(val) = std::env::var("FASTMCP_MASK_ERRORS") {
448            match val.to_lowercase().as_str() {
449                "true" | "1" | "yes" => {
450                    self.mask_error_details = true;
451                    return self;
452                }
453                "false" | "0" | "no" => {
454                    self.mask_error_details = false;
455                    return self;
456                }
457                _ => {} // Fall through to other checks
458            }
459        }
460
461        // Check for production environment
462        if let Ok(env) = std::env::var("FASTMCP_ENV") {
463            if env.to_lowercase() == "production" {
464                self.mask_error_details = true;
465                return self;
466            }
467        }
468
469        // Default: mask in release builds, don't mask in debug builds
470        self.mask_error_details = cfg!(not(debug_assertions));
471        self
472    }
473
474    /// Returns whether error masking is enabled.
475    #[must_use]
476    pub fn is_error_masking_enabled(&self) -> bool {
477        self.mask_error_details
478    }
479
480    /// Enables or disables strict input validation.
481    ///
482    /// When enabled, tool input validation will reject any properties not
483    /// explicitly defined in the tool's input schema (enforces `additionalProperties: false`).
484    ///
485    /// When disabled (default), extra properties are allowed unless the schema
486    /// explicitly sets `additionalProperties: false`.
487    ///
488    /// # Example
489    ///
490    /// ```ignore
491    /// let server = Server::new("api", "1.0")
492    ///     .strict_input_validation(true)  // Reject unknown properties
493    ///     .build();
494    /// ```
495    #[must_use]
496    pub fn strict_input_validation(mut self, enabled: bool) -> Self {
497        self.strict_input_validation = enabled;
498        self
499    }
500
501    /// Returns whether strict input validation is enabled.
502    #[must_use]
503    pub fn is_strict_input_validation_enabled(&self) -> bool {
504        self.strict_input_validation
505    }
506
507    /// Selects the immutable MCP protocol-era policy for live stdio and runtime connections.
508    ///
509    /// With the exact legacy adapter enabled, the default [`ProtocolPolicy::Auto`] classifies the
510    /// first accepted opening frame and then pins that connection to its selected era. Without
511    /// that adapter, construction defaults to [`ProtocolPolicy::ModernOnly`]. `ModernOnly` and
512    /// `LegacyOnly` reject an opening frame from the other exact supported era before it can enter
513    /// request dispatch. In a no-legacy production build, `Auto` and `LegacyOnly` return
514    /// [`ServerLaunchPolicyError::FeatureUnavailable`] before either can be stored.
515    pub fn protocol_policy(
516        mut self,
517        policy: ProtocolPolicy,
518    ) -> Result<Self, ServerLaunchPolicyError> {
519        self.try_set_protocol_policy(policy)?;
520        Ok(self)
521    }
522
523    /// Attempts to select the immutable MCP protocol-era policy without
524    /// consuming the builder.
525    ///
526    /// A policy unavailable in the compiled feature set is rejected before
527    /// this builder is changed. A reserved launch or component policy still
528    /// takes precedence over an explicit builder selection.
529    pub fn try_set_protocol_policy(
530        &mut self,
531        policy: ProtocolPolicy,
532    ) -> Result<(), ServerLaunchPolicyError> {
533        resolve_protocol_policy(Some(policy), legacy_protocol_is_available())?;
534        if self.launch_protocol_policy.is_none() {
535            self.protocol_policy = policy;
536        }
537        Ok(())
538    }
539
540    /// Installs the server's modern-only extension handlers and discovery settings.
541    ///
542    /// Descriptor registration remains mutable only until [`Self::build`]. The
543    /// builder validates the advertised identifiers immediately, then freezes
544    /// the handler and descriptor registries together while building the
545    /// immutable [`Server`]. Exact MCP 2024-11-05 remains outside this path.
546    pub fn extension_registry<R>(
547        mut self,
548        handlers: ExtensionHandlerRegistry,
549        server_discovery: ServerExtensionDiscovery,
550        resolver: R,
551    ) -> Result<Self, ServerExtensionConfigurationError>
552    where
553        R: ExtensionSettingsCompatibilityResolver + Send + 'static,
554    {
555        if self.extension_runtime.is_some() {
556            return Err(ServerExtensionConfigurationError::AlreadyInstalled);
557        }
558        #[cfg(feature = "apps")]
559        if let Some(settings) = server_discovery
560            .extensions
561            .get(&official_mcp_apps_extension_id())
562        {
563            validate_official_mcp_apps_server_settings(settings)
564                .map_err(ServerExtensionConfigurationError::Registry)?;
565        }
566        #[cfg(feature = "tasks")]
567        let mut extension_runtime =
568            ServerExtensionRuntime::new(handlers, server_discovery, resolver)?;
569        #[cfg(not(feature = "tasks"))]
570        let extension_runtime = ServerExtensionRuntime::new(handlers, server_discovery, resolver)?;
571        #[cfg(feature = "tasks")]
572        if let Some(task_runtime) = self.final_task_runtime.as_ref() {
573            extension_runtime.install_final_tasks(task_runtime)?;
574        }
575        #[cfg(all(feature = "proxy", feature = "tasks"))]
576        if self.final_task_runtime.is_none() {
577            if let Some(task_relay) = self.final_task_relay.as_ref() {
578                extension_runtime.install_proxy_final_tasks(Arc::clone(task_relay))?;
579            }
580        }
581        self.extension_runtime = Some(extension_runtime);
582        Ok(self)
583    }
584
585    /// Installs the official MCP Apps descriptor and empty server discovery marker.
586    ///
587    /// Apps owns no client-to-server JSON-RPC method on this surface. This
588    /// opt-in therefore configures bilateral capability negotiation and
589    /// `server/discover` metadata only. It composes in either order with
590    /// [`Self::final_tasks`], preserving an Apps inactive disposition when a
591    /// client does not advertise the Apps HTML MIME type.
592    #[cfg(feature = "apps")]
593    pub fn mcp_apps(mut self) -> Result<Self, ServerExtensionConfigurationError> {
594        if let Some(extension_runtime) = self.extension_runtime.as_mut() {
595            extension_runtime.install_official_mcp_apps()?;
596        } else {
597            let mut extension_runtime = ServerExtensionRuntime::with_official_mcp_apps()?;
598            #[cfg(feature = "tasks")]
599            if let Some(task_runtime) = self.final_task_runtime.as_ref() {
600                extension_runtime.install_final_tasks(task_runtime)?;
601            }
602            #[cfg(all(feature = "proxy", feature = "tasks"))]
603            if self.final_task_runtime.is_none() {
604                if let Some(task_relay) = self.final_task_relay.as_ref() {
605                    extension_runtime.install_proxy_final_tasks(Arc::clone(task_relay))?;
606                }
607            }
608            self.extension_runtime = Some(extension_runtime);
609        }
610        Ok(self)
611    }
612
613    /// Returns whether this destination owns an active, exact official Apps
614    /// capability configuration. Merely advertising the official identifier
615    /// is insufficient: the frozen descriptor, local enablement, and exact
616    /// empty server settings must all be present.
617    #[cfg(feature = "apps")]
618    fn has_active_official_mcp_apps(&self) -> bool {
619        let apps_id = official_mcp_apps_extension_id();
620        self.extension_runtime.as_ref().is_some_and(|runtime| {
621            runtime.local_enablement.is_enabled(&apps_id)
622                && runtime
623                    .handlers
624                    .descriptor_registry()
625                    .descriptor(&apps_id)
626                    .is_some_and(|descriptor| {
627                        validate_official_mcp_apps_descriptor(descriptor).is_ok()
628                    })
629                && runtime
630                    .server_discovery
631                    .extensions
632                    .get(&apps_id)
633                    .is_some_and(|settings| {
634                        validate_official_mcp_apps_server_settings(settings).is_ok()
635                    })
636        })
637    }
638
639    /// Registers one final-only `ui://` HTML document for a negotiated MCP Apps View.
640    ///
641    /// This resource is deliberately absent from exact MCP 2024-11-05
642    /// discovery and reads. Call [`Self::mcp_apps`] first so the server also
643    /// advertises the matching modern bilateral extension capability.
644    #[cfg(feature = "apps")]
645    pub fn mcp_apps_ui_resource(mut self, resource: McpAppsUiResource) -> McpResult<Self> {
646        if !self.has_active_official_mcp_apps() {
647            return Err(fastmcp_core::McpError::invalid_request(
648                "MCP Apps UI resources require ServerBuilder::mcp_apps first",
649            ));
650        }
651        self.router
652            .add_mcp_apps_ui_resource_with_behavior(resource, self.on_duplicate)?;
653        self.advertise_legacy_resource_subscriptions();
654        Ok(self)
655    }
656
657    /// Registers a tool with final-only MCP Apps metadata.
658    ///
659    /// Register the linked [`McpAppsUiResource`] first. The router validates
660    /// the tool's typed `_meta.ui.resourceUri` against that exact final Apps
661    /// HTML resource before either tool catalog is changed. Unlike
662    /// [`Self::tool`], this Apps-specific entry point returns the registration
663    /// failure directly.
664    #[cfg(feature = "apps")]
665    pub fn mcp_apps_tool<H: ToolHandler + 'static>(mut self, handler: H) -> McpResult<Self> {
666        if !self.has_active_official_mcp_apps() {
667            return Err(fastmcp_core::McpError::invalid_request(
668                "MCP Apps tools require ServerBuilder::mcp_apps first",
669            ));
670        }
671        self.router
672            .add_mcp_apps_tool_with_behavior(handler, self.on_duplicate)?;
673        self.advertise_legacy_tools_list_changed();
674        Ok(self)
675    }
676
677    /// Installs the official final Tasks extension around application-owned state.
678    ///
679    /// The supplied runtime owns neither an executor nor a task region. Its
680    /// store and notification emitter remain application-owned, while a
681    /// caller-owned structured supervisor advances worker state through the
682    /// [`FinalTaskRuntime`] retained on the built [`Server`]. This builder
683    /// installs the official descriptor, its three client-to-server request
684    /// handlers, and its `notifications/tasks` delivery path together.
685    #[cfg(feature = "tasks")]
686    pub fn final_tasks(
687        mut self,
688        task_runtime: FinalTaskRuntime,
689    ) -> Result<Self, ServerExtensionConfigurationError> {
690        if self.final_task_runtime.is_some() || {
691            #[cfg(feature = "proxy")]
692            {
693                self.final_task_relay.is_some()
694            }
695            #[cfg(not(feature = "proxy"))]
696            {
697                false
698            }
699        } {
700            return Err(ServerExtensionConfigurationError::FinalTasksAlreadyInstalled);
701        }
702        if let Some(extension_runtime) = self.extension_runtime.as_mut() {
703            extension_runtime.install_final_tasks(&task_runtime)?;
704        }
705        self.final_task_runtime = Some(task_runtime);
706        Ok(self)
707    }
708
709    /// Installs a process-local official Tasks runtime when the builder has
710    /// no Tasks owner yet.
711    ///
712    /// A caller-supplied [`Self::final_tasks`] runtime, a proxy Tasks relay,
713    /// or the quarantined [`Self::with_task_manager`] path all suppress this
714    /// default. If an existing extension registry already owns the official
715    /// Tasks methods, the default is left uninstalled rather than panicking.
716    #[cfg(feature = "tasks")]
717    fn install_default_in_memory_final_tasks(&mut self) {
718        if self.final_task_runtime.is_some() {
719            return;
720        }
721        #[cfg(all(test, feature = "tasks"))]
722        if self.task_manager.is_some() {
723            return;
724        }
725        #[cfg(feature = "proxy")]
726        if self.final_task_relay.is_some() {
727            return;
728        }
729        let runtime = FinalTaskRuntime::in_memory(
730            FinalTaskRuntimeConfig::new(60_000, Some(5_000))
731                .expect("default in-memory Tasks timing policy is valid"),
732            Arc::new(|_| {}),
733        );
734        if let Some(extension_runtime) = self.extension_runtime.as_mut() {
735            if extension_runtime.install_final_tasks(&runtime).is_err() {
736                return;
737            }
738        }
739        self.final_task_runtime = Some(runtime);
740    }
741
742    /// Sets configuration for the live dual-era HTTP endpoint.
743    ///
744    /// # Example
745    ///
746    /// ```ignore
747    /// use fastmcp_server::HttpServerConfig;
748    ///
749    /// Server::new("demo", "1.0")
750    ///     .http_config(HttpServerConfig::new().mcp_path("/api/mcp").max_connections(128))
751    ///     .build();
752    /// ```
753    #[must_use]
754    pub fn http_config(mut self, config: HttpServerConfig) -> Self {
755        self.http_config = config;
756        self
757    }
758
759    /// Installs immutable OAuth authorization, token, and revocation routes
760    /// into the native HTTP listener.
761    ///
762    /// The routes retain an explicit public HTTPS endpoint base and are
763    /// admitted before MCP request conversion. OIDC/JWKS/ID-token routes are
764    /// deliberately not installed here.
765    #[must_use]
766    pub fn oauth_http_routes(mut self, routes: OAuthHttpRoutes) -> Self {
767        self.oauth_http_routes = Some(routes);
768        self
769    }
770
771    /// Builds a live modern Streamable HTTP endpoint.
772    #[cfg(not(any(feature = "legacy-2024-11-05", test)))]
773    pub fn build_http_endpoint(
774        self,
775    ) -> Result<crate::ServerHttpEndpoint, crate::ServerHttpEndpointError> {
776        self.try_build()
777            .map_err(|error| {
778                crate::ServerHttpEndpointError::InvalidConfiguration(error.to_string())
779            })?
780            .into_http_endpoint()
781    }
782
783    /// Builds a live dual-era HTTP endpoint with an exact legacy SSE origin.
784    ///
785    /// The modern route remains at [`HttpServerConfig::mcp_path`], while the
786    /// exact MCP 2024-11-05 SSE route advertises `legacy_origin` plus the
787    /// configured legacy message path.
788    #[cfg(any(feature = "legacy-2024-11-05", test))]
789    pub fn build_http_endpoint(
790        self,
791        legacy_origin: impl Into<String>,
792    ) -> Result<crate::ServerHttpEndpoint, crate::ServerHttpEndpointError> {
793        self.try_build()
794            .map_err(|error| {
795                crate::ServerHttpEndpointError::InvalidConfiguration(error.to_string())
796            })?
797            .into_http_endpoint(legacy_origin)
798    }
799
800    /// Registers a middleware.
801    #[must_use]
802    pub fn middleware<M: crate::Middleware + 'static>(mut self, middleware: M) -> Self {
803        self.middleware.push(Box::new(middleware));
804        self
805    }
806
807    /// Registers a tool handler.
808    ///
809    /// Duplicate handling is controlled by [`on_duplicate`](Self::on_duplicate).
810    /// If [`DuplicateBehavior::Error`] is set and a duplicate is found,
811    /// an error will be logged and the tool will not be registered.
812    #[must_use]
813    pub fn tool<H: ToolHandler + 'static>(mut self, handler: H) -> Self {
814        if let Err(e) = self
815            .router
816            .add_tool_with_behavior(handler, self.on_duplicate)
817        {
818            log::error!(
819                target: "fastmcp_rust::builder",
820                "Failed to register tool; code={:?}",
821                e.code
822            );
823        } else {
824            self.advertise_legacy_tools_list_changed();
825        }
826        self
827    }
828
829    /// Registers an intentionally exact MCP 2024-11-05-only tool handler.
830    ///
831    /// This does not depend on builder call order or the connection protocol
832    /// policy. The tool is available through exact legacy list/call routes and
833    /// omitted from all MCP 2026-07-28 catalogs and dispatch. Use [`Self::tool`]
834    /// for ordinary dual-era registration; it never falls back to this path
835    /// when final schema admission fails.
836    #[must_use]
837    pub fn legacy_tool<H: ToolHandler + 'static>(mut self, handler: H) -> Self {
838        if let Err(error) = self
839            .router
840            .add_legacy_tool_with_behavior(handler, self.on_duplicate)
841        {
842            log::error!(
843                target: "fastmcp_rust::builder",
844                "Failed to register exact-2024-only tool; code={:?}",
845                error.code
846            );
847        } else {
848            self.advertise_legacy_tools_list_changed();
849        }
850        self
851    }
852
853    /// Registers a resource handler.
854    ///
855    /// Duplicate handling is controlled by [`on_duplicate`](Self::on_duplicate).
856    /// If [`DuplicateBehavior::Error`] is set and a duplicate is found,
857    /// an error will be logged and the resource will not be registered.
858    #[must_use]
859    pub fn resource<H: ResourceHandler + 'static>(mut self, handler: H) -> Self {
860        if let Err(e) = self
861            .router
862            .add_resource_with_behavior(handler, self.on_duplicate)
863        {
864            log::error!(
865                target: "fastmcp_rust::builder",
866                "Failed to register resource; code={:?}",
867                e.code
868            );
869        } else {
870            self.advertise_legacy_resource_subscriptions();
871        }
872        self
873    }
874
875    /// Advertises the `resources.subscribe` capability so clients may
876    /// subscribe to registered resource URIs.
877    ///
878    /// Registering a resource or template already advertises subscribe because
879    /// session and exact-2024 dispatch serve `resources/subscribe` for those
880    /// URIs. This remains for servers that want the capability visible before
881    /// any catalog entry is installed.
882    #[must_use]
883    pub fn resource_subscriptions(mut self) -> Self {
884        self.advertise_legacy_resource_subscriptions();
885        self
886    }
887
888    fn advertise_legacy_resource_subscriptions(&mut self) {
889        let resources = self
890            .capabilities
891            .resources
892            .get_or_insert_with(ResourcesCapability::default);
893        resources.subscribe = true;
894        resources.list_changed = true;
895    }
896
897    fn advertise_legacy_tools_list_changed(&mut self) {
898        self.capabilities
899            .tools
900            .get_or_insert_with(ToolsCapability::default)
901            .list_changed = true;
902    }
903
904    fn advertise_legacy_prompts_list_changed(&mut self) {
905        self.capabilities
906            .prompts
907            .get_or_insert_with(PromptsCapability::default)
908            .list_changed = true;
909    }
910
911    fn advertise_completions(&mut self) {
912        self.capabilities.completions = Some(fastmcp_protocol::CompletionsCapability::default());
913    }
914
915    /// Registers an intentionally exact MCP 2024-11-05-only resource.
916    #[must_use]
917    pub fn legacy_resource<H: ResourceHandler + 'static>(mut self, handler: H) -> Self {
918        if let Err(error) = self
919            .router
920            .add_legacy_resource_with_behavior(handler, self.on_duplicate)
921        {
922            log::error!(
923                target: "fastmcp_rust::builder",
924                "Failed to register exact-2024-only resource; code={:?}",
925                error.code
926            );
927        } else {
928            self.advertise_legacy_resource_subscriptions();
929        }
930        self
931    }
932
933    /// Registers a resource template.
934    ///
935    /// Duplicate handling is controlled by [`on_duplicate`](Self::on_duplicate).
936    /// With [`DuplicateBehavior::Error`], a conflicting template is rejected
937    /// and logged while builder construction continues.
938    #[must_use]
939    pub fn resource_template(mut self, template: ResourceTemplate) -> Self {
940        if let Err(error) = self
941            .router
942            .add_resource_template_with_behavior(template, self.on_duplicate)
943        {
944            log::error!(
945                target: "fastmcp_rust::builder",
946                "Failed to register resource template; code={:?}",
947                error.code
948            );
949        } else {
950            self.advertise_legacy_resource_subscriptions();
951        }
952        self
953    }
954
955    /// Registers an intentionally exact MCP 2024-11-05-only resource template.
956    #[must_use]
957    pub fn legacy_resource_template(mut self, template: ResourceTemplate) -> Self {
958        if let Err(error) = self
959            .router
960            .add_legacy_resource_template_with_behavior(template, self.on_duplicate)
961        {
962            log::error!(
963                target: "fastmcp_rust::builder",
964                "Failed to register exact-2024-only resource template; code={:?}",
965                error.code
966            );
967        } else {
968            self.advertise_legacy_resource_subscriptions();
969        }
970        self
971    }
972
973    /// Registers a prompt handler.
974    ///
975    /// Duplicate handling is controlled by [`on_duplicate`](Self::on_duplicate).
976    /// If [`DuplicateBehavior::Error`] is set and a duplicate is found,
977    /// an error will be logged and the prompt will not be registered.
978    #[must_use]
979    pub fn prompt<H: PromptHandler + 'static>(mut self, handler: H) -> Self {
980        if let Err(e) = self
981            .router
982            .add_prompt_with_behavior(handler, self.on_duplicate)
983        {
984            log::error!(
985                target: "fastmcp_rust::builder",
986                "Failed to register prompt; code={:?}",
987                e.code
988            );
989        } else {
990            self.advertise_legacy_prompts_list_changed();
991        }
992        self
993    }
994
995    /// Registers an intentionally exact MCP 2024-11-05-only prompt.
996    #[must_use]
997    pub fn legacy_prompt<H: PromptHandler + 'static>(mut self, handler: H) -> Self {
998        if let Err(error) = self
999            .router
1000            .add_legacy_prompt_with_behavior(handler, self.on_duplicate)
1001        {
1002            log::error!(
1003                target: "fastmcp_rust::builder",
1004                "Failed to register exact-2024-only prompt; code={:?}",
1005                error.code
1006            );
1007        } else {
1008            self.advertise_legacy_prompts_list_changed();
1009        }
1010        self
1011    }
1012
1013    /// Registers the server-wide `completion/complete` handler.
1014    ///
1015    /// The handler receives disjoint exact-legacy and final request parameter
1016    /// types. Building with a handler installs the real router dispatch target,
1017    /// which is the sole condition that enables final discovery's
1018    /// `capabilities.completions` claim.
1019    #[must_use]
1020    pub fn completion_handler<H: CompletionHandler + 'static>(mut self, handler: H) -> Self {
1021        self.router.add_completion_handler(handler);
1022        self.advertise_completions();
1023        self
1024    }
1025
1026    /// Registers a completion handler for exact MCP 2024-11-05 dispatch only.
1027    ///
1028    /// Initialize advertises `capabilities.completions` so a 2024-11-05 client
1029    /// can discover `completion/complete`. Final `server/discover` still omits
1030    /// completions unless a modern handler is also installed.
1031    #[must_use]
1032    pub fn legacy_completion_handler<H: CompletionHandler + 'static>(mut self, handler: H) -> Self {
1033        self.router.add_legacy_completion_handler(handler);
1034        self.advertise_completions();
1035        self
1036    }
1037
1038    /// Registers a final completion provider for one exact prompt name.
1039    ///
1040    /// Final `completion/complete` dispatch validates the referenced prompt
1041    /// and argument before selecting this provider. Exact MCP 2024-11-05
1042    /// completion remains on [`Self::completion_handler`] or
1043    /// [`Self::legacy_completion_handler`].
1044    #[must_use]
1045    pub fn prompt_completion_handler<H: CompletionHandler + 'static>(
1046        mut self,
1047        prompt_name: impl Into<String>,
1048        handler: H,
1049    ) -> Self {
1050        self.router
1051            .add_prompt_completion_handler(prompt_name, handler);
1052        self
1053    }
1054
1055    /// Registers a final completion provider for one exact resource-template URI.
1056    ///
1057    /// Final dispatch admits the registered resource template and requested
1058    /// template variable before selecting this provider.
1059    #[must_use]
1060    pub fn resource_template_completion_handler<H: CompletionHandler + 'static>(
1061        mut self,
1062        uri_template: impl Into<String>,
1063        handler: H,
1064    ) -> Self {
1065        self.router
1066            .add_resource_template_completion_handler(uri_template, handler);
1067        self
1068    }
1069
1070    /// Registers an exact MCP 2024-11-05 completion provider for one resource
1071    /// template URI.
1072    ///
1073    /// Exact-2024 dispatch selects this provider before the server-wide
1074    /// [`Self::legacy_completion_handler`] fallback. Initialize advertises
1075    /// `capabilities.completions` so a 2024-11-05 client can discover
1076    /// `completion/complete`.
1077    #[must_use]
1078    pub fn legacy_resource_template_completion_handler<H: CompletionHandler + 'static>(
1079        mut self,
1080        uri_template: impl Into<String>,
1081        handler: H,
1082    ) -> Self {
1083        self.router
1084            .add_legacy_resource_template_completion_handler(uri_template, handler);
1085        self.advertise_completions();
1086        self
1087    }
1088
1089    /// Returns whether this proxy registration will own the exact prompt
1090    /// target after the configured duplicate policy is applied.
1091    ///
1092    /// Router registration intentionally returns `Ok(())` when `Warn` or
1093    /// `Ignore` retains an existing target. Completion providers must not use
1094    /// that successful no-op as authorization to bind an upstream to the
1095    /// retained local target.
1096    #[cfg(feature = "proxy")]
1097    fn proxy_prompt_wins_admission(&self, name: &str) -> bool {
1098        self.router.get_prompt(name).is_none() || self.on_duplicate == DuplicateBehavior::Replace
1099    }
1100
1101    /// Returns whether this proxy registration will own the exact resource
1102    /// template target after the configured duplicate policy is applied.
1103    ///
1104    /// See [`Self::proxy_prompt_wins_admission`] for why `Ok(())` alone is
1105    /// insufficient for proxy completion binding.
1106    #[cfg(feature = "proxy")]
1107    fn proxy_resource_template_wins_admission(&self, uri_template: &str) -> bool {
1108        self.router.get_resource_template(uri_template).is_none()
1109            || self.on_duplicate == DuplicateBehavior::Replace
1110    }
1111
1112    /// Installs one route-bound modern Tasks relay into every server surface
1113    /// that owns Tasks discovery and dispatch. Exact-2024 never supplies a
1114    /// relay, so callers pass `None` for its selected upstream era.
1115    #[cfg(all(feature = "proxy", feature = "tasks"))]
1116    fn install_proxy_final_tasks_relay(
1117        &mut self,
1118        task_relay: Option<Arc<ProxyFinalTaskRelay>>,
1119    ) -> McpResult<()> {
1120        let Some(task_relay) = task_relay else {
1121            return Ok(());
1122        };
1123        if self.final_task_runtime.is_some() || self.final_task_relay.is_some() {
1124            return Err(fastmcp_core::McpError::invalid_request(
1125                "a server may install only one local or route-bound final Tasks service",
1126            ));
1127        }
1128        if let Some(extension_runtime) = self.extension_runtime.as_mut() {
1129            extension_runtime
1130                .install_proxy_final_tasks(Arc::clone(&task_relay))
1131                .map_err(|error| fastmcp_core::McpError::invalid_request(error.to_string()))?;
1132        }
1133        self.router
1134            .set_final_task_relay(Some(Arc::clone(&task_relay)));
1135        self.final_task_relay = Some(task_relay);
1136        Ok(())
1137    }
1138
1139    /// Registers proxy handlers for a remote MCP server.
1140    ///
1141    /// Use [`ProxyClient::catalog`] to fetch definitions before calling this
1142    /// method. A caller-supplied catalog must agree with an already-selected
1143    /// transport binding or backend-observed era; it cannot bind an unbound
1144    /// proxy route itself.
1145    ///
1146    /// # Errors
1147    ///
1148    /// Returns an error when the catalog is malformed, contradicts the route,
1149    /// or attempts to establish an era without upstream evidence.
1150    #[cfg(feature = "proxy")]
1151    pub fn proxy(mut self, client: ProxyClient, catalog: ProxyCatalog) -> McpResult<Self> {
1152        client.admit_catalog(&catalog)?;
1153        let catalog_era = catalog.era()?;
1154        let completion_supported = match client.supports_completion() {
1155            Ok(supported) => supported,
1156            Err(error) => {
1157                log::error!(
1158                    target: "fastmcp_rust::builder",
1159                    "Failed to determine proxied completion support; code={:?}",
1160                    error.code
1161                );
1162                false
1163            }
1164        };
1165        // An admitted final-only catalog owns the same public core capability
1166        // claims as its legacy counterpart. Registration below remains era
1167        // separated; this only makes discovery reflect the handlers that were
1168        // actually installed.
1169        let has_tools = !catalog.tools.is_empty() || !catalog.final_tools.is_empty();
1170        let has_resources = !catalog.resources.is_empty()
1171            || !catalog.resource_templates.is_empty()
1172            || !catalog.final_resources.is_empty()
1173            || !catalog.final_resource_templates.is_empty();
1174        let has_prompts = !catalog.prompts.is_empty() || !catalog.final_prompts.is_empty();
1175        #[cfg(feature = "tasks")]
1176        let task_relay = if catalog_era == ProtocolEra::Modern2026 {
1177            client.final_tasks_relay()?
1178        } else {
1179            None
1180        };
1181        #[cfg(feature = "tasks")]
1182        self.install_proxy_final_tasks_relay(task_relay.clone())?;
1183        #[cfg(not(feature = "tasks"))]
1184        let final_handlers = catalog.final_tool_handlers(client.clone())?;
1185        #[cfg(feature = "tasks")]
1186        let final_handlers = catalog
1187            .final_tools
1188            .iter()
1189            .cloned()
1190            .map(|tool| match task_relay.as_ref() {
1191                Some(task_relay) => ProxyToolHandler::from_final_with_task_relay(
1192                    tool,
1193                    client.clone(),
1194                    Arc::clone(task_relay),
1195                ),
1196                None => ProxyToolHandler::from_final(tool, client.clone()),
1197            })
1198            .collect::<McpResult<Vec<_>>>()?;
1199
1200        // Legacy catalog entries register exact-2024-only: dual-era proxy
1201        // composition must not promote a legacy upstream's components into
1202        // the final catalog beside a modern upstream's own registrations.
1203        for tool in catalog.tools {
1204            if let Err(error) = self.router.add_legacy_tool_with_behavior(
1205                ProxyToolHandler::new(tool, client.clone()),
1206                self.on_duplicate,
1207            ) {
1208                log::error!(
1209                    target: "fastmcp_rust::builder",
1210                    "Failed to register proxied tool; code={:?}",
1211                    error.code
1212                );
1213            }
1214        }
1215
1216        for handler in final_handlers {
1217            match self
1218                .router
1219                .add_final_tool_with_behavior(handler, self.on_duplicate)
1220            {
1221                Ok(()) => {}
1222                Err(error) => {
1223                    log::error!(
1224                        target: "fastmcp_rust::builder",
1225                        "Failed to register exact-final proxied tool; code={:?}",
1226                        error.code
1227                    );
1228                }
1229            }
1230        }
1231
1232        for resource in catalog.resources {
1233            if let Err(error) = self.router.add_legacy_resource_with_behavior(
1234                ProxyResourceHandler::new(resource, client.clone()),
1235                self.on_duplicate,
1236            ) {
1237                log::error!(
1238                    target: "fastmcp_rust::builder",
1239                    "Failed to register proxied resource; code={:?}",
1240                    error.code
1241                );
1242            }
1243        }
1244
1245        for template in catalog.resource_templates {
1246            let downstream_uri = template.uri_template.clone();
1247            let completion_target_admitted =
1248                self.proxy_resource_template_wins_admission(&downstream_uri);
1249            match self.router.add_legacy_resource_with_behavior(
1250                ProxyResourceHandler::from_template(template, client.clone()),
1251                self.on_duplicate,
1252            ) {
1253                Ok(()) if completion_target_admitted => {
1254                    if completion_supported && catalog_era == ProtocolEra::Legacy2024 {
1255                        self.router.add_legacy_resource_template_completion_handler(
1256                            downstream_uri.clone(),
1257                            ProxyCompletionHandler::for_resource_template(
1258                                client.clone(),
1259                                downstream_uri.clone(),
1260                                downstream_uri,
1261                            ),
1262                        );
1263                        self.advertise_completions();
1264                    }
1265                }
1266                Ok(()) => {}
1267                Err(error) => {
1268                    log::error!(
1269                        target: "fastmcp_rust::builder",
1270                        "Failed to register proxied resource template; code={:?}",
1271                        error.code
1272                    );
1273                }
1274            }
1275        }
1276
1277        for prompt in catalog.prompts {
1278            let downstream_name = prompt.name.clone();
1279            let completion_target_admitted = self.proxy_prompt_wins_admission(&downstream_name);
1280            match self.router.add_legacy_prompt_with_behavior(
1281                ProxyPromptHandler::new(prompt, client.clone()),
1282                self.on_duplicate,
1283            ) {
1284                Ok(()) if completion_target_admitted => {
1285                    if completion_supported && catalog_era == ProtocolEra::Legacy2024 {
1286                        self.router.add_legacy_prompt_completion_handler(
1287                            downstream_name.clone(),
1288                            ProxyCompletionHandler::for_prompt(
1289                                client.clone(),
1290                                downstream_name.clone(),
1291                                downstream_name,
1292                            ),
1293                        );
1294                        self.advertise_completions();
1295                    }
1296                }
1297                Ok(()) => {}
1298                Err(error) => {
1299                    log::error!(
1300                        target: "fastmcp_rust::builder",
1301                        "Failed to register proxied prompt; code={:?}",
1302                        error.code
1303                    );
1304                }
1305            }
1306        }
1307
1308        for resource in catalog.final_resources {
1309            if let Err(error) = self.router.add_final_resource_with_behavior(
1310                FinalProxyResourceHandler::new(resource, client.clone()),
1311                self.on_duplicate,
1312            ) {
1313                log::error!(
1314                    target: "fastmcp_rust::builder",
1315                    "Failed to register exact-final proxied resource; code={:?}",
1316                    error.code
1317                );
1318            }
1319        }
1320
1321        for template in catalog.final_resource_templates {
1322            let downstream_uri = template.uri_template.clone();
1323            let completion_target_admitted =
1324                self.proxy_resource_template_wins_admission(&downstream_uri);
1325            match self.router.add_final_resource_with_behavior(
1326                FinalProxyResourceTemplateHandler::new(template, client.clone()),
1327                self.on_duplicate,
1328            ) {
1329                Ok(())
1330                    if completion_target_admitted
1331                        && completion_supported
1332                        && catalog_era == ProtocolEra::Modern2026 =>
1333                {
1334                    self.router.add_resource_template_completion_handler(
1335                        downstream_uri.clone(),
1336                        ProxyCompletionHandler::for_resource_template(
1337                            client.clone(),
1338                            downstream_uri.clone(),
1339                            downstream_uri,
1340                        ),
1341                    );
1342                    self.advertise_completions();
1343                }
1344                Ok(()) => {}
1345                Err(error) => {
1346                    log::error!(
1347                        target: "fastmcp_rust::builder",
1348                        "Failed to register exact-final proxied resource template; code={:?}",
1349                        error.code
1350                    );
1351                }
1352            }
1353        }
1354
1355        for prompt in catalog.final_prompts {
1356            let downstream_name = prompt.name.clone();
1357            let completion_target_admitted = self.proxy_prompt_wins_admission(&downstream_name);
1358            match self.router.add_final_prompt_with_behavior(
1359                FinalProxyPromptHandler::new(prompt, client.clone()),
1360                self.on_duplicate,
1361            ) {
1362                Ok(())
1363                    if completion_target_admitted
1364                        && completion_supported
1365                        && catalog_era == ProtocolEra::Modern2026 =>
1366                {
1367                    self.router.add_prompt_completion_handler(
1368                        downstream_name.clone(),
1369                        ProxyCompletionHandler::for_prompt(
1370                            client.clone(),
1371                            downstream_name.clone(),
1372                            downstream_name,
1373                        ),
1374                    );
1375                    self.advertise_completions();
1376                }
1377                Ok(()) => {}
1378                Err(error) => {
1379                    log::error!(
1380                        target: "fastmcp_rust::builder",
1381                        "Failed to register exact-final proxied prompt; code={:?}",
1382                        error.code
1383                    );
1384                }
1385            }
1386        }
1387
1388        if has_tools {
1389            self.advertise_legacy_tools_list_changed();
1390        }
1391        if has_resources {
1392            self.advertise_legacy_resource_subscriptions();
1393        }
1394        if has_prompts {
1395            self.advertise_legacy_prompts_list_changed();
1396        }
1397
1398        Ok(self)
1399    }
1400
1401    /// Creates a proxy to an external MCP server with automatic discovery.
1402    ///
1403    /// This is a convenience method that combines connection, discovery, and
1404    /// handler registration. The client should already be initialized (connected
1405    /// to the server).
1406    ///
1407    /// All tools, resources, and prompts from the external server are registered
1408    /// as proxy handlers with the specified prefix.
1409    ///
1410    /// # Example
1411    ///
1412    /// ```ignore
1413    /// use fastmcp_client::Client;
1414    ///
1415    /// // Create and initialize client
1416    /// let mut client = Client::new(transport)?;
1417    /// client.initialize()?;
1418    ///
1419    /// // Create main server with proxy to external
1420    /// let main = Server::new("main", "1.0")
1421    ///     .tool(local_tool)
1422    ///     .as_proxy("ext", client)?    // ext/external_tool, etc.
1423    ///     .build();
1424    /// ```
1425    ///
1426    /// # Errors
1427    ///
1428    /// Returns an error if the catalog fetch fails. Duplicate-policy
1429    /// rejections of individual proxied components are logged and skipped so
1430    /// the rest of the catalog still installs.
1431    #[cfg(feature = "proxy")]
1432    pub fn as_proxy(
1433        self,
1434        prefix: &str,
1435        client: fastmcp_client::Client,
1436    ) -> Result<Self, fastmcp_core::McpError> {
1437        let proxy_client = ProxyClient::from_client(client)?;
1438        let catalog = proxy_client.catalog_typed()?;
1439        self.register_prefixed_typed_proxy_catalog(prefix, proxy_client, catalog)
1440    }
1441
1442    /// Registers one already-negotiated typed upstream catalog under a caller
1443    /// prefix. Tools and prompts take `{prefix}/{name}`; exact-final resource
1444    /// URIs stay unprefixed so they remain absolute.
1445    ///
1446    /// This is the HTTP-capable counterpart to [`as_proxy`](Self::as_proxy):
1447    /// a live `ProxyClient` from `connect_http_with_protocol_plan` already
1448    /// carries a ModernHttp/LegacyHttpSse binding, so it must not be rebuilt
1449    /// through `from_client` (which labels the adapter as stdio).
1450    ///
1451    /// # Errors
1452    ///
1453    /// Returns an error if the typed catalog is mixed-era or contradicts the
1454    /// selected route. Duplicate-policy rejections of individual proxied
1455    /// components are logged and skipped so the rest of the catalog still
1456    /// installs.
1457    #[cfg(feature = "proxy")]
1458    pub fn as_proxy_typed(
1459        self,
1460        prefix: &str,
1461        proxy_client: ProxyClient,
1462        catalog: ProxyTypedCatalog,
1463    ) -> Result<Self, fastmcp_core::McpError> {
1464        self.register_prefixed_typed_proxy_catalog(prefix, proxy_client, catalog)
1465    }
1466
1467    /// Copies nonempty upstream initialize/discover instructions onto this
1468    /// gateway when the builder has not already set its own string.
1469    #[cfg(feature = "proxy")]
1470    fn adopt_upstream_proxy_instructions(
1471        &mut self,
1472        proxy_client: &ProxyClient,
1473    ) -> Result<(), fastmcp_core::McpError> {
1474        if self
1475            .instructions
1476            .as_ref()
1477            .is_some_and(|instructions| !instructions.is_empty())
1478        {
1479            return Ok(());
1480        }
1481        if let Some(instructions) = proxy_client.upstream_instructions()?
1482            && !instructions.is_empty()
1483        {
1484            self.instructions = Some(instructions);
1485        }
1486        Ok(())
1487    }
1488
1489    /// Copies upstream modern Implementation extras onto this gateway when the
1490    /// builder has not already set title, description, website, or icons.
1491    ///
1492    /// The gateway keeps its own `name`/`version`. Exact-2024 initialize stays
1493    /// name/version-only.
1494    #[cfg(feature = "proxy")]
1495    fn adopt_upstream_proxy_implementation(
1496        &mut self,
1497        proxy_client: &ProxyClient,
1498    ) -> Result<(), fastmcp_core::McpError> {
1499        let gateway_has_extras = self.title.as_ref().is_some_and(|title| !title.is_empty())
1500            || self
1501                .description
1502                .as_ref()
1503                .is_some_and(|description| !description.is_empty())
1504            || self
1505                .website_url
1506                .as_ref()
1507                .is_some_and(|website| !website.is_empty())
1508            || !self.icons.is_empty();
1509        if gateway_has_extras {
1510            return Ok(());
1511        }
1512        let Some(implementation) = proxy_client.upstream_implementation()? else {
1513            return Ok(());
1514        };
1515        if implementation.title.is_none()
1516            && implementation.description.is_none()
1517            && implementation.website_url.is_none()
1518            && implementation.icons.is_empty()
1519        {
1520            return Ok(());
1521        }
1522        self.title = implementation.title;
1523        self.description = implementation.description;
1524        self.website_url = implementation
1525            .website_url
1526            .map(|uri| uri.as_str().to_owned());
1527        self.icons = implementation.icons;
1528        Ok(())
1529    }
1530
1531    #[cfg(feature = "proxy")]
1532    fn register_prefixed_typed_proxy_catalog(
1533        mut self,
1534        prefix: &str,
1535        proxy_client: ProxyClient,
1536        catalog: ProxyTypedCatalog,
1537    ) -> Result<Self, fastmcp_core::McpError> {
1538        proxy_client.admit_typed_catalog(&catalog)?;
1539        self.adopt_upstream_proxy_instructions(&proxy_client)?;
1540        self.adopt_upstream_proxy_implementation(&proxy_client)?;
1541        let completion_supported = proxy_client.supports_completion()?;
1542        #[cfg(feature = "tasks")]
1543        let catalog_era = catalog.era()?;
1544        #[cfg(feature = "tasks")]
1545        let task_relay = if catalog_era == ProtocolEra::Modern2026 {
1546            proxy_client.final_tasks_relay()?
1547        } else {
1548            None
1549        };
1550        #[cfg(feature = "tasks")]
1551        self.install_proxy_final_tasks_relay(task_relay.clone())?;
1552        let (tool_count, resource_count, template_count, prompt_count) = match catalog {
1553            ProxyTypedCatalog {
1554                tools: ProxyToolCatalog::Legacy(tools),
1555                resources: ProxyResourceCatalog::Legacy(resources),
1556                resource_templates: ProxyResourceTemplateCatalog::Legacy(resource_templates),
1557                prompts: ProxyPromptCatalog::Legacy(prompts),
1558            } => {
1559                let counts = (
1560                    tools.len(),
1561                    resources.len(),
1562                    resource_templates.len(),
1563                    prompts.len(),
1564                );
1565                // Prefixed legacy keys (`{prefix}/{uri}`) are not absolute
1566                // final URIs. Register them exact-2024-only so they are not
1567                // projected into the modern catalog.
1568                for tool in tools {
1569                    if let Err(error) = self.router.add_legacy_tool_with_behavior(
1570                        ProxyToolHandler::with_prefix(tool, prefix, proxy_client.clone()),
1571                        self.on_duplicate,
1572                    ) {
1573                        log::error!(
1574                            target: "fastmcp_rust::builder",
1575                            "Failed to register prefixed proxied tool; code={:?}",
1576                            error.code
1577                        );
1578                    }
1579                }
1580                for resource in resources {
1581                    if let Err(error) = self.router.add_legacy_resource_with_behavior(
1582                        ProxyResourceHandler::with_prefix(resource, prefix, proxy_client.clone()),
1583                        self.on_duplicate,
1584                    ) {
1585                        log::error!(
1586                            target: "fastmcp_rust::builder",
1587                            "Failed to register prefixed proxied resource; code={:?}",
1588                            error.code
1589                        );
1590                    }
1591                }
1592                for template in resource_templates {
1593                    let upstream_uri = template.uri_template.clone();
1594                    let downstream_uri = format!("{prefix}/{upstream_uri}");
1595                    let completion_target_admitted =
1596                        self.proxy_resource_template_wins_admission(&downstream_uri);
1597                    match self.router.add_legacy_resource_with_behavior(
1598                        ProxyResourceHandler::from_template_with_prefix(
1599                            template,
1600                            prefix,
1601                            proxy_client.clone(),
1602                        ),
1603                        self.on_duplicate,
1604                    ) {
1605                        Ok(()) if completion_target_admitted && completion_supported => {
1606                            self.router.add_legacy_resource_template_completion_handler(
1607                                downstream_uri.clone(),
1608                                ProxyCompletionHandler::for_resource_template(
1609                                    proxy_client.clone(),
1610                                    downstream_uri,
1611                                    upstream_uri,
1612                                ),
1613                            );
1614                            self.advertise_completions();
1615                        }
1616                        Ok(()) => {}
1617                        Err(error) => {
1618                            log::error!(
1619                                target: "fastmcp_rust::builder",
1620                                "Failed to register prefixed proxied resource template; code={:?}",
1621                                error.code
1622                            );
1623                        }
1624                    }
1625                }
1626                for prompt in prompts {
1627                    let upstream_name = prompt.name.clone();
1628                    let downstream_name = format!("{prefix}/{upstream_name}");
1629                    let completion_target_admitted =
1630                        self.proxy_prompt_wins_admission(&downstream_name);
1631                    match self.router.add_legacy_prompt_with_behavior(
1632                        ProxyPromptHandler::with_prefix(prompt, prefix, proxy_client.clone()),
1633                        self.on_duplicate,
1634                    ) {
1635                        Ok(()) if completion_target_admitted && completion_supported => {
1636                            self.router.add_legacy_prompt_completion_handler(
1637                                downstream_name.clone(),
1638                                ProxyCompletionHandler::for_prompt(
1639                                    proxy_client.clone(),
1640                                    downstream_name,
1641                                    upstream_name,
1642                                ),
1643                            );
1644                            self.advertise_completions();
1645                        }
1646                        Ok(()) => {}
1647                        Err(error) => {
1648                            log::error!(
1649                                target: "fastmcp_rust::builder",
1650                                "Failed to register prefixed proxied prompt; code={:?}",
1651                                error.code
1652                            );
1653                        }
1654                    }
1655                }
1656                counts
1657            }
1658            ProxyTypedCatalog {
1659                tools: ProxyToolCatalog::Final(tools),
1660                resources: ProxyResourceCatalog::Final(resources),
1661                resource_templates: ProxyResourceTemplateCatalog::Final(resource_templates),
1662                prompts: ProxyPromptCatalog::Final(prompts),
1663            } => {
1664                // Tools and prompts take the caller prefix. Exact-final resource
1665                // URIs stay unprefixed so they remain absolute; prefixing them
1666                // as `{prefix}/{uri}` would drop them from the modern catalog.
1667                let counts = (
1668                    tools.len(),
1669                    resources.len(),
1670                    resource_templates.len(),
1671                    prompts.len(),
1672                );
1673                for tool in tools {
1674                    #[cfg(feature = "tasks")]
1675                    let handler = match task_relay.as_ref() {
1676                        Some(task_relay) => ProxyToolHandler::with_prefix_final_with_task_relay(
1677                            tool,
1678                            prefix,
1679                            proxy_client.clone(),
1680                            Arc::clone(task_relay),
1681                        )?,
1682                        None => {
1683                            ProxyToolHandler::with_prefix_final(tool, prefix, proxy_client.clone())?
1684                        }
1685                    };
1686                    #[cfg(not(feature = "tasks"))]
1687                    let handler =
1688                        ProxyToolHandler::with_prefix_final(tool, prefix, proxy_client.clone())?;
1689                    if let Err(error) = self
1690                        .router
1691                        .add_final_tool_with_behavior(handler, self.on_duplicate)
1692                    {
1693                        log::error!(
1694                            target: "fastmcp_rust::builder",
1695                            "Failed to register prefixed exact-final proxied tool; code={:?}",
1696                            error.code
1697                        );
1698                    }
1699                }
1700                for resource in resources {
1701                    if let Err(error) = self.router.add_final_resource_with_behavior(
1702                        FinalProxyResourceHandler::new(resource, proxy_client.clone()),
1703                        self.on_duplicate,
1704                    ) {
1705                        log::error!(
1706                            target: "fastmcp_rust::builder",
1707                            "Failed to register prefixed exact-final proxied resource; code={:?}",
1708                            error.code
1709                        );
1710                    }
1711                }
1712                for template in resource_templates {
1713                    let downstream_uri = template.uri_template.clone();
1714                    let completion_target_admitted =
1715                        self.proxy_resource_template_wins_admission(&downstream_uri);
1716                    match self.router.add_final_resource_with_behavior(
1717                        FinalProxyResourceTemplateHandler::new(template, proxy_client.clone()),
1718                        self.on_duplicate,
1719                    ) {
1720                        Ok(()) if completion_target_admitted && completion_supported => {
1721                            self.router.add_resource_template_completion_handler(
1722                                downstream_uri.clone(),
1723                                ProxyCompletionHandler::for_resource_template(
1724                                    proxy_client.clone(),
1725                                    downstream_uri.clone(),
1726                                    downstream_uri,
1727                                ),
1728                            );
1729                            self.advertise_completions();
1730                        }
1731                        Ok(()) => {}
1732                        Err(error) => {
1733                            log::error!(
1734                                target: "fastmcp_rust::builder",
1735                                "Failed to register prefixed exact-final proxied resource template; code={:?}",
1736                                error.code
1737                            );
1738                        }
1739                    }
1740                }
1741                for prompt in prompts {
1742                    let upstream_name = prompt.name.clone();
1743                    let downstream_name = format!("{prefix}/{upstream_name}");
1744                    let completion_target_admitted =
1745                        self.proxy_prompt_wins_admission(&downstream_name);
1746                    match self.router.add_final_prompt_with_behavior(
1747                        FinalProxyPromptHandler::with_prefix(prompt, prefix, proxy_client.clone()),
1748                        self.on_duplicate,
1749                    ) {
1750                        Ok(()) if completion_target_admitted && completion_supported => {
1751                            self.router.add_prompt_completion_handler(
1752                                downstream_name.clone(),
1753                                ProxyCompletionHandler::for_prompt(
1754                                    proxy_client.clone(),
1755                                    downstream_name,
1756                                    upstream_name,
1757                                ),
1758                            );
1759                            self.advertise_completions();
1760                        }
1761                        Ok(()) => {}
1762                        Err(error) => {
1763                            log::error!(
1764                                target: "fastmcp_rust::builder",
1765                                "Failed to register prefixed exact-final proxied prompt; code={:?}",
1766                                error.code
1767                            );
1768                        }
1769                    }
1770                }
1771                counts
1772            }
1773            _ => {
1774                return Err(fastmcp_core::McpError::invalid_request(
1775                    "proxy typed catalog mixes legacy and final component vectors",
1776                ));
1777            }
1778        };
1779
1780        let has_tools = tool_count > 0;
1781        let has_resources = resource_count > 0 || template_count > 0;
1782        let has_prompts = prompt_count > 0;
1783
1784        // Update capabilities
1785        if has_tools {
1786            self.advertise_legacy_tools_list_changed();
1787        }
1788        if has_resources {
1789            self.advertise_legacy_resource_subscriptions();
1790        }
1791        if has_prompts {
1792            self.advertise_legacy_prompts_list_changed();
1793        }
1794
1795        log::info!(
1796            target: "fastmcp_rust::proxy",
1797            "Proxied {} tools, {} resources, {} templates, and {} prompts with a configured prefix",
1798            tool_count,
1799            resource_count,
1800            template_count,
1801            prompt_count
1802        );
1803
1804        Ok(self)
1805    }
1806
1807    /// Creates a proxy to an external MCP server without a prefix.
1808    ///
1809    /// Similar to [`as_proxy`](Self::as_proxy), but tools/resources/prompts
1810    /// keep their original names. Use this when proxying a single external
1811    /// server or when you don't need namespace separation.
1812    ///
1813    /// # Example
1814    ///
1815    /// ```ignore
1816    /// let main = Server::new("main", "1.0")
1817    ///     .as_proxy_raw(client)?  // External tools appear with original names
1818    ///     .build();
1819    /// ```
1820    ///
1821    /// # Errors
1822    ///
1823    /// Returns an error if catalog discovery fails or the configured duplicate
1824    /// policy rejects any unprefixed proxy component.
1825    #[cfg(feature = "proxy")]
1826    pub fn as_proxy_raw(
1827        self,
1828        client: fastmcp_client::Client,
1829    ) -> Result<Self, fastmcp_core::McpError> {
1830        self.as_proxy_raw_with_proxy_client(ProxyClient::from_client(client)?)
1831    }
1832
1833    /// Registers one already-negotiated, typed upstream catalog without any
1834    /// legacy projection. Final components are visible only to final routes;
1835    /// legacy components remain visible only to exact MCP 2024-11-05 routes.
1836    ///
1837    /// A caller-provided catalog is accepted only when the client already has
1838    /// immutable transport binding or backend-observed era evidence. Call
1839    /// [`ProxyClient::catalog_typed`] first for an unbound custom backend.
1840    ///
1841    /// # Errors
1842    ///
1843    /// Returns an error when the typed catalog is mixed-era, contradicts the
1844    /// selected route, or tries to bind an otherwise unbound route.
1845    #[cfg(feature = "proxy")]
1846    pub fn proxy_typed(
1847        self,
1848        proxy_client: ProxyClient,
1849        catalog: ProxyTypedCatalog,
1850    ) -> Result<Self, fastmcp_core::McpError> {
1851        self.register_raw_typed_proxy_catalog(proxy_client, catalog)
1852    }
1853
1854    #[cfg(feature = "proxy")]
1855    fn as_proxy_raw_with_proxy_client(
1856        self,
1857        proxy_client: ProxyClient,
1858    ) -> Result<Self, fastmcp_core::McpError> {
1859        let catalog = proxy_client.catalog_typed()?;
1860        self.register_raw_typed_proxy_catalog(proxy_client, catalog)
1861    }
1862
1863    /// Registers one already-negotiated typed upstream catalog without
1864    /// projecting final resource/template/prompt definitions through their
1865    /// narrower legacy models.
1866    #[cfg(feature = "proxy")]
1867    fn register_raw_typed_proxy_catalog(
1868        mut self,
1869        proxy_client: ProxyClient,
1870        catalog: ProxyTypedCatalog,
1871    ) -> Result<Self, fastmcp_core::McpError> {
1872        // `catalog_typed` seals backend-observed evidence itself. A
1873        // caller-provided typed catalog must instead prove it agrees with an
1874        // already-selected transport or an earlier observed catalog; it may
1875        // not establish an era for an unbound proxy route.
1876        proxy_client.admit_typed_catalog(&catalog)?;
1877        self.adopt_upstream_proxy_instructions(&proxy_client)?;
1878        self.adopt_upstream_proxy_implementation(&proxy_client)?;
1879        let catalog_era = catalog.era()?;
1880        let completion_supported = proxy_client.supports_completion()?;
1881        #[cfg(feature = "tasks")]
1882        let task_relay = if catalog_era == ProtocolEra::Modern2026 {
1883            proxy_client.final_tasks_relay()?
1884        } else {
1885            None
1886        };
1887        #[cfg(feature = "tasks")]
1888        self.install_proxy_final_tasks_relay(task_relay.clone())?;
1889        match (
1890            catalog.tools,
1891            catalog.resources,
1892            catalog.resource_templates,
1893            catalog.prompts,
1894        ) {
1895            (
1896                ProxyToolCatalog::Legacy(tools),
1897                ProxyResourceCatalog::Legacy(resources),
1898                ProxyResourceTemplateCatalog::Legacy(resource_templates),
1899                ProxyPromptCatalog::Legacy(prompts),
1900            ) => {
1901                let has_tools = !tools.is_empty();
1902                let has_resources = !resources.is_empty() || !resource_templates.is_empty();
1903                let has_prompts = !prompts.is_empty();
1904                // A legacy upstream catalog registers exact-2024-only: dual-era
1905                // proxy composition must not promote its entries into the
1906                // final catalog beside a modern upstream's own registrations.
1907                for tool in tools {
1908                    self.router.add_legacy_tool_with_behavior(
1909                        ProxyToolHandler::new(tool, proxy_client.clone()),
1910                        self.on_duplicate,
1911                    )?;
1912                }
1913                for resource in resources {
1914                    self.router.add_legacy_resource_with_behavior(
1915                        ProxyResourceHandler::new(resource, proxy_client.clone()),
1916                        self.on_duplicate,
1917                    )?;
1918                }
1919                for template in resource_templates {
1920                    let downstream_uri = template.uri_template.clone();
1921                    let completion_target_admitted =
1922                        self.proxy_resource_template_wins_admission(&downstream_uri);
1923                    self.router.add_legacy_resource_with_behavior(
1924                        ProxyResourceHandler::from_template(template, proxy_client.clone()),
1925                        self.on_duplicate,
1926                    )?;
1927                    if completion_target_admitted && completion_supported {
1928                        self.router.add_legacy_resource_template_completion_handler(
1929                            downstream_uri.clone(),
1930                            ProxyCompletionHandler::for_resource_template(
1931                                proxy_client.clone(),
1932                                downstream_uri.clone(),
1933                                downstream_uri,
1934                            ),
1935                        );
1936                        self.advertise_completions();
1937                    }
1938                }
1939                for prompt in prompts {
1940                    let downstream_name = prompt.name.clone();
1941                    let completion_target_admitted =
1942                        self.proxy_prompt_wins_admission(&downstream_name);
1943                    self.router.add_legacy_prompt_with_behavior(
1944                        ProxyPromptHandler::new(prompt, proxy_client.clone()),
1945                        self.on_duplicate,
1946                    )?;
1947                    if completion_target_admitted && completion_supported {
1948                        self.router.add_legacy_prompt_completion_handler(
1949                            downstream_name.clone(),
1950                            ProxyCompletionHandler::for_prompt(
1951                                proxy_client.clone(),
1952                                downstream_name.clone(),
1953                                downstream_name,
1954                            ),
1955                        );
1956                        self.advertise_completions();
1957                    }
1958                }
1959                if has_tools {
1960                    self.advertise_legacy_tools_list_changed();
1961                }
1962                if has_resources {
1963                    self.advertise_legacy_resource_subscriptions();
1964                }
1965                if has_prompts {
1966                    self.advertise_legacy_prompts_list_changed();
1967                }
1968            }
1969            (
1970                ProxyToolCatalog::Final(tools),
1971                ProxyResourceCatalog::Final(resources),
1972                ProxyResourceTemplateCatalog::Final(resource_templates),
1973                ProxyPromptCatalog::Final(prompts),
1974            ) => {
1975                let has_tools = !tools.is_empty();
1976                let has_resources = !resources.is_empty() || !resource_templates.is_empty();
1977                let has_prompts = !prompts.is_empty();
1978                for tool in tools {
1979                    // Complete-only registration: a live echo/tool catalog must
1980                    // not require the official Tasks client extension merely
1981                    // because the upstream server advertises Tasks. The route
1982                    // still installs `final_task_relay` for `tasks/*` controls.
1983                    let handler = ProxyToolHandler::from_final(tool, proxy_client.clone())?;
1984                    self.router
1985                        .add_final_tool_with_behavior(handler, self.on_duplicate)?;
1986                }
1987                for resource in resources {
1988                    self.router.add_final_resource_with_behavior(
1989                        FinalProxyResourceHandler::new(resource, proxy_client.clone()),
1990                        self.on_duplicate,
1991                    )?;
1992                }
1993                for template in resource_templates {
1994                    let downstream_uri = template.uri_template.clone();
1995                    let completion_target_admitted =
1996                        self.proxy_resource_template_wins_admission(&downstream_uri);
1997                    self.router.add_final_resource_with_behavior(
1998                        FinalProxyResourceTemplateHandler::new(template, proxy_client.clone()),
1999                        self.on_duplicate,
2000                    )?;
2001                    if completion_target_admitted && completion_supported {
2002                        self.router.add_resource_template_completion_handler(
2003                            downstream_uri.clone(),
2004                            ProxyCompletionHandler::for_resource_template(
2005                                proxy_client.clone(),
2006                                downstream_uri.clone(),
2007                                downstream_uri,
2008                            ),
2009                        );
2010                        self.advertise_completions();
2011                    }
2012                }
2013                for prompt in prompts {
2014                    let downstream_name = prompt.name.clone();
2015                    let completion_target_admitted =
2016                        self.proxy_prompt_wins_admission(&downstream_name);
2017                    self.router.add_final_prompt_with_behavior(
2018                        FinalProxyPromptHandler::new(prompt, proxy_client.clone()),
2019                        self.on_duplicate,
2020                    )?;
2021                    if completion_target_admitted && completion_supported {
2022                        self.router.add_prompt_completion_handler(
2023                            downstream_name.clone(),
2024                            ProxyCompletionHandler::for_prompt(
2025                                proxy_client.clone(),
2026                                downstream_name.clone(),
2027                                downstream_name,
2028                            ),
2029                        );
2030                        self.advertise_completions();
2031                    }
2032                }
2033                if has_tools {
2034                    self.advertise_legacy_tools_list_changed();
2035                }
2036                if has_resources {
2037                    self.advertise_legacy_resource_subscriptions();
2038                }
2039                if has_prompts {
2040                    self.advertise_legacy_prompts_list_changed();
2041                }
2042            }
2043            _ => {
2044                return Err(fastmcp_core::McpError::invalid_request(
2045                    "proxy typed catalog mixes legacy and final component vectors",
2046                ));
2047            }
2048        }
2049        Ok(self)
2050    }
2051
2052    // ─────────────────────────────────────────────────
2053    // Server Composition (Mount)
2054    // ─────────────────────────────────────────────────
2055
2056    /// Mounts another server's components into this server with an optional prefix.
2057    ///
2058    /// This consumes the source server and moves all its tools, resources, and prompts
2059    /// into this server. Names/URIs are prefixed with `prefix/` if a prefix is provided.
2060    ///
2061    /// # Example
2062    ///
2063    /// ```ignore
2064    /// let db_server = Server::new("db", "1.0")
2065    ///     .tool(query_tool)
2066    ///     .tool(insert_tool)
2067    ///     .build();
2068    ///
2069    /// let api_server = Server::new("api", "1.0")
2070    ///     .tool(endpoint_tool)
2071    ///     .build();
2072    ///
2073    /// let main = Server::new("main", "1.0")
2074    ///     .mount(db_server, Some("db"))      // db/query, db/insert
2075    ///     .mount(api_server, Some("api"))    // api/endpoint
2076    ///     .build();
2077    /// ```
2078    ///
2079    /// # Prefix Rules
2080    ///
2081    /// - Prefixes must be alphanumeric plus underscores and hyphens
2082    /// - Prefixes cannot contain slashes
2083    /// - With prefix `"db"`, tool `"query"` becomes `"db/query"`
2084    /// - Without prefix, names are preserved (may cause conflicts)
2085    ///
2086    /// Duplicate handling follows [`on_duplicate`](Self::on_duplicate). With
2087    /// [`DuplicateBehavior::Error`], any conflict rejects the complete mount;
2088    /// the failure is logged and fluent builder construction continues.
2089    #[must_use]
2090    pub fn mount(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2091        #[cfg(feature = "apps")]
2092        if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2093            log::error!(
2094                target: "fastmcp_rust::mount",
2095                "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2096            );
2097            return self;
2098        }
2099
2100        let has_tools = server.has_tools();
2101        let has_resources = server.has_resources();
2102        let has_prompts = server.has_prompts();
2103
2104        let source_router = server.into_router();
2105        let result = self
2106            .router
2107            .mount_with_behavior(source_router, prefix, self.on_duplicate);
2108
2109        // Log warnings if any
2110        for warning in &result.warnings {
2111            log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2112        }
2113        for error in &result.errors {
2114            log::error!(target: "fastmcp_rust::mount", "{}", error);
2115        }
2116
2117        // Update capabilities based on what was mounted
2118        if has_tools && result.tools > 0 {
2119            self.advertise_legacy_tools_list_changed();
2120        }
2121        if has_resources && (result.resources > 0 || result.resource_templates > 0) {
2122            self.advertise_legacy_resource_subscriptions();
2123        }
2124        if has_prompts && result.prompts > 0 {
2125            self.advertise_legacy_prompts_list_changed();
2126        }
2127
2128        self
2129    }
2130
2131    /// Mounts tools and prompts with an optional name prefix, and keeps
2132    /// resource and template URIs exact.
2133    ///
2134    /// Use this when the destination must remain a modern catalog: a nonempty
2135    /// `{prefix}/{uri}` key is not an absolute final URI.
2136    #[must_use]
2137    pub fn mount_preserving_resource_uris(
2138        mut self,
2139        server: crate::Server,
2140        prefix: Option<&str>,
2141    ) -> Self {
2142        #[cfg(feature = "apps")]
2143        if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2144            log::error!(
2145                target: "fastmcp_rust::mount",
2146                "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2147            );
2148            return self;
2149        }
2150
2151        let has_tools = server.has_tools();
2152        let has_resources = server.has_resources();
2153        let has_prompts = server.has_prompts();
2154
2155        let source_router = server.into_router();
2156        let result =
2157            self.router
2158                .mount_namespaced_with_behavior(source_router, prefix, self.on_duplicate);
2159
2160        for warning in &result.warnings {
2161            log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2162        }
2163        for error in &result.errors {
2164            log::error!(target: "fastmcp_rust::mount", "{}", error);
2165        }
2166
2167        if has_tools && result.tools > 0 {
2168            self.advertise_legacy_tools_list_changed();
2169        }
2170        if has_resources && (result.resources > 0 || result.resource_templates > 0) {
2171            self.advertise_legacy_resource_subscriptions();
2172        }
2173        if has_prompts && result.prompts > 0 {
2174            self.advertise_legacy_prompts_list_changed();
2175        }
2176
2177        self
2178    }
2179
2180    /// Mounts only tools from another server with an optional prefix.
2181    ///
2182    /// Similar to [`mount`](Self::mount), but only transfers tools, ignoring
2183    /// resources and prompts.
2184    ///
2185    /// # Example
2186    ///
2187    /// ```ignore
2188    /// let utils_server = Server::new("utils", "1.0")
2189    ///     .tool(format_tool)
2190    ///     .tool(parse_tool)
2191    ///     .resource(config_resource)  // Will NOT be mounted
2192    ///     .build();
2193    ///
2194    /// let main = Server::new("main", "1.0")
2195    ///     .mount_tools(utils_server, Some("utils"))  // Only tools
2196    ///     .build();
2197    /// ```
2198    ///
2199    /// Duplicate handling follows [`on_duplicate`](Self::on_duplicate).
2200    #[must_use]
2201    pub fn mount_tools(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2202        #[cfg(feature = "apps")]
2203        if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2204            log::error!(
2205                target: "fastmcp_rust::mount",
2206                "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2207            );
2208            return self;
2209        }
2210
2211        let source_router = server.into_router();
2212        let result =
2213            self.router
2214                .mount_tools_with_behavior(source_router, prefix, self.on_duplicate);
2215
2216        // Log warnings if any
2217        for warning in &result.warnings {
2218            log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2219        }
2220        for error in &result.errors {
2221            log::error!(target: "fastmcp_rust::mount", "{}", error);
2222        }
2223
2224        // Update capabilities if tools were mounted
2225        if result.tools > 0 {
2226            self.advertise_legacy_tools_list_changed();
2227        }
2228
2229        self
2230    }
2231
2232    /// Mounts only resources from another server with an optional prefix.
2233    ///
2234    /// Similar to [`mount`](Self::mount), but only transfers resources,
2235    /// ignoring tools and prompts.
2236    ///
2237    /// # Example
2238    ///
2239    /// ```ignore
2240    /// let data_server = Server::new("data", "1.0")
2241    ///     .resource(config_resource)
2242    ///     .resource(schema_resource)
2243    ///     .tool(query_tool)  // Will NOT be mounted
2244    ///     .build();
2245    ///
2246    /// let main = Server::new("main", "1.0")
2247    ///     .mount_resources(data_server, Some("data"))  // Only resources
2248    ///     .build();
2249    /// ```
2250    ///
2251    /// Duplicate handling follows [`on_duplicate`](Self::on_duplicate) for
2252    /// both static resources and resource templates.
2253    #[must_use]
2254    pub fn mount_resources(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2255        #[cfg(feature = "apps")]
2256        if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2257            log::error!(
2258                target: "fastmcp_rust::mount",
2259                "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2260            );
2261            return self;
2262        }
2263
2264        let source_router = server.into_router();
2265        let result =
2266            self.router
2267                .mount_resources_with_behavior(source_router, prefix, self.on_duplicate);
2268
2269        // Log warnings if any
2270        for warning in &result.warnings {
2271            log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2272        }
2273        for error in &result.errors {
2274            log::error!(target: "fastmcp_rust::mount", "{}", error);
2275        }
2276
2277        // Update capabilities if resources were mounted
2278        if result.resources > 0 || result.resource_templates > 0 {
2279            self.advertise_legacy_resource_subscriptions();
2280        }
2281
2282        self
2283    }
2284
2285    /// Mounts only prompts from another server with an optional prefix.
2286    ///
2287    /// Similar to [`mount`](Self::mount), but only transfers prompts,
2288    /// ignoring tools and resources.
2289    ///
2290    /// # Example
2291    ///
2292    /// ```ignore
2293    /// let templates_server = Server::new("templates", "1.0")
2294    ///     .prompt(greeting_prompt)
2295    ///     .prompt(error_prompt)
2296    ///     .tool(format_tool)  // Will NOT be mounted
2297    ///     .build();
2298    ///
2299    /// let main = Server::new("main", "1.0")
2300    ///     .mount_prompts(templates_server, Some("tmpl"))  // Only prompts
2301    ///     .build();
2302    /// ```
2303    ///
2304    /// Duplicate handling follows [`on_duplicate`](Self::on_duplicate).
2305    #[must_use]
2306    pub fn mount_prompts(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2307        let source_router = server.into_router();
2308        let result =
2309            self.router
2310                .mount_prompts_with_behavior(source_router, prefix, self.on_duplicate);
2311
2312        // Log warnings if any
2313        for warning in &result.warnings {
2314            log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2315        }
2316        for error in &result.errors {
2317            log::error!(target: "fastmcp_rust::mount", "{}", error);
2318        }
2319
2320        // Update capabilities if prompts were mounted
2321        if result.prompts > 0 {
2322            self.advertise_legacy_prompts_list_changed();
2323        }
2324
2325        self
2326    }
2327
2328    /// Sets custom server instructions.
2329    #[must_use]
2330    pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
2331        self.instructions = Some(instructions.into());
2332        self
2333    }
2334
2335    /// Sets the modern discovery title. Exact-2024 initialize stays name/version.
2336    #[must_use]
2337    pub fn title(mut self, title: impl Into<String>) -> Self {
2338        self.title = Some(title.into());
2339        self
2340    }
2341
2342    /// Sets the modern discovery description.
2343    #[must_use]
2344    pub fn description(mut self, description: impl Into<String>) -> Self {
2345        self.description = Some(description.into());
2346        self
2347    }
2348
2349    /// Sets the modern discovery website URL.
2350    #[must_use]
2351    pub fn website_url(mut self, website_url: impl Into<String>) -> Self {
2352        self.website_url = Some(website_url.into());
2353        self
2354    }
2355
2356    /// Sets the modern discovery icon set.
2357    #[must_use]
2358    pub fn icons(mut self, icons: Vec<fastmcp_protocol::common_types::RawIcon>) -> Self {
2359        self.icons = icons;
2360        self
2361    }
2362
2363    /// Sets the log level.
2364    ///
2365    /// Default is read from `FASTMCP_LOG` environment variable, or `INFO` if not set.
2366    #[must_use]
2367    pub fn log_level(mut self, level: Level) -> Self {
2368        let filter = level.to_level_filter();
2369        self.logging.level = filter;
2370        self.console_config.log_level = filter;
2371        self
2372    }
2373
2374    /// Sets the log level from a filter, including [`LevelFilter::Off`].
2375    #[must_use]
2376    pub fn log_level_filter(mut self, filter: LevelFilter) -> Self {
2377        self.logging.level = filter;
2378        self.console_config.log_level = filter;
2379        self
2380    }
2381
2382    /// Sets whether to show timestamps in logs.
2383    ///
2384    /// Default is `true`.
2385    #[must_use]
2386    pub fn log_timestamps(mut self, show: bool) -> Self {
2387        self.logging.timestamps = show;
2388        self.console_config.log_timestamps = show;
2389        self
2390    }
2391
2392    /// Sets whether to show target/module paths in logs.
2393    ///
2394    /// Default is `true`.
2395    #[must_use]
2396    pub fn log_targets(mut self, show: bool) -> Self {
2397        self.logging.targets = show;
2398        self.console_config.log_targets = show;
2399        self
2400    }
2401
2402    /// Sets whether to show source file and line in logs.
2403    #[must_use]
2404    pub fn log_file_line(mut self, show: bool) -> Self {
2405        self.logging.file_line = show;
2406        self.console_config.log_file_line = show;
2407        self
2408    }
2409
2410    /// Sets the full logging configuration.
2411    #[must_use]
2412    pub fn logging(mut self, config: LoggingConfig) -> Self {
2413        self.console_config.log_level = config.level;
2414        self.console_config.log_timestamps = config.timestamps;
2415        self.console_config.log_targets = config.targets;
2416        self.console_config.log_file_line = config.file_line;
2417        self.logging = config;
2418        self
2419    }
2420
2421    // ─────────────────────────────────────────────────
2422    // Console Configuration
2423    // ─────────────────────────────────────────────────
2424
2425    /// Sets the complete console configuration.
2426    ///
2427    /// This controls server-owned console output, including the banner,
2428    /// traffic logging, logger formatting, and rich/plain display mode.
2429    ///
2430    /// # Example
2431    ///
2432    /// ```ignore
2433    /// use fastmcp_console::config::{ConsoleConfig, BannerStyle};
2434    ///
2435    /// Server::new("demo", "1.0.0")
2436    ///     .with_console_config(
2437    ///         ConsoleConfig::new()
2438    ///             .with_banner(BannerStyle::Compact)
2439    ///             .plain_mode()
2440    ///     )
2441    ///     .build();
2442    /// ```
2443    #[must_use]
2444    pub fn with_console_config(mut self, config: ConsoleConfig) -> Self {
2445        self.logging = LoggingConfig::from(&config);
2446        self.console_config = config;
2447        self
2448    }
2449
2450    /// Sets the banner style.
2451    ///
2452    /// Controls how the startup banner is displayed.
2453    /// Default is `BannerStyle::Full`.
2454    #[must_use]
2455    pub fn with_banner(mut self, style: BannerStyle) -> Self {
2456        self.console_config = self.console_config.with_banner(style);
2457        self
2458    }
2459
2460    /// Disables the startup banner.
2461    #[must_use]
2462    pub fn without_banner(mut self) -> Self {
2463        self.console_config = self.console_config.without_banner();
2464        self
2465    }
2466
2467    /// Enables request/response traffic logging.
2468    ///
2469    /// Controls the verbosity of traffic logging:
2470    /// - `None`: No traffic logging (default)
2471    /// - `Summary`: Method name and timing only
2472    /// - `Full`: Full request/response bodies
2473    #[must_use]
2474    pub fn with_traffic_logging(mut self, verbosity: TrafficVerbosity) -> Self {
2475        self.console_config = self.console_config.with_traffic(verbosity);
2476        self
2477    }
2478
2479    /// Forces plain text output (no colors/styling).
2480    ///
2481    /// Useful for CI environments, logging to files, or when running
2482    /// as an MCP server where rich output might interfere with the
2483    /// JSON-RPC protocol.
2484    #[must_use]
2485    pub fn plain_mode(mut self) -> Self {
2486        self.console_config = self.console_config.plain_mode();
2487        self
2488    }
2489
2490    /// Forces color output even in non-TTY environments.
2491    #[must_use]
2492    pub fn force_color(mut self) -> Self {
2493        self.console_config = self.console_config.force_color(true);
2494        self
2495    }
2496
2497    /// Returns a reference to the current console configuration.
2498    #[must_use]
2499    pub fn console_config(&self) -> &ConsoleConfig {
2500        &self.console_config
2501    }
2502
2503    // ─────────────────────────────────────────────────
2504    // Lifecycle Hooks
2505    // ─────────────────────────────────────────────────
2506
2507    /// Registers a startup hook that runs before the server starts accepting connections.
2508    ///
2509    /// The hook can perform initialization tasks like:
2510    /// - Opening database connections
2511    /// - Loading configuration files
2512    /// - Initializing caches
2513    ///
2514    /// If the hook returns an error, the server will not start.
2515    ///
2516    /// # Example
2517    ///
2518    /// ```ignore
2519    /// Server::new("demo", "1.0.0")
2520    ///     .on_startup(|| {
2521    ///         println!("Server starting up...");
2522    ///         Ok(())
2523    ///     })
2524    ///     .run_stdio();
2525    /// ```
2526    #[must_use]
2527    pub fn on_startup<F, E>(mut self, hook: F) -> Self
2528    where
2529        F: FnOnce() -> Result<(), E> + Send + 'static,
2530        E: std::error::Error + Send + Sync + 'static,
2531    {
2532        self.lifespan.on_startup = Some(Box::new(move || {
2533            hook().map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
2534        }));
2535        self
2536    }
2537
2538    /// Registers a shutdown hook that runs when the server is shutting down.
2539    ///
2540    /// The hook can perform cleanup tasks like:
2541    /// - Closing database connections
2542    /// - Flushing caches
2543    /// - Saving state
2544    ///
2545    /// Shutdown hooks are run on a best-effort basis. If the process is
2546    /// forcefully terminated, hooks may not run.
2547    ///
2548    /// # Example
2549    ///
2550    /// ```ignore
2551    /// Server::new("demo", "1.0.0")
2552    ///     .on_shutdown(|| {
2553    ///         println!("Server shutting down...");
2554    ///     })
2555    ///     .run_stdio();
2556    /// ```
2557    #[must_use]
2558    pub fn on_shutdown<F>(mut self, hook: F) -> Self
2559    where
2560        F: FnOnce() + Send + 'static,
2561    {
2562        self.lifespan.on_shutdown = Some(Box::new(hook));
2563        self
2564    }
2565
2566    /// Retains the legacy task manager for unit-test archaeology only.
2567    /// Production builds expose no task-manager builder edge.
2568    #[cfg(all(test, feature = "tasks"))]
2569    #[must_use]
2570    pub(crate) fn with_task_manager(mut self, task_manager: SharedTaskManager) -> Self {
2571        self.task_manager = Some(task_manager);
2572        // Fail closed even if future builder composition adds another path
2573        // that mutates the capability object before this call.
2574        self.capabilities.tasks = None;
2575        self
2576    }
2577
2578    /// Returns the current request timeout.
2579    #[cfg(test)]
2580    fn request_timeout_secs(&self) -> u64 {
2581        self.request_timeout_secs
2582    }
2583
2584    /// Builds the server after a valid builder-level configuration.
2585    ///
2586    /// Both [`Self::try_new`] and [`Self::protocol_policy`] reject invalid or
2587    /// unavailable policy selections before a builder exists.
2588    ///
2589    /// When the `tasks` feature is enabled and the builder has not already
2590    /// installed a local or proxy Tasks owner, this installs a process-local
2591    /// in-memory official Tasks runtime so `tasks/get`, `tasks/update`, and
2592    /// `tasks/cancel` are served. Call [`Self::final_tasks`] to replace that
2593    /// default with an application-owned store. The historical
2594    /// [`Self::with_task_manager`] path stays quarantined and does not receive
2595    /// the official methods.
2596    #[must_use]
2597    pub fn build(mut self) -> Server {
2598        // Configure router with strict input validation setting
2599        self.router
2600            .set_strict_input_validation(self.strict_input_validation);
2601        let console = fastmcp_console::console::FastMcpConsole::with_enabled(
2602            self.console_config.should_use_rich(),
2603        );
2604        let final_subscriptions = Arc::new(FinalSubscriptionRegistry::default());
2605        #[cfg(feature = "tasks")]
2606        self.install_default_in_memory_final_tasks();
2607        #[cfg(feature = "tasks")]
2608        let final_task_runtime = self.final_task_runtime.clone();
2609        #[cfg(all(feature = "proxy", feature = "tasks"))]
2610        let final_task_relay = self.final_task_relay.clone();
2611        #[cfg(feature = "tasks")]
2612        if let Some(task_runtime) = final_task_runtime.as_ref() {
2613            let subscriptions = Arc::clone(&final_subscriptions);
2614            task_runtime.add_notification_emitter(Arc::new(move |notification| {
2615                if subscriptions.publish_task(notification).is_err() {
2616                    log::error!(
2617                        target: "fastmcp_rust::server",
2618                        "Failed to publish a typed final Task notification"
2619                    );
2620                }
2621            }));
2622        }
2623        #[cfg(feature = "tasks")]
2624        self.router
2625            .set_final_task_runtime(final_task_runtime.clone());
2626        #[cfg(all(feature = "proxy", feature = "tasks"))]
2627        self.router.set_final_task_relay(final_task_relay.clone());
2628        let extension_runtime = match self.extension_runtime {
2629            Some(mut runtime) => {
2630                runtime
2631                    .freeze()
2632                    .expect("validated server extension descriptors must freeze");
2633                Some(Arc::new(runtime))
2634            }
2635            #[cfg(all(feature = "proxy", feature = "tasks"))]
2636            None => match (final_task_runtime.as_ref(), final_task_relay.as_ref()) {
2637                (Some(task_runtime), None) => {
2638                    let mut runtime = ServerExtensionRuntime::with_final_tasks(task_runtime)
2639                        .expect("final Tasks must install into an empty extension registry");
2640                    runtime
2641                        .freeze()
2642                        .expect("final Tasks extension descriptors must freeze");
2643                    Some(Arc::new(runtime))
2644                }
2645                (None, Some(task_relay)) => {
2646                    let mut runtime =
2647                        ServerExtensionRuntime::with_proxy_final_tasks(Arc::clone(task_relay))
2648                            .expect(
2649                                "proxy final Tasks must install into an empty extension registry",
2650                            );
2651                    runtime
2652                        .freeze()
2653                        .expect("proxy final Tasks extension descriptors must freeze");
2654                    Some(Arc::new(runtime))
2655                }
2656                (None, None) => None,
2657                (Some(_), Some(_)) => unreachable!("builder rejects mixed final Tasks owners"),
2658            },
2659            #[cfg(all(feature = "tasks", not(feature = "proxy")))]
2660            None => match final_task_runtime.as_ref() {
2661                Some(task_runtime) => {
2662                    let mut runtime = ServerExtensionRuntime::with_final_tasks(task_runtime)
2663                        .expect("final Tasks must install into an empty extension registry");
2664                    runtime
2665                        .freeze()
2666                        .expect("final Tasks extension descriptors must freeze");
2667                    Some(Arc::new(runtime))
2668                }
2669                None => None,
2670            },
2671            #[cfg(not(feature = "tasks"))]
2672            None => None,
2673        };
2674
2675        Server {
2676            info: self.info,
2677            title: self.title,
2678            description: self.description,
2679            website_url: self.website_url,
2680            icons: self.icons,
2681            capabilities: self.capabilities,
2682            router: Arc::new(self.router),
2683            instructions: self.instructions,
2684            request_timeout_secs: self.request_timeout_secs,
2685            stats: if self.stats_enabled {
2686                Some(ServerStats::new())
2687            } else {
2688                None
2689            },
2690            mask_error_details: self.mask_error_details,
2691            logging: self.logging,
2692            console_config: self.console_config,
2693            console,
2694            lifespan: Mutex::new(Some(self.lifespan)),
2695            auth_provider: self.auth_provider,
2696            middleware: Arc::new(self.middleware),
2697            active_requests: Arc::new(Mutex::new(HashMap::new())),
2698            #[cfg(all(test, feature = "tasks"))]
2699            task_manager: self.task_manager,
2700            max_bidirectional_requests_per_connection: self
2701                .max_bidirectional_requests_per_connection,
2702            protocol_policy: self.protocol_policy,
2703            http_config: self.http_config,
2704            oauth_http_routes: self.oauth_http_routes,
2705            extension_runtime,
2706            #[cfg(feature = "tasks")]
2707            final_task_runtime: self.final_task_runtime,
2708            #[cfg(all(feature = "proxy", feature = "tasks"))]
2709            final_task_relay,
2710            final_subscriptions,
2711        }
2712    }
2713
2714    /// Builds a server through the historical fallible spelling.
2715    ///
2716    /// Invalid launch configuration is rejected by [`Self::try_new`] before
2717    /// the builder exists, so this always returns the result of [`Self::build`].
2718    pub fn try_build(self) -> Result<Server, ServerLaunchPolicyError> {
2719        Ok(self.build())
2720    }
2721}
2722
2723#[cfg(test)]
2724mod tests {
2725    use super::*;
2726    #[cfg(all(feature = "proxy", feature = "tasks"))]
2727    use crate::proxy::{ProxyFinalTaskListener, ProxyFinalTaskListenerEvent};
2728    #[cfg(feature = "proxy")]
2729    use asupersync::Cx;
2730    #[cfg(all(feature = "proxy", feature = "tasks"))]
2731    use fastmcp_client::FinalToolCallOutcome;
2732    #[cfg(all(feature = "proxy", feature = "tasks"))]
2733    use fastmcp_core::block_on;
2734    use fastmcp_core::{McpContext, McpResult};
2735    #[cfg(feature = "apps")]
2736    use fastmcp_protocol::FinalTool;
2737    #[cfg(feature = "apps")]
2738    use fastmcp_protocol::common_types::AbsoluteUri;
2739    #[cfg(feature = "proxy")]
2740    use fastmcp_protocol::common_types::{ContentBlock, Implementation};
2741    #[cfg(feature = "apps")]
2742    use fastmcp_protocol::extensions::ExtensionNegotiationError;
2743    use fastmcp_protocol::protocol_policy::ProtocolPolicy;
2744    #[cfg(feature = "proxy")]
2745    use fastmcp_protocol::protocol_policy::{ProtocolEra, StdioOpeningFrame};
2746    #[cfg(feature = "proxy")]
2747    use fastmcp_protocol::{
2748        CallToolResult, CompleteResult, CoreResult, FinalCallToolResult, FinalCoreResult,
2749        JsonRpcRequest, LegacyContent, LegacyCoreResult, ResultMeta,
2750    };
2751    use fastmcp_protocol::{Content, Prompt, Resource, ResourceContent, Tool};
2752    #[cfg(all(feature = "proxy", feature = "tasks"))]
2753    use fastmcp_protocol::{
2754        CoreResultDiscriminatorPolicy, CreateTaskResult, DecodedResult, EmptyTaskResult,
2755        ResultPeerEra, SubscriptionFilter, decode_peer_result,
2756    };
2757
2758    // ── Stub handlers ────────────────────────────────────────────────
2759
2760    struct TestTool;
2761    impl crate::ToolHandler for TestTool {
2762        fn definition(&self) -> Tool {
2763            Tool {
2764                name: "test_tool".to_string(),
2765                description: Some("a test tool".to_string()),
2766                input_schema: serde_json::json!({"type": "object"}),
2767                output_schema: None,
2768                icon: None,
2769                version: None,
2770                tags: vec![],
2771                annotations: None,
2772            }
2773        }
2774        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2775            Ok(vec![Content::text("ok")])
2776        }
2777    }
2778
2779    struct ExactLegacyOnlyTool;
2780    impl crate::ToolHandler for ExactLegacyOnlyTool {
2781        fn definition(&self) -> Tool {
2782            Tool {
2783                name: "exact_legacy_only".to_owned(),
2784                description: Some("exact 2024-only test tool".to_owned()),
2785                input_schema: serde_json::json!({"type": "object"}),
2786                output_schema: Some(serde_json::json!(false)),
2787                icon: None,
2788                version: None,
2789                tags: Vec::new(),
2790                annotations: None,
2791            }
2792        }
2793
2794        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2795            Ok(vec![Content::text("legacy")])
2796        }
2797    }
2798
2799    #[cfg(feature = "apps")]
2800    struct MountedAppsTool;
2801
2802    #[cfg(feature = "apps")]
2803    impl crate::ToolHandler for MountedAppsTool {
2804        fn definition(&self) -> Tool {
2805            Tool {
2806                name: "mounted_apps_tool".to_owned(),
2807                description: Some("final-only Apps mount fixture".to_owned()),
2808                input_schema: serde_json::json!({"type": "object"}),
2809                output_schema: None,
2810                icon: None,
2811                version: None,
2812                tags: Vec::new(),
2813                annotations: None,
2814            }
2815        }
2816
2817        fn final_definition(&self) -> Option<FinalTool> {
2818            let metadata = fastmcp_protocol::McpAppsToolMetadata::try_new(
2819                Some(
2820                    AbsoluteUri::parse("ui://mount/dashboard")
2821                        .expect("fixed Apps mount URI is valid"),
2822                ),
2823                None,
2824            )
2825            .expect("fixed Apps mount metadata is valid")
2826            .to_open_metadata()
2827            .expect("fixed Apps mount metadata serializes");
2828            Some(FinalTool {
2829                name: "mounted_apps_tool".to_owned(),
2830                title: None,
2831                description: Some("final-only Apps mount fixture".to_owned()),
2832                input_schema: serde_json::json!({"type": "object"}),
2833                output_schema: None,
2834                annotations: None,
2835                icons: None,
2836                meta: Some(metadata),
2837            })
2838        }
2839
2840        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2841            Ok(vec![Content::text("Apps mount fixture")])
2842        }
2843    }
2844
2845    #[cfg(feature = "apps")]
2846    fn apps_mount_child() -> crate::Server {
2847        let resource = McpAppsUiResource::try_new(
2848            AbsoluteUri::parse("ui://mount/dashboard").expect("fixed Apps mount URI is valid"),
2849            "mounted-apps-dashboard",
2850            "<main>Apps mount fixture</main>",
2851        )
2852        .expect("fixed Apps mount resource is valid");
2853        ServerBuilder::new("apps-child", "1.0")
2854            .mcp_apps()
2855            .expect("child Apps extension installs")
2856            .mcp_apps_ui_resource(resource)
2857            .expect("child Apps resource registers")
2858            .mcp_apps_tool(MountedAppsTool)
2859            .expect("child Apps tool registers")
2860            .build()
2861    }
2862
2863    #[cfg(feature = "apps")]
2864    fn apps_resource_bound_tool_mount_child() -> crate::Server {
2865        let resource = McpAppsUiResource::try_new(
2866            AbsoluteUri::parse("ui://mount/dashboard").expect("fixed Apps mount URI is valid"),
2867            "mounted-apps-dashboard",
2868            "<main>Apps mount fixture</main>",
2869        )
2870        .expect("fixed Apps mount resource is valid");
2871        ServerBuilder::new("apps-resource-child", "1.0")
2872            .mcp_apps()
2873            .expect("child Apps extension installs")
2874            .mcp_apps_ui_resource(resource)
2875            .expect("child Apps resource registers")
2876            .tool(TestTool)
2877            .build()
2878    }
2879
2880    struct TestResource;
2881    impl crate::ResourceHandler for TestResource {
2882        fn definition(&self) -> Resource {
2883            Resource {
2884                uri: "file:///test".to_string(),
2885                name: "test_res".to_string(),
2886                description: None,
2887                mime_type: None,
2888                icon: None,
2889                version: None,
2890                tags: vec![],
2891            }
2892        }
2893        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
2894            Ok(vec![ResourceContent {
2895                uri: "file:///test".to_string(),
2896                mime_type: None,
2897                text: Some("content".to_string()),
2898                blob: None,
2899            }])
2900        }
2901    }
2902
2903    struct TestPrompt;
2904    impl crate::PromptHandler for TestPrompt {
2905        fn definition(&self) -> Prompt {
2906            Prompt {
2907                name: "test_prompt".to_string(),
2908                description: None,
2909                arguments: vec![],
2910                icon: None,
2911                version: None,
2912                tags: vec![],
2913            }
2914        }
2915        fn get(
2916            &self,
2917            _ctx: &McpContext,
2918            _args: std::collections::HashMap<String, String>,
2919        ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
2920            Ok(vec![])
2921        }
2922    }
2923
2924    struct TestCompletion;
2925
2926    impl crate::handler::CompletionHandler for TestCompletion {
2927        fn complete_legacy(
2928            &self,
2929            _ctx: &McpContext,
2930            _params: fastmcp_protocol::LegacyCompletionParams,
2931        ) -> McpResult<fastmcp_protocol::CompletionValues> {
2932            Ok(fastmcp_protocol::CompletionValues {
2933                values: vec!["staging".to_string()],
2934                total: Some(1),
2935                has_more: Some(false),
2936            })
2937        }
2938
2939        fn complete_final(
2940            &self,
2941            _ctx: &McpContext,
2942            _params: fastmcp_protocol::FinalCompletionParams,
2943        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
2944            Ok(fastmcp_protocol::FinalCompletionValues {
2945                values: vec!["staging".to_string()],
2946                total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
2947                has_more: Some(false),
2948            })
2949        }
2950    }
2951
2952    struct CountingCompletion(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2953
2954    impl crate::handler::CompletionHandler for CountingCompletion {
2955        fn complete_legacy(
2956            &self,
2957            _ctx: &McpContext,
2958            _params: fastmcp_protocol::LegacyCompletionParams,
2959        ) -> McpResult<fastmcp_protocol::CompletionValues> {
2960            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2961            Ok(fastmcp_protocol::CompletionValues {
2962                values: vec!["staging".to_string()],
2963                total: Some(1),
2964                has_more: Some(false),
2965            })
2966        }
2967
2968        fn complete_final(
2969            &self,
2970            _ctx: &McpContext,
2971            _params: fastmcp_protocol::FinalCompletionParams,
2972        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
2973            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2974            Ok(fastmcp_protocol::FinalCompletionValues {
2975                values: vec!["staging".to_string()],
2976                total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
2977                has_more: Some(false),
2978            })
2979        }
2980    }
2981
2982    struct MarkedTool(&'static str);
2983
2984    impl crate::ToolHandler for MarkedTool {
2985        fn definition(&self) -> Tool {
2986            Tool {
2987                name: "duplicate_tool".to_string(),
2988                description: Some(self.0.to_string()),
2989                input_schema: serde_json::json!({"type": "object"}),
2990                output_schema: None,
2991                icon: None,
2992                version: None,
2993                tags: vec![self.0.to_string()],
2994                annotations: None,
2995            }
2996        }
2997
2998        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2999            Ok(vec![Content::text(self.0)])
3000        }
3001    }
3002
3003    struct MarkedResource(&'static str);
3004
3005    impl crate::ResourceHandler for MarkedResource {
3006        fn definition(&self) -> Resource {
3007            Resource {
3008                uri: "duplicate://resource".to_string(),
3009                name: self.0.to_string(),
3010                description: Some(self.0.to_string()),
3011                mime_type: None,
3012                icon: None,
3013                version: None,
3014                tags: vec![self.0.to_string()],
3015            }
3016        }
3017
3018        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
3019            Ok(vec![])
3020        }
3021    }
3022
3023    struct MarkedPrompt(&'static str);
3024
3025    impl crate::PromptHandler for MarkedPrompt {
3026        fn definition(&self) -> Prompt {
3027            Prompt {
3028                name: "duplicate_prompt".to_string(),
3029                description: Some(self.0.to_string()),
3030                arguments: vec![],
3031                icon: None,
3032                version: None,
3033                tags: vec![self.0.to_string()],
3034            }
3035        }
3036
3037        fn get(
3038            &self,
3039            _ctx: &McpContext,
3040            _args: std::collections::HashMap<String, String>,
3041        ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
3042            Ok(vec![])
3043        }
3044    }
3045
3046    fn marked_resource_template(marker: &str) -> ResourceTemplate {
3047        ResourceTemplate {
3048            uri_template: "duplicate://{item}".to_string(),
3049            name: marker.to_string(),
3050            description: Some(marker.to_string()),
3051            mime_type: None,
3052            icon: None,
3053            version: None,
3054            tags: vec![marker.to_string()],
3055        }
3056    }
3057
3058    fn marked_builder(marker: &'static str) -> ServerBuilder {
3059        ServerBuilder::new("marked", "1.0")
3060            .tool(MarkedTool(marker))
3061            .resource(MarkedResource(marker))
3062            .resource_template(marked_resource_template(marker))
3063            .prompt(MarkedPrompt(marker))
3064    }
3065
3066    fn assert_marked_server(server: &crate::Server, marker: &str) {
3067        assert_eq!(server.tools().len(), 1);
3068        assert_eq!(server.resources().len(), 1);
3069        assert_eq!(server.resource_templates().len(), 1);
3070        assert_eq!(server.prompts().len(), 1);
3071        assert_eq!(server.tools()[0].tags, vec![marker.to_string()]);
3072        assert_eq!(server.resources()[0].tags, vec![marker.to_string()]);
3073        assert_eq!(
3074            server.resource_templates()[0].tags,
3075            vec![marker.to_string()]
3076        );
3077        assert_eq!(server.prompts()[0].tags, vec![marker.to_string()]);
3078    }
3079
3080    #[cfg(feature = "proxy")]
3081    struct DuplicatePolicyProxyBackend;
3082
3083    #[cfg(feature = "proxy")]
3084    impl crate::proxy::ProxyBackend for DuplicatePolicyProxyBackend {
3085        fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
3086            Ok(duplicate_policy_proxy_catalog().tools)
3087        }
3088
3089        fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
3090            Ok(duplicate_policy_proxy_catalog().resources)
3091        }
3092
3093        fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
3094            Ok(duplicate_policy_proxy_catalog().resource_templates)
3095        }
3096
3097        fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
3098            Ok(duplicate_policy_proxy_catalog().prompts)
3099        }
3100
3101        fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
3102            Ok(vec![])
3103        }
3104
3105        fn call_tool_with_progress(
3106            &mut self,
3107            _: &str,
3108            _: serde_json::Value,
3109            _: crate::proxy::ProgressCallback<'_>,
3110        ) -> McpResult<Vec<Content>> {
3111            Ok(vec![])
3112        }
3113
3114        fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
3115            Ok(vec![])
3116        }
3117
3118        fn get_prompt(
3119            &mut self,
3120            _: &str,
3121            _: std::collections::HashMap<String, String>,
3122        ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
3123            Ok(vec![])
3124        }
3125    }
3126
3127    #[cfg(all(feature = "proxy", feature = "tasks"))]
3128    struct OrdinaryProxyTasksListener {
3129        accepted: Option<SubscriptionFilter>,
3130    }
3131
3132    #[cfg(all(feature = "proxy", feature = "tasks"))]
3133    impl ProxyFinalTaskListener for OrdinaryProxyTasksListener {
3134        fn next(
3135            &mut self,
3136            _cx: &Cx,
3137            _request_cancellation: &fastmcp_core::McpRequestCancellation,
3138        ) -> McpResult<ProxyFinalTaskListenerEvent> {
3139            match self.accepted.take() {
3140                Some(accepted) => Ok(ProxyFinalTaskListenerEvent::Acknowledged(accepted)),
3141                None => Ok(ProxyFinalTaskListenerEvent::Terminal),
3142            }
3143        }
3144    }
3145
3146    /// A modern-only upstream whose public final tools/call path can return
3147    /// either official Tasks or MRTR input_required results. The test uses it
3148    /// only through the ordinary public `ServerBuilder::proxy` entry point.
3149    #[cfg(all(feature = "proxy", feature = "tasks"))]
3150    struct OrdinaryProxyTasksBackend {
3151        calls: Arc<Mutex<Vec<String>>>,
3152        updates: Arc<Mutex<Vec<serde_json::Value>>>,
3153        task: CreateTaskResult,
3154    }
3155
3156    #[cfg(all(feature = "proxy", feature = "tasks"))]
3157    impl crate::proxy::ProxyBackend for OrdinaryProxyTasksBackend {
3158        fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
3159            Ok(Vec::new())
3160        }
3161
3162        fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
3163            Ok(Vec::new())
3164        }
3165
3166        fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
3167            Ok(Vec::new())
3168        }
3169
3170        fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
3171            Ok(Vec::new())
3172        }
3173
3174        fn call_tool(
3175            &mut self,
3176            _name: &str,
3177            _arguments: serde_json::Value,
3178        ) -> McpResult<Vec<Content>> {
3179            Err(fastmcp_core::McpError::internal_error(
3180                "ordinary Tasks proxy test must retain the final result algebra",
3181            ))
3182        }
3183
3184        fn call_tool_with_progress(
3185            &mut self,
3186            name: &str,
3187            arguments: serde_json::Value,
3188            _on_progress: crate::proxy::ProgressCallback<'_>,
3189        ) -> McpResult<Vec<Content>> {
3190            self.call_tool(name, arguments)
3191        }
3192
3193        fn read_resource(&mut self, _uri: &str) -> McpResult<Vec<ResourceContent>> {
3194            Err(fastmcp_core::McpError::internal_error("not used"))
3195        }
3196
3197        fn get_prompt(
3198            &mut self,
3199            _name: &str,
3200            _arguments: std::collections::HashMap<String, String>,
3201        ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
3202            Err(fastmcp_core::McpError::internal_error("not used"))
3203        }
3204
3205        fn supports_final_tasks_relay(&mut self) -> McpResult<bool> {
3206            Ok(true)
3207        }
3208
3209        fn call_tool_final_outcome(
3210            &mut self,
3211            name: &str,
3212            arguments: serde_json::Value,
3213        ) -> McpResult<FinalToolCallOutcome> {
3214            self.calls
3215                .lock()
3216                .expect("ordinary proxy task call log is not poisoned")
3217                .push(format!("tools/call:{name}"));
3218            if arguments.get("outcome") == Some(&serde_json::json!("task")) {
3219                return Ok(FinalToolCallOutcome::Task(self.task.clone()));
3220            }
3221            let (decoded, diagnostic) = decode_peer_result(
3222                r#"{"resultType":"input_required","requestState":"upstream-forged-state"}"#,
3223                ResultPeerEra::Modern,
3224                &CoreResultDiscriminatorPolicy,
3225            )
3226            .map_err(|error| fastmcp_core::McpError::invalid_request(error.to_string()))?;
3227            assert!(diagnostic.is_none(), "the fixed final fixture is explicit");
3228            let DecodedResult::InputRequired(result) = decoded else {
3229                return Err(fastmcp_core::McpError::internal_error(
3230                    "ordinary proxy test fixture must decode as input_required",
3231                ));
3232            };
3233            Ok(FinalToolCallOutcome::InputRequired(result))
3234        }
3235
3236        fn update_final_task(
3237            &mut self,
3238            task: &fastmcp_protocol::Task,
3239            input_responses: fastmcp_protocol::TaskInputResponses,
3240        ) -> McpResult<fastmcp_protocol::UpdateTaskResult> {
3241            self.calls
3242                .lock()
3243                .expect("ordinary proxy task call log is not poisoned")
3244                .push("tasks/update".to_owned());
3245            assert_eq!(
3246                task.base().task_id.as_str(),
3247                self.task.task.base().task_id.as_str(),
3248                "the relay supplies the retained upstream task rather than admitting an arbitrary id"
3249            );
3250            self.updates
3251                .lock()
3252                .expect("ordinary proxy task update receipt is not poisoned")
3253                .push(
3254                    serde_json::to_value(&input_responses)
3255                        .expect("the exact public Tasks input response map serializes"),
3256                );
3257            Ok(EmptyTaskResult::default())
3258        }
3259
3260        fn open_final_task_listener(
3261            &mut self,
3262            notifications: SubscriptionFilter,
3263        ) -> McpResult<Box<dyn ProxyFinalTaskListener>> {
3264            self.calls
3265                .lock()
3266                .expect("ordinary proxy task call log is not poisoned")
3267                .push("subscriptions/listen".to_owned());
3268            Ok(Box::new(OrdinaryProxyTasksListener {
3269                accepted: Some(notifications),
3270            }))
3271        }
3272    }
3273
3274    #[cfg(feature = "proxy")]
3275    fn duplicate_policy_proxy_catalog() -> ProxyCatalog {
3276        ProxyCatalog {
3277            tool_catalog_era: Some(ProtocolEra::Legacy2024),
3278            tools: vec![Tool {
3279                name: "test_tool".to_string(),
3280                description: Some("proxied tool".to_string()),
3281                // Final projection of a proxied legacy tool requires an
3282                // object-typed input schema.
3283                input_schema: serde_json::json!({"type": "object"}),
3284                output_schema: None,
3285                icon: None,
3286                version: None,
3287                tags: vec![],
3288                annotations: None,
3289            }],
3290            resources: vec![Resource {
3291                uri: "file:///test".to_string(),
3292                name: "proxied resource".to_string(),
3293                description: Some("proxied resource".to_string()),
3294                mime_type: None,
3295                icon: None,
3296                version: None,
3297                tags: vec![],
3298            }],
3299            prompts: vec![Prompt {
3300                name: "test_prompt".to_string(),
3301                description: Some("proxied prompt".to_string()),
3302                arguments: vec![],
3303                icon: None,
3304                version: None,
3305                tags: vec![],
3306            }],
3307            ..ProxyCatalog::default()
3308        }
3309    }
3310
3311    // ── Builder defaults ─────────────────────────────────────────────
3312
3313    #[test]
3314    fn builder_new_sets_info() {
3315        let builder = ServerBuilder::new("my-server", "2.0.0");
3316        let server = builder.build();
3317        assert_eq!(server.info().name, "my-server");
3318        assert_eq!(server.info().version, "2.0.0");
3319    }
3320
3321    #[test]
3322    fn builder_default_has_logging_capability() {
3323        let builder = ServerBuilder::new("srv", "1.0");
3324        let server = builder.build();
3325        assert!(server.capabilities().logging.is_some());
3326    }
3327
3328    #[test]
3329    fn builder_default_has_no_tool_resource_prompt_capabilities() {
3330        let builder = ServerBuilder::new("srv", "1.0");
3331        let server = builder.build();
3332        assert!(server.capabilities().tools.is_none());
3333        assert!(server.capabilities().resources.is_none());
3334        assert!(server.capabilities().prompts.is_none());
3335    }
3336
3337    #[cfg(feature = "apps")]
3338    #[test]
3339    fn builder_manual_apps_discovery_requires_the_exact_empty_marker_before_build() {
3340        let mut accepted_descriptors = fastmcp_protocol::ExtensionDescriptorRegistry::new();
3341        let accepted_id =
3342            fastmcp_protocol::register_official_mcp_apps_extension(&mut accepted_descriptors)
3343                .expect("the official Apps descriptor registers");
3344        let accepted = ServerBuilder::new("apps-marker", "1.0").extension_registry(
3345            crate::ExtensionHandlerRegistry::new(accepted_descriptors),
3346            ServerExtensionDiscovery {
3347                extensions: std::collections::BTreeMap::from([(
3348                    accepted_id,
3349                    fastmcp_protocol::official_mcp_apps_empty_server_settings(),
3350                )]),
3351            },
3352            |_descriptor: &fastmcp_protocol::ExtensionDescriptor,
3353             _client: &fastmcp_protocol::ExtensionSettings,
3354             _server: &fastmcp_protocol::ExtensionSettings|
3355             -> Result<fastmcp_protocol::ExtensionSettings, ExtensionNegotiationError> {
3356                Ok(fastmcp_protocol::official_mcp_apps_empty_server_settings())
3357            },
3358        );
3359        assert!(
3360            accepted.is_ok(),
3361            "the exact empty official Apps marker is accepted during builder configuration"
3362        );
3363
3364        let mut rejected_descriptors = fastmcp_protocol::ExtensionDescriptorRegistry::new();
3365        let rejected_id =
3366            fastmcp_protocol::register_official_mcp_apps_extension(&mut rejected_descriptors)
3367                .expect("the official Apps descriptor registers");
3368        let rejected = ServerBuilder::new("apps-marker", "1.0").extension_registry(
3369            crate::ExtensionHandlerRegistry::new(rejected_descriptors),
3370            ServerExtensionDiscovery {
3371                extensions: std::collections::BTreeMap::from([(
3372                    rejected_id,
3373                    fastmcp_protocol::ExtensionSettings::new(serde_json::json!({
3374                        "unexpected": true,
3375                    }))
3376                    .expect("the one-field alternate is generic extension metadata"),
3377                )]),
3378            },
3379            |_descriptor: &fastmcp_protocol::ExtensionDescriptor,
3380             _client: &fastmcp_protocol::ExtensionSettings,
3381             _server: &fastmcp_protocol::ExtensionSettings|
3382             -> Result<fastmcp_protocol::ExtensionSettings, ExtensionNegotiationError> {
3383                Ok(fastmcp_protocol::official_mcp_apps_empty_server_settings())
3384            },
3385        );
3386        assert!(
3387            matches!(
3388                rejected,
3389                Err(crate::ServerExtensionConfigurationError::Registry(
3390                    fastmcp_protocol::ExtensionRegistryError::OfficialMcpAppsServerSettingsNotEmpty
3391                ))
3392            ),
3393            "adding one discovery setting is rejected by extension_registry before build"
3394        );
3395    }
3396
3397    #[test]
3398    fn builder_completion_handler_activates_exact_discovery_capability() {
3399        let server = ServerBuilder::new("srv", "1.0")
3400            .completion_handler(TestCompletion)
3401            .build();
3402        let discovery = server
3403            .server_discovery()
3404            .expect("installed completion handler produces discovery");
3405        let wire = serde_json::to_value(discovery).expect("discovery serializes");
3406
3407        assert_eq!(wire["capabilities"]["completions"], serde_json::json!({}));
3408    }
3409
3410    #[test]
3411    fn builder_provider_specific_completion_requires_an_admitted_final_target() {
3412        let unmatched = ServerBuilder::new("srv", "1.0")
3413            .prompt_completion_handler("duplicate_prompt", TestCompletion)
3414            .build();
3415        let unmatched_discovery = unmatched
3416            .server_discovery()
3417            .expect("the unmatched provider-only server still discovers");
3418        let unmatched_wire =
3419            serde_json::to_value(unmatched_discovery).expect("discovery serializes");
3420        assert!(
3421            unmatched_wire["capabilities"].get("completions").is_none(),
3422            "an unbound provider-only registration must not advertise completion"
3423        );
3424
3425        let matched = ServerBuilder::new("srv", "1.0")
3426            .prompt(MarkedPrompt("provider-target"))
3427            .prompt_completion_handler("duplicate_prompt", TestCompletion)
3428            .build();
3429        let matched_discovery = matched
3430            .server_discovery()
3431            .expect("an admitted final prompt activates its provider route");
3432        let matched_wire = serde_json::to_value(matched_discovery).expect("discovery serializes");
3433
3434        assert_eq!(
3435            matched_wire["capabilities"]["completions"],
3436            serde_json::json!({}),
3437            "adding only the final prompt target makes the provider discoverable"
3438        );
3439    }
3440
3441    #[test]
3442    fn builder_without_completion_handler_omits_discovery_capability() {
3443        let server = ServerBuilder::new("srv", "1.0").build();
3444        let discovery = server
3445            .server_discovery()
3446            .expect("server without completion handler still discovers");
3447        let wire = serde_json::to_value(discovery).expect("discovery serializes");
3448
3449        assert!(
3450            wire["capabilities"].get("completions").is_none(),
3451            "absence of the handler must not advertise completion"
3452        );
3453    }
3454
3455    #[test]
3456    fn builder_completion_handler_rejects_final_metadata_before_handler_state_changes() {
3457        let invocations = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3458        let server = ServerBuilder::new("srv", "1.0")
3459            .completion_handler(CountingCompletion(std::sync::Arc::clone(&invocations)))
3460            .build();
3461        let request_ctx = McpContext::new(asupersync::Cx::for_testing(), 93);
3462        let baseline = fastmcp_protocol::JsonRpcRequest::new(
3463            "completion/complete",
3464            Some(serde_json::json!({
3465                "ref": {"type": "ref/prompt", "name": "deploy"},
3466                "argument": {"name": "environment", "value": "sta"},
3467            })),
3468            93_i64,
3469        );
3470        let mut planted = baseline.clone();
3471        planted
3472            .params
3473            .as_mut()
3474            .and_then(serde_json::Value::as_object_mut)
3475            .expect("completion parameters are an object")
3476            .insert(
3477                "_meta".to_string(),
3478                serde_json::json!({
3479                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
3480                }),
3481            );
3482
3483        let baseline_result = server
3484            .router
3485            .dispatch_legacy_completion(&request_ctx, &baseline)
3486            .expect("baseline legacy completion reaches the builder-installed handler");
3487        assert_eq!(
3488            invocations.load(std::sync::atomic::Ordering::SeqCst),
3489            1,
3490            "the accepted request invokes the installed handler once"
3491        );
3492        let planted_before = serde_json::to_vec(&planted).expect("planted request serializes");
3493
3494        let error = server
3495            .router
3496            .dispatch_legacy_completion(&request_ctx, &planted)
3497            .expect_err("the sole final metadata field is refused in the exact legacy route");
3498        assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidParams);
3499        assert_eq!(
3500            invocations.load(std::sync::atomic::Ordering::SeqCst),
3501            1,
3502            "cross-era rejection occurs before handler-owned state can change"
3503        );
3504        assert_eq!(
3505            serde_json::to_vec(&planted).expect("rejected request serializes"),
3506            planted_before,
3507            "the rejected request remains caller-owned and unchanged"
3508        );
3509        assert_eq!(
3510            server
3511                .router
3512                .dispatch_legacy_completion(&request_ctx, &baseline)
3513                .expect("baseline remains dispatchable after the rejection"),
3514            baseline_result,
3515            "the rejected one-field variant cannot alter the accepted completion result"
3516        );
3517        assert_eq!(
3518            invocations.load(std::sync::atomic::Ordering::SeqCst),
3519            2,
3520            "only accepted requests mutate handler-owned state"
3521        );
3522    }
3523
3524    #[test]
3525    fn builder_default_stats_enabled() {
3526        let server = ServerBuilder::new("srv", "1.0").build();
3527        assert!(server.stats().is_some());
3528    }
3529
3530    #[test]
3531    fn builder_default_request_timeout() {
3532        let builder = ServerBuilder::new("srv", "1.0");
3533        assert_eq!(builder.request_timeout_secs(), DEFAULT_REQUEST_TIMEOUT_SECS);
3534    }
3535
3536    #[test]
3537    fn builder_bidirectional_limit_has_exact_validated_boundaries() {
3538        let default = ServerBuilder::new("srv", "1.0");
3539        assert_eq!(
3540            default.max_bidirectional_requests_per_connection,
3541            crate::bidirectional::DEFAULT_MAX_IN_FLIGHT_REQUESTS
3542        );
3543
3544        for valid in [1, crate::bidirectional::HARD_MAX_IN_FLIGHT_REQUESTS] {
3545            let server = ServerBuilder::new("srv", "1.0")
3546                .max_bidirectional_requests_per_connection(valid)
3547                .expect("boundary must be valid")
3548                .build();
3549            assert_eq!(server.max_bidirectional_requests_per_connection, valid);
3550            assert_eq!(
3551                server.new_pending_requests_for_connection().max_in_flight(),
3552                valid
3553            );
3554        }
3555
3556        for invalid in [0, crate::bidirectional::HARD_MAX_IN_FLIGHT_REQUESTS + 1] {
3557            let Err(error) =
3558                ServerBuilder::new("srv", "1.0").max_bidirectional_requests_per_connection(invalid)
3559            else {
3560                panic!("out-of-range limit must fail closed");
3561            };
3562            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidParams);
3563        }
3564    }
3565
3566    #[test]
3567    fn builder_default_error_masking_disabled() {
3568        let builder = ServerBuilder::new("srv", "1.0");
3569        assert!(!builder.is_error_masking_enabled());
3570    }
3571
3572    #[test]
3573    fn builder_default_strict_validation_disabled() {
3574        let builder = ServerBuilder::new("srv", "1.0");
3575        assert!(!builder.is_strict_input_validation_enabled());
3576    }
3577
3578    #[test]
3579    fn explicit_protocol_policy_applies_without_reserved_launch_setting() {
3580        let server = ServerBuilder::from_launch_protocol_policy("srv", "1.0", Ok(None))
3581            .expect("unset launch policy must construct a builder")
3582            .protocol_policy(ProtocolPolicy::ModernOnly)
3583            .expect("ModernOnly must be available to this test build")
3584            .try_build()
3585            .expect("unset launch policy must permit a server");
3586
3587        assert_eq!(server.protocol_policy(), ProtocolPolicy::ModernOnly);
3588    }
3589
3590    #[test]
3591    fn launch_policy_unset_defaults_to_auto() {
3592        assert_eq!(protocol_policy_from_server_launch_value(None), Ok(None));
3593    }
3594
3595    #[cfg(not(feature = "legacy-2024-11-05"))]
3596    #[test]
3597    fn no_legacy_public_builder_defaults_to_modern_only() {
3598        let server = ServerBuilder::try_new("srv", "1.0")
3599            .expect("no-legacy construction must succeed")
3600            .try_build()
3601            .expect("the default no-legacy builder must build");
3602
3603        assert_eq!(server.protocol_policy(), ProtocolPolicy::ModernOnly);
3604    }
3605
3606    #[cfg(not(feature = "legacy-2024-11-05"))]
3607    #[test]
3608    fn no_legacy_source_exposes_a_modern_endpoint_builder_and_gates_the_dual_era_one() {
3609        let source = include_str!("builder.rs");
3610        assert!(source.contains(
3611            "/// Builds a live modern Streamable HTTP endpoint.\n    #[cfg(not(any(feature = \"legacy-2024-11-05\", test)))]\n    pub fn build_http_endpoint"
3612        ));
3613        assert!(
3614            source.contains(
3615                "/// Builds a live dual-era HTTP endpoint with an exact legacy SSE origin."
3616            )
3617        );
3618        assert!(source.contains("crate::ServerHttpEndpointError"));
3619        assert!(!source.contains("fastmcp_transport::http::DualEraHttpEndpointError"));
3620    }
3621
3622    #[cfg(not(feature = "legacy-2024-11-05"))]
3623    #[test]
3624    fn no_legacy_public_builder_rejects_legacy_policies_without_mutation() {
3625        for policy in [ProtocolPolicy::Auto, ProtocolPolicy::LegacyOnly] {
3626            let mut builder =
3627                ServerBuilder::try_new("srv", "1.0").expect("no-legacy construction must succeed");
3628
3629            assert_eq!(
3630                builder.try_set_protocol_policy(policy),
3631                Err(ServerLaunchPolicyError::FeatureUnavailable),
3632                "{policy:?} must reject before changing a no-legacy builder"
3633            );
3634
3635            let server = builder
3636                .try_build()
3637                .expect("a rejected policy must leave the builder buildable");
3638            assert_eq!(
3639                server.protocol_policy(),
3640                ProtocolPolicy::ModernOnly,
3641                "{policy:?} differs from the default only by requiring unavailable legacy behavior"
3642            );
3643        }
3644    }
3645
3646    #[cfg(feature = "legacy-2024-11-05")]
3647    #[test]
3648    fn legacy_enabled_public_builder_preserves_auto() {
3649        let mut builder =
3650            ServerBuilder::try_new("srv", "1.0").expect("legacy-enabled construction must succeed");
3651        builder
3652            .try_set_protocol_policy(ProtocolPolicy::Auto)
3653            .expect("Auto must remain available with the legacy adapter enabled");
3654
3655        let server = builder
3656            .try_build()
3657            .expect("legacy-enabled Auto builder must build");
3658        assert_eq!(server.protocol_policy(), ProtocolPolicy::Auto);
3659    }
3660
3661    #[test]
3662    fn launch_policy_accepts_exact_public_values() {
3663        assert_eq!(
3664            protocol_policy_from_server_launch_value(Some(OsStr::new("auto"))),
3665            Ok(Some(ProtocolPolicy::Auto))
3666        );
3667        assert_eq!(
3668            protocol_policy_from_server_launch_value(Some(OsStr::new("modern-only"))),
3669            Ok(Some(ProtocolPolicy::ModernOnly))
3670        );
3671        assert_eq!(
3672            protocol_policy_from_server_launch_value(Some(OsStr::new("legacy-only"))),
3673            Ok(Some(ProtocolPolicy::LegacyOnly))
3674        );
3675    }
3676
3677    #[cfg(feature = "legacy-2024-11-05")]
3678    #[test]
3679    fn valid_launch_policy_wins_over_explicit_builder_policy() {
3680        let server = ServerBuilder::from_launch_protocol_policy(
3681            "srv",
3682            "1.0",
3683            Ok(Some(ProtocolPolicy::ModernOnly)),
3684        )
3685        .expect("valid launch policy must construct a builder")
3686        .protocol_policy(ProtocolPolicy::LegacyOnly)
3687        .expect("the unit-test dual-era build supports LegacyOnly")
3688        .try_build()
3689        .expect("valid launch policy must build");
3690
3691        assert_eq!(server.protocol_policy(), ProtocolPolicy::ModernOnly);
3692    }
3693
3694    #[cfg(feature = "legacy-2024-11-05")]
3695    #[test]
3696    fn fixed_policy_constructor_reserves_policy_against_later_setter() {
3697        let server = ServerBuilder::try_new_with_fixed_protocol_policy(
3698            "srv",
3699            "1.0",
3700            ProtocolPolicy::ModernOnly,
3701        )
3702        .expect("ModernOnly is available in every feature profile")
3703        .protocol_policy(ProtocolPolicy::LegacyOnly)
3704        .expect("the later policy is valid in this dual-era test build")
3705        .try_build()
3706        .expect("the fixed-policy builder remains buildable");
3707
3708        assert_eq!(
3709            server.protocol_policy(),
3710            ProtocolPolicy::ModernOnly,
3711            "the fixed component policy must not be replaced by a later builder setter"
3712        );
3713    }
3714
3715    #[cfg(not(feature = "legacy-2024-11-05"))]
3716    #[test]
3717    fn fixed_policy_constructor_rejects_unavailable_policy_before_construction() {
3718        assert!(matches!(
3719            ServerBuilder::try_new_with_fixed_protocol_policy("srv", "1.0", ProtocolPolicy::Auto,),
3720            Err(ServerLaunchPolicyError::FeatureUnavailable)
3721        ));
3722    }
3723
3724    #[test]
3725    fn invalid_launch_policy_is_rejected_before_builder_construction() {
3726        let result = ServerBuilder::from_launch_protocol_policy(
3727            "srv",
3728            "1.0",
3729            Err(ServerLaunchPolicyError::InvalidValue),
3730        );
3731
3732        assert!(matches!(result, Err(ServerLaunchPolicyError::InvalidValue)));
3733    }
3734
3735    #[test]
3736    fn launch_policy_parser_rejects_unknown_value_without_auto_fallback() {
3737        assert_eq!(
3738            protocol_policy_from_server_launch_value(Some(OsStr::new("mcp-2025-11-25"))),
3739            Err(ServerLaunchPolicyError::InvalidValue)
3740        );
3741    }
3742
3743    #[cfg(unix)]
3744    #[test]
3745    fn launch_policy_parser_rejects_non_unicode_value_without_panic() {
3746        use std::os::unix::ffi::OsStrExt;
3747
3748        assert_eq!(
3749            protocol_policy_from_server_launch_value(Some(OsStr::from_bytes(b"modern-only\xff"))),
3750            Err(ServerLaunchPolicyError::NonUnicode)
3751        );
3752    }
3753
3754    // ── Fluent API setters ───────────────────────────────────────────
3755
3756    #[test]
3757    fn builder_request_timeout() {
3758        let builder = ServerBuilder::new("srv", "1.0").request_timeout(60);
3759        assert_eq!(builder.request_timeout_secs(), 60);
3760    }
3761
3762    #[test]
3763    fn builder_request_timeout_zero_omits_server_ceiling() {
3764        let builder = ServerBuilder::new("srv", "1.0").request_timeout(0);
3765        assert_eq!(builder.request_timeout_secs(), 0);
3766    }
3767
3768    #[test]
3769    fn builder_without_stats() {
3770        let server = ServerBuilder::new("srv", "1.0").without_stats().build();
3771        assert!(server.stats().is_none());
3772    }
3773
3774    #[test]
3775    fn builder_mask_error_details() {
3776        let builder = ServerBuilder::new("srv", "1.0").mask_error_details(true);
3777        assert!(builder.is_error_masking_enabled());
3778    }
3779
3780    #[test]
3781    fn builder_strict_input_validation() {
3782        let builder = ServerBuilder::new("srv", "1.0").strict_input_validation(true);
3783        assert!(builder.is_strict_input_validation_enabled());
3784    }
3785
3786    #[test]
3787    fn builder_instructions() {
3788        let server = ServerBuilder::new("srv", "1.0")
3789            .instructions("Use this server wisely")
3790            .build();
3791        // instructions stored internally - verify build succeeds
3792        let _ = server;
3793    }
3794
3795    #[test]
3796    fn builder_log_level() {
3797        let builder = ServerBuilder::new("srv", "1.0").log_level(Level::Debug);
3798        assert_eq!(builder.logging.level, LevelFilter::Debug);
3799        assert_eq!(builder.console_config.log_level, LevelFilter::Debug);
3800    }
3801
3802    #[test]
3803    fn builder_log_level_filter() {
3804        let builder = ServerBuilder::new("srv", "1.0").log_level_filter(LevelFilter::Warn);
3805        assert_eq!(builder.logging.level, LevelFilter::Warn);
3806        assert_eq!(builder.console_config.log_level, LevelFilter::Warn);
3807    }
3808
3809    #[test]
3810    fn builder_log_timestamps_and_targets() {
3811        let builder = ServerBuilder::new("srv", "1.0")
3812            .log_timestamps(false)
3813            .log_targets(false)
3814            .log_file_line(true);
3815        assert!(!builder.logging.timestamps);
3816        assert!(!builder.console_config.log_timestamps);
3817        assert!(!builder.logging.targets);
3818        assert!(!builder.console_config.log_targets);
3819        assert!(builder.logging.file_line);
3820        assert!(builder.console_config.log_file_line);
3821    }
3822
3823    // ── Console configuration ────────────────────────────────────────
3824
3825    #[test]
3826    fn builder_without_banner() {
3827        let builder = ServerBuilder::new("srv", "1.0").without_banner();
3828        let config = builder.console_config();
3829        assert_eq!(config.banner_style, BannerStyle::None);
3830    }
3831
3832    #[test]
3833    fn builder_with_banner_compact() {
3834        let builder = ServerBuilder::new("srv", "1.0").with_banner(BannerStyle::Compact);
3835        let config = builder.console_config();
3836        assert_eq!(config.banner_style, BannerStyle::Compact);
3837    }
3838
3839    #[test]
3840    fn builder_plain_mode() {
3841        let builder = ServerBuilder::new("srv", "1.0").plain_mode();
3842        let _config = builder.console_config();
3843    }
3844
3845    // ── Handler registration ─────────────────────────────────────────
3846
3847    #[test]
3848    fn builder_tool_enables_capability() {
3849        let server = ServerBuilder::new("srv", "1.0").tool(TestTool).build();
3850        assert!(server.capabilities().tools.is_some());
3851        assert!(
3852            server
3853                .capabilities()
3854                .tools
3855                .as_ref()
3856                .is_some_and(|tools| tools.list_changed),
3857            "registering a tool must advertise tools.listChanged so clients can watch catalog mutations"
3858        );
3859        assert!(server.has_tools());
3860    }
3861
3862    #[cfg(feature = "apps")]
3863    #[test]
3864    fn builder_mcp_apps_tool_requires_apps_opt_in() {
3865        let Err(error) = ServerBuilder::new("srv", "1.0").mcp_apps_tool(TestTool) else {
3866            panic!("Apps tools must not register before Apps negotiation is configured");
3867        };
3868        assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
3869    }
3870
3871    #[test]
3872    fn builder_legacy_tool_is_explicit_and_does_not_claim_modern_tools() {
3873        let server = ServerBuilder::new("srv", "1.0")
3874            .legacy_tool(ExactLegacyOnlyTool)
3875            .build();
3876        assert!(server.capabilities().tools.is_some());
3877        assert!(server.has_tools());
3878        let router = server.into_router();
3879        assert_eq!(router.tools_count(), 1);
3880        assert!(
3881            !router
3882                .server_discovery_behavior_registry()
3883                .contains(fastmcp_protocol::ServerBehavior::ToolsList)
3884        );
3885    }
3886
3887    #[test]
3888    fn builder_resource_enables_capability() {
3889        let server = ServerBuilder::new("srv", "1.0")
3890            .resource(TestResource)
3891            .build();
3892        assert!(server.capabilities().resources.is_some());
3893        assert!(
3894            server
3895                .capabilities()
3896                .resources
3897                .as_ref()
3898                .is_some_and(|resources| resources.subscribe && resources.list_changed),
3899            "registering a resource must advertise subscribe and listChanged"
3900        );
3901        assert!(server.has_resources());
3902    }
3903
3904    #[test]
3905    fn builder_prompt_enables_capability() {
3906        let server = ServerBuilder::new("srv", "1.0").prompt(TestPrompt).build();
3907        assert!(server.capabilities().prompts.is_some());
3908        assert!(
3909            server
3910                .capabilities()
3911                .prompts
3912                .as_ref()
3913                .is_some_and(|prompts| prompts.list_changed),
3914            "registering a prompt must advertise prompts.listChanged"
3915        );
3916        assert!(server.has_prompts());
3917    }
3918
3919    #[test]
3920    fn builder_all_handlers() {
3921        let server = ServerBuilder::new("srv", "1.0")
3922            .tool(TestTool)
3923            .resource(TestResource)
3924            .prompt(TestPrompt)
3925            .build();
3926        assert!(server.has_tools());
3927        assert!(server.has_resources());
3928        assert!(server.has_prompts());
3929    }
3930
3931    #[test]
3932    fn builder_no_handlers_means_no_capabilities() {
3933        let server = ServerBuilder::new("srv", "1.0").build();
3934        assert!(!server.has_tools());
3935        assert!(!server.has_resources());
3936        assert!(!server.has_prompts());
3937    }
3938
3939    // ── Duplicate behavior ───────────────────────────────────────────
3940
3941    #[test]
3942    fn builder_on_duplicate_default_is_warn() {
3943        let _builder = ServerBuilder::new("srv", "1.0");
3944        // DuplicateBehavior::default() is Warn - builder should use that
3945    }
3946
3947    #[test]
3948    fn builder_on_duplicate_ignore() {
3949        let server = ServerBuilder::new("srv", "1.0")
3950            .on_duplicate(DuplicateBehavior::Ignore)
3951            .tool(TestTool)
3952            .build();
3953        assert!(server.has_tools());
3954    }
3955
3956    #[test]
3957    fn builder_on_duplicate_replace() {
3958        let server = ServerBuilder::new("srv", "1.0")
3959            .on_duplicate(DuplicateBehavior::Replace)
3960            .tool(TestTool)
3961            .build();
3962        assert!(server.has_tools());
3963    }
3964
3965    #[test]
3966    fn builder_resource_template_honors_duplicate_policy() {
3967        for behavior in [
3968            DuplicateBehavior::Warn,
3969            DuplicateBehavior::Ignore,
3970            DuplicateBehavior::Error,
3971        ] {
3972            let server = ServerBuilder::new("srv", "1.0")
3973                .on_duplicate(behavior)
3974                .resource_template(marked_resource_template("original"))
3975                .resource_template(marked_resource_template("incoming"))
3976                .build();
3977            assert_eq!(server.resource_templates().len(), 1);
3978            assert_eq!(server.resource_templates()[0].name, "original");
3979        }
3980
3981        let server = ServerBuilder::new("srv", "1.0")
3982            .on_duplicate(DuplicateBehavior::Replace)
3983            .resource_template(marked_resource_template("original"))
3984            .resource_template(marked_resource_template("incoming"))
3985            .build();
3986        assert_eq!(server.resource_templates().len(), 1);
3987        assert_eq!(server.resource_templates()[0].name, "incoming");
3988    }
3989
3990    // ── Lifecycle hooks ──────────────────────────────────────────────
3991
3992    #[test]
3993    fn builder_on_startup_builds() {
3994        let server = ServerBuilder::new("srv", "1.0")
3995            .on_startup(|| -> Result<(), std::io::Error> { Ok(()) })
3996            .build();
3997        let _ = server;
3998    }
3999
4000    #[test]
4001    fn builder_on_shutdown_builds() {
4002        let server = ServerBuilder::new("srv", "1.0").on_shutdown(|| {}).build();
4003        let _ = server;
4004    }
4005
4006    // ── Console config on built server ───────────────────────────────
4007
4008    #[test]
4009    fn built_server_console_config_matches_builder() {
4010        let server = ServerBuilder::new("srv", "1.0").without_banner().build();
4011        assert_eq!(server.console_config().banner_style, BannerStyle::None);
4012    }
4013
4014    // ── Chaining ─────────────────────────────────────────────────────
4015
4016    #[test]
4017    fn builder_chaining_fluent_api() {
4018        let server = ServerBuilder::new("chain", "3.0")
4019            .request_timeout(120)
4020            .mask_error_details(true)
4021            .strict_input_validation(true)
4022            .without_banner()
4023            .plain_mode()
4024            .tool(TestTool)
4025            .resource(TestResource)
4026            .prompt(TestPrompt)
4027            .on_shutdown(|| {})
4028            .build();
4029
4030        assert_eq!(server.info().name, "chain");
4031        assert_eq!(server.info().version, "3.0");
4032        assert!(server.has_tools());
4033        assert!(server.has_resources());
4034        assert!(server.has_prompts());
4035    }
4036
4037    // ── Console configuration extended ─────────────────────────────
4038
4039    #[test]
4040    fn builder_with_console_config() {
4041        let mut config = ConsoleConfig::new().with_banner(BannerStyle::None);
4042        config.log_level = LevelFilter::Trace;
4043        config.log_timestamps = false;
4044        config.log_targets = false;
4045        config.log_file_line = true;
4046        let builder = ServerBuilder::new("srv", "1.0").with_console_config(config);
4047        assert_eq!(builder.console_config().banner_style, BannerStyle::None);
4048        assert_eq!(builder.logging.level, LevelFilter::Trace);
4049        assert!(!builder.logging.timestamps);
4050        assert!(!builder.logging.targets);
4051        assert!(builder.logging.file_line);
4052    }
4053
4054    #[test]
4055    fn logging_and_console_setters_keep_one_effective_configuration() {
4056        let console = ConsoleConfig::new().with_log_level_filter(LevelFilter::Off);
4057        let builder = ServerBuilder::new("srv", "1.0")
4058            .log_level(Level::Debug)
4059            .with_console_config(console);
4060        assert_eq!(builder.logging.level, LevelFilter::Off);
4061        assert_eq!(builder.console_config.log_level, LevelFilter::Off);
4062
4063        let builder = builder.log_level_filter(LevelFilter::Warn);
4064        assert_eq!(builder.logging.level, LevelFilter::Warn);
4065        assert_eq!(builder.console_config.log_level, LevelFilter::Warn);
4066    }
4067
4068    #[test]
4069    fn builder_with_traffic_logging() {
4070        let builder = ServerBuilder::new("srv", "1.0").with_traffic_logging(TrafficVerbosity::Full);
4071        let config = builder.console_config();
4072        assert_eq!(config.traffic_verbosity, TrafficVerbosity::Full);
4073    }
4074
4075    #[test]
4076    fn builder_force_color() {
4077        let builder = ServerBuilder::new("srv", "1.0").force_color();
4078        let _config = builder.console_config();
4079        // Just verify the chain completes without panic
4080    }
4081
4082    // ── Logging config ─────────────────────────────────────────────
4083
4084    #[test]
4085    fn builder_logging_full_config() {
4086        let config = LoggingConfig {
4087            level: LevelFilter::Trace,
4088            timestamps: false,
4089            targets: false,
4090            file_line: true,
4091        };
4092        let _builder = ServerBuilder::new("srv", "1.0").logging(config);
4093    }
4094
4095    // ── List page size ─────────────────────────────────────────────
4096
4097    #[test]
4098    fn builder_list_page_size() {
4099        let server = ServerBuilder::new("srv", "1.0")
4100            .list_page_size(50)
4101            .tool(TestTool)
4102            .build();
4103        assert!(server.has_tools());
4104    }
4105
4106    // ── Resource template ──────────────────────────────────────────
4107
4108    #[test]
4109    fn builder_resource_template_enables_capability() {
4110        let template = ResourceTemplate {
4111            uri_template: "file://{path}".to_string(),
4112            name: "Template".to_string(),
4113            description: None,
4114            mime_type: None,
4115            icon: None,
4116            version: None,
4117            tags: vec![],
4118        };
4119        let server = ServerBuilder::new("srv", "1.0")
4120            .resource_template(template)
4121            .build();
4122        assert!(server.capabilities().resources.is_some());
4123    }
4124
4125    // ── Middleware ──────────────────────────────────────────────────
4126
4127    struct NoopMiddleware;
4128    impl crate::Middleware for NoopMiddleware {}
4129
4130    #[test]
4131    fn builder_middleware() {
4132        let server = ServerBuilder::new("srv", "1.0")
4133            .middleware(NoopMiddleware)
4134            .build();
4135        let _ = server;
4136    }
4137
4138    #[test]
4139    fn builder_multiple_middleware() {
4140        let server = ServerBuilder::new("srv", "1.0")
4141            .middleware(NoopMiddleware)
4142            .middleware(NoopMiddleware)
4143            .build();
4144        let _ = server;
4145    }
4146
4147    // ── Auth provider ──────────────────────────────────────────────
4148
4149    struct TestAuthProvider;
4150    impl crate::AuthProvider for TestAuthProvider {
4151        fn authenticate(
4152            &self,
4153            _ctx: &McpContext,
4154            _request: crate::auth::AuthRequest<'_>,
4155        ) -> McpResult<fastmcp_core::AuthContext> {
4156            Ok(fastmcp_core::AuthContext::with_subject("test-user"))
4157        }
4158    }
4159
4160    #[test]
4161    fn builder_auth_provider() {
4162        let server = ServerBuilder::new("srv", "1.0")
4163            .auth_provider(TestAuthProvider)
4164            .build();
4165        let _ = server;
4166    }
4167
4168    // ── auto_mask_errors ───────────────────────────────────────────
4169
4170    #[test]
4171    fn builder_auto_mask_errors() {
4172        // In debug builds (test mode), auto_mask_errors defaults to false
4173        let builder = ServerBuilder::new("srv", "1.0").auto_mask_errors();
4174        // In debug_assertions mode, masking should be disabled
4175        assert!(!builder.is_error_masking_enabled());
4176    }
4177
4178    // ── Duplicate behavior error ───────────────────────────────────
4179
4180    struct DupTool(&'static str);
4181    impl crate::ToolHandler for DupTool {
4182        fn definition(&self) -> Tool {
4183            Tool {
4184                name: self.0.to_string(),
4185                description: None,
4186                input_schema: serde_json::json!({"type": "object"}),
4187                output_schema: None,
4188                icon: None,
4189                version: None,
4190                tags: vec![],
4191                annotations: None,
4192            }
4193        }
4194        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
4195            Ok(vec![Content::text("ok")])
4196        }
4197    }
4198
4199    #[test]
4200    fn builder_on_duplicate_error_logs_but_continues() {
4201        // With DuplicateBehavior::Error, duplicate registration logs error
4202        // but builder doesn't panic
4203        let server = ServerBuilder::new("srv", "1.0")
4204            .on_duplicate(DuplicateBehavior::Error)
4205            .tool(DupTool("dup"))
4206            .tool(DupTool("dup")) // duplicate - will log error
4207            .build();
4208        assert!(server.has_tools());
4209    }
4210
4211    // ── Mount ──────────────────────────────────────────────────────
4212
4213    #[test]
4214    fn builder_mount_with_prefix() {
4215        let source = ServerBuilder::new("sub", "1.0")
4216            .tool(TestTool)
4217            .resource(TestResource)
4218            .prompt(TestPrompt)
4219            .build();
4220
4221        let main = ServerBuilder::new("main", "1.0")
4222            .mount(source, Some("sub"))
4223            .build();
4224
4225        assert!(main.has_tools());
4226        assert!(main.has_resources());
4227        assert!(main.has_prompts());
4228    }
4229
4230    #[test]
4231    fn builder_mount_without_prefix() {
4232        let source = ServerBuilder::new("sub", "1.0").tool(TestTool).build();
4233
4234        let main = ServerBuilder::new("main", "1.0")
4235            .mount(source, None)
4236            .build();
4237
4238        assert!(main.has_tools());
4239    }
4240
4241    #[cfg(feature = "apps")]
4242    #[test]
4243    fn builder_mount_rejects_apps_bound_child_without_apps_opt_in_atomically() {
4244        let main = ServerBuilder::new("main", "1.0")
4245            .tool(TestTool)
4246            .resource(TestResource)
4247            .mount(apps_mount_child(), None)
4248            .build();
4249
4250        assert!(main.has_tools());
4251        assert!(main.has_resources());
4252        assert!(
4253            main.extension_registry_receipt().is_none(),
4254            "rejecting a child must not adopt its Apps extension runtime"
4255        );
4256
4257        let router = main.into_router();
4258        assert_eq!(router.tools_count(), 1, "the rejected child adds no tools");
4259        assert_eq!(
4260            router.resources_count(),
4261            1,
4262            "the rejected child adds no resources"
4263        );
4264    }
4265
4266    #[cfg(feature = "apps")]
4267    #[test]
4268    fn builder_mount_accepts_apps_bound_child_with_active_apps_opt_in() {
4269        let main = ServerBuilder::new("main", "1.0")
4270            .mcp_apps()
4271            .expect("parent Apps extension installs")
4272            .mount(apps_mount_child(), None)
4273            .build();
4274
4275        assert!(main.has_tools());
4276        assert!(main.has_resources());
4277        assert_eq!(
4278            main.extension_registry_receipt()
4279                .expect("parent retains its Apps extension runtime")
4280                .descriptor_count(),
4281            1,
4282            "mounting does not transfer or merge the child extension runtime"
4283        );
4284
4285        let router = main.into_router();
4286        assert_eq!(router.tools_count(), 1);
4287        assert_eq!(router.resources_count(), 1);
4288    }
4289
4290    #[cfg(feature = "apps")]
4291    #[test]
4292    fn builder_mount_tools_rejects_apps_bound_child_without_apps_opt_in_atomically() {
4293        let main = ServerBuilder::new("main", "1.0")
4294            .resource(TestResource)
4295            .mount_tools(apps_resource_bound_tool_mount_child(), None)
4296            .build();
4297
4298        assert!(!main.has_tools());
4299        assert!(main.has_resources());
4300        assert!(
4301            main.extension_registry_receipt().is_none(),
4302            "rejecting a child must not adopt its Apps extension runtime"
4303        );
4304
4305        let router = main.into_router();
4306        assert_eq!(router.tools_count(), 0, "the rejected child adds no tools");
4307        assert_eq!(
4308            router.resources_count(),
4309            1,
4310            "the rejected child leaves existing destination resources unchanged"
4311        );
4312    }
4313
4314    #[cfg(feature = "apps")]
4315    #[test]
4316    fn builder_mount_tools_accepts_apps_bound_child_with_active_apps_opt_in() {
4317        let resource = McpAppsUiResource::try_new(
4318            AbsoluteUri::parse("ui://mount/dashboard").expect("fixed Apps mount URI is valid"),
4319            "mounted-apps-dashboard",
4320            "<main>Apps mount fixture</main>",
4321        )
4322        .expect("fixed Apps mount resource is valid");
4323        let main = ServerBuilder::new("main", "1.0")
4324            .mcp_apps()
4325            .expect("parent Apps extension installs")
4326            .mcp_apps_ui_resource(resource)
4327            .expect("parent Apps resource registers")
4328            .mount_tools(apps_mount_child(), None)
4329            .build();
4330
4331        assert!(main.has_tools());
4332        assert!(main.has_resources());
4333        assert_eq!(
4334            main.extension_registry_receipt()
4335                .expect("parent retains its Apps extension runtime")
4336                .descriptor_count(),
4337            1,
4338            "mounting does not transfer or merge the child extension runtime"
4339        );
4340
4341        let router = main.into_router();
4342        assert_eq!(router.tools_count(), 1);
4343        assert_eq!(router.resources_count(), 1);
4344    }
4345
4346    #[cfg(feature = "apps")]
4347    #[test]
4348    fn builder_mount_resources_rejects_apps_bound_child_without_apps_opt_in_atomically() {
4349        let main = ServerBuilder::new("main", "1.0")
4350            .tool(TestTool)
4351            .mount_resources(apps_mount_child(), None)
4352            .build();
4353
4354        assert!(main.has_tools());
4355        assert!(!main.has_resources());
4356        assert!(
4357            main.extension_registry_receipt().is_none(),
4358            "rejecting a child must not adopt its Apps extension runtime"
4359        );
4360
4361        let router = main.into_router();
4362        assert_eq!(
4363            router.tools_count(),
4364            1,
4365            "the rejected child leaves existing destination tools unchanged"
4366        );
4367        assert_eq!(
4368            router.resources_count(),
4369            0,
4370            "the rejected child adds no resources"
4371        );
4372    }
4373
4374    #[cfg(feature = "apps")]
4375    #[test]
4376    fn builder_mount_resources_accepts_apps_bound_child_with_active_apps_opt_in() {
4377        let main = ServerBuilder::new("main", "1.0")
4378            .mcp_apps()
4379            .expect("parent Apps extension installs")
4380            .mount_resources(apps_mount_child(), None)
4381            .build();
4382
4383        assert!(!main.has_tools());
4384        assert!(main.has_resources());
4385        assert_eq!(
4386            main.extension_registry_receipt()
4387                .expect("parent retains its Apps extension runtime")
4388                .descriptor_count(),
4389            1,
4390            "mounting does not transfer or merge the child extension runtime"
4391        );
4392
4393        let router = main.into_router();
4394        assert_eq!(router.tools_count(), 0);
4395        assert_eq!(router.resources_count(), 1);
4396    }
4397
4398    #[test]
4399    fn builder_mount_tools_only() {
4400        let source = ServerBuilder::new("sub", "1.0")
4401            .tool(TestTool)
4402            .resource(TestResource)
4403            .prompt(TestPrompt)
4404            .build();
4405
4406        let main = ServerBuilder::new("main", "1.0")
4407            .mount_tools(source, Some("sub"))
4408            .build();
4409
4410        assert!(main.has_tools());
4411        // Resources and prompts should NOT be mounted
4412        assert!(!main.has_resources());
4413        assert!(!main.has_prompts());
4414    }
4415
4416    #[test]
4417    fn builder_mount_resources_only() {
4418        let source = ServerBuilder::new("sub", "1.0")
4419            .tool(TestTool)
4420            .resource(TestResource)
4421            .prompt(TestPrompt)
4422            .build();
4423
4424        let main = ServerBuilder::new("main", "1.0")
4425            .mount_resources(source, Some("data"))
4426            .build();
4427
4428        assert!(!main.has_tools());
4429        assert!(main.has_resources());
4430        assert!(!main.has_prompts());
4431    }
4432
4433    #[test]
4434    fn builder_mount_prompts_only() {
4435        let source = ServerBuilder::new("sub", "1.0")
4436            .tool(TestTool)
4437            .resource(TestResource)
4438            .prompt(TestPrompt)
4439            .build();
4440
4441        let main = ServerBuilder::new("main", "1.0")
4442            .mount_prompts(source, Some("tmpl"))
4443            .build();
4444
4445        assert!(!main.has_tools());
4446        assert!(!main.has_resources());
4447        assert!(main.has_prompts());
4448    }
4449
4450    #[test]
4451    fn builder_mount_empty_server() {
4452        let source = ServerBuilder::new("empty", "1.0").build();
4453
4454        let main = ServerBuilder::new("main", "1.0")
4455            .mount(source, Some("empty"))
4456            .build();
4457
4458        assert!(!main.has_tools());
4459        assert!(!main.has_resources());
4460        assert!(!main.has_prompts());
4461    }
4462
4463    #[test]
4464    fn builder_full_mount_honors_duplicate_policy_for_all_component_kinds() {
4465        for behavior in [
4466            DuplicateBehavior::Warn,
4467            DuplicateBehavior::Ignore,
4468            DuplicateBehavior::Replace,
4469            DuplicateBehavior::Error,
4470        ] {
4471            let server = marked_builder("original")
4472                .on_duplicate(behavior)
4473                .mount(marked_builder("incoming").build(), None)
4474                .build();
4475            assert_marked_server(
4476                &server,
4477                if behavior == DuplicateBehavior::Replace {
4478                    "incoming"
4479                } else {
4480                    "original"
4481                },
4482            );
4483        }
4484    }
4485
4486    #[test]
4487    fn builder_partial_mounts_honor_duplicate_policy() {
4488        for behavior in [
4489            DuplicateBehavior::Warn,
4490            DuplicateBehavior::Ignore,
4491            DuplicateBehavior::Replace,
4492            DuplicateBehavior::Error,
4493        ] {
4494            let replacement_marker = if behavior == DuplicateBehavior::Replace {
4495                "incoming"
4496            } else {
4497                "original"
4498            };
4499
4500            let tools = marked_builder("original")
4501                .on_duplicate(behavior)
4502                .mount_tools(marked_builder("incoming").build(), None)
4503                .build();
4504            assert_eq!(tools.tools()[0].tags, vec![replacement_marker.to_string()]);
4505            assert_eq!(tools.resources()[0].tags, vec!["original".to_string()]);
4506            assert_eq!(
4507                tools.resource_templates()[0].tags,
4508                vec!["original".to_string()]
4509            );
4510            assert_eq!(tools.prompts()[0].tags, vec!["original".to_string()]);
4511
4512            let resources = marked_builder("original")
4513                .on_duplicate(behavior)
4514                .mount_resources(marked_builder("incoming").build(), None)
4515                .build();
4516            assert_eq!(resources.tools()[0].tags, vec!["original".to_string()]);
4517            assert_eq!(
4518                resources.resources()[0].tags,
4519                vec![replacement_marker.to_string()]
4520            );
4521            assert_eq!(
4522                resources.resource_templates()[0].tags,
4523                vec![replacement_marker.to_string()]
4524            );
4525            assert_eq!(resources.prompts()[0].tags, vec!["original".to_string()]);
4526
4527            let prompts = marked_builder("original")
4528                .on_duplicate(behavior)
4529                .mount_prompts(marked_builder("incoming").build(), None)
4530                .build();
4531            assert_eq!(prompts.tools()[0].tags, vec!["original".to_string()]);
4532            assert_eq!(prompts.resources()[0].tags, vec!["original".to_string()]);
4533            assert_eq!(
4534                prompts.resource_templates()[0].tags,
4535                vec!["original".to_string()]
4536            );
4537            assert_eq!(
4538                prompts.prompts()[0].tags,
4539                vec![replacement_marker.to_string()]
4540            );
4541        }
4542    }
4543
4544    #[test]
4545    fn builder_full_and_partial_mounts_reject_invalid_prefixes() {
4546        let full = marked_builder("original")
4547            .on_duplicate(DuplicateBehavior::Replace)
4548            .mount(marked_builder("incoming").build(), Some("peer/secret"))
4549            .build();
4550        assert_marked_server(&full, "original");
4551
4552        let tools = marked_builder("original")
4553            .on_duplicate(DuplicateBehavior::Replace)
4554            .mount_tools(marked_builder("incoming").build(), Some("peer/secret"))
4555            .build();
4556        assert_marked_server(&tools, "original");
4557
4558        let resources = marked_builder("original")
4559            .on_duplicate(DuplicateBehavior::Replace)
4560            .mount_resources(marked_builder("incoming").build(), Some("peer/secret"))
4561            .build();
4562        assert_marked_server(&resources, "original");
4563
4564        let prompts = marked_builder("original")
4565            .on_duplicate(DuplicateBehavior::Replace)
4566            .mount_prompts(marked_builder("incoming").build(), Some("peer/secret"))
4567            .build();
4568        assert_marked_server(&prompts, "original");
4569    }
4570
4571    #[cfg(feature = "proxy")]
4572    mod proxy_registration_tests {
4573        use super::*;
4574
4575        // ── Proxy registration ─────────────────────────────────────────
4576
4577        struct CompletionProxyBackend {
4578            supported: bool,
4579            result: CoreResult,
4580            calls: Arc<Mutex<Vec<fastmcp_client::CompletionParams>>>,
4581        }
4582
4583        struct LocalFinalCompletionPrompt;
4584
4585        impl crate::PromptHandler for LocalFinalCompletionPrompt {
4586            fn definition(&self) -> Prompt {
4587                Prompt {
4588                    name: "final-deploy".to_owned(),
4589                    description: Some("local completion target".to_owned()),
4590                    arguments: vec![fastmcp_protocol::PromptArgument {
4591                        name: "environment".to_owned(),
4592                        description: None,
4593                        required: false,
4594                    }],
4595                    icon: None,
4596                    version: None,
4597                    tags: Vec::new(),
4598                }
4599            }
4600
4601            fn final_definition(&self) -> Option<fastmcp_protocol::FinalPrompt> {
4602                Some(fastmcp_protocol::FinalPrompt {
4603                    name: "final-deploy".to_owned(),
4604                    title: Some("Local Final Deploy".to_owned()),
4605                    description: Some("local completion target".to_owned()),
4606                    icons: None,
4607                    arguments: Some(vec![fastmcp_protocol::FinalPromptArgument {
4608                        name: "environment".to_owned(),
4609                        title: Some("Local Environment".to_owned()),
4610                        description: None,
4611                        required: Some(false),
4612                    }]),
4613                    meta: None,
4614                })
4615            }
4616
4617            fn get(
4618                &self,
4619                _ctx: &McpContext,
4620                _args: std::collections::HashMap<String, String>,
4621            ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
4622                Ok(Vec::new())
4623            }
4624        }
4625
4626        impl crate::proxy::ProxyBackend for CompletionProxyBackend {
4627            fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
4628                Ok(Vec::new())
4629            }
4630
4631            fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
4632                Ok(Vec::new())
4633            }
4634
4635            fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
4636                Ok(Vec::new())
4637            }
4638
4639            fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
4640                Ok(Vec::new())
4641            }
4642
4643            fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
4644                Ok(Vec::new())
4645            }
4646
4647            fn call_tool_with_progress(
4648                &mut self,
4649                _: &str,
4650                _: serde_json::Value,
4651                _: crate::proxy::ProgressCallback<'_>,
4652            ) -> McpResult<Vec<Content>> {
4653                Ok(Vec::new())
4654            }
4655
4656            fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
4657                Ok(Vec::new())
4658            }
4659
4660            fn get_prompt(
4661                &mut self,
4662                _: &str,
4663                _: std::collections::HashMap<String, String>,
4664            ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
4665                Ok(Vec::new())
4666            }
4667
4668            fn supports_completion(&mut self) -> McpResult<bool> {
4669                Ok(self.supported)
4670            }
4671
4672            fn complete_result(
4673                &mut self,
4674                params: fastmcp_client::CompletionParams,
4675            ) -> McpResult<CoreResult> {
4676                self.calls
4677                    .lock()
4678                    .expect("completion proxy call log is not poisoned")
4679                    .push(params);
4680                Ok(self.result.clone())
4681            }
4682        }
4683
4684        fn legacy_completion_proxy_catalog() -> ProxyTypedCatalog {
4685            ProxyTypedCatalog {
4686                tools: ProxyToolCatalog::Legacy(Vec::new()),
4687                resources: ProxyResourceCatalog::Legacy(Vec::new()),
4688                resource_templates: ProxyResourceTemplateCatalog::Legacy(Vec::new()),
4689                prompts: ProxyPromptCatalog::Legacy(vec![Prompt {
4690                    name: "legacy-deploy".to_owned(),
4691                    description: None,
4692                    arguments: Vec::new(),
4693                    icon: None,
4694                    version: None,
4695                    tags: Vec::new(),
4696                }]),
4697            }
4698        }
4699
4700        fn final_completion_proxy_catalog() -> ProxyTypedCatalog {
4701            let prompt = serde_json::from_value(serde_json::json!({
4702                "name": "final-deploy",
4703                "title": "Upstream Final Deploy",
4704                "arguments": [{"name": "environment", "title": "Environment"}]
4705            }))
4706            .expect("the final completion prompt fixture is valid");
4707            ProxyTypedCatalog {
4708                tools: ProxyToolCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4709                resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4710                resource_templates: ProxyResourceTemplateCatalog::Final(ProxyFinalCatalog::new(
4711                    Vec::new(),
4712                )),
4713                prompts: ProxyPromptCatalog::Final(ProxyFinalCatalog::new(vec![prompt])),
4714            }
4715        }
4716
4717        fn final_completion_proxy_template_catalog() -> ProxyTypedCatalog {
4718            let template = serde_json::from_value(serde_json::json!({
4719                "uriTemplate": "completion://{environment}",
4720                "name": "upstream-completion-template",
4721                "title": "Upstream Completion Template",
4722            }))
4723            .expect("the final completion resource-template fixture is valid");
4724            ProxyTypedCatalog {
4725                tools: ProxyToolCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4726                resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4727                resource_templates: ProxyResourceTemplateCatalog::Final(ProxyFinalCatalog::new(
4728                    vec![template],
4729                )),
4730                prompts: ProxyPromptCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4731            }
4732        }
4733
4734        fn legacy_completion_proxy_template_catalog() -> ProxyTypedCatalog {
4735            ProxyTypedCatalog {
4736                tools: ProxyToolCatalog::Legacy(Vec::new()),
4737                resources: ProxyResourceCatalog::Legacy(Vec::new()),
4738                resource_templates: ProxyResourceTemplateCatalog::Legacy(vec![ResourceTemplate {
4739                    uri_template: "completion://{environment}".to_owned(),
4740                    name: "upstream-completion-template".to_owned(),
4741                    description: None,
4742                    mime_type: None,
4743                    icon: None,
4744                    version: None,
4745                    tags: Vec::new(),
4746                }]),
4747                prompts: ProxyPromptCatalog::Legacy(Vec::new()),
4748            }
4749        }
4750
4751        fn local_completion_template() -> ResourceTemplate {
4752            ResourceTemplate {
4753                uri_template: "completion://{environment}".to_owned(),
4754                name: "local-completion-template".to_owned(),
4755                description: Some("local completion target".to_owned()),
4756                mime_type: None,
4757                icon: None,
4758                version: None,
4759                tags: Vec::new(),
4760            }
4761        }
4762
4763        fn legacy_completion_proxy_result() -> CoreResult {
4764            CoreResult::Legacy(LegacyCoreResult::Completion(
4765                fastmcp_protocol::LegacyCompletionResult {
4766                    completion: fastmcp_protocol::CompletionValues {
4767                        values: vec!["legacy-staging".to_owned()],
4768                        total: Some(1),
4769                        has_more: Some(false),
4770                    },
4771                    meta: None,
4772                },
4773            ))
4774        }
4775
4776        fn final_completion_proxy_result() -> CoreResult {
4777            CoreResult::Final(FinalCoreResult::Completion {
4778                result: CompleteResult::new(
4779                    fastmcp_protocol::FinalCompletionResult {
4780                        completion: fastmcp_protocol::FinalCompletionValues {
4781                            values: vec!["final-staging".to_owned()],
4782                            total: Some(
4783                                serde_json::from_str("92233720368547758081234567890")
4784                                    .expect("the fixed exact completion total is a JSON integer"),
4785                            ),
4786                            has_more: Some(false),
4787                        },
4788                    },
4789                    ResultMeta::server_generated(
4790                        Implementation::try_new("completion-upstream", "1.0")
4791                            .expect("the fixed completion implementation is valid"),
4792                    ),
4793                ),
4794                diagnostic: None,
4795            })
4796        }
4797
4798        fn final_completion_request(id: i64) -> JsonRpcRequest {
4799            JsonRpcRequest::new(
4800                "completion/complete",
4801                Some(serde_json::json!({
4802                    "_meta": {
4803                        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4804                        "io.modelcontextprotocol/clientCapabilities": {},
4805                    },
4806                    "ref": {
4807                        "type": "ref/prompt",
4808                        "name": "final-deploy",
4809                        "title": "Final Deploy",
4810                    },
4811                    "argument": {"name": "environment", "value": "sta"},
4812                    "context": {"arguments": {"region": "us-east-1"}},
4813                })),
4814                id,
4815            )
4816        }
4817
4818        fn final_resource_template_completion_request(id: i64) -> JsonRpcRequest {
4819            JsonRpcRequest::new(
4820                "completion/complete",
4821                Some(serde_json::json!({
4822                    "_meta": {
4823                        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4824                        "io.modelcontextprotocol/clientCapabilities": {},
4825                    },
4826                    "ref": {
4827                        "type": "ref/resource",
4828                        "uri": "completion://{environment}",
4829                    },
4830                    "argument": {"name": "environment", "value": "sta"},
4831                })),
4832                id,
4833            )
4834        }
4835
4836        fn legacy_completion_request(id: i64, reference: serde_json::Value) -> JsonRpcRequest {
4837            JsonRpcRequest::new(
4838                "completion/complete",
4839                Some(serde_json::json!({
4840                    "ref": reference,
4841                    "argument": {"name": "environment", "value": "sta"},
4842                })),
4843                id,
4844            )
4845        }
4846
4847        fn final_proxy_catalog() -> ProxyCatalog {
4848            ProxyCatalog {
4849                tool_catalog_era: Some(ProtocolEra::Modern2026),
4850                final_tools: vec![
4851                    serde_json::from_value(serde_json::json!({
4852                        "name": "weather",
4853                        "title": "Weather Forecast",
4854                        "description": "Returns a precise forecast.",
4855                        "icons": [{
4856                            "src": "https://example.test/icons/weather.svg",
4857                            "mimeType": "image/svg+xml",
4858                            "sizes": ["16x16", "32x32"],
4859                            "theme": "light",
4860                            "com.example/icon": {"retained": true}
4861                        }],
4862                        "inputSchema": {
4863                            "type": "object",
4864                            "properties": {"city": {"type": "string"}}
4865                        },
4866                        "outputSchema": {"type": "object"},
4867                        "annotations": {
4868                            "title": "Forecast",
4869                            "destructiveHint": false,
4870                            "idempotentHint": true,
4871                            "readOnlyHint": true,
4872                            "openWorldHint": false
4873                        },
4874                        "_meta": {"com.example/catalog": {"retained": true}}
4875                    }))
4876                    .expect("the exact final tool fixture is valid"),
4877                ],
4878                ..ProxyCatalog::default()
4879            }
4880        }
4881
4882        fn legacy_proxy_catalog() -> ProxyCatalog {
4883            ProxyCatalog {
4884                tool_catalog_era: Some(ProtocolEra::Legacy2024),
4885                tools: vec![Tool {
4886                    name: "legacy-weather".to_owned(),
4887                    description: Some("Exact legacy proxy fixture".to_owned()),
4888                    input_schema: serde_json::json!({"type": "object"}),
4889                    output_schema: None,
4890                    icon: None,
4891                    version: None,
4892                    tags: Vec::new(),
4893                    annotations: None,
4894                }],
4895                ..ProxyCatalog::default()
4896            }
4897        }
4898
4899        fn final_tools_list_request(id: i64) -> JsonRpcRequest {
4900            JsonRpcRequest::new(
4901                "tools/list",
4902                Some(serde_json::json!({
4903                    "_meta": {
4904                        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4905                        "io.modelcontextprotocol/clientCapabilities": {},
4906                    },
4907                })),
4908                id,
4909            )
4910        }
4911
4912        fn final_tools_call_request(
4913            name: &str,
4914            arguments: serde_json::Value,
4915            id: i64,
4916        ) -> JsonRpcRequest {
4917            JsonRpcRequest::new(
4918                "tools/call",
4919                Some(serde_json::json!({
4920                    "_meta": {
4921                        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4922                        "io.modelcontextprotocol/clientCapabilities": {},
4923                    },
4924                    "name": name,
4925                    "arguments": arguments,
4926                })),
4927                id,
4928            )
4929        }
4930
4931        fn bound_proxy_client<B: crate::proxy::ProxyBackend + 'static>(
4932            backend: B,
4933            era: ProtocolEra,
4934        ) -> ProxyClient {
4935            let mut bindings = ProxyClient::upstream_binding_registry();
4936            let policy = match era {
4937                ProtocolEra::Modern2026 => ProtocolPolicy::ModernOnly,
4938                ProtocolEra::Legacy2024 => ProtocolPolicy::LegacyOnly,
4939            };
4940            let opening = match era {
4941                ProtocolEra::Modern2026 => StdioOpeningFrame::ModernRequest {
4942                    protocol_version: era.version().as_str().to_owned(),
4943                },
4944                ProtocolEra::Legacy2024 => StdioOpeningFrame::LegacyInitialize,
4945            };
4946            let binding = bindings
4947                .bind_stdio(
4948                    "weather-route",
4949                    "stdio:weather",
4950                    "final-catalog-receipt",
4951                    1,
4952                    policy,
4953                    opening,
4954                )
4955                .expect("the route selects the requested exact era");
4956            let upstream_protocol_version = era.version().as_str().to_owned();
4957            ProxyClient::from_backend_with_upstream_binding(
4958                backend,
4959                binding,
4960                &upstream_protocol_version,
4961            )
4962            .expect("the selected upstream version matches its immutable binding")
4963        }
4964
4965        fn final_catalog_proxy_client(era: ProtocolEra) -> ProxyClient {
4966            bound_proxy_client(DuplicatePolicyProxyBackend, era)
4967        }
4968
4969        #[cfg(feature = "tasks")]
4970        fn ordinary_proxy_tasks_client(
4971            calls: Arc<Mutex<Vec<String>>>,
4972            updates: Arc<Mutex<Vec<serde_json::Value>>>,
4973        ) -> ProxyClient {
4974            let task = serde_json::from_value(serde_json::json!({
4975                "resultType": "task",
4976                "taskId": "ordinary-proxy-task-71",
4977                "status": "input_required",
4978                "createdAt": "2026-07-28T12:00:00Z",
4979                "lastUpdatedAt": "2026-07-28T12:00:00Z",
4980                "ttlMs": null,
4981                "inputRequests": {},
4982            }))
4983            .expect("the ordinary proxy Task fixture is exact");
4984            bound_proxy_client(
4985                OrdinaryProxyTasksBackend {
4986                    calls,
4987                    updates,
4988                    task,
4989                },
4990                ProtocolEra::Modern2026,
4991            )
4992        }
4993
4994        fn discovered_duplicate_policy_proxy() -> (ProxyClient, ProxyCatalog) {
4995            let client = ProxyClient::from_backend(DuplicatePolicyProxyBackend);
4996            let catalog = client
4997                .catalog()
4998                .expect("backend discovery supplies the legacy era evidence");
4999            (client, catalog)
5000        }
5001
5002        #[derive(Clone, Copy)]
5003        enum DualEraProxyRoute {
5004            Legacy,
5005            Final,
5006        }
5007
5008        struct RecordingDualEraProxyBackend {
5009            route: DualEraProxyRoute,
5010            calls: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5011        }
5012
5013        impl RecordingDualEraProxyBackend {
5014            fn record(&self, name: &str, arguments: serde_json::Value) {
5015                self.calls
5016                    .lock()
5017                    .expect("the test call log lock is not poisoned")
5018                    .push((name.to_owned(), arguments));
5019            }
5020        }
5021
5022        impl crate::proxy::ProxyBackend for RecordingDualEraProxyBackend {
5023            fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
5024                Ok(Vec::new())
5025            }
5026
5027            fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
5028                Ok(Vec::new())
5029            }
5030
5031            fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
5032                Ok(Vec::new())
5033            }
5034
5035            fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
5036                Ok(Vec::new())
5037            }
5038
5039            fn call_tool(
5040                &mut self,
5041                name: &str,
5042                arguments: serde_json::Value,
5043            ) -> McpResult<Vec<Content>> {
5044                self.record(name, arguments);
5045                Ok(vec![Content::text(match self.route {
5046                    DualEraProxyRoute::Legacy => "bound legacy proxy",
5047                    DualEraProxyRoute::Final => "bound final proxy",
5048                })])
5049            }
5050
5051            fn call_tool_with_progress(
5052                &mut self,
5053                name: &str,
5054                arguments: serde_json::Value,
5055                _: crate::proxy::ProgressCallback<'_>,
5056            ) -> McpResult<Vec<Content>> {
5057                self.call_tool(name, arguments)
5058            }
5059
5060            fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
5061                Ok(Vec::new())
5062            }
5063
5064            fn get_prompt(
5065                &mut self,
5066                _: &str,
5067                _: std::collections::HashMap<String, String>,
5068            ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
5069                Ok(Vec::new())
5070            }
5071
5072            fn call_tool_result(
5073                &mut self,
5074                name: &str,
5075                arguments: serde_json::Value,
5076            ) -> McpResult<CoreResult> {
5077                self.record(name, arguments);
5078                match self.route {
5079                    DualEraProxyRoute::Legacy => Ok(CoreResult::Legacy(
5080                        LegacyCoreResult::ToolsCall(CallToolResult {
5081                            content: vec![LegacyContent::Text {
5082                                text: "bound legacy proxy".to_owned(),
5083                                annotations: None,
5084                                additional: std::collections::BTreeMap::new(),
5085                            }],
5086                            is_error: false,
5087                            meta: None,
5088                            additional: std::collections::BTreeMap::new(),
5089                        }),
5090                    )),
5091                    DualEraProxyRoute::Final => Ok(CoreResult::Final(FinalCoreResult::ToolsCall {
5092                        result: CompleteResult::new(
5093                            FinalCallToolResult {
5094                                content: vec![ContentBlock::text("bound final proxy")],
5095                                is_error: false,
5096                                structured_content: Some(serde_json::json!({"route": "final"})),
5097                            },
5098                            ResultMeta::server_generated(
5099                                Implementation::try_new("bound-final-upstream", "1.0")
5100                                    .expect("the fixed test implementation is valid"),
5101                            ),
5102                        ),
5103                        diagnostic: None,
5104                    })),
5105                }
5106            }
5107        }
5108
5109        fn dual_era_proxy_client(
5110            route: DualEraProxyRoute,
5111            calls: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5112        ) -> ProxyClient {
5113            let era = match route {
5114                DualEraProxyRoute::Legacy => ProtocolEra::Legacy2024,
5115                DualEraProxyRoute::Final => ProtocolEra::Modern2026,
5116            };
5117            let (route_id, upstream_identity) = match route {
5118                DualEraProxyRoute::Legacy => ("dual-era-legacy-route", "stdio:dual-era-legacy"),
5119                DualEraProxyRoute::Final => ("dual-era-final-route", "stdio:dual-era-final"),
5120            };
5121            let policy = match era {
5122                ProtocolEra::Modern2026 => ProtocolPolicy::ModernOnly,
5123                ProtocolEra::Legacy2024 => ProtocolPolicy::LegacyOnly,
5124            };
5125            let opening = match era {
5126                ProtocolEra::Modern2026 => StdioOpeningFrame::ModernRequest {
5127                    protocol_version: era.version().as_str().to_owned(),
5128                },
5129                ProtocolEra::Legacy2024 => StdioOpeningFrame::LegacyInitialize,
5130            };
5131            let mut bindings = ProxyClient::upstream_binding_registry();
5132            let binding = bindings
5133                .bind_stdio(
5134                    route_id,
5135                    upstream_identity,
5136                    "dual-era-receipt",
5137                    1,
5138                    policy,
5139                    opening,
5140                )
5141                .expect("the test route selects one immutable era");
5142            let upstream_protocol_version = era.version().as_str().to_owned();
5143            ProxyClient::from_backend_with_upstream_binding(
5144                RecordingDualEraProxyBackend { route, calls },
5145                binding,
5146                &upstream_protocol_version,
5147            )
5148            .expect("the selected upstream version matches its immutable binding")
5149        }
5150
5151        fn dual_era_proxy_server() -> (
5152            crate::Server,
5153            Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5154            Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5155        ) {
5156            let legacy_calls = Arc::new(Mutex::new(Vec::new()));
5157            let final_calls = Arc::new(Mutex::new(Vec::new()));
5158            let server = ServerBuilder::new("srv", "1.0")
5159                .proxy(
5160                    dual_era_proxy_client(DualEraProxyRoute::Legacy, Arc::clone(&legacy_calls)),
5161                    legacy_proxy_catalog(),
5162                )
5163                .expect("the legacy proxy catalog agrees with its bound route")
5164                .proxy(
5165                    dual_era_proxy_client(DualEraProxyRoute::Final, Arc::clone(&final_calls)),
5166                    final_proxy_catalog(),
5167                )
5168                .expect("the final proxy catalog agrees with its bound route")
5169                .build();
5170            (server, legacy_calls, final_calls)
5171        }
5172
5173        fn initialized_legacy_proxy_session(server: &crate::Server) -> crate::Session {
5174            let mut session =
5175                crate::Session::new(server.info().clone(), server.capabilities().clone());
5176            session.initialize(
5177                fastmcp_protocol::ClientInfo {
5178                    name: "dual-era-legacy-client".to_owned(),
5179                    version: "1.0".to_owned(),
5180                },
5181                fastmcp_protocol::ClientCapabilities::default(),
5182                "2024-11-05".to_owned(),
5183            );
5184            session
5185        }
5186
5187        fn assert_rejected_proxy_catalog_returns_an_error(catalog: ProxyCatalog) {
5188            let error = match ServerBuilder::new("srv", "1.0").tool(TestTool).proxy(
5189                ProxyClient::from_backend(DuplicatePolicyProxyBackend),
5190                catalog,
5191            ) {
5192                Ok(_) => {
5193                    panic!("the malformed or unbound proxy catalog is rejected before registration")
5194                }
5195                Err(error) => error,
5196            };
5197
5198            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
5199        }
5200
5201        #[test]
5202        fn builder_proxy_accepts_a_coherent_legacy_catalog() {
5203            let server = ServerBuilder::new("srv", "1.0")
5204                .proxy(
5205                    final_catalog_proxy_client(ProtocolEra::Legacy2024),
5206                    legacy_proxy_catalog(),
5207                )
5208                .expect("the coherent legacy catalog agrees with its route")
5209                .build();
5210
5211            assert!(server.has_tools());
5212            let tools = server.tools();
5213            assert_eq!(
5214                tools[0].name, "legacy-weather",
5215                "the public builder path registers a catalog whose marker and entries select legacy"
5216            );
5217        }
5218
5219        #[test]
5220        fn builder_proxy_rejects_an_unbound_caller_asserted_legacy_catalog() {
5221            let error = match ServerBuilder::new("srv", "1.0").proxy(
5222                ProxyClient::from_backend(DuplicatePolicyProxyBackend),
5223                legacy_proxy_catalog(),
5224            ) {
5225                Ok(_) => panic!("a caller-supplied catalog cannot bind an unbound proxy route"),
5226                Err(error) => error,
5227            };
5228
5229            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
5230            assert!(error.message.contains("cannot bind an unbound route"));
5231        }
5232
5233        #[test]
5234        fn builder_proxy_rejects_legacy_tools_when_only_the_marker_changes_to_modern() {
5235            let mut catalog = legacy_proxy_catalog();
5236            catalog.tool_catalog_era = Some(ProtocolEra::Modern2026);
5237
5238            assert_rejected_proxy_catalog_returns_an_error(catalog);
5239        }
5240
5241        #[test]
5242        fn builder_proxy_rejects_final_tools_when_only_the_marker_changes_to_legacy() {
5243            let mut catalog = final_proxy_catalog();
5244            catalog.tool_catalog_era = Some(ProtocolEra::Legacy2024);
5245
5246            assert_rejected_proxy_catalog_returns_an_error(catalog);
5247        }
5248
5249        #[test]
5250        fn builder_proxy_rejects_mixed_vectors_when_only_final_tools_are_added() {
5251            let mut catalog = legacy_proxy_catalog();
5252            catalog.final_tools = final_proxy_catalog().final_tools;
5253
5254            assert_rejected_proxy_catalog_returns_an_error(catalog);
5255        }
5256
5257        #[test]
5258        fn builder_proxy_rejects_a_missing_marker() {
5259            let mut catalog = legacy_proxy_catalog();
5260            catalog.tool_catalog_era = None;
5261
5262            assert_rejected_proxy_catalog_returns_an_error(catalog);
5263        }
5264
5265        #[test]
5266        fn public_builder_proxy_advertises_and_lists_the_exact_final_tool_catalog() {
5267            let catalog = final_proxy_catalog();
5268            let expected = serde_json::to_value(&catalog.final_tools[0])
5269                .expect("the final fixture serializes");
5270            let server = ServerBuilder::new("srv", "1.0")
5271                .proxy(final_catalog_proxy_client(ProtocolEra::Modern2026), catalog)
5272                .expect("the final catalog agrees with its bound route")
5273                .build();
5274
5275            assert!(server.has_tools());
5276            assert!(server.tools().is_empty());
5277            let discovery = serde_json::to_value(
5278                server
5279                    .server_discovery()
5280                    .expect("the public final proxy server is discoverable"),
5281            )
5282            .expect("final discovery serializes");
5283            assert_eq!(
5284                discovery.pointer("/capabilities/tools"),
5285                Some(&serde_json::json!({}))
5286            );
5287            let inbound = crate::InboundRequestContext::new(
5288                Cx::for_testing(),
5289                701,
5290                crate::InboundRequestTransport::Memory,
5291            );
5292            let response = server
5293                .dispatch_stateless(&inbound, &final_tools_list_request(701))
5294                .expect("the public server path responds to the modern tools/list request");
5295            assert!(response.error.is_none());
5296            let catalog = response
5297                .result
5298                .expect("the modern tools/list response has a result payload");
5299            assert_eq!(catalog["tools"][0], expected);
5300        }
5301
5302        #[test]
5303        fn public_builder_proxy_final_catalog_absence_changes_only_tool_advertisement_and_registry()
5304        {
5305            let mut catalog = final_proxy_catalog();
5306            let configured_tool_count = catalog.final_tools.len();
5307            catalog.final_tools.clear();
5308            assert_eq!(
5309                configured_tool_count, 1,
5310                "the planted negative removes only the final tool catalog entry"
5311            );
5312
5313            let server = ServerBuilder::new("srv", "1.0")
5314                .proxy(final_catalog_proxy_client(ProtocolEra::Modern2026), catalog)
5315                .expect("an otherwise identical empty final catalog remains admissible")
5316                .build();
5317
5318            assert!(!server.has_tools());
5319            let discovery = serde_json::to_value(
5320                server
5321                    .server_discovery()
5322                    .expect("the empty final proxy server remains discoverable"),
5323            )
5324            .expect("empty final discovery serializes");
5325            assert!(discovery.pointer("/capabilities/tools").is_none());
5326            let inbound = crate::InboundRequestContext::new(
5327                Cx::for_testing(),
5328                702,
5329                crate::InboundRequestTransport::Memory,
5330            );
5331            let response = server
5332                .dispatch_stateless(&inbound, &final_tools_list_request(702))
5333                .expect("the public final tools/list path responds for an empty catalog");
5334            assert_eq!(response.result, Some(serde_json::json!({"tools": []})));
5335            assert!(
5336                server.tools().is_empty(),
5337                "removing only the final catalog entry leaves no legacy registry mutation"
5338            );
5339        }
5340
5341        #[cfg(feature = "tasks")]
5342        #[test]
5343        fn public_builder_proxy_relays_modern_tasks_input_required_and_listener() {
5344            let calls = Arc::new(Mutex::new(Vec::new()));
5345            let updates = Arc::new(Mutex::new(Vec::new()));
5346            let proxy = ordinary_proxy_tasks_client(Arc::clone(&calls), Arc::clone(&updates));
5347            let server = Arc::new(
5348                ServerBuilder::new("ordinary-proxy-tasks", "1.0")
5349                    .proxy(proxy.clone(), final_proxy_catalog())
5350                    .expect("the ordinary public proxy path installs the admitted modern route")
5351                    .build(),
5352            );
5353            let discovery = serde_json::to_value(
5354                server
5355                    .server_discovery()
5356                    .expect("the Tasks proxy is publicly discoverable"),
5357            )
5358            .expect("Tasks proxy discovery serializes");
5359            assert_eq!(
5360                discovery.pointer("/capabilities/extensions/io.modelcontextprotocol~1tasks"),
5361                Some(&serde_json::json!({})),
5362                "the ordinary proxy path advertises the same Tasks extension as the typed path"
5363            );
5364
5365            let cx = Cx::for_testing();
5366            let connection = crate::ModernConnection::new();
5367            let emitted_notifications = Arc::new(Mutex::new(Vec::<JsonRpcRequest>::new()));
5368            let notification_sender: crate::NotificationSender = {
5369                let emitted_notifications = Arc::clone(&emitted_notifications);
5370                Arc::new(move |notification| {
5371                    emitted_notifications
5372                        .lock()
5373                        .expect("ordinary proxy emitted notification log is not poisoned")
5374                        .push(notification);
5375                })
5376            };
5377            let dispatch_modern = |request_id, request| {
5378                let inbound = crate::InboundRequestContext::with_modern_connection(
5379                    cx.clone(),
5380                    request_id,
5381                    crate::InboundRequestTransport::Memory,
5382                    &connection,
5383                );
5384                block_on(Arc::clone(&server).dispatch_with_protocol_policy_owned(
5385                    server.protocol_policy,
5386                    &inbound,
5387                    request,
5388                    None,
5389                    None,
5390                    None,
5391                    None,
5392                    fastmcp_core::McpRequestCancellation::new(),
5393                    None,
5394                    Arc::clone(&notification_sender),
5395                ))
5396                .expect("public ordinary proxy request receives a JSON-RPC response")
5397            };
5398            let task_parameters = serde_json::json!({
5399                "_meta": {
5400                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
5401                    "io.modelcontextprotocol/clientCapabilities": {
5402                        "extensions": {"io.modelcontextprotocol/tasks": {}}
5403                    },
5404                },
5405                "name": "weather",
5406                "arguments": {"outcome": "task"},
5407            });
5408            let task = dispatch_modern(
5409                703,
5410                JsonRpcRequest::new("tools/call", Some(task_parameters.clone()), 703_i64),
5411            );
5412            assert_eq!(
5413                task.result
5414                    .as_ref()
5415                    .and_then(|result| result.get("resultType")),
5416                Some(&serde_json::json!("task"))
5417            );
5418            assert_eq!(
5419                task.result.as_ref().and_then(|result| result.get("taskId")),
5420                Some(&serde_json::json!("ordinary-proxy-task-71"))
5421            );
5422            let relay_after_task = proxy
5423                .final_task_registry_snapshot_for_test()
5424                .expect("the retained ordinary proxy exposes its route-local Task snapshot");
5425            assert_eq!(
5426                relay_after_task.pointer("/tasks/ordinary-proxy-task-71/status"),
5427                Some(&serde_json::json!("input_required")),
5428                "the public tools/call task result is retained by the ordinary route-local relay"
5429            );
5430
5431            let update = dispatch_modern(
5432                704,
5433                JsonRpcRequest::new(
5434                    "tasks/update",
5435                    Some(serde_json::json!({
5436                        "taskId": "ordinary-proxy-task-71",
5437                        "inputResponses": {},
5438                        "_meta": task_parameters["_meta"].clone(),
5439                    })),
5440                    704_i64,
5441                ),
5442            );
5443            assert_eq!(
5444                update
5445                    .result
5446                    .as_ref()
5447                    .and_then(|result| result.get("resultType")),
5448                Some(&serde_json::json!("complete")),
5449                "the public Tasks update preserves the upstream final result algebra"
5450            );
5451            assert_eq!(
5452                updates
5453                    .lock()
5454                    .expect("ordinary proxy task update receipt is not poisoned")
5455                    .as_slice(),
5456                [serde_json::json!({})],
5457                "the public Tasks update reaches the upstream backend through the local relay"
5458            );
5459            assert_eq!(
5460                calls
5461                    .lock()
5462                    .expect("ordinary proxy task call log is not poisoned")
5463                    .as_slice(),
5464                ["tools/call:weather".to_owned(), "tasks/update".to_owned()],
5465                "the public update reaches the selected upstream only after local relay admission"
5466            );
5467
5468            let mut input_required_parameters = task_parameters.clone();
5469            input_required_parameters["arguments"] = serde_json::json!({});
5470            let listened = dispatch_modern(
5471                705,
5472                JsonRpcRequest::new(
5473                    "subscriptions/listen",
5474                    Some(serde_json::json!({
5475                        "notifications": {"taskIds": []},
5476                        "_meta": task_parameters["_meta"].clone(),
5477                    })),
5478                    705_i64,
5479                ),
5480            );
5481            assert_eq!(
5482                listened
5483                    .result
5484                    .as_ref()
5485                    .and_then(|result| result.get("resultType")),
5486                Some(&serde_json::json!("complete"))
5487            );
5488            let emitted_before_rejection = emitted_notifications
5489                .lock()
5490                .expect("ordinary proxy emitted notification log is not poisoned")
5491                .clone();
5492            assert_eq!(
5493                emitted_before_rejection.len(),
5494                1,
5495                "the public listener emits its acknowledgement before terminal completion"
5496            );
5497            assert_eq!(
5498                emitted_before_rejection
5499                    .first()
5500                    .map(|notification| notification.method.as_str()),
5501                Some("notifications/subscriptions/acknowledged"),
5502                "the captured local notification is the listener acknowledgement"
5503            );
5504            let calls_before_rejection = calls
5505                .lock()
5506                .expect("ordinary proxy task call log is not poisoned")
5507                .clone();
5508            let relay_before_rejection = proxy
5509                .final_task_registry_snapshot_for_test()
5510                .expect("the ordinary proxy keeps its local final Task registry");
5511            let subscriptions_before_rejection = server.final_subscription_snapshot_for_test();
5512            let mut missing_capability = task_parameters;
5513            assert!(
5514                missing_capability
5515                    .pointer_mut("/_meta/io.modelcontextprotocol~1clientCapabilities/extensions")
5516                    .and_then(serde_json::Value::as_object_mut)
5517                    .expect("the admitted request has an extension map")
5518                    .remove("io.modelcontextprotocol/tasks")
5519                    .is_some(),
5520                "the RH-5 negative changes only the client Tasks capability"
5521            );
5522            let rejected = dispatch_modern(
5523                706,
5524                JsonRpcRequest::new("tools/call", Some(missing_capability), 706_i64),
5525            );
5526            assert!(rejected.error.is_some());
5527            assert_eq!(
5528                calls
5529                    .lock()
5530                    .expect("ordinary proxy task call log is not poisoned")
5531                    .clone(),
5532                calls_before_rejection,
5533                "the one-field capability rejection must not invoke or mutate the upstream route"
5534            );
5535            assert_eq!(
5536                proxy
5537                    .final_task_registry_snapshot_for_test()
5538                    .expect("the ordinary proxy keeps its local final Task registry"),
5539                relay_before_rejection,
5540                "the one-field capability rejection must not mutate the local relay task registry"
5541            );
5542            assert_eq!(
5543                server.final_subscription_snapshot_for_test(),
5544                subscriptions_before_rejection,
5545                "the one-field capability rejection must not mutate local subscription delivery state"
5546            );
5547            assert_eq!(
5548                emitted_notifications
5549                    .lock()
5550                    .expect("ordinary proxy emitted notification log is not poisoned")
5551                    .clone(),
5552                emitted_before_rejection,
5553                "the one-field capability rejection must not emit or alter local notification state"
5554            );
5555
5556            let input_required = dispatch_modern(
5557                707,
5558                JsonRpcRequest::new("tools/call", Some(input_required_parameters), 707_i64),
5559            );
5560            assert_eq!(
5561                input_required
5562                    .result
5563                    .as_ref()
5564                    .and_then(|result| result.get("resultType")),
5565                Some(&serde_json::json!("input_required"))
5566            );
5567            assert_ne!(
5568                input_required
5569                    .result
5570                    .as_ref()
5571                    .and_then(|result| result.get("requestState")),
5572                Some(&serde_json::json!("upstream-forged-state")),
5573                "the downstream router must mint rather than replay upstream MRTR state"
5574            );
5575
5576            assert_eq!(
5577                calls
5578                    .lock()
5579                    .expect("ordinary proxy task call log is not poisoned")
5580                    .as_slice(),
5581                [
5582                    "tools/call:weather".to_owned(),
5583                    "tasks/update".to_owned(),
5584                    "subscriptions/listen".to_owned(),
5585                    "tools/call:weather".to_owned(),
5586                ],
5587                "public task update, listener, and input_required requests stay on the one admitted modern upstream"
5588            );
5589        }
5590
5591        #[test]
5592        fn builder_proxy_dual_era_preserves_final_catalog_and_routes_bound_calls() {
5593            let expected_final_tool = serde_json::to_value(&final_proxy_catalog().final_tools[0])
5594                .expect("the final fixture serializes");
5595            let (server, legacy_calls, final_calls) = dual_era_proxy_server();
5596            let mut legacy_session = initialized_legacy_proxy_session(&server);
5597            let notification_sender: crate::NotificationSender = Arc::new(|_| {});
5598            let request_sender = crate::RequestSender::new(
5599                Arc::new(crate::PendingRequests::new()),
5600                Arc::new(|message| {
5601                    Err(format!("unexpected outbound message in test: {message:?}"))
5602                }),
5603            );
5604
5605            let legacy_catalog = server
5606                .dispatch_request(
5607                    &Cx::for_testing(),
5608                    &mut legacy_session,
5609                    JsonRpcRequest::new("tools/list", Some(serde_json::json!({})), 801_i64),
5610                    &notification_sender,
5611                    &request_sender,
5612                )
5613                .expect("the legacy tools/list request receives a response")
5614                .result
5615                .expect("the legacy tools/list response has a result payload");
5616            assert_eq!(legacy_catalog["tools"].as_array().map(Vec::len), Some(1));
5617            assert_eq!(legacy_catalog["tools"][0]["name"], "legacy-weather");
5618
5619            let final_inbound = crate::InboundRequestContext::new(
5620                Cx::for_testing(),
5621                802,
5622                crate::InboundRequestTransport::Memory,
5623            );
5624            let final_catalog = server
5625                .dispatch_stateless(&final_inbound, &final_tools_list_request(802_i64))
5626                .expect("the final tools/list request receives a response")
5627                .result
5628                .expect("the final tools/list response has a result payload");
5629            assert_eq!(final_catalog["tools"].as_array().map(Vec::len), Some(1));
5630            assert_eq!(final_catalog["tools"][0]["name"], "weather");
5631            assert_eq!(
5632                serde_json::to_vec(&final_catalog["tools"][0])
5633                    .expect("the emitted final tool normalizes to JSON"),
5634                serde_json::to_vec(&expected_final_tool)
5635                    .expect("the exact final fixture normalizes to JSON"),
5636                "the final proxy path retains the full normalized FinalTool model"
5637            );
5638
5639            let legacy_call = server
5640                .dispatch_request(
5641                    &Cx::for_testing(),
5642                    &mut legacy_session,
5643                    JsonRpcRequest::new(
5644                        "tools/call",
5645                        Some(serde_json::json!({
5646                            "name": "legacy-weather",
5647                            "arguments": {"city": "Portland"},
5648                        })),
5649                        803_i64,
5650                    ),
5651                    &notification_sender,
5652                    &request_sender,
5653                )
5654                .expect("the legacy tools/call request receives a response")
5655                .result
5656                .expect("the legacy tools/call response has a result payload");
5657            assert_eq!(legacy_call["content"][0]["text"], "bound legacy proxy");
5658
5659            let final_call_inbound = crate::InboundRequestContext::new(
5660                Cx::for_testing(),
5661                804,
5662                crate::InboundRequestTransport::Memory,
5663            );
5664            let final_call = server
5665                .dispatch_stateless(
5666                    &final_call_inbound,
5667                    &final_tools_call_request(
5668                        "weather",
5669                        serde_json::json!({"city": "Boston"}),
5670                        804,
5671                    ),
5672                )
5673                .expect("the final tools/call request receives a response")
5674                .result
5675                .expect("the final tools/call response has a result payload");
5676            assert_eq!(final_call["resultType"], "complete");
5677            assert_eq!(final_call["content"][0]["text"], "bound final proxy");
5678            assert_eq!(
5679                final_call["structuredContent"],
5680                serde_json::json!({"route": "final"})
5681            );
5682
5683            assert_eq!(
5684                legacy_calls
5685                    .lock()
5686                    .expect("the test call log lock is not poisoned")
5687                    .clone(),
5688                vec![(
5689                    "legacy-weather".to_owned(),
5690                    serde_json::json!({"city": "Portland"}),
5691                )],
5692                "the legacy request reaches only its bound legacy upstream"
5693            );
5694            assert_eq!(
5695                final_calls
5696                    .lock()
5697                    .expect("the test call log lock is not poisoned")
5698                    .clone(),
5699                vec![("weather".to_owned(), serde_json::json!({"city": "Boston"}))],
5700                "the final request reaches only its bound final upstream"
5701            );
5702        }
5703
5704        #[test]
5705        fn builder_proxy_dual_era_rejects_cross_era_names_without_upstream_calls() {
5706            let (server, legacy_calls, final_calls) = dual_era_proxy_server();
5707            let mut legacy_session = initialized_legacy_proxy_session(&server);
5708            let notification_sender: crate::NotificationSender = Arc::new(|_| {});
5709            let request_sender = crate::RequestSender::new(
5710                Arc::new(crate::PendingRequests::new()),
5711                Arc::new(|message| {
5712                    Err(format!("unexpected outbound message in test: {message:?}"))
5713                }),
5714            );
5715
5716            let legacy_rejected = server
5717                .dispatch_request(
5718                    &Cx::for_testing(),
5719                    &mut legacy_session,
5720                    JsonRpcRequest::new(
5721                        "tools/call",
5722                        Some(serde_json::json!({
5723                            "name": "weather",
5724                            "arguments": {},
5725                        })),
5726                        805_i64,
5727                    ),
5728                    &notification_sender,
5729                    &request_sender,
5730                )
5731                .expect("the legacy cross-era request receives a JSON-RPC error");
5732            assert!(legacy_rejected.result.is_none());
5733            assert_eq!(
5734                legacy_rejected.error.and_then(|error| error.code.as_i32()),
5735                Some(-32601),
5736                "the final-only name is absent from the legacy call route"
5737            );
5738
5739            let final_inbound = crate::InboundRequestContext::new(
5740                Cx::for_testing(),
5741                806,
5742                crate::InboundRequestTransport::Memory,
5743            );
5744            let final_rejected = server
5745                .dispatch_stateless(
5746                    &final_inbound,
5747                    &final_tools_call_request("legacy-weather", serde_json::json!({}), 806),
5748                )
5749                .expect("the final cross-era request receives a JSON-RPC error");
5750            assert!(final_rejected.result.is_none());
5751            assert_eq!(
5752                final_rejected.error.and_then(|error| error.code.as_i32()),
5753                Some(-32602),
5754                "the legacy-only name is absent from the final call route"
5755            );
5756
5757            assert!(
5758                legacy_calls
5759                    .lock()
5760                    .expect("the test call log lock is not poisoned")
5761                    .is_empty(),
5762                "the final-only name must be rejected before the legacy upstream is called"
5763            );
5764            assert!(
5765                final_calls
5766                    .lock()
5767                    .expect("the test call log lock is not poisoned")
5768                    .is_empty(),
5769                "the legacy-only name must be rejected before the final upstream is called"
5770            );
5771        }
5772
5773        #[test]
5774        fn public_proxy_completion_forwards_exact_legacy_and_final_results() {
5775            let legacy_calls = Arc::new(Mutex::new(Vec::new()));
5776            let final_calls = Arc::new(Mutex::new(Vec::new()));
5777            let server = ServerBuilder::new("completion-proxy", "1.0")
5778                .proxy_typed(
5779                    bound_proxy_client(
5780                        CompletionProxyBackend {
5781                            supported: true,
5782                            result: legacy_completion_proxy_result(),
5783                            calls: Arc::clone(&legacy_calls),
5784                        },
5785                        ProtocolEra::Legacy2024,
5786                    ),
5787                    legacy_completion_proxy_catalog(),
5788                )
5789                .expect("the exact legacy completion proxy installs")
5790                .proxy_typed(
5791                    bound_proxy_client(
5792                        CompletionProxyBackend {
5793                            supported: true,
5794                            result: final_completion_proxy_result(),
5795                            calls: Arc::clone(&final_calls),
5796                        },
5797                        ProtocolEra::Modern2026,
5798                    ),
5799                    final_completion_proxy_catalog(),
5800                )
5801                .expect("the exact final completion proxy installs")
5802                .build();
5803            assert!(
5804                server.capabilities().completions.is_some(),
5805                "installing a proxied completion provider must advertise initialize completions"
5806            );
5807
5808            let mut legacy_session = initialized_legacy_proxy_session(&server);
5809            let notification_sender: crate::NotificationSender = Arc::new(|_| {});
5810            let request_sender = crate::RequestSender::new(
5811                Arc::new(crate::PendingRequests::new()),
5812                Arc::new(|message| {
5813                    Err(format!("unexpected outbound message in test: {message:?}"))
5814                }),
5815            );
5816            let legacy = server
5817                .dispatch_request(
5818                    &Cx::for_testing(),
5819                    &mut legacy_session,
5820                    JsonRpcRequest::new(
5821                        "completion/complete",
5822                        Some(serde_json::json!({
5823                            "ref": {"type": "ref/prompt", "name": "legacy-deploy"},
5824                            "argument": {"name": "environment", "value": "sta"},
5825                        })),
5826                        821_i64,
5827                    ),
5828                    &notification_sender,
5829                    &request_sender,
5830                )
5831                .expect("the public exact-2024 completion path returns a response")
5832                .result
5833                .expect("the exact-2024 completion response has a result payload");
5834            assert_eq!(
5835                legacy["completion"]["values"],
5836                serde_json::json!(["legacy-staging"])
5837            );
5838
5839            let final_inbound = crate::InboundRequestContext::new(
5840                Cx::for_testing(),
5841                822,
5842                crate::InboundRequestTransport::Memory,
5843            );
5844            let final_response = server
5845                .dispatch_stateless(&final_inbound, &final_completion_request(822))
5846                .expect("the public final completion path returns a response")
5847                .result
5848                .expect("the final completion response has a result payload");
5849            assert_eq!(final_response["resultType"], "complete");
5850            assert_eq!(
5851                final_response["completion"]["values"],
5852                serde_json::json!(["final-staging"])
5853            );
5854            assert_eq!(
5855                final_response["completion"]["total"],
5856                serde_json::json!(92233720368547758081234567890_u128),
5857                "the proxy retains the final arbitrary-precision completion total"
5858            );
5859
5860            let legacy_calls = legacy_calls
5861                .lock()
5862                .expect("legacy completion call log is not poisoned");
5863            assert_eq!(legacy_calls.len(), 1);
5864            assert!(matches!(
5865                &legacy_calls[0].reference,
5866                fastmcp_client::CompletionReference::Prompt { name } if name == "legacy-deploy"
5867            ));
5868            assert!(legacy_calls[0].context.is_none());
5869            let final_calls = final_calls
5870                .lock()
5871                .expect("final completion call log is not poisoned");
5872            assert_eq!(final_calls.len(), 1);
5873            assert!(matches!(
5874                &final_calls[0].reference,
5875                fastmcp_client::CompletionReference::PromptWithTitle { name, title }
5876                    if name == "final-deploy" && title == "Final Deploy"
5877            ));
5878            assert_eq!(
5879                final_calls[0]
5880                    .context
5881                    .as_ref()
5882                    .and_then(|context| context.arguments.as_ref())
5883                    .and_then(|arguments| arguments.get("region")),
5884                Some(&"us-east-1".to_owned()),
5885                "the proxy forwards final completion context unchanged"
5886            );
5887        }
5888
5889        #[test]
5890        fn public_proxy_completion_unsupported_upstream_rejects_without_downstream_mutation() {
5891            let calls = Arc::new(Mutex::new(Vec::new()));
5892            let server = ServerBuilder::new("completion-proxy", "1.0")
5893                .proxy_typed(
5894                    bound_proxy_client(
5895                        CompletionProxyBackend {
5896                            supported: false,
5897                            result: final_completion_proxy_result(),
5898                            calls: Arc::clone(&calls),
5899                        },
5900                        ProtocolEra::Modern2026,
5901                    ),
5902                    final_completion_proxy_catalog(),
5903                )
5904                .expect("changing only upstream completion support preserves catalog registration")
5905                .build();
5906            assert!(
5907                server.capabilities().completions.is_none(),
5908                "an unsupported upstream must not advertise initialize completions"
5909            );
5910            let discovery = serde_json::to_value(
5911                server
5912                    .server_discovery()
5913                    .expect("the final proxy server remains discoverable"),
5914            )
5915            .expect("discovery serializes");
5916            assert!(
5917                discovery["capabilities"].get("completions").is_none(),
5918                "an unsupported upstream must not create a downstream completion claim"
5919            );
5920
5921            let before_inbound = crate::InboundRequestContext::new(
5922                Cx::for_testing(),
5923                823,
5924                crate::InboundRequestTransport::Memory,
5925            );
5926            let before = server
5927                .dispatch_stateless(
5928                    &before_inbound,
5929                    &JsonRpcRequest::new(
5930                        "prompts/list",
5931                        Some(serde_json::json!({
5932                            "_meta": {
5933                                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
5934                                "io.modelcontextprotocol/clientCapabilities": {},
5935                            },
5936                        })),
5937                        823_i64,
5938                    ),
5939                )
5940                .expect("the proxied final prompt catalog is public before rejection")
5941                .result
5942                .expect("the proxied final prompt catalog has a result payload");
5943            let rejected_inbound = crate::InboundRequestContext::new(
5944                Cx::for_testing(),
5945                824,
5946                crate::InboundRequestTransport::Memory,
5947            );
5948            let rejected = server
5949                .dispatch_stateless(&rejected_inbound, &final_completion_request(824))
5950                .expect("the unsupported completion request receives a JSON-RPC error");
5951            assert_eq!(
5952                rejected.error.and_then(|error| error.code.as_i32()),
5953                Some(-32601),
5954                "the absent local completion handler rejects before an upstream call"
5955            );
5956            assert!(rejected.result.is_none());
5957            let after_inbound = crate::InboundRequestContext::new(
5958                Cx::for_testing(),
5959                825,
5960                crate::InboundRequestTransport::Memory,
5961            );
5962            let after = server
5963                .dispatch_stateless(
5964                    &after_inbound,
5965                    &JsonRpcRequest::new(
5966                        "prompts/list",
5967                        Some(serde_json::json!({
5968                            "_meta": {
5969                                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
5970                                "io.modelcontextprotocol/clientCapabilities": {},
5971                            },
5972                        })),
5973                        825_i64,
5974                    ),
5975                )
5976                .expect("the proxied final prompt catalog remains public after rejection")
5977                .result
5978                .expect("the proxied final prompt catalog still has a result payload");
5979            assert_eq!(
5980                before, after,
5981                "the rejected request cannot mutate downstream catalog state"
5982            );
5983            assert!(
5984                calls
5985                    .lock()
5986                    .expect("completion proxy call log is not poisoned")
5987                    .is_empty(),
5988                "the unsupported upstream is never invoked"
5989            );
5990        }
5991
5992        #[test]
5993        fn public_proxy_completion_duplicate_local_target_keeps_no_upstream_mapping() {
5994            for duplicate_behavior in [DuplicateBehavior::Warn, DuplicateBehavior::Ignore] {
5995                let calls = Arc::new(Mutex::new(Vec::new()));
5996                let server = ServerBuilder::new("completion-proxy", "1.0")
5997                    .prompt(LocalFinalCompletionPrompt)
5998                    .on_duplicate(duplicate_behavior)
5999                    .proxy_typed(
6000                        bound_proxy_client(
6001                            CompletionProxyBackend {
6002                                supported: true,
6003                                result: final_completion_proxy_result(),
6004                                calls: Arc::clone(&calls),
6005                            },
6006                            ProtocolEra::Modern2026,
6007                        ),
6008                        final_completion_proxy_catalog(),
6009                    )
6010                    .expect("retaining the local final prompt is a successful duplicate admission")
6011                    .build();
6012
6013                let discovery = serde_json::to_value(
6014                    server
6015                        .server_discovery()
6016                        .expect("the retained local prompt server remains discoverable"),
6017                )
6018                .expect("discovery serializes");
6019                assert!(
6020                    discovery["capabilities"].get("completions").is_none(),
6021                    "{duplicate_behavior:?} must not advertise an upstream provider for a retained local target"
6022                );
6023
6024                let before_inbound = crate::InboundRequestContext::new(
6025                    Cx::for_testing(),
6026                    826,
6027                    crate::InboundRequestTransport::Memory,
6028                );
6029                let before = server
6030                    .dispatch_stateless(
6031                        &before_inbound,
6032                        &JsonRpcRequest::new(
6033                            "prompts/list",
6034                            Some(serde_json::json!({
6035                                "_meta": {
6036                                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6037                                    "io.modelcontextprotocol/clientCapabilities": {},
6038                                },
6039                            })),
6040                            826_i64,
6041                        ),
6042                    )
6043                    .expect("the retained local prompt is visible through the public final list")
6044                    .result
6045                    .expect("the retained local prompt list has a result payload");
6046                assert_eq!(
6047                    before["prompts"][0]["title"], "Local Final Deploy",
6048                    "{duplicate_behavior:?} retains the pre-existing local prompt target"
6049                );
6050
6051                let rejected_inbound = crate::InboundRequestContext::new(
6052                    Cx::for_testing(),
6053                    827,
6054                    crate::InboundRequestTransport::Memory,
6055                );
6056                let rejected = server
6057                    .dispatch_stateless(&rejected_inbound, &final_completion_request(827))
6058                    .expect("the absent completion mapping returns a JSON-RPC error");
6059                assert_eq!(
6060                    rejected.error.and_then(|error| error.code.as_i32()),
6061                    Some(-32601),
6062                    "{duplicate_behavior:?} rejects before invocation because no upstream mapping was installed"
6063                );
6064                assert!(rejected.result.is_none());
6065
6066                let after_inbound = crate::InboundRequestContext::new(
6067                    Cx::for_testing(),
6068                    828,
6069                    crate::InboundRequestTransport::Memory,
6070                );
6071                let after = server
6072                    .dispatch_stateless(
6073                        &after_inbound,
6074                        &JsonRpcRequest::new(
6075                            "prompts/list",
6076                            Some(serde_json::json!({
6077                                "_meta": {
6078                                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6079                                    "io.modelcontextprotocol/clientCapabilities": {},
6080                                },
6081                            })),
6082                            828_i64,
6083                        ),
6084                    )
6085                    .expect("the rejected request leaves the retained local target public")
6086                    .result
6087                    .expect("the retained local prompt list still has a result payload");
6088                assert_eq!(
6089                    before, after,
6090                    "{duplicate_behavior:?} completion rejection cannot mutate the retained local target"
6091                );
6092                assert!(
6093                    calls
6094                        .lock()
6095                        .expect("completion proxy call log is not poisoned")
6096                        .is_empty(),
6097                    "{duplicate_behavior:?} must not invoke the upstream completion backend"
6098                );
6099            }
6100        }
6101
6102        #[test]
6103        fn public_proxy_completion_replace_installs_upstream_mapping_for_replaced_target() {
6104            let calls = Arc::new(Mutex::new(Vec::new()));
6105            let server = ServerBuilder::new("completion-proxy", "1.0")
6106                .prompt(LocalFinalCompletionPrompt)
6107                .on_duplicate(DuplicateBehavior::Replace)
6108                .proxy_typed(
6109                    bound_proxy_client(
6110                        CompletionProxyBackend {
6111                            supported: true,
6112                            result: final_completion_proxy_result(),
6113                            calls: Arc::clone(&calls),
6114                        },
6115                        ProtocolEra::Modern2026,
6116                    ),
6117                    final_completion_proxy_catalog(),
6118                )
6119                .expect("Replace admits the exact upstream final prompt target")
6120                .build();
6121
6122            let discovery = serde_json::to_value(
6123                server
6124                    .server_discovery()
6125                    .expect("the replacement proxy server remains discoverable"),
6126            )
6127            .expect("discovery serializes");
6128            assert_eq!(
6129                discovery["capabilities"]["completions"],
6130                serde_json::json!({}),
6131                "the real upstream mapping advertises final completion support"
6132            );
6133
6134            let list_inbound = crate::InboundRequestContext::new(
6135                Cx::for_testing(),
6136                829,
6137                crate::InboundRequestTransport::Memory,
6138            );
6139            let prompts = server
6140                .dispatch_stateless(
6141                    &list_inbound,
6142                    &JsonRpcRequest::new(
6143                        "prompts/list",
6144                        Some(serde_json::json!({
6145                            "_meta": {
6146                                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6147                                "io.modelcontextprotocol/clientCapabilities": {},
6148                            },
6149                        })),
6150                        829_i64,
6151                    ),
6152                )
6153                .expect("the replaced prompt is public through the final list")
6154                .result
6155                .expect("the replaced prompt list has a result payload");
6156            assert_eq!(
6157                prompts["prompts"][0]["title"], "Upstream Final Deploy",
6158                "Replace exposes the admitted upstream prompt instead of the local target"
6159            );
6160
6161            let completion_inbound = crate::InboundRequestContext::new(
6162                Cx::for_testing(),
6163                830,
6164                crate::InboundRequestTransport::Memory,
6165            );
6166            let completion = server
6167                .dispatch_stateless(&completion_inbound, &final_completion_request(830))
6168                .expect("the replaced target forwards through the public final completion route")
6169                .result
6170                .expect("the replaced target completion has a result payload");
6171            assert_eq!(
6172                completion["completion"]["values"],
6173                serde_json::json!(["final-staging"])
6174            );
6175            assert_eq!(
6176                calls
6177                    .lock()
6178                    .expect("completion proxy call log is not poisoned")
6179                    .len(),
6180                1,
6181                "Replace installs exactly one upstream completion invocation path"
6182            );
6183        }
6184
6185        #[test]
6186        fn public_final_proxy_completion_replacement_evicts_proxy_targets_and_restores_local_providers()
6187         {
6188            let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6189            let template_calls = Arc::new(Mutex::new(Vec::new()));
6190            let evicted = ServerBuilder::new("completion-proxy", "1.0")
6191                .proxy_typed(
6192                    bound_proxy_client(
6193                        CompletionProxyBackend {
6194                            supported: true,
6195                            result: final_completion_proxy_result(),
6196                            calls: Arc::clone(&prompt_calls),
6197                        },
6198                        ProtocolEra::Modern2026,
6199                    ),
6200                    final_completion_proxy_catalog(),
6201                )
6202                .expect("the final prompt proxy installs")
6203                .proxy_typed(
6204                    bound_proxy_client(
6205                        CompletionProxyBackend {
6206                            supported: true,
6207                            result: final_completion_proxy_result(),
6208                            calls: Arc::clone(&template_calls),
6209                        },
6210                        ProtocolEra::Modern2026,
6211                    ),
6212                    final_completion_proxy_template_catalog(),
6213                )
6214                .expect("the final resource-template proxy installs")
6215                .on_duplicate(DuplicateBehavior::Replace)
6216                .prompt(LocalFinalCompletionPrompt)
6217                .resource_template(local_completion_template())
6218                .build();
6219
6220            let discovery = serde_json::to_value(
6221                evicted
6222                    .server_discovery()
6223                    .expect("the locally replaced catalog remains discoverable"),
6224            )
6225            .expect("discovery serializes");
6226            assert!(
6227                discovery["capabilities"].get("completions").is_none(),
6228                "replacing every proxied final target removes proxy completion advertisement"
6229            );
6230            for (id, request) in [
6231                (831, final_completion_request(831)),
6232                (832, final_resource_template_completion_request(832)),
6233            ] {
6234                let inbound = crate::InboundRequestContext::new(
6235                    Cx::for_testing(),
6236                    id,
6237                    crate::InboundRequestTransport::Memory,
6238                );
6239                let rejected = evicted
6240                    .dispatch_stateless(&inbound, &request)
6241                    .expect("the evicted completion mapping returns a JSON-RPC error");
6242                assert_eq!(
6243                    rejected.error.and_then(|error| error.code.as_i32()),
6244                    Some(-32601),
6245                    "the local replacement has no retained upstream completion provider"
6246                );
6247                assert!(rejected.result.is_none());
6248            }
6249            assert!(
6250                prompt_calls
6251                    .lock()
6252                    .expect("prompt completion proxy call log is not poisoned")
6253                    .is_empty()
6254            );
6255            assert!(
6256                template_calls
6257                    .lock()
6258                    .expect("template completion proxy call log is not poisoned")
6259                    .is_empty()
6260            );
6261
6262            let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6263            let template_calls = Arc::new(Mutex::new(Vec::new()));
6264            let restored = ServerBuilder::new("completion-proxy", "1.0")
6265                .proxy_typed(
6266                    bound_proxy_client(
6267                        CompletionProxyBackend {
6268                            supported: true,
6269                            result: final_completion_proxy_result(),
6270                            calls: Arc::clone(&prompt_calls),
6271                        },
6272                        ProtocolEra::Modern2026,
6273                    ),
6274                    final_completion_proxy_catalog(),
6275                )
6276                .expect("the final prompt proxy installs")
6277                .proxy_typed(
6278                    bound_proxy_client(
6279                        CompletionProxyBackend {
6280                            supported: true,
6281                            result: final_completion_proxy_result(),
6282                            calls: Arc::clone(&template_calls),
6283                        },
6284                        ProtocolEra::Modern2026,
6285                    ),
6286                    final_completion_proxy_template_catalog(),
6287                )
6288                .expect("the final resource-template proxy installs")
6289                .on_duplicate(DuplicateBehavior::Replace)
6290                .prompt(LocalFinalCompletionPrompt)
6291                .resource_template(local_completion_template())
6292                .prompt_completion_handler("final-deploy", TestCompletion)
6293                .resource_template_completion_handler("completion://{environment}", TestCompletion)
6294                .build();
6295            let discovery = serde_json::to_value(
6296                restored
6297                    .server_discovery()
6298                    .expect("the local completion providers make final discovery available"),
6299            )
6300            .expect("discovery serializes");
6301            assert_eq!(
6302                discovery["capabilities"]["completions"],
6303                serde_json::json!({}),
6304                "only the restored local final providers advertise completion support"
6305            );
6306            for (id, request) in [
6307                (833, final_completion_request(833)),
6308                (834, final_resource_template_completion_request(834)),
6309            ] {
6310                let inbound = crate::InboundRequestContext::new(
6311                    Cx::for_testing(),
6312                    id,
6313                    crate::InboundRequestTransport::Memory,
6314                );
6315                let response = restored
6316                    .dispatch_stateless(&inbound, &request)
6317                    .expect("the local replacement provider handles the public final request")
6318                    .result
6319                    .expect("the local replacement provider returns a result payload");
6320                assert_eq!(
6321                    response["completion"]["values"],
6322                    serde_json::json!(["staging"])
6323                );
6324            }
6325            assert!(
6326                prompt_calls
6327                    .lock()
6328                    .expect("prompt completion proxy call log is not poisoned")
6329                    .is_empty(),
6330                "local prompt providers must not invoke the displaced proxy"
6331            );
6332            assert!(
6333                template_calls
6334                    .lock()
6335                    .expect("template completion proxy call log is not poisoned")
6336                    .is_empty(),
6337                "local template providers must not invoke the displaced proxy"
6338            );
6339        }
6340
6341        #[test]
6342        fn public_legacy_proxy_completion_replacement_evicts_proxy_targets_and_restores_local_provider()
6343         {
6344            let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6345            let template_calls = Arc::new(Mutex::new(Vec::new()));
6346            let evicted = ServerBuilder::new("completion-proxy", "1.0")
6347                .proxy_typed(
6348                    bound_proxy_client(
6349                        CompletionProxyBackend {
6350                            supported: true,
6351                            result: legacy_completion_proxy_result(),
6352                            calls: Arc::clone(&prompt_calls),
6353                        },
6354                        ProtocolEra::Legacy2024,
6355                    ),
6356                    legacy_completion_proxy_catalog(),
6357                )
6358                .expect("the legacy prompt proxy installs")
6359                .proxy_typed(
6360                    bound_proxy_client(
6361                        CompletionProxyBackend {
6362                            supported: true,
6363                            result: legacy_completion_proxy_result(),
6364                            calls: Arc::clone(&template_calls),
6365                        },
6366                        ProtocolEra::Legacy2024,
6367                    ),
6368                    legacy_completion_proxy_template_catalog(),
6369                )
6370                .expect("the legacy resource-template proxy installs")
6371                .on_duplicate(DuplicateBehavior::Replace)
6372                .legacy_prompt(LocalFinalCompletionPrompt)
6373                .legacy_resource_template(local_completion_template())
6374                .build();
6375            let mut legacy_session = initialized_legacy_proxy_session(&evicted);
6376            let notification_sender: crate::NotificationSender = Arc::new(|_| {});
6377            let request_sender = crate::RequestSender::new(
6378                Arc::new(crate::PendingRequests::new()),
6379                Arc::new(|message| {
6380                    Err(format!("unexpected outbound message in test: {message:?}"))
6381                }),
6382            );
6383            for request in [
6384                legacy_completion_request(
6385                    835,
6386                    serde_json::json!({"type": "ref/prompt", "name": "legacy-deploy"}),
6387                ),
6388                legacy_completion_request(
6389                    836,
6390                    serde_json::json!({"type": "ref/resource", "uri": "completion://{environment}"}),
6391                ),
6392            ] {
6393                let rejected = evicted
6394                    .dispatch_request(
6395                        &Cx::for_testing(),
6396                        &mut legacy_session,
6397                        request,
6398                        &notification_sender,
6399                        &request_sender,
6400                    )
6401                    .expect("the public replaced legacy target returns a JSON-RPC error");
6402                assert_eq!(
6403                    rejected.error.and_then(|error| error.code.as_i32()),
6404                    Some(-32601),
6405                    "the local replacement has no retained upstream completion provider"
6406                );
6407                assert!(rejected.result.is_none());
6408            }
6409            assert!(
6410                prompt_calls
6411                    .lock()
6412                    .expect("prompt completion proxy call log is not poisoned")
6413                    .is_empty()
6414            );
6415            assert!(
6416                template_calls
6417                    .lock()
6418                    .expect("template completion proxy call log is not poisoned")
6419                    .is_empty()
6420            );
6421
6422            let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6423            let template_calls = Arc::new(Mutex::new(Vec::new()));
6424            let restored = ServerBuilder::new("completion-proxy", "1.0")
6425                .proxy_typed(
6426                    bound_proxy_client(
6427                        CompletionProxyBackend {
6428                            supported: true,
6429                            result: legacy_completion_proxy_result(),
6430                            calls: Arc::clone(&prompt_calls),
6431                        },
6432                        ProtocolEra::Legacy2024,
6433                    ),
6434                    legacy_completion_proxy_catalog(),
6435                )
6436                .expect("the legacy prompt proxy installs")
6437                .proxy_typed(
6438                    bound_proxy_client(
6439                        CompletionProxyBackend {
6440                            supported: true,
6441                            result: legacy_completion_proxy_result(),
6442                            calls: Arc::clone(&template_calls),
6443                        },
6444                        ProtocolEra::Legacy2024,
6445                    ),
6446                    legacy_completion_proxy_template_catalog(),
6447                )
6448                .expect("the legacy resource-template proxy installs")
6449                .on_duplicate(DuplicateBehavior::Replace)
6450                .legacy_prompt(LocalFinalCompletionPrompt)
6451                .legacy_resource_template(local_completion_template())
6452                .legacy_completion_handler(TestCompletion)
6453                .build();
6454            let mut legacy_session = initialized_legacy_proxy_session(&restored);
6455            let notification_sender: crate::NotificationSender = Arc::new(|_| {});
6456            let request_sender = crate::RequestSender::new(
6457                Arc::new(crate::PendingRequests::new()),
6458                Arc::new(|message| {
6459                    Err(format!("unexpected outbound message in test: {message:?}"))
6460                }),
6461            );
6462            for request in [
6463                legacy_completion_request(
6464                    837,
6465                    serde_json::json!({"type": "ref/prompt", "name": "legacy-deploy"}),
6466                ),
6467                legacy_completion_request(
6468                    838,
6469                    serde_json::json!({"type": "ref/resource", "uri": "completion://{environment}"}),
6470                ),
6471            ] {
6472                let response = restored
6473                    .dispatch_request(
6474                        &Cx::for_testing(),
6475                        &mut legacy_session,
6476                        request,
6477                        &notification_sender,
6478                        &request_sender,
6479                    )
6480                    .expect("the public local legacy provider returns a response")
6481                    .result
6482                    .expect("the local legacy provider returns a result payload");
6483                assert_eq!(
6484                    response["completion"]["values"],
6485                    serde_json::json!(["staging"])
6486                );
6487            }
6488            assert!(
6489                prompt_calls
6490                    .lock()
6491                    .expect("prompt completion proxy call log is not poisoned")
6492                    .is_empty()
6493            );
6494            assert!(
6495                template_calls
6496                    .lock()
6497                    .expect("template completion proxy call log is not poisoned")
6498                    .is_empty()
6499            );
6500        }
6501
6502        #[test]
6503        fn builder_proxy_rejects_final_tools_with_one_legacy_resource_vector() {
6504            let mut catalog = final_proxy_catalog();
6505            catalog.resources.push(Resource {
6506                uri: "file:///must-not-project".to_owned(),
6507                name: "must-not-project".to_owned(),
6508                description: None,
6509                mime_type: None,
6510                icon: None,
6511                version: None,
6512                tags: Vec::new(),
6513            });
6514
6515            assert_rejected_proxy_catalog_returns_an_error(catalog);
6516        }
6517
6518        #[test]
6519        fn public_legacy_and_stateless_ping_answer_empty_object_without_entering_the_final_request_union()
6520         {
6521            let server = ServerBuilder::new("srv", "1.0").build();
6522            let mut legacy_session =
6523                crate::Session::new(server.info().clone(), server.capabilities().clone());
6524            legacy_session.initialize(
6525                fastmcp_protocol::ClientInfo {
6526                    name: "exact-2024-ping-client".to_owned(),
6527                    version: "1.0".to_owned(),
6528                },
6529                fastmcp_protocol::ClientCapabilities::default(),
6530                "2024-11-05".to_owned(),
6531            );
6532            let state_before = legacy_session.state().len();
6533            let legacy_ping = JsonRpcRequest::new("ping", Some(serde_json::json!({})), 714_i64);
6534            let mut final_ping = legacy_ping.clone();
6535            final_ping
6536                .params
6537                .as_mut()
6538                .expect("the cloned ping request retains its object parameters")
6539                .as_object_mut()
6540                .expect("the cloned ping parameters remain an object")
6541                .insert(
6542                    "_meta".to_owned(),
6543                    serde_json::json!({
6544                        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6545                        "io.modelcontextprotocol/clientCapabilities": {},
6546                    }),
6547                );
6548            let mut final_without_metadata = final_ping
6549                .params
6550                .clone()
6551                .expect("the final ping parameters are retained for the negative-control check");
6552            final_without_metadata
6553                .as_object_mut()
6554                .expect("the final ping parameters remain an object")
6555                .remove("_meta");
6556            assert_eq!(final_ping.method, legacy_ping.method);
6557            assert_eq!(final_ping.id, legacy_ping.id);
6558            assert_eq!(Some(final_without_metadata), legacy_ping.params);
6559
6560            let notification_sender: crate::NotificationSender = Arc::new(|_| {});
6561            let request_sender = crate::RequestSender::new(
6562                Arc::new(crate::PendingRequests::new()),
6563                Arc::new(|message| {
6564                    Err(format!("unexpected outbound message in test: {message:?}"))
6565                }),
6566            );
6567            let legacy = server
6568                .dispatch_request(
6569                    &Cx::for_testing(),
6570                    &mut legacy_session,
6571                    legacy_ping,
6572                    &notification_sender,
6573                    &request_sender,
6574                )
6575                .expect("the exact-2024 public dispatch path responds to ping");
6576            assert_eq!(legacy.result, Some(serde_json::json!({})));
6577            assert!(legacy.error.is_none());
6578
6579            let inbound = crate::InboundRequestContext::new(
6580                Cx::for_testing(),
6581                714,
6582                crate::InboundRequestTransport::Memory,
6583            );
6584            let answered = server
6585                .dispatch_stateless(&inbound, &final_ping)
6586                .expect("stateless modern ping is a connection health-check");
6587            assert_eq!(answered.result, Some(serde_json::json!({})));
6588            assert!(answered.error.is_none());
6589            assert_eq!(
6590                legacy_session.state().len(),
6591                state_before,
6592                "ping cannot mutate session state"
6593            );
6594
6595            assert!(
6596                !fastmcp_protocol::methods::FINAL_2026_07_28_METHODS
6597                    .iter()
6598                    .any(|method| method.name == "ping"),
6599                "ping must remain outside the official 2026 client-request union"
6600            );
6601            let decode = fastmcp_protocol::CoreRequest::decode(
6602                ProtocolEra::Modern2026,
6603                "ping",
6604                Some(&serde_json::json!({})),
6605            )
6606            .expect_err("FinalCoreRequest must not grow a Ping variant");
6607            assert!(matches!(
6608                decode,
6609                fastmcp_protocol::CoreDispatchError::UnsupportedMethod {
6610                    era: ProtocolEra::Modern2026,
6611                    method,
6612                } if method == "ping"
6613            ));
6614        }
6615
6616        #[test]
6617        fn typed_proxy_registration_retains_final_resource_template_and_prompt_metadata() {
6618            let resource = serde_json::json!({
6619                "uri": "mcp://upstream/resource",
6620                "name": "upstream-resource",
6621                "size": 4096,
6622                "_meta": {"com.example/resource": {"retained": true}}
6623            });
6624            let template = serde_json::json!({
6625                "uriTemplate": "mcp://upstream/{name}",
6626                "name": "upstream-template",
6627                "_meta": {"com.example/template": {"retained": true}}
6628            });
6629            let prompt = serde_json::json!({
6630                "name": "upstream-prompt",
6631                "arguments": [{"name": "region", "title": "Region"}],
6632                "_meta": {"com.example/prompt": {"retained": true}}
6633            });
6634            let typed = ProxyTypedCatalog {
6635                tools: ProxyToolCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
6636                resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(vec![
6637                    serde_json::from_value(resource.clone())
6638                        .expect("the final resource fixture is valid"),
6639                ])),
6640                resource_templates: ProxyResourceTemplateCatalog::Final(ProxyFinalCatalog::new(
6641                    vec![
6642                        serde_json::from_value(template.clone())
6643                            .expect("the final resource-template fixture is valid"),
6644                    ],
6645                )),
6646                prompts: ProxyPromptCatalog::Final(ProxyFinalCatalog::new(vec![
6647                    serde_json::from_value(prompt.clone())
6648                        .expect("the final prompt fixture is valid"),
6649                ])),
6650            };
6651            let server = ServerBuilder::new("srv", "1.0")
6652                .proxy_typed(
6653                    bound_proxy_client(DuplicatePolicyProxyBackend, ProtocolEra::Modern2026),
6654                    typed,
6655                )
6656                .expect("coherent typed catalog registers")
6657                .build();
6658            for (method, member, expected, id) in [
6659                ("resources/list", "resources", resource, 711_i64),
6660                (
6661                    "resources/templates/list",
6662                    "resourceTemplates",
6663                    template,
6664                    712_i64,
6665                ),
6666                ("prompts/list", "prompts", prompt, 713_i64),
6667            ] {
6668                let inbound = crate::InboundRequestContext::new(
6669                    Cx::for_testing(),
6670                    u64::try_from(id).expect("test request IDs are non-negative"),
6671                    crate::InboundRequestTransport::Memory,
6672                );
6673                let response = server
6674                    .dispatch_stateless(
6675                        &inbound,
6676                        &JsonRpcRequest::new(
6677                            method,
6678                            Some(serde_json::json!({
6679                                "_meta": {
6680                                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6681                                    "io.modelcontextprotocol/clientCapabilities": {},
6682                                },
6683                            })),
6684                            id,
6685                        ),
6686                    )
6687                    .expect("final discovery dispatch succeeds");
6688                assert_eq!(
6689                    response.result.expect("final discovery has a result")[member][0],
6690                    expected,
6691                    "final proxy registration must retain exact {member} metadata"
6692                );
6693            }
6694
6695            assert!(server.resources().is_empty());
6696            assert!(server.resource_templates().is_empty());
6697            assert!(server.prompts().is_empty());
6698            let router = server.into_router();
6699            let state = fastmcp_core::SessionState::new();
6700            let request_ctx =
6701                fastmcp_core::McpContext::with_state(Cx::for_testing(), 714, state.clone());
6702            let legacy_resource = router
6703                .handle_resources_read(
6704                    &request_ctx,
6705                    &fastmcp_protocol::ReadResourceParams {
6706                        uri: "mcp://upstream/resource".to_owned(),
6707                        meta: None,
6708                    },
6709                    state.clone(),
6710                    None,
6711                    None,
6712                )
6713                .expect_err("typed-final resource registration is not legacy-visible");
6714            assert_eq!(
6715                legacy_resource.code,
6716                fastmcp_core::McpErrorCode::ResourceNotFound
6717            );
6718            let legacy_prompt = router
6719                .handle_prompts_get(
6720                    &request_ctx,
6721                    fastmcp_protocol::GetPromptParams {
6722                        name: "upstream-prompt".to_owned(),
6723                        arguments: None,
6724                        meta: None,
6725                    },
6726                    state,
6727                    None,
6728                    None,
6729                )
6730                .expect_err("typed-final prompt registration is not legacy-visible");
6731            assert_eq!(
6732                legacy_prompt.code,
6733                fastmcp_core::McpErrorCode::PromptNotFound
6734            );
6735        }
6736
6737        #[test]
6738        fn typed_proxy_registration_rejects_an_unbound_caller_asserted_final_catalog() {
6739            let error = match ServerBuilder::new("srv", "1.0").proxy_typed(
6740                ProxyClient::from_backend(DuplicatePolicyProxyBackend),
6741                final_completion_proxy_catalog(),
6742            ) {
6743                Ok(_) => {
6744                    panic!("a caller-supplied typed catalog cannot bind an unbound proxy route")
6745                }
6746                Err(error) => error,
6747            };
6748
6749            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6750            assert!(error.message.contains("cannot bind an unbound route"));
6751        }
6752
6753        #[test]
6754        fn typed_proxy_registration_rejects_one_mixed_era_component_vector() {
6755            let typed = ProxyTypedCatalog {
6756                tools: ProxyToolCatalog::Legacy(Vec::new()),
6757                resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
6758                resource_templates: ProxyResourceTemplateCatalog::Legacy(Vec::new()),
6759                prompts: ProxyPromptCatalog::Legacy(Vec::new()),
6760            };
6761            let error = match ServerBuilder::new("srv", "1.0").register_raw_typed_proxy_catalog(
6762                bound_proxy_client(DuplicatePolicyProxyBackend, ProtocolEra::Legacy2024),
6763                typed,
6764            ) {
6765                Err(error) => error,
6766                Ok(_) => {
6767                    panic!("changing only resources to final rejects a mixed-era proxy catalog")
6768                }
6769            };
6770            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6771        }
6772
6773        #[test]
6774        fn builder_proxy_rejects_the_same_final_catalog_for_a_legacy_binding() {
6775            let error = match ServerBuilder::new("srv", "1.0").proxy(
6776                final_catalog_proxy_client(ProtocolEra::Legacy2024),
6777                final_proxy_catalog(),
6778            ) {
6779                Ok(_) => panic!("the catalog era cannot contradict the immutable route binding"),
6780                Err(error) => error,
6781            };
6782
6783            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6784        }
6785
6786        #[test]
6787        fn builder_proxy_with_catalog() {
6788            use crate::proxy::ProxyClient;
6789
6790            struct DummyBackend;
6791            impl crate::proxy::ProxyBackend for DummyBackend {
6792                fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
6793                    Ok(vec![Tool {
6794                        name: "proxy-tool".to_owned(),
6795                        description: None,
6796                        input_schema: serde_json::json!({}),
6797                        output_schema: None,
6798                        icon: None,
6799                        version: None,
6800                        tags: Vec::new(),
6801                        annotations: None,
6802                    }])
6803                }
6804                fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
6805                    Ok(vec![])
6806                }
6807                fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
6808                    Ok(vec![])
6809                }
6810                fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
6811                    Ok(vec![])
6812                }
6813                fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
6814                    Ok(vec![])
6815                }
6816                fn call_tool_with_progress(
6817                    &mut self,
6818                    _: &str,
6819                    _: serde_json::Value,
6820                    _: crate::proxy::ProgressCallback<'_>,
6821                ) -> McpResult<Vec<Content>> {
6822                    Ok(vec![])
6823                }
6824                fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
6825                    Ok(vec![])
6826                }
6827                fn get_prompt(
6828                    &mut self,
6829                    _: &str,
6830                    _: std::collections::HashMap<String, String>,
6831                ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
6832                    Ok(vec![])
6833                }
6834            }
6835
6836            let client = ProxyClient::from_backend(DummyBackend);
6837            let catalog = client
6838                .catalog()
6839                .expect("backend discovery supplies the legacy era evidence");
6840
6841            let server = ServerBuilder::new("srv", "1.0")
6842                .proxy(client, catalog)
6843                .expect("the backend-observed catalog is admitted")
6844                .build();
6845            assert!(server.has_tools());
6846        }
6847
6848        #[test]
6849        fn builder_proxy_honors_duplicate_policy_for_every_component_kind() {
6850            for behavior in [
6851                DuplicateBehavior::Warn,
6852                DuplicateBehavior::Ignore,
6853                DuplicateBehavior::Error,
6854            ] {
6855                let (client, catalog) = discovered_duplicate_policy_proxy();
6856                let server = ServerBuilder::new("srv", "1.0")
6857                    .on_duplicate(behavior)
6858                    .tool(TestTool)
6859                    .resource(TestResource)
6860                    .prompt(TestPrompt)
6861                    .proxy(client, catalog)
6862                    .expect("the backend-observed catalog is admitted")
6863                    .build();
6864                let router = server.into_router();
6865
6866                assert_eq!(
6867                    router.tools()[0].description.as_deref(),
6868                    Some("a test tool")
6869                );
6870                assert_eq!(router.resources()[0].name, "test_res");
6871                assert_eq!(router.prompts()[0].description, None);
6872            }
6873
6874            let (client, catalog) = discovered_duplicate_policy_proxy();
6875            let replaced = ServerBuilder::new("srv", "1.0")
6876                .on_duplicate(DuplicateBehavior::Replace)
6877                .tool(TestTool)
6878                .resource(TestResource)
6879                .prompt(TestPrompt)
6880                .proxy(client, catalog)
6881                .expect("the backend-observed catalog is admitted")
6882                .build()
6883                .into_router();
6884
6885            assert_eq!(
6886                replaced.tools()[0].description.as_deref(),
6887                Some("proxied tool")
6888            );
6889            assert_eq!(replaced.resources()[0].name, "proxied resource");
6890            assert_eq!(
6891                replaced.prompts()[0].description.as_deref(),
6892                Some("proxied prompt")
6893            );
6894        }
6895
6896        #[cfg(feature = "tasks")]
6897        #[test]
6898        fn as_proxy_typed_installs_route_bound_final_tasks_relay() {
6899            let calls = Arc::new(Mutex::new(Vec::new()));
6900            let updates = Arc::new(Mutex::new(Vec::new()));
6901            let server = ServerBuilder::new("prefixed-proxy-tasks", "1.0")
6902                .as_proxy_typed(
6903                    "ext",
6904                    ordinary_proxy_tasks_client(calls, updates),
6905                    final_completion_proxy_catalog(),
6906                )
6907                .expect("as_proxy_typed admits a modern catalog with a Tasks-capable route")
6908                .build();
6909            assert!(
6910                server.final_task_runtime().is_none(),
6911                "as_proxy_typed must install the route-bound Tasks relay instead of the default in-memory store"
6912            );
6913            let discovery = serde_json::to_value(
6914                server
6915                    .server_discovery()
6916                    .expect("the prefixed Tasks proxy remains discoverable"),
6917            )
6918            .expect("prefixed Tasks proxy discovery serializes");
6919            assert_eq!(
6920                discovery.pointer("/capabilities/extensions/io.modelcontextprotocol~1tasks"),
6921                Some(&serde_json::json!({})),
6922                "as_proxy_typed must advertise the same Tasks extension as proxy_typed"
6923            );
6924        }
6925
6926        #[test]
6927        fn as_proxy_raw_propagates_duplicate_registration_errors() {
6928            let result = ServerBuilder::new("srv", "1.0")
6929                .on_duplicate(DuplicateBehavior::Error)
6930                .tool(TestTool)
6931                .as_proxy_raw_with_proxy_client(ProxyClient::from_backend(
6932                    DuplicatePolicyProxyBackend,
6933                ));
6934
6935            let error = match result {
6936                Ok(_) => panic!("raw proxy registration unexpectedly accepted a duplicate tool"),
6937                Err(error) => error,
6938            };
6939            assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6940            assert!(error.message.starts_with("Tool already exists"));
6941        }
6942    }
6943
6944    // ── DEFAULT_REQUEST_TIMEOUT_SECS constant ──────────────────────
6945
6946    #[test]
6947    fn default_request_timeout_constant() {
6948        assert_eq!(DEFAULT_REQUEST_TIMEOUT_SECS, 30);
6949    }
6950
6951    // ── mask_error_details toggling ────────────────────────────────
6952
6953    #[test]
6954    fn builder_mask_error_details_toggle() {
6955        let builder = ServerBuilder::new("srv", "1.0")
6956            .mask_error_details(true)
6957            .mask_error_details(false);
6958        assert!(!builder.is_error_masking_enabled());
6959    }
6960
6961    // ── strict_input_validation toggling ────────────────────────────
6962
6963    #[test]
6964    fn builder_strict_validation_toggle() {
6965        let builder = ServerBuilder::new("srv", "1.0")
6966            .strict_input_validation(true)
6967            .strict_input_validation(false);
6968        assert!(!builder.is_strict_input_validation_enabled());
6969    }
6970
6971    #[cfg(feature = "tasks")]
6972    mod task_manager_tests {
6973        use super::*;
6974
6975        // ── Task manager ──────────────────────────────────────────────────
6976
6977        #[test]
6978        fn default_build_installs_in_memory_official_tasks() {
6979            let server = ServerBuilder::new("srv", "1.0").build();
6980            assert!(
6981                server.final_task_runtime().is_some(),
6982                "default build must install official in-memory Tasks"
6983            );
6984        }
6985
6986        #[test]
6987        fn builder_with_task_manager_retains_manager_without_advertising_capability() {
6988            use crate::tasks::TaskManager;
6989            let tm = TaskManager::new().into_shared();
6990            let server = ServerBuilder::new("srv", "1.0")
6991                .with_task_manager(tm)
6992                .build();
6993            assert!(server.task_manager().is_some());
6994            assert!(server.capabilities().tasks.is_none());
6995            assert!(
6996                server.final_task_runtime().is_none(),
6997                "quarantined task manager must not receive official Tasks"
6998            );
6999        }
7000
7001        #[test]
7002        fn builder_with_notifying_task_manager_keeps_capability_quarantined() {
7003            use crate::tasks::TaskManager;
7004            let tm = TaskManager::with_list_changed_notifications().into_shared();
7005            let server = ServerBuilder::new("srv", "1.0")
7006                .with_task_manager(tm)
7007                .build();
7008            assert!(server.task_manager().is_some());
7009            assert!(server.capabilities().tasks.is_none());
7010        }
7011
7012        #[test]
7013        fn builder_with_non_notifying_task_manager_keeps_capability_quarantined() {
7014            use crate::tasks::TaskManager;
7015            let tm = TaskManager::new().into_shared();
7016            let server = ServerBuilder::new("srv", "1.0")
7017                .with_task_manager(tm)
7018                .build();
7019            assert!(server.task_manager().is_some());
7020            assert!(server.capabilities().tasks.is_none());
7021        }
7022    }
7023
7024    // ── Duplicate behavior for resources and prompts ─────────────────
7025
7026    struct DupResource(&'static str);
7027    impl crate::ResourceHandler for DupResource {
7028        fn definition(&self) -> Resource {
7029            Resource {
7030                uri: format!("file:///{}", self.0),
7031                name: self.0.to_string(),
7032                description: None,
7033                mime_type: None,
7034                icon: None,
7035                version: None,
7036                tags: vec![],
7037            }
7038        }
7039        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
7040            Ok(vec![])
7041        }
7042    }
7043
7044    struct DupPrompt(&'static str);
7045    impl crate::PromptHandler for DupPrompt {
7046        fn definition(&self) -> Prompt {
7047            Prompt {
7048                name: self.0.to_string(),
7049                description: None,
7050                arguments: vec![],
7051                icon: None,
7052                version: None,
7053                tags: vec![],
7054            }
7055        }
7056        fn get(
7057            &self,
7058            _ctx: &McpContext,
7059            _args: std::collections::HashMap<String, String>,
7060        ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
7061            Ok(vec![])
7062        }
7063    }
7064
7065    #[test]
7066    fn builder_on_duplicate_error_resource_logs_but_continues() {
7067        let server = ServerBuilder::new("srv", "1.0")
7068            .on_duplicate(DuplicateBehavior::Error)
7069            .resource(DupResource("dup"))
7070            .resource(DupResource("dup"))
7071            .build();
7072        assert!(server.has_resources());
7073    }
7074
7075    #[test]
7076    fn builder_on_duplicate_error_prompt_logs_but_continues() {
7077        let server = ServerBuilder::new("srv", "1.0")
7078            .on_duplicate(DuplicateBehavior::Error)
7079            .prompt(DupPrompt("dup"))
7080            .prompt(DupPrompt("dup"))
7081            .build();
7082        assert!(server.has_prompts());
7083    }
7084
7085    // ── Proxy with resources and prompts ─────────────────────────────
7086
7087    #[cfg(feature = "proxy")]
7088    #[test]
7089    fn builder_proxy_with_resources_and_prompts() {
7090        use crate::proxy::ProxyClient;
7091
7092        struct DummyBackend2;
7093        impl crate::proxy::ProxyBackend for DummyBackend2 {
7094            fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
7095                Ok(vec![])
7096            }
7097            fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
7098                Ok(vec![Resource {
7099                    uri: "file:///proxy-res".to_owned(),
7100                    name: "proxy-res".to_owned(),
7101                    description: None,
7102                    mime_type: None,
7103                    icon: None,
7104                    version: None,
7105                    tags: Vec::new(),
7106                }])
7107            }
7108            fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
7109                Ok(vec![ResourceTemplate {
7110                    uri_template: "db://{table}".to_owned(),
7111                    name: "db".to_owned(),
7112                    description: None,
7113                    mime_type: None,
7114                    icon: None,
7115                    version: None,
7116                    tags: Vec::new(),
7117                }])
7118            }
7119            fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
7120                Ok(vec![Prompt {
7121                    name: "proxy-prompt".to_owned(),
7122                    description: None,
7123                    arguments: Vec::new(),
7124                    icon: None,
7125                    version: None,
7126                    tags: Vec::new(),
7127                }])
7128            }
7129            fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
7130                Ok(vec![])
7131            }
7132            fn call_tool_with_progress(
7133                &mut self,
7134                _: &str,
7135                _: serde_json::Value,
7136                _: crate::proxy::ProgressCallback<'_>,
7137            ) -> McpResult<Vec<Content>> {
7138                Ok(vec![])
7139            }
7140            fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
7141                Ok(vec![])
7142            }
7143            fn get_prompt(
7144                &mut self,
7145                _: &str,
7146                _: std::collections::HashMap<String, String>,
7147            ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
7148                Ok(vec![])
7149            }
7150        }
7151
7152        let client = ProxyClient::from_backend(DummyBackend2);
7153        let catalog = client
7154            .catalog()
7155            .expect("backend discovery supplies the legacy era evidence");
7156
7157        let server = ServerBuilder::new("srv", "1.0")
7158            .proxy(client, catalog)
7159            .expect("the backend-observed catalog is admitted")
7160            .build();
7161        assert!(server.has_resources());
7162        assert!(server.has_prompts());
7163        assert!(!server.has_tools());
7164    }
7165
7166    // ── Build propagates strict validation to router ─────────────────
7167
7168    #[test]
7169    fn build_propagates_strict_validation_to_router() {
7170        let server = ServerBuilder::new("srv", "1.0")
7171            .strict_input_validation(true)
7172            .build();
7173        let router = server.into_router();
7174        assert!(router.strict_input_validation());
7175    }
7176
7177    #[test]
7178    fn build_propagates_strict_validation_false_to_router() {
7179        let server = ServerBuilder::new("srv", "1.0")
7180            .strict_input_validation(false)
7181            .build();
7182        let router = server.into_router();
7183        assert!(!router.strict_input_validation());
7184    }
7185
7186    // ── log_level_filter with Off ────────────────────────────────────
7187
7188    #[test]
7189    fn builder_log_level_filter_off() {
7190        let builder = ServerBuilder::new("srv", "1.0").log_level_filter(LevelFilter::Off);
7191        assert_eq!(builder.logging.level, LevelFilter::Off);
7192        assert_eq!(builder.console_config.log_level, LevelFilter::Off);
7193    }
7194
7195    // ── mount does not update capabilities when nothing mounted ──────
7196
7197    #[test]
7198    fn builder_mount_no_op_leaves_capabilities_unchanged() {
7199        let source = ServerBuilder::new("sub", "1.0").build();
7200        let main = ServerBuilder::new("main", "1.0")
7201            .mount(source, Some("ns"))
7202            .build();
7203        assert!(!main.has_tools());
7204        assert!(!main.has_resources());
7205        assert!(!main.has_prompts());
7206    }
7207}