@@ -31,6 +31,7 @@ use raysense_memory::{
3131 BaselineTableQuery , BaselineTableSort ,
3232} ;
3333use serde_json:: { json, Value } ;
34+ use std:: collections:: { HashMap , HashSet , VecDeque } ;
3435use std:: fs;
3536use std:: io:: { self , BufRead , Write } ;
3637use std:: path:: { Path , PathBuf } ;
@@ -182,6 +183,36 @@ fn tools_list() -> Value {
182183 "description" : "Return DSM top-level module dependency edges from project health." ,
183184 "inputSchema" : health_limit_schema( "Maximum module edges to return. Defaults to 100." )
184185 } ,
186+ {
187+ "name" : "raysense_architecture" ,
188+ "description" : "Return architecture metrics, root cause scores, cycles, levels, and unstable modules." ,
189+ "inputSchema" : health_limit_schema( "Maximum repeated architecture rows to return. Defaults to 100." )
190+ } ,
191+ {
192+ "name" : "raysense_coupling" ,
193+ "description" : "Return coupling metrics, dependency hotspots, and top module edges." ,
194+ "inputSchema" : health_limit_schema( "Maximum hotspot and module-edge rows to return. Defaults to 100." )
195+ } ,
196+ {
197+ "name" : "raysense_cycles" ,
198+ "description" : "Return detected dependency cycles." ,
199+ "inputSchema" : health_limit_schema( "Maximum cycles to return. Defaults to 100." )
200+ } ,
201+ {
202+ "name" : "raysense_hottest" ,
203+ "description" : "Return the hottest files and functions by dependency and call traffic." ,
204+ "inputSchema" : health_limit_schema( "Maximum hot items per list to return. Defaults to 100." )
205+ } ,
206+ {
207+ "name" : "raysense_blast_radius" ,
208+ "description" : "Return reachable local dependency impact for a file, or the current max blast-radius file when omitted." ,
209+ "inputSchema" : blast_radius_schema( )
210+ } ,
211+ {
212+ "name" : "raysense_level" ,
213+ "description" : "Return dependency level information for all modules or one requested module." ,
214+ "inputSchema" : level_schema( )
215+ } ,
185216 {
186217 "name" : "raysense_session_start" ,
187218 "description" : "Save an in-memory and persisted baseline for an agent session." ,
@@ -292,6 +323,12 @@ fn call_tool(params: &Value, state: &mut McpState) -> Result<Value> {
292323 "raysense_hotspots" => hotspots_tool ( & args) ,
293324 "raysense_rules" => rules_tool ( & args) ,
294325 "raysense_module_edges" => module_edges_tool ( & args) ,
326+ "raysense_architecture" => architecture_tool ( & args) ,
327+ "raysense_coupling" => coupling_tool ( & args) ,
328+ "raysense_cycles" => cycles_tool ( & args) ,
329+ "raysense_hottest" => hottest_tool ( & args) ,
330+ "raysense_blast_radius" => blast_radius_tool ( & args) ,
331+ "raysense_level" => level_tool ( & args) ,
295332 "raysense_session_start" => session_start_tool ( & args, state) ,
296333 "raysense_session_end" => session_end_tool ( & args, state) ,
297334 "raysense_rescan" => rescan_tool ( & args, state) ,
@@ -456,6 +493,133 @@ fn module_edges_tool(args: &Value) -> Result<Value> {
456493 } ) )
457494}
458495
496+ fn architecture_tool ( args : & Value ) -> Result < Value > {
497+ let ( root, health) = health_from_args ( args) ?;
498+ let limit = limit_arg ( args, 100 ) ?;
499+
500+ Ok ( json ! ( {
501+ "root" : root,
502+ "score" : health. score,
503+ "quality_signal" : health. quality_signal,
504+ "root_causes" : health. root_causes,
505+ "architecture" : {
506+ "module_depth" : health. metrics. architecture. module_depth,
507+ "max_blast_radius" : health. metrics. architecture. max_blast_radius,
508+ "max_blast_radius_file" : health. metrics. architecture. max_blast_radius_file,
509+ "levels" : health. metrics. architecture. levels,
510+ "cycles" : limited( & health. metrics. architecture. cycles, limit) ,
511+ "unstable_modules" : limited( & health. metrics. architecture. unstable_modules, limit) ,
512+ "cycle_total" : health. metrics. architecture. cycles. len( ) ,
513+ "unstable_module_total" : health. metrics. architecture. unstable_modules. len( )
514+ } ,
515+ "dsm" : {
516+ "module_count" : health. metrics. dsm. module_count,
517+ "module_edges" : health. metrics. dsm. module_edges,
518+ "top_module_edges" : limited( & health. metrics. dsm. top_module_edges, limit)
519+ }
520+ } ) )
521+ }
522+
523+ fn coupling_tool ( args : & Value ) -> Result < Value > {
524+ let ( root, health) = health_from_args ( args) ?;
525+ let limit = limit_arg ( args, 100 ) ?;
526+
527+ Ok ( json ! ( {
528+ "root" : root,
529+ "coupling" : health. metrics. coupling,
530+ "hotspots" : limited( & health. hotspots, limit) ,
531+ "module_edges" : limited( & health. metrics. dsm. top_module_edges, limit) ,
532+ "limits" : {
533+ "limit" : limit,
534+ "hotspots_total" : health. hotspots. len( ) ,
535+ "module_edges_total" : health. metrics. dsm. top_module_edges. len( )
536+ }
537+ } ) )
538+ }
539+
540+ fn cycles_tool ( args : & Value ) -> Result < Value > {
541+ let ( root, health) = health_from_args ( args) ?;
542+ let limit = limit_arg ( args, 100 ) ?;
543+
544+ Ok ( json ! ( {
545+ "root" : root,
546+ "cycles" : limited( & health. metrics. architecture. cycles, limit) ,
547+ "limit" : limit,
548+ "total" : health. metrics. architecture. cycles. len( )
549+ } ) )
550+ }
551+
552+ fn hottest_tool ( args : & Value ) -> Result < Value > {
553+ let ( root, health) = health_from_args ( args) ?;
554+ let limit = limit_arg ( args, 100 ) ?;
555+
556+ Ok ( json ! ( {
557+ "root" : root,
558+ "files" : limited( & health. hotspots, limit) ,
559+ "top_called_functions" : limited( & health. metrics. calls. top_called_functions, limit) ,
560+ "top_calling_functions" : limited( & health. metrics. calls. top_calling_functions, limit) ,
561+ "complex_functions" : limited( & health. metrics. complexity. complex_functions, limit) ,
562+ "limits" : {
563+ "limit" : limit,
564+ "file_total" : health. hotspots. len( ) ,
565+ "top_called_total" : health. metrics. calls. top_called_functions. len( ) ,
566+ "top_calling_total" : health. metrics. calls. top_calling_functions. len( ) ,
567+ "complex_function_total" : health. metrics. complexity. complex_functions. len( )
568+ }
569+ } ) )
570+ }
571+
572+ fn blast_radius_tool ( args : & Value ) -> Result < Value > {
573+ let root = root_arg ( args) ?;
574+ let config = effective_config ( args, & root) ?;
575+ let limit = limit_arg ( args, 100 ) ?;
576+ let requested_file = args. get ( "file" ) . and_then ( Value :: as_str) ;
577+ let report = scan_path_with_config ( & root, & config) ?;
578+ let health = compute_health_with_config ( & report, & config) ;
579+ let file_id = match requested_file {
580+ Some ( file) => {
581+ find_file_id ( & report, file) . ok_or_else ( || anyhow ! ( "file not found in scan: {file}" ) ) ?
582+ }
583+ None => find_file_id ( & report, & health. metrics . architecture . max_blast_radius_file )
584+ . ok_or_else ( || anyhow ! ( "no max blast-radius file found" ) ) ?,
585+ } ;
586+ let Some ( file) = report. files . get ( file_id) else {
587+ return Err ( anyhow ! ( "file id {file_id} is out of range" ) ) ;
588+ } ;
589+ let reachable = reachable_files ( & report, file_id, limit) ;
590+ let reachable_total = reachable_count ( & report, file_id) ;
591+
592+ Ok ( json ! ( {
593+ "root" : report. snapshot. root,
594+ "file_id" : file_id,
595+ "file" : file. path,
596+ "blast_radius" : reachable_total,
597+ "reachable_files" : reachable,
598+ "limit" : limit
599+ } ) )
600+ }
601+
602+ fn level_tool ( args : & Value ) -> Result < Value > {
603+ let ( root, health) = health_from_args ( args) ?;
604+ let module = args. get ( "module" ) . and_then ( Value :: as_str) ;
605+ let levels = & health. metrics . architecture . levels ;
606+
607+ if let Some ( module) = module {
608+ return Ok ( json ! ( {
609+ "root" : root,
610+ "module" : module,
611+ "level" : levels. get( module) ,
612+ "found" : levels. contains_key( module)
613+ } ) ) ;
614+ }
615+
616+ Ok ( json ! ( {
617+ "root" : root,
618+ "levels" : levels,
619+ "total" : levels. len( )
620+ } ) )
621+ }
622+
459623fn session_start_tool ( args : & Value , state : & mut McpState ) -> Result < Value > {
460624 let root = root_arg ( args) ?;
461625 let baseline_path = baseline_dir_arg ( args, & root) . ok ( ) ;
@@ -781,6 +945,92 @@ fn baseline_dir_arg(args: &Value, root: &Path) -> Result<PathBuf> {
781945 . map ( |path| path. unwrap_or_else ( || root. join ( ".raysense/baseline" ) ) )
782946}
783947
948+ fn find_file_id ( report : & raysense_core:: ScanReport , requested : & str ) -> Option < usize > {
949+ let requested = requested. replace ( '\\' , "/" ) ;
950+ report
951+ . files
952+ . iter ( )
953+ . find ( |file| normalize_path ( & file. path ) == requested)
954+ . or_else ( || {
955+ report
956+ . files
957+ . iter ( )
958+ . find ( |file| normalize_path ( & file. path ) . ends_with ( & requested) )
959+ } )
960+ . map ( |file| file. file_id )
961+ }
962+
963+ fn reachable_files ( report : & raysense_core:: ScanReport , start : usize , limit : usize ) -> Vec < Value > {
964+ let adjacency = local_adjacency ( report) ;
965+ let mut seen = HashSet :: new ( ) ;
966+ let mut queue = VecDeque :: new ( ) ;
967+ let mut out = Vec :: new ( ) ;
968+ seen. insert ( start) ;
969+ queue. push_back ( start) ;
970+
971+ while let Some ( file_id) = queue. pop_front ( ) {
972+ let Some ( next_files) = adjacency. get ( & file_id) else {
973+ continue ;
974+ } ;
975+ for next in next_files {
976+ if !seen. insert ( * next) {
977+ continue ;
978+ }
979+ queue. push_back ( * next) ;
980+ if out. len ( ) < limit {
981+ if let Some ( file) = report. files . get ( * next) {
982+ out. push ( json ! ( {
983+ "file_id" : file. file_id,
984+ "path" : file. path,
985+ "module" : file. module,
986+ "language" : file. language_name
987+ } ) ) ;
988+ }
989+ }
990+ }
991+ }
992+
993+ out
994+ }
995+
996+ fn reachable_count ( report : & raysense_core:: ScanReport , start : usize ) -> usize {
997+ let adjacency = local_adjacency ( report) ;
998+ let mut seen = HashSet :: new ( ) ;
999+ let mut queue = VecDeque :: new ( ) ;
1000+ seen. insert ( start) ;
1001+ queue. push_back ( start) ;
1002+
1003+ while let Some ( file_id) = queue. pop_front ( ) {
1004+ let Some ( next_files) = adjacency. get ( & file_id) else {
1005+ continue ;
1006+ } ;
1007+ for next in next_files {
1008+ if seen. insert ( * next) {
1009+ queue. push_back ( * next) ;
1010+ }
1011+ }
1012+ }
1013+
1014+ seen. len ( ) . saturating_sub ( 1 )
1015+ }
1016+
1017+ fn local_adjacency ( report : & raysense_core:: ScanReport ) -> HashMap < usize , Vec < usize > > {
1018+ let mut adjacency: HashMap < usize , Vec < usize > > = HashMap :: new ( ) ;
1019+ for import in & report. imports {
1020+ let Some ( to_file) = import. resolved_file else {
1021+ continue ;
1022+ } ;
1023+ if import. resolution == ImportResolution :: Local && import. from_file != to_file {
1024+ adjacency. entry ( import. from_file ) . or_default ( ) . push ( to_file) ;
1025+ }
1026+ }
1027+ adjacency
1028+ }
1029+
1030+ fn normalize_path ( path : & Path ) -> String {
1031+ path. to_string_lossy ( ) . replace ( '\\' , "/" )
1032+ }
1033+
7841034fn root_arg ( args : & Value ) -> Result < PathBuf > {
7851035 Ok ( match args. get ( "path" ) . and_then ( Value :: as_str) {
7861036 Some ( path) => PathBuf :: from ( path) ,
@@ -1134,6 +1384,31 @@ fn health_limit_schema(limit_description: &str) -> Value {
11341384 } )
11351385}
11361386
1387+ fn blast_radius_schema ( ) -> Value {
1388+ json ! ( {
1389+ "type" : "object" ,
1390+ "properties" : {
1391+ "path" : { "type" : "string" , "description" : "Project root. Defaults to the current directory." } ,
1392+ "config_path" : { "type" : "string" , "description" : "Explicit config file. Defaults to <path>/.raysense.toml when present." } ,
1393+ "config" : config_schema( ) ,
1394+ "file" : { "type" : "string" , "description" : "Optional scanned file path. Defaults to the current max blast-radius file." } ,
1395+ "limit" : { "type" : "integer" , "minimum" : 1 , "description" : "Maximum reachable files to return. Defaults to 100." }
1396+ }
1397+ } )
1398+ }
1399+
1400+ fn level_schema ( ) -> Value {
1401+ json ! ( {
1402+ "type" : "object" ,
1403+ "properties" : {
1404+ "path" : { "type" : "string" , "description" : "Project root. Defaults to the current directory." } ,
1405+ "config_path" : { "type" : "string" , "description" : "Explicit config file. Defaults to <path>/.raysense.toml when present." } ,
1406+ "config" : config_schema( ) ,
1407+ "module" : { "type" : "string" , "description" : "Optional module name. When omitted, all module levels are returned." }
1408+ }
1409+ } )
1410+ }
1411+
11371412fn baseline_schema ( path_description : & str ) -> Value {
11381413 json ! ( {
11391414 "type" : "object" ,
@@ -1241,6 +1516,12 @@ mod tests {
12411516 assert ! ( names. contains( & "raysense_hotspots" ) ) ;
12421517 assert ! ( names. contains( & "raysense_rules" ) ) ;
12431518 assert ! ( names. contains( & "raysense_module_edges" ) ) ;
1519+ assert ! ( names. contains( & "raysense_architecture" ) ) ;
1520+ assert ! ( names. contains( & "raysense_coupling" ) ) ;
1521+ assert ! ( names. contains( & "raysense_cycles" ) ) ;
1522+ assert ! ( names. contains( & "raysense_hottest" ) ) ;
1523+ assert ! ( names. contains( & "raysense_blast_radius" ) ) ;
1524+ assert ! ( names. contains( & "raysense_level" ) ) ;
12441525 assert ! ( names. contains( & "raysense_session_start" ) ) ;
12451526 assert ! ( names. contains( & "raysense_session_end" ) ) ;
12461527 assert ! ( names. contains( & "raysense_rescan" ) ) ;
0 commit comments