Skip to main content

agentshield/adapter/
mcp.rs

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
18/// MCP Server adapter.
19///
20/// Detects MCP servers by looking for:
21/// - package.json with `@modelcontextprotocol/sdk` dependency
22/// - Python files importing `mcp` or `mcp.server`
23/// - mcp.json / mcp-config.json manifest
24pub 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
137    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    // Phase 1: Parse each source file, collecting results for cross-file analysis.
157    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    // Phase 2: Cross-file sanitizer-aware analysis — downgrade operations
167    // in functions that are only called with sanitized arguments.
168    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    // Phase 3: Merge parsed results into execution surface.
196    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    // Parse tool definitions from JSON if available
209    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
257/// Check if a file path belongs to a test file or test directory.
258///
259/// Matches common conventions across Python, TypeScript, and JavaScript:
260/// - Directories: `test/`, `tests/`, `__tests__/`, `__pycache__/`
261/// - Suffixes: `.test.{ts,js,tsx,jsx,py,sh}`, `.spec.{ts,js,tsx,jsx,py,sh}`
262/// - Python conventions: `test_*.py`, `*_test.py`
263/// - Config files: `conftest.py`, `jest.config.*`, `vitest.config.*`, `pytest.ini`, `setup.cfg`
264pub fn is_test_file(path: &Path) -> bool {
265    // Check if any path component is a test directory
266    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    // Test config files
285    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    // pytest conventions: test_*.py and *_test.py
293    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    // Suffix conventions: *.test.{ts,js,tsx,jsx,py,sh}, *.spec.{ts,js,tsx,jsx,py,sh}
300    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        // Template literals are treated as opaque strings. Nested backticks
982        // inside `${...}` are intentionally unsupported in this lightweight
983        // extractor; handler-to-body resolution remains AST-backed follow-up work.
984        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        // A decorator applies to the next top-level function definition.
1075        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    // Columns are UTF-8 byte offsets and `end` is exclusive, matching the
1246    // half-open span produced by the source segment scanner.
1247    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        // Skip files larger than 1MB
1300        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    // Parse requirements.txt as a dependency manifest (NOT a lockfile)
1331    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    // Check for Python lockfiles
1373    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    // Parse package.json dependencies
1393    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        // Check for npm / yarn / pnpm lockfiles
1423        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        // Poetry lockfiles typically carry checksums in package.files[].hashes entries.
1539        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
1842/// Find the 1-based line number where a JSON key (e.g. `"package-name"`) appears.
1843/// Falls back to line 1 if the key is not found.
1844fn 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    // From package.json
1861    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    // From pyproject.toml
1883    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}