1use 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
58const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
60
61const FASTMCP_PROTOCOL_POLICY_ENV: &str = "FASTMCP_PROTOCOL_POLICY";
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ServerLaunchPolicyError {
68 NonUnicode,
70 InvalidValue,
72 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
140pub struct ServerBuilder {
142 info: ServerInfo,
143 capabilities: ServerCapabilities,
144 router: Router,
145 instructions: Option<String>,
146 title: Option<String>,
148 description: Option<String>,
150 website_url: Option<String>,
152 icons: Vec<fastmcp_protocol::common_types::RawIcon>,
154 request_timeout_secs: u64,
156 stats_enabled: bool,
158 mask_error_details: bool,
160 logging: LoggingConfig,
162 console_config: ConsoleConfig,
164 lifespan: LifespanHooks,
166 auth_provider: Option<Arc<dyn AuthProvider>>,
168 middleware: Vec<Box<dyn crate::Middleware>>,
170 #[cfg(all(test, feature = "tasks"))]
173 task_manager: Option<SharedTaskManager>,
174 on_duplicate: DuplicateBehavior,
176 strict_input_validation: bool,
178 max_bidirectional_requests_per_connection: usize,
180 protocol_policy: ProtocolPolicy,
182 launch_protocol_policy: Option<ProtocolPolicy>,
185 http_config: HttpServerConfig,
187 oauth_http_routes: Option<OAuthHttpRoutes>,
189 extension_runtime: Option<ServerExtensionRuntime>,
191 #[cfg(feature = "tasks")]
193 final_task_runtime: Option<FinalTaskRuntime>,
194 #[cfg(all(feature = "proxy", feature = "tasks"))]
197 final_task_relay: Option<Arc<ProxyFinalTaskRelay>>,
198}
199
200impl ServerBuilder {
201 #[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 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 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, 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 #[must_use]
340 pub fn on_duplicate(mut self, behavior: DuplicateBehavior) -> Self {
341 self.on_duplicate = behavior;
342 self
343 }
344
345 #[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 #[must_use]
359 pub fn without_stats(mut self) -> Self {
360 self.stats_enabled = false;
361 self
362 }
363
364 #[must_use]
372 pub fn request_timeout(mut self, secs: u64) -> Self {
373 self.request_timeout_secs = secs;
374 self
375 }
376
377 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 #[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 #[must_use]
422 pub fn mask_error_details(mut self, enabled: bool) -> Self {
423 self.mask_error_details = enabled;
424 self
425 }
426
427 #[must_use]
445 pub fn auto_mask_errors(mut self) -> Self {
446 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 _ => {} }
459 }
460
461 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 self.mask_error_details = cfg!(not(debug_assertions));
471 self
472 }
473
474 #[must_use]
476 pub fn is_error_masking_enabled(&self) -> bool {
477 self.mask_error_details
478 }
479
480 #[must_use]
496 pub fn strict_input_validation(mut self, enabled: bool) -> Self {
497 self.strict_input_validation = enabled;
498 self
499 }
500
501 #[must_use]
503 pub fn is_strict_input_validation_enabled(&self) -> bool {
504 self.strict_input_validation
505 }
506
507 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
754 pub fn http_config(mut self, config: HttpServerConfig) -> Self {
755 self.http_config = config;
756 self
757 }
758
759 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[cfg(feature = "proxy")]
1116 fn absorb_prefixed_proxy_duplicate(
1117 error: fastmcp_core::McpError,
1118 ) -> Result<(), fastmcp_core::McpError> {
1119 if crate::router::is_duplicate_registration_error(&error) {
1120 Ok(())
1121 } else {
1122 Err(error)
1123 }
1124 }
1125
1126 #[cfg(all(feature = "proxy", feature = "tasks"))]
1130 fn install_proxy_final_tasks_relay(
1131 &mut self,
1132 task_relay: Option<Arc<ProxyFinalTaskRelay>>,
1133 ) -> McpResult<()> {
1134 let Some(task_relay) = task_relay else {
1135 return Ok(());
1136 };
1137 if self.final_task_runtime.is_some() || self.final_task_relay.is_some() {
1138 return Err(fastmcp_core::McpError::invalid_request(
1139 "a server may install only one local or route-bound final Tasks service",
1140 ));
1141 }
1142 if let Some(extension_runtime) = self.extension_runtime.as_mut() {
1143 extension_runtime
1144 .install_proxy_final_tasks(Arc::clone(&task_relay))
1145 .map_err(|error| fastmcp_core::McpError::invalid_request(error.to_string()))?;
1146 }
1147 self.router
1148 .set_final_task_relay(Some(Arc::clone(&task_relay)));
1149 self.final_task_relay = Some(task_relay);
1150 Ok(())
1151 }
1152
1153 #[cfg(feature = "proxy")]
1165 pub fn proxy(mut self, client: ProxyClient, catalog: ProxyCatalog) -> McpResult<Self> {
1166 client.admit_catalog(&catalog)?;
1167 let catalog_era = catalog.era()?;
1168 let completion_supported = match client.supports_completion() {
1169 Ok(supported) => supported,
1170 Err(error) => {
1171 log::error!(
1172 target: "fastmcp_rust::builder",
1173 "Failed to determine proxied completion support; code={:?}",
1174 error.code
1175 );
1176 false
1177 }
1178 };
1179 let has_tools = !catalog.tools.is_empty() || !catalog.final_tools.is_empty();
1184 let has_resources = !catalog.resources.is_empty()
1185 || !catalog.resource_templates.is_empty()
1186 || !catalog.final_resources.is_empty()
1187 || !catalog.final_resource_templates.is_empty();
1188 let has_prompts = !catalog.prompts.is_empty() || !catalog.final_prompts.is_empty();
1189 #[cfg(feature = "tasks")]
1190 let task_relay = if catalog_era == ProtocolEra::Modern2026 {
1191 client.final_tasks_relay()?
1192 } else {
1193 None
1194 };
1195 #[cfg(feature = "tasks")]
1196 self.install_proxy_final_tasks_relay(task_relay.clone())?;
1197 #[cfg(not(feature = "tasks"))]
1198 let final_handlers = catalog.final_tool_handlers(client.clone())?;
1199 #[cfg(feature = "tasks")]
1200 let final_handlers = catalog
1201 .final_tools
1202 .iter()
1203 .cloned()
1204 .map(|tool| match task_relay.as_ref() {
1205 Some(task_relay) => ProxyToolHandler::from_final_with_task_relay(
1206 tool,
1207 client.clone(),
1208 Arc::clone(task_relay),
1209 ),
1210 None => ProxyToolHandler::from_final(tool, client.clone()),
1211 })
1212 .collect::<McpResult<Vec<_>>>()?;
1213
1214 for tool in catalog.tools {
1218 if let Err(error) = self.router.add_legacy_tool_with_behavior(
1219 ProxyToolHandler::new(tool, client.clone()),
1220 self.on_duplicate,
1221 ) {
1222 log::error!(
1223 target: "fastmcp_rust::builder",
1224 "Failed to register proxied tool; code={:?}",
1225 error.code
1226 );
1227 Self::absorb_prefixed_proxy_duplicate(error)?;
1228 }
1229 }
1230
1231 for handler in final_handlers {
1232 match self
1233 .router
1234 .add_final_tool_with_behavior(handler, self.on_duplicate)
1235 {
1236 Ok(()) => {}
1237 Err(error) => {
1238 log::error!(
1239 target: "fastmcp_rust::builder",
1240 "Failed to register exact-final proxied tool; code={:?}",
1241 error.code
1242 );
1243 Self::absorb_prefixed_proxy_duplicate(error)?;
1244 }
1245 }
1246 }
1247
1248 for resource in catalog.resources {
1249 if let Err(error) = self.router.add_legacy_resource_with_behavior(
1250 ProxyResourceHandler::new(resource, client.clone()),
1251 self.on_duplicate,
1252 ) {
1253 log::error!(
1254 target: "fastmcp_rust::builder",
1255 "Failed to register proxied resource; code={:?}",
1256 error.code
1257 );
1258 Self::absorb_prefixed_proxy_duplicate(error)?;
1259 }
1260 }
1261
1262 for template in catalog.resource_templates {
1263 let downstream_uri = template.uri_template.clone();
1264 let completion_target_admitted =
1265 self.proxy_resource_template_wins_admission(&downstream_uri);
1266 match self.router.add_legacy_resource_with_behavior(
1267 ProxyResourceHandler::from_template(template, client.clone()),
1268 self.on_duplicate,
1269 ) {
1270 Ok(()) if completion_target_admitted => {
1271 if completion_supported && catalog_era == ProtocolEra::Legacy2024 {
1272 self.router.add_legacy_resource_template_completion_handler(
1273 downstream_uri.clone(),
1274 ProxyCompletionHandler::for_resource_template(
1275 client.clone(),
1276 downstream_uri.clone(),
1277 downstream_uri,
1278 ),
1279 );
1280 self.advertise_completions();
1281 }
1282 }
1283 Ok(()) => {}
1284 Err(error) => {
1285 log::error!(
1286 target: "fastmcp_rust::builder",
1287 "Failed to register proxied resource template; code={:?}",
1288 error.code
1289 );
1290 Self::absorb_prefixed_proxy_duplicate(error)?;
1291 }
1292 }
1293 }
1294
1295 for prompt in catalog.prompts {
1296 let downstream_name = prompt.name.clone();
1297 let completion_target_admitted = self.proxy_prompt_wins_admission(&downstream_name);
1298 match self.router.add_legacy_prompt_with_behavior(
1299 ProxyPromptHandler::new(prompt, client.clone()),
1300 self.on_duplicate,
1301 ) {
1302 Ok(()) if completion_target_admitted => {
1303 if completion_supported && catalog_era == ProtocolEra::Legacy2024 {
1304 self.router.add_legacy_prompt_completion_handler(
1305 downstream_name.clone(),
1306 ProxyCompletionHandler::for_prompt(
1307 client.clone(),
1308 downstream_name.clone(),
1309 downstream_name,
1310 ),
1311 );
1312 self.advertise_completions();
1313 }
1314 }
1315 Ok(()) => {}
1316 Err(error) => {
1317 log::error!(
1318 target: "fastmcp_rust::builder",
1319 "Failed to register proxied prompt; code={:?}",
1320 error.code
1321 );
1322 Self::absorb_prefixed_proxy_duplicate(error)?;
1323 }
1324 }
1325 }
1326
1327 for resource in catalog.final_resources {
1328 if let Err(error) = self.router.add_final_resource_with_behavior(
1329 FinalProxyResourceHandler::new(resource, client.clone()),
1330 self.on_duplicate,
1331 ) {
1332 log::error!(
1333 target: "fastmcp_rust::builder",
1334 "Failed to register exact-final proxied resource; code={:?}",
1335 error.code
1336 );
1337 Self::absorb_prefixed_proxy_duplicate(error)?;
1338 }
1339 }
1340
1341 for template in catalog.final_resource_templates {
1342 let downstream_uri = template.uri_template.clone();
1343 let completion_target_admitted =
1344 self.proxy_resource_template_wins_admission(&downstream_uri);
1345 match self.router.add_final_resource_with_behavior(
1346 FinalProxyResourceTemplateHandler::new(template, client.clone()),
1347 self.on_duplicate,
1348 ) {
1349 Ok(())
1350 if completion_target_admitted
1351 && completion_supported
1352 && catalog_era == ProtocolEra::Modern2026 =>
1353 {
1354 self.router.add_resource_template_completion_handler(
1355 downstream_uri.clone(),
1356 ProxyCompletionHandler::for_resource_template(
1357 client.clone(),
1358 downstream_uri.clone(),
1359 downstream_uri,
1360 ),
1361 );
1362 self.advertise_completions();
1363 }
1364 Ok(()) => {}
1365 Err(error) => {
1366 log::error!(
1367 target: "fastmcp_rust::builder",
1368 "Failed to register exact-final proxied resource template; code={:?}",
1369 error.code
1370 );
1371 Self::absorb_prefixed_proxy_duplicate(error)?;
1372 }
1373 }
1374 }
1375
1376 for prompt in catalog.final_prompts {
1377 let downstream_name = prompt.name.clone();
1378 let completion_target_admitted = self.proxy_prompt_wins_admission(&downstream_name);
1379 match self.router.add_final_prompt_with_behavior(
1380 FinalProxyPromptHandler::new(prompt, client.clone()),
1381 self.on_duplicate,
1382 ) {
1383 Ok(())
1384 if completion_target_admitted
1385 && completion_supported
1386 && catalog_era == ProtocolEra::Modern2026 =>
1387 {
1388 self.router.add_prompt_completion_handler(
1389 downstream_name.clone(),
1390 ProxyCompletionHandler::for_prompt(
1391 client.clone(),
1392 downstream_name.clone(),
1393 downstream_name,
1394 ),
1395 );
1396 self.advertise_completions();
1397 }
1398 Ok(()) => {}
1399 Err(error) => {
1400 log::error!(
1401 target: "fastmcp_rust::builder",
1402 "Failed to register exact-final proxied prompt; code={:?}",
1403 error.code
1404 );
1405 Self::absorb_prefixed_proxy_duplicate(error)?;
1406 }
1407 }
1408 }
1409
1410 if has_tools {
1411 self.advertise_legacy_tools_list_changed();
1412 }
1413 if has_resources {
1414 self.advertise_legacy_resource_subscriptions();
1415 }
1416 if has_prompts {
1417 self.advertise_legacy_prompts_list_changed();
1418 }
1419
1420 Ok(self)
1421 }
1422
1423 #[cfg(feature = "proxy")]
1454 pub fn as_proxy(
1455 self,
1456 prefix: &str,
1457 client: fastmcp_client::Client,
1458 ) -> Result<Self, fastmcp_core::McpError> {
1459 let proxy_client = ProxyClient::from_client(client)?;
1460 let catalog = proxy_client.catalog_typed()?;
1461 self.register_prefixed_typed_proxy_catalog(prefix, proxy_client, catalog)
1462 }
1463
1464 #[cfg(feature = "proxy")]
1480 pub fn as_proxy_typed(
1481 self,
1482 prefix: &str,
1483 proxy_client: ProxyClient,
1484 catalog: ProxyTypedCatalog,
1485 ) -> Result<Self, fastmcp_core::McpError> {
1486 self.register_prefixed_typed_proxy_catalog(prefix, proxy_client, catalog)
1487 }
1488
1489 #[cfg(feature = "proxy")]
1492 fn adopt_upstream_proxy_instructions(
1493 &mut self,
1494 proxy_client: &ProxyClient,
1495 ) -> Result<(), fastmcp_core::McpError> {
1496 if self
1497 .instructions
1498 .as_ref()
1499 .is_some_and(|instructions| !instructions.is_empty())
1500 {
1501 return Ok(());
1502 }
1503 if let Some(instructions) = proxy_client.upstream_instructions()?
1504 && !instructions.is_empty()
1505 {
1506 self.instructions = Some(instructions);
1507 }
1508 Ok(())
1509 }
1510
1511 #[cfg(feature = "proxy")]
1517 fn adopt_upstream_proxy_implementation(
1518 &mut self,
1519 proxy_client: &ProxyClient,
1520 ) -> Result<(), fastmcp_core::McpError> {
1521 let gateway_has_extras = self.title.as_ref().is_some_and(|title| !title.is_empty())
1522 || self
1523 .description
1524 .as_ref()
1525 .is_some_and(|description| !description.is_empty())
1526 || self
1527 .website_url
1528 .as_ref()
1529 .is_some_and(|website| !website.is_empty())
1530 || !self.icons.is_empty();
1531 if gateway_has_extras {
1532 return Ok(());
1533 }
1534 let Some(implementation) = proxy_client.upstream_implementation()? else {
1535 return Ok(());
1536 };
1537 if implementation.title.is_none()
1538 && implementation.description.is_none()
1539 && implementation.website_url.is_none()
1540 && implementation.icons.is_empty()
1541 {
1542 return Ok(());
1543 }
1544 self.title = implementation.title;
1545 self.description = implementation.description;
1546 self.website_url = implementation
1547 .website_url
1548 .map(|uri| uri.as_str().to_owned());
1549 self.icons = implementation.icons;
1550 Ok(())
1551 }
1552
1553 #[cfg(feature = "proxy")]
1554 fn register_prefixed_typed_proxy_catalog(
1555 mut self,
1556 prefix: &str,
1557 proxy_client: ProxyClient,
1558 catalog: ProxyTypedCatalog,
1559 ) -> Result<Self, fastmcp_core::McpError> {
1560 proxy_client.admit_typed_catalog(&catalog)?;
1561 self.adopt_upstream_proxy_instructions(&proxy_client)?;
1562 self.adopt_upstream_proxy_implementation(&proxy_client)?;
1563 let completion_supported = proxy_client.supports_completion()?;
1564 #[cfg(feature = "tasks")]
1565 let catalog_era = catalog.era()?;
1566 #[cfg(feature = "tasks")]
1567 let task_relay = if catalog_era == ProtocolEra::Modern2026 {
1568 proxy_client.final_tasks_relay()?
1569 } else {
1570 None
1571 };
1572 #[cfg(feature = "tasks")]
1573 self.install_proxy_final_tasks_relay(task_relay.clone())?;
1574 let (tool_count, resource_count, template_count, prompt_count) = match catalog {
1575 ProxyTypedCatalog {
1576 tools: ProxyToolCatalog::Legacy(tools),
1577 resources: ProxyResourceCatalog::Legacy(resources),
1578 resource_templates: ProxyResourceTemplateCatalog::Legacy(resource_templates),
1579 prompts: ProxyPromptCatalog::Legacy(prompts),
1580 } => {
1581 let counts = (
1582 tools.len(),
1583 resources.len(),
1584 resource_templates.len(),
1585 prompts.len(),
1586 );
1587 for tool in tools {
1591 if let Err(error) = self.router.add_legacy_tool_with_behavior(
1592 ProxyToolHandler::with_prefix(tool, prefix, proxy_client.clone()),
1593 self.on_duplicate,
1594 ) {
1595 log::error!(
1596 target: "fastmcp_rust::builder",
1597 "Failed to register prefixed proxied tool; code={:?}",
1598 error.code
1599 );
1600 Self::absorb_prefixed_proxy_duplicate(error)?;
1601 }
1602 }
1603 for resource in resources {
1604 if let Err(error) = self.router.add_legacy_resource_with_behavior(
1605 ProxyResourceHandler::with_prefix(resource, prefix, proxy_client.clone()),
1606 self.on_duplicate,
1607 ) {
1608 log::error!(
1609 target: "fastmcp_rust::builder",
1610 "Failed to register prefixed proxied resource; code={:?}",
1611 error.code
1612 );
1613 Self::absorb_prefixed_proxy_duplicate(error)?;
1614 }
1615 }
1616 for template in resource_templates {
1617 let upstream_uri = template.uri_template.clone();
1618 let downstream_uri = format!("{prefix}/{upstream_uri}");
1619 let completion_target_admitted =
1620 self.proxy_resource_template_wins_admission(&downstream_uri);
1621 match self.router.add_legacy_resource_with_behavior(
1622 ProxyResourceHandler::from_template_with_prefix(
1623 template,
1624 prefix,
1625 proxy_client.clone(),
1626 ),
1627 self.on_duplicate,
1628 ) {
1629 Ok(()) if completion_target_admitted && completion_supported => {
1630 self.router.add_legacy_resource_template_completion_handler(
1631 downstream_uri.clone(),
1632 ProxyCompletionHandler::for_resource_template(
1633 proxy_client.clone(),
1634 downstream_uri,
1635 upstream_uri,
1636 ),
1637 );
1638 self.advertise_completions();
1639 }
1640 Ok(()) => {}
1641 Err(error) => {
1642 log::error!(
1643 target: "fastmcp_rust::builder",
1644 "Failed to register prefixed proxied resource template; code={:?}",
1645 error.code
1646 );
1647 Self::absorb_prefixed_proxy_duplicate(error)?;
1648 }
1649 }
1650 }
1651 for prompt in prompts {
1652 let upstream_name = prompt.name.clone();
1653 let downstream_name = format!("{prefix}/{upstream_name}");
1654 let completion_target_admitted =
1655 self.proxy_prompt_wins_admission(&downstream_name);
1656 match self.router.add_legacy_prompt_with_behavior(
1657 ProxyPromptHandler::with_prefix(prompt, prefix, proxy_client.clone()),
1658 self.on_duplicate,
1659 ) {
1660 Ok(()) if completion_target_admitted && completion_supported => {
1661 self.router.add_legacy_prompt_completion_handler(
1662 downstream_name.clone(),
1663 ProxyCompletionHandler::for_prompt(
1664 proxy_client.clone(),
1665 downstream_name,
1666 upstream_name,
1667 ),
1668 );
1669 self.advertise_completions();
1670 }
1671 Ok(()) => {}
1672 Err(error) => {
1673 log::error!(
1674 target: "fastmcp_rust::builder",
1675 "Failed to register prefixed proxied prompt; code={:?}",
1676 error.code
1677 );
1678 Self::absorb_prefixed_proxy_duplicate(error)?;
1679 }
1680 }
1681 }
1682 counts
1683 }
1684 ProxyTypedCatalog {
1685 tools: ProxyToolCatalog::Final(tools),
1686 resources: ProxyResourceCatalog::Final(resources),
1687 resource_templates: ProxyResourceTemplateCatalog::Final(resource_templates),
1688 prompts: ProxyPromptCatalog::Final(prompts),
1689 } => {
1690 let counts = (
1694 tools.len(),
1695 resources.len(),
1696 resource_templates.len(),
1697 prompts.len(),
1698 );
1699 for tool in tools {
1700 #[cfg(feature = "tasks")]
1701 let handler = match task_relay.as_ref() {
1702 Some(task_relay) => ProxyToolHandler::with_prefix_final_with_task_relay(
1703 tool,
1704 prefix,
1705 proxy_client.clone(),
1706 Arc::clone(task_relay),
1707 )?,
1708 None => {
1709 ProxyToolHandler::with_prefix_final(tool, prefix, proxy_client.clone())?
1710 }
1711 };
1712 #[cfg(not(feature = "tasks"))]
1713 let handler =
1714 ProxyToolHandler::with_prefix_final(tool, prefix, proxy_client.clone())?;
1715 if let Err(error) = self
1716 .router
1717 .add_final_tool_with_behavior(handler, self.on_duplicate)
1718 {
1719 log::error!(
1720 target: "fastmcp_rust::builder",
1721 "Failed to register prefixed exact-final proxied tool; code={:?}",
1722 error.code
1723 );
1724 Self::absorb_prefixed_proxy_duplicate(error)?;
1725 }
1726 }
1727 for resource in resources {
1728 if let Err(error) = self.router.add_final_resource_with_behavior(
1729 FinalProxyResourceHandler::new(resource, proxy_client.clone()),
1730 self.on_duplicate,
1731 ) {
1732 log::error!(
1733 target: "fastmcp_rust::builder",
1734 "Failed to register prefixed exact-final proxied resource; code={:?}",
1735 error.code
1736 );
1737 Self::absorb_prefixed_proxy_duplicate(error)?;
1738 }
1739 }
1740 for template in resource_templates {
1741 let downstream_uri = template.uri_template.clone();
1742 let completion_target_admitted =
1743 self.proxy_resource_template_wins_admission(&downstream_uri);
1744 match self.router.add_final_resource_with_behavior(
1745 FinalProxyResourceTemplateHandler::new(template, proxy_client.clone()),
1746 self.on_duplicate,
1747 ) {
1748 Ok(()) if completion_target_admitted && completion_supported => {
1749 self.router.add_resource_template_completion_handler(
1750 downstream_uri.clone(),
1751 ProxyCompletionHandler::for_resource_template(
1752 proxy_client.clone(),
1753 downstream_uri.clone(),
1754 downstream_uri,
1755 ),
1756 );
1757 self.advertise_completions();
1758 }
1759 Ok(()) => {}
1760 Err(error) => {
1761 log::error!(
1762 target: "fastmcp_rust::builder",
1763 "Failed to register prefixed exact-final proxied resource template; code={:?}",
1764 error.code
1765 );
1766 Self::absorb_prefixed_proxy_duplicate(error)?;
1767 }
1768 }
1769 }
1770 for prompt in prompts {
1771 let upstream_name = prompt.name.clone();
1772 let downstream_name = format!("{prefix}/{upstream_name}");
1773 let completion_target_admitted =
1774 self.proxy_prompt_wins_admission(&downstream_name);
1775 match self.router.add_final_prompt_with_behavior(
1776 FinalProxyPromptHandler::with_prefix(prompt, prefix, proxy_client.clone()),
1777 self.on_duplicate,
1778 ) {
1779 Ok(()) if completion_target_admitted && completion_supported => {
1780 self.router.add_prompt_completion_handler(
1781 downstream_name.clone(),
1782 ProxyCompletionHandler::for_prompt(
1783 proxy_client.clone(),
1784 downstream_name,
1785 upstream_name,
1786 ),
1787 );
1788 self.advertise_completions();
1789 }
1790 Ok(()) => {}
1791 Err(error) => {
1792 log::error!(
1793 target: "fastmcp_rust::builder",
1794 "Failed to register prefixed exact-final proxied prompt; code={:?}",
1795 error.code
1796 );
1797 Self::absorb_prefixed_proxy_duplicate(error)?;
1798 }
1799 }
1800 }
1801 counts
1802 }
1803 _ => {
1804 return Err(fastmcp_core::McpError::invalid_request(
1805 "proxy typed catalog mixes legacy and final component vectors",
1806 ));
1807 }
1808 };
1809
1810 let has_tools = tool_count > 0;
1811 let has_resources = resource_count > 0 || template_count > 0;
1812 let has_prompts = prompt_count > 0;
1813
1814 if has_tools {
1816 self.advertise_legacy_tools_list_changed();
1817 }
1818 if has_resources {
1819 self.advertise_legacy_resource_subscriptions();
1820 }
1821 if has_prompts {
1822 self.advertise_legacy_prompts_list_changed();
1823 }
1824
1825 log::info!(
1826 target: "fastmcp_rust::proxy",
1827 "Proxied {} tools, {} resources, {} templates, and {} prompts with a configured prefix",
1828 tool_count,
1829 resource_count,
1830 template_count,
1831 prompt_count
1832 );
1833
1834 Ok(self)
1835 }
1836
1837 #[cfg(feature = "proxy")]
1856 pub fn as_proxy_raw(
1857 self,
1858 client: fastmcp_client::Client,
1859 ) -> Result<Self, fastmcp_core::McpError> {
1860 self.as_proxy_raw_with_proxy_client(ProxyClient::from_client(client)?)
1861 }
1862
1863 #[cfg(feature = "proxy")]
1876 pub fn proxy_typed(
1877 self,
1878 proxy_client: ProxyClient,
1879 catalog: ProxyTypedCatalog,
1880 ) -> Result<Self, fastmcp_core::McpError> {
1881 self.register_raw_typed_proxy_catalog(proxy_client, catalog)
1882 }
1883
1884 #[cfg(feature = "proxy")]
1885 fn as_proxy_raw_with_proxy_client(
1886 self,
1887 proxy_client: ProxyClient,
1888 ) -> Result<Self, fastmcp_core::McpError> {
1889 let catalog = proxy_client.catalog_typed()?;
1890 self.register_raw_typed_proxy_catalog(proxy_client, catalog)
1891 }
1892
1893 #[cfg(feature = "proxy")]
1897 fn register_raw_typed_proxy_catalog(
1898 mut self,
1899 proxy_client: ProxyClient,
1900 catalog: ProxyTypedCatalog,
1901 ) -> Result<Self, fastmcp_core::McpError> {
1902 proxy_client.admit_typed_catalog(&catalog)?;
1907 self.adopt_upstream_proxy_instructions(&proxy_client)?;
1908 self.adopt_upstream_proxy_implementation(&proxy_client)?;
1909 let catalog_era = catalog.era()?;
1910 let completion_supported = proxy_client.supports_completion()?;
1911 #[cfg(feature = "tasks")]
1912 let task_relay = if catalog_era == ProtocolEra::Modern2026 {
1913 proxy_client.final_tasks_relay()?
1914 } else {
1915 None
1916 };
1917 #[cfg(feature = "tasks")]
1918 self.install_proxy_final_tasks_relay(task_relay.clone())?;
1919 match (
1920 catalog.tools,
1921 catalog.resources,
1922 catalog.resource_templates,
1923 catalog.prompts,
1924 ) {
1925 (
1926 ProxyToolCatalog::Legacy(tools),
1927 ProxyResourceCatalog::Legacy(resources),
1928 ProxyResourceTemplateCatalog::Legacy(resource_templates),
1929 ProxyPromptCatalog::Legacy(prompts),
1930 ) => {
1931 let has_tools = !tools.is_empty();
1932 let has_resources = !resources.is_empty() || !resource_templates.is_empty();
1933 let has_prompts = !prompts.is_empty();
1934 for tool in tools {
1938 self.router.add_legacy_tool_with_behavior(
1939 ProxyToolHandler::new(tool, proxy_client.clone()),
1940 self.on_duplicate,
1941 )?;
1942 }
1943 for resource in resources {
1944 self.router.add_legacy_resource_with_behavior(
1945 ProxyResourceHandler::new(resource, proxy_client.clone()),
1946 self.on_duplicate,
1947 )?;
1948 }
1949 for template in resource_templates {
1950 let downstream_uri = template.uri_template.clone();
1951 let completion_target_admitted =
1952 self.proxy_resource_template_wins_admission(&downstream_uri);
1953 self.router.add_legacy_resource_with_behavior(
1954 ProxyResourceHandler::from_template(template, proxy_client.clone()),
1955 self.on_duplicate,
1956 )?;
1957 if completion_target_admitted && completion_supported {
1958 self.router.add_legacy_resource_template_completion_handler(
1959 downstream_uri.clone(),
1960 ProxyCompletionHandler::for_resource_template(
1961 proxy_client.clone(),
1962 downstream_uri.clone(),
1963 downstream_uri,
1964 ),
1965 );
1966 self.advertise_completions();
1967 }
1968 }
1969 for prompt in prompts {
1970 let downstream_name = prompt.name.clone();
1971 let completion_target_admitted =
1972 self.proxy_prompt_wins_admission(&downstream_name);
1973 self.router.add_legacy_prompt_with_behavior(
1974 ProxyPromptHandler::new(prompt, proxy_client.clone()),
1975 self.on_duplicate,
1976 )?;
1977 if completion_target_admitted && completion_supported {
1978 self.router.add_legacy_prompt_completion_handler(
1979 downstream_name.clone(),
1980 ProxyCompletionHandler::for_prompt(
1981 proxy_client.clone(),
1982 downstream_name.clone(),
1983 downstream_name,
1984 ),
1985 );
1986 self.advertise_completions();
1987 }
1988 }
1989 if has_tools {
1990 self.advertise_legacy_tools_list_changed();
1991 }
1992 if has_resources {
1993 self.advertise_legacy_resource_subscriptions();
1994 }
1995 if has_prompts {
1996 self.advertise_legacy_prompts_list_changed();
1997 }
1998 }
1999 (
2000 ProxyToolCatalog::Final(tools),
2001 ProxyResourceCatalog::Final(resources),
2002 ProxyResourceTemplateCatalog::Final(resource_templates),
2003 ProxyPromptCatalog::Final(prompts),
2004 ) => {
2005 let has_tools = !tools.is_empty();
2006 let has_resources = !resources.is_empty() || !resource_templates.is_empty();
2007 let has_prompts = !prompts.is_empty();
2008 for tool in tools {
2009 let handler = ProxyToolHandler::from_final(tool, proxy_client.clone())?;
2014 self.router
2015 .add_final_tool_with_behavior(handler, self.on_duplicate)?;
2016 }
2017 for resource in resources {
2018 self.router.add_final_resource_with_behavior(
2019 FinalProxyResourceHandler::new(resource, proxy_client.clone()),
2020 self.on_duplicate,
2021 )?;
2022 }
2023 for template in resource_templates {
2024 let downstream_uri = template.uri_template.clone();
2025 let completion_target_admitted =
2026 self.proxy_resource_template_wins_admission(&downstream_uri);
2027 self.router.add_final_resource_with_behavior(
2028 FinalProxyResourceTemplateHandler::new(template, proxy_client.clone()),
2029 self.on_duplicate,
2030 )?;
2031 if completion_target_admitted && completion_supported {
2032 self.router.add_resource_template_completion_handler(
2033 downstream_uri.clone(),
2034 ProxyCompletionHandler::for_resource_template(
2035 proxy_client.clone(),
2036 downstream_uri.clone(),
2037 downstream_uri,
2038 ),
2039 );
2040 self.advertise_completions();
2041 }
2042 }
2043 for prompt in prompts {
2044 let downstream_name = prompt.name.clone();
2045 let completion_target_admitted =
2046 self.proxy_prompt_wins_admission(&downstream_name);
2047 self.router.add_final_prompt_with_behavior(
2048 FinalProxyPromptHandler::new(prompt, proxy_client.clone()),
2049 self.on_duplicate,
2050 )?;
2051 if completion_target_admitted && completion_supported {
2052 self.router.add_prompt_completion_handler(
2053 downstream_name.clone(),
2054 ProxyCompletionHandler::for_prompt(
2055 proxy_client.clone(),
2056 downstream_name.clone(),
2057 downstream_name,
2058 ),
2059 );
2060 self.advertise_completions();
2061 }
2062 }
2063 if has_tools {
2064 self.advertise_legacy_tools_list_changed();
2065 }
2066 if has_resources {
2067 self.advertise_legacy_resource_subscriptions();
2068 }
2069 if has_prompts {
2070 self.advertise_legacy_prompts_list_changed();
2071 }
2072 }
2073 _ => {
2074 return Err(fastmcp_core::McpError::invalid_request(
2075 "proxy typed catalog mixes legacy and final component vectors",
2076 ));
2077 }
2078 }
2079 Ok(self)
2080 }
2081
2082 #[must_use]
2120 pub fn mount(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2121 #[cfg(feature = "apps")]
2122 if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2123 log::error!(
2124 target: "fastmcp_rust::mount",
2125 "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2126 );
2127 return self;
2128 }
2129
2130 let has_tools = server.has_tools();
2131 let has_resources = server.has_resources();
2132 let has_prompts = server.has_prompts();
2133
2134 let source_router = server.into_router();
2135 let result = self
2136 .router
2137 .mount_with_behavior(source_router, prefix, self.on_duplicate);
2138
2139 for warning in &result.warnings {
2141 log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2142 }
2143 for error in &result.errors {
2144 log::error!(target: "fastmcp_rust::mount", "{}", error);
2145 }
2146
2147 if has_tools && result.tools > 0 {
2149 self.advertise_legacy_tools_list_changed();
2150 }
2151 if has_resources && (result.resources > 0 || result.resource_templates > 0) {
2152 self.advertise_legacy_resource_subscriptions();
2153 }
2154 if has_prompts && result.prompts > 0 {
2155 self.advertise_legacy_prompts_list_changed();
2156 }
2157 if self.router.has_completion_handler() {
2158 self.advertise_completions();
2159 }
2160
2161 self
2162 }
2163
2164 #[must_use]
2170 pub fn mount_preserving_resource_uris(
2171 mut self,
2172 server: crate::Server,
2173 prefix: Option<&str>,
2174 ) -> Self {
2175 #[cfg(feature = "apps")]
2176 if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2177 log::error!(
2178 target: "fastmcp_rust::mount",
2179 "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2180 );
2181 return self;
2182 }
2183
2184 let has_tools = server.has_tools();
2185 let has_resources = server.has_resources();
2186 let has_prompts = server.has_prompts();
2187
2188 let source_router = server.into_router();
2189 let result =
2190 self.router
2191 .mount_namespaced_with_behavior(source_router, prefix, self.on_duplicate);
2192
2193 for warning in &result.warnings {
2194 log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2195 }
2196 for error in &result.errors {
2197 log::error!(target: "fastmcp_rust::mount", "{}", error);
2198 }
2199
2200 if has_tools && result.tools > 0 {
2201 self.advertise_legacy_tools_list_changed();
2202 }
2203 if has_resources && (result.resources > 0 || result.resource_templates > 0) {
2204 self.advertise_legacy_resource_subscriptions();
2205 }
2206 if has_prompts && result.prompts > 0 {
2207 self.advertise_legacy_prompts_list_changed();
2208 }
2209 if self.router.has_completion_handler() {
2210 self.advertise_completions();
2211 }
2212
2213 self
2214 }
2215
2216 #[must_use]
2237 pub fn mount_tools(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2238 #[cfg(feature = "apps")]
2239 if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2240 log::error!(
2241 target: "fastmcp_rust::mount",
2242 "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2243 );
2244 return self;
2245 }
2246
2247 let source_router = server.into_router();
2248 let result =
2249 self.router
2250 .mount_tools_with_behavior(source_router, prefix, self.on_duplicate);
2251
2252 for warning in &result.warnings {
2254 log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2255 }
2256 for error in &result.errors {
2257 log::error!(target: "fastmcp_rust::mount", "{}", error);
2258 }
2259
2260 if result.tools > 0 {
2262 self.advertise_legacy_tools_list_changed();
2263 }
2264
2265 self
2266 }
2267
2268 #[must_use]
2290 pub fn mount_resources(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2291 #[cfg(feature = "apps")]
2292 if server.router.has_mcp_apps_bound_components() && !self.has_active_official_mcp_apps() {
2293 log::error!(
2294 target: "fastmcp_rust::mount",
2295 "Mount rejected because the child contains MCP Apps components but the destination has no active compatible MCP Apps extension"
2296 );
2297 return self;
2298 }
2299
2300 let source_router = server.into_router();
2301 let result =
2302 self.router
2303 .mount_resources_with_behavior(source_router, prefix, self.on_duplicate);
2304
2305 for warning in &result.warnings {
2307 log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2308 }
2309 for error in &result.errors {
2310 log::error!(target: "fastmcp_rust::mount", "{}", error);
2311 }
2312
2313 if result.resources > 0 || result.resource_templates > 0 {
2315 self.advertise_legacy_resource_subscriptions();
2316 }
2317 if self.router.has_completion_handler() {
2318 self.advertise_completions();
2319 }
2320
2321 self
2322 }
2323
2324 #[must_use]
2345 pub fn mount_prompts(mut self, server: crate::Server, prefix: Option<&str>) -> Self {
2346 let source_router = server.into_router();
2347 let result =
2348 self.router
2349 .mount_prompts_with_behavior(source_router, prefix, self.on_duplicate);
2350
2351 for warning in &result.warnings {
2353 log::warn!(target: "fastmcp_rust::mount", "{}", warning);
2354 }
2355 for error in &result.errors {
2356 log::error!(target: "fastmcp_rust::mount", "{}", error);
2357 }
2358
2359 if result.prompts > 0 {
2361 self.advertise_legacy_prompts_list_changed();
2362 }
2363 if self.router.has_completion_handler() {
2364 self.advertise_completions();
2365 }
2366
2367 self
2368 }
2369
2370 #[must_use]
2372 pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
2373 self.instructions = Some(instructions.into());
2374 self
2375 }
2376
2377 #[must_use]
2379 pub fn title(mut self, title: impl Into<String>) -> Self {
2380 self.title = Some(title.into());
2381 self
2382 }
2383
2384 #[must_use]
2386 pub fn description(mut self, description: impl Into<String>) -> Self {
2387 self.description = Some(description.into());
2388 self
2389 }
2390
2391 #[must_use]
2393 pub fn website_url(mut self, website_url: impl Into<String>) -> Self {
2394 self.website_url = Some(website_url.into());
2395 self
2396 }
2397
2398 #[must_use]
2400 pub fn icons(mut self, icons: Vec<fastmcp_protocol::common_types::RawIcon>) -> Self {
2401 self.icons = icons;
2402 self
2403 }
2404
2405 #[must_use]
2409 pub fn log_level(mut self, level: Level) -> Self {
2410 let filter = level.to_level_filter();
2411 self.logging.level = filter;
2412 self.console_config.log_level = filter;
2413 self
2414 }
2415
2416 #[must_use]
2418 pub fn log_level_filter(mut self, filter: LevelFilter) -> Self {
2419 self.logging.level = filter;
2420 self.console_config.log_level = filter;
2421 self
2422 }
2423
2424 #[must_use]
2428 pub fn log_timestamps(mut self, show: bool) -> Self {
2429 self.logging.timestamps = show;
2430 self.console_config.log_timestamps = show;
2431 self
2432 }
2433
2434 #[must_use]
2438 pub fn log_targets(mut self, show: bool) -> Self {
2439 self.logging.targets = show;
2440 self.console_config.log_targets = show;
2441 self
2442 }
2443
2444 #[must_use]
2446 pub fn log_file_line(mut self, show: bool) -> Self {
2447 self.logging.file_line = show;
2448 self.console_config.log_file_line = show;
2449 self
2450 }
2451
2452 #[must_use]
2454 pub fn logging(mut self, config: LoggingConfig) -> Self {
2455 self.console_config.log_level = config.level;
2456 self.console_config.log_timestamps = config.timestamps;
2457 self.console_config.log_targets = config.targets;
2458 self.console_config.log_file_line = config.file_line;
2459 self.logging = config;
2460 self
2461 }
2462
2463 #[must_use]
2486 pub fn with_console_config(mut self, config: ConsoleConfig) -> Self {
2487 self.logging = LoggingConfig::from(&config);
2488 self.console_config = config;
2489 self
2490 }
2491
2492 #[must_use]
2497 pub fn with_banner(mut self, style: BannerStyle) -> Self {
2498 self.console_config = self.console_config.with_banner(style);
2499 self
2500 }
2501
2502 #[must_use]
2504 pub fn without_banner(mut self) -> Self {
2505 self.console_config = self.console_config.without_banner();
2506 self
2507 }
2508
2509 #[must_use]
2516 pub fn with_traffic_logging(mut self, verbosity: TrafficVerbosity) -> Self {
2517 self.console_config = self.console_config.with_traffic(verbosity);
2518 self
2519 }
2520
2521 #[must_use]
2527 pub fn plain_mode(mut self) -> Self {
2528 self.console_config = self.console_config.plain_mode();
2529 self
2530 }
2531
2532 #[must_use]
2534 pub fn force_color(mut self) -> Self {
2535 self.console_config = self.console_config.force_color(true);
2536 self
2537 }
2538
2539 #[must_use]
2541 pub fn console_config(&self) -> &ConsoleConfig {
2542 &self.console_config
2543 }
2544
2545 #[must_use]
2569 pub fn on_startup<F, E>(mut self, hook: F) -> Self
2570 where
2571 F: FnOnce() -> Result<(), E> + Send + 'static,
2572 E: std::error::Error + Send + Sync + 'static,
2573 {
2574 self.lifespan.on_startup = Some(Box::new(move || {
2575 hook().map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
2576 }));
2577 self
2578 }
2579
2580 #[must_use]
2600 pub fn on_shutdown<F>(mut self, hook: F) -> Self
2601 where
2602 F: FnOnce() + Send + 'static,
2603 {
2604 self.lifespan.on_shutdown = Some(Box::new(hook));
2605 self
2606 }
2607
2608 #[cfg(all(test, feature = "tasks"))]
2611 #[must_use]
2612 pub(crate) fn with_task_manager(mut self, task_manager: SharedTaskManager) -> Self {
2613 self.task_manager = Some(task_manager);
2614 self.capabilities.tasks = None;
2617 self
2618 }
2619
2620 #[cfg(test)]
2622 fn request_timeout_secs(&self) -> u64 {
2623 self.request_timeout_secs
2624 }
2625
2626 #[must_use]
2639 pub fn build(mut self) -> Server {
2640 self.router
2642 .set_strict_input_validation(self.strict_input_validation);
2643 let console = fastmcp_console::console::FastMcpConsole::with_enabled(
2644 self.console_config.should_use_rich(),
2645 );
2646 let final_subscriptions = Arc::new(FinalSubscriptionRegistry::default());
2647 #[cfg(feature = "tasks")]
2648 self.install_default_in_memory_final_tasks();
2649 #[cfg(feature = "tasks")]
2650 let final_task_runtime = self.final_task_runtime.clone();
2651 #[cfg(all(feature = "proxy", feature = "tasks"))]
2652 let final_task_relay = self.final_task_relay.clone();
2653 #[cfg(feature = "tasks")]
2654 if let Some(task_runtime) = final_task_runtime.as_ref() {
2655 let subscriptions = Arc::clone(&final_subscriptions);
2656 task_runtime.add_notification_emitter(Arc::new(move |notification| {
2657 if subscriptions.publish_task(notification).is_err() {
2658 log::error!(
2659 target: "fastmcp_rust::server",
2660 "Failed to publish a typed final Task notification"
2661 );
2662 }
2663 }));
2664 }
2665 #[cfg(feature = "tasks")]
2666 self.router
2667 .set_final_task_runtime(final_task_runtime.clone());
2668 #[cfg(all(feature = "proxy", feature = "tasks"))]
2669 self.router.set_final_task_relay(final_task_relay.clone());
2670 let extension_runtime = match self.extension_runtime {
2671 Some(mut runtime) => {
2672 runtime
2673 .freeze()
2674 .expect("validated server extension descriptors must freeze");
2675 Some(Arc::new(runtime))
2676 }
2677 #[cfg(all(feature = "proxy", feature = "tasks"))]
2678 None => match (final_task_runtime.as_ref(), final_task_relay.as_ref()) {
2679 (Some(task_runtime), None) => {
2680 let mut runtime = ServerExtensionRuntime::with_final_tasks(task_runtime)
2681 .expect("final Tasks must install into an empty extension registry");
2682 runtime
2683 .freeze()
2684 .expect("final Tasks extension descriptors must freeze");
2685 Some(Arc::new(runtime))
2686 }
2687 (None, Some(task_relay)) => {
2688 let mut runtime =
2689 ServerExtensionRuntime::with_proxy_final_tasks(Arc::clone(task_relay))
2690 .expect(
2691 "proxy final Tasks must install into an empty extension registry",
2692 );
2693 runtime
2694 .freeze()
2695 .expect("proxy final Tasks extension descriptors must freeze");
2696 Some(Arc::new(runtime))
2697 }
2698 (None, None) => None,
2699 (Some(_), Some(_)) => unreachable!("builder rejects mixed final Tasks owners"),
2700 },
2701 #[cfg(all(feature = "tasks", not(feature = "proxy")))]
2702 None => match final_task_runtime.as_ref() {
2703 Some(task_runtime) => {
2704 let mut runtime = ServerExtensionRuntime::with_final_tasks(task_runtime)
2705 .expect("final Tasks must install into an empty extension registry");
2706 runtime
2707 .freeze()
2708 .expect("final Tasks extension descriptors must freeze");
2709 Some(Arc::new(runtime))
2710 }
2711 None => None,
2712 },
2713 #[cfg(not(feature = "tasks"))]
2714 None => None,
2715 };
2716
2717 Server {
2718 info: self.info,
2719 title: self.title,
2720 description: self.description,
2721 website_url: self.website_url,
2722 icons: self.icons,
2723 capabilities: self.capabilities,
2724 router: Arc::new(self.router),
2725 instructions: self.instructions,
2726 request_timeout_secs: self.request_timeout_secs,
2727 stats: if self.stats_enabled {
2728 Some(ServerStats::new())
2729 } else {
2730 None
2731 },
2732 mask_error_details: self.mask_error_details,
2733 logging: self.logging,
2734 console_config: self.console_config,
2735 console,
2736 lifespan: Mutex::new(Some(self.lifespan)),
2737 auth_provider: self.auth_provider,
2738 middleware: Arc::new(self.middleware),
2739 active_requests: Arc::new(Mutex::new(HashMap::new())),
2740 #[cfg(all(test, feature = "tasks"))]
2741 task_manager: self.task_manager,
2742 max_bidirectional_requests_per_connection: self
2743 .max_bidirectional_requests_per_connection,
2744 protocol_policy: self.protocol_policy,
2745 http_config: self.http_config,
2746 oauth_http_routes: self.oauth_http_routes,
2747 extension_runtime,
2748 #[cfg(feature = "tasks")]
2749 final_task_runtime: self.final_task_runtime,
2750 #[cfg(all(feature = "proxy", feature = "tasks"))]
2751 final_task_relay,
2752 final_subscriptions,
2753 }
2754 }
2755
2756 pub fn try_build(self) -> Result<Server, ServerLaunchPolicyError> {
2761 Ok(self.build())
2762 }
2763}
2764
2765#[cfg(test)]
2766mod tests {
2767 use super::*;
2768 #[cfg(all(feature = "proxy", feature = "tasks"))]
2769 use crate::proxy::{ProxyFinalTaskListener, ProxyFinalTaskListenerEvent};
2770 #[cfg(feature = "proxy")]
2771 use asupersync::Cx;
2772 #[cfg(all(feature = "proxy", feature = "tasks"))]
2773 use fastmcp_client::FinalToolCallOutcome;
2774 #[cfg(all(feature = "proxy", feature = "tasks"))]
2775 use fastmcp_core::block_on;
2776 use fastmcp_core::{McpContext, McpResult};
2777 #[cfg(feature = "apps")]
2778 use fastmcp_protocol::FinalTool;
2779 #[cfg(feature = "apps")]
2780 use fastmcp_protocol::common_types::AbsoluteUri;
2781 #[cfg(feature = "proxy")]
2782 use fastmcp_protocol::common_types::{ContentBlock, Implementation};
2783 #[cfg(feature = "apps")]
2784 use fastmcp_protocol::extensions::ExtensionNegotiationError;
2785 use fastmcp_protocol::protocol_policy::ProtocolPolicy;
2786 #[cfg(feature = "proxy")]
2787 use fastmcp_protocol::protocol_policy::{ProtocolEra, StdioOpeningFrame};
2788 #[cfg(feature = "proxy")]
2789 use fastmcp_protocol::{
2790 CallToolResult, CompleteResult, CoreResult, FinalCallToolResult, FinalCoreResult,
2791 JsonRpcRequest, LegacyContent, LegacyCoreResult, ResultMeta,
2792 };
2793 use fastmcp_protocol::{Content, Prompt, Resource, ResourceContent, Tool};
2794 #[cfg(all(feature = "proxy", feature = "tasks"))]
2795 use fastmcp_protocol::{
2796 CoreResultDiscriminatorPolicy, CreateTaskResult, DecodedResult, EmptyTaskResult,
2797 ResultPeerEra, SubscriptionFilter, decode_peer_result,
2798 };
2799
2800 struct TestTool;
2803 impl crate::ToolHandler for TestTool {
2804 fn definition(&self) -> Tool {
2805 Tool {
2806 name: "test_tool".to_string(),
2807 description: Some("a test tool".to_string()),
2808 input_schema: serde_json::json!({"type": "object"}),
2809 output_schema: None,
2810 icon: None,
2811 version: None,
2812 tags: vec![],
2813 annotations: None,
2814 }
2815 }
2816 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2817 Ok(vec![Content::text("ok")])
2818 }
2819 }
2820
2821 struct ExactLegacyOnlyTool;
2822 impl crate::ToolHandler for ExactLegacyOnlyTool {
2823 fn definition(&self) -> Tool {
2824 Tool {
2825 name: "exact_legacy_only".to_owned(),
2826 description: Some("exact 2024-only test tool".to_owned()),
2827 input_schema: serde_json::json!({"type": "object"}),
2828 output_schema: Some(serde_json::json!(false)),
2829 icon: None,
2830 version: None,
2831 tags: Vec::new(),
2832 annotations: None,
2833 }
2834 }
2835
2836 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2837 Ok(vec![Content::text("legacy")])
2838 }
2839 }
2840
2841 #[cfg(feature = "apps")]
2842 struct MountedAppsTool;
2843
2844 #[cfg(feature = "apps")]
2845 impl crate::ToolHandler for MountedAppsTool {
2846 fn definition(&self) -> Tool {
2847 Tool {
2848 name: "mounted_apps_tool".to_owned(),
2849 description: Some("final-only Apps mount fixture".to_owned()),
2850 input_schema: serde_json::json!({"type": "object"}),
2851 output_schema: None,
2852 icon: None,
2853 version: None,
2854 tags: Vec::new(),
2855 annotations: None,
2856 }
2857 }
2858
2859 fn final_definition(&self) -> Option<FinalTool> {
2860 let metadata = fastmcp_protocol::McpAppsToolMetadata::try_new(
2861 Some(
2862 AbsoluteUri::parse("ui://mount/dashboard")
2863 .expect("fixed Apps mount URI is valid"),
2864 ),
2865 None,
2866 )
2867 .expect("fixed Apps mount metadata is valid")
2868 .to_open_metadata()
2869 .expect("fixed Apps mount metadata serializes");
2870 Some(FinalTool {
2871 name: "mounted_apps_tool".to_owned(),
2872 title: None,
2873 description: Some("final-only Apps mount fixture".to_owned()),
2874 input_schema: serde_json::json!({"type": "object"}),
2875 output_schema: None,
2876 annotations: None,
2877 icons: None,
2878 meta: Some(metadata),
2879 })
2880 }
2881
2882 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
2883 Ok(vec![Content::text("Apps mount fixture")])
2884 }
2885 }
2886
2887 #[cfg(feature = "apps")]
2888 fn apps_mount_child() -> crate::Server {
2889 let resource = McpAppsUiResource::try_new(
2890 AbsoluteUri::parse("ui://mount/dashboard").expect("fixed Apps mount URI is valid"),
2891 "mounted-apps-dashboard",
2892 "<main>Apps mount fixture</main>",
2893 )
2894 .expect("fixed Apps mount resource is valid");
2895 ServerBuilder::new("apps-child", "1.0")
2896 .mcp_apps()
2897 .expect("child Apps extension installs")
2898 .mcp_apps_ui_resource(resource)
2899 .expect("child Apps resource registers")
2900 .mcp_apps_tool(MountedAppsTool)
2901 .expect("child Apps tool registers")
2902 .build()
2903 }
2904
2905 #[cfg(feature = "apps")]
2906 fn apps_resource_bound_tool_mount_child() -> crate::Server {
2907 let resource = McpAppsUiResource::try_new(
2908 AbsoluteUri::parse("ui://mount/dashboard").expect("fixed Apps mount URI is valid"),
2909 "mounted-apps-dashboard",
2910 "<main>Apps mount fixture</main>",
2911 )
2912 .expect("fixed Apps mount resource is valid");
2913 ServerBuilder::new("apps-resource-child", "1.0")
2914 .mcp_apps()
2915 .expect("child Apps extension installs")
2916 .mcp_apps_ui_resource(resource)
2917 .expect("child Apps resource registers")
2918 .tool(TestTool)
2919 .build()
2920 }
2921
2922 struct TestResource;
2923 impl crate::ResourceHandler for TestResource {
2924 fn definition(&self) -> Resource {
2925 Resource {
2926 uri: "file:///test".to_string(),
2927 name: "test_res".to_string(),
2928 description: None,
2929 mime_type: None,
2930 icon: None,
2931 version: None,
2932 tags: vec![],
2933 }
2934 }
2935 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
2936 Ok(vec![ResourceContent {
2937 uri: "file:///test".to_string(),
2938 mime_type: None,
2939 text: Some("content".to_string()),
2940 blob: None,
2941 }])
2942 }
2943 }
2944
2945 struct TestPrompt;
2946 impl crate::PromptHandler for TestPrompt {
2947 fn definition(&self) -> Prompt {
2948 Prompt {
2949 name: "test_prompt".to_string(),
2950 description: None,
2951 arguments: vec![],
2952 icon: None,
2953 version: None,
2954 tags: vec![],
2955 }
2956 }
2957 fn get(
2958 &self,
2959 _ctx: &McpContext,
2960 _args: std::collections::HashMap<String, String>,
2961 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
2962 Ok(vec![])
2963 }
2964 }
2965
2966 struct TestCompletion;
2967
2968 impl crate::handler::CompletionHandler for TestCompletion {
2969 fn complete_legacy(
2970 &self,
2971 _ctx: &McpContext,
2972 _params: fastmcp_protocol::LegacyCompletionParams,
2973 ) -> McpResult<fastmcp_protocol::CompletionValues> {
2974 Ok(fastmcp_protocol::CompletionValues {
2975 values: vec!["staging".to_string()],
2976 total: Some(1),
2977 has_more: Some(false),
2978 })
2979 }
2980
2981 fn complete_final(
2982 &self,
2983 _ctx: &McpContext,
2984 _params: fastmcp_protocol::FinalCompletionParams,
2985 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
2986 Ok(fastmcp_protocol::FinalCompletionValues {
2987 values: vec!["staging".to_string()],
2988 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
2989 has_more: Some(false),
2990 })
2991 }
2992 }
2993
2994 struct CountingCompletion(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2995
2996 impl crate::handler::CompletionHandler for CountingCompletion {
2997 fn complete_legacy(
2998 &self,
2999 _ctx: &McpContext,
3000 _params: fastmcp_protocol::LegacyCompletionParams,
3001 ) -> McpResult<fastmcp_protocol::CompletionValues> {
3002 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3003 Ok(fastmcp_protocol::CompletionValues {
3004 values: vec!["staging".to_string()],
3005 total: Some(1),
3006 has_more: Some(false),
3007 })
3008 }
3009
3010 fn complete_final(
3011 &self,
3012 _ctx: &McpContext,
3013 _params: fastmcp_protocol::FinalCompletionParams,
3014 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
3015 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3016 Ok(fastmcp_protocol::FinalCompletionValues {
3017 values: vec!["staging".to_string()],
3018 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
3019 has_more: Some(false),
3020 })
3021 }
3022 }
3023
3024 struct MarkedTool(&'static str);
3025
3026 impl crate::ToolHandler for MarkedTool {
3027 fn definition(&self) -> Tool {
3028 Tool {
3029 name: "duplicate_tool".to_string(),
3030 description: Some(self.0.to_string()),
3031 input_schema: serde_json::json!({"type": "object"}),
3032 output_schema: None,
3033 icon: None,
3034 version: None,
3035 tags: vec![self.0.to_string()],
3036 annotations: None,
3037 }
3038 }
3039
3040 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
3041 Ok(vec![Content::text(self.0)])
3042 }
3043 }
3044
3045 struct MarkedResource(&'static str);
3046
3047 impl crate::ResourceHandler for MarkedResource {
3048 fn definition(&self) -> Resource {
3049 Resource {
3050 uri: "duplicate://resource".to_string(),
3051 name: self.0.to_string(),
3052 description: Some(self.0.to_string()),
3053 mime_type: None,
3054 icon: None,
3055 version: None,
3056 tags: vec![self.0.to_string()],
3057 }
3058 }
3059
3060 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
3061 Ok(vec![])
3062 }
3063 }
3064
3065 struct MarkedPrompt(&'static str);
3066
3067 impl crate::PromptHandler for MarkedPrompt {
3068 fn definition(&self) -> Prompt {
3069 Prompt {
3070 name: "duplicate_prompt".to_string(),
3071 description: Some(self.0.to_string()),
3072 arguments: vec![],
3073 icon: None,
3074 version: None,
3075 tags: vec![self.0.to_string()],
3076 }
3077 }
3078
3079 fn get(
3080 &self,
3081 _ctx: &McpContext,
3082 _args: std::collections::HashMap<String, String>,
3083 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
3084 Ok(vec![])
3085 }
3086 }
3087
3088 fn marked_resource_template(marker: &str) -> ResourceTemplate {
3089 ResourceTemplate {
3090 uri_template: "mcp://duplicate-item/{item}".to_string(),
3091 name: marker.to_string(),
3092 description: Some(marker.to_string()),
3093 mime_type: None,
3094 icon: None,
3095 version: None,
3096 tags: vec![marker.to_string()],
3097 }
3098 }
3099
3100 fn marked_builder(marker: &'static str) -> ServerBuilder {
3101 ServerBuilder::new("marked", "1.0")
3102 .tool(MarkedTool(marker))
3103 .resource(MarkedResource(marker))
3104 .resource_template(marked_resource_template(marker))
3105 .prompt(MarkedPrompt(marker))
3106 }
3107
3108 fn assert_marked_server(server: &crate::Server, marker: &str) {
3109 assert_eq!(server.tools().len(), 1);
3110 assert_eq!(server.resources().len(), 1);
3111 assert_eq!(server.resource_templates().len(), 1);
3112 assert_eq!(server.prompts().len(), 1);
3113 assert_eq!(server.tools()[0].tags, vec![marker.to_string()]);
3114 assert_eq!(server.resources()[0].tags, vec![marker.to_string()]);
3115 assert_eq!(
3116 server.resource_templates()[0].tags,
3117 vec![marker.to_string()]
3118 );
3119 assert_eq!(server.prompts()[0].tags, vec![marker.to_string()]);
3120 }
3121
3122 #[cfg(feature = "proxy")]
3123 struct DuplicatePolicyProxyBackend;
3124
3125 #[cfg(feature = "proxy")]
3126 impl crate::proxy::ProxyBackend for DuplicatePolicyProxyBackend {
3127 fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
3128 Ok(duplicate_policy_proxy_catalog().tools)
3129 }
3130
3131 fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
3132 Ok(duplicate_policy_proxy_catalog().resources)
3133 }
3134
3135 fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
3136 Ok(duplicate_policy_proxy_catalog().resource_templates)
3137 }
3138
3139 fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
3140 Ok(duplicate_policy_proxy_catalog().prompts)
3141 }
3142
3143 fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
3144 Ok(vec![])
3145 }
3146
3147 fn call_tool_with_progress(
3148 &mut self,
3149 _: &str,
3150 _: serde_json::Value,
3151 _: crate::proxy::ProgressCallback<'_>,
3152 ) -> McpResult<Vec<Content>> {
3153 Ok(vec![])
3154 }
3155
3156 fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
3157 Ok(vec![])
3158 }
3159
3160 fn get_prompt(
3161 &mut self,
3162 _: &str,
3163 _: std::collections::HashMap<String, String>,
3164 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
3165 Ok(vec![])
3166 }
3167 }
3168
3169 #[cfg(all(feature = "proxy", feature = "tasks"))]
3170 struct OrdinaryProxyTasksListener {
3171 accepted: Option<SubscriptionFilter>,
3172 }
3173
3174 #[cfg(all(feature = "proxy", feature = "tasks"))]
3175 impl ProxyFinalTaskListener for OrdinaryProxyTasksListener {
3176 fn next(
3177 &mut self,
3178 _cx: &Cx,
3179 _request_cancellation: &fastmcp_core::McpRequestCancellation,
3180 ) -> McpResult<ProxyFinalTaskListenerEvent> {
3181 match self.accepted.take() {
3182 Some(accepted) => Ok(ProxyFinalTaskListenerEvent::Acknowledged(accepted)),
3183 None => Ok(ProxyFinalTaskListenerEvent::Terminal),
3184 }
3185 }
3186 }
3187
3188 #[cfg(all(feature = "proxy", feature = "tasks"))]
3192 struct OrdinaryProxyTasksBackend {
3193 calls: Arc<Mutex<Vec<String>>>,
3194 updates: Arc<Mutex<Vec<serde_json::Value>>>,
3195 task: CreateTaskResult,
3196 }
3197
3198 #[cfg(all(feature = "proxy", feature = "tasks"))]
3199 impl crate::proxy::ProxyBackend for OrdinaryProxyTasksBackend {
3200 fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
3201 Ok(Vec::new())
3202 }
3203
3204 fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
3205 Ok(Vec::new())
3206 }
3207
3208 fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
3209 Ok(Vec::new())
3210 }
3211
3212 fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
3213 Ok(Vec::new())
3214 }
3215
3216 fn call_tool(
3217 &mut self,
3218 _name: &str,
3219 _arguments: serde_json::Value,
3220 ) -> McpResult<Vec<Content>> {
3221 Err(fastmcp_core::McpError::internal_error(
3222 "ordinary Tasks proxy test must retain the final result algebra",
3223 ))
3224 }
3225
3226 fn call_tool_with_progress(
3227 &mut self,
3228 name: &str,
3229 arguments: serde_json::Value,
3230 _on_progress: crate::proxy::ProgressCallback<'_>,
3231 ) -> McpResult<Vec<Content>> {
3232 self.call_tool(name, arguments)
3233 }
3234
3235 fn read_resource(&mut self, _uri: &str) -> McpResult<Vec<ResourceContent>> {
3236 Err(fastmcp_core::McpError::internal_error("not used"))
3237 }
3238
3239 fn get_prompt(
3240 &mut self,
3241 _name: &str,
3242 _arguments: std::collections::HashMap<String, String>,
3243 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
3244 Err(fastmcp_core::McpError::internal_error("not used"))
3245 }
3246
3247 fn supports_final_tasks_relay(&mut self) -> McpResult<bool> {
3248 Ok(true)
3249 }
3250
3251 fn call_tool_final_outcome(
3252 &mut self,
3253 name: &str,
3254 arguments: serde_json::Value,
3255 ) -> McpResult<FinalToolCallOutcome> {
3256 self.calls
3257 .lock()
3258 .expect("ordinary proxy task call log is not poisoned")
3259 .push(format!("tools/call:{name}"));
3260 if arguments.get("outcome") == Some(&serde_json::json!("task")) {
3261 return Ok(FinalToolCallOutcome::Task(self.task.clone()));
3262 }
3263 let (decoded, diagnostic) = decode_peer_result(
3264 r#"{"resultType":"input_required","requestState":"upstream-forged-state"}"#,
3265 ResultPeerEra::Modern,
3266 &CoreResultDiscriminatorPolicy,
3267 )
3268 .map_err(|error| fastmcp_core::McpError::invalid_request(error.to_string()))?;
3269 assert!(diagnostic.is_none(), "the fixed final fixture is explicit");
3270 let DecodedResult::InputRequired(result) = decoded else {
3271 return Err(fastmcp_core::McpError::internal_error(
3272 "ordinary proxy test fixture must decode as input_required",
3273 ));
3274 };
3275 Ok(FinalToolCallOutcome::InputRequired(result))
3276 }
3277
3278 fn update_final_task(
3279 &mut self,
3280 task: &fastmcp_protocol::Task,
3281 input_responses: fastmcp_protocol::TaskInputResponses,
3282 ) -> McpResult<fastmcp_protocol::UpdateTaskResult> {
3283 self.calls
3284 .lock()
3285 .expect("ordinary proxy task call log is not poisoned")
3286 .push("tasks/update".to_owned());
3287 assert_eq!(
3288 task.base().task_id.as_str(),
3289 self.task.task.base().task_id.as_str(),
3290 "the relay supplies the retained upstream task rather than admitting an arbitrary id"
3291 );
3292 self.updates
3293 .lock()
3294 .expect("ordinary proxy task update receipt is not poisoned")
3295 .push(
3296 serde_json::to_value(&input_responses)
3297 .expect("the exact public Tasks input response map serializes"),
3298 );
3299 Ok(EmptyTaskResult::default())
3300 }
3301
3302 fn open_final_task_listener(
3303 &mut self,
3304 notifications: SubscriptionFilter,
3305 ) -> McpResult<Box<dyn ProxyFinalTaskListener>> {
3306 self.calls
3307 .lock()
3308 .expect("ordinary proxy task call log is not poisoned")
3309 .push("subscriptions/listen".to_owned());
3310 Ok(Box::new(OrdinaryProxyTasksListener {
3311 accepted: Some(notifications),
3312 }))
3313 }
3314 }
3315
3316 #[cfg(feature = "proxy")]
3317 fn duplicate_policy_proxy_catalog() -> ProxyCatalog {
3318 ProxyCatalog {
3319 tool_catalog_era: Some(ProtocolEra::Legacy2024),
3320 tools: vec![Tool {
3321 name: "test_tool".to_string(),
3322 description: Some("proxied tool".to_string()),
3323 input_schema: serde_json::json!({"type": "object"}),
3326 output_schema: None,
3327 icon: None,
3328 version: None,
3329 tags: vec![],
3330 annotations: None,
3331 }],
3332 resources: vec![Resource {
3333 uri: "file:///test".to_string(),
3334 name: "proxied resource".to_string(),
3335 description: Some("proxied resource".to_string()),
3336 mime_type: None,
3337 icon: None,
3338 version: None,
3339 tags: vec![],
3340 }],
3341 prompts: vec![Prompt {
3342 name: "test_prompt".to_string(),
3343 description: Some("proxied prompt".to_string()),
3344 arguments: vec![],
3345 icon: None,
3346 version: None,
3347 tags: vec![],
3348 }],
3349 ..ProxyCatalog::default()
3350 }
3351 }
3352
3353 #[test]
3356 fn builder_new_sets_info() {
3357 let builder = ServerBuilder::new("my-server", "2.0.0");
3358 let server = builder.build();
3359 assert_eq!(server.info().name, "my-server");
3360 assert_eq!(server.info().version, "2.0.0");
3361 }
3362
3363 #[test]
3364 fn builder_default_has_logging_capability() {
3365 let builder = ServerBuilder::new("srv", "1.0");
3366 let server = builder.build();
3367 assert!(server.capabilities().logging.is_some());
3368 }
3369
3370 #[test]
3371 fn builder_default_has_no_tool_resource_prompt_capabilities() {
3372 let builder = ServerBuilder::new("srv", "1.0");
3373 let server = builder.build();
3374 assert!(server.capabilities().tools.is_none());
3375 assert!(server.capabilities().resources.is_none());
3376 assert!(server.capabilities().prompts.is_none());
3377 }
3378
3379 #[cfg(feature = "apps")]
3380 #[test]
3381 fn builder_manual_apps_discovery_requires_the_exact_empty_marker_before_build() {
3382 let mut accepted_descriptors = fastmcp_protocol::ExtensionDescriptorRegistry::new();
3383 let accepted_id =
3384 fastmcp_protocol::register_official_mcp_apps_extension(&mut accepted_descriptors)
3385 .expect("the official Apps descriptor registers");
3386 let accepted = ServerBuilder::new("apps-marker", "1.0").extension_registry(
3387 crate::ExtensionHandlerRegistry::new(accepted_descriptors),
3388 ServerExtensionDiscovery {
3389 extensions: std::collections::BTreeMap::from([(
3390 accepted_id,
3391 fastmcp_protocol::official_mcp_apps_empty_server_settings(),
3392 )]),
3393 },
3394 |_descriptor: &fastmcp_protocol::ExtensionDescriptor,
3395 _client: &fastmcp_protocol::ExtensionSettings,
3396 _server: &fastmcp_protocol::ExtensionSettings|
3397 -> Result<fastmcp_protocol::ExtensionSettings, ExtensionNegotiationError> {
3398 Ok(fastmcp_protocol::official_mcp_apps_empty_server_settings())
3399 },
3400 );
3401 assert!(
3402 accepted.is_ok(),
3403 "the exact empty official Apps marker is accepted during builder configuration"
3404 );
3405
3406 let mut rejected_descriptors = fastmcp_protocol::ExtensionDescriptorRegistry::new();
3407 let rejected_id =
3408 fastmcp_protocol::register_official_mcp_apps_extension(&mut rejected_descriptors)
3409 .expect("the official Apps descriptor registers");
3410 let rejected = ServerBuilder::new("apps-marker", "1.0").extension_registry(
3411 crate::ExtensionHandlerRegistry::new(rejected_descriptors),
3412 ServerExtensionDiscovery {
3413 extensions: std::collections::BTreeMap::from([(
3414 rejected_id,
3415 fastmcp_protocol::ExtensionSettings::new(serde_json::json!({
3416 "unexpected": true,
3417 }))
3418 .expect("the one-field alternate is generic extension metadata"),
3419 )]),
3420 },
3421 |_descriptor: &fastmcp_protocol::ExtensionDescriptor,
3422 _client: &fastmcp_protocol::ExtensionSettings,
3423 _server: &fastmcp_protocol::ExtensionSettings|
3424 -> Result<fastmcp_protocol::ExtensionSettings, ExtensionNegotiationError> {
3425 Ok(fastmcp_protocol::official_mcp_apps_empty_server_settings())
3426 },
3427 );
3428 assert!(
3429 matches!(
3430 rejected,
3431 Err(crate::ServerExtensionConfigurationError::Registry(
3432 fastmcp_protocol::ExtensionRegistryError::OfficialMcpAppsServerSettingsNotEmpty
3433 ))
3434 ),
3435 "adding one discovery setting is rejected by extension_registry before build"
3436 );
3437 }
3438
3439 #[test]
3440 fn builder_completion_handler_activates_exact_discovery_capability() {
3441 let server = ServerBuilder::new("srv", "1.0")
3442 .completion_handler(TestCompletion)
3443 .build();
3444 let discovery = server
3445 .server_discovery()
3446 .expect("installed completion handler produces discovery");
3447 let wire = serde_json::to_value(discovery).expect("discovery serializes");
3448
3449 assert_eq!(wire["capabilities"]["completions"], serde_json::json!({}));
3450 }
3451
3452 #[test]
3453 fn builder_provider_specific_completion_requires_an_admitted_final_target() {
3454 let unmatched = ServerBuilder::new("srv", "1.0")
3455 .prompt_completion_handler("duplicate_prompt", TestCompletion)
3456 .build();
3457 let unmatched_discovery = unmatched
3458 .server_discovery()
3459 .expect("the unmatched provider-only server still discovers");
3460 let unmatched_wire =
3461 serde_json::to_value(unmatched_discovery).expect("discovery serializes");
3462 assert!(
3463 unmatched_wire["capabilities"].get("completions").is_none(),
3464 "an unbound provider-only registration must not advertise completion"
3465 );
3466
3467 let matched = ServerBuilder::new("srv", "1.0")
3468 .prompt(MarkedPrompt("provider-target"))
3469 .prompt_completion_handler("duplicate_prompt", TestCompletion)
3470 .build();
3471 let matched_discovery = matched
3472 .server_discovery()
3473 .expect("an admitted final prompt activates its provider route");
3474 let matched_wire = serde_json::to_value(matched_discovery).expect("discovery serializes");
3475
3476 assert_eq!(
3477 matched_wire["capabilities"]["completions"],
3478 serde_json::json!({}),
3479 "adding only the final prompt target makes the provider discoverable"
3480 );
3481 }
3482
3483 #[test]
3484 fn builder_without_completion_handler_omits_discovery_capability() {
3485 let server = ServerBuilder::new("srv", "1.0").build();
3486 let discovery = server
3487 .server_discovery()
3488 .expect("server without completion handler still discovers");
3489 let wire = serde_json::to_value(discovery).expect("discovery serializes");
3490
3491 assert!(
3492 wire["capabilities"].get("completions").is_none(),
3493 "absence of the handler must not advertise completion"
3494 );
3495 }
3496
3497 #[test]
3498 fn builder_completion_handler_rejects_final_metadata_before_handler_state_changes() {
3499 let invocations = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3500 let server = ServerBuilder::new("srv", "1.0")
3501 .completion_handler(CountingCompletion(std::sync::Arc::clone(&invocations)))
3502 .build();
3503 let request_ctx = McpContext::new(asupersync::Cx::for_testing(), 93);
3504 let baseline = fastmcp_protocol::JsonRpcRequest::new(
3505 "completion/complete",
3506 Some(serde_json::json!({
3507 "ref": {"type": "ref/prompt", "name": "deploy"},
3508 "argument": {"name": "environment", "value": "sta"},
3509 })),
3510 93_i64,
3511 );
3512 let mut planted = baseline.clone();
3513 planted
3514 .params
3515 .as_mut()
3516 .and_then(serde_json::Value::as_object_mut)
3517 .expect("completion parameters are an object")
3518 .insert(
3519 "_meta".to_string(),
3520 serde_json::json!({
3521 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
3522 }),
3523 );
3524
3525 let baseline_result = server
3526 .router
3527 .dispatch_legacy_completion(&request_ctx, &baseline)
3528 .expect("baseline legacy completion reaches the builder-installed handler");
3529 assert_eq!(
3530 invocations.load(std::sync::atomic::Ordering::SeqCst),
3531 1,
3532 "the accepted request invokes the installed handler once"
3533 );
3534 let planted_before = serde_json::to_vec(&planted).expect("planted request serializes");
3535
3536 let error = server
3537 .router
3538 .dispatch_legacy_completion(&request_ctx, &planted)
3539 .expect_err("the sole final metadata field is refused in the exact legacy route");
3540 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidParams);
3541 assert_eq!(
3542 invocations.load(std::sync::atomic::Ordering::SeqCst),
3543 1,
3544 "cross-era rejection occurs before handler-owned state can change"
3545 );
3546 assert_eq!(
3547 serde_json::to_vec(&planted).expect("rejected request serializes"),
3548 planted_before,
3549 "the rejected request remains caller-owned and unchanged"
3550 );
3551 assert_eq!(
3552 server
3553 .router
3554 .dispatch_legacy_completion(&request_ctx, &baseline)
3555 .expect("baseline remains dispatchable after the rejection"),
3556 baseline_result,
3557 "the rejected one-field variant cannot alter the accepted completion result"
3558 );
3559 assert_eq!(
3560 invocations.load(std::sync::atomic::Ordering::SeqCst),
3561 2,
3562 "only accepted requests mutate handler-owned state"
3563 );
3564 }
3565
3566 #[test]
3567 fn builder_default_stats_enabled() {
3568 let server = ServerBuilder::new("srv", "1.0").build();
3569 assert!(server.stats().is_some());
3570 }
3571
3572 #[test]
3573 fn builder_default_request_timeout() {
3574 let builder = ServerBuilder::new("srv", "1.0");
3575 assert_eq!(builder.request_timeout_secs(), DEFAULT_REQUEST_TIMEOUT_SECS);
3576 }
3577
3578 #[test]
3579 fn builder_bidirectional_limit_has_exact_validated_boundaries() {
3580 let default = ServerBuilder::new("srv", "1.0");
3581 assert_eq!(
3582 default.max_bidirectional_requests_per_connection,
3583 crate::bidirectional::DEFAULT_MAX_IN_FLIGHT_REQUESTS
3584 );
3585
3586 for valid in [1, crate::bidirectional::HARD_MAX_IN_FLIGHT_REQUESTS] {
3587 let server = ServerBuilder::new("srv", "1.0")
3588 .max_bidirectional_requests_per_connection(valid)
3589 .expect("boundary must be valid")
3590 .build();
3591 assert_eq!(server.max_bidirectional_requests_per_connection, valid);
3592 assert_eq!(
3593 server.new_pending_requests_for_connection().max_in_flight(),
3594 valid
3595 );
3596 }
3597
3598 for invalid in [0, crate::bidirectional::HARD_MAX_IN_FLIGHT_REQUESTS + 1] {
3599 let Err(error) =
3600 ServerBuilder::new("srv", "1.0").max_bidirectional_requests_per_connection(invalid)
3601 else {
3602 panic!("out-of-range limit must fail closed");
3603 };
3604 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidParams);
3605 }
3606 }
3607
3608 #[test]
3609 fn builder_default_error_masking_disabled() {
3610 let builder = ServerBuilder::new("srv", "1.0");
3611 assert!(!builder.is_error_masking_enabled());
3612 }
3613
3614 #[test]
3615 fn builder_default_strict_validation_disabled() {
3616 let builder = ServerBuilder::new("srv", "1.0");
3617 assert!(!builder.is_strict_input_validation_enabled());
3618 }
3619
3620 #[test]
3621 fn explicit_protocol_policy_applies_without_reserved_launch_setting() {
3622 let server = ServerBuilder::from_launch_protocol_policy("srv", "1.0", Ok(None))
3623 .expect("unset launch policy must construct a builder")
3624 .protocol_policy(ProtocolPolicy::ModernOnly)
3625 .expect("ModernOnly must be available to this test build")
3626 .try_build()
3627 .expect("unset launch policy must permit a server");
3628
3629 assert_eq!(server.protocol_policy(), ProtocolPolicy::ModernOnly);
3630 }
3631
3632 #[test]
3633 fn launch_policy_unset_defaults_to_auto() {
3634 assert_eq!(protocol_policy_from_server_launch_value(None), Ok(None));
3635 }
3636
3637 #[cfg(not(feature = "legacy-2024-11-05"))]
3638 #[test]
3639 fn no_legacy_public_builder_defaults_to_modern_only() {
3640 let server = ServerBuilder::try_new("srv", "1.0")
3641 .expect("no-legacy construction must succeed")
3642 .try_build()
3643 .expect("the default no-legacy builder must build");
3644
3645 assert_eq!(server.protocol_policy(), ProtocolPolicy::ModernOnly);
3646 }
3647
3648 #[cfg(not(feature = "legacy-2024-11-05"))]
3649 #[test]
3650 fn no_legacy_source_exposes_a_modern_endpoint_builder_and_gates_the_dual_era_one() {
3651 let source = include_str!("builder.rs");
3652 assert!(source.contains(
3653 "/// Builds a live modern Streamable HTTP endpoint.\n #[cfg(not(any(feature = \"legacy-2024-11-05\", test)))]\n pub fn build_http_endpoint"
3654 ));
3655 assert!(
3656 source.contains(
3657 "/// Builds a live dual-era HTTP endpoint with an exact legacy SSE origin."
3658 )
3659 );
3660 assert!(source.contains("crate::ServerHttpEndpointError"));
3661 assert!(!source.contains("fastmcp_transport::http::DualEraHttpEndpointError"));
3662 }
3663
3664 #[cfg(not(feature = "legacy-2024-11-05"))]
3665 #[test]
3666 fn no_legacy_public_builder_rejects_legacy_policies_without_mutation() {
3667 for policy in [ProtocolPolicy::Auto, ProtocolPolicy::LegacyOnly] {
3668 let mut builder =
3669 ServerBuilder::try_new("srv", "1.0").expect("no-legacy construction must succeed");
3670
3671 assert_eq!(
3672 builder.try_set_protocol_policy(policy),
3673 Err(ServerLaunchPolicyError::FeatureUnavailable),
3674 "{policy:?} must reject before changing a no-legacy builder"
3675 );
3676
3677 let server = builder
3678 .try_build()
3679 .expect("a rejected policy must leave the builder buildable");
3680 assert_eq!(
3681 server.protocol_policy(),
3682 ProtocolPolicy::ModernOnly,
3683 "{policy:?} differs from the default only by requiring unavailable legacy behavior"
3684 );
3685 }
3686 }
3687
3688 #[cfg(feature = "legacy-2024-11-05")]
3689 #[test]
3690 fn legacy_enabled_public_builder_preserves_auto() {
3691 let mut builder =
3692 ServerBuilder::try_new("srv", "1.0").expect("legacy-enabled construction must succeed");
3693 builder
3694 .try_set_protocol_policy(ProtocolPolicy::Auto)
3695 .expect("Auto must remain available with the legacy adapter enabled");
3696
3697 let server = builder
3698 .try_build()
3699 .expect("legacy-enabled Auto builder must build");
3700 assert_eq!(server.protocol_policy(), ProtocolPolicy::Auto);
3701 }
3702
3703 #[test]
3704 fn launch_policy_accepts_exact_public_values() {
3705 assert_eq!(
3706 protocol_policy_from_server_launch_value(Some(OsStr::new("auto"))),
3707 Ok(Some(ProtocolPolicy::Auto))
3708 );
3709 assert_eq!(
3710 protocol_policy_from_server_launch_value(Some(OsStr::new("modern-only"))),
3711 Ok(Some(ProtocolPolicy::ModernOnly))
3712 );
3713 assert_eq!(
3714 protocol_policy_from_server_launch_value(Some(OsStr::new("legacy-only"))),
3715 Ok(Some(ProtocolPolicy::LegacyOnly))
3716 );
3717 }
3718
3719 #[cfg(feature = "legacy-2024-11-05")]
3720 #[test]
3721 fn valid_launch_policy_wins_over_explicit_builder_policy() {
3722 let server = ServerBuilder::from_launch_protocol_policy(
3723 "srv",
3724 "1.0",
3725 Ok(Some(ProtocolPolicy::ModernOnly)),
3726 )
3727 .expect("valid launch policy must construct a builder")
3728 .protocol_policy(ProtocolPolicy::LegacyOnly)
3729 .expect("the unit-test dual-era build supports LegacyOnly")
3730 .try_build()
3731 .expect("valid launch policy must build");
3732
3733 assert_eq!(server.protocol_policy(), ProtocolPolicy::ModernOnly);
3734 }
3735
3736 #[cfg(feature = "legacy-2024-11-05")]
3737 #[test]
3738 fn fixed_policy_constructor_reserves_policy_against_later_setter() {
3739 let server = ServerBuilder::try_new_with_fixed_protocol_policy(
3740 "srv",
3741 "1.0",
3742 ProtocolPolicy::ModernOnly,
3743 )
3744 .expect("ModernOnly is available in every feature profile")
3745 .protocol_policy(ProtocolPolicy::LegacyOnly)
3746 .expect("the later policy is valid in this dual-era test build")
3747 .try_build()
3748 .expect("the fixed-policy builder remains buildable");
3749
3750 assert_eq!(
3751 server.protocol_policy(),
3752 ProtocolPolicy::ModernOnly,
3753 "the fixed component policy must not be replaced by a later builder setter"
3754 );
3755 }
3756
3757 #[cfg(not(feature = "legacy-2024-11-05"))]
3758 #[test]
3759 fn fixed_policy_constructor_rejects_unavailable_policy_before_construction() {
3760 assert!(matches!(
3761 ServerBuilder::try_new_with_fixed_protocol_policy("srv", "1.0", ProtocolPolicy::Auto,),
3762 Err(ServerLaunchPolicyError::FeatureUnavailable)
3763 ));
3764 }
3765
3766 #[test]
3767 fn invalid_launch_policy_is_rejected_before_builder_construction() {
3768 let result = ServerBuilder::from_launch_protocol_policy(
3769 "srv",
3770 "1.0",
3771 Err(ServerLaunchPolicyError::InvalidValue),
3772 );
3773
3774 assert!(matches!(result, Err(ServerLaunchPolicyError::InvalidValue)));
3775 }
3776
3777 #[test]
3778 fn launch_policy_parser_rejects_unknown_value_without_auto_fallback() {
3779 assert_eq!(
3780 protocol_policy_from_server_launch_value(Some(OsStr::new("mcp-2025-11-25"))),
3781 Err(ServerLaunchPolicyError::InvalidValue)
3782 );
3783 }
3784
3785 #[cfg(unix)]
3786 #[test]
3787 fn launch_policy_parser_rejects_non_unicode_value_without_panic() {
3788 use std::os::unix::ffi::OsStrExt;
3789
3790 assert_eq!(
3791 protocol_policy_from_server_launch_value(Some(OsStr::from_bytes(b"modern-only\xff"))),
3792 Err(ServerLaunchPolicyError::NonUnicode)
3793 );
3794 }
3795
3796 #[test]
3799 fn builder_request_timeout() {
3800 let builder = ServerBuilder::new("srv", "1.0").request_timeout(60);
3801 assert_eq!(builder.request_timeout_secs(), 60);
3802 }
3803
3804 #[test]
3805 fn builder_request_timeout_zero_omits_server_ceiling() {
3806 let builder = ServerBuilder::new("srv", "1.0").request_timeout(0);
3807 assert_eq!(builder.request_timeout_secs(), 0);
3808 }
3809
3810 #[test]
3811 fn builder_without_stats() {
3812 let server = ServerBuilder::new("srv", "1.0").without_stats().build();
3813 assert!(server.stats().is_none());
3814 }
3815
3816 #[test]
3817 fn builder_mask_error_details() {
3818 let builder = ServerBuilder::new("srv", "1.0").mask_error_details(true);
3819 assert!(builder.is_error_masking_enabled());
3820 }
3821
3822 #[test]
3823 fn builder_strict_input_validation() {
3824 let builder = ServerBuilder::new("srv", "1.0").strict_input_validation(true);
3825 assert!(builder.is_strict_input_validation_enabled());
3826 }
3827
3828 #[test]
3829 fn builder_instructions() {
3830 let server = ServerBuilder::new("srv", "1.0")
3831 .instructions("Use this server wisely")
3832 .build();
3833 let _ = server;
3835 }
3836
3837 #[test]
3838 fn builder_log_level() {
3839 let builder = ServerBuilder::new("srv", "1.0").log_level(Level::Debug);
3840 assert_eq!(builder.logging.level, LevelFilter::Debug);
3841 assert_eq!(builder.console_config.log_level, LevelFilter::Debug);
3842 }
3843
3844 #[test]
3845 fn builder_log_level_filter() {
3846 let builder = ServerBuilder::new("srv", "1.0").log_level_filter(LevelFilter::Warn);
3847 assert_eq!(builder.logging.level, LevelFilter::Warn);
3848 assert_eq!(builder.console_config.log_level, LevelFilter::Warn);
3849 }
3850
3851 #[test]
3852 fn builder_log_timestamps_and_targets() {
3853 let builder = ServerBuilder::new("srv", "1.0")
3854 .log_timestamps(false)
3855 .log_targets(false)
3856 .log_file_line(true);
3857 assert!(!builder.logging.timestamps);
3858 assert!(!builder.console_config.log_timestamps);
3859 assert!(!builder.logging.targets);
3860 assert!(!builder.console_config.log_targets);
3861 assert!(builder.logging.file_line);
3862 assert!(builder.console_config.log_file_line);
3863 }
3864
3865 #[test]
3868 fn builder_without_banner() {
3869 let builder = ServerBuilder::new("srv", "1.0").without_banner();
3870 let config = builder.console_config();
3871 assert_eq!(config.banner_style, BannerStyle::None);
3872 }
3873
3874 #[test]
3875 fn builder_with_banner_compact() {
3876 let builder = ServerBuilder::new("srv", "1.0").with_banner(BannerStyle::Compact);
3877 let config = builder.console_config();
3878 assert_eq!(config.banner_style, BannerStyle::Compact);
3879 }
3880
3881 #[test]
3882 fn builder_plain_mode() {
3883 let builder = ServerBuilder::new("srv", "1.0").plain_mode();
3884 let _config = builder.console_config();
3885 }
3886
3887 #[test]
3890 fn builder_tool_enables_capability() {
3891 let server = ServerBuilder::new("srv", "1.0").tool(TestTool).build();
3892 assert!(server.capabilities().tools.is_some());
3893 assert!(
3894 server
3895 .capabilities()
3896 .tools
3897 .as_ref()
3898 .is_some_and(|tools| tools.list_changed),
3899 "registering a tool must advertise tools.listChanged so clients can watch catalog mutations"
3900 );
3901 assert!(server.has_tools());
3902 }
3903
3904 #[cfg(feature = "apps")]
3905 #[test]
3906 fn builder_mcp_apps_tool_requires_apps_opt_in() {
3907 let Err(error) = ServerBuilder::new("srv", "1.0").mcp_apps_tool(TestTool) else {
3908 panic!("Apps tools must not register before Apps negotiation is configured");
3909 };
3910 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
3911 }
3912
3913 #[test]
3914 fn builder_legacy_tool_is_explicit_and_does_not_claim_modern_tools() {
3915 let server = ServerBuilder::new("srv", "1.0")
3916 .legacy_tool(ExactLegacyOnlyTool)
3917 .build();
3918 assert!(server.capabilities().tools.is_some());
3919 assert!(server.has_tools());
3920 let router = server.into_router();
3921 assert_eq!(router.tools_count(), 1);
3922 assert!(
3923 !router
3924 .server_discovery_behavior_registry()
3925 .contains(fastmcp_protocol::ServerBehavior::ToolsList)
3926 );
3927 }
3928
3929 #[test]
3930 fn builder_resource_enables_capability() {
3931 let server = ServerBuilder::new("srv", "1.0")
3932 .resource(TestResource)
3933 .build();
3934 assert!(server.capabilities().resources.is_some());
3935 assert!(
3936 server
3937 .capabilities()
3938 .resources
3939 .as_ref()
3940 .is_some_and(|resources| resources.subscribe && resources.list_changed),
3941 "registering a resource must advertise subscribe and listChanged"
3942 );
3943 assert!(server.has_resources());
3944 }
3945
3946 #[test]
3947 fn builder_prompt_enables_capability() {
3948 let server = ServerBuilder::new("srv", "1.0").prompt(TestPrompt).build();
3949 assert!(server.capabilities().prompts.is_some());
3950 assert!(
3951 server
3952 .capabilities()
3953 .prompts
3954 .as_ref()
3955 .is_some_and(|prompts| prompts.list_changed),
3956 "registering a prompt must advertise prompts.listChanged"
3957 );
3958 assert!(server.has_prompts());
3959 }
3960
3961 #[test]
3962 fn builder_all_handlers() {
3963 let server = ServerBuilder::new("srv", "1.0")
3964 .tool(TestTool)
3965 .resource(TestResource)
3966 .prompt(TestPrompt)
3967 .build();
3968 assert!(server.has_tools());
3969 assert!(server.has_resources());
3970 assert!(server.has_prompts());
3971 }
3972
3973 #[test]
3974 fn builder_no_handlers_means_no_capabilities() {
3975 let server = ServerBuilder::new("srv", "1.0").build();
3976 assert!(!server.has_tools());
3977 assert!(!server.has_resources());
3978 assert!(!server.has_prompts());
3979 }
3980
3981 #[test]
3984 fn builder_on_duplicate_default_is_warn() {
3985 let _builder = ServerBuilder::new("srv", "1.0");
3986 }
3988
3989 #[test]
3990 fn builder_on_duplicate_ignore() {
3991 let server = ServerBuilder::new("srv", "1.0")
3992 .on_duplicate(DuplicateBehavior::Ignore)
3993 .tool(TestTool)
3994 .build();
3995 assert!(server.has_tools());
3996 }
3997
3998 #[test]
3999 fn builder_on_duplicate_replace() {
4000 let server = ServerBuilder::new("srv", "1.0")
4001 .on_duplicate(DuplicateBehavior::Replace)
4002 .tool(TestTool)
4003 .build();
4004 assert!(server.has_tools());
4005 }
4006
4007 #[test]
4008 fn builder_resource_template_honors_duplicate_policy() {
4009 for behavior in [
4010 DuplicateBehavior::Warn,
4011 DuplicateBehavior::Ignore,
4012 DuplicateBehavior::Error,
4013 ] {
4014 let server = ServerBuilder::new("srv", "1.0")
4015 .on_duplicate(behavior)
4016 .resource_template(marked_resource_template("original"))
4017 .resource_template(marked_resource_template("incoming"))
4018 .build();
4019 assert_eq!(server.resource_templates().len(), 1);
4020 assert_eq!(server.resource_templates()[0].name, "original");
4021 }
4022
4023 let server = ServerBuilder::new("srv", "1.0")
4024 .on_duplicate(DuplicateBehavior::Replace)
4025 .resource_template(marked_resource_template("original"))
4026 .resource_template(marked_resource_template("incoming"))
4027 .build();
4028 assert_eq!(server.resource_templates().len(), 1);
4029 assert_eq!(server.resource_templates()[0].name, "incoming");
4030 }
4031
4032 #[test]
4035 fn builder_on_startup_builds() {
4036 let server = ServerBuilder::new("srv", "1.0")
4037 .on_startup(|| -> Result<(), std::io::Error> { Ok(()) })
4038 .build();
4039 let _ = server;
4040 }
4041
4042 #[test]
4043 fn builder_on_shutdown_builds() {
4044 let server = ServerBuilder::new("srv", "1.0").on_shutdown(|| {}).build();
4045 let _ = server;
4046 }
4047
4048 #[test]
4051 fn built_server_console_config_matches_builder() {
4052 let server = ServerBuilder::new("srv", "1.0").without_banner().build();
4053 assert_eq!(server.console_config().banner_style, BannerStyle::None);
4054 }
4055
4056 #[test]
4059 fn builder_chaining_fluent_api() {
4060 let server = ServerBuilder::new("chain", "3.0")
4061 .request_timeout(120)
4062 .mask_error_details(true)
4063 .strict_input_validation(true)
4064 .without_banner()
4065 .plain_mode()
4066 .tool(TestTool)
4067 .resource(TestResource)
4068 .prompt(TestPrompt)
4069 .on_shutdown(|| {})
4070 .build();
4071
4072 assert_eq!(server.info().name, "chain");
4073 assert_eq!(server.info().version, "3.0");
4074 assert!(server.has_tools());
4075 assert!(server.has_resources());
4076 assert!(server.has_prompts());
4077 }
4078
4079 #[test]
4082 fn builder_with_console_config() {
4083 let mut config = ConsoleConfig::new().with_banner(BannerStyle::None);
4084 config.log_level = LevelFilter::Trace;
4085 config.log_timestamps = false;
4086 config.log_targets = false;
4087 config.log_file_line = true;
4088 let builder = ServerBuilder::new("srv", "1.0").with_console_config(config);
4089 assert_eq!(builder.console_config().banner_style, BannerStyle::None);
4090 assert_eq!(builder.logging.level, LevelFilter::Trace);
4091 assert!(!builder.logging.timestamps);
4092 assert!(!builder.logging.targets);
4093 assert!(builder.logging.file_line);
4094 }
4095
4096 #[test]
4097 fn logging_and_console_setters_keep_one_effective_configuration() {
4098 let console = ConsoleConfig::new().with_log_level_filter(LevelFilter::Off);
4099 let builder = ServerBuilder::new("srv", "1.0")
4100 .log_level(Level::Debug)
4101 .with_console_config(console);
4102 assert_eq!(builder.logging.level, LevelFilter::Off);
4103 assert_eq!(builder.console_config.log_level, LevelFilter::Off);
4104
4105 let builder = builder.log_level_filter(LevelFilter::Warn);
4106 assert_eq!(builder.logging.level, LevelFilter::Warn);
4107 assert_eq!(builder.console_config.log_level, LevelFilter::Warn);
4108 }
4109
4110 #[test]
4111 fn builder_with_traffic_logging() {
4112 let builder = ServerBuilder::new("srv", "1.0").with_traffic_logging(TrafficVerbosity::Full);
4113 let config = builder.console_config();
4114 assert_eq!(config.traffic_verbosity, TrafficVerbosity::Full);
4115 }
4116
4117 #[test]
4118 fn builder_force_color() {
4119 let builder = ServerBuilder::new("srv", "1.0").force_color();
4120 let _config = builder.console_config();
4121 }
4123
4124 #[test]
4127 fn builder_logging_full_config() {
4128 let config = LoggingConfig {
4129 level: LevelFilter::Trace,
4130 timestamps: false,
4131 targets: false,
4132 file_line: true,
4133 };
4134 let _builder = ServerBuilder::new("srv", "1.0").logging(config);
4135 }
4136
4137 #[test]
4140 fn builder_list_page_size() {
4141 let server = ServerBuilder::new("srv", "1.0")
4142 .list_page_size(50)
4143 .tool(TestTool)
4144 .build();
4145 assert!(server.has_tools());
4146 }
4147
4148 #[test]
4151 fn builder_resource_template_enables_capability() {
4152 let template = ResourceTemplate {
4153 uri_template: "file://{path}".to_string(),
4154 name: "Template".to_string(),
4155 description: None,
4156 mime_type: None,
4157 icon: None,
4158 version: None,
4159 tags: vec![],
4160 };
4161 let server = ServerBuilder::new("srv", "1.0")
4162 .resource_template(template)
4163 .build();
4164 assert!(server.capabilities().resources.is_some());
4165 }
4166
4167 struct NoopMiddleware;
4170 impl crate::Middleware for NoopMiddleware {}
4171
4172 #[test]
4173 fn builder_middleware() {
4174 let server = ServerBuilder::new("srv", "1.0")
4175 .middleware(NoopMiddleware)
4176 .build();
4177 let _ = server;
4178 }
4179
4180 #[test]
4181 fn builder_multiple_middleware() {
4182 let server = ServerBuilder::new("srv", "1.0")
4183 .middleware(NoopMiddleware)
4184 .middleware(NoopMiddleware)
4185 .build();
4186 let _ = server;
4187 }
4188
4189 struct TestAuthProvider;
4192 impl crate::AuthProvider for TestAuthProvider {
4193 fn authenticate(
4194 &self,
4195 _ctx: &McpContext,
4196 _request: crate::auth::AuthRequest<'_>,
4197 ) -> McpResult<fastmcp_core::AuthContext> {
4198 Ok(fastmcp_core::AuthContext::with_subject("test-user"))
4199 }
4200 }
4201
4202 #[test]
4203 fn builder_auth_provider() {
4204 let server = ServerBuilder::new("srv", "1.0")
4205 .auth_provider(TestAuthProvider)
4206 .build();
4207 let _ = server;
4208 }
4209
4210 #[test]
4213 fn builder_auto_mask_errors() {
4214 let builder = ServerBuilder::new("srv", "1.0").auto_mask_errors();
4216 assert!(!builder.is_error_masking_enabled());
4218 }
4219
4220 struct DupTool(&'static str);
4223 impl crate::ToolHandler for DupTool {
4224 fn definition(&self) -> Tool {
4225 Tool {
4226 name: self.0.to_string(),
4227 description: None,
4228 input_schema: serde_json::json!({"type": "object"}),
4229 output_schema: None,
4230 icon: None,
4231 version: None,
4232 tags: vec![],
4233 annotations: None,
4234 }
4235 }
4236 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
4237 Ok(vec![Content::text("ok")])
4238 }
4239 }
4240
4241 #[test]
4242 fn builder_on_duplicate_error_logs_but_continues() {
4243 let server = ServerBuilder::new("srv", "1.0")
4246 .on_duplicate(DuplicateBehavior::Error)
4247 .tool(DupTool("dup"))
4248 .tool(DupTool("dup")) .build();
4250 assert!(server.has_tools());
4251 }
4252
4253 #[test]
4256 fn builder_mount_with_prefix() {
4257 let source = ServerBuilder::new("sub", "1.0")
4258 .tool(TestTool)
4259 .resource(TestResource)
4260 .prompt(TestPrompt)
4261 .build();
4262
4263 let main = ServerBuilder::new("main", "1.0")
4264 .mount(source, Some("sub"))
4265 .build();
4266
4267 assert!(main.has_tools());
4268 assert!(main.has_resources());
4269 assert!(main.has_prompts());
4270 }
4271
4272 #[test]
4273 fn builder_mount_without_prefix() {
4274 let source = ServerBuilder::new("sub", "1.0").tool(TestTool).build();
4275
4276 let main = ServerBuilder::new("main", "1.0")
4277 .mount(source, None)
4278 .build();
4279
4280 assert!(main.has_tools());
4281 }
4282
4283 #[cfg(feature = "apps")]
4284 #[test]
4285 fn builder_mount_rejects_apps_bound_child_without_apps_opt_in_atomically() {
4286 let main = ServerBuilder::new("main", "1.0")
4287 .tool(TestTool)
4288 .resource(TestResource)
4289 .mount(apps_mount_child(), None)
4290 .build();
4291
4292 assert!(main.has_tools());
4293 assert!(main.has_resources());
4294 assert!(
4295 main.extension_registry_receipt().is_none(),
4296 "rejecting a child must not adopt its Apps extension runtime"
4297 );
4298
4299 let router = main.into_router();
4300 assert_eq!(router.tools_count(), 1, "the rejected child adds no tools");
4301 assert_eq!(
4302 router.resources_count(),
4303 1,
4304 "the rejected child adds no resources"
4305 );
4306 }
4307
4308 #[cfg(feature = "apps")]
4309 #[test]
4310 fn builder_mount_accepts_apps_bound_child_with_active_apps_opt_in() {
4311 let main = ServerBuilder::new("main", "1.0")
4312 .mcp_apps()
4313 .expect("parent Apps extension installs")
4314 .mount(apps_mount_child(), None)
4315 .build();
4316
4317 assert!(main.has_tools());
4318 assert!(main.has_resources());
4319 assert_eq!(
4320 main.extension_registry_receipt()
4321 .expect("parent retains its Apps extension runtime")
4322 .descriptor_count(),
4323 1,
4324 "mounting does not transfer or merge the child extension runtime"
4325 );
4326
4327 let router = main.into_router();
4328 assert_eq!(router.tools_count(), 1);
4329 assert_eq!(router.resources_count(), 1);
4330 }
4331
4332 #[cfg(feature = "apps")]
4333 #[test]
4334 fn builder_mount_tools_rejects_apps_bound_child_without_apps_opt_in_atomically() {
4335 let main = ServerBuilder::new("main", "1.0")
4336 .resource(TestResource)
4337 .mount_tools(apps_resource_bound_tool_mount_child(), None)
4338 .build();
4339
4340 assert!(!main.has_tools());
4341 assert!(main.has_resources());
4342 assert!(
4343 main.extension_registry_receipt().is_none(),
4344 "rejecting a child must not adopt its Apps extension runtime"
4345 );
4346
4347 let router = main.into_router();
4348 assert_eq!(router.tools_count(), 0, "the rejected child adds no tools");
4349 assert_eq!(
4350 router.resources_count(),
4351 1,
4352 "the rejected child leaves existing destination resources unchanged"
4353 );
4354 }
4355
4356 #[cfg(feature = "apps")]
4357 #[test]
4358 fn builder_mount_tools_accepts_apps_bound_child_with_active_apps_opt_in() {
4359 let resource = McpAppsUiResource::try_new(
4360 AbsoluteUri::parse("ui://mount/dashboard").expect("fixed Apps mount URI is valid"),
4361 "mounted-apps-dashboard",
4362 "<main>Apps mount fixture</main>",
4363 )
4364 .expect("fixed Apps mount resource is valid");
4365 let main = ServerBuilder::new("main", "1.0")
4366 .mcp_apps()
4367 .expect("parent Apps extension installs")
4368 .mcp_apps_ui_resource(resource)
4369 .expect("parent Apps resource registers")
4370 .mount_tools(apps_mount_child(), None)
4371 .build();
4372
4373 assert!(main.has_tools());
4374 assert!(main.has_resources());
4375 assert_eq!(
4376 main.extension_registry_receipt()
4377 .expect("parent retains its Apps extension runtime")
4378 .descriptor_count(),
4379 1,
4380 "mounting does not transfer or merge the child extension runtime"
4381 );
4382
4383 let router = main.into_router();
4384 assert_eq!(router.tools_count(), 1);
4385 assert_eq!(router.resources_count(), 1);
4386 }
4387
4388 #[cfg(feature = "apps")]
4389 #[test]
4390 fn builder_mount_resources_rejects_apps_bound_child_without_apps_opt_in_atomically() {
4391 let main = ServerBuilder::new("main", "1.0")
4392 .tool(TestTool)
4393 .mount_resources(apps_mount_child(), None)
4394 .build();
4395
4396 assert!(main.has_tools());
4397 assert!(!main.has_resources());
4398 assert!(
4399 main.extension_registry_receipt().is_none(),
4400 "rejecting a child must not adopt its Apps extension runtime"
4401 );
4402
4403 let router = main.into_router();
4404 assert_eq!(
4405 router.tools_count(),
4406 1,
4407 "the rejected child leaves existing destination tools unchanged"
4408 );
4409 assert_eq!(
4410 router.resources_count(),
4411 0,
4412 "the rejected child adds no resources"
4413 );
4414 }
4415
4416 #[cfg(feature = "apps")]
4417 #[test]
4418 fn builder_mount_resources_accepts_apps_bound_child_with_active_apps_opt_in() {
4419 let main = ServerBuilder::new("main", "1.0")
4420 .mcp_apps()
4421 .expect("parent Apps extension installs")
4422 .mount_resources(apps_mount_child(), None)
4423 .build();
4424
4425 assert!(!main.has_tools());
4426 assert!(main.has_resources());
4427 assert_eq!(
4428 main.extension_registry_receipt()
4429 .expect("parent retains its Apps extension runtime")
4430 .descriptor_count(),
4431 1,
4432 "mounting does not transfer or merge the child extension runtime"
4433 );
4434
4435 let router = main.into_router();
4436 assert_eq!(router.tools_count(), 0);
4437 assert_eq!(router.resources_count(), 1);
4438 }
4439
4440 #[test]
4441 fn builder_mount_tools_only() {
4442 let source = ServerBuilder::new("sub", "1.0")
4443 .tool(TestTool)
4444 .resource(TestResource)
4445 .prompt(TestPrompt)
4446 .build();
4447
4448 let main = ServerBuilder::new("main", "1.0")
4449 .mount_tools(source, Some("sub"))
4450 .build();
4451
4452 assert!(main.has_tools());
4453 assert!(!main.has_resources());
4455 assert!(!main.has_prompts());
4456 }
4457
4458 #[test]
4459 fn builder_mount_resources_only() {
4460 let source = ServerBuilder::new("sub", "1.0")
4461 .tool(TestTool)
4462 .resource(TestResource)
4463 .prompt(TestPrompt)
4464 .build();
4465
4466 let main = ServerBuilder::new("main", "1.0")
4467 .mount_resources(source, Some("data"))
4468 .build();
4469
4470 assert!(!main.has_tools());
4471 assert!(main.has_resources());
4472 assert!(!main.has_prompts());
4473 }
4474
4475 #[test]
4476 fn builder_mount_prompts_only() {
4477 let source = ServerBuilder::new("sub", "1.0")
4478 .tool(TestTool)
4479 .resource(TestResource)
4480 .prompt(TestPrompt)
4481 .build();
4482
4483 let main = ServerBuilder::new("main", "1.0")
4484 .mount_prompts(source, Some("tmpl"))
4485 .build();
4486
4487 assert!(!main.has_tools());
4488 assert!(!main.has_resources());
4489 assert!(main.has_prompts());
4490 }
4491
4492 #[test]
4493 fn builder_mount_empty_server() {
4494 let source = ServerBuilder::new("empty", "1.0").build();
4495
4496 let main = ServerBuilder::new("main", "1.0")
4497 .mount(source, Some("empty"))
4498 .build();
4499
4500 assert!(!main.has_tools());
4501 assert!(!main.has_resources());
4502 assert!(!main.has_prompts());
4503 }
4504
4505 #[test]
4506 fn builder_full_mount_honors_duplicate_policy_for_all_component_kinds() {
4507 for behavior in [
4508 DuplicateBehavior::Warn,
4509 DuplicateBehavior::Ignore,
4510 DuplicateBehavior::Replace,
4511 DuplicateBehavior::Error,
4512 ] {
4513 let server = marked_builder("original")
4514 .on_duplicate(behavior)
4515 .mount(marked_builder("incoming").build(), None)
4516 .build();
4517 assert_marked_server(
4518 &server,
4519 if behavior == DuplicateBehavior::Replace {
4520 "incoming"
4521 } else {
4522 "original"
4523 },
4524 );
4525 }
4526 }
4527
4528 #[test]
4529 fn builder_partial_mounts_honor_duplicate_policy() {
4530 for behavior in [
4531 DuplicateBehavior::Warn,
4532 DuplicateBehavior::Ignore,
4533 DuplicateBehavior::Replace,
4534 DuplicateBehavior::Error,
4535 ] {
4536 let replacement_marker = if behavior == DuplicateBehavior::Replace {
4537 "incoming"
4538 } else {
4539 "original"
4540 };
4541
4542 let tools = marked_builder("original")
4543 .on_duplicate(behavior)
4544 .mount_tools(marked_builder("incoming").build(), None)
4545 .build();
4546 assert_eq!(tools.tools()[0].tags, vec![replacement_marker.to_string()]);
4547 assert_eq!(tools.resources()[0].tags, vec!["original".to_string()]);
4548 assert_eq!(
4549 tools.resource_templates()[0].tags,
4550 vec!["original".to_string()]
4551 );
4552 assert_eq!(tools.prompts()[0].tags, vec!["original".to_string()]);
4553
4554 let resources = marked_builder("original")
4555 .on_duplicate(behavior)
4556 .mount_resources(marked_builder("incoming").build(), None)
4557 .build();
4558 assert_eq!(resources.tools()[0].tags, vec!["original".to_string()]);
4559 assert_eq!(
4560 resources.resources()[0].tags,
4561 vec![replacement_marker.to_string()]
4562 );
4563 assert_eq!(
4564 resources.resource_templates()[0].tags,
4565 vec![replacement_marker.to_string()]
4566 );
4567 assert_eq!(resources.prompts()[0].tags, vec!["original".to_string()]);
4568
4569 let prompts = marked_builder("original")
4570 .on_duplicate(behavior)
4571 .mount_prompts(marked_builder("incoming").build(), None)
4572 .build();
4573 assert_eq!(prompts.tools()[0].tags, vec!["original".to_string()]);
4574 assert_eq!(prompts.resources()[0].tags, vec!["original".to_string()]);
4575 assert_eq!(
4576 prompts.resource_templates()[0].tags,
4577 vec!["original".to_string()]
4578 );
4579 assert_eq!(
4580 prompts.prompts()[0].tags,
4581 vec![replacement_marker.to_string()]
4582 );
4583 }
4584 }
4585
4586 #[test]
4587 fn builder_full_and_partial_mounts_reject_invalid_prefixes() {
4588 let full = marked_builder("original")
4589 .on_duplicate(DuplicateBehavior::Replace)
4590 .mount(marked_builder("incoming").build(), Some("peer/secret"))
4591 .build();
4592 assert_marked_server(&full, "original");
4593
4594 let tools = marked_builder("original")
4595 .on_duplicate(DuplicateBehavior::Replace)
4596 .mount_tools(marked_builder("incoming").build(), Some("peer/secret"))
4597 .build();
4598 assert_marked_server(&tools, "original");
4599
4600 let resources = marked_builder("original")
4601 .on_duplicate(DuplicateBehavior::Replace)
4602 .mount_resources(marked_builder("incoming").build(), Some("peer/secret"))
4603 .build();
4604 assert_marked_server(&resources, "original");
4605
4606 let prompts = marked_builder("original")
4607 .on_duplicate(DuplicateBehavior::Replace)
4608 .mount_prompts(marked_builder("incoming").build(), Some("peer/secret"))
4609 .build();
4610 assert_marked_server(&prompts, "original");
4611 }
4612
4613 #[cfg(feature = "proxy")]
4614 mod proxy_registration_tests {
4615 use super::*;
4616
4617 struct CompletionProxyBackend {
4620 supported: bool,
4621 result: CoreResult,
4622 calls: Arc<Mutex<Vec<fastmcp_client::CompletionParams>>>,
4623 }
4624
4625 struct LocalFinalCompletionPrompt;
4626
4627 impl crate::PromptHandler for LocalFinalCompletionPrompt {
4628 fn definition(&self) -> Prompt {
4629 Prompt {
4630 name: "final-deploy".to_owned(),
4631 description: Some("local completion target".to_owned()),
4632 arguments: vec![fastmcp_protocol::PromptArgument {
4633 name: "environment".to_owned(),
4634 description: None,
4635 required: false,
4636 }],
4637 icon: None,
4638 version: None,
4639 tags: Vec::new(),
4640 }
4641 }
4642
4643 fn final_definition(&self) -> Option<fastmcp_protocol::FinalPrompt> {
4644 Some(fastmcp_protocol::FinalPrompt {
4645 name: "final-deploy".to_owned(),
4646 title: Some("Local Final Deploy".to_owned()),
4647 description: Some("local completion target".to_owned()),
4648 icons: None,
4649 arguments: Some(vec![fastmcp_protocol::FinalPromptArgument {
4650 name: "environment".to_owned(),
4651 title: Some("Local Environment".to_owned()),
4652 description: None,
4653 required: Some(false),
4654 }]),
4655 meta: None,
4656 })
4657 }
4658
4659 fn get(
4660 &self,
4661 _ctx: &McpContext,
4662 _args: std::collections::HashMap<String, String>,
4663 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
4664 Ok(Vec::new())
4665 }
4666 }
4667
4668 impl crate::proxy::ProxyBackend for CompletionProxyBackend {
4669 fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
4670 Ok(Vec::new())
4671 }
4672
4673 fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
4674 Ok(Vec::new())
4675 }
4676
4677 fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
4678 Ok(Vec::new())
4679 }
4680
4681 fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
4682 Ok(Vec::new())
4683 }
4684
4685 fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
4686 Ok(Vec::new())
4687 }
4688
4689 fn call_tool_with_progress(
4690 &mut self,
4691 _: &str,
4692 _: serde_json::Value,
4693 _: crate::proxy::ProgressCallback<'_>,
4694 ) -> McpResult<Vec<Content>> {
4695 Ok(Vec::new())
4696 }
4697
4698 fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
4699 Ok(Vec::new())
4700 }
4701
4702 fn get_prompt(
4703 &mut self,
4704 _: &str,
4705 _: std::collections::HashMap<String, String>,
4706 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
4707 Ok(Vec::new())
4708 }
4709
4710 fn supports_completion(&mut self) -> McpResult<bool> {
4711 Ok(self.supported)
4712 }
4713
4714 fn complete_result(
4715 &mut self,
4716 params: fastmcp_client::CompletionParams,
4717 ) -> McpResult<CoreResult> {
4718 self.calls
4719 .lock()
4720 .expect("completion proxy call log is not poisoned")
4721 .push(params);
4722 Ok(self.result.clone())
4723 }
4724 }
4725
4726 fn legacy_completion_proxy_catalog() -> ProxyTypedCatalog {
4727 ProxyTypedCatalog {
4728 tools: ProxyToolCatalog::Legacy(Vec::new()),
4729 resources: ProxyResourceCatalog::Legacy(Vec::new()),
4730 resource_templates: ProxyResourceTemplateCatalog::Legacy(Vec::new()),
4731 prompts: ProxyPromptCatalog::Legacy(vec![Prompt {
4732 name: "legacy-deploy".to_owned(),
4733 description: None,
4734 arguments: Vec::new(),
4735 icon: None,
4736 version: None,
4737 tags: Vec::new(),
4738 }]),
4739 }
4740 }
4741
4742 fn final_completion_proxy_catalog() -> ProxyTypedCatalog {
4743 let prompt = serde_json::from_value(serde_json::json!({
4744 "name": "final-deploy",
4745 "title": "Upstream Final Deploy",
4746 "arguments": [{"name": "environment", "title": "Environment"}]
4747 }))
4748 .expect("the final completion prompt fixture is valid");
4749 ProxyTypedCatalog {
4750 tools: ProxyToolCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4751 resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4752 resource_templates: ProxyResourceTemplateCatalog::Final(ProxyFinalCatalog::new(
4753 Vec::new(),
4754 )),
4755 prompts: ProxyPromptCatalog::Final(ProxyFinalCatalog::new(vec![prompt])),
4756 }
4757 }
4758
4759 fn final_completion_proxy_template_catalog() -> ProxyTypedCatalog {
4760 let template = serde_json::from_value(serde_json::json!({
4761 "uriTemplate": "completion://{environment}",
4762 "name": "upstream-completion-template",
4763 "title": "Upstream Completion Template",
4764 }))
4765 .expect("the final completion resource-template fixture is valid");
4766 ProxyTypedCatalog {
4767 tools: ProxyToolCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4768 resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4769 resource_templates: ProxyResourceTemplateCatalog::Final(ProxyFinalCatalog::new(
4770 vec![template],
4771 )),
4772 prompts: ProxyPromptCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
4773 }
4774 }
4775
4776 fn legacy_completion_proxy_template_catalog() -> ProxyTypedCatalog {
4777 ProxyTypedCatalog {
4778 tools: ProxyToolCatalog::Legacy(Vec::new()),
4779 resources: ProxyResourceCatalog::Legacy(Vec::new()),
4780 resource_templates: ProxyResourceTemplateCatalog::Legacy(vec![ResourceTemplate {
4781 uri_template: "completion://{environment}".to_owned(),
4782 name: "upstream-completion-template".to_owned(),
4783 description: None,
4784 mime_type: None,
4785 icon: None,
4786 version: None,
4787 tags: Vec::new(),
4788 }]),
4789 prompts: ProxyPromptCatalog::Legacy(Vec::new()),
4790 }
4791 }
4792
4793 fn local_completion_template() -> ResourceTemplate {
4794 ResourceTemplate {
4795 uri_template: "completion://{environment}".to_owned(),
4796 name: "local-completion-template".to_owned(),
4797 description: Some("local completion target".to_owned()),
4798 mime_type: None,
4799 icon: None,
4800 version: None,
4801 tags: Vec::new(),
4802 }
4803 }
4804
4805 fn legacy_completion_proxy_result() -> CoreResult {
4806 CoreResult::Legacy(LegacyCoreResult::Completion(
4807 fastmcp_protocol::LegacyCompletionResult {
4808 completion: fastmcp_protocol::CompletionValues {
4809 values: vec!["legacy-staging".to_owned()],
4810 total: Some(1),
4811 has_more: Some(false),
4812 },
4813 meta: None,
4814 },
4815 ))
4816 }
4817
4818 fn final_completion_proxy_result() -> CoreResult {
4819 CoreResult::Final(FinalCoreResult::Completion {
4820 result: CompleteResult::new(
4821 fastmcp_protocol::FinalCompletionResult {
4822 completion: fastmcp_protocol::FinalCompletionValues {
4823 values: vec!["final-staging".to_owned()],
4824 total: Some(
4825 serde_json::from_str("92233720368547758081234567890")
4826 .expect("the fixed exact completion total is a JSON integer"),
4827 ),
4828 has_more: Some(false),
4829 },
4830 },
4831 ResultMeta::server_generated(
4832 Implementation::try_new("completion-upstream", "1.0")
4833 .expect("the fixed completion implementation is valid"),
4834 ),
4835 ),
4836 diagnostic: None,
4837 })
4838 }
4839
4840 fn final_completion_request(id: i64) -> JsonRpcRequest {
4841 JsonRpcRequest::new(
4842 "completion/complete",
4843 Some(serde_json::json!({
4844 "_meta": {
4845 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4846 "io.modelcontextprotocol/clientCapabilities": {},
4847 },
4848 "ref": {
4849 "type": "ref/prompt",
4850 "name": "final-deploy",
4851 "title": "Final Deploy",
4852 },
4853 "argument": {"name": "environment", "value": "sta"},
4854 "context": {"arguments": {"region": "us-east-1"}},
4855 })),
4856 id,
4857 )
4858 }
4859
4860 fn final_resource_template_completion_request(id: i64) -> JsonRpcRequest {
4861 JsonRpcRequest::new(
4862 "completion/complete",
4863 Some(serde_json::json!({
4864 "_meta": {
4865 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4866 "io.modelcontextprotocol/clientCapabilities": {},
4867 },
4868 "ref": {
4869 "type": "ref/resource",
4870 "uri": "completion://{environment}",
4871 },
4872 "argument": {"name": "environment", "value": "sta"},
4873 })),
4874 id,
4875 )
4876 }
4877
4878 fn legacy_completion_request(id: i64, reference: serde_json::Value) -> JsonRpcRequest {
4879 JsonRpcRequest::new(
4880 "completion/complete",
4881 Some(serde_json::json!({
4882 "ref": reference,
4883 "argument": {"name": "environment", "value": "sta"},
4884 })),
4885 id,
4886 )
4887 }
4888
4889 fn final_proxy_catalog() -> ProxyCatalog {
4890 ProxyCatalog {
4891 tool_catalog_era: Some(ProtocolEra::Modern2026),
4892 final_tools: vec![
4893 serde_json::from_value(serde_json::json!({
4894 "name": "weather",
4895 "title": "Weather Forecast",
4896 "description": "Returns a precise forecast.",
4897 "icons": [{
4898 "src": "https://example.test/icons/weather.svg",
4899 "mimeType": "image/svg+xml",
4900 "sizes": ["16x16", "32x32"],
4901 "theme": "light",
4902 "com.example/icon": {"retained": true}
4903 }],
4904 "inputSchema": {
4905 "type": "object",
4906 "properties": {"city": {"type": "string"}}
4907 },
4908 "outputSchema": {"type": "object"},
4909 "annotations": {
4910 "title": "Forecast",
4911 "destructiveHint": false,
4912 "idempotentHint": true,
4913 "readOnlyHint": true,
4914 "openWorldHint": false
4915 },
4916 "_meta": {"com.example/catalog": {"retained": true}}
4917 }))
4918 .expect("the exact final tool fixture is valid"),
4919 ],
4920 ..ProxyCatalog::default()
4921 }
4922 }
4923
4924 fn legacy_proxy_catalog() -> ProxyCatalog {
4925 ProxyCatalog {
4926 tool_catalog_era: Some(ProtocolEra::Legacy2024),
4927 tools: vec![Tool {
4928 name: "legacy-weather".to_owned(),
4929 description: Some("Exact legacy proxy fixture".to_owned()),
4930 input_schema: serde_json::json!({"type": "object"}),
4931 output_schema: None,
4932 icon: None,
4933 version: None,
4934 tags: Vec::new(),
4935 annotations: None,
4936 }],
4937 ..ProxyCatalog::default()
4938 }
4939 }
4940
4941 fn final_tools_list_request(id: i64) -> JsonRpcRequest {
4942 JsonRpcRequest::new(
4943 "tools/list",
4944 Some(serde_json::json!({
4945 "_meta": {
4946 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4947 "io.modelcontextprotocol/clientCapabilities": {},
4948 },
4949 })),
4950 id,
4951 )
4952 }
4953
4954 fn final_tools_call_request(
4955 name: &str,
4956 arguments: serde_json::Value,
4957 id: i64,
4958 ) -> JsonRpcRequest {
4959 JsonRpcRequest::new(
4960 "tools/call",
4961 Some(serde_json::json!({
4962 "_meta": {
4963 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
4964 "io.modelcontextprotocol/clientCapabilities": {},
4965 },
4966 "name": name,
4967 "arguments": arguments,
4968 })),
4969 id,
4970 )
4971 }
4972
4973 fn bound_proxy_client<B: crate::proxy::ProxyBackend + 'static>(
4974 backend: B,
4975 era: ProtocolEra,
4976 ) -> ProxyClient {
4977 let mut bindings = ProxyClient::upstream_binding_registry();
4978 let policy = match era {
4979 ProtocolEra::Modern2026 => ProtocolPolicy::ModernOnly,
4980 ProtocolEra::Legacy2024 => ProtocolPolicy::LegacyOnly,
4981 };
4982 let opening = match era {
4983 ProtocolEra::Modern2026 => StdioOpeningFrame::ModernRequest {
4984 protocol_version: era.version().as_str().to_owned(),
4985 },
4986 ProtocolEra::Legacy2024 => StdioOpeningFrame::LegacyInitialize,
4987 };
4988 let binding = bindings
4989 .bind_stdio(
4990 "weather-route",
4991 "stdio:weather",
4992 "final-catalog-receipt",
4993 1,
4994 policy,
4995 opening,
4996 )
4997 .expect("the route selects the requested exact era");
4998 let upstream_protocol_version = era.version().as_str().to_owned();
4999 ProxyClient::from_backend_with_upstream_binding(
5000 backend,
5001 binding,
5002 &upstream_protocol_version,
5003 )
5004 .expect("the selected upstream version matches its immutable binding")
5005 }
5006
5007 fn final_catalog_proxy_client(era: ProtocolEra) -> ProxyClient {
5008 bound_proxy_client(DuplicatePolicyProxyBackend, era)
5009 }
5010
5011 #[cfg(feature = "tasks")]
5012 fn ordinary_proxy_tasks_client(
5013 calls: Arc<Mutex<Vec<String>>>,
5014 updates: Arc<Mutex<Vec<serde_json::Value>>>,
5015 ) -> ProxyClient {
5016 let task = serde_json::from_value(serde_json::json!({
5017 "resultType": "task",
5018 "taskId": "ordinary-proxy-task-71",
5019 "status": "input_required",
5020 "createdAt": "2026-07-28T12:00:00Z",
5021 "lastUpdatedAt": "2026-07-28T12:00:00Z",
5022 "ttlMs": null,
5023 "inputRequests": {},
5024 }))
5025 .expect("the ordinary proxy Task fixture is exact");
5026 bound_proxy_client(
5027 OrdinaryProxyTasksBackend {
5028 calls,
5029 updates,
5030 task,
5031 },
5032 ProtocolEra::Modern2026,
5033 )
5034 }
5035
5036 fn discovered_duplicate_policy_proxy() -> (ProxyClient, ProxyCatalog) {
5037 let client = ProxyClient::from_backend(DuplicatePolicyProxyBackend);
5038 let catalog = client
5039 .catalog()
5040 .expect("backend discovery supplies the legacy era evidence");
5041 (client, catalog)
5042 }
5043
5044 #[derive(Clone, Copy)]
5045 enum DualEraProxyRoute {
5046 Legacy,
5047 Final,
5048 }
5049
5050 struct RecordingDualEraProxyBackend {
5051 route: DualEraProxyRoute,
5052 calls: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5053 }
5054
5055 impl RecordingDualEraProxyBackend {
5056 fn record(&self, name: &str, arguments: serde_json::Value) {
5057 self.calls
5058 .lock()
5059 .expect("the test call log lock is not poisoned")
5060 .push((name.to_owned(), arguments));
5061 }
5062 }
5063
5064 impl crate::proxy::ProxyBackend for RecordingDualEraProxyBackend {
5065 fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
5066 Ok(Vec::new())
5067 }
5068
5069 fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
5070 Ok(Vec::new())
5071 }
5072
5073 fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
5074 Ok(Vec::new())
5075 }
5076
5077 fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
5078 Ok(Vec::new())
5079 }
5080
5081 fn call_tool(
5082 &mut self,
5083 name: &str,
5084 arguments: serde_json::Value,
5085 ) -> McpResult<Vec<Content>> {
5086 self.record(name, arguments);
5087 Ok(vec![Content::text(match self.route {
5088 DualEraProxyRoute::Legacy => "bound legacy proxy",
5089 DualEraProxyRoute::Final => "bound final proxy",
5090 })])
5091 }
5092
5093 fn call_tool_with_progress(
5094 &mut self,
5095 name: &str,
5096 arguments: serde_json::Value,
5097 _: crate::proxy::ProgressCallback<'_>,
5098 ) -> McpResult<Vec<Content>> {
5099 self.call_tool(name, arguments)
5100 }
5101
5102 fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
5103 Ok(Vec::new())
5104 }
5105
5106 fn get_prompt(
5107 &mut self,
5108 _: &str,
5109 _: std::collections::HashMap<String, String>,
5110 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
5111 Ok(Vec::new())
5112 }
5113
5114 fn call_tool_result(
5115 &mut self,
5116 name: &str,
5117 arguments: serde_json::Value,
5118 ) -> McpResult<CoreResult> {
5119 self.record(name, arguments);
5120 match self.route {
5121 DualEraProxyRoute::Legacy => Ok(CoreResult::Legacy(
5122 LegacyCoreResult::ToolsCall(CallToolResult {
5123 content: vec![LegacyContent::Text {
5124 text: "bound legacy proxy".to_owned(),
5125 annotations: None,
5126 additional: std::collections::BTreeMap::new(),
5127 }],
5128 is_error: false,
5129 meta: None,
5130 additional: std::collections::BTreeMap::new(),
5131 }),
5132 )),
5133 DualEraProxyRoute::Final => Ok(CoreResult::Final(FinalCoreResult::ToolsCall {
5134 result: CompleteResult::new(
5135 FinalCallToolResult {
5136 content: vec![ContentBlock::text("bound final proxy")],
5137 is_error: false,
5138 structured_content: Some(serde_json::json!({"route": "final"})),
5139 },
5140 ResultMeta::server_generated(
5141 Implementation::try_new("bound-final-upstream", "1.0")
5142 .expect("the fixed test implementation is valid"),
5143 ),
5144 ),
5145 diagnostic: None,
5146 })),
5147 }
5148 }
5149 }
5150
5151 fn dual_era_proxy_client(
5152 route: DualEraProxyRoute,
5153 calls: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5154 ) -> ProxyClient {
5155 let era = match route {
5156 DualEraProxyRoute::Legacy => ProtocolEra::Legacy2024,
5157 DualEraProxyRoute::Final => ProtocolEra::Modern2026,
5158 };
5159 let (route_id, upstream_identity) = match route {
5160 DualEraProxyRoute::Legacy => ("dual-era-legacy-route", "stdio:dual-era-legacy"),
5161 DualEraProxyRoute::Final => ("dual-era-final-route", "stdio:dual-era-final"),
5162 };
5163 let policy = match era {
5164 ProtocolEra::Modern2026 => ProtocolPolicy::ModernOnly,
5165 ProtocolEra::Legacy2024 => ProtocolPolicy::LegacyOnly,
5166 };
5167 let opening = match era {
5168 ProtocolEra::Modern2026 => StdioOpeningFrame::ModernRequest {
5169 protocol_version: era.version().as_str().to_owned(),
5170 },
5171 ProtocolEra::Legacy2024 => StdioOpeningFrame::LegacyInitialize,
5172 };
5173 let mut bindings = ProxyClient::upstream_binding_registry();
5174 let binding = bindings
5175 .bind_stdio(
5176 route_id,
5177 upstream_identity,
5178 "dual-era-receipt",
5179 1,
5180 policy,
5181 opening,
5182 )
5183 .expect("the test route selects one immutable era");
5184 let upstream_protocol_version = era.version().as_str().to_owned();
5185 ProxyClient::from_backend_with_upstream_binding(
5186 RecordingDualEraProxyBackend { route, calls },
5187 binding,
5188 &upstream_protocol_version,
5189 )
5190 .expect("the selected upstream version matches its immutable binding")
5191 }
5192
5193 fn dual_era_proxy_server() -> (
5194 crate::Server,
5195 Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5196 Arc<Mutex<Vec<(String, serde_json::Value)>>>,
5197 ) {
5198 let legacy_calls = Arc::new(Mutex::new(Vec::new()));
5199 let final_calls = Arc::new(Mutex::new(Vec::new()));
5200 let server = ServerBuilder::new("srv", "1.0")
5201 .proxy(
5202 dual_era_proxy_client(DualEraProxyRoute::Legacy, Arc::clone(&legacy_calls)),
5203 legacy_proxy_catalog(),
5204 )
5205 .expect("the legacy proxy catalog agrees with its bound route")
5206 .proxy(
5207 dual_era_proxy_client(DualEraProxyRoute::Final, Arc::clone(&final_calls)),
5208 final_proxy_catalog(),
5209 )
5210 .expect("the final proxy catalog agrees with its bound route")
5211 .build();
5212 (server, legacy_calls, final_calls)
5213 }
5214
5215 fn initialized_legacy_proxy_session(server: &crate::Server) -> crate::Session {
5216 let mut session =
5217 crate::Session::new(server.info().clone(), server.capabilities().clone());
5218 session.initialize(
5219 fastmcp_protocol::ClientInfo {
5220 name: "dual-era-legacy-client".to_owned(),
5221 version: "1.0".to_owned(),
5222 },
5223 fastmcp_protocol::ClientCapabilities::default(),
5224 "2024-11-05".to_owned(),
5225 );
5226 session
5227 }
5228
5229 fn assert_rejected_proxy_catalog_returns_an_error(catalog: ProxyCatalog) {
5230 let error = match ServerBuilder::new("srv", "1.0").tool(TestTool).proxy(
5231 ProxyClient::from_backend(DuplicatePolicyProxyBackend),
5232 catalog,
5233 ) {
5234 Ok(_) => {
5235 panic!("the malformed or unbound proxy catalog is rejected before registration")
5236 }
5237 Err(error) => error,
5238 };
5239
5240 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
5241 }
5242
5243 #[test]
5244 fn builder_proxy_accepts_a_coherent_legacy_catalog() {
5245 let server = ServerBuilder::new("srv", "1.0")
5246 .proxy(
5247 final_catalog_proxy_client(ProtocolEra::Legacy2024),
5248 legacy_proxy_catalog(),
5249 )
5250 .expect("the coherent legacy catalog agrees with its route")
5251 .build();
5252
5253 assert!(server.has_tools());
5254 let tools = server.tools();
5255 assert_eq!(
5256 tools[0].name, "legacy-weather",
5257 "the public builder path registers a catalog whose marker and entries select legacy"
5258 );
5259 }
5260
5261 #[test]
5262 fn builder_proxy_rejects_an_unbound_caller_asserted_legacy_catalog() {
5263 let error = match ServerBuilder::new("srv", "1.0").proxy(
5264 ProxyClient::from_backend(DuplicatePolicyProxyBackend),
5265 legacy_proxy_catalog(),
5266 ) {
5267 Ok(_) => panic!("a caller-supplied catalog cannot bind an unbound proxy route"),
5268 Err(error) => error,
5269 };
5270
5271 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
5272 assert!(error.message.contains("cannot bind an unbound route"));
5273 }
5274
5275 #[test]
5276 fn builder_proxy_rejects_legacy_tools_when_only_the_marker_changes_to_modern() {
5277 let mut catalog = legacy_proxy_catalog();
5278 catalog.tool_catalog_era = Some(ProtocolEra::Modern2026);
5279
5280 assert_rejected_proxy_catalog_returns_an_error(catalog);
5281 }
5282
5283 #[test]
5284 fn builder_proxy_rejects_final_tools_when_only_the_marker_changes_to_legacy() {
5285 let mut catalog = final_proxy_catalog();
5286 catalog.tool_catalog_era = Some(ProtocolEra::Legacy2024);
5287
5288 assert_rejected_proxy_catalog_returns_an_error(catalog);
5289 }
5290
5291 #[test]
5292 fn builder_proxy_rejects_mixed_vectors_when_only_final_tools_are_added() {
5293 let mut catalog = legacy_proxy_catalog();
5294 catalog.final_tools = final_proxy_catalog().final_tools;
5295
5296 assert_rejected_proxy_catalog_returns_an_error(catalog);
5297 }
5298
5299 #[test]
5300 fn builder_proxy_rejects_a_missing_marker() {
5301 let mut catalog = legacy_proxy_catalog();
5302 catalog.tool_catalog_era = None;
5303
5304 assert_rejected_proxy_catalog_returns_an_error(catalog);
5305 }
5306
5307 #[test]
5308 fn public_builder_proxy_advertises_and_lists_the_exact_final_tool_catalog() {
5309 let catalog = final_proxy_catalog();
5310 let expected = serde_json::to_value(&catalog.final_tools[0])
5311 .expect("the final fixture serializes");
5312 let server = ServerBuilder::new("srv", "1.0")
5313 .proxy(final_catalog_proxy_client(ProtocolEra::Modern2026), catalog)
5314 .expect("the final catalog agrees with its bound route")
5315 .build();
5316
5317 assert!(server.has_tools());
5318 assert!(server.tools().is_empty());
5319 let discovery = serde_json::to_value(
5320 server
5321 .server_discovery()
5322 .expect("the public final proxy server is discoverable"),
5323 )
5324 .expect("final discovery serializes");
5325 assert_eq!(
5326 discovery.pointer("/capabilities/tools"),
5327 Some(&serde_json::json!({}))
5328 );
5329 let inbound = crate::InboundRequestContext::new(
5330 Cx::for_testing(),
5331 701,
5332 crate::InboundRequestTransport::Memory,
5333 );
5334 let response = server
5335 .dispatch_stateless(&inbound, &final_tools_list_request(701))
5336 .expect("the public server path responds to the modern tools/list request");
5337 assert!(response.error.is_none());
5338 let catalog = response
5339 .result
5340 .expect("the modern tools/list response has a result payload");
5341 assert_eq!(catalog["tools"][0], expected);
5342 }
5343
5344 #[test]
5345 fn public_builder_proxy_final_catalog_absence_changes_only_tool_advertisement_and_registry()
5346 {
5347 let mut catalog = final_proxy_catalog();
5348 let configured_tool_count = catalog.final_tools.len();
5349 catalog.final_tools.clear();
5350 assert_eq!(
5351 configured_tool_count, 1,
5352 "the planted negative removes only the final tool catalog entry"
5353 );
5354
5355 let server = ServerBuilder::new("srv", "1.0")
5356 .proxy(final_catalog_proxy_client(ProtocolEra::Modern2026), catalog)
5357 .expect("an otherwise identical empty final catalog remains admissible")
5358 .build();
5359
5360 assert!(!server.has_tools());
5361 let discovery = serde_json::to_value(
5362 server
5363 .server_discovery()
5364 .expect("the empty final proxy server remains discoverable"),
5365 )
5366 .expect("empty final discovery serializes");
5367 assert!(discovery.pointer("/capabilities/tools").is_none());
5368 let inbound = crate::InboundRequestContext::new(
5369 Cx::for_testing(),
5370 702,
5371 crate::InboundRequestTransport::Memory,
5372 );
5373 let response = server
5374 .dispatch_stateless(&inbound, &final_tools_list_request(702))
5375 .expect("the public final tools/list path responds for an empty catalog");
5376 assert_eq!(response.result, Some(serde_json::json!({"tools": []})));
5377 assert!(
5378 server.tools().is_empty(),
5379 "removing only the final catalog entry leaves no legacy registry mutation"
5380 );
5381 }
5382
5383 #[cfg(feature = "tasks")]
5384 #[test]
5385 fn public_builder_proxy_relays_modern_tasks_input_required_and_listener() {
5386 let calls = Arc::new(Mutex::new(Vec::new()));
5387 let updates = Arc::new(Mutex::new(Vec::new()));
5388 let proxy = ordinary_proxy_tasks_client(Arc::clone(&calls), Arc::clone(&updates));
5389 let server = Arc::new(
5390 ServerBuilder::new("ordinary-proxy-tasks", "1.0")
5391 .proxy(proxy.clone(), final_proxy_catalog())
5392 .expect("the ordinary public proxy path installs the admitted modern route")
5393 .build(),
5394 );
5395 let discovery = serde_json::to_value(
5396 server
5397 .server_discovery()
5398 .expect("the Tasks proxy is publicly discoverable"),
5399 )
5400 .expect("Tasks proxy discovery serializes");
5401 assert_eq!(
5402 discovery.pointer("/capabilities/extensions/io.modelcontextprotocol~1tasks"),
5403 Some(&serde_json::json!({})),
5404 "the ordinary proxy path advertises the same Tasks extension as the typed path"
5405 );
5406
5407 let cx = Cx::for_testing();
5408 let connection = crate::ModernConnection::new();
5409 let emitted_notifications = Arc::new(Mutex::new(Vec::<JsonRpcRequest>::new()));
5410 let notification_sender: crate::NotificationSender = {
5411 let emitted_notifications = Arc::clone(&emitted_notifications);
5412 Arc::new(move |notification| {
5413 emitted_notifications
5414 .lock()
5415 .expect("ordinary proxy emitted notification log is not poisoned")
5416 .push(notification);
5417 })
5418 };
5419 let dispatch_modern = |request_id, request| {
5420 let inbound = crate::InboundRequestContext::with_modern_connection(
5421 cx.clone(),
5422 request_id,
5423 crate::InboundRequestTransport::Memory,
5424 &connection,
5425 );
5426 block_on(Arc::clone(&server).dispatch_with_protocol_policy_owned(
5427 server.protocol_policy,
5428 &inbound,
5429 request,
5430 None,
5431 None,
5432 None,
5433 None,
5434 fastmcp_core::McpRequestCancellation::new(),
5435 None,
5436 Arc::clone(¬ification_sender),
5437 ))
5438 .expect("public ordinary proxy request receives a JSON-RPC response")
5439 };
5440 let task_parameters = serde_json::json!({
5441 "_meta": {
5442 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
5443 "io.modelcontextprotocol/clientCapabilities": {
5444 "extensions": {"io.modelcontextprotocol/tasks": {}}
5445 },
5446 },
5447 "name": "weather",
5448 "arguments": {"outcome": "task"},
5449 });
5450 let task = dispatch_modern(
5451 703,
5452 JsonRpcRequest::new("tools/call", Some(task_parameters.clone()), 703_i64),
5453 );
5454 assert_eq!(
5455 task.result
5456 .as_ref()
5457 .and_then(|result| result.get("resultType")),
5458 Some(&serde_json::json!("task"))
5459 );
5460 assert_eq!(
5461 task.result.as_ref().and_then(|result| result.get("taskId")),
5462 Some(&serde_json::json!("ordinary-proxy-task-71"))
5463 );
5464 let relay_after_task = proxy
5465 .final_task_registry_snapshot_for_test()
5466 .expect("the retained ordinary proxy exposes its route-local Task snapshot");
5467 assert_eq!(
5468 relay_after_task.pointer("/tasks/ordinary-proxy-task-71/status"),
5469 Some(&serde_json::json!("input_required")),
5470 "the public tools/call task result is retained by the ordinary route-local relay"
5471 );
5472
5473 let update = dispatch_modern(
5474 704,
5475 JsonRpcRequest::new(
5476 "tasks/update",
5477 Some(serde_json::json!({
5478 "taskId": "ordinary-proxy-task-71",
5479 "inputResponses": {},
5480 "_meta": task_parameters["_meta"].clone(),
5481 })),
5482 704_i64,
5483 ),
5484 );
5485 assert_eq!(
5486 update
5487 .result
5488 .as_ref()
5489 .and_then(|result| result.get("resultType")),
5490 Some(&serde_json::json!("complete")),
5491 "the public Tasks update preserves the upstream final result algebra"
5492 );
5493 assert_eq!(
5494 updates
5495 .lock()
5496 .expect("ordinary proxy task update receipt is not poisoned")
5497 .as_slice(),
5498 [serde_json::json!({})],
5499 "the public Tasks update reaches the upstream backend through the local relay"
5500 );
5501 assert_eq!(
5502 calls
5503 .lock()
5504 .expect("ordinary proxy task call log is not poisoned")
5505 .as_slice(),
5506 ["tools/call:weather".to_owned(), "tasks/update".to_owned()],
5507 "the public update reaches the selected upstream only after local relay admission"
5508 );
5509
5510 let mut input_required_parameters = task_parameters.clone();
5511 input_required_parameters["arguments"] = serde_json::json!({});
5512 let listened = dispatch_modern(
5513 705,
5514 JsonRpcRequest::new(
5515 "subscriptions/listen",
5516 Some(serde_json::json!({
5517 "notifications": {"taskIds": []},
5518 "_meta": task_parameters["_meta"].clone(),
5519 })),
5520 705_i64,
5521 ),
5522 );
5523 assert_eq!(
5524 listened
5525 .result
5526 .as_ref()
5527 .and_then(|result| result.get("resultType")),
5528 Some(&serde_json::json!("complete"))
5529 );
5530 let emitted_before_rejection = emitted_notifications
5531 .lock()
5532 .expect("ordinary proxy emitted notification log is not poisoned")
5533 .clone();
5534 assert_eq!(
5535 emitted_before_rejection.len(),
5536 1,
5537 "the public listener emits its acknowledgement before terminal completion"
5538 );
5539 assert_eq!(
5540 emitted_before_rejection
5541 .first()
5542 .map(|notification| notification.method.as_str()),
5543 Some("notifications/subscriptions/acknowledged"),
5544 "the captured local notification is the listener acknowledgement"
5545 );
5546 let calls_before_rejection = calls
5547 .lock()
5548 .expect("ordinary proxy task call log is not poisoned")
5549 .clone();
5550 let relay_before_rejection = proxy
5551 .final_task_registry_snapshot_for_test()
5552 .expect("the ordinary proxy keeps its local final Task registry");
5553 let subscriptions_before_rejection = server.final_subscription_snapshot_for_test();
5554 let mut missing_capability = task_parameters;
5555 assert!(
5556 missing_capability
5557 .pointer_mut("/_meta/io.modelcontextprotocol~1clientCapabilities/extensions")
5558 .and_then(serde_json::Value::as_object_mut)
5559 .expect("the admitted request has an extension map")
5560 .remove("io.modelcontextprotocol/tasks")
5561 .is_some(),
5562 "the RH-5 negative changes only the client Tasks capability"
5563 );
5564 let rejected = dispatch_modern(
5565 706,
5566 JsonRpcRequest::new("tools/call", Some(missing_capability), 706_i64),
5567 );
5568 assert!(rejected.error.is_some());
5569 assert_eq!(
5570 calls
5571 .lock()
5572 .expect("ordinary proxy task call log is not poisoned")
5573 .clone(),
5574 calls_before_rejection,
5575 "the one-field capability rejection must not invoke or mutate the upstream route"
5576 );
5577 assert_eq!(
5578 proxy
5579 .final_task_registry_snapshot_for_test()
5580 .expect("the ordinary proxy keeps its local final Task registry"),
5581 relay_before_rejection,
5582 "the one-field capability rejection must not mutate the local relay task registry"
5583 );
5584 assert_eq!(
5585 server.final_subscription_snapshot_for_test(),
5586 subscriptions_before_rejection,
5587 "the one-field capability rejection must not mutate local subscription delivery state"
5588 );
5589 assert_eq!(
5590 serde_json::to_value(
5591 emitted_notifications
5592 .lock()
5593 .expect("ordinary proxy emitted notification log is not poisoned")
5594 .clone()
5595 )
5596 .expect("emitted notification log serializes"),
5597 serde_json::to_value(emitted_before_rejection)
5598 .expect("baseline notification log serializes"),
5599 "the one-field capability rejection must not emit or alter local notification state"
5600 );
5601
5602 let input_required = dispatch_modern(
5603 707,
5604 JsonRpcRequest::new("tools/call", Some(input_required_parameters), 707_i64),
5605 );
5606 assert_eq!(
5607 input_required
5608 .result
5609 .as_ref()
5610 .and_then(|result| result.get("resultType")),
5611 Some(&serde_json::json!("input_required"))
5612 );
5613 assert_ne!(
5614 input_required
5615 .result
5616 .as_ref()
5617 .and_then(|result| result.get("requestState")),
5618 Some(&serde_json::json!("upstream-forged-state")),
5619 "the downstream router must mint rather than replay upstream MRTR state"
5620 );
5621
5622 assert_eq!(
5623 calls
5624 .lock()
5625 .expect("ordinary proxy task call log is not poisoned")
5626 .as_slice(),
5627 [
5628 "tools/call:weather".to_owned(),
5629 "tasks/update".to_owned(),
5630 "subscriptions/listen".to_owned(),
5631 "tools/call:weather".to_owned(),
5632 ],
5633 "public task update, listener, and input_required requests stay on the one admitted modern upstream"
5634 );
5635 }
5636
5637 #[test]
5638 fn builder_proxy_dual_era_preserves_final_catalog_and_routes_bound_calls() {
5639 let expected_final_tool = serde_json::to_value(&final_proxy_catalog().final_tools[0])
5640 .expect("the final fixture serializes");
5641 let (server, legacy_calls, final_calls) = dual_era_proxy_server();
5642 let mut legacy_session = initialized_legacy_proxy_session(&server);
5643 let notification_sender: crate::NotificationSender = Arc::new(|_| {});
5644 let request_sender = crate::RequestSender::new(
5645 Arc::new(crate::PendingRequests::new()),
5646 Arc::new(|message| {
5647 Err(format!("unexpected outbound message in test: {message:?}"))
5648 }),
5649 );
5650
5651 let legacy_catalog = server
5652 .dispatch_request(
5653 &Cx::for_testing(),
5654 &mut legacy_session,
5655 JsonRpcRequest::new("tools/list", Some(serde_json::json!({})), 801_i64),
5656 ¬ification_sender,
5657 &request_sender,
5658 )
5659 .expect("the legacy tools/list request receives a response")
5660 .result
5661 .expect("the legacy tools/list response has a result payload");
5662 assert_eq!(legacy_catalog["tools"].as_array().map(Vec::len), Some(1));
5663 assert_eq!(legacy_catalog["tools"][0]["name"], "legacy-weather");
5664
5665 let final_inbound = crate::InboundRequestContext::new(
5666 Cx::for_testing(),
5667 802,
5668 crate::InboundRequestTransport::Memory,
5669 );
5670 let final_catalog = server
5671 .dispatch_stateless(&final_inbound, &final_tools_list_request(802_i64))
5672 .expect("the final tools/list request receives a response")
5673 .result
5674 .expect("the final tools/list response has a result payload");
5675 assert_eq!(final_catalog["tools"].as_array().map(Vec::len), Some(1));
5676 assert_eq!(final_catalog["tools"][0]["name"], "weather");
5677 assert_eq!(
5678 serde_json::to_vec(&final_catalog["tools"][0])
5679 .expect("the emitted final tool normalizes to JSON"),
5680 serde_json::to_vec(&expected_final_tool)
5681 .expect("the exact final fixture normalizes to JSON"),
5682 "the final proxy path retains the full normalized FinalTool model"
5683 );
5684
5685 let legacy_call = server
5686 .dispatch_request(
5687 &Cx::for_testing(),
5688 &mut legacy_session,
5689 JsonRpcRequest::new(
5690 "tools/call",
5691 Some(serde_json::json!({
5692 "name": "legacy-weather",
5693 "arguments": {"city": "Portland"},
5694 })),
5695 803_i64,
5696 ),
5697 ¬ification_sender,
5698 &request_sender,
5699 )
5700 .expect("the legacy tools/call request receives a response")
5701 .result
5702 .expect("the legacy tools/call response has a result payload");
5703 assert_eq!(legacy_call["content"][0]["text"], "bound legacy proxy");
5704
5705 let final_call_inbound = crate::InboundRequestContext::new(
5706 Cx::for_testing(),
5707 804,
5708 crate::InboundRequestTransport::Memory,
5709 );
5710 let final_call = server
5711 .dispatch_stateless(
5712 &final_call_inbound,
5713 &final_tools_call_request(
5714 "weather",
5715 serde_json::json!({"city": "Boston"}),
5716 804,
5717 ),
5718 )
5719 .expect("the final tools/call request receives a response")
5720 .result
5721 .expect("the final tools/call response has a result payload");
5722 assert_eq!(final_call["resultType"], "complete");
5723 assert_eq!(final_call["content"][0]["text"], "bound final proxy");
5724 assert_eq!(
5725 final_call["structuredContent"],
5726 serde_json::json!({"route": "final"})
5727 );
5728
5729 assert_eq!(
5730 legacy_calls
5731 .lock()
5732 .expect("the test call log lock is not poisoned")
5733 .clone(),
5734 vec![(
5735 "legacy-weather".to_owned(),
5736 serde_json::json!({"city": "Portland"}),
5737 )],
5738 "the legacy request reaches only its bound legacy upstream"
5739 );
5740 assert_eq!(
5741 final_calls
5742 .lock()
5743 .expect("the test call log lock is not poisoned")
5744 .clone(),
5745 vec![("weather".to_owned(), serde_json::json!({"city": "Boston"}))],
5746 "the final request reaches only its bound final upstream"
5747 );
5748 }
5749
5750 #[test]
5751 fn builder_proxy_dual_era_rejects_cross_era_names_without_upstream_calls() {
5752 let (server, legacy_calls, final_calls) = dual_era_proxy_server();
5753 let mut legacy_session = initialized_legacy_proxy_session(&server);
5754 let notification_sender: crate::NotificationSender = Arc::new(|_| {});
5755 let request_sender = crate::RequestSender::new(
5756 Arc::new(crate::PendingRequests::new()),
5757 Arc::new(|message| {
5758 Err(format!("unexpected outbound message in test: {message:?}"))
5759 }),
5760 );
5761
5762 let legacy_rejected = server
5763 .dispatch_request(
5764 &Cx::for_testing(),
5765 &mut legacy_session,
5766 JsonRpcRequest::new(
5767 "tools/call",
5768 Some(serde_json::json!({
5769 "name": "weather",
5770 "arguments": {},
5771 })),
5772 805_i64,
5773 ),
5774 ¬ification_sender,
5775 &request_sender,
5776 )
5777 .expect("the legacy cross-era request receives a JSON-RPC error");
5778 assert!(legacy_rejected.result.is_none());
5779 assert_eq!(
5780 legacy_rejected.error.and_then(|error| error.code.as_i32()),
5781 Some(-32601),
5782 "the final-only name is absent from the legacy call route"
5783 );
5784
5785 let final_inbound = crate::InboundRequestContext::new(
5786 Cx::for_testing(),
5787 806,
5788 crate::InboundRequestTransport::Memory,
5789 );
5790 let final_rejected = server
5791 .dispatch_stateless(
5792 &final_inbound,
5793 &final_tools_call_request("legacy-weather", serde_json::json!({}), 806),
5794 )
5795 .expect("the final cross-era request receives a JSON-RPC error");
5796 assert!(final_rejected.result.is_none());
5797 assert_eq!(
5798 final_rejected.error.and_then(|error| error.code.as_i32()),
5799 Some(-32602),
5800 "the legacy-only name is absent from the final call route"
5801 );
5802
5803 assert!(
5804 legacy_calls
5805 .lock()
5806 .expect("the test call log lock is not poisoned")
5807 .is_empty(),
5808 "the final-only name must be rejected before the legacy upstream is called"
5809 );
5810 assert!(
5811 final_calls
5812 .lock()
5813 .expect("the test call log lock is not poisoned")
5814 .is_empty(),
5815 "the legacy-only name must be rejected before the final upstream is called"
5816 );
5817 }
5818
5819 #[test]
5820 fn public_proxy_completion_forwards_exact_legacy_and_final_results() {
5821 let legacy_calls = Arc::new(Mutex::new(Vec::new()));
5822 let final_calls = Arc::new(Mutex::new(Vec::new()));
5823 let server = ServerBuilder::new("completion-proxy", "1.0")
5824 .proxy_typed(
5825 bound_proxy_client(
5826 CompletionProxyBackend {
5827 supported: true,
5828 result: legacy_completion_proxy_result(),
5829 calls: Arc::clone(&legacy_calls),
5830 },
5831 ProtocolEra::Legacy2024,
5832 ),
5833 legacy_completion_proxy_catalog(),
5834 )
5835 .expect("the exact legacy completion proxy installs")
5836 .proxy_typed(
5837 bound_proxy_client(
5838 CompletionProxyBackend {
5839 supported: true,
5840 result: final_completion_proxy_result(),
5841 calls: Arc::clone(&final_calls),
5842 },
5843 ProtocolEra::Modern2026,
5844 ),
5845 final_completion_proxy_catalog(),
5846 )
5847 .expect("the exact final completion proxy installs")
5848 .build();
5849 assert!(
5850 server.capabilities().completions.is_some(),
5851 "installing a proxied completion provider must advertise initialize completions"
5852 );
5853
5854 let mut legacy_session = initialized_legacy_proxy_session(&server);
5855 let notification_sender: crate::NotificationSender = Arc::new(|_| {});
5856 let request_sender = crate::RequestSender::new(
5857 Arc::new(crate::PendingRequests::new()),
5858 Arc::new(|message| {
5859 Err(format!("unexpected outbound message in test: {message:?}"))
5860 }),
5861 );
5862 let legacy = server
5863 .dispatch_request(
5864 &Cx::for_testing(),
5865 &mut legacy_session,
5866 JsonRpcRequest::new(
5867 "completion/complete",
5868 Some(serde_json::json!({
5869 "ref": {"type": "ref/prompt", "name": "legacy-deploy"},
5870 "argument": {"name": "environment", "value": "sta"},
5871 })),
5872 821_i64,
5873 ),
5874 ¬ification_sender,
5875 &request_sender,
5876 )
5877 .expect("the public exact-2024 completion path returns a response")
5878 .result
5879 .expect("the exact-2024 completion response has a result payload");
5880 assert_eq!(
5881 legacy["completion"]["values"],
5882 serde_json::json!(["legacy-staging"])
5883 );
5884
5885 let final_inbound = crate::InboundRequestContext::new(
5886 Cx::for_testing(),
5887 822,
5888 crate::InboundRequestTransport::Memory,
5889 );
5890 let final_response = server
5891 .dispatch_stateless(&final_inbound, &final_completion_request(822))
5892 .expect("the public final completion path returns a response")
5893 .result
5894 .expect("the final completion response has a result payload");
5895 assert_eq!(final_response["resultType"], "complete");
5896 assert_eq!(
5897 final_response["completion"]["values"],
5898 serde_json::json!(["final-staging"])
5899 );
5900 assert_eq!(
5901 final_response["completion"]["total"],
5902 serde_json::json!(92233720368547758081234567890_u128),
5903 "the proxy retains the final arbitrary-precision completion total"
5904 );
5905
5906 let legacy_calls = legacy_calls
5907 .lock()
5908 .expect("legacy completion call log is not poisoned");
5909 assert_eq!(legacy_calls.len(), 1);
5910 assert!(matches!(
5911 &legacy_calls[0].reference,
5912 fastmcp_client::CompletionReference::Prompt { name } if name == "legacy-deploy"
5913 ));
5914 assert!(legacy_calls[0].context.is_none());
5915 let final_calls = final_calls
5916 .lock()
5917 .expect("final completion call log is not poisoned");
5918 assert_eq!(final_calls.len(), 1);
5919 assert!(matches!(
5920 &final_calls[0].reference,
5921 fastmcp_client::CompletionReference::PromptWithTitle { name, title }
5922 if name == "final-deploy" && title == "Final Deploy"
5923 ));
5924 assert_eq!(
5925 final_calls[0]
5926 .context
5927 .as_ref()
5928 .and_then(|context| context.arguments.as_ref())
5929 .and_then(|arguments| arguments.get("region")),
5930 Some(&"us-east-1".to_owned()),
5931 "the proxy forwards final completion context unchanged"
5932 );
5933 }
5934
5935 #[test]
5936 fn public_proxy_completion_unsupported_upstream_rejects_without_downstream_mutation() {
5937 let calls = Arc::new(Mutex::new(Vec::new()));
5938 let server = ServerBuilder::new("completion-proxy", "1.0")
5939 .proxy_typed(
5940 bound_proxy_client(
5941 CompletionProxyBackend {
5942 supported: false,
5943 result: final_completion_proxy_result(),
5944 calls: Arc::clone(&calls),
5945 },
5946 ProtocolEra::Modern2026,
5947 ),
5948 final_completion_proxy_catalog(),
5949 )
5950 .expect("changing only upstream completion support preserves catalog registration")
5951 .build();
5952 assert!(
5953 server.capabilities().completions.is_none(),
5954 "an unsupported upstream must not advertise initialize completions"
5955 );
5956 let discovery = serde_json::to_value(
5957 server
5958 .server_discovery()
5959 .expect("the final proxy server remains discoverable"),
5960 )
5961 .expect("discovery serializes");
5962 assert!(
5963 discovery["capabilities"].get("completions").is_none(),
5964 "an unsupported upstream must not create a downstream completion claim"
5965 );
5966
5967 let before_inbound = crate::InboundRequestContext::new(
5968 Cx::for_testing(),
5969 823,
5970 crate::InboundRequestTransport::Memory,
5971 );
5972 let before = server
5973 .dispatch_stateless(
5974 &before_inbound,
5975 &JsonRpcRequest::new(
5976 "prompts/list",
5977 Some(serde_json::json!({
5978 "_meta": {
5979 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
5980 "io.modelcontextprotocol/clientCapabilities": {},
5981 },
5982 })),
5983 823_i64,
5984 ),
5985 )
5986 .expect("the proxied final prompt catalog is public before rejection")
5987 .result
5988 .expect("the proxied final prompt catalog has a result payload");
5989 let rejected_inbound = crate::InboundRequestContext::new(
5990 Cx::for_testing(),
5991 824,
5992 crate::InboundRequestTransport::Memory,
5993 );
5994 let rejected = server
5995 .dispatch_stateless(&rejected_inbound, &final_completion_request(824))
5996 .expect("the unsupported completion request receives a JSON-RPC error");
5997 assert_eq!(
5998 rejected.error.and_then(|error| error.code.as_i32()),
5999 Some(-32601),
6000 "the absent local completion handler rejects before an upstream call"
6001 );
6002 assert!(rejected.result.is_none());
6003 let after_inbound = crate::InboundRequestContext::new(
6004 Cx::for_testing(),
6005 825,
6006 crate::InboundRequestTransport::Memory,
6007 );
6008 let after = server
6009 .dispatch_stateless(
6010 &after_inbound,
6011 &JsonRpcRequest::new(
6012 "prompts/list",
6013 Some(serde_json::json!({
6014 "_meta": {
6015 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6016 "io.modelcontextprotocol/clientCapabilities": {},
6017 },
6018 })),
6019 825_i64,
6020 ),
6021 )
6022 .expect("the proxied final prompt catalog remains public after rejection")
6023 .result
6024 .expect("the proxied final prompt catalog still has a result payload");
6025 assert_eq!(
6026 before, after,
6027 "the rejected request cannot mutate downstream catalog state"
6028 );
6029 assert!(
6030 calls
6031 .lock()
6032 .expect("completion proxy call log is not poisoned")
6033 .is_empty(),
6034 "the unsupported upstream is never invoked"
6035 );
6036 }
6037
6038 #[test]
6039 fn public_proxy_completion_duplicate_local_target_keeps_no_upstream_mapping() {
6040 for duplicate_behavior in [DuplicateBehavior::Warn, DuplicateBehavior::Ignore] {
6041 let calls = Arc::new(Mutex::new(Vec::new()));
6042 let server = ServerBuilder::new("completion-proxy", "1.0")
6043 .prompt(LocalFinalCompletionPrompt)
6044 .on_duplicate(duplicate_behavior)
6045 .proxy_typed(
6046 bound_proxy_client(
6047 CompletionProxyBackend {
6048 supported: true,
6049 result: final_completion_proxy_result(),
6050 calls: Arc::clone(&calls),
6051 },
6052 ProtocolEra::Modern2026,
6053 ),
6054 final_completion_proxy_catalog(),
6055 )
6056 .expect("retaining the local final prompt is a successful duplicate admission")
6057 .build();
6058
6059 let discovery = serde_json::to_value(
6060 server
6061 .server_discovery()
6062 .expect("the retained local prompt server remains discoverable"),
6063 )
6064 .expect("discovery serializes");
6065 assert!(
6066 discovery["capabilities"].get("completions").is_none(),
6067 "{duplicate_behavior:?} must not advertise an upstream provider for a retained local target"
6068 );
6069
6070 let before_inbound = crate::InboundRequestContext::new(
6071 Cx::for_testing(),
6072 826,
6073 crate::InboundRequestTransport::Memory,
6074 );
6075 let before = server
6076 .dispatch_stateless(
6077 &before_inbound,
6078 &JsonRpcRequest::new(
6079 "prompts/list",
6080 Some(serde_json::json!({
6081 "_meta": {
6082 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6083 "io.modelcontextprotocol/clientCapabilities": {},
6084 },
6085 })),
6086 826_i64,
6087 ),
6088 )
6089 .expect("the retained local prompt is visible through the public final list")
6090 .result
6091 .expect("the retained local prompt list has a result payload");
6092 assert_eq!(
6093 before["prompts"][0]["title"], "Local Final Deploy",
6094 "{duplicate_behavior:?} retains the pre-existing local prompt target"
6095 );
6096
6097 let rejected_inbound = crate::InboundRequestContext::new(
6098 Cx::for_testing(),
6099 827,
6100 crate::InboundRequestTransport::Memory,
6101 );
6102 let rejected = server
6103 .dispatch_stateless(&rejected_inbound, &final_completion_request(827))
6104 .expect("the absent completion mapping returns a JSON-RPC error");
6105 assert_eq!(
6106 rejected.error.and_then(|error| error.code.as_i32()),
6107 Some(-32601),
6108 "{duplicate_behavior:?} rejects before invocation because no upstream mapping was installed"
6109 );
6110 assert!(rejected.result.is_none());
6111
6112 let after_inbound = crate::InboundRequestContext::new(
6113 Cx::for_testing(),
6114 828,
6115 crate::InboundRequestTransport::Memory,
6116 );
6117 let after = server
6118 .dispatch_stateless(
6119 &after_inbound,
6120 &JsonRpcRequest::new(
6121 "prompts/list",
6122 Some(serde_json::json!({
6123 "_meta": {
6124 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6125 "io.modelcontextprotocol/clientCapabilities": {},
6126 },
6127 })),
6128 828_i64,
6129 ),
6130 )
6131 .expect("the rejected request leaves the retained local target public")
6132 .result
6133 .expect("the retained local prompt list still has a result payload");
6134 assert_eq!(
6135 before, after,
6136 "{duplicate_behavior:?} completion rejection cannot mutate the retained local target"
6137 );
6138 assert!(
6139 calls
6140 .lock()
6141 .expect("completion proxy call log is not poisoned")
6142 .is_empty(),
6143 "{duplicate_behavior:?} must not invoke the upstream completion backend"
6144 );
6145 }
6146 }
6147
6148 #[test]
6149 fn public_proxy_completion_replace_installs_upstream_mapping_for_replaced_target() {
6150 let calls = Arc::new(Mutex::new(Vec::new()));
6151 let server = ServerBuilder::new("completion-proxy", "1.0")
6152 .prompt(LocalFinalCompletionPrompt)
6153 .on_duplicate(DuplicateBehavior::Replace)
6154 .proxy_typed(
6155 bound_proxy_client(
6156 CompletionProxyBackend {
6157 supported: true,
6158 result: final_completion_proxy_result(),
6159 calls: Arc::clone(&calls),
6160 },
6161 ProtocolEra::Modern2026,
6162 ),
6163 final_completion_proxy_catalog(),
6164 )
6165 .expect("Replace admits the exact upstream final prompt target")
6166 .build();
6167
6168 let discovery = serde_json::to_value(
6169 server
6170 .server_discovery()
6171 .expect("the replacement proxy server remains discoverable"),
6172 )
6173 .expect("discovery serializes");
6174 assert_eq!(
6175 discovery["capabilities"]["completions"],
6176 serde_json::json!({}),
6177 "the real upstream mapping advertises final completion support"
6178 );
6179
6180 let list_inbound = crate::InboundRequestContext::new(
6181 Cx::for_testing(),
6182 829,
6183 crate::InboundRequestTransport::Memory,
6184 );
6185 let prompts = server
6186 .dispatch_stateless(
6187 &list_inbound,
6188 &JsonRpcRequest::new(
6189 "prompts/list",
6190 Some(serde_json::json!({
6191 "_meta": {
6192 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6193 "io.modelcontextprotocol/clientCapabilities": {},
6194 },
6195 })),
6196 829_i64,
6197 ),
6198 )
6199 .expect("the replaced prompt is public through the final list")
6200 .result
6201 .expect("the replaced prompt list has a result payload");
6202 assert_eq!(
6203 prompts["prompts"][0]["title"], "Upstream Final Deploy",
6204 "Replace exposes the admitted upstream prompt instead of the local target"
6205 );
6206
6207 let completion_inbound = crate::InboundRequestContext::new(
6208 Cx::for_testing(),
6209 830,
6210 crate::InboundRequestTransport::Memory,
6211 );
6212 let completion = server
6213 .dispatch_stateless(&completion_inbound, &final_completion_request(830))
6214 .expect("the replaced target forwards through the public final completion route")
6215 .result
6216 .expect("the replaced target completion has a result payload");
6217 assert_eq!(
6218 completion["completion"]["values"],
6219 serde_json::json!(["final-staging"])
6220 );
6221 assert_eq!(
6222 calls
6223 .lock()
6224 .expect("completion proxy call log is not poisoned")
6225 .len(),
6226 1,
6227 "Replace installs exactly one upstream completion invocation path"
6228 );
6229 }
6230
6231 #[test]
6232 fn public_final_proxy_completion_replacement_evicts_proxy_targets_and_restores_local_providers()
6233 {
6234 let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6235 let template_calls = Arc::new(Mutex::new(Vec::new()));
6236 let evicted = ServerBuilder::new("completion-proxy", "1.0")
6237 .proxy_typed(
6238 bound_proxy_client(
6239 CompletionProxyBackend {
6240 supported: true,
6241 result: final_completion_proxy_result(),
6242 calls: Arc::clone(&prompt_calls),
6243 },
6244 ProtocolEra::Modern2026,
6245 ),
6246 final_completion_proxy_catalog(),
6247 )
6248 .expect("the final prompt proxy installs")
6249 .proxy_typed(
6250 bound_proxy_client(
6251 CompletionProxyBackend {
6252 supported: true,
6253 result: final_completion_proxy_result(),
6254 calls: Arc::clone(&template_calls),
6255 },
6256 ProtocolEra::Modern2026,
6257 ),
6258 final_completion_proxy_template_catalog(),
6259 )
6260 .expect("the final resource-template proxy installs")
6261 .on_duplicate(DuplicateBehavior::Replace)
6262 .prompt(LocalFinalCompletionPrompt)
6263 .resource_template(local_completion_template())
6264 .build();
6265
6266 let discovery = serde_json::to_value(
6267 evicted
6268 .server_discovery()
6269 .expect("the locally replaced catalog remains discoverable"),
6270 )
6271 .expect("discovery serializes");
6272 assert!(
6273 discovery["capabilities"].get("completions").is_none(),
6274 "replacing every proxied final target removes proxy completion advertisement"
6275 );
6276 for (id, request) in [
6277 (831, final_completion_request(831)),
6278 (832, final_resource_template_completion_request(832)),
6279 ] {
6280 let inbound = crate::InboundRequestContext::new(
6281 Cx::for_testing(),
6282 id,
6283 crate::InboundRequestTransport::Memory,
6284 );
6285 let rejected = evicted
6286 .dispatch_stateless(&inbound, &request)
6287 .expect("the evicted completion mapping returns a JSON-RPC error");
6288 assert_eq!(
6289 rejected.error.and_then(|error| error.code.as_i32()),
6290 Some(-32601),
6291 "the local replacement has no retained upstream completion provider"
6292 );
6293 assert!(rejected.result.is_none());
6294 }
6295 assert!(
6296 prompt_calls
6297 .lock()
6298 .expect("prompt completion proxy call log is not poisoned")
6299 .is_empty()
6300 );
6301 assert!(
6302 template_calls
6303 .lock()
6304 .expect("template completion proxy call log is not poisoned")
6305 .is_empty()
6306 );
6307
6308 let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6309 let template_calls = Arc::new(Mutex::new(Vec::new()));
6310 let restored = ServerBuilder::new("completion-proxy", "1.0")
6311 .proxy_typed(
6312 bound_proxy_client(
6313 CompletionProxyBackend {
6314 supported: true,
6315 result: final_completion_proxy_result(),
6316 calls: Arc::clone(&prompt_calls),
6317 },
6318 ProtocolEra::Modern2026,
6319 ),
6320 final_completion_proxy_catalog(),
6321 )
6322 .expect("the final prompt proxy installs")
6323 .proxy_typed(
6324 bound_proxy_client(
6325 CompletionProxyBackend {
6326 supported: true,
6327 result: final_completion_proxy_result(),
6328 calls: Arc::clone(&template_calls),
6329 },
6330 ProtocolEra::Modern2026,
6331 ),
6332 final_completion_proxy_template_catalog(),
6333 )
6334 .expect("the final resource-template proxy installs")
6335 .on_duplicate(DuplicateBehavior::Replace)
6336 .prompt(LocalFinalCompletionPrompt)
6337 .resource_template(local_completion_template())
6338 .prompt_completion_handler("final-deploy", TestCompletion)
6339 .resource_template_completion_handler("completion://{environment}", TestCompletion)
6340 .build();
6341 let discovery = serde_json::to_value(
6342 restored
6343 .server_discovery()
6344 .expect("the local completion providers make final discovery available"),
6345 )
6346 .expect("discovery serializes");
6347 assert_eq!(
6348 discovery["capabilities"]["completions"],
6349 serde_json::json!({}),
6350 "only the restored local final providers advertise completion support"
6351 );
6352 for (id, request) in [
6353 (833, final_completion_request(833)),
6354 (834, final_resource_template_completion_request(834)),
6355 ] {
6356 let inbound = crate::InboundRequestContext::new(
6357 Cx::for_testing(),
6358 id,
6359 crate::InboundRequestTransport::Memory,
6360 );
6361 let response = restored
6362 .dispatch_stateless(&inbound, &request)
6363 .expect("the local replacement provider handles the public final request")
6364 .result
6365 .expect("the local replacement provider returns a result payload");
6366 assert_eq!(
6367 response["completion"]["values"],
6368 serde_json::json!(["staging"])
6369 );
6370 }
6371 assert!(
6372 prompt_calls
6373 .lock()
6374 .expect("prompt completion proxy call log is not poisoned")
6375 .is_empty(),
6376 "local prompt providers must not invoke the displaced proxy"
6377 );
6378 assert!(
6379 template_calls
6380 .lock()
6381 .expect("template completion proxy call log is not poisoned")
6382 .is_empty(),
6383 "local template providers must not invoke the displaced proxy"
6384 );
6385 }
6386
6387 #[test]
6388 fn public_legacy_proxy_completion_replacement_evicts_proxy_targets_and_restores_local_provider()
6389 {
6390 let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6391 let template_calls = Arc::new(Mutex::new(Vec::new()));
6392 let evicted = ServerBuilder::new("completion-proxy", "1.0")
6393 .proxy_typed(
6394 bound_proxy_client(
6395 CompletionProxyBackend {
6396 supported: true,
6397 result: legacy_completion_proxy_result(),
6398 calls: Arc::clone(&prompt_calls),
6399 },
6400 ProtocolEra::Legacy2024,
6401 ),
6402 legacy_completion_proxy_catalog(),
6403 )
6404 .expect("the legacy prompt proxy installs")
6405 .proxy_typed(
6406 bound_proxy_client(
6407 CompletionProxyBackend {
6408 supported: true,
6409 result: legacy_completion_proxy_result(),
6410 calls: Arc::clone(&template_calls),
6411 },
6412 ProtocolEra::Legacy2024,
6413 ),
6414 legacy_completion_proxy_template_catalog(),
6415 )
6416 .expect("the legacy resource-template proxy installs")
6417 .on_duplicate(DuplicateBehavior::Replace)
6418 .legacy_prompt(LocalFinalCompletionPrompt)
6419 .legacy_resource_template(local_completion_template())
6420 .build();
6421 let mut legacy_session = initialized_legacy_proxy_session(&evicted);
6422 let notification_sender: crate::NotificationSender = Arc::new(|_| {});
6423 let request_sender = crate::RequestSender::new(
6424 Arc::new(crate::PendingRequests::new()),
6425 Arc::new(|message| {
6426 Err(format!("unexpected outbound message in test: {message:?}"))
6427 }),
6428 );
6429 for request in [
6430 legacy_completion_request(
6431 835,
6432 serde_json::json!({"type": "ref/prompt", "name": "legacy-deploy"}),
6433 ),
6434 legacy_completion_request(
6435 836,
6436 serde_json::json!({"type": "ref/resource", "uri": "completion://{environment}"}),
6437 ),
6438 ] {
6439 let rejected = evicted
6440 .dispatch_request(
6441 &Cx::for_testing(),
6442 &mut legacy_session,
6443 request,
6444 ¬ification_sender,
6445 &request_sender,
6446 )
6447 .expect("the public replaced legacy target returns a JSON-RPC error");
6448 assert_eq!(
6449 rejected.error.and_then(|error| error.code.as_i32()),
6450 Some(-32601),
6451 "the local replacement has no retained upstream completion provider"
6452 );
6453 assert!(rejected.result.is_none());
6454 }
6455 assert!(
6456 prompt_calls
6457 .lock()
6458 .expect("prompt completion proxy call log is not poisoned")
6459 .is_empty()
6460 );
6461 assert!(
6462 template_calls
6463 .lock()
6464 .expect("template completion proxy call log is not poisoned")
6465 .is_empty()
6466 );
6467
6468 let prompt_calls = Arc::new(Mutex::new(Vec::new()));
6469 let template_calls = Arc::new(Mutex::new(Vec::new()));
6470 let restored = ServerBuilder::new("completion-proxy", "1.0")
6471 .proxy_typed(
6472 bound_proxy_client(
6473 CompletionProxyBackend {
6474 supported: true,
6475 result: legacy_completion_proxy_result(),
6476 calls: Arc::clone(&prompt_calls),
6477 },
6478 ProtocolEra::Legacy2024,
6479 ),
6480 legacy_completion_proxy_catalog(),
6481 )
6482 .expect("the legacy prompt proxy installs")
6483 .proxy_typed(
6484 bound_proxy_client(
6485 CompletionProxyBackend {
6486 supported: true,
6487 result: legacy_completion_proxy_result(),
6488 calls: Arc::clone(&template_calls),
6489 },
6490 ProtocolEra::Legacy2024,
6491 ),
6492 legacy_completion_proxy_template_catalog(),
6493 )
6494 .expect("the legacy resource-template proxy installs")
6495 .on_duplicate(DuplicateBehavior::Replace)
6496 .legacy_prompt(LocalFinalCompletionPrompt)
6497 .legacy_resource_template(local_completion_template())
6498 .legacy_completion_handler(TestCompletion)
6499 .build();
6500 let mut legacy_session = initialized_legacy_proxy_session(&restored);
6501 let notification_sender: crate::NotificationSender = Arc::new(|_| {});
6502 let request_sender = crate::RequestSender::new(
6503 Arc::new(crate::PendingRequests::new()),
6504 Arc::new(|message| {
6505 Err(format!("unexpected outbound message in test: {message:?}"))
6506 }),
6507 );
6508 for request in [
6509 legacy_completion_request(
6510 837,
6511 serde_json::json!({"type": "ref/prompt", "name": "legacy-deploy"}),
6512 ),
6513 legacy_completion_request(
6514 838,
6515 serde_json::json!({"type": "ref/resource", "uri": "completion://{environment}"}),
6516 ),
6517 ] {
6518 let response = restored
6519 .dispatch_request(
6520 &Cx::for_testing(),
6521 &mut legacy_session,
6522 request,
6523 ¬ification_sender,
6524 &request_sender,
6525 )
6526 .expect("the public local legacy provider returns a response")
6527 .result
6528 .expect("the local legacy provider returns a result payload");
6529 assert_eq!(
6530 response["completion"]["values"],
6531 serde_json::json!(["staging"])
6532 );
6533 }
6534 assert!(
6535 prompt_calls
6536 .lock()
6537 .expect("prompt completion proxy call log is not poisoned")
6538 .is_empty()
6539 );
6540 assert!(
6541 template_calls
6542 .lock()
6543 .expect("template completion proxy call log is not poisoned")
6544 .is_empty()
6545 );
6546 }
6547
6548 #[test]
6549 fn builder_proxy_rejects_final_tools_with_one_legacy_resource_vector() {
6550 let mut catalog = final_proxy_catalog();
6551 catalog.resources.push(Resource {
6552 uri: "file:///must-not-project".to_owned(),
6553 name: "must-not-project".to_owned(),
6554 description: None,
6555 mime_type: None,
6556 icon: None,
6557 version: None,
6558 tags: Vec::new(),
6559 });
6560
6561 assert_rejected_proxy_catalog_returns_an_error(catalog);
6562 }
6563
6564 #[test]
6565 fn public_legacy_and_stateless_ping_answer_empty_object_without_entering_the_final_request_union()
6566 {
6567 let server = ServerBuilder::new("srv", "1.0").build();
6568 let mut legacy_session =
6569 crate::Session::new(server.info().clone(), server.capabilities().clone());
6570 legacy_session.initialize(
6571 fastmcp_protocol::ClientInfo {
6572 name: "exact-2024-ping-client".to_owned(),
6573 version: "1.0".to_owned(),
6574 },
6575 fastmcp_protocol::ClientCapabilities::default(),
6576 "2024-11-05".to_owned(),
6577 );
6578 let state_before = legacy_session.state().len();
6579 let legacy_ping = JsonRpcRequest::new("ping", Some(serde_json::json!({})), 714_i64);
6580 let mut final_ping = legacy_ping.clone();
6581 final_ping
6582 .params
6583 .as_mut()
6584 .expect("the cloned ping request retains its object parameters")
6585 .as_object_mut()
6586 .expect("the cloned ping parameters remain an object")
6587 .insert(
6588 "_meta".to_owned(),
6589 serde_json::json!({
6590 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6591 "io.modelcontextprotocol/clientCapabilities": {},
6592 }),
6593 );
6594 let mut final_without_metadata = final_ping
6595 .params
6596 .clone()
6597 .expect("the final ping parameters are retained for the negative-control check");
6598 final_without_metadata
6599 .as_object_mut()
6600 .expect("the final ping parameters remain an object")
6601 .remove("_meta");
6602 assert_eq!(final_ping.method, legacy_ping.method);
6603 assert_eq!(final_ping.id, legacy_ping.id);
6604 assert_eq!(Some(final_without_metadata), legacy_ping.params);
6605
6606 let notification_sender: crate::NotificationSender = Arc::new(|_| {});
6607 let request_sender = crate::RequestSender::new(
6608 Arc::new(crate::PendingRequests::new()),
6609 Arc::new(|message| {
6610 Err(format!("unexpected outbound message in test: {message:?}"))
6611 }),
6612 );
6613 let legacy = server
6614 .dispatch_request(
6615 &Cx::for_testing(),
6616 &mut legacy_session,
6617 legacy_ping,
6618 ¬ification_sender,
6619 &request_sender,
6620 )
6621 .expect("the exact-2024 public dispatch path responds to ping");
6622 assert_eq!(legacy.result, Some(serde_json::json!({})));
6623 assert!(legacy.error.is_none());
6624
6625 let inbound = crate::InboundRequestContext::new(
6626 Cx::for_testing(),
6627 714,
6628 crate::InboundRequestTransport::Memory,
6629 );
6630 let answered = server
6631 .dispatch_stateless(&inbound, &final_ping)
6632 .expect("stateless modern ping is a connection health-check");
6633 assert_eq!(answered.result, Some(serde_json::json!({})));
6634 assert!(answered.error.is_none());
6635 assert_eq!(
6636 legacy_session.state().len(),
6637 state_before,
6638 "ping cannot mutate session state"
6639 );
6640
6641 assert!(
6642 !fastmcp_protocol::methods::FINAL_2026_07_28_METHODS
6643 .iter()
6644 .any(|method| method.name == "ping"),
6645 "ping must remain outside the official 2026 client-request union"
6646 );
6647 let decode = fastmcp_protocol::CoreRequest::decode(
6648 ProtocolEra::Modern2026,
6649 "ping",
6650 Some(&serde_json::json!({})),
6651 )
6652 .expect_err("FinalCoreRequest must not grow a Ping variant");
6653 assert!(matches!(
6654 decode,
6655 fastmcp_protocol::CoreDispatchError::UnsupportedMethod {
6656 era: ProtocolEra::Modern2026,
6657 method,
6658 } if method == "ping"
6659 ));
6660 }
6661
6662 #[test]
6663 fn typed_proxy_registration_retains_final_resource_template_and_prompt_metadata() {
6664 let resource = serde_json::json!({
6665 "uri": "mcp://upstream/resource",
6666 "name": "upstream-resource",
6667 "size": 4096,
6668 "_meta": {"com.example/resource": {"retained": true}}
6669 });
6670 let template = serde_json::json!({
6671 "uriTemplate": "mcp://upstream/{name}",
6672 "name": "upstream-template",
6673 "_meta": {"com.example/template": {"retained": true}}
6674 });
6675 let prompt = serde_json::json!({
6676 "name": "upstream-prompt",
6677 "arguments": [{"name": "region", "title": "Region"}],
6678 "_meta": {"com.example/prompt": {"retained": true}}
6679 });
6680 let typed = ProxyTypedCatalog {
6681 tools: ProxyToolCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
6682 resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(vec![
6683 serde_json::from_value(resource.clone())
6684 .expect("the final resource fixture is valid"),
6685 ])),
6686 resource_templates: ProxyResourceTemplateCatalog::Final(ProxyFinalCatalog::new(
6687 vec![
6688 serde_json::from_value(template.clone())
6689 .expect("the final resource-template fixture is valid"),
6690 ],
6691 )),
6692 prompts: ProxyPromptCatalog::Final(ProxyFinalCatalog::new(vec![
6693 serde_json::from_value(prompt.clone())
6694 .expect("the final prompt fixture is valid"),
6695 ])),
6696 };
6697 let server = ServerBuilder::new("srv", "1.0")
6698 .proxy_typed(
6699 bound_proxy_client(DuplicatePolicyProxyBackend, ProtocolEra::Modern2026),
6700 typed,
6701 )
6702 .expect("coherent typed catalog registers")
6703 .build();
6704 for (method, member, expected, id) in [
6705 ("resources/list", "resources", resource, 711_i64),
6706 (
6707 "resources/templates/list",
6708 "resourceTemplates",
6709 template,
6710 712_i64,
6711 ),
6712 ("prompts/list", "prompts", prompt, 713_i64),
6713 ] {
6714 let inbound = crate::InboundRequestContext::new(
6715 Cx::for_testing(),
6716 u64::try_from(id).expect("test request IDs are non-negative"),
6717 crate::InboundRequestTransport::Memory,
6718 );
6719 let response = server
6720 .dispatch_stateless(
6721 &inbound,
6722 &JsonRpcRequest::new(
6723 method,
6724 Some(serde_json::json!({
6725 "_meta": {
6726 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
6727 "io.modelcontextprotocol/clientCapabilities": {},
6728 },
6729 })),
6730 id,
6731 ),
6732 )
6733 .expect("final discovery dispatch succeeds");
6734 assert_eq!(
6735 response.result.expect("final discovery has a result")[member][0],
6736 expected,
6737 "final proxy registration must retain exact {member} metadata"
6738 );
6739 }
6740
6741 assert!(server.resources().is_empty());
6742 assert!(server.resource_templates().is_empty());
6743 assert!(server.prompts().is_empty());
6744 let router = server.into_router();
6745 let state = fastmcp_core::SessionState::new();
6746 let request_ctx =
6747 fastmcp_core::McpContext::with_state(Cx::for_testing(), 714, state.clone());
6748 let legacy_resource = router
6749 .handle_resources_read(
6750 &request_ctx,
6751 &fastmcp_protocol::ReadResourceParams {
6752 uri: "mcp://upstream/resource".to_owned(),
6753 meta: None,
6754 },
6755 state.clone(),
6756 None,
6757 None,
6758 )
6759 .expect_err("typed-final resource registration is not legacy-visible");
6760 assert_eq!(
6761 legacy_resource.code,
6762 fastmcp_core::McpErrorCode::ResourceNotFound
6763 );
6764 let legacy_prompt = router
6765 .handle_prompts_get(
6766 &request_ctx,
6767 fastmcp_protocol::GetPromptParams {
6768 name: "upstream-prompt".to_owned(),
6769 arguments: None,
6770 meta: None,
6771 },
6772 state,
6773 None,
6774 None,
6775 )
6776 .expect_err("typed-final prompt registration is not legacy-visible");
6777 assert_eq!(
6778 legacy_prompt.code,
6779 fastmcp_core::McpErrorCode::PromptNotFound
6780 );
6781 }
6782
6783 #[test]
6784 fn typed_proxy_registration_rejects_an_unbound_caller_asserted_final_catalog() {
6785 let error = match ServerBuilder::new("srv", "1.0").proxy_typed(
6786 ProxyClient::from_backend(DuplicatePolicyProxyBackend),
6787 final_completion_proxy_catalog(),
6788 ) {
6789 Ok(_) => {
6790 panic!("a caller-supplied typed catalog cannot bind an unbound proxy route")
6791 }
6792 Err(error) => error,
6793 };
6794
6795 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6796 assert!(error.message.contains("cannot bind an unbound route"));
6797 }
6798
6799 #[test]
6800 fn typed_proxy_registration_rejects_one_mixed_era_component_vector() {
6801 let typed = ProxyTypedCatalog {
6802 tools: ProxyToolCatalog::Legacy(Vec::new()),
6803 resources: ProxyResourceCatalog::Final(ProxyFinalCatalog::new(Vec::new())),
6804 resource_templates: ProxyResourceTemplateCatalog::Legacy(Vec::new()),
6805 prompts: ProxyPromptCatalog::Legacy(Vec::new()),
6806 };
6807 let error = match ServerBuilder::new("srv", "1.0").register_raw_typed_proxy_catalog(
6808 bound_proxy_client(DuplicatePolicyProxyBackend, ProtocolEra::Legacy2024),
6809 typed,
6810 ) {
6811 Err(error) => error,
6812 Ok(_) => {
6813 panic!("changing only resources to final rejects a mixed-era proxy catalog")
6814 }
6815 };
6816 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6817 }
6818
6819 #[test]
6820 fn builder_proxy_rejects_the_same_final_catalog_for_a_legacy_binding() {
6821 let error = match ServerBuilder::new("srv", "1.0").proxy(
6822 final_catalog_proxy_client(ProtocolEra::Legacy2024),
6823 final_proxy_catalog(),
6824 ) {
6825 Ok(_) => panic!("the catalog era cannot contradict the immutable route binding"),
6826 Err(error) => error,
6827 };
6828
6829 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6830 }
6831
6832 #[test]
6833 fn builder_proxy_with_catalog() {
6834 use crate::proxy::ProxyClient;
6835
6836 struct DummyBackend;
6837 impl crate::proxy::ProxyBackend for DummyBackend {
6838 fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
6839 Ok(vec![Tool {
6840 name: "proxy-tool".to_owned(),
6841 description: None,
6842 input_schema: serde_json::json!({}),
6843 output_schema: None,
6844 icon: None,
6845 version: None,
6846 tags: Vec::new(),
6847 annotations: None,
6848 }])
6849 }
6850 fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
6851 Ok(vec![])
6852 }
6853 fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
6854 Ok(vec![])
6855 }
6856 fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
6857 Ok(vec![])
6858 }
6859 fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
6860 Ok(vec![])
6861 }
6862 fn call_tool_with_progress(
6863 &mut self,
6864 _: &str,
6865 _: serde_json::Value,
6866 _: crate::proxy::ProgressCallback<'_>,
6867 ) -> McpResult<Vec<Content>> {
6868 Ok(vec![])
6869 }
6870 fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
6871 Ok(vec![])
6872 }
6873 fn get_prompt(
6874 &mut self,
6875 _: &str,
6876 _: std::collections::HashMap<String, String>,
6877 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
6878 Ok(vec![])
6879 }
6880 }
6881
6882 let client = ProxyClient::from_backend(DummyBackend);
6883 let catalog = client
6884 .catalog()
6885 .expect("backend discovery supplies the legacy era evidence");
6886
6887 let server = ServerBuilder::new("srv", "1.0")
6888 .proxy(client, catalog)
6889 .expect("the backend-observed catalog is admitted")
6890 .build();
6891 assert!(server.has_tools());
6892 }
6893
6894 #[test]
6895 fn builder_proxy_honors_duplicate_policy_for_every_component_kind() {
6896 for behavior in [
6897 DuplicateBehavior::Warn,
6898 DuplicateBehavior::Ignore,
6899 DuplicateBehavior::Error,
6900 ] {
6901 let (client, catalog) = discovered_duplicate_policy_proxy();
6902 let server = ServerBuilder::new("srv", "1.0")
6903 .on_duplicate(behavior)
6904 .tool(TestTool)
6905 .resource(TestResource)
6906 .prompt(TestPrompt)
6907 .proxy(client, catalog)
6908 .expect("the backend-observed catalog is admitted")
6909 .build();
6910 let router = server.into_router();
6911
6912 assert_eq!(
6913 router.tools()[0].description.as_deref(),
6914 Some("a test tool")
6915 );
6916 assert_eq!(router.resources()[0].name, "test_res");
6917 assert_eq!(router.prompts()[0].description, None);
6918 }
6919
6920 let (client, catalog) = discovered_duplicate_policy_proxy();
6921 let replaced = ServerBuilder::new("srv", "1.0")
6922 .on_duplicate(DuplicateBehavior::Replace)
6923 .tool(TestTool)
6924 .resource(TestResource)
6925 .prompt(TestPrompt)
6926 .proxy(client, catalog)
6927 .expect("the backend-observed catalog is admitted")
6928 .build()
6929 .into_router();
6930
6931 assert_eq!(
6932 replaced.tools()[0].description.as_deref(),
6933 Some("proxied tool")
6934 );
6935 assert_eq!(replaced.resources()[0].name, "proxied resource");
6936 assert_eq!(
6937 replaced.prompts()[0].description.as_deref(),
6938 Some("proxied prompt")
6939 );
6940 }
6941
6942 #[cfg(feature = "tasks")]
6943 #[test]
6944 fn as_proxy_typed_installs_route_bound_final_tasks_relay() {
6945 let calls = Arc::new(Mutex::new(Vec::new()));
6946 let updates = Arc::new(Mutex::new(Vec::new()));
6947 let server = ServerBuilder::new("prefixed-proxy-tasks", "1.0")
6948 .as_proxy_typed(
6949 "ext",
6950 ordinary_proxy_tasks_client(calls, updates),
6951 final_completion_proxy_catalog(),
6952 )
6953 .expect("as_proxy_typed admits a modern catalog with a Tasks-capable route")
6954 .build();
6955 assert!(
6956 server.final_task_runtime().is_none(),
6957 "as_proxy_typed must install the route-bound Tasks relay instead of the default in-memory store"
6958 );
6959 let discovery = serde_json::to_value(
6960 server
6961 .server_discovery()
6962 .expect("the prefixed Tasks proxy remains discoverable"),
6963 )
6964 .expect("prefixed Tasks proxy discovery serializes");
6965 assert_eq!(
6966 discovery.pointer("/capabilities/extensions/io.modelcontextprotocol~1tasks"),
6967 Some(&serde_json::json!({})),
6968 "as_proxy_typed must advertise the same Tasks extension as proxy_typed"
6969 );
6970 }
6971
6972 #[test]
6973 fn as_proxy_raw_propagates_duplicate_registration_errors() {
6974 let result = ServerBuilder::new("srv", "1.0")
6975 .on_duplicate(DuplicateBehavior::Error)
6976 .tool(TestTool)
6977 .as_proxy_raw_with_proxy_client(ProxyClient::from_backend(
6978 DuplicatePolicyProxyBackend,
6979 ));
6980
6981 let error = match result {
6982 Ok(_) => panic!("raw proxy registration unexpectedly accepted a duplicate tool"),
6983 Err(error) => error,
6984 };
6985 assert_eq!(error.code, fastmcp_core::McpErrorCode::InvalidRequest);
6986 assert!(error.message.starts_with("Tool already exists"));
6987 }
6988 }
6989
6990 #[test]
6993 fn default_request_timeout_constant() {
6994 assert_eq!(DEFAULT_REQUEST_TIMEOUT_SECS, 30);
6995 }
6996
6997 #[test]
7000 fn builder_mask_error_details_toggle() {
7001 let builder = ServerBuilder::new("srv", "1.0")
7002 .mask_error_details(true)
7003 .mask_error_details(false);
7004 assert!(!builder.is_error_masking_enabled());
7005 }
7006
7007 #[test]
7010 fn builder_strict_validation_toggle() {
7011 let builder = ServerBuilder::new("srv", "1.0")
7012 .strict_input_validation(true)
7013 .strict_input_validation(false);
7014 assert!(!builder.is_strict_input_validation_enabled());
7015 }
7016
7017 #[cfg(feature = "tasks")]
7018 mod task_manager_tests {
7019 use super::*;
7020
7021 #[test]
7024 fn default_build_installs_in_memory_official_tasks() {
7025 let server = ServerBuilder::new("srv", "1.0").build();
7026 assert!(
7027 server.final_task_runtime().is_some(),
7028 "default build must install official in-memory Tasks"
7029 );
7030 }
7031
7032 #[test]
7033 fn builder_with_task_manager_retains_manager_without_advertising_capability() {
7034 use crate::tasks::TaskManager;
7035 let tm = TaskManager::new().into_shared();
7036 let server = ServerBuilder::new("srv", "1.0")
7037 .with_task_manager(tm)
7038 .build();
7039 assert!(server.task_manager().is_some());
7040 assert!(server.capabilities().tasks.is_none());
7041 assert!(
7042 server.final_task_runtime().is_none(),
7043 "quarantined task manager must not receive official Tasks"
7044 );
7045 }
7046
7047 #[test]
7048 fn builder_with_notifying_task_manager_keeps_capability_quarantined() {
7049 use crate::tasks::TaskManager;
7050 let tm = TaskManager::with_list_changed_notifications().into_shared();
7051 let server = ServerBuilder::new("srv", "1.0")
7052 .with_task_manager(tm)
7053 .build();
7054 assert!(server.task_manager().is_some());
7055 assert!(server.capabilities().tasks.is_none());
7056 }
7057
7058 #[test]
7059 fn builder_with_non_notifying_task_manager_keeps_capability_quarantined() {
7060 use crate::tasks::TaskManager;
7061 let tm = TaskManager::new().into_shared();
7062 let server = ServerBuilder::new("srv", "1.0")
7063 .with_task_manager(tm)
7064 .build();
7065 assert!(server.task_manager().is_some());
7066 assert!(server.capabilities().tasks.is_none());
7067 }
7068 }
7069
7070 struct DupResource(&'static str);
7073 impl crate::ResourceHandler for DupResource {
7074 fn definition(&self) -> Resource {
7075 Resource {
7076 uri: format!("file:///{}", self.0),
7077 name: self.0.to_string(),
7078 description: None,
7079 mime_type: None,
7080 icon: None,
7081 version: None,
7082 tags: vec![],
7083 }
7084 }
7085 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
7086 Ok(vec![])
7087 }
7088 }
7089
7090 struct DupPrompt(&'static str);
7091 impl crate::PromptHandler for DupPrompt {
7092 fn definition(&self) -> Prompt {
7093 Prompt {
7094 name: self.0.to_string(),
7095 description: None,
7096 arguments: vec![],
7097 icon: None,
7098 version: None,
7099 tags: vec![],
7100 }
7101 }
7102 fn get(
7103 &self,
7104 _ctx: &McpContext,
7105 _args: std::collections::HashMap<String, String>,
7106 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
7107 Ok(vec![])
7108 }
7109 }
7110
7111 #[test]
7112 fn builder_on_duplicate_error_resource_logs_but_continues() {
7113 let server = ServerBuilder::new("srv", "1.0")
7114 .on_duplicate(DuplicateBehavior::Error)
7115 .resource(DupResource("dup"))
7116 .resource(DupResource("dup"))
7117 .build();
7118 assert!(server.has_resources());
7119 }
7120
7121 #[test]
7122 fn builder_on_duplicate_error_prompt_logs_but_continues() {
7123 let server = ServerBuilder::new("srv", "1.0")
7124 .on_duplicate(DuplicateBehavior::Error)
7125 .prompt(DupPrompt("dup"))
7126 .prompt(DupPrompt("dup"))
7127 .build();
7128 assert!(server.has_prompts());
7129 }
7130
7131 #[cfg(feature = "proxy")]
7132 #[test]
7133 fn prefixed_as_proxy_skips_only_duplicate_registration_errors() {
7134 let duplicate =
7135 fastmcp_core::McpError::invalid_request("Tool already exists; component_key=tool_key");
7136 assert!(
7137 ServerBuilder::absorb_prefixed_proxy_duplicate(duplicate).is_ok(),
7138 "a colliding catalog member may skip under DuplicateBehavior::Error"
7139 );
7140 let panicked =
7141 fastmcp_core::McpError::internal_error("tool metadata hook panicked during admission");
7142 assert!(
7143 ServerBuilder::absorb_prefixed_proxy_duplicate(panicked).is_err(),
7144 "a non-duplicate admission failure must fail the proxy / as_proxy_typed install"
7145 );
7146 let apps = fastmcp_core::McpError::invalid_request(
7147 "MCP Apps tool metadata requires ServerBuilder::mcp_apps_tool",
7148 );
7149 assert!(
7150 ServerBuilder::absorb_prefixed_proxy_duplicate(apps).is_err(),
7151 "InvalidRequest that is not a duplicate must not be skipped"
7152 );
7153 }
7154
7155 #[cfg(feature = "proxy")]
7158 #[test]
7159 fn builder_proxy_with_resources_and_prompts() {
7160 use crate::proxy::ProxyClient;
7161
7162 struct DummyBackend2;
7163 impl crate::proxy::ProxyBackend for DummyBackend2 {
7164 fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
7165 Ok(vec![])
7166 }
7167 fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
7168 Ok(vec![Resource {
7169 uri: "file:///proxy-res".to_owned(),
7170 name: "proxy-res".to_owned(),
7171 description: None,
7172 mime_type: None,
7173 icon: None,
7174 version: None,
7175 tags: Vec::new(),
7176 }])
7177 }
7178 fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
7179 Ok(vec![ResourceTemplate {
7180 uri_template: "db://{table}".to_owned(),
7181 name: "db".to_owned(),
7182 description: None,
7183 mime_type: None,
7184 icon: None,
7185 version: None,
7186 tags: Vec::new(),
7187 }])
7188 }
7189 fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
7190 Ok(vec![Prompt {
7191 name: "proxy-prompt".to_owned(),
7192 description: None,
7193 arguments: Vec::new(),
7194 icon: None,
7195 version: None,
7196 tags: Vec::new(),
7197 }])
7198 }
7199 fn call_tool(&mut self, _: &str, _: serde_json::Value) -> McpResult<Vec<Content>> {
7200 Ok(vec![])
7201 }
7202 fn call_tool_with_progress(
7203 &mut self,
7204 _: &str,
7205 _: serde_json::Value,
7206 _: crate::proxy::ProgressCallback<'_>,
7207 ) -> McpResult<Vec<Content>> {
7208 Ok(vec![])
7209 }
7210 fn read_resource(&mut self, _: &str) -> McpResult<Vec<ResourceContent>> {
7211 Ok(vec![])
7212 }
7213 fn get_prompt(
7214 &mut self,
7215 _: &str,
7216 _: std::collections::HashMap<String, String>,
7217 ) -> McpResult<Vec<fastmcp_protocol::PromptMessage>> {
7218 Ok(vec![])
7219 }
7220 }
7221
7222 let client = ProxyClient::from_backend(DummyBackend2);
7223 let catalog = client
7224 .catalog()
7225 .expect("backend discovery supplies the legacy era evidence");
7226
7227 let server = ServerBuilder::new("srv", "1.0")
7228 .proxy(client, catalog)
7229 .expect("the backend-observed catalog is admitted")
7230 .build();
7231 assert!(server.has_resources());
7232 assert!(server.has_prompts());
7233 assert!(!server.has_tools());
7234 }
7235
7236 #[test]
7239 fn build_propagates_strict_validation_to_router() {
7240 let server = ServerBuilder::new("srv", "1.0")
7241 .strict_input_validation(true)
7242 .build();
7243 let router = server.into_router();
7244 assert!(router.strict_input_validation());
7245 }
7246
7247 #[test]
7248 fn build_propagates_strict_validation_false_to_router() {
7249 let server = ServerBuilder::new("srv", "1.0")
7250 .strict_input_validation(false)
7251 .build();
7252 let router = server.into_router();
7253 assert!(!router.strict_input_validation());
7254 }
7255
7256 #[test]
7259 fn builder_log_level_filter_off() {
7260 let builder = ServerBuilder::new("srv", "1.0").log_level_filter(LevelFilter::Off);
7261 assert_eq!(builder.logging.level, LevelFilter::Off);
7262 assert_eq!(builder.console_config.log_level, LevelFilter::Off);
7263 }
7264
7265 #[test]
7268 fn builder_mount_no_op_leaves_capabilities_unchanged() {
7269 let source = ServerBuilder::new("sub", "1.0").build();
7270 let main = ServerBuilder::new("main", "1.0")
7271 .mount(source, Some("ns"))
7272 .build();
7273 assert!(!main.has_tools());
7274 assert!(!main.has_resources());
7275 assert!(!main.has_prompts());
7276 }
7277}