1use std::path::{Path, PathBuf};
2
3use crate::analysis::AnalysisBundle;
4use serde_json::Value;
5
6use crate::analysis::composite_flow::{SourceUnit, ToolFlowInput, build_composite_flow_candidates};
7use crate::analysis::cross_file::apply_cross_file_sanitization;
8use crate::config::ScanPathFilter;
9use crate::error::Result;
10use crate::ir::capability::{
11 project_declared_description, project_declared_permissions, project_observed_execution,
12};
13use crate::ir::execution_surface::ExecutionSurface;
14use crate::ir::taint_builder::build_data_surface;
15use crate::ir::*;
16use crate::parser;
17
18pub struct McpAdapter;
25
26impl super::Adapter for McpAdapter {
27 fn framework(&self) -> Framework {
28 Framework::Mcp
29 }
30
31 fn detect(&self, root: &Path) -> bool {
32 super::mcp_metadata::metadata_root_for_scan(root).is_some()
33 }
34
35 fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
36 let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
37 self.load_with_filter(root, &filter)
38 }
39
40 fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
41 Ok(load_mcp_target(root, filter)
42 .into_iter()
43 .map(|(target, _)| target)
44 .collect())
45 }
46}
47
48impl super::AnalysisAdapter for McpAdapter {
49 fn framework(&self) -> Framework {
50 Framework::Mcp
51 }
52
53 fn detect(&self, root: &Path) -> bool {
54 super::mcp_metadata::metadata_root_for_scan(root).is_some()
55 }
56
57 fn load_analysis_with_filter(
58 &self,
59 root: &Path,
60 filter: &ScanPathFilter,
61 ) -> Result<Vec<AnalysisBundle>> {
62 load_mcp_analysis(root, filter)
63 }
64}
65
66pub(crate) struct McpAnalysisAdapter;
67
68impl super::AnalysisAdapter for McpAnalysisAdapter {
69 fn framework(&self) -> Framework {
70 Framework::Mcp
71 }
72
73 fn detect(&self, root: &Path) -> bool {
74 super::mcp_metadata::metadata_root_for_scan(root).is_some()
75 }
76
77 fn load_analysis_with_filter(
78 &self,
79 root: &Path,
80 filter: &ScanPathFilter,
81 ) -> Result<Vec<AnalysisBundle>> {
82 load_mcp_analysis(root, filter)
83 }
84}
85
86fn load_mcp_analysis(root: &Path, filter: &ScanPathFilter) -> Result<Vec<AnalysisBundle>> {
87 let (target, composite_tools) = load_mcp_target(root, filter)?;
88
89 let source_for_composite = target
90 .source_files
91 .iter()
92 .filter_map(|source_file| match source_file.language {
93 Language::TypeScript | Language::JavaScript => Some(SourceUnit {
94 path: &source_file.path,
95 content: &source_file.content,
96 }),
97 _ => None,
98 })
99 .collect::<Vec<_>>();
100
101 let mut tool_flow_inputs = Vec::new();
102 for tool in &composite_tools {
103 let Some(location) = &tool.handler_location else {
104 continue;
105 };
106 tool_flow_inputs.push(ToolFlowInput {
107 tool_name: tool.tool_name.clone(),
108 handler: location.clone(),
109 });
110 }
111
112 let composite_flows = build_composite_flow_candidates(&tool_flow_inputs, &source_for_composite);
113
114 Ok(vec![AnalysisBundle {
115 target,
116 composite_flows,
117 }])
118}
119
120fn load_mcp_target(
121 root: &Path,
122 filter: &ScanPathFilter,
123) -> Result<(ScanTarget, Vec<ToolDeclForComposite>)> {
124 let metadata_root =
125 super::mcp_metadata::metadata_root_for_scan(root).unwrap_or_else(|| root.to_path_buf());
126 let name = root
127 .file_name()
128 .map(|n| n.to_string_lossy().to_string())
129 .unwrap_or_else(|| "mcp-server".into());
130
131 let mut source_files = Vec::new();
132 let mut execution = ExecutionSurface::default();
133 let mut tool_declarations = Vec::new();
134 let mut python_tools = Vec::new();
135
136 collect_source_files_with_filter(root, filter, &mut source_files)?;
138 for source_file in &source_files {
139 match source_file.language {
140 Language::TypeScript | Language::JavaScript => {
141 tool_declarations.extend(extract_mcp_tool_declarations_from_source(
142 &source_file.path,
143 &source_file.content,
144 ));
145 }
146 Language::Python => {
147 python_tools.extend(extract_mcp_tools_from_source(
148 &source_file.path,
149 &source_file.content,
150 ));
151 }
152 _ => {}
153 }
154 }
155
156 let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
158 for sf in &source_files {
159 if let Some(parser) = parser::parser_for_language(sf.language) {
160 if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
161 parsed_files.push((sf.path.clone(), parsed));
162 }
163 }
164 }
165
166 apply_cross_file_sanitization(&mut parsed_files);
169
170 let operation_bindings = bind_mcp_tool_operations(&tool_declarations, &parsed_files);
171 debug_assert_eq!(operation_bindings.len(), tool_declarations.len());
172 debug_assert!(
173 operation_bindings
174 .iter()
175 .all(McpToolOperationBinding::is_consistent)
176 );
177
178 let mut tool_decls_for_composite = Vec::with_capacity(tool_declarations.len());
179 let mut tools = python_tools;
180 tools.reserve(tool_declarations.len());
181
182 for (declaration, binding) in tool_declarations.into_iter().zip(operation_bindings) {
183 let mut tool = declaration.tool;
184 if binding.handler_resolved {
185 project_observed_execution(&mut tool, &binding.execution);
186 }
187 tool.capability_observation_complete = binding.observation_complete;
188 tool_decls_for_composite.push(ToolDeclForComposite {
189 tool_name: tool.name.clone(),
190 handler_location: binding.handler_location.clone(),
191 });
192 tools.push(tool);
193 }
194
195 for (_, parsed) in &parsed_files {
197 execution.commands.extend(parsed.commands.clone());
198 execution
199 .file_operations
200 .extend(parsed.file_operations.clone());
201 execution
202 .network_operations
203 .extend(parsed.network_operations.clone());
204 execution.env_accesses.extend(parsed.env_accesses.clone());
205 execution.dynamic_exec.extend(parsed.dynamic_exec.clone());
206 }
207
208 let tools_json = root.join("tools.json");
210 if tools_json.exists() && filter.allows_path(root, &tools_json) {
211 if let Ok(content) = std::fs::read_to_string(&tools_json) {
212 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
213 tools.extend(parser::json_schema::parse_tools_from_json(&value));
214 tools = dedupe_tools_by_name(tools);
215 }
216 }
217 }
218 for tool in &mut tools {
219 project_declared_permissions(tool);
220 project_declared_description(tool);
221 }
222
223 let (dependencies, provenance) = if super::mcp_metadata::same_path(root, &metadata_root) {
224 (
225 parse_dependencies(root, filter),
226 parse_provenance(root, filter),
227 )
228 } else {
229 (
230 parse_dependencies(&metadata_root, filter),
231 parse_provenance(&metadata_root, filter),
232 )
233 };
234
235 let data = build_data_surface(&tools, &execution);
236
237 let target = ScanTarget {
238 name,
239 framework: Framework::Mcp,
240 root_path: metadata_root,
241 tools,
242 execution,
243 data,
244 dependencies,
245 provenance,
246 source_files,
247 };
248
249 Ok((target, tool_decls_for_composite))
250}
251
252struct ToolDeclForComposite {
253 tool_name: String,
254 handler_location: Option<SourceLocation>,
255}
256
257pub fn is_test_file(path: &Path) -> bool {
265 for component in path.components() {
267 if let std::path::Component::Normal(name) = component {
268 let name = name.to_string_lossy();
269 if matches!(
270 name.as_ref(),
271 "test" | "tests" | "__tests__" | "__pycache__"
272 ) {
273 return true;
274 }
275 }
276 }
277
278 let file_name = match path.file_name() {
279 Some(n) => n.to_string_lossy(),
280 None => return false,
281 };
282 let file_name = file_name.as_ref();
283
284 if matches!(file_name, "conftest.py" | "pytest.ini" | "setup.cfg")
286 || file_name.starts_with("jest.config.")
287 || file_name.starts_with("vitest.config.")
288 {
289 return true;
290 }
291
292 if file_name.ends_with(".py")
294 && (file_name.starts_with("test_") || file_name.ends_with("_test.py"))
295 {
296 return true;
297 }
298
299 for suffix in [
301 ".test.ts",
302 ".test.js",
303 ".test.tsx",
304 ".test.jsx",
305 ".test.py",
306 ".test.sh",
307 ".spec.ts",
308 ".spec.js",
309 ".spec.tsx",
310 ".spec.jsx",
311 ".spec.py",
312 ".spec.sh",
313 ] {
314 if file_name.ends_with(suffix) {
315 return true;
316 }
317 }
318
319 false
320}
321
322pub(crate) fn has_recursive_python_import(root: &Path, needles: &[&str]) -> bool {
323 let walker = ignore::WalkBuilder::new(root)
324 .hidden(true)
325 .git_ignore(true)
326 .build();
327
328 for entry in walker.flatten() {
329 let path = entry.path();
330 if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("py") {
331 continue;
332 }
333
334 if let Ok(content) = std::fs::read_to_string(path) {
335 if needles.iter().any(|needle| content.contains(needle)) {
336 return true;
337 }
338 }
339 }
340
341 false
342}
343
344#[derive(Debug, Clone)]
345struct McpToolDeclaration {
346 tool: ToolSurface,
347 handler: Option<McpToolHandler>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351enum McpToolHandler {
352 Named { symbol: String },
353 Inline { location: SourceLocation },
354}
355
356#[derive(Debug, Clone)]
357struct McpToolOperationBinding {
358 execution: ExecutionSurface,
359 handler_resolved: bool,
360 observation_complete: bool,
361 resolved_callees: Vec<String>,
362 handler_location: Option<SourceLocation>,
363}
364
365impl McpToolOperationBinding {
366 fn is_consistent(&self) -> bool {
367 self.handler_resolved
368 || (self.execution.commands.is_empty()
369 && self.execution.file_operations.is_empty()
370 && self.execution.network_operations.is_empty()
371 && self.execution.env_accesses.is_empty()
372 && self.execution.dynamic_exec.is_empty()
373 && !self.observation_complete
374 && self.resolved_callees.is_empty())
375 }
376}
377
378#[cfg(feature = "typescript")]
379struct ResolvedMcpHandler {
380 span: SourceLocation,
381 caller: Option<String>,
382}
383
384fn extract_mcp_tool_declarations_from_source(
385 path: &Path,
386 content: &str,
387) -> Vec<McpToolDeclaration> {
388 let mut declarations = Vec::new();
389
390 let mut offset = 0;
391
392 while let Some(relative_start) = find_next_mcp_tool_call(&content[offset..]) {
393 let call_start = offset + relative_start;
394 let Some(open_paren) = content[call_start..].find('(').map(|pos| call_start + pos) else {
395 break;
396 };
397 let Some(close_paren) = find_matching_delimiter(content, open_paren, b'(', b')') else {
398 break;
399 };
400 let arguments = top_level_segments(content, open_paren + 1, close_paren);
401 let Some(&(name_start, _)) = arguments.first() else {
402 offset = close_paren + 1;
403 continue;
404 };
405 let Some((name, _)) = parse_string_literal_at(content, name_start) else {
406 offset = close_paren + 1;
407 continue;
408 };
409 let description = arguments.get(1).and_then(|&(start, end)| {
410 parse_string_literal_at(content, start)
411 .filter(|(_, after)| *after <= end)
412 .map(|(value, _)| value)
413 .or_else(|| parse_object_string_property(content, start, end, "description"))
414 });
415 let handler = arguments
416 .last()
417 .and_then(|&(start, end)| parse_mcp_tool_handler(path, content, start, end));
418 let line = content[..call_start].lines().count() + 1;
419
420 declarations.push(McpToolDeclaration {
421 tool: ToolSurface {
422 name,
423 description,
424 input_schema: None,
425 output_schema: None,
426 declared_permissions: Vec::new(),
427 defined_at: Some(source_loc(path, line)),
428 declared_capabilities: Default::default(),
429 capability_declarations: Vec::new(),
430 observed_capabilities: Default::default(),
431 capability_observation_complete: false,
432 capability_evidence: Vec::new(),
433 },
434 handler,
435 });
436
437 offset = close_paren + 1;
438 }
439
440 dedupe_mcp_tool_declarations(declarations)
441}
442
443#[cfg(feature = "typescript")]
444fn bind_mcp_tool_operations(
445 declarations: &[McpToolDeclaration],
446 parsed_files: &[(PathBuf, parser::ParsedFile)],
447) -> Vec<McpToolOperationBinding> {
448 declarations
449 .iter()
450 .map(|declaration| {
451 let Some(handler) = resolve_handler(declaration, parsed_files) else {
452 return McpToolOperationBinding {
453 execution: ExecutionSurface::default(),
454 handler_resolved: false,
455 observation_complete: false,
456 resolved_callees: Vec::new(),
457 handler_location: None,
458 };
459 };
460
461 let mut resolved_callees = call_sites_for_handler(parsed_files, &handler)
462 .filter_map(|call_site| {
463 resolve_unique_function_span(&call_site.callee, parsed_files)
464 .map(|span| (call_site.callee.clone(), span))
465 })
466 .collect::<Vec<_>>();
467 resolved_callees.sort_by(|left, right| left.0.cmp(&right.0));
468 resolved_callees.dedup_by(|left, right| left.0 == right.0);
469
470 let handler_span = handler.span.clone();
471 let mut scopes = Vec::with_capacity(resolved_callees.len() + 1);
472 scopes.push((handler_span.clone(), handler.caller));
473 scopes.extend(
474 resolved_callees
475 .iter()
476 .map(|(name, span)| (span.clone(), Some(name.clone()))),
477 );
478
479 let execution = execution_within_scopes(parsed_files, &scopes);
480 let observation_complete =
481 binding_observation_complete(parsed_files, &scopes, &resolved_callees, &execution);
482
483 McpToolOperationBinding {
484 execution,
485 handler_resolved: true,
486 observation_complete,
487 resolved_callees: resolved_callees.into_iter().map(|(name, _)| name).collect(),
488 handler_location: Some(handler_span),
489 }
490 })
491 .collect()
492}
493
494#[cfg(not(feature = "typescript"))]
495fn bind_mcp_tool_operations(
496 declarations: &[McpToolDeclaration],
497 _parsed_files: &[(PathBuf, parser::ParsedFile)],
498) -> Vec<McpToolOperationBinding> {
499 declarations
500 .iter()
501 .map(|_| McpToolOperationBinding {
502 execution: ExecutionSurface::default(),
503 handler_resolved: false,
504 observation_complete: false,
505 resolved_callees: Vec::new(),
506 handler_location: None,
507 })
508 .collect()
509}
510
511#[cfg(feature = "typescript")]
512fn resolve_handler(
513 declaration: &McpToolDeclaration,
514 parsed_files: &[(PathBuf, parser::ParsedFile)],
515) -> Option<ResolvedMcpHandler> {
516 match declaration.handler.as_ref()? {
517 McpToolHandler::Inline { location } => parsed_files
518 .iter()
519 .any(|(path, _)| path == &location.file)
520 .then(|| ResolvedMcpHandler {
521 span: location.clone(),
522 caller: None,
523 }),
524 McpToolHandler::Named { symbol } => {
525 resolve_unique_function_span(symbol, parsed_files).map(|span| ResolvedMcpHandler {
526 span,
527 caller: Some(symbol.clone()),
528 })
529 }
530 }
531}
532
533#[cfg(feature = "typescript")]
534fn resolve_unique_function_span(
535 symbol: &str,
536 parsed_files: &[(PathBuf, parser::ParsedFile)],
537) -> Option<SourceLocation> {
538 let mut matches = parsed_files.iter().flat_map(|(_, parsed)| {
539 parsed
540 .function_defs
541 .iter()
542 .filter(move |definition| definition.name == symbol)
543 });
544 let location = matches.next()?.location.clone();
545 matches.next().is_none().then_some(location)
546}
547
548#[cfg(feature = "typescript")]
549fn call_sites_for_handler<'a>(
550 parsed_files: &'a [(PathBuf, parser::ParsedFile)],
551 handler: &'a ResolvedMcpHandler,
552) -> impl Iterator<Item = &'a parser::CallSite> {
553 parsed_files
554 .iter()
555 .flat_map(|(_, parsed)| parsed.call_sites.iter())
556 .filter(|call_site| {
557 location_within_span(&call_site.location, &handler.span)
558 && match handler.caller.as_deref() {
559 Some(caller) => call_site.caller.as_deref() == Some(caller),
560 None => call_site.caller.is_none(),
561 }
562 })
563}
564
565#[cfg(feature = "typescript")]
566fn execution_within_scopes(
567 parsed_files: &[(PathBuf, parser::ParsedFile)],
568 scopes: &[(SourceLocation, Option<String>)],
569) -> ExecutionSurface {
570 let contains = |location: &SourceLocation| {
571 scopes.iter().any(|(span, function_name)| {
572 operation_belongs_to_scope(location, span, function_name.as_deref(), parsed_files)
573 })
574 };
575 let mut execution = ExecutionSurface::default();
576 for (_, parsed) in parsed_files {
577 execution.commands.extend(
578 parsed
579 .commands
580 .iter()
581 .filter(|operation| contains(&operation.location))
582 .cloned(),
583 );
584 execution.file_operations.extend(
585 parsed
586 .file_operations
587 .iter()
588 .filter(|operation| contains(&operation.location))
589 .cloned(),
590 );
591 execution.network_operations.extend(
592 parsed
593 .network_operations
594 .iter()
595 .filter(|operation| contains(&operation.location))
596 .cloned(),
597 );
598 execution.env_accesses.extend(
599 parsed
600 .env_accesses
601 .iter()
602 .filter(|operation| contains(&operation.location))
603 .cloned(),
604 );
605 execution.dynamic_exec.extend(
606 parsed
607 .dynamic_exec
608 .iter()
609 .filter(|operation| contains(&operation.location))
610 .cloned(),
611 );
612 }
613 execution
614}
615
616#[cfg(feature = "typescript")]
617fn binding_observation_complete(
618 parsed_files: &[(PathBuf, parser::ParsedFile)],
619 scopes: &[(SourceLocation, Option<String>)],
620 resolved_callees: &[(String, SourceLocation)],
621 execution: &ExecutionSurface,
622) -> bool {
623 if !execution.dynamic_exec.is_empty() {
624 return false;
625 }
626
627 parsed_files
628 .iter()
629 .flat_map(|(_, parsed)| parsed.call_sites.iter())
630 .filter(|call_site| {
631 scopes.iter().any(|(span, function_name)| {
632 operation_belongs_to_scope(
633 &call_site.location,
634 span,
635 function_name.as_deref(),
636 parsed_files,
637 )
638 })
639 })
640 .all(|call_site| {
641 call_is_modeled(call_site, execution)
642 || resolved_callees
643 .iter()
644 .any(|(name, _)| name == &call_site.callee)
645 })
646}
647
648#[cfg(feature = "typescript")]
649fn call_is_modeled(call_site: &parser::CallSite, execution: &ExecutionSurface) -> bool {
650 execution
651 .commands
652 .iter()
653 .any(|operation| operation.location == call_site.location)
654 || execution
655 .file_operations
656 .iter()
657 .any(|operation| operation.location == call_site.location)
658 || execution
659 .network_operations
660 .iter()
661 .any(|operation| operation.location == call_site.location)
662 || execution
663 .env_accesses
664 .iter()
665 .any(|operation| operation.location == call_site.location)
666}
667
668#[cfg(feature = "typescript")]
669fn operation_belongs_to_scope(
670 location: &SourceLocation,
671 span: &SourceLocation,
672 function_name: Option<&str>,
673 parsed_files: &[(PathBuf, parser::ParsedFile)],
674) -> bool {
675 if !location_within_span(location, span) {
676 return false;
677 }
678
679 let innermost = parsed_files
680 .iter()
681 .flat_map(|(_, parsed)| parsed.function_defs.iter())
682 .filter(|definition| {
683 location_within_span(&definition.location, span)
684 && location_within_span(location, &definition.location)
685 })
686 .max_by_key(|definition| (definition.location.line, definition.location.column));
687
688 match (function_name, innermost) {
689 (Some(expected), Some(definition)) => definition.name == expected,
690 (Some(_), None) => false,
691 (None, None) => true,
692 (None, Some(_)) => false,
693 }
694}
695
696#[cfg(feature = "typescript")]
697fn location_within_span(location: &SourceLocation, span: &SourceLocation) -> bool {
698 if location.file != span.file {
699 return false;
700 }
701 let start = (location.line, location.column);
702 let span_start = (span.line, span.column);
703 let span_end = (
704 span.end_line.unwrap_or(span.line),
705 span.end_column.unwrap_or(usize::MAX),
706 );
707 start >= span_start && start < span_end
708}
709
710fn find_next_mcp_tool_call(content: &str) -> Option<usize> {
711 let mut cursor = 0;
712 while cursor < content.len() {
713 if let Some(next) = skip_js_string_or_comment(content, cursor, content.len()) {
714 cursor = next;
715 continue;
716 }
717 if content[cursor..].starts_with(".tool(")
718 || content[cursor..].starts_with(".registerTool(")
719 {
720 return Some(cursor);
721 }
722 cursor += 1;
723 }
724 None
725}
726
727fn parse_mcp_tool_handler(
728 path: &Path,
729 content: &str,
730 start: usize,
731 end: usize,
732) -> Option<McpToolHandler> {
733 let (start, end) = trim_range(content, start, end);
734 let candidate = &content[start..end];
735 if is_inline_handler(candidate) {
736 return Some(McpToolHandler::Inline {
737 location: source_loc_span(path, content, start, end),
738 });
739 }
740
741 is_js_symbol(candidate).then(|| McpToolHandler::Named {
742 symbol: candidate.to_string(),
743 })
744}
745
746fn is_inline_handler(candidate: &str) -> bool {
747 if candidate.starts_with('{') || candidate.starts_with('[') {
748 return false;
749 }
750 if is_function_expression(candidate) {
751 return true;
752 }
753
754 let arrow_candidate = candidate
755 .strip_prefix("async")
756 .filter(|rest| {
757 rest.starts_with('(') || rest.chars().next().is_some_and(char::is_whitespace)
758 })
759 .map(str::trim_start)
760 .unwrap_or(candidate);
761
762 if arrow_candidate.starts_with('(') {
763 return find_matching_delimiter(arrow_candidate, 0, b'(', b')')
764 .is_some_and(|close| arrow_candidate[close + 1..].trim_start().starts_with("=>"));
765 }
766
767 arrow_candidate
768 .split_once("=>")
769 .is_some_and(|(parameter, _)| is_js_identifier(parameter.trim()))
770}
771
772fn is_function_expression(candidate: &str) -> bool {
773 let candidate = candidate
774 .strip_prefix("async")
775 .filter(|rest| rest.chars().next().is_some_and(char::is_whitespace))
776 .map(str::trim_start)
777 .unwrap_or(candidate);
778 candidate.strip_prefix("function").is_some_and(|rest| {
779 rest.is_empty()
780 || rest.starts_with('(')
781 || rest.starts_with('*')
782 || rest.chars().next().is_some_and(char::is_whitespace)
783 })
784}
785
786fn is_js_symbol(candidate: &str) -> bool {
787 let mut segments = candidate.split('.');
788 let Some(first) = segments.next() else {
789 return false;
790 };
791 !is_js_reserved_word(first) && is_js_identifier(first) && segments.all(is_js_identifier)
792}
793
794fn is_js_identifier(candidate: &str) -> bool {
795 let mut chars = candidate.chars();
796 chars
797 .next()
798 .is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '_' | '$'))
799 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$'))
800}
801
802fn is_js_reserved_word(candidate: &str) -> bool {
803 matches!(
804 candidate,
805 "async"
806 | "await"
807 | "break"
808 | "case"
809 | "catch"
810 | "class"
811 | "const"
812 | "continue"
813 | "debugger"
814 | "default"
815 | "delete"
816 | "do"
817 | "else"
818 | "export"
819 | "extends"
820 | "false"
821 | "finally"
822 | "for"
823 | "function"
824 | "if"
825 | "import"
826 | "in"
827 | "instanceof"
828 | "let"
829 | "new"
830 | "null"
831 | "return"
832 | "static"
833 | "super"
834 | "switch"
835 | "this"
836 | "throw"
837 | "true"
838 | "try"
839 | "typeof"
840 | "undefined"
841 | "var"
842 | "void"
843 | "while"
844 | "with"
845 | "yield"
846 )
847}
848
849fn parse_object_string_property(
850 content: &str,
851 start: usize,
852 end: usize,
853 property: &str,
854) -> Option<String> {
855 let (start, end) = trim_range(content, start, end);
856 if content.as_bytes().get(start) != Some(&b'{')
857 || content.as_bytes().get(end.saturating_sub(1)) != Some(&b'}')
858 {
859 return None;
860 }
861
862 for (property_start, property_end) in top_level_segments(content, start + 1, end - 1) {
863 let Some(colon) = find_top_level_byte(content, property_start, property_end, b':') else {
864 continue;
865 };
866 let (key_start, key_end) = trim_range(content, property_start, colon);
867 let key = parse_string_literal_at(content, key_start)
868 .filter(|(_, after)| *after <= key_end)
869 .map(|(value, _)| value)
870 .unwrap_or_else(|| content[key_start..key_end].to_string());
871 if key != property {
872 continue;
873 }
874
875 let (value_start, value_end) = trim_range(content, colon + 1, property_end);
876 return parse_string_literal_at(content, value_start)
877 .filter(|(_, after)| *after <= value_end)
878 .map(|(value, _)| value);
879 }
880
881 None
882}
883
884fn top_level_segments(content: &str, start: usize, end: usize) -> Vec<(usize, usize)> {
885 let mut segments = Vec::new();
886 let mut segment_start = start;
887 let mut cursor = start;
888 let mut depths = [0usize; 3];
889
890 while cursor < end {
891 if let Some(next) = skip_js_string_or_comment(content, cursor, end) {
892 cursor = next;
893 continue;
894 }
895
896 match content.as_bytes()[cursor] {
897 b'(' => depths[0] += 1,
898 b')' => depths[0] = depths[0].saturating_sub(1),
899 b'{' => depths[1] += 1,
900 b'}' => depths[1] = depths[1].saturating_sub(1),
901 b'[' => depths[2] += 1,
902 b']' => depths[2] = depths[2].saturating_sub(1),
903 b',' if depths == [0, 0, 0] => {
904 let segment = trim_range(content, segment_start, cursor);
905 if segment.0 < segment.1 {
906 segments.push(segment);
907 }
908 segment_start = cursor + 1;
909 }
910 _ => {}
911 }
912 cursor += 1;
913 }
914
915 let segment = trim_range(content, segment_start, end);
916 if segment.0 < segment.1 {
917 segments.push(segment);
918 }
919 segments
920}
921
922fn find_matching_delimiter(
923 content: &str,
924 open: usize,
925 open_byte: u8,
926 close_byte: u8,
927) -> Option<usize> {
928 let mut depth = 0usize;
929 let mut cursor = open;
930 while cursor < content.len() {
931 if let Some(next) = skip_js_string_or_comment(content, cursor, content.len()) {
932 cursor = next;
933 continue;
934 }
935
936 let byte = content.as_bytes()[cursor];
937 if byte == open_byte {
938 depth += 1;
939 } else if byte == close_byte {
940 depth = depth.checked_sub(1)?;
941 if depth == 0 {
942 return Some(cursor);
943 }
944 }
945 cursor += 1;
946 }
947 None
948}
949
950fn find_top_level_byte(content: &str, start: usize, end: usize, needle: u8) -> Option<usize> {
951 let mut cursor = start;
952 let mut depths = [0usize; 3];
953 while cursor < end {
954 if let Some(next) = skip_js_string_or_comment(content, cursor, end) {
955 cursor = next;
956 continue;
957 }
958
959 let byte = content.as_bytes()[cursor];
960 if byte == needle && depths == [0, 0, 0] {
961 return Some(cursor);
962 }
963 match byte {
964 b'(' => depths[0] += 1,
965 b')' => depths[0] = depths[0].saturating_sub(1),
966 b'{' => depths[1] += 1,
967 b'}' => depths[1] = depths[1].saturating_sub(1),
968 b'[' => depths[2] += 1,
969 b']' => depths[2] = depths[2].saturating_sub(1),
970 _ => {}
971 }
972 cursor += 1;
973 }
974 None
975}
976
977fn skip_js_string_or_comment(content: &str, start: usize, end: usize) -> Option<usize> {
978 let bytes = content.as_bytes();
979 let quote = *bytes.get(start)?;
980 if matches!(quote, b'\'' | b'"' | b'`') {
981 let mut cursor = start + 1;
985 while cursor < end {
986 if bytes[cursor] == b'\\' {
987 cursor = (cursor + 2).min(end);
988 } else if bytes[cursor] == quote {
989 return Some(cursor + 1);
990 } else {
991 cursor += 1;
992 }
993 }
994 return Some(end);
995 }
996
997 if quote == b'/' && bytes.get(start + 1) == Some(&b'/') {
998 let mut cursor = start + 2;
999 while cursor < end && bytes[cursor] != b'\n' {
1000 cursor += 1;
1001 }
1002 return Some(cursor);
1003 }
1004 if quote == b'/' && bytes.get(start + 1) == Some(&b'*') {
1005 let mut cursor = start + 2;
1006 while cursor + 1 < end {
1007 if bytes[cursor] == b'*' && bytes[cursor + 1] == b'/' {
1008 return Some(cursor + 2);
1009 }
1010 cursor += 1;
1011 }
1012 return Some(end);
1013 }
1014
1015 None
1016}
1017
1018fn trim_range(content: &str, mut start: usize, mut end: usize) -> (usize, usize) {
1019 while start < end && content.as_bytes()[start].is_ascii_whitespace() {
1020 start += 1;
1021 }
1022 while end > start && content.as_bytes()[end - 1].is_ascii_whitespace() {
1023 end -= 1;
1024 }
1025 (start, end)
1026}
1027
1028fn parse_string_literal_at(content: &str, offset: usize) -> Option<(String, usize)> {
1029 let offset = skip_whitespace(content, offset);
1030 let quote = content[offset..].chars().next()?;
1031 if !matches!(quote, '\'' | '"' | '`') {
1032 return None;
1033 }
1034
1035 let mut value = String::new();
1036 let mut escaped = false;
1037 for (relative_index, ch) in content[offset + quote.len_utf8()..].char_indices() {
1038 let absolute_index = offset + quote.len_utf8() + relative_index;
1039 if escaped {
1040 value.push(ch);
1041 escaped = false;
1042 continue;
1043 }
1044 if ch == '\\' {
1045 escaped = true;
1046 continue;
1047 }
1048 if ch == quote {
1049 return Some((value, absolute_index + quote.len_utf8()));
1050 }
1051 value.push(ch);
1052 }
1053
1054 None
1055}
1056
1057fn extract_mcp_python_decorators(path: &Path, content: &str) -> Vec<ToolSurface> {
1058 let mut tools = Vec::new();
1059
1060 let mut pending_tool_name: Option<String> = None;
1061 let mut pending_description: Option<String> = None;
1062 let mut pending_line: Option<usize> = None;
1063
1064 for (line_idx, line) in content.lines().enumerate() {
1065 let trimmed = line.trim();
1066
1067 if let Some((explicit_name, description)) = parse_python_decorator_tool(trimmed) {
1068 pending_tool_name = explicit_name;
1069 pending_description = description;
1070 pending_line = Some(line_idx + 1);
1071 continue;
1072 }
1073
1074 if pending_line.is_some() {
1076 if let Some(name) = parse_python_function_name(trimmed) {
1077 let tool_name = pending_tool_name.take().unwrap_or_else(|| name.to_string());
1078 let description = pending_description.take();
1079 tools.push(ToolSurface {
1080 name: tool_name,
1081 description,
1082 input_schema: None,
1083 output_schema: None,
1084 declared_permissions: Vec::new(),
1085 defined_at: Some(source_loc(path, pending_line.unwrap_or(line_idx + 1))),
1086 declared_capabilities: Default::default(),
1087 capability_declarations: Vec::new(),
1088 observed_capabilities: Default::default(),
1089 capability_observation_complete: false,
1090 capability_evidence: Vec::new(),
1091 });
1092 pending_line = None;
1093 continue;
1094 }
1095
1096 if !trimmed.is_empty() && !trimmed.starts_with('@') && !trimmed.starts_with("\"\"\"") {
1097 pending_tool_name = None;
1098 pending_description = None;
1099 pending_line = None;
1100 }
1101 }
1102 }
1103
1104 dedupe_tools_by_name(tools)
1105}
1106
1107fn extract_mcp_tools_from_source(path: &Path, content: &str) -> Vec<ToolSurface> {
1108 let mut tools = if path.extension().and_then(|ext| ext.to_str()) == Some("py") {
1109 extract_mcp_python_decorators(path, content)
1110 } else {
1111 Vec::new()
1112 };
1113
1114 tools.extend(
1115 extract_mcp_tool_declarations_from_source(path, content)
1116 .into_iter()
1117 .map(|declaration| declaration.tool),
1118 );
1119 dedupe_tools_by_name(tools)
1120}
1121
1122fn parse_python_decorator_tool(line: &str) -> Option<(Option<String>, Option<String>)> {
1123 let trimmed = line.trim();
1124 if !trimmed.starts_with('@') {
1125 return None;
1126 }
1127
1128 if trimmed.ends_with(".tool") || trimmed == "@tool" {
1129 return Some((None, None));
1130 }
1131
1132 let call_idx = trimmed.find(".tool(").or_else(|| trimmed.find("tool("))?;
1133 let open_paren = trimmed[call_idx..]
1134 .find('(')
1135 .and_then(|idx| call_idx.checked_add(idx + 1))?;
1136 let Some((name, after_name)) = parse_string_literal_at(trimmed, open_paren) else {
1137 let arg_slice = &trimmed[open_paren..];
1138 return Some((
1139 parse_python_kwarg_string_arg(arg_slice, "name"),
1140 parse_python_kwarg_string_arg(arg_slice, "description"),
1141 ));
1142 };
1143 Some((Some(name), parse_next_string_argument(trimmed, after_name)))
1144}
1145
1146fn parse_next_string_argument(content: &str, offset: usize) -> Option<String> {
1147 let mut index = skip_whitespace(content, offset);
1148 if content[index..].starts_with(',') {
1149 index += 1;
1150 } else {
1151 return None;
1152 }
1153
1154 let index = skip_whitespace(content, index);
1155 parse_string_literal_at(content, index).map(|(value, _)| value)
1156}
1157
1158fn parse_python_kwarg_string_arg(args: &str, key: &str) -> Option<String> {
1159 let needle = format!("{key}=");
1160 let idx = args.find(&needle)?;
1161 let rest = &args[idx + needle.len()..];
1162 let rest = rest.trim_start();
1163 parse_string_literal_at(rest, 0).map(|(value, _)| value)
1164}
1165
1166fn parse_python_function_name(line: &str) -> Option<String> {
1167 let trimmed = line.trim_start();
1168 if !trimmed.starts_with("def ") && !trimmed.starts_with("async def ") {
1169 return None;
1170 }
1171
1172 if let Some(rest) = trimmed.strip_prefix("def ") {
1173 let func = rest.split('(').next()?.trim();
1174 if func.is_empty() {
1175 return None;
1176 }
1177 return Some(func.to_string());
1178 }
1179
1180 let rest = trimmed.strip_prefix("async def ")?;
1181 let func = rest.split('(').next()?.trim();
1182 if func.is_empty() {
1183 return None;
1184 }
1185 Some(func.to_string())
1186}
1187
1188fn skip_whitespace(content: &str, mut offset: usize) -> usize {
1189 while let Some(ch) = content[offset..].chars().next() {
1190 if !ch.is_whitespace() {
1191 break;
1192 }
1193 offset += ch.len_utf8();
1194 }
1195 offset
1196}
1197
1198fn dedupe_tools_by_name(tools: Vec<ToolSurface>) -> Vec<ToolSurface> {
1199 let mut seen = std::collections::HashSet::new();
1200 let mut deduped = Vec::new();
1201 for tool in tools {
1202 if seen.insert(tool.name.clone()) {
1203 deduped.push(tool);
1204 }
1205 }
1206 deduped
1207}
1208
1209fn dedupe_mcp_tool_declarations(declarations: Vec<McpToolDeclaration>) -> Vec<McpToolDeclaration> {
1210 let mut deduped: Vec<McpToolDeclaration> = Vec::new();
1211 for declaration in declarations {
1212 if let Some(existing) = deduped
1213 .iter_mut()
1214 .find(|existing| existing.tool.name == declaration.tool.name)
1215 {
1216 let existing_score = (
1217 usize::from(existing.handler.is_some()),
1218 usize::from(existing.tool.description.is_some()),
1219 );
1220 let new_score = (
1221 usize::from(declaration.handler.is_some()),
1222 usize::from(declaration.tool.description.is_some()),
1223 );
1224 if new_score > existing_score {
1225 *existing = declaration;
1226 }
1227 } else {
1228 deduped.push(declaration);
1229 }
1230 }
1231 deduped
1232}
1233
1234fn source_loc(file: &Path, line: usize) -> SourceLocation {
1235 SourceLocation {
1236 file: file.to_path_buf(),
1237 line,
1238 column: 0,
1239 end_line: None,
1240 end_column: None,
1241 }
1242}
1243
1244fn source_loc_span(file: &Path, content: &str, start: usize, end: usize) -> SourceLocation {
1245 let start_line = content[..start].lines().count() + 1;
1248 let start_column = content[..start]
1249 .rsplit_once('\n')
1250 .map_or(start, |(_, line)| line.len());
1251 let end_line = content[..end].lines().count() + 1;
1252 let end_column = content[..end]
1253 .rsplit_once('\n')
1254 .map_or(end, |(_, line)| line.len());
1255 SourceLocation {
1256 file: file.to_path_buf(),
1257 line: start_line,
1258 column: start_column,
1259 end_line: Some(end_line),
1260 end_column: Some(end_column),
1261 }
1262}
1263
1264pub(super) fn collect_source_files_with_filter(
1265 root: &Path,
1266 filter: &ScanPathFilter,
1267 files: &mut Vec<SourceFile>,
1268) -> Result<()> {
1269 let walker = ignore::WalkBuilder::new(root)
1270 .hidden(true)
1271 .git_ignore(true)
1272 .max_depth(Some(5))
1273 .build();
1274
1275 for entry in walker.flatten() {
1276 let path = entry.path();
1277 if !path.is_file() {
1278 continue;
1279 }
1280
1281 if filter.ignore_tests() && is_test_file(path) {
1282 continue;
1283 }
1284
1285 if !filter.allows_path(root, path) {
1286 continue;
1287 }
1288
1289 let ext = path
1290 .extension()
1291 .map(|e| e.to_string_lossy().to_string())
1292 .unwrap_or_default();
1293 let lang = Language::from_extension(&ext);
1294
1295 if matches!(lang, Language::Unknown) {
1296 continue;
1297 }
1298
1299 let metadata = std::fs::metadata(path)?;
1301 if metadata.len() > 1_048_576 {
1302 continue;
1303 }
1304
1305 if let Ok(content) = std::fs::read_to_string(path) {
1306 let hash = format!(
1307 "{:x}",
1308 sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
1309 );
1310 files.push(SourceFile {
1311 path: path.to_path_buf(),
1312 language: lang,
1313 size_bytes: metadata.len(),
1314 content_hash: hash,
1315 content,
1316 });
1317 }
1318 }
1319
1320 Ok(())
1321}
1322
1323pub(super) fn parse_dependencies(
1324 root: &Path,
1325 filter: &ScanPathFilter,
1326) -> dependency_surface::DependencySurface {
1327 use crate::ir::dependency_surface::*;
1328 let mut surface = DependencySurface::default();
1329
1330 let req_file = root.join("requirements.txt");
1332 if req_file.exists() && filter.allows_path(root, &req_file) {
1333 if let Ok(content) = std::fs::read_to_string(&req_file) {
1334 for (idx, line) in content.lines().enumerate() {
1335 let line = line.trim();
1336 if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
1337 continue;
1338 }
1339 let (name, version) = if let Some(pos) = line.find("==") {
1340 (
1341 line[..pos].trim().to_string(),
1342 Some(line[pos + 2..].trim().to_string()),
1343 )
1344 } else if let Some(pos) = line.find(">=") {
1345 (
1346 line[..pos].trim().to_string(),
1347 Some(line[pos..].trim().to_string()),
1348 )
1349 } else {
1350 (line.to_string(), None)
1351 };
1352
1353 surface.dependencies.push(Dependency {
1354 name,
1355 version_constraint: version,
1356 locked_version: None,
1357 locked_hash: None,
1358 registry: "pypi".into(),
1359 is_dev: false,
1360 location: Some(SourceLocation {
1361 file: req_file.clone(),
1362 line: idx + 1,
1363 column: 0,
1364 end_line: None,
1365 end_column: None,
1366 }),
1367 });
1368 }
1369 }
1370 }
1371
1372 for (filename, format) in [
1374 ("Pipfile.lock", LockfileFormat::PipenvLock),
1375 ("poetry.lock", LockfileFormat::PoetryLock),
1376 ("uv.lock", LockfileFormat::UvLock),
1377 ] {
1378 let lock_path = root.join(filename);
1379 if lock_path.exists() && filter.allows_path(root, &lock_path) {
1380 let content = std::fs::read_to_string(&lock_path).unwrap_or_default();
1381 let (all_pinned, all_hashed) = detect_dependency_lock_confidence(format, &content);
1382 surface.lockfile = Some(LockfileInfo {
1383 path: lock_path,
1384 format,
1385 all_pinned,
1386 all_hashed,
1387 });
1388 break;
1389 }
1390 }
1391
1392 let pkg_json = root.join("package.json");
1394 if pkg_json.exists() && filter.allows_path(root, &pkg_json) {
1395 if let Ok(content) = std::fs::read_to_string(&pkg_json) {
1396 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
1397 for (key, is_dev) in [("dependencies", false), ("devDependencies", true)] {
1398 if let Some(deps) = value.get(key).and_then(|v| v.as_object()) {
1399 for (name, version) in deps {
1400 let line = find_json_key_line(&content, name);
1401 surface.dependencies.push(Dependency {
1402 name: name.clone(),
1403 version_constraint: version.as_str().map(|s| s.to_string()),
1404 locked_version: None,
1405 locked_hash: None,
1406 registry: "npm".into(),
1407 is_dev,
1408 location: Some(SourceLocation {
1409 file: pkg_json.clone(),
1410 line,
1411 column: 0,
1412 end_line: None,
1413 end_column: None,
1414 }),
1415 });
1416 }
1417 }
1418 }
1419 }
1420 }
1421
1422 for (filename, format) in [
1424 (
1425 "package-lock.json",
1426 dependency_surface::LockfileFormat::NpmLock,
1427 ),
1428 (
1429 "pnpm-lock.yaml",
1430 dependency_surface::LockfileFormat::PnpmLock,
1431 ),
1432 ("yarn.lock", dependency_surface::LockfileFormat::YarnLock),
1433 ] {
1434 let lock_path = root.join(filename);
1435 if lock_path.exists() && filter.allows_path(root, &lock_path) {
1436 let content = std::fs::read_to_string(&lock_path).unwrap_or_default();
1437 let (all_pinned, all_hashed) = detect_dependency_lock_confidence(format, &content);
1438 surface.lockfile = Some(LockfileInfo {
1439 path: lock_path,
1440 format,
1441 all_pinned,
1442 all_hashed,
1443 });
1444 break;
1445 }
1446 }
1447 }
1448
1449 surface
1450}
1451
1452fn detect_dependency_lock_confidence(
1453 format: dependency_surface::LockfileFormat,
1454 content: &str,
1455) -> (bool, bool) {
1456 match format {
1457 dependency_surface::LockfileFormat::PipenvLock => detect_pipenv_lock_confidence(content),
1458 dependency_surface::LockfileFormat::PoetryLock => detect_poetry_lock_confidence(content),
1459 dependency_surface::LockfileFormat::UvLock => detect_uv_lock_confidence(content),
1460 dependency_surface::LockfileFormat::NpmLock => detect_npm_lock_confidence(content),
1461 dependency_surface::LockfileFormat::PnpmLock => detect_pnpm_lock_confidence(content),
1462 dependency_surface::LockfileFormat::YarnLock => detect_yarn_lock_confidence(content),
1463 dependency_surface::LockfileFormat::PipRequirements => (false, false),
1464 }
1465}
1466
1467fn detect_pipenv_lock_confidence(content: &str) -> (bool, bool) {
1468 let Ok(value) = serde_json::from_str::<Value>(content) else {
1469 return (false, false);
1470 };
1471
1472 let mut all_pinned = true;
1473 let mut all_hashed = true;
1474 let mut packages_seen = 0usize;
1475
1476 for bucket_name in ["default", "develop", "packages", "dev-packages"] {
1477 if let Some(bucket) = value.get(bucket_name).and_then(|v| v.as_object()) {
1478 for (_, meta) in bucket {
1479 let Some(meta_obj) = meta.as_object() else {
1480 continue;
1481 };
1482 packages_seen += 1;
1483
1484 let version = meta_obj
1485 .get("version")
1486 .and_then(|v| v.as_str())
1487 .unwrap_or_default()
1488 .trim();
1489 if !is_exact_pinned_version(version) {
1490 all_pinned = false;
1491 }
1492
1493 let has_hash = meta_obj
1494 .get("hashes")
1495 .and_then(|v| v.as_array())
1496 .is_some_and(|hashes| !hashes.is_empty());
1497 if !has_hash {
1498 all_hashed = false;
1499 }
1500 }
1501 }
1502 }
1503
1504 if packages_seen == 0 {
1505 (false, false)
1506 } else {
1507 (all_pinned, all_hashed)
1508 }
1509}
1510
1511fn detect_poetry_lock_confidence(content: &str) -> (bool, bool) {
1512 let Ok(value) = content.parse::<toml::Value>() else {
1513 return (false, false);
1514 };
1515
1516 let mut all_pinned = true;
1517 let mut all_hashed = true;
1518 let mut packages_seen = 0usize;
1519
1520 let Some(packages) = value.get("package").and_then(|v| v.as_array()) else {
1521 return (false, false);
1522 };
1523
1524 for pkg in packages {
1525 let Some(pkg_obj) = pkg.as_table() else {
1526 continue;
1527 };
1528 packages_seen += 1;
1529
1530 let version = pkg_obj
1531 .get("version")
1532 .and_then(|v| v.as_str())
1533 .unwrap_or_default();
1534 if !is_exact_pinned_version(version) {
1535 all_pinned = false;
1536 }
1537
1538 let mut package_hashed = false;
1540 if let Some(files) = pkg_obj.get("files").and_then(|v| v.as_array()) {
1541 if files.iter().any(|entry| {
1542 entry
1543 .as_table()
1544 .is_some_and(|file_entry| file_entry.get("hash").is_some())
1545 }) {
1546 package_hashed = true;
1547 }
1548 }
1549
1550 if !package_hashed {
1551 all_hashed = false;
1552 }
1553 }
1554
1555 if packages_seen == 0 {
1556 (false, false)
1557 } else {
1558 (all_pinned, all_hashed)
1559 }
1560}
1561
1562fn detect_uv_lock_confidence(content: &str) -> (bool, bool) {
1563 let Ok(value) = content.parse::<toml::Value>() else {
1564 return (false, false);
1565 };
1566
1567 let mut all_pinned = true;
1568 let mut all_hashed = true;
1569 let mut packages_seen = 0usize;
1570
1571 let Some(packages) = value
1572 .get("package")
1573 .or_else(|| value.get("packages"))
1574 .and_then(|v| v.as_array())
1575 else {
1576 return (false, false);
1577 };
1578
1579 for pkg in packages {
1580 let Some(pkg_obj) = pkg.as_table() else {
1581 continue;
1582 };
1583 packages_seen += 1;
1584
1585 let version = pkg_obj
1586 .get("version")
1587 .and_then(|v| v.as_str())
1588 .unwrap_or_default();
1589 if !is_exact_pinned_version(version) {
1590 all_pinned = false;
1591 }
1592
1593 let has_hash = pkg_obj.get("hash").is_some()
1594 || pkg_obj
1595 .get("hashes")
1596 .is_some_and(|v| !v.as_array().is_none_or(|arr| arr.is_empty()));
1597 if !has_hash {
1598 all_hashed = false;
1599 }
1600 }
1601
1602 if packages_seen == 0 {
1603 (false, false)
1604 } else {
1605 (all_pinned, all_hashed)
1606 }
1607}
1608
1609fn detect_npm_lock_confidence(content: &str) -> (bool, bool) {
1610 let Ok(value) = serde_json::from_str::<Value>(content) else {
1611 return (false, false);
1612 };
1613
1614 let mut all_pinned = true;
1615 let mut all_hashed = true;
1616 let mut packages_seen = 0usize;
1617
1618 if let Some(packages) = value.get("packages").and_then(|v| v.as_object()) {
1619 for (_, pkg_value) in packages {
1620 if let Some(pkg_obj) = pkg_value.as_object() {
1621 packages_seen += 1;
1622
1623 let version = pkg_obj
1624 .get("version")
1625 .and_then(|v| v.as_str())
1626 .unwrap_or_default();
1627 if !is_exact_pinned_version(version) {
1628 all_pinned = false;
1629 }
1630
1631 let has_integrity = pkg_obj
1632 .get("integrity")
1633 .and_then(|v| v.as_str())
1634 .is_some_and(|v| !v.trim().is_empty());
1635 if !has_integrity {
1636 all_hashed = false;
1637 }
1638 }
1639 }
1640 }
1641
1642 if let Some(dependencies) = value.get("dependencies").and_then(|v| v.as_object()) {
1643 for (_, dep_value) in dependencies {
1644 if let Some(dep_obj) = dep_value.as_object() {
1645 if let Some(version) = dep_obj.get("version").and_then(|v| v.as_str()) {
1646 packages_seen += 1;
1647 if !is_exact_pinned_version(version) {
1648 all_pinned = false;
1649 }
1650
1651 let has_integrity = dep_obj
1652 .get("integrity")
1653 .and_then(|v| v.as_str())
1654 .is_some_and(|v| !v.trim().is_empty());
1655 if !has_integrity {
1656 all_hashed = false;
1657 }
1658 } else if let Some(nested_deps) =
1659 dep_obj.get("dependencies").and_then(|v| v.as_object())
1660 {
1661 for (_, nested_dep_value) in nested_deps {
1662 if let Some(nested_obj) = nested_dep_value.as_object() {
1663 packages_seen += 1;
1664 let version = nested_obj
1665 .get("version")
1666 .and_then(|v| v.as_str())
1667 .unwrap_or_default();
1668 if !is_exact_pinned_version(version) {
1669 all_pinned = false;
1670 }
1671
1672 let has_integrity = nested_obj
1673 .get("integrity")
1674 .and_then(|v| v.as_str())
1675 .is_some_and(|v| !v.trim().is_empty());
1676 if !has_integrity {
1677 all_hashed = false;
1678 }
1679 }
1680 }
1681 }
1682 }
1683 }
1684 }
1685
1686 if packages_seen == 0 {
1687 (false, false)
1688 } else {
1689 (all_pinned, all_hashed)
1690 }
1691}
1692
1693fn detect_pnpm_lock_confidence(content: &str) -> (bool, bool) {
1694 let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(content) else {
1695 return (false, false);
1696 };
1697
1698 let mut all_pinned = true;
1699 let mut all_hashed = true;
1700 let mut packages_seen = 0usize;
1701
1702 let Some(packages) = value
1703 .as_mapping()
1704 .and_then(|m| m.get("packages"))
1705 .and_then(|v| v.as_mapping())
1706 else {
1707 return (false, false);
1708 };
1709
1710 for (_key, package_value) in packages {
1711 let Some(package_obj) = package_value.as_mapping() else {
1712 continue;
1713 };
1714 packages_seen += 1;
1715
1716 let version = package_obj
1717 .get("version")
1718 .and_then(|v| v.as_str())
1719 .unwrap_or_default()
1720 .trim();
1721 if !is_exact_pinned_version(version) {
1722 all_pinned = false;
1723 }
1724
1725 let has_integrity = package_obj
1726 .get("resolution")
1727 .and_then(|v| v.as_mapping())
1728 .and_then(|r| r.get("integrity"))
1729 .and_then(|v| v.as_str())
1730 .is_some_and(|v| !v.trim().is_empty())
1731 || package_obj
1732 .get("integrity")
1733 .and_then(|v| v.as_str())
1734 .is_some_and(|v| !v.trim().is_empty());
1735 if !has_integrity {
1736 all_hashed = false;
1737 }
1738 }
1739
1740 if packages_seen == 0 {
1741 (false, false)
1742 } else {
1743 (all_pinned, all_hashed)
1744 }
1745}
1746
1747fn detect_yarn_lock_confidence(content: &str) -> (bool, bool) {
1748 let mut all_pinned = true;
1749 let mut all_hashed = true;
1750 let mut packages_seen = 0usize;
1751
1752 let mut in_package_block = false;
1753 let mut current_has_version = false;
1754 let mut current_has_integrity = false;
1755
1756 for raw_line in content.lines() {
1757 let line = raw_line.trim();
1758 if line.is_empty() || line.starts_with('#') {
1759 continue;
1760 }
1761
1762 let is_package_header =
1763 !raw_line.starts_with(' ') && !raw_line.starts_with('\t') && line.ends_with(':');
1764 if is_package_header {
1765 if in_package_block {
1766 if !current_has_version {
1767 all_pinned = false;
1768 }
1769 if !current_has_integrity {
1770 all_hashed = false;
1771 }
1772 }
1773
1774 in_package_block = !line.starts_with("__");
1775 current_has_version = false;
1776 current_has_integrity = false;
1777 if in_package_block {
1778 packages_seen += 1;
1779 }
1780 continue;
1781 }
1782
1783 if !in_package_block {
1784 continue;
1785 }
1786
1787 if raw_line.starts_with(' ') || raw_line.starts_with('\t') {
1788 if line.starts_with("version ") {
1789 current_has_version = true;
1790 }
1791 if line.starts_with("resolved ") || line.starts_with("integrity ") {
1792 current_has_integrity = true;
1793 }
1794 }
1795 }
1796
1797 if in_package_block {
1798 if !current_has_version {
1799 all_pinned = false;
1800 }
1801 if !current_has_integrity {
1802 all_hashed = false;
1803 }
1804 }
1805
1806 if packages_seen == 0 {
1807 (false, false)
1808 } else {
1809 (all_pinned, all_hashed)
1810 }
1811}
1812
1813fn is_exact_pinned_version(version: &str) -> bool {
1814 let version = version.trim();
1815 if version.is_empty() {
1816 return false;
1817 }
1818
1819 let normalized = version
1820 .trim_start_matches("==")
1821 .trim_start_matches("~=")
1822 .trim_start_matches('=')
1823 .trim_start_matches("v")
1824 .trim();
1825
1826 if normalized.contains('*')
1827 || normalized.contains('x')
1828 || normalized.contains('>')
1829 || normalized.contains('<')
1830 || normalized.contains('^')
1831 || normalized.contains('~')
1832 || normalized.contains('|')
1833 || normalized.contains(',')
1834 || normalized.contains(' ')
1835 {
1836 return false;
1837 }
1838
1839 true
1840}
1841
1842fn find_json_key_line(content: &str, key: &str) -> usize {
1845 let needle = format!("\"{}\"", key);
1846 for (idx, line) in content.lines().enumerate() {
1847 if line.contains(&needle) {
1848 return idx + 1;
1849 }
1850 }
1851 1
1852}
1853
1854pub(super) fn parse_provenance(
1855 root: &Path,
1856 filter: &ScanPathFilter,
1857) -> provenance_surface::ProvenanceSurface {
1858 let mut prov = provenance_surface::ProvenanceSurface::default();
1859
1860 let pkg_json = root.join("package.json");
1862 if pkg_json.exists() && filter.allows_path(root, &pkg_json) {
1863 if let Ok(content) = std::fs::read_to_string(&pkg_json) {
1864 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
1865 prov.author = value
1866 .get("author")
1867 .and_then(|v| v.as_str())
1868 .map(|s| s.to_string());
1869 prov.repository = value
1870 .get("repository")
1871 .and_then(|v| v.get("url").or(Some(v)))
1872 .and_then(|v| v.as_str())
1873 .map(|s| s.to_string());
1874 prov.license = value
1875 .get("license")
1876 .and_then(|v| v.as_str())
1877 .map(|s| s.to_string());
1878 }
1879 }
1880 }
1881
1882 let pyproject = root.join("pyproject.toml");
1884 if pyproject.exists() && filter.allows_path(root, &pyproject) {
1885 if let Ok(content) = std::fs::read_to_string(&pyproject) {
1886 if let Ok(value) = content.parse::<toml::Value>() {
1887 if let Some(project) = value.get("project") {
1888 prov.license = project
1889 .get("license")
1890 .and_then(|v| v.get("text").or(Some(v)))
1891 .and_then(|v| v.as_str())
1892 .map(|s| s.to_string());
1893 if let Some(authors) = project.get("authors").and_then(|v| v.as_array()) {
1894 if let Some(first) = authors.first() {
1895 prov.author = first
1896 .get("name")
1897 .and_then(|v| v.as_str())
1898 .map(|s| s.to_string());
1899 }
1900 }
1901 }
1902 if let Some(urls) = value.get("project").and_then(|p| p.get("urls")) {
1903 prov.repository = urls
1904 .get("Repository")
1905 .or(urls.get("repository"))
1906 .and_then(|v| v.as_str())
1907 .map(|s| s.to_string());
1908 }
1909 }
1910 }
1911 }
1912
1913 prov
1914}
1915
1916use sha2::Digest;
1917
1918#[cfg(test)]
1919mod tests {
1920 use super::*;
1921
1922 #[test]
1923 fn test_file_detection_covers_shell_and_suffix_python_tests() {
1924 assert!(is_test_file(Path::new("scripts/check.test.sh")));
1925 assert!(is_test_file(Path::new("scripts/check.spec.sh")));
1926 assert!(is_test_file(Path::new("scripts/import_data_test.py")));
1927 assert!(is_test_file(Path::new("tests/unit.py")));
1928 assert!(!is_test_file(Path::new("scripts/load.py")));
1929 }
1930
1931 #[test]
1932 fn extracts_typescript_mcp_server_tool_declarations() {
1933 let content = r#"
1934const server = new McpServer({ name: "demo" })
1935
1936server.tool(
1937 'search_party',
1938 'Busca fuzzy por nome.',
1939 {},
1940 async () => ({ content: [] })
1941)
1942
1943server.registerTool("create_report", { description: "Create report" }, async () => {})
1944"#;
1945
1946 let tools =
1947 extract_mcp_tool_declarations_from_source(Path::new("src/mcp/server.ts"), content)
1948 .into_iter()
1949 .map(|declaration| declaration.tool)
1950 .collect::<Vec<_>>();
1951 assert_eq!(tools.len(), 2);
1952 assert_eq!(tools[0].name, "search_party");
1953 assert_eq!(
1954 tools[0].description.as_deref(),
1955 Some("Busca fuzzy por nome.")
1956 );
1957 assert_eq!(tools[0].defined_at.as_ref().map(|loc| loc.line), Some(5));
1958 assert_eq!(tools[1].name, "create_report");
1959 assert_eq!(tools[1].description.as_deref(), Some("Create report"));
1960 }
1961
1962 #[test]
1963 fn extracts_config_description_and_inline_handler_binding() {
1964 let content = r#"
1965server.registerTool(
1966 "create_report",
1967 {
1968 description: "Create a local report",
1969 inputSchema: { path: { type: "string", description: "Output path" } },
1970 },
1971 async ({ path }) => {
1972 await writeFile(path, "report");
1973 },
1974)
1975"#;
1976
1977 let declarations =
1978 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
1979
1980 assert_eq!(declarations.len(), 1);
1981 assert_eq!(declarations[0].tool.name, "create_report");
1982 assert_eq!(
1983 declarations[0].tool.description.as_deref(),
1984 Some("Create a local report")
1985 );
1986 assert!(matches!(
1987 declarations[0].handler,
1988 Some(McpToolHandler::Inline { .. })
1989 ));
1990 }
1991
1992 #[test]
1993 fn extracts_named_handler_binding_without_using_nested_descriptions() {
1994 let content = r#"
1995server.registerTool(
1996 "fetch_report",
1997 {
1998 inputSchema: { url: { type: "string", description: "Remote URL" } },
1999 description: "Fetch a report from a URL",
2000 },
2001 fetchReport,
2002)
2003"#;
2004
2005 let declarations =
2006 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
2007
2008 assert_eq!(declarations.len(), 1);
2009 assert_eq!(
2010 declarations[0].tool.description.as_deref(),
2011 Some("Fetch a report from a URL")
2012 );
2013 assert!(matches!(
2014 declarations[0].handler,
2015 Some(McpToolHandler::Named { ref symbol }) if symbol == "fetchReport"
2016 ));
2017 }
2018
2019 #[test]
2020 fn extracts_tool_callback_after_description_and_schema_arguments() {
2021 let content = r#"
2022server.tool(
2023 "read_file",
2024 "Read a local file",
2025 { path: z.string() },
2026 handleReadFile,
2027)
2028"#;
2029
2030 let declarations =
2031 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
2032
2033 assert_eq!(declarations.len(), 1);
2034 assert_eq!(
2035 declarations[0].tool.description.as_deref(),
2036 Some("Read a local file")
2037 );
2038 assert!(matches!(
2039 declarations[0].handler,
2040 Some(McpToolHandler::Named { ref symbol }) if symbol == "handleReadFile"
2041 ));
2042 }
2043
2044 #[test]
2045 fn duplicate_tool_prefers_declaration_with_handler_binding() {
2046 let content = r#"
2047server.registerTool("report", { description: "Incomplete declaration" })
2048server.registerTool(
2049 "report",
2050 { description: "Bound declaration" },
2051 async () => ({ content: [] }),
2052)
2053"#;
2054
2055 let declarations =
2056 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
2057
2058 assert_eq!(declarations.len(), 1);
2059 assert_eq!(
2060 declarations[0].tool.description.as_deref(),
2061 Some("Bound declaration")
2062 );
2063 assert!(matches!(
2064 declarations[0].handler,
2065 Some(McpToolHandler::Inline { .. })
2066 ));
2067 }
2068
2069 #[test]
2070 fn schema_arrow_function_is_not_misclassified_as_handler() {
2071 let content = r#"
2072server.tool(
2073 "read_file",
2074 "Read a local file",
2075 { path: z.string().transform(value => value.trim()) },
2076)
2077"#;
2078
2079 let declarations =
2080 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
2081
2082 assert_eq!(declarations.len(), 1);
2083 assert_eq!(declarations[0].handler, None);
2084 }
2085
2086 #[test]
2087 fn arrow_text_in_config_description_is_not_misclassified_as_handler() {
2088 let content = r#"
2089server.registerTool("map_value", {
2090 description: "Maps a => b",
2091 inputSchema: { value: { type: "string" } },
2092})
2093"#;
2094
2095 let declarations =
2096 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
2097
2098 assert_eq!(declarations.len(), 1);
2099 assert_eq!(
2100 declarations[0].tool.description.as_deref(),
2101 Some("Maps a => b")
2102 );
2103 assert_eq!(declarations[0].handler, None);
2104 }
2105
2106 #[test]
2107 fn reserved_literals_are_not_named_handlers() {
2108 for candidate in ["async", "true", "false", "null", "undefined", "this"] {
2109 assert_eq!(
2110 parse_mcp_tool_handler(Path::new("src/server.ts"), candidate, 0, candidate.len()),
2111 None,
2112 "{candidate} must not be classified as a named handler"
2113 );
2114 }
2115 }
2116
2117 #[test]
2118 fn handler_names_with_function_prefix_remain_named() {
2119 let candidate = "functionHandler";
2120 assert!(matches!(
2121 parse_mcp_tool_handler(Path::new("src/server.ts"), candidate, 0, candidate.len()),
2122 Some(McpToolHandler::Named { ref symbol }) if symbol == candidate
2123 ));
2124
2125 let inline = "async() => ({ content: [] })";
2126 assert!(matches!(
2127 parse_mcp_tool_handler(Path::new("src/server.ts"), inline, 0, inline.len()),
2128 Some(McpToolHandler::Inline { .. })
2129 ));
2130 }
2131
2132 #[test]
2133 fn ignores_tool_calls_inside_comments_and_strings() {
2134 let content = r#"
2135// server.tool("commented", "Nope", async () => {})
2136const docs = 'call server.registerTool("string", {}, handler)'
2137/* server.registerTool("blocked", {}, handler) */
2138server.registerTool("real", { description: "Real tool" }, handlers.run)
2139"#;
2140
2141 let declarations =
2142 extract_mcp_tool_declarations_from_source(Path::new("src/server.ts"), content);
2143
2144 assert_eq!(declarations.len(), 1);
2145 assert_eq!(declarations[0].tool.name, "real");
2146 assert!(matches!(
2147 declarations[0].handler,
2148 Some(McpToolHandler::Named { ref symbol }) if symbol == "handlers.run"
2149 ));
2150 }
2151
2152 #[cfg(feature = "typescript")]
2153 #[test]
2154 fn binds_named_handlers_without_cross_tool_operation_leakage() {
2155 use crate::parser::LanguageParser;
2156
2157 let path = Path::new("src/server.ts");
2158 let content = r#"
2159server.registerTool("read_file", { description: "Read a file" }, handleRead)
2160server.registerTool("fetch_url", { description: "Fetch a URL" }, handleFetch)
2161
2162async function handleRead(path: string) {
2163 return readFile(path)
2164}
2165
2166async function handleFetch(url: string) {
2167 return fetch(url)
2168}
2169"#;
2170 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2171 let parsed = parser::typescript::TypeScriptParser
2172 .parse_file(path, content)
2173 .unwrap();
2174
2175 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2176
2177 assert_eq!(bindings.len(), 2);
2178 assert!(bindings[0].handler_resolved);
2179 assert!(bindings[0].observation_complete);
2180 assert_eq!(bindings[0].execution.file_operations.len(), 1);
2181 assert!(bindings[0].execution.network_operations.is_empty());
2182 assert!(bindings[1].handler_resolved);
2183 assert!(bindings[1].observation_complete);
2184 assert!(bindings[1].execution.file_operations.is_empty());
2185 assert_eq!(bindings[1].execution.network_operations.len(), 1);
2186 }
2187
2188 #[cfg(feature = "typescript")]
2189 #[test]
2190 fn binds_inline_handler_and_one_hop_in_project_callee() {
2191 use crate::parser::LanguageParser;
2192
2193 let path = Path::new("src/server.ts");
2194 let content = r#"
2195server.registerTool(
2196 "fetch_report",
2197 { description: "Fetch a report" },
2198 async (url: string) => {
2199 await writeFile("audit.log", "started")
2200 return fetchThroughClient(url)
2201 },
2202)
2203
2204async function fetchThroughClient(url: string) {
2205 return fetch(url)
2206}
2207"#;
2208 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2209 let parsed = parser::typescript::TypeScriptParser
2210 .parse_file(path, content)
2211 .unwrap();
2212
2213 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2214
2215 assert_eq!(bindings.len(), 1);
2216 assert!(bindings[0].handler_resolved);
2217 assert!(bindings[0].observation_complete);
2218 assert_eq!(bindings[0].resolved_callees, vec!["fetchThroughClient"]);
2219 assert_eq!(bindings[0].execution.file_operations.len(), 1);
2220 assert_eq!(bindings[0].execution.network_operations.len(), 1);
2221 }
2222
2223 #[cfg(feature = "typescript")]
2224 #[test]
2225 fn operation_binding_stops_after_one_callee_hop() {
2226 use crate::parser::LanguageParser;
2227
2228 let path = Path::new("src/server.ts");
2229 let content = r#"
2230server.registerTool("report", { description: "Build report" }, handleReport)
2231
2232async function handleReport() {
2233 return firstHop()
2234}
2235
2236async function firstHop() {
2237 return secondHop()
2238}
2239
2240async function secondHop() {
2241 return fetch("https://example.com")
2242}
2243"#;
2244 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2245 let parsed = parser::typescript::TypeScriptParser
2246 .parse_file(path, content)
2247 .unwrap();
2248
2249 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2250
2251 assert_eq!(bindings.len(), 1);
2252 assert!(bindings[0].handler_resolved);
2253 assert!(!bindings[0].observation_complete);
2254 assert_eq!(bindings[0].resolved_callees, vec!["firstHop"]);
2255 assert!(
2256 bindings[0].execution.network_operations.is_empty(),
2257 "depth-2 operations must not be attributed to the tool"
2258 );
2259 }
2260
2261 #[cfg(feature = "typescript")]
2262 #[test]
2263 fn opaque_call_keeps_operation_observation_incomplete() {
2264 use crate::parser::LanguageParser;
2265
2266 let path = Path::new("src/server.ts");
2267 let content = r#"
2268server.registerTool("report", { description: "Fetch URLs" }, handleReport)
2269
2270async function handleReport(url: string) {
2271 return externalClient(url)
2272}
2273"#;
2274 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2275 let parsed = parser::typescript::TypeScriptParser
2276 .parse_file(path, content)
2277 .unwrap();
2278
2279 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2280
2281 assert_eq!(bindings.len(), 1);
2282 assert!(bindings[0].handler_resolved);
2283 assert!(!bindings[0].observation_complete);
2284 assert!(bindings[0].execution.network_operations.is_empty());
2285 }
2286
2287 #[cfg(feature = "typescript")]
2288 #[test]
2289 fn dynamic_execution_keeps_operation_observation_incomplete() {
2290 use crate::parser::LanguageParser;
2291
2292 let path = Path::new("src/server.ts");
2293 let content = r#"
2294server.registerTool("evaluate", { description: "Evaluate arbitrary code" }, handleEval)
2295function handleEval(code: string) { return eval(code) }
2296"#;
2297 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2298 let parsed = parser::typescript::TypeScriptParser
2299 .parse_file(path, content)
2300 .unwrap();
2301
2302 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2303
2304 assert_eq!(bindings.len(), 1);
2305 assert!(bindings[0].handler_resolved);
2306 assert!(!bindings[0].observation_complete);
2307 assert_eq!(bindings[0].execution.dynamic_exec.len(), 1);
2308 }
2309
2310 #[cfg(feature = "typescript")]
2311 #[test]
2312 fn uncalled_nested_function_operations_are_not_attributed_to_handler() {
2313 use crate::parser::LanguageParser;
2314
2315 let path = Path::new("src/server.ts");
2316 let content = r#"
2317server.registerTool("report", { description: "Build report" }, handleReport)
2318
2319async function handleReport() {
2320 async function unusedNetworkHelper() {
2321 return fetch("https://example.com")
2322 }
2323 return "local report"
2324}
2325"#;
2326 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2327 let parsed = parser::typescript::TypeScriptParser
2328 .parse_file(path, content)
2329 .unwrap();
2330
2331 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2332
2333 assert_eq!(bindings.len(), 1);
2334 assert!(bindings[0].handler_resolved);
2335 assert!(bindings[0].observation_complete);
2336 assert!(bindings[0].resolved_callees.is_empty());
2337 assert!(
2338 bindings[0].execution.network_operations.is_empty(),
2339 "an uncalled nested function is not part of handler execution"
2340 );
2341 }
2342
2343 #[cfg(feature = "typescript")]
2344 #[test]
2345 fn ambiguous_named_handler_stays_unresolved() {
2346 use crate::parser::LanguageParser;
2347
2348 let registration_path = Path::new("src/server.ts");
2349 let registration =
2350 r#"server.registerTool("report", { description: "Report" }, handleReport)"#;
2351 let first_path = Path::new("src/first.ts");
2352 let first = "function handleReport() { return readFile('report.txt') }";
2353 let second_path = Path::new("src/second.ts");
2354 let second = "function handleReport() { return fetch('https://example.com') }";
2355
2356 let declarations =
2357 extract_mcp_tool_declarations_from_source(registration_path, registration);
2358 let parsed_files = vec![
2359 (
2360 first_path.to_path_buf(),
2361 parser::typescript::TypeScriptParser
2362 .parse_file(first_path, first)
2363 .unwrap(),
2364 ),
2365 (
2366 second_path.to_path_buf(),
2367 parser::typescript::TypeScriptParser
2368 .parse_file(second_path, second)
2369 .unwrap(),
2370 ),
2371 ];
2372
2373 let bindings = bind_mcp_tool_operations(&declarations, &parsed_files);
2374
2375 assert_eq!(bindings.len(), 1);
2376 assert!(!bindings[0].handler_resolved);
2377 assert!(!bindings[0].observation_complete);
2378 assert!(bindings[0].execution.file_operations.is_empty());
2379 assert!(bindings[0].execution.network_operations.is_empty());
2380 }
2381
2382 #[cfg(feature = "typescript")]
2383 #[test]
2384 fn resolves_named_handler_across_source_files() {
2385 use crate::parser::LanguageParser;
2386
2387 let registration_path = Path::new("src/server.ts");
2388 let registration =
2389 r#"server.registerTool("report", { description: "Report" }, handleReport)"#;
2390 let handler_path = Path::new("src/handlers.ts");
2391 let handler = "function handleReport() { return readFile('report.txt') }";
2392
2393 let declarations =
2394 extract_mcp_tool_declarations_from_source(registration_path, registration);
2395 let parsed_files = vec![(
2396 handler_path.to_path_buf(),
2397 parser::typescript::TypeScriptParser
2398 .parse_file(handler_path, handler)
2399 .unwrap(),
2400 )];
2401
2402 let bindings = bind_mcp_tool_operations(&declarations, &parsed_files);
2403
2404 assert_eq!(bindings.len(), 1);
2405 assert!(bindings[0].handler_resolved);
2406 assert!(bindings[0].observation_complete);
2407 assert_eq!(bindings[0].execution.file_operations.len(), 1);
2408 }
2409
2410 #[cfg(feature = "typescript")]
2411 #[test]
2412 fn dotted_named_handler_stays_unresolved_without_member_resolution() {
2413 use crate::parser::LanguageParser;
2414
2415 let path = Path::new("src/server.ts");
2416 let content = r#"
2417server.registerTool("report", { description: "Report" }, handlers.run)
2418function run() { return readFile("report.txt") }
2419"#;
2420 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2421 let parsed = parser::typescript::TypeScriptParser
2422 .parse_file(path, content)
2423 .unwrap();
2424
2425 let bindings = bind_mcp_tool_operations(&declarations, &[(path.to_path_buf(), parsed)]);
2426
2427 assert_eq!(bindings.len(), 1);
2428 assert!(!bindings[0].handler_resolved);
2429 assert!(!bindings[0].observation_complete);
2430 assert!(bindings[0].execution.file_operations.is_empty());
2431 }
2432
2433 #[cfg(feature = "typescript")]
2434 #[test]
2435 fn adapter_load_projects_per_tool_observed_capabilities() {
2436 use crate::adapter::Adapter;
2437
2438 let fixture = tempfile::tempdir().unwrap();
2439 std::fs::write(
2440 fixture.path().join("package.json"),
2441 r#"{"dependencies":{"@modelcontextprotocol/sdk":"1.0.0"}}"#,
2442 )
2443 .unwrap();
2444 std::fs::write(
2445 fixture.path().join("server.ts"),
2446 r#"
2447server.registerTool("read_file", { description: "Read a file" }, handleRead)
2448server.registerTool("fetch_url", { description: "Fetch a URL" }, handleFetch)
2449
2450function handleRead(path: string) { return readFile(path) }
2451function handleFetch(url: string) { return fetch(url) }
2452"#,
2453 )
2454 .unwrap();
2455
2456 let target = McpAdapter.load(fixture.path(), false).unwrap().remove(0);
2457 let read = target
2458 .tools
2459 .iter()
2460 .find(|tool| tool.name == "read_file")
2461 .unwrap();
2462 let fetch = target
2463 .tools
2464 .iter()
2465 .find(|tool| tool.name == "fetch_url")
2466 .unwrap();
2467
2468 assert_eq!(
2469 read.observed_capabilities,
2470 std::collections::BTreeSet::from([Capability::FsRead])
2471 );
2472 assert!(
2473 read.capability_evidence
2474 .iter()
2475 .all(|evidence| evidence.capability == Capability::FsRead)
2476 );
2477 assert_eq!(
2478 fetch.observed_capabilities,
2479 std::collections::BTreeSet::from([Capability::NetworkEgress])
2480 );
2481 assert!(read.capability_observation_complete);
2482 assert!(fetch.capability_observation_complete);
2483 }
2484
2485 #[cfg(not(feature = "typescript"))]
2486 #[test]
2487 fn adapter_load_without_typescript_keeps_observed_capabilities_empty() {
2488 use crate::adapter::Adapter;
2489
2490 let fixture = tempfile::tempdir().unwrap();
2491 std::fs::write(
2492 fixture.path().join("package.json"),
2493 r#"{"dependencies":{"@modelcontextprotocol/sdk":"1.0.0"}}"#,
2494 )
2495 .unwrap();
2496 std::fs::write(
2497 fixture.path().join("server.ts"),
2498 r#"
2499server.registerTool("fetch_url", { description: "Fetch a URL" }, handleFetch)
2500function handleFetch(url: string) { return fetch(url) }
2501"#,
2502 )
2503 .unwrap();
2504
2505 let target = McpAdapter.load(fixture.path(), false).unwrap().remove(0);
2506 let tool = target
2507 .tools
2508 .iter()
2509 .find(|tool| tool.name == "fetch_url")
2510 .unwrap();
2511
2512 assert!(tool.observed_capabilities.is_empty());
2513 assert!(tool.capability_evidence.is_empty());
2514 assert!(!tool.capability_observation_complete);
2515 }
2516
2517 #[test]
2518 fn adapter_load_projects_permissions_but_not_input_schema() {
2519 use crate::adapter::Adapter;
2520
2521 let fixture = tempfile::tempdir().unwrap();
2522 std::fs::write(
2523 fixture.path().join("package.json"),
2524 r#"{"dependencies":{"@modelcontextprotocol/sdk":"1.0.0"}}"#,
2525 )
2526 .unwrap();
2527 std::fs::write(
2528 fixture.path().join("tools.json"),
2529 r#"{
2530 "tools": [
2531 {
2532 "name": "fetch_url",
2533 "description": "Fetch URLs",
2534 "inputSchema": {"properties": {"url": {"type": "string"}}}
2535 },
2536 {
2537 "name": "schema_only",
2538 "inputSchema": {"properties": {"url": {"type": "string"}}}
2539 }
2540 ]
2541}"#,
2542 )
2543 .unwrap();
2544
2545 let target = McpAdapter.load(fixture.path(), false).unwrap().remove(0);
2546 let fetch = target
2547 .tools
2548 .iter()
2549 .find(|tool| tool.name == "fetch_url")
2550 .unwrap();
2551 let schema_only = target
2552 .tools
2553 .iter()
2554 .find(|tool| tool.name == "schema_only")
2555 .unwrap();
2556
2557 assert_eq!(
2558 fetch.declared_capabilities,
2559 std::collections::BTreeSet::from([Capability::NetworkEgress])
2560 );
2561 assert_eq!(
2562 fetch
2563 .capability_declarations
2564 .iter()
2565 .filter(|declaration| {
2566 declaration.source == CapabilityDeclarationSource::Description
2567 })
2568 .count(),
2569 1
2570 );
2571 assert_eq!(
2572 fetch
2573 .capability_declarations
2574 .iter()
2575 .filter(|declaration| {
2576 declaration.source == CapabilityDeclarationSource::Permission
2577 })
2578 .count(),
2579 1
2580 );
2581 assert!(schema_only.declared_capabilities.is_empty());
2582 assert!(schema_only.capability_declarations.is_empty());
2583 }
2584
2585 #[cfg(not(feature = "typescript"))]
2586 #[test]
2587 fn no_typescript_feature_keeps_operation_binding_unresolved() {
2588 let path = Path::new("src/server.ts");
2589 let content = r#"server.registerTool("fetch", { description: "Fetch" }, handleFetch)"#;
2590 let declarations = extract_mcp_tool_declarations_from_source(path, content);
2591
2592 let bindings = bind_mcp_tool_operations(&declarations, &[]);
2593
2594 assert_eq!(bindings.len(), 1);
2595 assert!(!bindings[0].handler_resolved);
2596 assert!(bindings[0].execution.network_operations.is_empty());
2597 assert!(bindings[0].resolved_callees.is_empty());
2598 }
2599
2600 #[test]
2601 fn extracts_python_mcp_tool_decorators() {
2602 let content = r#"
2603from mcp.server.fastmcp import FastMCP
2604
2605mcp = FastMCP("demo")
2606
2607@mcp.tool(name="search", description="Search web")
2608async def search(query: str):
2609 return []
2610
2611@mcp.tool()
2612def status():
2613 return {}
2614"#;
2615
2616 let tools = extract_mcp_tools_from_source(Path::new("src/mcp/server.py"), content);
2617 assert_eq!(tools.len(), 2);
2618 assert_eq!(tools[0].name, "search");
2619 assert_eq!(tools[0].description.as_deref(), Some("Search web"));
2620 assert_eq!(tools[1].name, "status");
2621 }
2622
2623 #[test]
2624 fn extracts_python_mcp_tool_call_syntax() {
2625 let content = r#"
2626server = FastMCP("demo")
2627
2628server.tool("echo", "Run echo command")
2629"#;
2630
2631 let tools = extract_mcp_tools_from_source(Path::new("src/mcp/server.py"), content);
2632 assert_eq!(tools.len(), 1);
2633 assert_eq!(tools[0].name, "echo");
2634 assert_eq!(tools[0].description.as_deref(), Some("Run echo command"));
2635 }
2636
2637 #[test]
2638 fn extracts_python_bare_mcp_tool_decorators() {
2639 let content = r#"
2640from mcp.server.fastmcp import FastMCP
2641
2642mcp = FastMCP("demo")
2643
2644@mcp.tool
2645def calculate(expr: str):
2646 return eval(expr)
2647"#;
2648
2649 let tools = extract_mcp_tools_from_source(Path::new("src/mcp/server.py"), content);
2650 assert_eq!(tools.len(), 1);
2651 assert_eq!(tools[0].name, "calculate");
2652 }
2653}