mcpsec 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,22 @@ MCP gives AI agents access to tools, files, databases, and APIs. A single malici
14
14
 
15
15
  mcpsec finds these problems before attackers do.
16
16
 
17
+ ## Real-World Results
18
+
19
+ We tested mcpsec against 10 popular MCP servers (GitHub, Slack, Postgres, Brave Search, Puppeteer, etc.) configured exactly as their official README files recommend.
20
+
21
+ **Score: 2/100**
22
+
23
+ | Severity | Findings | Examples |
24
+ |----------|----------|---------|
25
+ | Critical | 3 | GitHub PAT, Slack bot token, and Postgres password in plain text config |
26
+ | High | 1 | API key hardcoded in env block |
27
+ | Medium | 1 | Unverified third-party npm package via `npx -y` |
28
+
29
+ The only servers that passed clean were ones that don't require credentials (filesystem, memory, sqlite). Every server that needs an API key had it hardcoded in the config file - because that's what the docs tell you to do.
30
+
31
+ Against deliberately vulnerable MCP server configs (from security research projects), mcpsec found **17 findings** including hardcoded AWS keys, Stripe live keys, SSRF endpoints, and supply chain risks.
32
+
17
33
  ## Quick Start
18
34
 
19
35
  ```bash
@@ -44,7 +60,7 @@ npx mcpsec scan --baseline --json
44
60
  ## Example Output
45
61
 
46
62
  ```
47
- mcpsec - MCP Security Scanner v0.1.0
63
+ mcpsec - MCP Security Scanner v0.3.0
48
64
  ──────────────────────────────────────────────────
49
65
 
50
66
  Configurations Found
@@ -85,6 +101,41 @@ npx mcpsec scan --baseline --json
85
101
  Fix: Pin to a specific version: npx package@1.2.3
86
102
  ```
87
103
 
104
+ ## Registry Scanning
105
+
106
+ Scan MCP servers directly from the official [MCP Registry](https://registry.modelcontextprotocol.io/) to assess their security before installing them.
107
+
108
+ ```bash
109
+ # Scan top servers from the registry
110
+ npx mcpsec scan --registry
111
+
112
+ # Limit number of servers
113
+ npx mcpsec scan --registry --limit 50
114
+
115
+ # Search for servers by keyword
116
+ npx mcpsec scan --registry --search "database"
117
+
118
+ # Scan a specific server by name
119
+ npx mcpsec scan --registry --server "filesystem"
120
+
121
+ # Combine with output formats
122
+ npx mcpsec scan --registry --json
123
+ npx mcpsec scan --registry --sarif
124
+ ```
125
+
126
+ ### Registry-Specific Checks
127
+
128
+ In addition to running the standard static analysis pipeline, registry mode performs supply-chain checks against npm:
129
+
130
+ | Check | Category | Severity |
131
+ |-------|----------|----------|
132
+ | Package not found on npm | Supply Chain | High |
133
+ | Package < 1 week old | Supply Chain | Medium |
134
+ | Package < 100 weekly downloads | Supply Chain | Low |
135
+ | Single maintainer on npm | Supply Chain | Info |
136
+ | No repository URL in registry entry | Supply Chain | Low |
137
+ | Repository URL returns 404 | Supply Chain | Medium |
138
+
88
139
  ## What It Detects
89
140
 
90
141
  ### Static Analysis (default)
@@ -234,6 +285,10 @@ Options:
234
285
  --json Output results as JSON
235
286
  --sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
236
287
  --path <file> Scan a specific config file
288
+ --registry Scan servers from the official MCP registry
289
+ --limit <n> Max servers to fetch from registry (default: 20)
290
+ --search <query> Search registry servers by keyword
291
+ --server <name> Scan a specific registry server by name
237
292
  --save-baseline [file] Save scan results as baseline (default: .mcpsec-baseline.json)
238
293
  --baseline [file] Compare scan against baseline and show diff
239
294
  --no-color Disable colored output
@@ -269,11 +324,13 @@ src/
269
324
  injection-patterns.ts Prompt injection / tool poisoning patterns
270
325
  url-validator.ts SSRF detection (cloud metadata, private IPs)
271
326
  mcp-client.ts MCP protocol client (stdio + HTTP)
327
+ registry-client.ts MCP registry + npm API client
272
328
  scanner/
273
329
  config-scanner.ts Config-level checks (transport, docker, supply chain)
274
330
  credential-scanner.ts API key and credential detection
275
331
  tool-scanner.ts Injection and command injection scanning
276
332
  live-scanner.ts Live server tool/resource/prompt analysis
333
+ registry-scanner.ts Registry supply-chain checks (npm age, downloads)
277
334
  report.ts Score calculation and report output
278
335
  sarif.ts SARIF 2.1.0 output for GitHub Code Scanning
279
336
  baseline.ts Baseline save/load and diff engine
@@ -283,7 +340,7 @@ src/
283
340
 
284
341
  - [x] Cross-server tool shadowing detection
285
342
  - [x] GitHub Actions action (`uses: robdtaylor/sentinel-mcp@v1`)
286
- - [ ] MCP server registry scanning
343
+ - [x] MCP server registry scanning
287
344
  - [x] Baseline / diff mode (track changes between scans)
288
345
  - [x] SARIF output format (`--sarif` for GitHub Code Scanning)
289
346
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpsec",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Security scanner for MCP (Model Context Protocol) servers - detects tool poisoning, credential exposure, prompt injection, and SSRF",
5
5
  "license": "MIT",
6
6
  "author": "Rob Taylor <robdtaylor@users.noreply.github.com>",
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@ import { discoverConfigs, scanConfigs } from '../scanner/config-scanner';
13
13
  import { credentialScanner } from '../scanner/credential-scanner';
14
14
  import { toolScanner } from '../scanner/tool-scanner';
15
15
  import { liveScanner } from '../scanner/live-scanner';
16
+ import { registryScanner } from '../scanner/registry-scanner';
16
17
  import { generateReport, printReport, printReportJSON } from '../scanner/report';
17
18
  import { printReportSARIF } from '../scanner/sarif';
18
19
  import {
@@ -39,6 +40,10 @@ const HELP = `
39
40
  --json Output results as JSON
40
41
  --sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
41
42
  --path <file> Scan a specific config file
43
+ --registry Scan servers from the official MCP registry
44
+ --limit <n> Max servers to fetch from registry (default: 20)
45
+ --search <query> Search registry servers by keyword
46
+ --server <name> Scan a specific registry server by name
42
47
  --save-baseline [file] Save scan results as baseline (default: ${DEFAULT_BASELINE_PATH})
43
48
  --baseline [file] Compare scan against baseline and show diff
44
49
  --no-color Disable colored output
@@ -50,6 +55,11 @@ const HELP = `
50
55
  mcpsec scan --live
51
56
  mcpsec scan --json
52
57
  mcpsec scan --path ~/.cursor/mcp.json
58
+ mcpsec scan --registry
59
+ mcpsec scan --registry --limit 50
60
+ mcpsec scan --registry --search "database"
61
+ mcpsec scan --registry --server "filesystem"
62
+ mcpsec scan --registry --json
53
63
  mcpsec scan --save-baseline
54
64
  mcpsec scan --baseline
55
65
  mcpsec scan --baseline --json
@@ -65,7 +75,7 @@ async function main() {
65
75
  }
66
76
 
67
77
  if (command === '--version' || command === '-v') {
68
- console.log('mcpsec v0.2.0');
78
+ console.log('mcpsec v0.3.0');
69
79
  process.exit(0);
70
80
  }
71
81
 
@@ -82,6 +92,15 @@ async function main() {
82
92
  const pathIndex = args.indexOf('--path');
83
93
  const specificPath = pathIndex !== -1 ? args[pathIndex + 1] : undefined;
84
94
 
95
+ // Registry flags
96
+ const registryMode = args.includes('--registry');
97
+ const limitIndex = args.indexOf('--limit');
98
+ const registryLimit = limitIndex !== -1 ? parseInt(args[limitIndex + 1], 10) || 20 : 20;
99
+ const searchIndex = args.indexOf('--search');
100
+ const registrySearch = searchIndex !== -1 ? args[searchIndex + 1] : undefined;
101
+ const serverIndex = args.indexOf('--server');
102
+ const registryServer = serverIndex !== -1 ? args[serverIndex + 1] : undefined;
103
+
85
104
  // Baseline flags
86
105
  const saveBaselineIndex = args.indexOf('--save-baseline');
87
106
  const saveBaselineMode = saveBaselineIndex !== -1;
@@ -106,8 +125,45 @@ async function main() {
106
125
 
107
126
  // Discover configs
108
127
  let configs: MCPConfigFile[];
128
+ const allFindings: Finding[] = [];
129
+
130
+ if (registryMode) {
131
+ // Registry scanning mode
132
+ if (!jsonOutput && !sarifOutput) {
133
+ const label = registryServer
134
+ ? `server "${registryServer}"`
135
+ : registrySearch
136
+ ? `"${registrySearch}" (limit: ${registryLimit})`
137
+ : `top ${registryLimit} servers`;
138
+ process.stderr.write(`\n\x1b[1m🌐 Registry Scan: ${label}\x1b[0m\n`);
139
+ process.stderr.write(`\x1b[2m Fetching from registry.modelcontextprotocol.io...\x1b[0m\n`);
140
+ }
141
+
142
+ try {
143
+ const result = await registryScanner.scanRegistry({
144
+ limit: registryLimit,
145
+ search: registrySearch,
146
+ server: registryServer,
147
+ });
148
+
149
+ configs = result.configs;
150
+ allFindings.push(...result.findings);
151
+
152
+ if (configs.length === 0) {
153
+ if (!jsonOutput && !sarifOutput) {
154
+ console.error(' No servers found matching your criteria.');
155
+ }
156
+ process.exit(0);
157
+ }
109
158
 
110
- if (specificPath) {
159
+ if (!jsonOutput && !sarifOutput) {
160
+ process.stderr.write(`\x1b[2m Found ${configs.length} server(s), running security analysis...\x1b[0m\n`);
161
+ }
162
+ } catch (err) {
163
+ console.error(`Registry fetch failed: ${(err as Error).message}`);
164
+ process.exit(1);
165
+ }
166
+ } else if (specificPath) {
111
167
  // Scan a specific file
112
168
  const { existsSync, readFileSync } = await import('fs');
113
169
  if (!existsSync(specificPath)) {
@@ -140,23 +196,18 @@ async function main() {
140
196
  configs = discoverConfigs();
141
197
  }
142
198
 
143
- // Run all scanners
144
- const allFindings: Finding[] = [];
145
-
146
- // Config-level checks
199
+ // Run standard scanners (config, credential, tool)
147
200
  const configFindings = scanConfigs(configs);
148
201
  allFindings.push(...configFindings);
149
202
 
150
- // Credential scanner
151
203
  const credFindings = await credentialScanner.scan(configs);
152
204
  allFindings.push(...credFindings);
153
205
 
154
- // Tool/injection scanner
155
206
  const toolFindings = await toolScanner.scan(configs);
156
207
  allFindings.push(...toolFindings);
157
208
 
158
- // Live server scanner (connects to running servers)
159
- if (liveMode) {
209
+ // Live server scanner (connects to running servers -- not used in registry mode)
210
+ if (liveMode && !registryMode) {
160
211
  if (!jsonOutput) {
161
212
  process.stderr.write('\n\x1b[1m🔴 Live Server Scan\x1b[0m\n');
162
213
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Sentinel MCP - Registry Client
3
+ *
4
+ * HTTP client for fetching MCP server listings from public registries
5
+ * and npm package metadata for supply-chain analysis.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ export interface RegistryServer {
13
+ name: string;
14
+ description: string;
15
+ packages: { type: string; name: string }[];
16
+ repository?: string;
17
+ version?: string;
18
+ }
19
+
20
+ export interface NpmPackageInfo {
21
+ exists: boolean;
22
+ created?: string;
23
+ weeklyDownloads?: number;
24
+ maintainers?: number;
25
+ repository?: string;
26
+ }
27
+
28
+ // ============================================================================
29
+ // Official MCP Registry
30
+ // ============================================================================
31
+
32
+ const REGISTRY_BASE = 'https://registry.modelcontextprotocol.io/v0.1';
33
+
34
+ /**
35
+ * Fetch server listings from the official MCP registry
36
+ */
37
+ export async function fetchRegistryServers(options: {
38
+ limit?: number;
39
+ search?: string;
40
+ server?: string;
41
+ }): Promise<RegistryServer[]> {
42
+ const { limit = 20, search, server } = options;
43
+
44
+ let url = `${REGISTRY_BASE}/servers`;
45
+ const params = new URLSearchParams();
46
+
47
+ if (search) {
48
+ params.set('q', search);
49
+ }
50
+
51
+ if (params.toString()) {
52
+ url += `?${params.toString()}`;
53
+ }
54
+
55
+ const response = await fetch(url);
56
+
57
+ if (!response.ok) {
58
+ throw new Error(`Registry API error: ${response.status} ${response.statusText}`);
59
+ }
60
+
61
+ const data = await response.json();
62
+
63
+ // The API returns { servers: [{ server: {...}, _meta: {...} }, ...] }
64
+ const rawEntries: any[] = Array.isArray(data)
65
+ ? data
66
+ : (data.servers || data.items || data.results || []);
67
+
68
+ // Each entry wraps the actual server data in a `server` key
69
+ let servers: RegistryServer[] = rawEntries.map((entry: any) => {
70
+ const s = entry.server || entry;
71
+ return {
72
+ name: s.name || s.id || '',
73
+ description: s.description || '',
74
+ packages: normalizePackages(s),
75
+ repository: (typeof s.repository === 'object' ? s.repository?.url : s.repository) || undefined,
76
+ version: s.version || s.latest_version || undefined,
77
+ };
78
+ });
79
+
80
+ // Filter to a specific server name if requested
81
+ if (server) {
82
+ const needle = server.toLowerCase();
83
+ servers = servers.filter(
84
+ (s) => s.name.toLowerCase() === needle || s.name.toLowerCase().includes(needle)
85
+ );
86
+ }
87
+
88
+ return servers.slice(0, limit);
89
+ }
90
+
91
+ /**
92
+ * Normalize the various package formats from the registry API.
93
+ *
94
+ * The official registry uses:
95
+ * packages: [{ registryType: "npm", name: "@scope/pkg" }]
96
+ * or: packages: [{ registryType: "oci", identifier: "docker.io/img:tag" }]
97
+ * or: remotes: [{ type: "streamable-http", url: "..." }] (no installable package)
98
+ */
99
+ function normalizePackages(entry: any): { type: string; name: string }[] {
100
+ // Direct packages array
101
+ if (Array.isArray(entry.packages)) {
102
+ return entry.packages
103
+ .map((p: any) => ({
104
+ type: p.registryType || p.registry_type || p.type || 'npm',
105
+ name: p.name || p.identifier || p.package_name || '',
106
+ }))
107
+ .filter((p: { name: string }) => p.name);
108
+ }
109
+
110
+ // npm field directly
111
+ if (entry.npm) {
112
+ return [{ type: 'npm', name: entry.npm }];
113
+ }
114
+
115
+ // pypi field directly
116
+ if (entry.pypi) {
117
+ return [{ type: 'pypi', name: entry.pypi }];
118
+ }
119
+
120
+ // package_name field
121
+ if (entry.package_name) {
122
+ return [{ type: 'npm', name: entry.package_name }];
123
+ }
124
+
125
+ return [];
126
+ }
127
+
128
+ // ============================================================================
129
+ // npm Registry
130
+ // ============================================================================
131
+
132
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
133
+ const NPM_API = 'https://api.npmjs.org';
134
+
135
+ /**
136
+ * Fetch package metadata from npm for supply-chain analysis
137
+ */
138
+ export async function fetchNpmPackageInfo(packageName: string): Promise<NpmPackageInfo | null> {
139
+ try {
140
+ // Fetch package metadata
141
+ const metaResponse = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}`);
142
+
143
+ if (metaResponse.status === 404) {
144
+ return { exists: false };
145
+ }
146
+
147
+ if (!metaResponse.ok) {
148
+ return null; // API error, can't determine
149
+ }
150
+
151
+ const meta = await metaResponse.json();
152
+
153
+ // Extract creation date
154
+ const created = meta.time?.created || undefined;
155
+
156
+ // Extract maintainer count
157
+ const maintainers = Array.isArray(meta.maintainers) ? meta.maintainers.length : undefined;
158
+
159
+ // Extract repository URL
160
+ const repository = typeof meta.repository === 'string'
161
+ ? meta.repository
162
+ : meta.repository?.url || undefined;
163
+
164
+ // Fetch weekly download count (separate API)
165
+ let weeklyDownloads: number | undefined;
166
+ try {
167
+ const dlResponse = await fetch(
168
+ `${NPM_API}/downloads/point/last-week/${encodeURIComponent(packageName)}`
169
+ );
170
+ if (dlResponse.ok) {
171
+ const dlData = await dlResponse.json();
172
+ weeklyDownloads = dlData.downloads;
173
+ }
174
+ } catch {
175
+ // Download stats unavailable, not critical
176
+ }
177
+
178
+ return {
179
+ exists: true,
180
+ created,
181
+ weeklyDownloads,
182
+ maintainers,
183
+ repository,
184
+ };
185
+ } catch {
186
+ return null; // Network error
187
+ }
188
+ }
@@ -193,9 +193,25 @@ export function scanConfigs(configs: MCPConfigFile[]): Finding[] {
193
193
  // Check for stdio transport with absolute paths to unknown binaries
194
194
  if (server.command) {
195
195
  // npx/bunx with unknown packages
196
- if (/^(npx|bunx|pnpx)\s/.test(server.command)) {
197
- const pkg = server.command.split(/\s+/)[1];
196
+ // Handle both formats:
197
+ // "command": "npx -y some-pkg" (inline)
198
+ // "command": "npx", "args": ["-y", "some-pkg"] (split)
199
+ const cmdBase = server.command.split(/\s+/)[0];
200
+ if (/^(npx|bunx|pnpx)$/.test(cmdBase)) {
201
+ // Extract package name from inline command or args array
202
+ let pkg: string | undefined;
203
+ const inlineParts = server.command.split(/\s+/).slice(1);
204
+ const allArgs = [...inlineParts, ...(server.args || [])];
205
+ // Find the first arg that isn't a flag (skip -y, --yes, etc.)
206
+ for (const arg of allArgs) {
207
+ if (!arg.startsWith('-')) {
208
+ pkg = arg;
209
+ break;
210
+ }
211
+ }
212
+
198
213
  if (pkg && !pkg.startsWith('@anthropic') && !pkg.startsWith('@modelcontextprotocol')) {
214
+ const fullCommand = [server.command, ...(server.args || [])].join(' ');
199
215
  findings.push({
200
216
  id: `CFG-${++findingId}`,
201
217
  severity: 'medium',
@@ -204,7 +220,7 @@ export function scanConfigs(configs: MCPConfigFile[]): Finding[] {
204
220
  description: `Server "${serverName}" uses npx/bunx to run "${pkg}". This package is downloaded and executed at runtime without integrity verification.`,
205
221
  server: serverName,
206
222
  configFile: config.path,
207
- evidence: `command: ${server.command}`,
223
+ evidence: `command: ${fullCommand}`,
208
224
  remediation: 'Pin the package version and verify its integrity. Consider installing locally instead of using npx.',
209
225
  });
210
226
  }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Sentinel MCP - Registry Scanner
3
+ *
4
+ * Fetches MCP servers from public registries, converts them to synthetic
5
+ * MCPConfigFile objects for the existing scanner pipeline, and runs
6
+ * registry-specific supply-chain checks (npm package age, downloads, etc).
7
+ */
8
+
9
+ import type { Finding, MCPConfigFile, Scanner } from '../lib/types';
10
+ import { fetchRegistryServers, fetchNpmPackageInfo } from '../lib/registry-client';
11
+ import type { RegistryServer } from '../lib/registry-client';
12
+
13
+ // ============================================================================
14
+ // Registry Scanner
15
+ // ============================================================================
16
+
17
+ export interface RegistryScanOptions {
18
+ limit?: number;
19
+ search?: string;
20
+ server?: string;
21
+ }
22
+
23
+ /**
24
+ * Convert a registry server entry into a synthetic MCPConfigFile
25
+ * so existing scanners (credential, tool, config) can process it unchanged.
26
+ */
27
+ export function toSyntheticConfig(server: RegistryServer): MCPConfigFile {
28
+ const servers: Record<string, any> = {};
29
+
30
+ // Build a synthetic server config based on the package type
31
+ const npmPkg = server.packages.find((p) => p.type === 'npm');
32
+ const pypiPkg = server.packages.find((p) => p.type === 'pypi');
33
+ const ociPkg = server.packages.find((p) => p.type === 'oci');
34
+
35
+ if (npmPkg) {
36
+ servers[server.name] = {
37
+ command: 'npx',
38
+ args: ['-y', npmPkg.name],
39
+ };
40
+ } else if (pypiPkg) {
41
+ servers[server.name] = {
42
+ command: 'uvx',
43
+ args: [pypiPkg.name],
44
+ };
45
+ } else if (ociPkg) {
46
+ servers[server.name] = {
47
+ command: 'docker',
48
+ args: ['run', '-i', ociPkg.name],
49
+ };
50
+ } else {
51
+ // Remote-only or unknown -- create a placeholder
52
+ servers[server.name] = {
53
+ command: 'unknown',
54
+ args: [],
55
+ };
56
+ }
57
+
58
+ return {
59
+ path: `registry://modelcontextprotocol.io/${server.name}`,
60
+ client: 'MCP Registry',
61
+ servers,
62
+ raw: server,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Run registry-specific supply-chain checks against npm packages.
68
+ * These checks go beyond what static config analysis catches.
69
+ */
70
+ async function checkNpmSupplyChain(
71
+ server: RegistryServer,
72
+ configPath: string,
73
+ ): Promise<Finding[]> {
74
+ const findings: Finding[] = [];
75
+ const npmPkg = server.packages.find((p) => p.type === 'npm');
76
+
77
+ if (!npmPkg) return findings;
78
+
79
+ const info = await fetchNpmPackageInfo(npmPkg.name);
80
+
81
+ if (!info) return findings; // API unavailable, skip
82
+
83
+ // Package not found on npm
84
+ if (!info.exists) {
85
+ findings.push({
86
+ id: 'REG-NPM-404',
87
+ severity: 'high',
88
+ category: 'supply-chain',
89
+ title: `npm package not found: ${npmPkg.name}`,
90
+ description: `The npm package "${npmPkg.name}" listed for "${server.name}" does not exist on the npm registry. This may indicate a typosquat risk or a removed package.`,
91
+ server: server.name,
92
+ configFile: configPath,
93
+ evidence: `package: ${npmPkg.name}`,
94
+ remediation: 'Verify the package name is correct. Do not install packages that cannot be found on npm.',
95
+ });
96
+ return findings; // No point checking further
97
+ }
98
+
99
+ // Package less than 1 week old
100
+ if (info.created) {
101
+ const createdDate = new Date(info.created);
102
+ const ageMs = Date.now() - createdDate.getTime();
103
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
104
+
105
+ if (ageDays < 7) {
106
+ findings.push({
107
+ id: 'REG-NPM-NEW',
108
+ severity: 'medium',
109
+ category: 'supply-chain',
110
+ title: `Very new npm package: ${npmPkg.name}`,
111
+ description: `The npm package "${npmPkg.name}" was created ${Math.floor(ageDays)} day(s) ago. New packages have not been vetted by the community and may pose supply-chain risks.`,
112
+ server: server.name,
113
+ configFile: configPath,
114
+ evidence: `created: ${info.created}`,
115
+ remediation: 'Wait for the package to establish a track record before installing. Review the source code manually.',
116
+ });
117
+ }
118
+ }
119
+
120
+ // Low weekly downloads
121
+ if (info.weeklyDownloads !== undefined && info.weeklyDownloads < 100) {
122
+ findings.push({
123
+ id: 'REG-NPM-LOW-DL',
124
+ severity: 'low',
125
+ category: 'supply-chain',
126
+ title: `Low download count: ${npmPkg.name}`,
127
+ description: `The npm package "${npmPkg.name}" has only ${info.weeklyDownloads} weekly downloads. Low adoption means fewer eyes reviewing the code for security issues.`,
128
+ server: server.name,
129
+ configFile: configPath,
130
+ evidence: `weekly downloads: ${info.weeklyDownloads}`,
131
+ remediation: 'Review the package source code before installing. Consider well-established alternatives.',
132
+ });
133
+ }
134
+
135
+ // Single maintainer
136
+ if (info.maintainers !== undefined && info.maintainers <= 1) {
137
+ findings.push({
138
+ id: 'REG-NPM-SOLE',
139
+ severity: 'info',
140
+ category: 'supply-chain',
141
+ title: `Single maintainer: ${npmPkg.name}`,
142
+ description: `The npm package "${npmPkg.name}" has only ${info.maintainers} maintainer(s). Single-maintainer packages are more susceptible to account takeover attacks.`,
143
+ server: server.name,
144
+ configFile: configPath,
145
+ evidence: `maintainers: ${info.maintainers}`,
146
+ remediation: 'Monitor the package for unexpected changes. Consider pinning to a specific verified version.',
147
+ });
148
+ }
149
+
150
+ return findings;
151
+ }
152
+
153
+ /**
154
+ * Check registry-level metadata for supply-chain signals
155
+ */
156
+ function checkRegistryMetadata(
157
+ server: RegistryServer,
158
+ configPath: string,
159
+ ): Finding[] {
160
+ const findings: Finding[] = [];
161
+
162
+ // No repository URL
163
+ if (!server.repository) {
164
+ findings.push({
165
+ id: 'REG-NO-REPO',
166
+ severity: 'low',
167
+ category: 'supply-chain',
168
+ title: `No repository URL: ${server.name}`,
169
+ description: `The registry entry for "${server.name}" has no linked source repository. Without a repo, it's harder to audit the server's code.`,
170
+ server: server.name,
171
+ configFile: configPath,
172
+ evidence: 'repository: not provided',
173
+ remediation: 'Prefer MCP servers that link to a public source repository for auditability.',
174
+ });
175
+ }
176
+
177
+ // No packages at all
178
+ if (server.packages.length === 0) {
179
+ findings.push({
180
+ id: 'REG-NO-PKG',
181
+ severity: 'low',
182
+ category: 'supply-chain',
183
+ title: `No install packages listed: ${server.name}`,
184
+ description: `The registry entry for "${server.name}" has no npm or PyPI package listed. The installation method is unclear.`,
185
+ server: server.name,
186
+ configFile: configPath,
187
+ evidence: 'packages: []',
188
+ remediation: 'Check the server documentation for manual installation instructions.',
189
+ });
190
+ }
191
+
192
+ return findings;
193
+ }
194
+
195
+ /**
196
+ * Check if repository URL is reachable (HTTP HEAD request)
197
+ */
198
+ async function checkRepositoryReachable(
199
+ server: RegistryServer,
200
+ configPath: string,
201
+ ): Promise<Finding[]> {
202
+ if (!server.repository) return [];
203
+
204
+ try {
205
+ const response = await fetch(server.repository, {
206
+ method: 'HEAD',
207
+ redirect: 'follow',
208
+ signal: AbortSignal.timeout(5000),
209
+ });
210
+
211
+ if (response.status === 404) {
212
+ return [{
213
+ id: 'REG-REPO-404',
214
+ severity: 'medium',
215
+ category: 'supply-chain',
216
+ title: `Repository not found: ${server.name}`,
217
+ description: `The repository URL for "${server.name}" returns 404. The source code may have been deleted or moved.`,
218
+ server: server.name,
219
+ configFile: configPath,
220
+ evidence: `repository: ${server.repository} (HTTP ${response.status})`,
221
+ remediation: 'Do not use MCP servers whose source repository has been removed.',
222
+ }];
223
+ }
224
+ } catch {
225
+ // Timeout or network error - don't flag, could be transient
226
+ }
227
+
228
+ return [];
229
+ }
230
+
231
+ // ============================================================================
232
+ // Exported Scanner
233
+ // ============================================================================
234
+
235
+ export const registryScanner: Scanner & {
236
+ scanRegistry(options: RegistryScanOptions): Promise<{
237
+ configs: MCPConfigFile[];
238
+ findings: Finding[];
239
+ }>;
240
+ } = {
241
+ name: 'Registry Scanner',
242
+
243
+ /**
244
+ * Standard Scanner interface - not used for registry mode,
245
+ * but satisfies the interface for type compatibility.
246
+ */
247
+ async scan(_configs: MCPConfigFile[]): Promise<Finding[]> {
248
+ return [];
249
+ },
250
+
251
+ /**
252
+ * Main entry point: fetch from registry, build synthetic configs,
253
+ * and run registry-specific checks.
254
+ */
255
+ async scanRegistry(options: RegistryScanOptions): Promise<{
256
+ configs: MCPConfigFile[];
257
+ findings: Finding[];
258
+ }> {
259
+ // Fetch servers from registry
260
+ const servers = await fetchRegistryServers(options);
261
+
262
+ if (servers.length === 0) {
263
+ return { configs: [], findings: [] };
264
+ }
265
+
266
+ // Convert to synthetic configs
267
+ const configs = servers.map(toSyntheticConfig);
268
+
269
+ // Run registry-specific checks in parallel
270
+ const allFindings: Finding[] = [];
271
+ let findingCounter = 0;
272
+
273
+ for (const server of servers) {
274
+ const configPath = `registry://modelcontextprotocol.io/${server.name}`;
275
+
276
+ // Metadata checks (synchronous)
277
+ const metaFindings = checkRegistryMetadata(server, configPath);
278
+ allFindings.push(...metaFindings);
279
+
280
+ // npm supply-chain checks (async)
281
+ const npmFindings = await checkNpmSupplyChain(server, configPath);
282
+ allFindings.push(...npmFindings);
283
+
284
+ // Repository reachability check (async)
285
+ const repoFindings = await checkRepositoryReachable(server, configPath);
286
+ allFindings.push(...repoFindings);
287
+ }
288
+
289
+ // Assign unique IDs
290
+ for (const finding of allFindings) {
291
+ findingCounter++;
292
+ finding.id = `${finding.id}-${findingCounter}`;
293
+ }
294
+
295
+ return { configs, findings: allFindings };
296
+ },
297
+ };
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { ScanReport, ScanSummary, Finding, MCPConfigFile, ScanStatus } from '../lib/types';
8
8
 
9
- const VERSION = '0.2.0';
9
+ const VERSION = '0.3.0';
10
10
 
11
11
  // ============================================================================
12
12
  // Score Calculation