Skip to content

Commit 628b4f1

Browse files
committed
feat: add focused architecture mcp tools
1 parent 8fce020 commit 628b4f1

2 files changed

Lines changed: 293 additions & 10 deletions

File tree

README.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,13 @@ automatically. `--config` overrides that path.
116116

117117
`raysense mcp` runs a stdio MCP server for agents. It exposes tools to read and
118118
write config, run health, inspect scan facts, list dependency edges, read
119-
hotspots, read rule findings, read DSM module edges, and materialize memory
120-
table summaries. It can also save/diff baselines and query saved baseline
121-
tables with projection, filters, sorting, and pagination. Agent session tools
122-
can save an in-memory baseline, rescan, end the session, check rules, inspect
123-
evolution, inspect DSM data, inspect test gaps, and list configured language
124-
plugins.
119+
hotspots, read rule findings, read DSM module edges, inspect architecture,
120+
coupling, cycles, hottest files/functions, blast radius, module levels, and
121+
materialize memory table summaries. It can also save/diff baselines and query
122+
saved baseline tables with projection, filters, sorting, and pagination. Agent
123+
session tools can save an in-memory baseline, rescan, end the session, check
124+
rules, inspect evolution, inspect DSM data, inspect test gaps, and list
125+
configured language plugins.
125126

126127
Baselines are stored under `<path>/.raysense/baseline` by default. The manifest
127128
is JSON for fast agent diffs, and baseline tables are written under `tables/`
@@ -282,10 +283,11 @@ and formats:
282283
- Rule thresholds can be configured with TOML.
283284
- Forbidden top-level module dependencies can be configured with TOML.
284285
- Config read/write, health runs, scan facts, edges, hotspots, rule findings,
285-
module edges, session start/end, rescans, rule checks, evolution, DSM, test
286-
gaps, plugin listing, remediation suggestions, trend metrics, policy presets,
287-
memory summaries, and saved baseline table queries are exposed through the
288-
MCP interface.
286+
module edges, architecture, coupling, cycles, hottest files/functions, blast
287+
radius, module levels, session start/end, rescans, rule checks, evolution,
288+
DSM, test gaps, plugin listing, remediation suggestions, trend metrics,
289+
policy presets, memory summaries, and saved baseline table queries are
290+
exposed through the MCP interface.
289291
- Baseline save/diff is available through the CLI and MCP, with Rayforce
290292
splayed-table storage for baseline tables.
291293
- MCP session baselines are persisted by default and can be compared across

crates/raysense-cli/src/mcp.rs

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use raysense_memory::{
3131
BaselineTableQuery, BaselineTableSort,
3232
};
3333
use serde_json::{json, Value};
34+
use std::collections::{HashMap, HashSet, VecDeque};
3435
use std::fs;
3536
use std::io::{self, BufRead, Write};
3637
use 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+
459623
fn 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+
7841034
fn 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+
11371412
fn 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

Comments
 (0)