osv-ui-mcp 1.0.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 +149 -0
- package/index.js +436 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# osv-ui-mcp
|
|
2
|
+
|
|
3
|
+
> MCP server for [osv-ui](https://github.com/toan203/osv-ui) — scan projects for CVEs inside Claude, Cursor, or any MCP client, with **human-in-the-loop UI confirmation** before applying fixes.
|
|
4
|
+
|
|
5
|
+
## What makes this different
|
|
6
|
+
|
|
7
|
+
| | osv-ui-mcp | StacklokLabs/osv-mcp | others |
|
|
8
|
+
|---|:---:|:---:|:---:|
|
|
9
|
+
| Auto-detect manifests | ✅ | ❌ manual query | ❌ |
|
|
10
|
+
| npm + Python + Go + Rust | ✅ | ✅ (query only) | partial |
|
|
11
|
+
| Visual dashboard (browser UI) | ✅ | ❌ | ❌ |
|
|
12
|
+
| Human-in-the-loop confirm | ✅ | ❌ | ❌ |
|
|
13
|
+
| Apply fixes from chat | ✅ | ❌ | ❌ |
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g osv-ui-mcp
|
|
19
|
+
# also install osv-ui for the dashboard feature
|
|
20
|
+
npm install -g osv-ui
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Setup — Claude Desktop
|
|
24
|
+
|
|
25
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"osv-ui": {
|
|
31
|
+
"command": "osv-ui-mcp"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Setup — Cursor
|
|
38
|
+
|
|
39
|
+
Add to `~/.cursor/mcp.json`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"osv-ui": {
|
|
45
|
+
"command": "osv-ui-mcp"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Setup — Claude Code (npx, no install)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add osv-ui -- npx osv-ui-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
Just talk naturally in Claude or Cursor:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
"Scan my project for CVEs"
|
|
63
|
+
"Are there any critical vulnerabilities in ./frontend?"
|
|
64
|
+
"Show me the fix commands for axios and lodash"
|
|
65
|
+
"Open the security dashboard so I can review before fixing"
|
|
66
|
+
"Fix all high severity vulnerabilities in ./api"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Available MCP tools
|
|
70
|
+
|
|
71
|
+
### `scan_project`
|
|
72
|
+
Scan a directory for CVEs across all supported ecosystems.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
scan_project({ path: "./", severity_filter: "high" })
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Returns: full vulnerability report with risk score, CVE list, and fix recommendations.
|
|
79
|
+
|
|
80
|
+
### `open_dashboard`
|
|
81
|
+
Launch the osv-ui visual dashboard in your browser.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
open_dashboard({ path: "./" })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This is the **human-in-the-loop step** — review the full dashboard before applying any fixes. The dashboard shows severity charts, CVE drill-down, and the upgrade guide.
|
|
88
|
+
|
|
89
|
+
### `get_fix_commands`
|
|
90
|
+
Get safe upgrade commands without executing them.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
get_fix_commands({ path: "./", packages: ["axios", "lodash"] })
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Returns: a table of current → safe version + commands to run.
|
|
97
|
+
|
|
98
|
+
### `apply_fixes`
|
|
99
|
+
Execute upgrade commands after your explicit confirmation.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
apply_fixes({ path: "./", packages: ["axios", "lodash"] })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> ⚠️ Always review with `get_fix_commands` or `open_dashboard` before calling this.
|
|
106
|
+
|
|
107
|
+
## Human-in-the-loop flow
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
You: "Scan my project for vulnerabilities and fix them"
|
|
111
|
+
|
|
112
|
+
AI: scan_project("./")
|
|
113
|
+
→ "Found 28 CVEs: 1 HIGH, 2 MODERATE, 25 LOW.
|
|
114
|
+
3 direct packages can be upgraded.
|
|
115
|
+
Want me to open the dashboard to review first?"
|
|
116
|
+
|
|
117
|
+
You: "Yes, show me the dashboard"
|
|
118
|
+
|
|
119
|
+
AI: open_dashboard("./")
|
|
120
|
+
→ Browser opens with full osv-ui UI ✨
|
|
121
|
+
|
|
122
|
+
You: [reviews dashboard, comes back]
|
|
123
|
+
"Fix axios and lodash, skip next for now"
|
|
124
|
+
|
|
125
|
+
AI: get_fix_commands({ packages: ["axios", "lodash"] })
|
|
126
|
+
→ "Will run:
|
|
127
|
+
npm install axios@0.30.3 (fixes 4 CVEs)
|
|
128
|
+
npm install lodash@4.17.23 (fixes 3 CVEs)
|
|
129
|
+
Confirm?"
|
|
130
|
+
|
|
131
|
+
You: "Yes, do it"
|
|
132
|
+
|
|
133
|
+
AI: apply_fixes({ packages: ["axios", "lodash"] })
|
|
134
|
+
→ "✅ Done. 7 CVEs resolved."
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Monorepo usage
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
"Scan all services in my monorepo"
|
|
141
|
+
→ scan_project("./frontend")
|
|
142
|
+
→ scan_project("./api")
|
|
143
|
+
→ scan_project("./worker")
|
|
144
|
+
→ Summary: "Found CVEs in 2/3 services. Worst: api (risk score 67/100)"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* osv-ui-mcp — MCP server with human-in-the-loop UI confirmation
|
|
4
|
+
*
|
|
5
|
+
* Tools:
|
|
6
|
+
* scan_project — parse manifests + query OSV.dev → structured CVE report
|
|
7
|
+
* open_dashboard — launch osv-ui browser dashboard (human review step)
|
|
8
|
+
* get_fix_commands — list safe upgrade commands without executing
|
|
9
|
+
* apply_fixes — execute fixes AFTER user confirms
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import {
|
|
15
|
+
CallToolRequestSchema,
|
|
16
|
+
ListToolsRequestSchema,
|
|
17
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
import { resolve, join } from 'path';
|
|
19
|
+
import { existsSync } from 'fs';
|
|
20
|
+
import { execSync, spawn } from 'child_process';
|
|
21
|
+
|
|
22
|
+
import { scanService } from 'osv-ui/src/scanner.js';
|
|
23
|
+
|
|
24
|
+
// ── Running dashboard instances (path → { port, pid }) ──────────────────────
|
|
25
|
+
const runningDashboards = new Map();
|
|
26
|
+
let nextPort = 2003;
|
|
27
|
+
|
|
28
|
+
// ── Server definition ────────────────────────────────────────────────────────
|
|
29
|
+
const server = new Server(
|
|
30
|
+
{ name: 'osv-ui-mcp', version: '1.0.0' },
|
|
31
|
+
{ capabilities: { tools: {} } }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// ── Tool definitions ─────────────────────────────────────────────────────────
|
|
35
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
36
|
+
tools: [
|
|
37
|
+
{
|
|
38
|
+
name: 'scan_project',
|
|
39
|
+
description:
|
|
40
|
+
'Scan a project directory for CVE vulnerabilities. ' +
|
|
41
|
+
'Automatically detects npm (package-lock.json), Python (requirements.txt / Pipfile.lock / poetry.lock), ' +
|
|
42
|
+
'Go (go.sum), and Rust (Cargo.lock) manifests. ' +
|
|
43
|
+
'Queries live CVE data from OSV.dev. ' +
|
|
44
|
+
'Returns structured vulnerability report with severity counts, risk score, and fix recommendations. ' +
|
|
45
|
+
'Use this as the first step before open_dashboard or apply_fixes.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
path: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Absolute or relative path to the project directory. Defaults to current working directory.',
|
|
52
|
+
},
|
|
53
|
+
severity_filter: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
enum: ['all', 'critical', 'high', 'moderate', 'low'],
|
|
56
|
+
description: 'Only return vulnerabilities at this severity or above. Default: all.',
|
|
57
|
+
},
|
|
58
|
+
offline: {
|
|
59
|
+
type: 'boolean',
|
|
60
|
+
description: 'If true, skip OSV.dev query and only parse manifests. Default: false.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'open_dashboard',
|
|
67
|
+
description:
|
|
68
|
+
'Launch the osv-ui visual dashboard in the browser for human review. ' +
|
|
69
|
+
'This is the HUMAN-IN-THE-LOOP step — always offer this before applying fixes. ' +
|
|
70
|
+
'The dashboard shows full CVE details, severity charts, and the upgrade guide. ' +
|
|
71
|
+
'Returns the dashboard URL. If already running for this path, returns existing URL.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
path: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Path to the project directory to display in the dashboard.',
|
|
78
|
+
},
|
|
79
|
+
port: {
|
|
80
|
+
type: 'number',
|
|
81
|
+
description: 'Port to run the dashboard on. Default: auto-assigned starting from 2003.',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'get_fix_commands',
|
|
88
|
+
description:
|
|
89
|
+
'Get the safe upgrade commands for vulnerable packages WITHOUT executing them. ' +
|
|
90
|
+
'Use this to show the user what will be changed before calling apply_fixes. ' +
|
|
91
|
+
'Returns a list of commands grouped by ecosystem (npm install / pip install).',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
path: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Path to the project directory.',
|
|
98
|
+
},
|
|
99
|
+
packages: {
|
|
100
|
+
type: 'array',
|
|
101
|
+
items: { type: 'string' },
|
|
102
|
+
description: 'Optional: only return fix commands for these package names. If omitted, returns all fixable packages.',
|
|
103
|
+
},
|
|
104
|
+
severity_filter: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
enum: ['all', 'critical', 'high', 'moderate', 'low'],
|
|
107
|
+
description: 'Only return fixes for this severity or above. Default: all.',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
required: ['path'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'apply_fixes',
|
|
115
|
+
description:
|
|
116
|
+
'Execute package upgrade commands to fix CVEs. ' +
|
|
117
|
+
'IMPORTANT: This is a DESTRUCTIVE action that modifies package files. ' +
|
|
118
|
+
'ALWAYS call get_fix_commands first and confirm with the user before calling this. ' +
|
|
119
|
+
'Returns the command output for each fix applied.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
path: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
description: 'Path to the project directory.',
|
|
126
|
+
},
|
|
127
|
+
packages: {
|
|
128
|
+
type: 'array',
|
|
129
|
+
items: { type: 'string' },
|
|
130
|
+
description: 'Package names to fix. Must be explicit — never fix all without user confirmation.',
|
|
131
|
+
},
|
|
132
|
+
dry_run: {
|
|
133
|
+
type: 'boolean',
|
|
134
|
+
description: 'If true, print commands without executing. Useful for final confirmation step.',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
required: ['path', 'packages'],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// ── Tool handlers ─────────────────────────────────────────────────────────────
|
|
144
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
145
|
+
const { name, arguments: args } = request.params;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
switch (name) {
|
|
149
|
+
case 'scan_project': return await handleScanProject(args);
|
|
150
|
+
case 'open_dashboard': return await handleOpenDashboard(args);
|
|
151
|
+
case 'get_fix_commands': return await handleGetFixCommands(args);
|
|
152
|
+
case 'apply_fixes': return await handleApplyFixes(args);
|
|
153
|
+
default:
|
|
154
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: `Error in ${name}: ${err.message}` }],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── scan_project ─────────────────────────────────────────────────────────────
|
|
165
|
+
async function handleScanProject({ path: dir = '.', severity_filter = 'all', offline = false }) {
|
|
166
|
+
const absDir = resolve(dir);
|
|
167
|
+
if (!existsSync(absDir)) {
|
|
168
|
+
return err(`Directory not found: ${absDir}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = await scanService(absDir, { noOsv: offline });
|
|
172
|
+
const sevOrder = { critical: 0, high: 1, moderate: 2, low: 3 };
|
|
173
|
+
const filterRank = sevOrder[severity_filter] ?? 4;
|
|
174
|
+
|
|
175
|
+
const filtered = result.vulns.filter(v => (sevOrder[v.severity] ?? 4) <= filterRank);
|
|
176
|
+
|
|
177
|
+
// Build a clean, LLM-readable summary
|
|
178
|
+
const lines = [
|
|
179
|
+
`## CVE Scan: ${result.name}`,
|
|
180
|
+
`**Directory:** ${absDir}`,
|
|
181
|
+
`**Ecosystem:** ${result.ecosystem}`,
|
|
182
|
+
`**Packages scanned:** ${result.totalPackages} (${result.directCount} direct)`,
|
|
183
|
+
`**Risk score:** ${result.riskScore}/100`,
|
|
184
|
+
'',
|
|
185
|
+
'### Vulnerability summary',
|
|
186
|
+
`| Severity | Count |`,
|
|
187
|
+
`|----------|-------|`,
|
|
188
|
+
`| 🔴 Critical | ${result.severity.critical} |`,
|
|
189
|
+
`| 🟠 High | ${result.severity.high} |`,
|
|
190
|
+
`| 🟡 Moderate | ${result.severity.moderate} |`,
|
|
191
|
+
`| 🔵 Low | ${result.severity.low} |`,
|
|
192
|
+
`| **Total** | **${result.vulns.length}** |`,
|
|
193
|
+
'',
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
if (filtered.length === 0) {
|
|
197
|
+
lines.push(severity_filter === 'all'
|
|
198
|
+
? '✅ No vulnerabilities found!'
|
|
199
|
+
: `✅ No ${severity_filter}+ vulnerabilities found.`);
|
|
200
|
+
} else {
|
|
201
|
+
lines.push(`### Vulnerabilities (${filtered.length}${severity_filter !== 'all' ? ` filtered to ${severity_filter}+` : ''})`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
for (const v of filtered.slice(0, 30)) {
|
|
204
|
+
const fix = v.fixedIn ? `→ fix: **${v.fixedIn}**` : '→ no fix available';
|
|
205
|
+
const type = v.isDirect ? 'direct' : 'transitive';
|
|
206
|
+
lines.push(`- **[${v.severity.toUpperCase()}]** \`${v.packageName}@${v.packageVersion}\` (${type}) — ${v.title} (${v.cveId || v.id}) ${fix}`);
|
|
207
|
+
}
|
|
208
|
+
if (filtered.length > 30) {
|
|
209
|
+
lines.push(`\n_... and ${filtered.length - 30} more. Use open_dashboard for full list._`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Fixable direct packages
|
|
213
|
+
const fixable = getFixableGroups(result.vulns);
|
|
214
|
+
if (fixable.length > 0) {
|
|
215
|
+
lines.push('');
|
|
216
|
+
lines.push(`### Quick wins — ${fixable.length} direct package(s) can be upgraded`);
|
|
217
|
+
for (const f of fixable) {
|
|
218
|
+
lines.push(`- \`${f.name}\` ${f.currentVersion} → **${f.fixVersion}** (fixes ${f.cveCount} CVE${f.cveCount > 1 ? 's' : ''}): \`${f.command}\``);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push('> 💡 Call `open_dashboard` to review in a visual UI before applying any fixes.');
|
|
225
|
+
|
|
226
|
+
return ok(lines.join('\n'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── open_dashboard ────────────────────────────────────────────────────────────
|
|
230
|
+
async function handleOpenDashboard({ path: dir = '.', port }) {
|
|
231
|
+
const absDir = resolve(dir);
|
|
232
|
+
|
|
233
|
+
// Already running?
|
|
234
|
+
const existing = runningDashboards.get(absDir);
|
|
235
|
+
if (existing) {
|
|
236
|
+
const url = `http://localhost:${existing.port}`;
|
|
237
|
+
try { const { default: open } = await import('open'); await open(url); } catch {}
|
|
238
|
+
return ok(`Dashboard already running at ${url}\n\nThe osv-ui dashboard is now open in your browser. Review the vulnerabilities and upgrade guide, then come back and tell me which packages you want to fix.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const assignedPort = port || nextPort++;
|
|
242
|
+
const osvUiBin = findOsvUiBin();
|
|
243
|
+
|
|
244
|
+
if (!osvUiBin) {
|
|
245
|
+
return err(
|
|
246
|
+
'osv-ui CLI not found. Install it first:\n\n npm install -g osv-ui\n\nThen retry open_dashboard.'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Spawn osv-ui detached
|
|
251
|
+
const child = spawn(
|
|
252
|
+
process.execPath,
|
|
253
|
+
[osvUiBin, absDir, `--port=${assignedPort}`, '--no-open'],
|
|
254
|
+
{ detached: true, stdio: 'ignore' }
|
|
255
|
+
);
|
|
256
|
+
child.unref();
|
|
257
|
+
runningDashboards.set(absDir, { port: assignedPort, pid: child.pid });
|
|
258
|
+
|
|
259
|
+
// Wait for server to be ready
|
|
260
|
+
await waitForPort(assignedPort, 8000);
|
|
261
|
+
|
|
262
|
+
const url = `http://localhost:${assignedPort}`;
|
|
263
|
+
try { const { default: open } = await import('open'); await open(url); } catch {}
|
|
264
|
+
|
|
265
|
+
return ok(
|
|
266
|
+
`✅ Dashboard launched at ${url}\n\n` +
|
|
267
|
+
`The osv-ui dashboard is now open in your browser.\n\n` +
|
|
268
|
+
`**Review the vulnerabilities and upgrade guide**, then come back here and tell me:\n` +
|
|
269
|
+
`- Which packages you want to upgrade\n` +
|
|
270
|
+
`- Or just say "fix all critical and high" and I'll prepare the commands for your approval.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── get_fix_commands ──────────────────────────────────────────────────────────
|
|
275
|
+
async function handleGetFixCommands({ path: dir = '.', packages, severity_filter = 'all' }) {
|
|
276
|
+
const absDir = resolve(dir);
|
|
277
|
+
const result = await scanService(absDir, { noOsv: false });
|
|
278
|
+
const sevOrder = { critical: 0, high: 1, moderate: 2, low: 3 };
|
|
279
|
+
const filterRank = sevOrder[severity_filter] ?? 4;
|
|
280
|
+
|
|
281
|
+
const fixable = getFixableGroups(result.vulns, packages, filterRank);
|
|
282
|
+
|
|
283
|
+
if (fixable.length === 0) {
|
|
284
|
+
return ok('No fixable vulnerabilities found' + (packages ? ` for packages: ${packages.join(', ')}` : '') + '.');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lines = [
|
|
288
|
+
`## Fix commands for ${result.name}`,
|
|
289
|
+
'',
|
|
290
|
+
`The following ${fixable.length} package(s) can be upgraded to fix CVEs:`,
|
|
291
|
+
'',
|
|
292
|
+
'| Package | Current | Safe version | Fixes | Command |',
|
|
293
|
+
'|---------|---------|-------------|-------|---------|',
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
for (const f of fixable) {
|
|
297
|
+
lines.push(`| \`${f.name}\` | ${f.currentVersion} | **${f.fixVersion}** | ${f.cveCount} CVE(s) | \`${f.command}\` |`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
lines.push('');
|
|
301
|
+
lines.push('⚠️ **Review before running.** Call `apply_fixes` with the package names you want to upgrade.');
|
|
302
|
+
lines.push('');
|
|
303
|
+
lines.push('**To apply all:** `apply_fixes({ path: "' + dir + '", packages: [' + fixable.map(f => `"${f.name}"`).join(', ') + '] })`');
|
|
304
|
+
|
|
305
|
+
return ok(lines.join('\n'));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── apply_fixes ───────────────────────────────────────────────────────────────
|
|
309
|
+
async function handleApplyFixes({ path: dir = '.', packages, dry_run = false }) {
|
|
310
|
+
const absDir = resolve(dir);
|
|
311
|
+
const result = await scanService(absDir, { noOsv: false });
|
|
312
|
+
|
|
313
|
+
const fixable = getFixableGroups(result.vulns, packages);
|
|
314
|
+
const requested = packages.map(p => p.toLowerCase());
|
|
315
|
+
const toApply = fixable.filter(f => requested.includes(f.name.toLowerCase()));
|
|
316
|
+
|
|
317
|
+
if (toApply.length === 0) {
|
|
318
|
+
return err(`No fix commands found for: ${packages.join(', ')}. Run get_fix_commands to see available fixes.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const outputs = [];
|
|
322
|
+
|
|
323
|
+
for (const f of toApply) {
|
|
324
|
+
if (dry_run) {
|
|
325
|
+
outputs.push(`[DRY RUN] Would run: ${f.command}`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const stdout = execSync(f.command, { cwd: absDir, timeout: 60000 }).toString().trim();
|
|
331
|
+
outputs.push(`✅ ${f.name}: upgraded to ${f.fixVersion} (fixes ${f.cveCount} CVE${f.cveCount > 1 ? 's' : ''})\n $ ${f.command}\n ${stdout.slice(0, 200)}`);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
outputs.push(`❌ ${f.name}: command failed\n $ ${f.command}\n ${e.message.slice(0, 200)}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const summary = dry_run
|
|
338
|
+
? `## Dry run — commands that would be executed\n\n${outputs.join('\n\n')}`
|
|
339
|
+
: `## Fix results\n\n${outputs.join('\n\n')}\n\n> Run \`scan_project\` again to verify CVEs are resolved.`;
|
|
340
|
+
|
|
341
|
+
return ok(summary);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
345
|
+
function getFixableGroups(vulns, filterNames, maxSevRank = 4) {
|
|
346
|
+
const sevOrder = { critical: 0, high: 1, moderate: 2, low: 3 };
|
|
347
|
+
const map = new Map();
|
|
348
|
+
|
|
349
|
+
for (const v of vulns) {
|
|
350
|
+
if (!v.fixedIn || !v.isDirect) continue;
|
|
351
|
+
if ((sevOrder[v.severity] ?? 4) > maxSevRank) continue;
|
|
352
|
+
if (filterNames && !filterNames.map(n => n.toLowerCase()).includes(v.packageName.toLowerCase())) continue;
|
|
353
|
+
|
|
354
|
+
const key = v.packageName;
|
|
355
|
+
const existing = map.get(key);
|
|
356
|
+
if (!existing) {
|
|
357
|
+
map.set(key, {
|
|
358
|
+
name: v.packageName,
|
|
359
|
+
ecosystem: v.ecosystem,
|
|
360
|
+
currentVersion: v.packageVersion,
|
|
361
|
+
fixVersion: v.fixedIn,
|
|
362
|
+
severity: v.severity,
|
|
363
|
+
cveCount: 1,
|
|
364
|
+
command: v.fixCommand || buildFixCommand(v),
|
|
365
|
+
});
|
|
366
|
+
} else {
|
|
367
|
+
existing.cveCount++;
|
|
368
|
+
// Keep highest fix version
|
|
369
|
+
if (compareSemver(v.fixedIn, existing.fixVersion) > 0) {
|
|
370
|
+
existing.fixVersion = v.fixedIn;
|
|
371
|
+
existing.command = v.fixCommand || buildFixCommand(v);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return Array.from(map.values()).sort((a, b) => {
|
|
377
|
+
return (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildFixCommand(v) {
|
|
382
|
+
if (v.ecosystem === 'npm') return `npm install ${v.packageName}@${v.fixedIn}`;
|
|
383
|
+
if (v.ecosystem === 'PyPI') return `pip install "${v.packageName}>=${v.fixedIn}"`;
|
|
384
|
+
return `# update ${v.packageName} to ${v.fixedIn}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function compareSemver(a, b) {
|
|
388
|
+
const pa = a.split('.').map(Number);
|
|
389
|
+
const pb = b.split('.').map(Number);
|
|
390
|
+
for (let i = 0; i < 3; i++) {
|
|
391
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
392
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
393
|
+
}
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function findOsvUiBin() {
|
|
398
|
+
// Check common locations
|
|
399
|
+
const candidates = [
|
|
400
|
+
// Global npm bin
|
|
401
|
+
join(process.env.npm_config_prefix || '', 'bin', 'osv-ui'),
|
|
402
|
+
// npx cache
|
|
403
|
+
join(process.env.HOME || '', '.npm', '_npx'),
|
|
404
|
+
// Same node_modules
|
|
405
|
+
join(process.cwd(), 'node_modules', '.bin', 'osv-ui'),
|
|
406
|
+
join(process.cwd(), '..', 'node_modules', '.bin', 'osv-ui'),
|
|
407
|
+
];
|
|
408
|
+
for (const c of candidates) {
|
|
409
|
+
if (existsSync(c)) return c;
|
|
410
|
+
}
|
|
411
|
+
// Try which
|
|
412
|
+
try {
|
|
413
|
+
return execSync('which osv-ui', { stdio: 'pipe' }).toString().trim();
|
|
414
|
+
} catch {}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function waitForPort(port, timeoutMs) {
|
|
419
|
+
const start = Date.now();
|
|
420
|
+
while (Date.now() - start < timeoutMs) {
|
|
421
|
+
try {
|
|
422
|
+
await fetch(`http://localhost:${port}/api/data`, { signal: AbortSignal.timeout(500) });
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
await new Promise(r => setTimeout(r, 300));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function ok(text) { return { content: [{ type: 'text', text }] }; }
|
|
432
|
+
function err(text) { return { content: [{ type: 'text', text }], isError: true }; }
|
|
433
|
+
|
|
434
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
435
|
+
const transport = new StdioServerTransport();
|
|
436
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "osv-ui-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for osv-ui — scan projects for CVEs, open visual dashboard, apply fixes with human confirmation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"osv-ui-mcp": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": ["mcp", "security", "cve", "osv", "npm-audit", "python", "vulnerability", "dependabot"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
13
|
+
"open": "^9.1.0",
|
|
14
|
+
"osv-ui": "^1.1.3"
|
|
15
|
+
},
|
|
16
|
+
"engines": { "node": ">=18.0.0" },
|
|
17
|
+
"publishConfig": { "access": "public" }
|
|
18
|
+
}
|