Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 15 additions & 54 deletions src/adapter/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,54 +22,7 @@ impl super::Adapter for McpAdapter {
}

fn detect(&self, root: &Path) -> bool {
// Check package.json for MCP SDK
let pkg_json = root.join("package.json");
if pkg_json.exists() {
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if content.contains("@modelcontextprotocol/sdk") || content.contains("mcp-server") {
return true;
}
}
}

// Check pyproject.toml for mcp dependency
let pyproject = root.join("pyproject.toml");
if pyproject.exists() {
if let Ok(content) = std::fs::read_to_string(&pyproject) {
if content.contains("mcp") {
return true;
}
}
}

// Check for Python files importing mcp
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "py") {
if let Ok(content) = std::fs::read_to_string(&path) {
if content.contains("from mcp")
|| content.contains("import mcp")
|| content.contains("@server.tool")
{
return true;
}
}
}
}
}

// Check requirements.txt
let requirements = root.join("requirements.txt");
if requirements.exists() {
if let Ok(content) = std::fs::read_to_string(&requirements) {
if content.lines().any(|l| l.trim().starts_with("mcp")) {
return true;
}
}
}

false
super::mcp_metadata::metadata_root_for_scan(root).is_some()
}

fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
Expand All @@ -78,6 +31,8 @@ impl super::Adapter for McpAdapter {
}

fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
let metadata_root =
super::mcp_metadata::metadata_root_for_scan(root).unwrap_or_else(|| root.to_path_buf());
let name = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
Expand Down Expand Up @@ -137,18 +92,24 @@ impl super::Adapter for McpAdapter {
}
}

// Parse dependencies (metadata files honor the path filter)
let dependencies = parse_dependencies(root, filter);

// Parse provenance from package.json or pyproject.toml (filtered)
let provenance = parse_provenance(root, filter);
let (dependencies, provenance) = if super::mcp_metadata::same_path(root, &metadata_root) {
(
parse_dependencies(root, filter),
parse_provenance(root, filter),
)
} else {
(
parse_dependencies(&metadata_root, filter),
parse_provenance(&metadata_root, filter),
)
};

let data = build_data_surface(&tools, &execution);

Ok(vec![ScanTarget {
name,
framework: Framework::Mcp,
root_path: root.to_path_buf(),
root_path: metadata_root,
tools,
execution,
data,
Expand Down
122 changes: 122 additions & 0 deletions src/adapter/mcp_metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
use std::path::{Path, PathBuf};

pub(super) fn metadata_root_for_scan(scan_root: &Path) -> Option<PathBuf> {
if has_mcp_metadata(scan_root) {
return Some(scan_root.to_path_buf());
}

if let Some(metadata_root) = ancestor_metadata_root(scan_root) {
if contains_mcp_tool_source(scan_root) {
return Some(metadata_root);
}
}

contains_mcp_sdk_source(scan_root).then(|| scan_root.to_path_buf())
}

pub(super) fn same_path(left: &Path, right: &Path) -> bool {
let normalized_left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
let normalized_right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
normalized_left == normalized_right
}

fn ancestor_metadata_root(scan_root: &Path) -> Option<PathBuf> {
scan_root
.ancestors()
.skip(1)
.find(|ancestor| has_mcp_metadata(ancestor))
.map(Path::to_path_buf)
}

fn has_mcp_metadata(root: &Path) -> bool {
package_json_declares_mcp(root)
|| pyproject_declares_mcp(root)
|| requirements_declare_mcp(root)
|| root.join("mcp.json").exists()
|| root.join("mcp-config.json").exists()
}

fn package_json_declares_mcp(root: &Path) -> bool {
let path = root.join("package.json");
std::fs::read_to_string(path).is_ok_and(|content| {
content.contains("@modelcontextprotocol/sdk") || content.contains("mcp-server")
})
}

fn pyproject_declares_mcp(root: &Path) -> bool {
std::fs::read_to_string(root.join("pyproject.toml"))
.is_ok_and(|content| content.contains("mcp"))
}

fn requirements_declare_mcp(root: &Path) -> bool {
std::fs::read_to_string(root.join("requirements.txt")).is_ok_and(|content| {
content
.lines()
.map(str::trim)
.any(|line| line.starts_with("mcp"))
})
}

fn contains_mcp_tool_source(root: &Path) -> bool {
contains_mcp_source(root, SourceDetectionMode::ToolSurface)
}

fn contains_mcp_sdk_source(root: &Path) -> bool {
contains_mcp_source(root, SourceDetectionMode::SdkUsage)
}

#[derive(Debug, Clone, Copy)]
enum SourceDetectionMode {
SdkUsage,
ToolSurface,
}

fn contains_mcp_source(root: &Path, mode: SourceDetectionMode) -> bool {
let walker = ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.max_depth(Some(5))
.build();

for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if !is_mcp_source_candidate(path) {
continue;
}
if std::fs::read_to_string(path)
.is_ok_and(|content| source_mentions_mcp(path, &content, mode))
{
return true;
}
}

false
}

fn is_mcp_source_candidate(path: &Path) -> bool {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("py" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs")
)
}

fn source_mentions_mcp(path: &Path, content: &str, mode: SourceDetectionMode) -> bool {
match path.extension().and_then(|extension| extension.to_str()) {
Some("py") => {
content.contains("from mcp")
|| content.contains("import mcp")
|| matches!(mode, SourceDetectionMode::ToolSurface)
&& content.contains("@server.tool")
}
Some("ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs") => {
content.contains("@modelcontextprotocol/sdk")
|| content.contains("McpServer")
|| matches!(mode, SourceDetectionMode::ToolSurface)
&& (content.contains(".registerTool(") || content.contains(".tool("))
}
Some(_) | None => false,
}
}
1 change: 1 addition & 0 deletions src/adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod gpt_actions;
pub mod hermes;
pub mod langchain;
pub mod mcp;
pub(super) mod mcp_metadata;
pub mod openclaw;

use std::path::Path;
Expand Down
6 changes: 6 additions & 0 deletions src/ux.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
mod hotspots;
mod roots;

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

Expand Down Expand Up @@ -134,6 +137,7 @@ pub fn render_explain(report: &ScanReport, options: &ExplainOptions) -> String {
"- Adapters: {}\n",
display_list(&coverage.frameworks, "none")
));
output.push_str(&roots::render(report));
output.push_str(&format!("- Targets: {}\n", coverage.targets));
output.push_str(&format!(
"- Source files parsed: {} ({})\n",
Expand Down Expand Up @@ -173,6 +177,8 @@ pub fn render_explain(report: &ScanReport, options: &ExplainOptions) -> String {
severity_counts(&report.findings)
));

output.push_str(&hotspots::render(report));

output.push_str("Next actions:\n");
for action in next_actions(report) {
output.push_str(&format!("- {action}\n"));
Expand Down
Loading
Loading