Skip to main content

agentshield/adapter/
mcp.rs

1use std::path::Path;
2
3use crate::error::Result;
4use crate::ir::execution_surface::ExecutionSurface;
5use crate::ir::*;
6use crate::parser;
7
8/// MCP Server adapter.
9///
10/// Detects MCP servers by looking for:
11/// - package.json with `@modelcontextprotocol/sdk` dependency
12/// - Python files importing `mcp` or `mcp.server`
13/// - mcp.json / mcp-config.json manifest
14pub struct McpAdapter;
15
16impl super::Adapter for McpAdapter {
17    fn framework(&self) -> Framework {
18        Framework::Mcp
19    }
20
21    fn detect(&self, root: &Path) -> bool {
22        // Check package.json for MCP SDK
23        let pkg_json = root.join("package.json");
24        if pkg_json.exists() {
25            if let Ok(content) = std::fs::read_to_string(&pkg_json) {
26                if content.contains("@modelcontextprotocol/sdk") || content.contains("mcp-server") {
27                    return true;
28                }
29            }
30        }
31
32        // Check pyproject.toml for mcp dependency
33        let pyproject = root.join("pyproject.toml");
34        if pyproject.exists() {
35            if let Ok(content) = std::fs::read_to_string(&pyproject) {
36                if content.contains("mcp") {
37                    return true;
38                }
39            }
40        }
41
42        // Check for Python files importing mcp
43        if let Ok(entries) = std::fs::read_dir(root) {
44            for entry in entries.flatten() {
45                let path = entry.path();
46                if path.extension().is_some_and(|e| e == "py") {
47                    if let Ok(content) = std::fs::read_to_string(&path) {
48                        if content.contains("from mcp")
49                            || content.contains("import mcp")
50                            || content.contains("@server.tool")
51                        {
52                            return true;
53                        }
54                    }
55                }
56            }
57        }
58
59        // Check requirements.txt
60        let requirements = root.join("requirements.txt");
61        if requirements.exists() {
62            if let Ok(content) = std::fs::read_to_string(&requirements) {
63                if content.lines().any(|l| l.trim().starts_with("mcp")) {
64                    return true;
65                }
66            }
67        }
68
69        false
70    }
71
72    fn load(&self, root: &Path) -> Result<Vec<ScanTarget>> {
73        let name = root
74            .file_name()
75            .map(|n| n.to_string_lossy().to_string())
76            .unwrap_or_else(|| "mcp-server".into());
77
78        let mut source_files = Vec::new();
79        let mut execution = ExecutionSurface::default();
80        let mut tools = Vec::new();
81
82        // Collect source files
83        collect_source_files(root, &mut source_files)?;
84
85        // Parse each source file
86        for sf in &source_files {
87            if let Some(parser) = parser::parser_for_language(sf.language) {
88                if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
89                    execution.commands.extend(parsed.commands);
90                    execution.file_operations.extend(parsed.file_operations);
91                    execution
92                        .network_operations
93                        .extend(parsed.network_operations);
94                    execution.env_accesses.extend(parsed.env_accesses);
95                    execution.dynamic_exec.extend(parsed.dynamic_exec);
96                }
97            }
98        }
99
100        // Parse tool definitions from JSON if available
101        let tools_json = root.join("tools.json");
102        if tools_json.exists() {
103            if let Ok(content) = std::fs::read_to_string(&tools_json) {
104                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
105                    tools = parser::json_schema::parse_tools_from_json(&value);
106                }
107            }
108        }
109
110        // Parse dependencies
111        let dependencies = parse_dependencies(root);
112
113        // Parse provenance from package.json or pyproject.toml
114        let provenance = parse_provenance(root);
115
116        Ok(vec![ScanTarget {
117            name,
118            framework: Framework::Mcp,
119            root_path: root.to_path_buf(),
120            tools,
121            execution,
122            data: Default::default(),
123            dependencies,
124            provenance,
125            source_files,
126        }])
127    }
128}
129
130fn collect_source_files(root: &Path, files: &mut Vec<SourceFile>) -> Result<()> {
131    let walker = ignore::WalkBuilder::new(root)
132        .hidden(true)
133        .git_ignore(true)
134        .max_depth(Some(5))
135        .build();
136
137    for entry in walker.flatten() {
138        let path = entry.path();
139        if !path.is_file() {
140            continue;
141        }
142
143        let ext = path
144            .extension()
145            .map(|e| e.to_string_lossy().to_string())
146            .unwrap_or_default();
147        let lang = Language::from_extension(&ext);
148
149        if matches!(lang, Language::Unknown) {
150            continue;
151        }
152
153        // Skip files larger than 1MB
154        let metadata = std::fs::metadata(path)?;
155        if metadata.len() > 1_048_576 {
156            continue;
157        }
158
159        if let Ok(content) = std::fs::read_to_string(path) {
160            let hash = format!(
161                "{:x}",
162                sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
163            );
164            files.push(SourceFile {
165                path: path.to_path_buf(),
166                language: lang,
167                size_bytes: metadata.len(),
168                content_hash: hash,
169                content,
170            });
171        }
172    }
173
174    Ok(())
175}
176
177fn parse_dependencies(root: &Path) -> dependency_surface::DependencySurface {
178    use crate::ir::dependency_surface::*;
179    let mut surface = DependencySurface::default();
180
181    // Parse requirements.txt as a dependency manifest (NOT a lockfile)
182    let req_file = root.join("requirements.txt");
183    if req_file.exists() {
184        if let Ok(content) = std::fs::read_to_string(&req_file) {
185            for (idx, line) in content.lines().enumerate() {
186                let line = line.trim();
187                if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
188                    continue;
189                }
190                let (name, version) = if let Some(pos) = line.find("==") {
191                    (
192                        line[..pos].trim().to_string(),
193                        Some(line[pos + 2..].trim().to_string()),
194                    )
195                } else if let Some(pos) = line.find(">=") {
196                    (
197                        line[..pos].trim().to_string(),
198                        Some(line[pos..].trim().to_string()),
199                    )
200                } else {
201                    (line.to_string(), None)
202                };
203
204                surface.dependencies.push(Dependency {
205                    name,
206                    version_constraint: version,
207                    locked_version: None,
208                    locked_hash: None,
209                    registry: "pypi".into(),
210                    is_dev: false,
211                    location: Some(SourceLocation {
212                        file: req_file.clone(),
213                        line: idx + 1,
214                        column: 0,
215                        end_line: None,
216                        end_column: None,
217                    }),
218                });
219            }
220        }
221    }
222
223    // Check for actual Python lockfiles
224    for (filename, format) in [
225        ("Pipfile.lock", LockfileFormat::PipenvLock),
226        ("poetry.lock", LockfileFormat::PoetryLock),
227        ("uv.lock", LockfileFormat::UvLock),
228    ] {
229        let lock_path = root.join(filename);
230        if lock_path.exists() {
231            surface.lockfile = Some(LockfileInfo {
232                path: lock_path,
233                format,
234                all_pinned: true,
235                all_hashed: false,
236            });
237            break;
238        }
239    }
240
241    // Parse package.json dependencies
242    let pkg_json = root.join("package.json");
243    if pkg_json.exists() {
244        if let Ok(content) = std::fs::read_to_string(&pkg_json) {
245            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
246                for (key, is_dev) in [("dependencies", false), ("devDependencies", true)] {
247                    if let Some(deps) = value.get(key).and_then(|v| v.as_object()) {
248                        for (name, version) in deps {
249                            surface.dependencies.push(Dependency {
250                                name: name.clone(),
251                                version_constraint: version.as_str().map(|s| s.to_string()),
252                                locked_version: None,
253                                locked_hash: None,
254                                registry: "npm".into(),
255                                is_dev,
256                                location: None,
257                            });
258                        }
259                    }
260                }
261            }
262        }
263
264        // Check for lockfile
265        let lock = root.join("package-lock.json");
266        if lock.exists() {
267            surface.lockfile = Some(LockfileInfo {
268                path: lock,
269                format: dependency_surface::LockfileFormat::NpmLock,
270                all_pinned: true,
271                all_hashed: false,
272            });
273        }
274    }
275
276    surface
277}
278
279fn parse_provenance(root: &Path) -> provenance_surface::ProvenanceSurface {
280    let mut prov = provenance_surface::ProvenanceSurface::default();
281
282    // From package.json
283    let pkg_json = root.join("package.json");
284    if pkg_json.exists() {
285        if let Ok(content) = std::fs::read_to_string(&pkg_json) {
286            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
287                prov.author = value
288                    .get("author")
289                    .and_then(|v| v.as_str())
290                    .map(|s| s.to_string());
291                prov.repository = value
292                    .get("repository")
293                    .and_then(|v| v.get("url").or(Some(v)))
294                    .and_then(|v| v.as_str())
295                    .map(|s| s.to_string());
296                prov.license = value
297                    .get("license")
298                    .and_then(|v| v.as_str())
299                    .map(|s| s.to_string());
300            }
301        }
302    }
303
304    // From pyproject.toml
305    let pyproject = root.join("pyproject.toml");
306    if pyproject.exists() {
307        if let Ok(content) = std::fs::read_to_string(&pyproject) {
308            if let Ok(value) = content.parse::<toml::Value>() {
309                if let Some(project) = value.get("project") {
310                    prov.license = project
311                        .get("license")
312                        .and_then(|v| v.get("text").or(Some(v)))
313                        .and_then(|v| v.as_str())
314                        .map(|s| s.to_string());
315                    if let Some(authors) = project.get("authors").and_then(|v| v.as_array()) {
316                        if let Some(first) = authors.first() {
317                            prov.author = first
318                                .get("name")
319                                .and_then(|v| v.as_str())
320                                .map(|s| s.to_string());
321                        }
322                    }
323                }
324                if let Some(urls) = value.get("project").and_then(|p| p.get("urls")) {
325                    prov.repository = urls
326                        .get("Repository")
327                        .or(urls.get("repository"))
328                        .and_then(|v| v.as_str())
329                        .map(|s| s.to_string());
330                }
331            }
332        }
333    }
334
335    prov
336}
337
338use sha2::Digest;