11//! Feature Discovery Service - SRP: Discovery & caching
22
33use anyhow:: Result ;
4+ use std:: future:: Future ;
45use std:: sync:: Arc ;
6+ use std:: time:: Duration ;
57use tracing:: { debug, info, warn} ;
68
79use super :: { convert_to_feature, resource_to_feature, CachedFeatures } ;
@@ -14,10 +16,29 @@ pub struct FeatureDiscoveryService {
1416}
1517
1618impl FeatureDiscoveryService {
19+ const LIST_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
20+
1721 pub fn new ( feature_repo : Arc < dyn ServerFeatureRepository > ) -> Self {
1822 Self { feature_repo }
1923 }
2024
25+ async fn with_list_timeout < T , E , F > ( label : & str , fut : F ) -> Option < Result < T , E > >
26+ where
27+ F : Future < Output = Result < T , E > > ,
28+ {
29+ match tokio:: time:: timeout ( Self :: LIST_TIMEOUT , fut) . await {
30+ Ok ( result) => Some ( result) ,
31+ Err ( _) => {
32+ warn ! (
33+ "[FeatureDiscovery] {} timed out after {:?}" ,
34+ label,
35+ Self :: LIST_TIMEOUT
36+ ) ;
37+ None
38+ }
39+ }
40+ }
41+
2142 /// Discover features from a connected MCP client and cache them
2243 pub async fn discover_and_cache (
2344 & self ,
@@ -31,50 +52,83 @@ impl FeatureDiscoveryService {
3152 ) ;
3253
3354 let mut discovered = CachedFeatures :: default ( ) ;
55+ let capabilities = client. peer_info ( ) . map ( |info| info. capabilities . clone ( ) ) ;
56+ let capabilities_known = capabilities. is_some ( ) ;
57+ let has_tools = capabilities
58+ . as_ref ( )
59+ . and_then ( |c| c. tools . as_ref ( ) )
60+ . is_some ( ) ;
61+ let has_prompts = capabilities
62+ . as_ref ( )
63+ . and_then ( |c| c. prompts . as_ref ( ) )
64+ . is_some ( ) ;
65+ let has_resources = capabilities
66+ . as_ref ( )
67+ . and_then ( |c| c. resources . as_ref ( ) )
68+ . is_some ( ) ;
3469
35- // Discover tools
36- match client. list_all_tools ( ) . await {
37- Ok ( tools) => {
38- discovered. tools = tools
39- . into_iter ( )
40- . map ( |t| convert_to_feature ( space_id, server_id, t) )
41- . collect ( ) ;
42- debug ! (
43- "[FeatureDiscovery] Discovered {} tools" ,
44- discovered. tools. len( )
45- ) ;
70+ debug ! (
71+ "[FeatureDiscovery] Capability gates for {}/{}: known={}, tools={}, prompts={}, resources={}" ,
72+ space_id, server_id, capabilities_known, has_tools, has_prompts, has_resources
73+ ) ;
74+
75+ if !capabilities_known || has_tools {
76+ match Self :: with_list_timeout ( "tools/list" , client. list_all_tools ( ) ) . await {
77+ Some ( Ok ( tools) ) => {
78+ discovered. tools = tools
79+ . into_iter ( )
80+ . map ( |t| convert_to_feature ( space_id, server_id, t) )
81+ . collect ( ) ;
82+ debug ! (
83+ "[FeatureDiscovery] Discovered {} tools" ,
84+ discovered. tools. len( )
85+ ) ;
86+ }
87+ Some ( Err ( e) ) => warn ! ( "[FeatureDiscovery] Failed to list tools: {}" , e) ,
88+ None => { }
4689 }
47- Err ( e) => warn ! ( "[FeatureDiscovery] Failed to list tools: {}" , e) ,
90+ } else {
91+ debug ! (
92+ "[FeatureDiscovery] Skipping tools/list: server explicitly did not advertise tools capability"
93+ ) ;
4894 }
4995
50- // Discover prompts
51- match client. list_all_prompts ( ) . await {
52- Ok ( prompts) => {
53- discovered. prompts = prompts
54- . into_iter ( )
55- . map ( |p| convert_to_feature ( space_id, server_id, p) )
56- . collect ( ) ;
57- debug ! (
58- "[FeatureDiscovery] Discovered {} prompts" ,
59- discovered. prompts. len( )
60- ) ;
96+ if !capabilities_known || has_prompts {
97+ match Self :: with_list_timeout ( "prompts/list" , client. list_all_prompts ( ) ) . await {
98+ Some ( Ok ( prompts) ) => {
99+ discovered. prompts = prompts
100+ . into_iter ( )
101+ . map ( |p| convert_to_feature ( space_id, server_id, p) )
102+ . collect ( ) ;
103+ debug ! (
104+ "[FeatureDiscovery] Discovered {} prompts" ,
105+ discovered. prompts. len( )
106+ ) ;
107+ }
108+ Some ( Err ( e) ) => warn ! ( "[FeatureDiscovery] Failed to list prompts: {}" , e) ,
109+ None => { }
61110 }
62- Err ( e) => warn ! ( "[FeatureDiscovery] Failed to list prompts: {}" , e) ,
111+ } else {
112+ debug ! ( "[FeatureDiscovery] Skipping prompts/list: server explicitly did not advertise prompts capability" ) ;
63113 }
64114
65- // Discover resources
66- match client. list_all_resources ( ) . await {
67- Ok ( resources) => {
68- discovered. resources = resources
69- . into_iter ( )
70- . map ( |r| resource_to_feature ( space_id, server_id, r) )
71- . collect ( ) ;
72- debug ! (
73- "[FeatureDiscovery] Discovered {} resources" ,
74- discovered. resources. len( )
75- ) ;
115+ if !capabilities_known || has_resources {
116+ match Self :: with_list_timeout ( "resources/list" , client. list_all_resources ( ) ) . await {
117+ Some ( Ok ( resources) ) => {
118+ discovered. resources = resources
119+ . into_iter ( )
120+ . map ( |r| resource_to_feature ( space_id, server_id, r) )
121+ . collect ( ) ;
122+ debug ! (
123+ "[FeatureDiscovery] Discovered {} resources" ,
124+ discovered. resources. len( )
125+ ) ;
126+ }
127+ Some ( Err ( e) ) => warn ! ( "[FeatureDiscovery] Failed to list resources: {}" , e) ,
128+ None => { }
76129 }
77- Err ( e) => warn ! ( "[FeatureDiscovery] Failed to list resources: {}" , e) ,
130+ } else {
131+ debug ! ( "[FeatureDiscovery] Skipping resources/list: server explicitly did not advertise resources capability" ) ;
78132 }
79133
80134 // Cache all features in database
@@ -109,3 +163,36 @@ impl FeatureDiscoveryService {
109163 . await
110164 }
111165}
166+
167+ #[ cfg( test) ]
168+ mod tests {
169+ use super :: * ;
170+ use std:: future:: pending;
171+
172+ #[ tokio:: test]
173+ async fn with_list_timeout_returns_some_ok_when_future_completes ( ) {
174+ let out =
175+ FeatureDiscoveryService :: with_list_timeout ( "tools/list" , async { Ok :: < i32 , & str > ( 42 ) } )
176+ . await ;
177+ assert ! ( matches!( out, Some ( Ok ( 42 ) ) ) ) ;
178+ }
179+
180+ #[ tokio:: test]
181+ async fn with_list_timeout_propagates_inner_error ( ) {
182+ let out = FeatureDiscoveryService :: with_list_timeout ( "prompts/list" , async {
183+ Err :: < i32 , & str > ( "boom" )
184+ } )
185+ . await ;
186+ assert ! ( matches!( out, Some ( Err ( "boom" ) ) ) ) ;
187+ }
188+
189+ #[ tokio:: test( start_paused = true ) ]
190+ async fn with_list_timeout_returns_none_on_timeout ( ) {
191+ // A future that never resolves. Under tokio's paused clock the runtime
192+ // auto-advances to the LIST_TIMEOUT deadline, so this resolves to a
193+ // timeout without actually waiting 10 seconds.
194+ let never = pending :: < Result < i32 , & str > > ( ) ;
195+ let out = FeatureDiscoveryService :: with_list_timeout ( "resources/list" , never) . await ;
196+ assert ! ( out. is_none( ) ) ;
197+ }
198+ }
0 commit comments