@@ -149,12 +149,14 @@ impl Builtin for ToolBuiltinAdapter {
149149// HelpBuiltin — runtime schema introspection
150150// ============================================================================
151151
152- /// Snapshot of a tool definition for the `help` builtin .
152+ /// Snapshot of a tool definition for the `help` and `discover` builtins .
153153#[ derive( Clone ) ]
154154struct ToolDefSnapshot {
155155 name : String ,
156156 description : String ,
157157 input_schema : serde_json:: Value ,
158+ tags : Vec < String > ,
159+ category : Option < String > ,
158160}
159161
160162/// Built-in `help` command for runtime tool schema introspection.
@@ -219,6 +221,134 @@ impl Builtin for HelpBuiltin {
219221 }
220222}
221223
224+ // ============================================================================
225+ // DiscoverBuiltin — progressive tool discovery
226+ // ============================================================================
227+
228+ /// Built-in `discover` command for exploring large tool sets.
229+ struct DiscoverBuiltin {
230+ tools : Vec < ToolDefSnapshot > ,
231+ }
232+
233+ impl DiscoverBuiltin {
234+ fn filter_tools ( & self , args : & [ String ] ) -> ( Vec < & ToolDefSnapshot > , bool ) {
235+ let json_mode = args. iter ( ) . any ( |a| a == "--json" ) ;
236+
237+ if args. iter ( ) . any ( |a| a == "--categories" ) {
238+ return ( Vec :: new ( ) , json_mode) ;
239+ }
240+
241+ if let Some ( pos) = args. iter ( ) . position ( |a| a == "--category" ) {
242+ let cat = args. get ( pos + 1 ) . map ( |s| s. as_str ( ) ) . unwrap_or ( "" ) ;
243+ let filtered: Vec < & ToolDefSnapshot > = self
244+ . tools
245+ . iter ( )
246+ . filter ( |t| t. category . as_deref ( ) == Some ( cat) )
247+ . collect ( ) ;
248+ return ( filtered, json_mode) ;
249+ }
250+
251+ if let Some ( pos) = args. iter ( ) . position ( |a| a == "--tag" ) {
252+ let tag = args. get ( pos + 1 ) . map ( |s| s. as_str ( ) ) . unwrap_or ( "" ) ;
253+ let filtered: Vec < & ToolDefSnapshot > = self
254+ . tools
255+ . iter ( )
256+ . filter ( |t| t. tags . iter ( ) . any ( |tg| tg == tag) )
257+ . collect ( ) ;
258+ return ( filtered, json_mode) ;
259+ }
260+
261+ if let Some ( pos) = args. iter ( ) . position ( |a| a == "--search" ) {
262+ let keyword = args
263+ . get ( pos + 1 )
264+ . map ( |s| s. to_lowercase ( ) )
265+ . unwrap_or_default ( ) ;
266+ let filtered: Vec < & ToolDefSnapshot > = self
267+ . tools
268+ . iter ( )
269+ . filter ( |t| {
270+ t. name . to_lowercase ( ) . contains ( & keyword)
271+ || t. description . to_lowercase ( ) . contains ( & keyword)
272+ } )
273+ . collect ( ) ;
274+ return ( filtered, json_mode) ;
275+ }
276+
277+ ( self . tools . iter ( ) . collect ( ) , json_mode)
278+ }
279+ }
280+
281+ #[ async_trait]
282+ impl Builtin for DiscoverBuiltin {
283+ async fn execute ( & self , ctx : Context < ' _ > ) -> Result < ExecResult > {
284+ let args = ctx. args ;
285+
286+ if args. is_empty ( ) {
287+ return Ok ( ExecResult :: err (
288+ "usage: discover --categories | --category <name> | --tag <tag> | --search <keyword> [--json]" . to_string ( ) ,
289+ 1 ,
290+ ) ) ;
291+ }
292+
293+ let json_mode = args. iter ( ) . any ( |a| a == "--json" ) ;
294+
295+ // --categories
296+ if args. iter ( ) . any ( |a| a == "--categories" ) {
297+ let mut cats: std:: collections:: BTreeMap < String , usize > =
298+ std:: collections:: BTreeMap :: new ( ) ;
299+ for t in & self . tools {
300+ if let Some ( ref cat) = t. category {
301+ * cats. entry ( cat. clone ( ) ) . or_insert ( 0 ) += 1 ;
302+ }
303+ }
304+ if json_mode {
305+ let arr: Vec < serde_json:: Value > = cats
306+ . iter ( )
307+ . map ( |( name, count) | serde_json:: json!( { "category" : name, "count" : count} ) )
308+ . collect ( ) ;
309+ let json_str =
310+ serde_json:: to_string_pretty ( & arr) . unwrap_or_else ( |_| "[]" . to_string ( ) ) ;
311+ return Ok ( ExecResult :: ok ( format ! ( "{json_str}\n " ) ) ) ;
312+ }
313+ let mut out = String :: new ( ) ;
314+ for ( name, count) in & cats {
315+ let plural = if * count == 1 { "tool" } else { "tools" } ;
316+ out. push_str ( & format ! ( "{name} ({count} {plural})\n " ) ) ;
317+ }
318+ return Ok ( ExecResult :: ok ( out) ) ;
319+ }
320+
321+ let ( filtered, _) = self . filter_tools ( args) ;
322+
323+ if json_mode {
324+ let arr: Vec < serde_json:: Value > = filtered
325+ . iter ( )
326+ . map ( |t| {
327+ let mut obj = serde_json:: json!( {
328+ "name" : t. name,
329+ "description" : t. description,
330+ } ) ;
331+ if !t. tags . is_empty ( ) {
332+ obj[ "tags" ] = serde_json:: json!( t. tags) ;
333+ }
334+ if let Some ( ref cat) = t. category {
335+ obj[ "category" ] = serde_json:: json!( cat) ;
336+ }
337+ obj
338+ } )
339+ . collect ( ) ;
340+ let json_str = serde_json:: to_string_pretty ( & arr) . unwrap_or_else ( |_| "[]" . to_string ( ) ) ;
341+ return Ok ( ExecResult :: ok ( format ! ( "{json_str}\n " ) ) ) ;
342+ }
343+
344+ let mut out = String :: new ( ) ;
345+ for t in & filtered {
346+ out. push_str ( & format ! ( "{:<20} {}\n " , t. name, t. description) ) ;
347+ }
348+ Ok ( ExecResult :: ok ( out) )
349+ }
350+ }
351+
222352// ============================================================================
223353// ScriptedTool — internal helpers
224354// ============================================================================
@@ -243,19 +373,27 @@ impl ScriptedTool {
243373 builder = builder. builtin ( name, builtin) ;
244374 }
245375
246- // Register the help builtin
376+ // Register the help and discover builtins
247377 let snapshots: Vec < ToolDefSnapshot > = self
248378 . tools
249379 . iter ( )
250380 . map ( |t| ToolDefSnapshot {
251381 name : t. def . name . clone ( ) ,
252382 description : t. def . description . clone ( ) ,
253383 input_schema : t. def . input_schema . clone ( ) ,
384+ tags : t. def . tags . clone ( ) ,
385+ category : t. def . category . clone ( ) ,
254386 } )
255387 . collect ( ) ;
256388 builder = builder. builtin (
257389 "help" . to_string ( ) ,
258- Box :: new ( HelpBuiltin { tools : snapshots } ) ,
390+ Box :: new ( HelpBuiltin {
391+ tools : snapshots. clone ( ) ,
392+ } ) ,
393+ ) ;
394+ builder = builder. builtin (
395+ "discover" . to_string ( ) ,
396+ Box :: new ( DiscoverBuiltin { tools : snapshots } ) ,
259397 ) ;
260398
261399 builder. build ( )
@@ -739,4 +877,189 @@ mod tests {
739877 ) ;
740878 }
741879 }
880+
881+ // -- DiscoverBuiltin tests --
882+
883+ fn build_discover_test_tool ( ) -> ScriptedTool {
884+ ScriptedTool :: builder ( "big_api" )
885+ . short_description ( "Big API" )
886+ . tool (
887+ ToolDef :: new ( "create_charge" , "Create a payment charge" )
888+ . with_category ( "payments" )
889+ . with_tags ( & [ "billing" , "write" ] ) ,
890+ |_args : & super :: ToolArgs | Ok ( "ok\n " . to_string ( ) ) ,
891+ )
892+ . tool (
893+ ToolDef :: new ( "refund" , "Issue a refund" )
894+ . with_category ( "payments" )
895+ . with_tags ( & [ "billing" , "write" ] ) ,
896+ |_args : & super :: ToolArgs | Ok ( "ok\n " . to_string ( ) ) ,
897+ )
898+ . tool (
899+ ToolDef :: new ( "get_user" , "Fetch user by ID" )
900+ . with_category ( "users" )
901+ . with_tags ( & [ "read" ] ) ,
902+ |_args : & super :: ToolArgs | Ok ( "ok\n " . to_string ( ) ) ,
903+ )
904+ . tool (
905+ ToolDef :: new ( "delete_user" , "Delete a user account" )
906+ . with_category ( "users" )
907+ . with_tags ( & [ "admin" , "write" ] ) ,
908+ |_args : & super :: ToolArgs | Ok ( "ok\n " . to_string ( ) ) ,
909+ )
910+ . tool (
911+ ToolDef :: new ( "get_inventory" , "Check inventory levels" ) . with_category ( "inventory" ) ,
912+ |_args : & super :: ToolArgs | Ok ( "ok\n " . to_string ( ) ) ,
913+ )
914+ . build ( )
915+ }
916+
917+ #[ tokio:: test]
918+ async fn test_discover_categories ( ) {
919+ let mut tool = build_discover_test_tool ( ) ;
920+ let resp = tool
921+ . execute ( ToolRequest {
922+ commands : "discover --categories" . to_string ( ) ,
923+ timeout_ms : None ,
924+ } )
925+ . await ;
926+ assert_eq ! ( resp. exit_code, 0 ) ;
927+ assert ! ( resp. stdout. contains( "payments (2 tools)" ) ) ;
928+ assert ! ( resp. stdout. contains( "users (2 tools)" ) ) ;
929+ assert ! ( resp. stdout. contains( "inventory (1 tool)" ) ) ;
930+ }
931+
932+ #[ tokio:: test]
933+ async fn test_discover_category_filter ( ) {
934+ let mut tool = build_discover_test_tool ( ) ;
935+ let resp = tool
936+ . execute ( ToolRequest {
937+ commands : "discover --category payments" . to_string ( ) ,
938+ timeout_ms : None ,
939+ } )
940+ . await ;
941+ assert_eq ! ( resp. exit_code, 0 ) ;
942+ assert ! ( resp. stdout. contains( "create_charge" ) ) ;
943+ assert ! ( resp. stdout. contains( "refund" ) ) ;
944+ assert ! ( !resp. stdout. contains( "get_user" ) ) ;
945+ }
946+
947+ #[ tokio:: test]
948+ async fn test_discover_tag_filter ( ) {
949+ let mut tool = build_discover_test_tool ( ) ;
950+ let resp = tool
951+ . execute ( ToolRequest {
952+ commands : "discover --tag admin" . to_string ( ) ,
953+ timeout_ms : None ,
954+ } )
955+ . await ;
956+ assert_eq ! ( resp. exit_code, 0 ) ;
957+ assert ! ( resp. stdout. contains( "delete_user" ) ) ;
958+ assert ! ( !resp. stdout. contains( "create_charge" ) ) ;
959+ }
960+
961+ #[ tokio:: test]
962+ async fn test_discover_search ( ) {
963+ let mut tool = build_discover_test_tool ( ) ;
964+ let resp = tool
965+ . execute ( ToolRequest {
966+ commands : "discover --search user" . to_string ( ) ,
967+ timeout_ms : None ,
968+ } )
969+ . await ;
970+ assert_eq ! ( resp. exit_code, 0 ) ;
971+ assert ! ( resp. stdout. contains( "get_user" ) ) ;
972+ assert ! ( resp. stdout. contains( "delete_user" ) ) ;
973+ assert ! ( !resp. stdout. contains( "create_charge" ) ) ;
974+ }
975+
976+ #[ tokio:: test]
977+ async fn test_discover_search_case_insensitive ( ) {
978+ let mut tool = build_discover_test_tool ( ) ;
979+ let resp = tool
980+ . execute ( ToolRequest {
981+ commands : "discover --search REFUND" . to_string ( ) ,
982+ timeout_ms : None ,
983+ } )
984+ . await ;
985+ assert_eq ! ( resp. exit_code, 0 ) ;
986+ assert ! ( resp. stdout. contains( "refund" ) ) ;
987+ }
988+
989+ #[ tokio:: test]
990+ async fn test_discover_categories_json ( ) {
991+ let mut tool = build_discover_test_tool ( ) ;
992+ let resp = tool
993+ . execute ( ToolRequest {
994+ commands : "discover --categories --json" . to_string ( ) ,
995+ timeout_ms : None ,
996+ } )
997+ . await ;
998+ assert_eq ! ( resp. exit_code, 0 ) ;
999+ let arr: Vec < serde_json:: Value > =
1000+ serde_json:: from_str ( resp. stdout . trim ( ) ) . expect ( "valid JSON" ) ;
1001+ assert ! (
1002+ arr. iter( )
1003+ . any( |v| v[ "category" ] == "payments" && v[ "count" ] == 2 )
1004+ ) ;
1005+ }
1006+
1007+ #[ tokio:: test]
1008+ async fn test_discover_category_json ( ) {
1009+ let mut tool = build_discover_test_tool ( ) ;
1010+ let resp = tool
1011+ . execute ( ToolRequest {
1012+ commands : "discover --category payments --json" . to_string ( ) ,
1013+ timeout_ms : None ,
1014+ } )
1015+ . await ;
1016+ assert_eq ! ( resp. exit_code, 0 ) ;
1017+ let arr: Vec < serde_json:: Value > =
1018+ serde_json:: from_str ( resp. stdout . trim ( ) ) . expect ( "valid JSON" ) ;
1019+ assert_eq ! ( arr. len( ) , 2 ) ;
1020+ assert ! ( arr. iter( ) . any( |v| v[ "name" ] == "create_charge" ) ) ;
1021+ }
1022+
1023+ #[ tokio:: test]
1024+ async fn test_discover_no_args_shows_usage ( ) {
1025+ let mut tool = build_discover_test_tool ( ) ;
1026+ let resp = tool
1027+ . execute ( ToolRequest {
1028+ commands : "discover" . to_string ( ) ,
1029+ timeout_ms : None ,
1030+ } )
1031+ . await ;
1032+ assert_ne ! ( resp. exit_code, 0 ) ;
1033+ assert ! ( resp. stderr. contains( "usage:" ) ) ;
1034+ }
1035+
1036+ #[ tokio:: test]
1037+ async fn test_discover_tag_json ( ) {
1038+ let mut tool = build_discover_test_tool ( ) ;
1039+ let resp = tool
1040+ . execute ( ToolRequest {
1041+ commands : "discover --tag billing --json" . to_string ( ) ,
1042+ timeout_ms : None ,
1043+ } )
1044+ . await ;
1045+ assert_eq ! ( resp. exit_code, 0 ) ;
1046+ let arr: Vec < serde_json:: Value > =
1047+ serde_json:: from_str ( resp. stdout . trim ( ) ) . expect ( "valid JSON" ) ;
1048+ assert_eq ! ( arr. len( ) , 2 ) ;
1049+ assert ! ( arr. iter( ) . all( |v| {
1050+ v[ "tags" ]
1051+ . as_array( )
1052+ . expect( "tags array" )
1053+ . contains( & serde_json:: json!( "billing" ) )
1054+ } ) ) ;
1055+ }
1056+
1057+ #[ tokio:: test]
1058+ async fn test_tooldef_with_tags_and_category ( ) {
1059+ let def = ToolDef :: new ( "test" , "A test tool" )
1060+ . with_tags ( & [ "admin" , "billing" ] )
1061+ . with_category ( "payments" ) ;
1062+ assert_eq ! ( def. tags, vec![ "admin" , "billing" ] ) ;
1063+ assert_eq ! ( def. category. as_deref( ) , Some ( "payments" ) ) ;
1064+ }
7421065}
0 commit comments