use std::path::{Path, PathBuf};
use crate::analysis::AnalysisBundle;
use serde_json::Value;
use crate::analysis::composite_flow::{SourceUnit, ToolFlowInput, build_composite_flow_candidates};
use crate::analysis::cross_file::apply_cross_file_sanitization;
use crate::config::ScanPathFilter;
use crate::error::Result;
use crate::ir::capability::{
project_declared_description, project_declared_permissions, project_observed_execution,
};
use crate::ir::execution_surface::ExecutionSurface;
use crate::ir::taint_builder::build_data_surface;
use crate::ir::*;
use crate::parser;
pub struct McpAdapter;
impl super::Adapter for McpAdapter {
fn framework(&self) -> Framework {
Framework::Mcp
}
fn detect(&self, root: &Path) -> bool {
super::mcp_metadata::metadata_root_for_scan(root).is_some()
}
fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
self.load_with_filter(root, &filter)
}
fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
Ok(load_mcp_target(root, filter)
.into_iter()
.map(|(target, _)| target)
.collect())
}
}
impl super::AnalysisAdapter for McpAdapter {
fn framework(&self) -> Framework {
Framework::Mcp
}
fn detect(&self, root: &Path) -> bool {
super::mcp_metadata::metadata_root_for_scan(root).is_some()
}
fn load_analysis_with_filter(
&self,
root: &Path,
filter: &ScanPathFilter,
) -> Result<Vec<AnalysisBundle>> {
load_mcp_analysis(root, filter)
}
}
pub(crate) struct McpAnalysisAdapter;
impl super::AnalysisAdapter for McpAnalysisAdapter {
fn framework(&self) -> Framework {
Framework::Mcp
}
fn detect(&self, root: &Path) -> bool {
super::mcp_metadata::metadata_root_for_scan(root).is_some()
}
fn load_analysis_with_filter(
&self,
root: &Path,
filter: &ScanPathFilter,
) -> Result<Vec<AnalysisBundle>> {
load_mcp_analysis(root, filter)
}
}
fn load_mcp_analysis(root: &Path, filter: &ScanPathFilter) -> Result<Vec<AnalysisBundle>> {
let (target, composite_tools) = load_mcp_target(root, filter)?;
let source_for_composite = target
.source_files
.iter()
.filter_map(|source_file| match source_file.language {
Language::TypeScript | Language::JavaScript => Some(SourceUnit {
path: &source_file.path,
content: &source_file.content,
}),
_ => None,
})
.collect::<Vec<_>>();
let mut tool_flow_inputs = Vec::new();
for tool in &composite_tools {
let Some(location) = &tool.handler_location else {
continue;
};
tool_flow_inputs.push(ToolFlowInput {
tool_name: tool.tool_name.clone(),
handler: location.clone(),
});
}
let composite_flows = build_composite_flow_candidates(&tool_flow_inputs, &source_for_composite);
Ok(vec![AnalysisBundle {
target,
composite_flows,
}])
}
fn load_mcp_target(
root: &Path,
filter: &ScanPathFilter,
) -> Result<(ScanTarget, Vec<ToolDeclForComposite>)> {
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())
.unwrap_or_else(|| "mcp-server".into());
let mut source_files = Vec::new();
let mut execution = ExecutionSurface::default();
let mut tool_declarations = Vec::new();
let mut python_tools = Vec::new();
collect_source_files_with_filter(root, filter, &mut source_files)?;
for source_file in &source_files {
match source_file.language {
Language::TypeScript | Language::JavaScript => {
tool_declarations.extend(extract_mcp_tool_declarations_from_source(
&source_file.path,
&source_file.content,
));
}
Language::Python => {
python_tools.extend(extract_mcp_tools_from_source(
&source_file.path,
&source_file.content,
));
}
_ => {}
}
}
let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
for sf in &source_files {
if let Some(parser) = parser::parser_for_language(sf.language) {
if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
parsed_files.push((sf.path.clone(), parsed));
}
}
}
apply_cross_file_sanitization(&mut parsed_files);
let operation_bindings = bind_mcp_tool_operations(&tool_declarations, &parsed_files);
debug_assert_eq!(operation_bindings.len(), tool_declarations.len());
debug_assert!(
operation_bindings
.iter()
.all(McpToolOperationBinding::is_consistent)
);
let mut tool_decls_for_composite = Vec::with_capacity(tool_declarations.len());
let mut tools = python_tools;
tools.reserve(tool_declarations.len());
for (declaration, binding) in tool_declarations.into_iter().zip(operation_bindings) {
let mut tool = declaration.tool;
if binding.handler_resolved {
project_observed_execution(&mut tool, &binding.execution);
}
tool.capability_observation_complete = binding.observation_complete;
tool_decls_for_composite.push(ToolDeclForComposite {
tool_name: tool.name.clone(),
handler_location: binding.handler_location.clone(),
});
tools.push(tool);
}
for (_, parsed) in &parsed_files {
execution.commands.extend(parsed.commands.clone());
execution
.file_operations
.extend(parsed.file_operations.clone());
execution
.network_operations
.extend(parsed.network_operations.clone());
execution.env_accesses.extend(parsed.env_accesses.clone());
execution.dynamic_exec.extend(parsed.dynamic_exec.clone());
}
let tools_json = root.join("tools.json");
if tools_json.exists() && filter.allows_path(root, &tools_json) {
if let Ok(content) = std::fs::read_to_string(&tools_json) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
tools.extend(parser::json_schema::parse_tools_from_json(&value));
tools = dedupe_tools_by_name(tools);
}
}
}
for tool in &mut tools {
project_declared_permissions(tool);
project_declared_description(tool);
}
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);
let target = ScanTarget {
name,
framework: Framework::Mcp,
root_path: metadata_root,
tools,
execution,
data,
dependencies,
provenance,
source_files,
};
Ok((target, tool_decls_for_composite))
}
struct ToolDeclForComposite {
tool_name: String,
handler_location: Option<SourceLocation>,
}
pub fn is_test_file(path: &Path) -> bool {
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name = name.to_string_lossy();
if matches!(
name.as_ref(),
"test" | "tests" | "__tests__" | "__pycache__"
) {
return true;
}
}
}
let file_name = match path.file_name() {
Some(n) => n.to_string_lossy(),
None => return false,
};
let file_name = file_name.as_ref();
if matches!(file_name, "conftest.py" | "pytest.ini" | "setup.cfg")
|| file_name.starts_with("jest.config.")
|| file_name.starts_with("vitest.config.")
{
return true;
}
if file_name.ends_with(".py")
&& (file_name.starts_with("test_") || file_name.ends_with("_test.py"))
{
return true;
}
for suffix in [
".test.ts",
".test.js",
".test.tsx",
".test.jsx",
".test.py",
".test.sh",
".spec.ts",
".spec.js",
".spec.tsx",
".spec.jsx",
".spec.py",
".spec.sh",
] {
if file_name.ends_with(suffix) {
return true;
}
}
false
}
pub(crate) fn has_recursive_python_import(root: &Path, needles: &[&str]) -> bool {
let walker = ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.build();
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("py") {
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
if needles.iter().any(|needle| content.contains(needle)) {
return true;
}
}
}
false
}
#[derive(Debug, Clone)]
struct McpToolDeclaration {
tool: ToolSurface,
handler: Option<McpToolHandler>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum McpToolHandler {
Named { symbol: String },
Inline { location: SourceLocation },
}
#[derive(Debug, Clone)]
struct McpToolOperationBinding {
execution: ExecutionSurface,
handler_resolved: bool,
observation_complete: bool,
resolved_callees: Vec<String>,
handler_location: Option<SourceLocation>,
}
impl McpToolOperationBinding {
fn is_consistent(&self) -> bool {
self.handler_resolved
|| (self.execution.commands.is_empty()
&& self.execution.file_operations.is_empty()
&& self.execution.network_operations.is_empty()
&& self.execution.env_accesses.is_empty()
&& self.execution.dynamic_exec.is_empty()
&& !self.observation_complete
&& self.resolved_callees.is_empty())
}
}
#[cfg(feature = "typescript")]
struct ResolvedMcpHandler {
span: SourceLocation,
caller: Option<String>,
}
fn extract_mcp_tool_declarations_from_source(
path: &Path,
content: &str,
) -> Vec<McpToolDeclaration> {
let mut declarations = Vec::new();
let mut offset = 0;
while let Some(relative_start) = find_next_mcp_tool_call(&content[offset..]) {
let call_start = offset + relative_start;
let Some(open_paren) = content[call_start..].find('(').map(|pos| call_start + pos) else {
break;
};
let Some(close_paren) = find_matching_delimiter(content, open_paren, b'(', b')') else {
break;
};
let arguments = top_level_segments(content, open_paren + 1, close_paren);
let Some(&(name_start, _)) = arguments.first() else {
offset = close_paren + 1;
continue;
};
let Some((name, _)) = parse_string_literal_at(content, name_start) else {
offset = close_paren + 1;
continue;
};
let description = arguments.get(1).and_then(|&(start, end)| {
parse_string_literal_at(content, start)
.filter(|(_, after)| *after <= end)
.map(|(value, _)| value)
.or_else(|| parse_object_string_property(content, start, end, "description"))
});
let handler = arguments
.last()
.and_then(|&(start, end)| parse_mcp_tool_handler(path, content, start, end));
let line = content[..call_start].lines().count() + 1;
declarations.push(McpToolDeclaration {
tool: ToolSurface {
name,
description,
input_schema: None,
output_schema: None,
declared_permissions: Vec::new(),
defined_at: Some(source_loc(path, line)),
declared_capabilities: Default::default(),
capability_declarations: Vec::new(),
observed_capabilities: Default::default(),
capability_observation_complete: false,
capability_evidence: Vec::new(),
},
handler,
});
offset = close_paren + 1;
}
dedupe_mcp_tool_declarations(declarations)
}
#[cfg(feature = "typescript")]
fn bind_mcp_tool_operations(
declarations: &[McpToolDeclaration],
parsed_files: &[(PathBuf, parser::ParsedFile)],
) -> Vec<McpToolOperationBinding> {
declarations
.iter()
.map(|declaration| {
let Some(handler) = resolve_handler(declaration, parsed_files) else {
return McpToolOperationBinding {
execution: ExecutionSurface::default(),
handler_resolved: false,
observation_complete: false,
resolved_callees: Vec::new(),
handler_location: None,
};
};
let mut resolved_callees = call_sites_for_handler(parsed_files, &handler)
.filter_map(|call_site| {
resolve_unique_function_span(&call_site.callee, parsed_files)
.map(|span| (call_site.callee.clone(), span))
})
.collect::<Vec<_>>();
resolved_callees.sort_by(|left, right| left.0.cmp(&right.0));
resolved_callees.dedup_by(|left, right| left.0 == right.0);
let handler_span = handler.span.clone();
let mut scopes = Vec::with_capacity(resolved_callees.len() + 1);
scopes.push((handler_span.clone(), handler.caller));
scopes.extend(
resolved_callees
.iter()
.map(|(name, span)| (span.clone(), Some(name.clone()))),
);
let execution = execution_within_scopes(parsed_files, &scopes);
let observation_complete =
binding_observation_complete(parsed_files, &scopes, &resolved_callees, &execution);
McpToolOperationBinding {
execution,
handler_resolved: true,
observation_complete,
resolved_callees: resolved_callees.into_iter().map(|(name, _)| name).collect(),
handler_location: Some(handler_span),
}
})
.collect()
}
#[cfg(not(feature = "typescript"))]
fn bind_mcp_tool_operations(
declarations: &[McpToolDeclaration],
_parsed_files: &[(PathBuf, parser::ParsedFile)],
) -> Vec<McpToolOperationBinding> {
declarations
.iter()
.map(|_| McpToolOperationBinding {
execution: ExecutionSurface::default(),
handler_resolved: false,
observation_complete: false,
resolved_callees: Vec::new(),
handler_location: None,
})
.collect()
}
#[cfg(feature = "typescript")]
fn resolve_handler(
declaration: &McpToolDeclaration,
parsed_files: &[(PathBuf, parser::ParsedFile)],
) -> Option<ResolvedMcpHandler> {
match declaration.handler.as_ref()? {
McpToolHandler::Inline { location } => parsed_files
.iter()
.any(|(path, _)| path == &location.file)
.then(|| ResolvedMcpHandler {
span: location.clone(),
caller: None,
}),
McpToolHandler::Named { symbol } => {
resolve_unique_function_span(symbol, parsed_files).map(|span| ResolvedMcpHandler {
span,
caller: Some(symbol.clone()),
})
}
}
}
#[cfg(feature = "typescript")]
fn resolve_unique_function_span(
symbol: &str,
parsed_files: &[(PathBuf, parser::ParsedFile)],
) -> Option<SourceLocation> {
let mut matches = parsed_files.iter().flat_map(|(_, parsed)| {
parsed
.function_defs
.iter()
.filter(move |definition| definition.name == symbol)
});
let location = matches.next()?.location.clone();
matches.next().is_none().then_some(location)
}
#[cfg(feature = "typescript")]
fn call_sites_for_handler<'a>(
parsed_files: &'a [(PathBuf, parser::ParsedFile)],
handler: &'a ResolvedMcpHandler,
) -> impl Iterator<Item = &'a parser::CallSite> {
parsed_files
.iter()
.flat_map(|(_, parsed)| parsed.call_sites.iter())
.filter(|call_site| {
location_within_span(&call_site.location, &handler.span)
&& match handler.caller.as_deref() {
Some(caller) => call_site.caller.as_deref() == Some(caller),
None => call_site.caller.is_none(),
}
})
}
#[cfg(feature = "typescript")]
fn execution_within_scopes(
parsed_files: &[(PathBuf, parser::ParsedFile)],
scopes: &[(SourceLocation, Option<String>)],
) -> ExecutionSurface {
let contains = |location: &SourceLocation| {
scopes.iter().any(|(span, function_name)| {
operation_belongs_to_scope(location, span, function_name.as_deref(), parsed_files)
})
};
let mut execution = ExecutionSurface::default();
for (_, parsed) in parsed_files {
execution.commands.extend(
parsed
.commands
.iter()
.filter(|operation| contains(&operation.location))
.cloned(),
);
execution.file_operations.extend(
parsed
.file_operations
.iter()
.filter(|operation| contains(&operation.location))
.cloned(),
);
execution.network_operations.extend(
parsed
.network_operations
.iter()
.filter(|operation| contains(&operation.location))
.cloned(),
);
execution.env_accesses.extend(
parsed
.env_accesses
.iter()
.filter(|operation| contains(&operation.location))
.cloned(),
);
execution.dynamic_exec.extend(
parsed
.dynamic_exec
.iter()
.filter(|operation| contains(&operation.location))
.cloned(),
);
}
execution
}
#[cfg(feature = "typescript")]
fn binding_observation_complete(
parsed_files: &[(PathBuf, parser::ParsedFile)],
scopes: &[(SourceLocation, Option<String>)],
resolved_callees: &[(String, SourceLocation)],
execution: &ExecutionSurface,
) -> bool {
if !execution.dynamic_exec.is_empty() {
return false;
}
parsed_files
.iter()
.flat_map(|(_, parsed)| parsed.call_sites.iter())
.filter(|call_site| {
scopes.iter().any(|(span, function_name)| {
operation_belongs_to_scope(
&call_site.location,
span,
function_name.as_deref(),
parsed_files,
)
})
})
.all(|call_site| {
call_is_modeled(call_site, execution)
|| resolved_callees
.iter()
.any(|(name, _)| name == &call_site.callee)
})
}
#[cfg(feature = "typescript")]
fn call_is_modeled(call_site: &parser::CallSite, execution: &ExecutionSurface) -> bool {
execution
.commands
.iter()
.any(|operation| operation.location == call_site.location)
|| execution
.file_operations
.iter()
.any(|operation| operation.location == call_site.location)
|| execution
.network_operations
.iter()
.any(|operation| operation.location == call_site.location)
|| execution
.env_accesses
.iter()
.any(|operation| operation.location == call_site.location)
}
#[cfg(feature = "typescript")]
fn operation_belongs_to_scope(
location: &SourceLocation,
span: &SourceLocation,
function_name: Option<&str>,
parsed_files: &[(PathBuf, parser::ParsedFile)],
) -> bool {
if !location_within_span(location, span) {
return false;
}
let innermost = parsed_files
.iter()
.flat_map(|(_, parsed)| parsed.function_defs.iter())
.filter(|definition| {
location_within_span(&definition.location, span)
&& location_within_span(location, &definition.location)
})
.max_by_key(|definition| (definition.location.line, definition.location.column));
match (function_name, innermost) {
(Some(expected), Some(definition)) => definition.name == expected,
(Some(_), None) => false,
(None, None) => true,
(None, Some(_)) => false,
}
}
#[cfg(feature = "typescript")]
fn location_within_span(location: &SourceLocation, span: &SourceLocation) -> bool {
if location.file != span.file {
return false;
}
let start = (location.line, location.column);
let span_start = (span.line, span.column);
let span_end = (
span.end_line.unwrap_or(span.line),
span.end_column.unwrap_or(usize::MAX),
);
start >= span_start && start < span_end
}
fn find_next_mcp_tool_call(content: &str) -> Option<usize> {
let mut cursor = 0;
while cursor < content.len() {
if let Some(next) = skip_js_string_or_comment(content, cursor, content.len()) {
cursor = next;
continue;
}
if content[cursor..].starts_with(".tool(")
|| content[cursor..].starts_with(".registerTool(")
{
return Some(cursor);
}
cursor += 1;
}
None
}
fn parse_mcp_tool_handler(
path: &Path,
content: &str,
start: usize,
end: usize,
) -> Option<McpToolHandler> {
let (start, end) = trim_range(content, start, end);
let candidate = &content[start..end];
if is_inline_handler(candidate) {
return Some(McpToolHandler::Inline {
location: source_loc_span(path, content, start, end),
});
}
is_js_symbol(candidate).then(|| McpToolHandler::Named {
symbol: candidate.to_string(),
})
}
fn is_inline_handler(candidate: &str) -> bool {
if candidate.starts_with('{') || candidate.starts_with('[') {
return false;
}
if is_function_expression(candidate) {
return true;
}
let arrow_candidate = candidate
.strip_prefix("async")
.filter(|rest| {
rest.starts_with('(') || rest.chars().next().is_some_and(char::is_whitespace)
})
.map(str::trim_start)
.unwrap_or(candidate);
if arrow_candidate.starts_with('(') {
return find_matching_delimiter(arrow_candidate, 0, b'(', b')')
.is_some_and(|close| arrow_candidate[close + 1..].trim_start().starts_with("=>"));
}
arrow_candidate
.split_once("=>")
.is_some_and(|(parameter, _)| is_js_identifier(parameter.trim()))
}
fn is_function_expression(candidate: &str) -> bool {
let candidate = candidate
.strip_prefix("async")
.filter(|rest| rest.chars().next().is_some_and(char::is_whitespace))
.map(str::trim_start)
.unwrap_or(candidate);
candidate.strip_prefix("function").is_some_and(|rest| {
rest.is_empty()
|| rest.starts_with('(')
|| rest.starts_with('*')
|| rest.chars().next().is_some_and(char::is_whitespace)
})
}
fn is_js_symbol(candidate: &str) -> bool {
let mut segments = candidate.split('.');
let Some(first) = segments.next() else {
return false;
};
!is_js_reserved_word(first) && is_js_identifier(first) && segments.all(is_js_identifier)
}
fn is_js_identifier(candidate: &str) -> bool {
let mut chars = candidate.chars();
chars
.next()
.is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '_' | '$'))
&& chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$'))
}
fn is_js_reserved_word(candidate: &str) -> bool {
matches!(
candidate,
"async"
| "await"
| "break"
| "case"
| "catch"
| "class"
| "const"
| "continue"
| "debugger"
| "default"
| "delete"
| "do"
| "else"
| "export"
| "extends"
| "false"
| "finally"
| "for"
| "function"
| "if"
| "import"
| "in"
| "instanceof"
| "let"
| "new"
| "null"
| "return"
| "static"
| "super"
| "switch"
| "this"
| "throw"
| "true"
| "try"
| "typeof"
| "undefined"
| "var"
| "void"
| "while"
| "with"
| "yield"
)
}
fn parse_object_string_property(
content: &str,
start: usize,
end: usize,
property: &str,
) -> Option<String> {
let (start, end) = trim_range(content, start, end);
if content.as_bytes().get(start) != Some(&b'{')
|| content.as_bytes().get(end.saturating_sub(1)) != Some(&b'}')
{
return None;
}
for (property_start, property_end) in top_level_segments(content, start + 1, end - 1) {
let Some(colon) = find_top_level_byte(content, property_start, property_end, b':') else {
continue;
};
let (key_start, key_end) = trim_range(content, property_start, colon);
let key = parse_string_literal_at(content, key_start)
.filter(|(_, after)| *after <= key_end)
.map(|(value, _)| value)
.unwrap_or_else(|| content[key_start..key_end].to_string());
if key != property {
continue;
}
let (value_start, value_end) = trim_range(content, colon + 1, property_end);
return parse_string_literal_at(content, value_start)
.filter(|(_, after)| *after <= value_end)
.map(|(value, _)| value);
}
None
}
fn top_level_segments(content: &str, start: usize, end: usize) -> Vec<(usize, usize)> {
let mut segments = Vec::new();
let mut segment_start = start;
let mut cursor = start;
let mut depths = [0usize; 3];
while cursor < end {
if let Some(next) = skip_js_string_or_comment(content, cursor, end) {
cursor = next;
continue;
}
match content.as_bytes()[cursor] {
b'(' => depths[0] += 1,
b')' => depths[0] = depths[0].saturating_sub(1),
b'{' => depths[1] += 1,
b'}' => depths[1] = depths[1].saturating_sub(1),
b'[' => depths[2] += 1,
b']' => depths[2] = depths[2].saturating_sub(1),
b',' if depths == [0, 0, 0] => {
let segment = trim_range(content, segment_start, cursor);
if segment.0 < segment.1 {
segments.push(segment);
}
segment_start = cursor + 1;
}
_ => {}
}
cursor += 1;
}
let segment = trim_range(content, segment_start, end);
if segment.0 < segment.1 {
segments.push(segment);
}
segments
}
fn find_matching_delimiter(
content: &str,
open: usize,
open_byte: u8,
close_byte: u8,
) -> Option<usize> {
let mut depth = 0usize;
let mut cursor = open;
while cursor < content.len() {
if let Some(next) = skip_js_string_or_comment(content, cursor, content.len()) {
cursor = next;
continue;
}
let byte = content.as_bytes()[cursor];
if byte == open_byte {
depth += 1;
} else if byte == close_byte {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(cursor);
}
}
cursor += 1;
}
None
}
fn find_top_level_byte(content: &str, start: usize, end: usize, needle: u8) -> Option<usize> {
let mut cursor = start;
let mut depths = [0usize; 3];
while cursor < end {
if let Some(next) = skip_js_string_or_comment(content, cursor, end) {
cursor = next;
continue;
}
let byte = content.as_bytes()[cursor];
if byte == needle && depths == [0, 0, 0] {
return Some(cursor);
}
match byte {
b'(' => depths[0] += 1,
b')' => depths[0] = depths[0].saturating_sub(1),
b'{' => depths[1] += 1,
b'}' => depths[1] = depths[1].saturating_sub(1),
b'[' => depths[2] += 1,
b']' => depths[2] = depths[2].saturating_sub(1),
_ => {}
}
cursor += 1;
}
None
}
fn skip_js_string_or_comment(content: &str, start: usize, end: usize) -> Option<usize> {
let bytes = content.as_bytes();
let quote = *bytes.get(start)?;
if matches!(quote, b'\'' | b'"' | b'`') {
let mut cursor = start + 1;
while cursor < end {
if bytes[cursor] == b'\\' {
cursor = (cursor + 2).min(end);
} else if bytes[cursor] == quote {
return Some(cursor + 1);
} else {
cursor += 1;
}
}
return Some(end);
}
if quote == b'/' && bytes.get(start + 1) == Some(&b'/') {
let mut cursor = start + 2;
while cursor < end && bytes[cursor] != b'\n' {
cursor += 1;
}
return Some(cursor);
}
if quote == b'/' && bytes.get(start + 1) == Some(&b'*') {
let mut cursor = start + 2;
while cursor + 1 < end {
if bytes[cursor] == b'*' && bytes[cursor + 1] == b'/' {
return Some(cursor + 2);
}
cursor += 1;
}
return Some(end);
}
None
}
fn trim_range(content: &str, mut start: usize, mut end: usize) -> (usize, usize) {
while start < end && content.as_bytes()[start].is_ascii_whitespace() {
start += 1;
}
while end > start && content.as_bytes()[end - 1].is_ascii_whitespace() {
end -= 1;
}
(start, end)
}
fn parse_string_literal_at(content: &str, offset: usize) -> Option<(String, usize)> {
let offset = skip_whitespace(content, offset);
let quote = content[offset..].chars().next()?;
if !matches!(quote, '\'' | '"' | '`') {
return None;
}
let mut value = String::new();
let mut escaped = false;
for (relative_index, ch) in content[offset + quote.len_utf8()..].char_indices() {
let absolute_index = offset + quote.len_utf8() + relative_index;
if escaped {
value.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == quote {
return Some((value, absolute_index + quote.len_utf8()));
}
value.push(ch);
}
None
}
fn extract_mcp_python_decorators(path: &Path, content: &str) -> Vec<ToolSurface> {
let mut tools = Vec::new();
let mut pending_tool_name: Option<String> = None;
let mut pending_description: Option<String> = None;
let mut pending_line: Option<usize> = None;
for (line_idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if let Some((explicit_name, description)) = parse_python_decorator_tool(trimmed) {
pending_tool_name = explicit_name;
pending_description = description;
pending_line = Some(line_idx + 1);
continue;
}
if pending_line.is_some() {
if let Some(name) = parse_python_function_name(trimmed) {
let tool_name = pending_tool_name.take().unwrap_or_else(|| name.to_string());
let description = pending_description.take();
tools.push(ToolSurface {
name: tool_name,
description,
input_schema: None,
output_schema: None,
declared_permissions: Vec::new(),
defined_at: Some(source_loc(path, pending_line.unwrap_or(line_idx + 1))),
declared_capabilities: Default::default(),
capability_declarations: Vec::new(),
observed_capabilities: Default::default(),
capability_observation_complete: false,
capability_evidence: Vec::new(),
});
pending_line = None;
continue;
}
if !trimmed.is_empty() && !trimmed.starts_with('@') && !trimmed.starts_with("\"\"\"") {
pending_tool_name = None;
pending_description = None;
pending_line = None;
}
}
}
dedupe_tools_by_name(tools)
}
fn extract_mcp_tools_from_source(path: &Path, content: &str) -> Vec<ToolSurface> {
let mut tools = if path.extension().and_then(|ext| ext.to_str()) == Some("py") {
extract_mcp_python_decorators(path, content)
} else {
Vec::new()
};
tools.extend(
extract_mcp_tool_declarations_from_source(path, content)
.into_iter()
.map(|declaration| declaration.tool),
);
dedupe_tools_by_name(tools)
}
fn parse_python_decorator_tool(line: &str) -> Option<(Option<String>, Option<String>)> {
let trimmed = line.trim();
if !trimmed.starts_with('@') {
return None;
}
if trimmed.ends_with(".tool") || trimmed == "@tool" {
return Some((None, None));
}
let call_idx = trimmed.find(".tool(").or_else(|| trimmed.find("tool("))?;
let open_paren = trimmed[call_idx..]
.find('(')
.and_then(|idx| call_idx.checked_add(idx + 1))?;
let Some((name, after_name)) = parse_string_literal_at(trimmed, open_paren) else {
let arg_slice = &trimmed[open_paren..];
return Some((
parse_python_kwarg_string_arg(arg_slice, "name"),
parse_python_kwarg_string_arg(arg_slice, "description"),
));
};
Some((Some(name), parse_next_string_argument(trimmed, after_name)))
}
fn parse_next_string_argument(content: &str, offset: usize) -> Option<String> {
let mut index = skip_whitespace(content, offset);
if content[index..].starts_with(',') {
index += 1;
} else {
return None;
}
let index = skip_whitespace(content, index);
parse_string_literal_at(content, index).map(|(value, _)| value)
}
fn parse_python_kwarg_string_arg(args: &str, key: &str) -> Option<String> {
let needle = format!("{key}=");
let idx = args.find(&needle)?;
let rest = &args[idx + needle.len()..];
let rest = rest.trim_start();
parse_string_literal_at(rest, 0).map(|(value, _)| value)
}
fn parse_python_function_name(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if !trimmed.starts_with("def ") && !trimmed.starts_with("async def ") {
return None;
}
if let Some(rest) = trimmed.strip_prefix("def ") {
let func = rest.split('(').next()?.trim();
if func.is_empty() {
return None;
}
return Some(func.to_string());
}
let rest = trimmed.strip_prefix("async def ")?;
let func = rest.split('(').next()?.trim();
if func.is_empty() {
return None;
}
Some(func.to_string())
}
fn skip_whitespace(content: &str, mut offset: usize) -> usize {
while let Some(ch) = content[offset..].chars().next() {
if !ch.is_whitespace() {
break;
}
offset += ch.len_utf8();
}
offset
}
fn dedupe_tools_by_name(tools: Vec<ToolSurface>) -> Vec<ToolSurface> {
let mut seen = std::collections::HashSet::new();
let mut deduped = Vec::new();
for tool in tools {
if seen.insert(tool.name.clone()) {
deduped.push(tool);
}
}
deduped
}
fn dedupe_mcp_tool_declarations(declarations: Vec<McpToolDeclaration>) -> Vec<McpToolDeclaration> {
let mut deduped: Vec<McpToolDeclaration> = Vec::new();
for declaration in declarations {
if let Some(existing) = deduped
.iter_mut()
.find(|existing| existing.tool.name == declaration.tool.name)
{
let existing_score = (
usize::from(existing.handler.is_some()),
usize::from(existing.tool.description.is_some()),
);
let new_score = (
usize::from(declaration.handler.is_some()),
usize::from(declaration.tool.description.is_some()),
);
if new_score > existing_score {
*existing = declaration;
}
} else {
deduped.push(declaration);
}
}
deduped
}
fn source_loc(file: &Path, line: usize) -> SourceLocation {
SourceLocation {
file: file.to_path_buf(),
line,
column: 0,
end_line: None,
end_column: None,
}
}
fn source_loc_span(file: &Path, content: &str, start: usize, end: usize) -> SourceLocation {
let start_line = content[..start].lines().count() + 1;
let start_column = content[..start]
.rsplit_once('\n')
.map_or(start, |(_, line)| line.len());
let end_line = content[..end].lines().count() + 1;
let end_column = content[..end]
.rsplit_once('\n')
.map_or(end, |(_, line)| line.len());
SourceLocation {
file: file.to_path_buf(),
line: start_line,
column: start_column,
end_line: Some(end_line),
end_column: Some(end_column),
}
}
pub(super) fn collect_source_files_with_filter(
root: &Path,
filter: &ScanPathFilter,
files: &mut Vec<SourceFile>,
) -> Result<()> {
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 filter.ignore_tests() && is_test_file(path) {
continue;
}
if !filter.allows_path(root, path) {
continue;
}
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
let lang = Language::from_extension(&ext);
if matches!(lang, Language::Unknown) {
continue;
}
let metadata = std::fs::metadata(path)?;
if metadata.len() > 1_048_576 {
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
let hash = format!(
"{:x}",
sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
);
files.push(SourceFile {
path: path.to_path_buf(),
language: lang,
size_bytes: metadata.len(),
content_hash: hash,
content,
});
}
}
Ok(())
}
pub(super) fn parse_dependencies(
root: &Path,
filter: &ScanPathFilter,
) -> dependency_surface::DependencySurface {
use crate::ir::dependency_surface::*;
let mut surface = DependencySurface::default();
let req_file = root.join("requirements.txt");
if req_file.exists() && filter.allows_path(root, &req_file) {
if let Ok(content) = std::fs::read_to_string(&req_file) {
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
continue;
}
let (name, version) = if let Some(pos) = line.find("==") {
(
line[..pos].trim().to_string(),
Some(line[pos + 2..].trim().to_string()),
)
} else if let Some(pos) = line.find(">=") {
(
line[..pos].trim().to_string(),
Some(line[pos..].trim().to_string()),
)
} else {
(line.to_string(), None)
};
surface.dependencies.push(Dependency {
name,
version_constraint: version,
locked_version: None,
locked_hash: None,
registry: "pypi".into(),
is_dev: false,
location: Some(SourceLocation {
file: req_file.clone(),
line: idx + 1,
column: 0,
end_line: None,
end_column: None,
}),
});
}
}
}
for (filename, format) in [
("Pipfile.lock", LockfileFormat::PipenvLock),
("poetry.lock", LockfileFormat::PoetryLock),
("uv.lock", LockfileFormat::UvLock),
] {
let lock_path = root.join(filename);
if lock_path.exists() && filter.allows_path(root, &lock_path) {
let content = std::fs::read_to_string(&lock_path).unwrap_or_default();
let (all_pinned, all_hashed) = detect_dependency_lock_confidence(format, &content);
surface.lockfile = Some(LockfileInfo {
path: lock_path,
format,
all_pinned,
all_hashed,
});
break;
}
}
let pkg_json = root.join("package.json");
if pkg_json.exists() && filter.allows_path(root, &pkg_json) {
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
for (key, is_dev) in [("dependencies", false), ("devDependencies", true)] {
if let Some(deps) = value.get(key).and_then(|v| v.as_object()) {
for (name, version) in deps {
let line = find_json_key_line(&content, name);
surface.dependencies.push(Dependency {
name: name.clone(),
version_constraint: version.as_str().map(|s| s.to_string()),
locked_version: None,
locked_hash: None,
registry: "npm".into(),
is_dev,
location: Some(SourceLocation {
file: pkg_json.clone(),
line,
column: 0,
end_line: None,
end_column: None,
}),
});
}
}
}
}
}
for (filename, format) in [
(
"package-lock.json",
dependency_surface::LockfileFormat::NpmLock,
),
(
"pnpm-lock.yaml",
dependency_surface::LockfileFormat::PnpmLock,
),
("yarn.lock", dependency_surface::LockfileFormat::YarnLock),
] {
let lock_path = root.join(filename);
if lock_path.exists() && filter.allows_path(root, &lock_path) {
let content = std::fs::read_to_string(&lock_path).unwrap_or_default();
let (all_pinned, all_hashed) = detect_dependency_lock_confidence(format, &content);
surface.lockfile = Some(LockfileInfo {
path: lock_path,
format,
all_pinned,
all_hashed,
});
break;
}
}
}
surface
}
fn detect_dependency_lock_confidence(
format: dependency_surface::LockfileFormat,
content: &str,
) -> (bool, bool) {
match format {
dependency_surface::LockfileFormat::PipenvLock => detect_pipenv_lock_confidence(content),
dependency_surface::LockfileFormat::PoetryLock => detect_poetry_lock_confidence(content),
dependency_surface::LockfileFormat::UvLock => detect_uv_lock_confidence(content),
dependency_surface::LockfileFormat::NpmLock => detect_npm_lock_confidence(content),
dependency_surface::LockfileFormat::PnpmLock => detect_pnpm_lock_confidence(content),
dependency_surface::LockfileFormat::YarnLock => detect_yarn_lock_confidence(content),
dependency_surface::LockfileFormat::PipRequirements => (false, false),
}
}
fn detect_pipenv_lock_confidence(content: &str) -> (bool, bool) {
let Ok(value) = serde_json::from_str::<Value>(content) else {
return (false, false);
};
let mut all_pinned = true;
let mut all_hashed = true;
let mut packages_seen = 0usize;
for bucket_name in ["default", "develop", "packages", "dev-packages"] {
if let Some(bucket) = value.get(bucket_name).and_then(|v| v.as_object()) {
for (_, meta) in bucket {
let Some(meta_obj) = meta.as_object() else {
continue;
};
packages_seen += 1;
let version = meta_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim();
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let has_hash = meta_obj
.get("hashes")
.and_then(|v| v.as_array())
.is_some_and(|hashes| !hashes.is_empty());
if !has_hash {
all_hashed = false;
}
}
}
}
if packages_seen == 0 {
(false, false)
} else {
(all_pinned, all_hashed)
}
}
fn detect_poetry_lock_confidence(content: &str) -> (bool, bool) {
let Ok(value) = content.parse::<toml::Value>() else {
return (false, false);
};
let mut all_pinned = true;
let mut all_hashed = true;
let mut packages_seen = 0usize;
let Some(packages) = value.get("package").and_then(|v| v.as_array()) else {
return (false, false);
};
for pkg in packages {
let Some(pkg_obj) = pkg.as_table() else {
continue;
};
packages_seen += 1;
let version = pkg_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let mut package_hashed = false;
if let Some(files) = pkg_obj.get("files").and_then(|v| v.as_array()) {
if files.iter().any(|entry| {
entry
.as_table()
.is_some_and(|file_entry| file_entry.get("hash").is_some())
}) {
package_hashed = true;
}
}
if !package_hashed {
all_hashed = false;
}
}
if packages_seen == 0 {
(false, false)
} else {
(all_pinned, all_hashed)
}
}
fn detect_uv_lock_confidence(content: &str) -> (bool, bool) {
let Ok(value) = content.parse::<toml::Value>() else {
return (false, false);
};
let mut all_pinned = true;
let mut all_hashed = true;
let mut packages_seen = 0usize;
let Some(packages) = value
.get("package")
.or_else(|| value.get("packages"))
.and_then(|v| v.as_array())
else {
return (false, false);
};
for pkg in packages {
let Some(pkg_obj) = pkg.as_table() else {
continue;
};
packages_seen += 1;
let version = pkg_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let has_hash = pkg_obj.get("hash").is_some()
|| pkg_obj
.get("hashes")
.is_some_and(|v| !v.as_array().is_none_or(|arr| arr.is_empty()));
if !has_hash {
all_hashed = false;
}
}
if packages_seen == 0 {
(false, false)
} else {
(all_pinned, all_hashed)
}
}
fn detect_npm_lock_confidence(content: &str) -> (bool, bool) {
let Ok(value) = serde_json::from_str::<Value>(content) else {
return (false, false);
};
let mut all_pinned = true;
let mut all_hashed = true;
let mut packages_seen = 0usize;
if let Some(packages) = value.get("packages").and_then(|v| v.as_object()) {
for (_, pkg_value) in packages {
if let Some(pkg_obj) = pkg_value.as_object() {
packages_seen += 1;
let version = pkg_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let has_integrity = pkg_obj
.get("integrity")
.and_then(|v| v.as_str())
.is_some_and(|v| !v.trim().is_empty());
if !has_integrity {
all_hashed = false;
}
}
}
}
if let Some(dependencies) = value.get("dependencies").and_then(|v| v.as_object()) {
for (_, dep_value) in dependencies {
if let Some(dep_obj) = dep_value.as_object() {
if let Some(version) = dep_obj.get("version").and_then(|v| v.as_str()) {
packages_seen += 1;
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let has_integrity = dep_obj
.get("integrity")
.and_then(|v| v.as_str())
.is_some_and(|v| !v.trim().is_empty());
if !has_integrity {
all_hashed = false;
}
} else if let Some(nested_deps) =
dep_obj.get("dependencies").and_then(|v| v.as_object())
{
for (_, nested_dep_value) in nested_deps {
if let Some(nested_obj) = nested_dep_value.as_object() {
packages_seen += 1;
let version = nested_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let has_integrity = nested_obj
.get("integrity")
.and_then(|v| v.as_str())
.is_some_and(|v| !v.trim().is_empty());
if !has_integrity {
all_hashed = false;
}
}
}
}
}
}
}
if packages_seen == 0 {
(false, false)
} else {
(all_pinned, all_hashed)
}
}
fn detect_pnpm_lock_confidence(content: &str) -> (bool, bool) {
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(content) else {
return (false, false);
};
let mut all_pinned = true;
let mut all_hashed = true;
let mut packages_seen = 0usize;
let Some(packages) = value
.as_mapping()
.and_then(|m| m.get("packages"))
.and_then(|v| v.as_mapping())
else {
return (false, false);
};
for (_key, package_value) in packages {
let Some(package_obj) = package_value.as_mapping() else {
continue;
};
packages_seen += 1;
let version = package_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim();
if !is_exact_pinned_version(version) {
all_pinned = false;
}
let has_integrity = package_obj
.get("resolution")
.and_then(|v| v.as_mapping())
.and_then(|r| r.get("integrity"))
.and_then(|v| v.as_str())
.is_some_and(|v| !v.trim().is_empty())
|| package_obj
.get("integrity")
.and_then(|v| v.as_str())
.is_some_and(|v| !v.trim().is_empty());
if !has_integrity {
all_hashed = false;
}
}
if packages_seen == 0 {
(false, false)
} else {
(all_pinned, all_hashed)
}
}
fn detect_yarn_lock_confidence(content: &str) -> (bool, bool) {
let mut all_pinned = true;
let mut all_hashed = true;
let mut packages_seen = 0usize;
let mut in_package_block = false;
let mut current_has_version = false;
let mut current_has_integrity = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let is_package_header =
!raw_line.starts_with(' ') && !raw_line.starts_with('\t') && line.ends_with(':');
if is_package_header {
if in_package_block {
if !current_has_version {
all_pinned = false;
}
if !current_has_integrity {
all_hashed = false;
}
}
in_package_block = !line.starts_with("__");
current_has_version = false;
current_has_integrity = false;
if in_package_block {
packages_seen += 1;
}
continue;
}
if !in_package_block {
continue;
}
if raw_line.starts_with(' ') || raw_line.starts_with('\t') {
if line.starts_with("version ") {
current_has_version = true;
}
if line.starts_with("resolved ") || line.starts_with("integrity ") {
current_has_integrity = true;
}
}
}
if in_package_block {
if !current_has_version {
all_pinned = false;
}
if !current_has_integrity {
all_hashed = false;
}
}
if packages_seen == 0 {
(false, false)
} else {
(all_pinned, all_hashed)
}
}
fn is_exact_pinned_version(version: &str) -> bool {
let version = version.trim();
if version.is_empty() {
return false;
}
let normalized = version
.trim_start_matches("==")
.trim_start_matches("~=")
.trim_start_matches('=')
.trim_start_matches("v")
.trim();
if normalized.contains('*')
|| normalized.contains('x')
|| normalized.contains('>')
|| normalized.contains('<')
|| normalized.contains('^')
|| normalized.contains('~')
|| normalized.contains('|')
|| normalized.contains(',')
|| normalized.contains(' ')
{
return false;
}
true
}
fn find_json_key_line(content: &str, key: &str) -> usize {
let needle = format!("\"{}\"", key);
for (idx, line) in content.lines().enumerate() {
if line.contains(&needle) {
return idx + 1;
}
}
1
}
pub(super) fn parse_provenance(
root: &Path,
filter: &ScanPathFilter,
) -> provenance_surface::ProvenanceSurface {
let mut prov = provenance_surface::ProvenanceSurface::default();
let pkg_json = root.join("package.json");
if pkg_json.exists() && filter.allows_path(root, &pkg_json) {
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
prov.author = value
.get("author")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
prov.repository = value
.get("repository")
.and_then(|v| v.get("url").or(Some(v)))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
prov.license = value
.get("license")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
}
let pyproject = root.join("pyproject.toml");
if pyproject.exists() && filter.allows_path(root, &pyproject) {
if let Ok(content) = std::fs::read_to_string(&pyproject) {
if let Ok(value) = content.parse::<toml::Value>() {
if let Some(project) = value.get("project") {
prov.license = project
.get("license")
.and_then(|v| v.get("text").or(Some(v)))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(authors) = project.get("authors").and_then(|v| v.as_array()) {
if let Some(first) = authors.first() {
prov.author = first
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
}
if let Some(urls) = value.get("project").and_then(|p| p.get("urls")) {
prov.repository = urls
.get("Repository")
.or(urls.get("repository"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
}
}
prov
}
use sha2::Digest;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_detection_covers_shell_and_suffix_python_tests() {
assert!(is_test_file(Path::new("scripts/check.test.sh")));
assert!(is_test_file(Path::new("scripts/check.spec.sh")));
assert!(is_test_file(Path::new("scripts/import_data_test.py")));
assert!(is_test_file(Path::new("tests/unit.py")));
assert!(!is_test_file(Path::new("scripts/load.py")));
}
#[test]
fn extracts_typescript_mcp_server_tool_declarations() {
let content = r#"
const server = new McpServer({ name: "demo" })
server.tool(
'search_party',
'Busca fuzzy por nome.',
{},
async () => ({ content: [] })
)
server.registerTool("create_report", { description: "Create report" }, async () => {})
"#;
let tools =
extract_mcp_tool_declarations_from_source(Path::new("src/mcp/server.ts"), content)
.into_iter()
.map(|declaration| declaration.tool)
.collect::<Vec<_>>();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].name, "search_party");
assert_eq!(
tools[0].description.as_deref(),
Some("Busca fuzzy por nome.")
);
assert_eq!(tools[0].defined_at.as_ref().map(|loc| loc.line), Some(5));
assert_eq!(tools[1].name, "create_report");
assert_eq!(tools[1].description.as_deref(), Some("Create report"));
}
#[test]
fn extracts_config_description_and_inline_handler_binding() {
let content = r#"
server.registerTool(
"create_report",
{
description: "Create a local report",
inputSchema: { path: { type: "string", description: "Output path" } },
},
async ({ path }) => {
await writeFile(path, "report");
},
)
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].tool.name, "create_report");
assert_eq!(
declarations[0].tool.description.as_deref(),
Some("Create a local report")
);
assert!(matches!(
declarations[0].handler,
Some(McpToolHandler::Inline { .. })
));
}
#[test]
fn extracts_named_handler_binding_without_using_nested_descriptions() {
let content = r#"
server.registerTool(
"fetch_report",
{
inputSchema: { url: { type: "string", description: "Remote URL" } },
description: "Fetch a report from a URL",
},
fetchReport,
)
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(
declarations[0].tool.description.as_deref(),
Some("Fetch a report from a URL")
);
assert!(matches!(
declarations[0].handler,
Some(McpToolHandler::Named { ref symbol }) if symbol == "fetchReport"
));
}
#[test]
fn extracts_tool_callback_after_description_and_schema_arguments() {
let content = r#"
server.tool(
"read_file",
"Read a local file",
{ path: z.string() },
handleReadFile,
)
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(
declarations[0].tool.description.as_deref(),
Some("Read a local file")
);
assert!(matches!(
declarations[0].handler,
Some(McpToolHandler::Named { ref symbol }) if symbol == "handleReadFile"
));
}
#[test]
fn duplicate_tool_prefers_declaration_with_handler_binding() {
let content = r#"
server.registerTool("report", { description: "Incomplete declaration" })
server.registerTool(
"report",
{ description: "Bound declaration" },
async () => ({ content: [] }),
)
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(
declarations[0].tool.description.as_deref(),
Some("Bound declaration")
);
assert!(matches!(
declarations[0].handler,
Some(McpToolHandler::Inline { .. })
));
}
#[test]
fn schema_arrow_function_is_not_misclassified_as_handler() {
let content = r#"
server.tool(
"read_file",
"Read a local file",
{ path: z.string().transform(value => value.trim()) },
)
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].handler, None);
}
#[test]
fn arrow_text_in_config_description_is_not_misclassified_as_handler() {
let content = r#"
server.registerTool("map_value", {
description: "Maps a => b",
inputSchema: { value: { type: "string" } },
})
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(
declarations[0].tool.description.as_deref(),
Some("Maps a => b")
);
assert_eq!(declarations[0].handler, None);
}
#[test]
fn reserved_literals_are_not_named_handlers() {
for candidate in ["async", "true", "false", "null", "undefined", "this"] {
assert_eq!(
parse_mcp_tool_handler(Path::new("src/server.ts"), candidate, 0, candidate.len()),
None,
"{candidate} must not be classified as a named handler"
);
}
}
#[test]
fn handler_names_with_function_prefix_remain_named() {
let candidate = "functionHandler";
assert!(matches!(
parse_mcp_tool_handler(Path::new("src/server.ts"), candidate, 0, candidate.len()),
Some(McpToolHandler::Named { ref symbol }) if symbol == candidate
));
let inline = "async() => ({ content: [] })";
assert!(matches!(
parse_mcp_tool_handler(Path::new("src/server.ts"), inline, 0, inline.len()),
Some(McpToolHandler::Inline { .. })
));
}
#[test]
fn ignores_tool_calls_inside_comments_and_strings() {
let content = r#"
// server.tool("commented", "Nope", async () => {})
const docs = 'call server.registerTool("string", {}, handler)'
/* server.registerTool("blocked", {}, handler) */
server.registerTool("real", { description: "Real tool" }, handlers.run)
"#;
let declarations =
extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].tool.name, "real");
assert!(matches!(
declarations[0].handler,
Some(McpToolHandler::Named { ref symbol }) if symbol == "handlers.run"
));
}
#[cfg(feature = "typescript")]
#[test]
fn binds_named_handlers_without_cross_tool_operation_leakage() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool("read_file", { description: "Read a file" }, handleRead)
server.registerTool("fetch_url", { description: "Fetch a URL" }, handleFetch)
async function handleRead(path: string) {
return readFile(path)
}
async function handleFetch(url: string) {
return fetch(url)
}
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 2);
assert!(bindings[0].handler_resolved);
assert!(bindings[0].observation_complete);
assert_eq!(bindings[0].execution.file_operations.len(), 1);
assert!(bindings[0].execution.network_operations.is_empty());
assert!(bindings[1].handler_resolved);
assert!(bindings[1].observation_complete);
assert!(bindings[1].execution.file_operations.is_empty());
assert_eq!(bindings[1].execution.network_operations.len(), 1);
}
#[cfg(feature = "typescript")]
#[test]
fn binds_inline_handler_and_one_hop_in_project_callee() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool(
"fetch_report",
{ description: "Fetch a report" },
async (url: string) => {
await writeFile("audit.log", "started")
return fetchThroughClient(url)
},
)
async function fetchThroughClient(url: string) {
return fetch(url)
}
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].handler_resolved);
assert!(bindings[0].observation_complete);
assert_eq!(bindings[0].resolved_callees, vec!["fetchThroughClient"]);
assert_eq!(bindings[0].execution.file_operations.len(), 1);
assert_eq!(bindings[0].execution.network_operations.len(), 1);
}
#[cfg(feature = "typescript")]
#[test]
fn operation_binding_stops_after_one_callee_hop() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool("report", { description: "Build report" }, handleReport)
async function handleReport() {
return firstHop()
}
async function firstHop() {
return secondHop()
}
async function secondHop() {
return fetch("https://example.com")
}
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].handler_resolved);
assert!(!bindings[0].observation_complete);
assert_eq!(bindings[0].resolved_callees, vec!["firstHop"]);
assert!(
bindings[0].execution.network_operations.is_empty(),
"depth-2 operations must not be attributed to the tool"
);
}
#[cfg(feature = "typescript")]
#[test]
fn opaque_call_keeps_operation_observation_incomplete() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool("report", { description: "Fetch URLs" }, handleReport)
async function handleReport(url: string) {
return externalClient(url)
}
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].handler_resolved);
assert!(!bindings[0].observation_complete);
assert!(bindings[0].execution.network_operations.is_empty());
}
#[cfg(feature = "typescript")]
#[test]
fn dynamic_execution_keeps_operation_observation_incomplete() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool("evaluate", { description: "Evaluate arbitrary code" }, handleEval)
function handleEval(code: string) { return eval(code) }
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].handler_resolved);
assert!(!bindings[0].observation_complete);
assert_eq!(bindings[0].execution.dynamic_exec.len(), 1);
}
#[cfg(feature = "typescript")]
#[test]
fn uncalled_nested_function_operations_are_not_attributed_to_handler() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool("report", { description: "Build report" }, handleReport)
async function handleReport() {
async function unusedNetworkHelper() {
return fetch("https://example.com")
}
return "local report"
}
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].handler_resolved);
assert!(bindings[0].observation_complete);
assert!(bindings[0].resolved_callees.is_empty());
assert!(
bindings[0].execution.network_operations.is_empty(),
"an uncalled nested function is not part of handler execution"
);
}
#[cfg(feature = "typescript")]
#[test]
fn ambiguous_named_handler_stays_unresolved() {
use crate::parser::LanguageParser;
let registration_path = Path::new("src/server.ts");
let registration =
r#"server.registerTool("report", { description: "Report" }, handleReport)"#;
let first_path = Path::new("src/first.ts");
let first = "function handleReport() { return readFile('report.txt') }";
let second_path = Path::new("src/second.ts");
let second = "function handleReport() { return fetch('https://example.com') }";
let declarations =
extract_mcp_tool_declarations_from_source(registration_path, registration);
let parsed_files = vec![
(
first_path.to_path_buf(),
parser::typescript::TypeScriptParser
.parse_file(first_path, first)
.unwrap(),
),
(
second_path.to_path_buf(),
parser::typescript::TypeScriptParser
.parse_file(second_path, second)
.unwrap(),
),
];
let bindings = bind_mcp_tool_operations(&declarations, &parsed_files);
assert_eq!(bindings.len(), 1);
assert!(!bindings[0].handler_resolved);
assert!(!bindings[0].observation_complete);
assert!(bindings[0].execution.file_operations.is_empty());
assert!(bindings[0].execution.network_operations.is_empty());
}
#[cfg(feature = "typescript")]
#[test]
fn resolves_named_handler_across_source_files() {
use crate::parser::LanguageParser;
let registration_path = Path::new("src/server.ts");
let registration =
r#"server.registerTool("report", { description: "Report" }, handleReport)"#;
let handler_path = Path::new("src/handlers.ts");
let handler = "function handleReport() { return readFile('report.txt') }";
let declarations =
extract_mcp_tool_declarations_from_source(registration_path, registration);
let parsed_files = vec![(
handler_path.to_path_buf(),
parser::typescript::TypeScriptParser
.parse_file(handler_path, handler)
.unwrap(),
)];
let bindings = bind_mcp_tool_operations(&declarations, &parsed_files);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].handler_resolved);
assert!(bindings[0].observation_complete);
assert_eq!(bindings[0].execution.file_operations.len(), 1);
}
#[cfg(feature = "typescript")]
#[test]
fn dotted_named_handler_stays_unresolved_without_member_resolution() {
use crate::parser::LanguageParser;
let path = Path::new("src/server.ts");
let content = r#"
server.registerTool("report", { description: "Report" }, handlers.run)
function run() { return readFile("report.txt") }
"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let parsed = parser::typescript::TypeScriptParser
.parse_file(path, content)
.unwrap();
let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
assert_eq!(bindings.len(), 1);
assert!(!bindings[0].handler_resolved);
assert!(!bindings[0].observation_complete);
assert!(bindings[0].execution.file_operations.is_empty());
}
#[cfg(feature = "typescript")]
#[test]
fn adapter_load_projects_per_tool_observed_capabilities() {
use crate::adapter::Adapter;
let fixture = tempfile::tempdir().unwrap();
std::fs::write(
fixture.path().join("package.json"),
r#"{"dependencies":{"@modelcontextprotocol/sdk":"1.0.0"}}"#,
)
.unwrap();
std::fs::write(
fixture.path().join("server.ts"),
r#"
server.registerTool("read_file", { description: "Read a file" }, handleRead)
server.registerTool("fetch_url", { description: "Fetch a URL" }, handleFetch)
function handleRead(path: string) { return readFile(path) }
function handleFetch(url: string) { return fetch(url) }
"#,
)
.unwrap();
let target = McpAdapter.load(fixture.path(), false).unwrap().remove(0);
let read = target
.tools
.iter()
.find(|tool| tool.name == "read_file")
.unwrap();
let fetch = target
.tools
.iter()
.find(|tool| tool.name == "fetch_url")
.unwrap();
assert_eq!(
read.observed_capabilities,
std::collections::BTreeSet::from([Capability::FsRead])
);
assert!(
read.capability_evidence
.iter()
.all(|evidence| evidence.capability == Capability::FsRead)
);
assert_eq!(
fetch.observed_capabilities,
std::collections::BTreeSet::from([Capability::NetworkEgress])
);
assert!(read.capability_observation_complete);
assert!(fetch.capability_observation_complete);
}
#[cfg(not(feature = "typescript"))]
#[test]
fn adapter_load_without_typescript_keeps_observed_capabilities_empty() {
use crate::adapter::Adapter;
let fixture = tempfile::tempdir().unwrap();
std::fs::write(
fixture.path().join("package.json"),
r#"{"dependencies":{"@modelcontextprotocol/sdk":"1.0.0"}}"#,
)
.unwrap();
std::fs::write(
fixture.path().join("server.ts"),
r#"
server.registerTool("fetch_url", { description: "Fetch a URL" }, handleFetch)
function handleFetch(url: string) { return fetch(url) }
"#,
)
.unwrap();
let target = McpAdapter.load(fixture.path(), false).unwrap().remove(0);
let tool = target
.tools
.iter()
.find(|tool| tool.name == "fetch_url")
.unwrap();
assert!(tool.observed_capabilities.is_empty());
assert!(tool.capability_evidence.is_empty());
assert!(!tool.capability_observation_complete);
}
#[test]
fn adapter_load_projects_permissions_but_not_input_schema() {
use crate::adapter::Adapter;
let fixture = tempfile::tempdir().unwrap();
std::fs::write(
fixture.path().join("package.json"),
r#"{"dependencies":{"@modelcontextprotocol/sdk":"1.0.0"}}"#,
)
.unwrap();
std::fs::write(
fixture.path().join("tools.json"),
r#"{
"tools": [
{
"name": "fetch_url",
"description": "Fetch URLs",
"inputSchema": {"properties": {"url": {"type": "string"}}}
},
{
"name": "schema_only",
"inputSchema": {"properties": {"url": {"type": "string"}}}
}
]
}"#,
)
.unwrap();
let target = McpAdapter.load(fixture.path(), false).unwrap().remove(0);
let fetch = target
.tools
.iter()
.find(|tool| tool.name == "fetch_url")
.unwrap();
let schema_only = target
.tools
.iter()
.find(|tool| tool.name == "schema_only")
.unwrap();
assert_eq!(
fetch.declared_capabilities,
std::collections::BTreeSet::from([Capability::NetworkEgress])
);
assert_eq!(
fetch
.capability_declarations
.iter()
.filter(|declaration| {
declaration.source == CapabilityDeclarationSource::Description
})
.count(),
1
);
assert_eq!(
fetch
.capability_declarations
.iter()
.filter(|declaration| {
declaration.source == CapabilityDeclarationSource::Permission
})
.count(),
1
);
assert!(schema_only.declared_capabilities.is_empty());
assert!(schema_only.capability_declarations.is_empty());
}
#[cfg(not(feature = "typescript"))]
#[test]
fn no_typescript_feature_keeps_operation_binding_unresolved() {
let path = Path::new("src/server.ts");
let content = r#"server.registerTool("fetch", { description: "Fetch" }, handleFetch)"#;
let declarations = extract_mcp_tool_declarations_from_source(path, content);
let bindings = bind_mcp_tool_operations(&declarations, &[]);
assert_eq!(bindings.len(), 1);
assert!(!bindings[0].handler_resolved);
assert!(bindings[0].execution.network_operations.is_empty());
assert!(bindings[0].resolved_callees.is_empty());
}
#[test]
fn extracts_python_mcp_tool_decorators() {
let content = r#"
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo")
@mcp.tool(name="search", description="Search web")
async def search(query: str):
return []
@mcp.tool()
def status():
return {}
"#;
let tools = extract_mcp_tools_from_source(Path::new("src/mcp/server.py"), content);
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].name, "search");
assert_eq!(tools[0].description.as_deref(), Some("Search web"));
assert_eq!(tools[1].name, "status");
}
#[test]
fn extracts_python_mcp_tool_call_syntax() {
let content = r#"
server = FastMCP("demo")
server.tool("echo", "Run echo command")
"#;
let tools = extract_mcp_tools_from_source(Path::new("src/mcp/server.py"), content);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "echo");
assert_eq!(tools[0].description.as_deref(), Some("Run echo command"));
}
#[test]
fn extracts_python_bare_mcp_tool_decorators() {
let content = r#"
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo")
@mcp.tool
def calculate(expr: str):
return eval(expr)
"#;
let tools = extract_mcp_tools_from_source(Path::new("src/mcp/server.py"), content);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "calculate");
}
}