Skip to content

Commit ff8ddf0

Browse files
another-rexcuixq
andauthored
feat: OSV-Scanner MCP server (#2256)
Basic MVP of an osv-scanner MCP server fulfilling workflow 1. Directly vulnerability scanning of a project with prioritisation. --------- Co-authored-by: Xueqin Cui <72771658+cuixq@users.noreply.github.com>
1 parent 280ac6a commit ff8ddf0

14 files changed

Lines changed: 480 additions & 76 deletions

File tree

.github/workflows/checks.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ name: Checks
1616

1717
on:
1818
push:
19-
branches: [main, v1]
19+
branches: ["main", "v1", "mcp"]
2020
pull_request:
2121
# The branches below must be a subset of the branches above
22-
branches: [main, v1]
22+
branches: ["main", "v1", "mcp"]
2323
workflow_dispatch:
2424

2525
concurrency:

.github/workflows/osv-scanner-unified-action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ name: OSV-Scanner Scheduled Scan
1616

1717
on:
1818
pull_request:
19-
branches: ["main", "v1"]
19+
branches: ["main", "v1", "mcp"]
2020
schedule:
2121
- cron: "12 12 * * 1"
2222
push:
23-
branches: ["main", "v1"]
23+
branches: ["main", "v1", "mcp"]
2424

2525
# Restrict jobs in this workflow to have no permissions by default; permissions
2626
# should be granted per job as needed using a dedicated `permissions` block

cmd/osv-scanner/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/google/osv-scanner/v2/cmd/osv-scanner/fix"
77
"github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/cmd"
8+
"github.com/google/osv-scanner/v2/cmd/osv-scanner/mcp"
89
"github.com/google/osv-scanner/v2/cmd/osv-scanner/scan"
910
"github.com/google/osv-scanner/v2/cmd/osv-scanner/update"
1011
)
@@ -15,6 +16,7 @@ func main() {
1516
scan.Command,
1617
fix.Command,
1718
update.Command,
19+
mcp.Command,
1820
}),
1921
)
2022
}

cmd/osv-scanner/mcp/command.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Package mcp implements the `mcp` command for osv-scanner.
2+
package mcp
3+
4+
import (
5+
"context"
6+
_ "embed"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"net/http"
15+
16+
"github.com/google/osv-scanner/v2/internal/cmdlogger"
17+
"github.com/google/osv-scanner/v2/internal/output"
18+
"github.com/google/osv-scanner/v2/internal/version"
19+
"github.com/google/osv-scanner/v2/pkg/osvscanner"
20+
"github.com/jedib0t/go-pretty/v6/text"
21+
"github.com/modelcontextprotocol/go-sdk/mcp"
22+
"github.com/ossf/osv-schema/bindings/go/osvschema"
23+
"github.com/urfave/cli/v3"
24+
"osv.dev/bindings/go/osvdev"
25+
)
26+
27+
// vulnCacheMap is a cache of vulnerability details that have been retrieved from the OSV API during normal scanning.
28+
// This avoids unnecessary double queries to the osv.dev API.
29+
// vulnCacheMap: map[string]*osvschema.Vulnerability
30+
var vulnCacheMap = sync.Map{}
31+
32+
// Command is the entry point for the `mcp` subcommand.
33+
func Command(_, _ io.Writer) *cli.Command {
34+
return &cli.Command{
35+
Name: "experimental-mcp",
36+
Usage: "Run osv-scanner as an MCP service (experimental)",
37+
Description: "Run osv-scanner as an MCP service, speaking the MCP protocol over stdin/stdout.",
38+
Flags: []cli.Flag{
39+
&cli.StringFlag{
40+
Name: "sse",
41+
DefaultText: "localhost:8080",
42+
Value: "localhost:8080",
43+
Usage: "The listening address for the SSE server, e.g. localhost:8080",
44+
},
45+
},
46+
Action: action,
47+
}
48+
}
49+
50+
// scanVulnerableDependenciesInput is the input for the scan_vulnerable_dependencies tool.
51+
type scanVulnerableDependenciesInput struct {
52+
Paths []string `json:"paths" jsonschema:"A list of absolute or relative path to a file or directory to scan."`
53+
IgnoreGlobPatterns []string `json:"ignore_glob_patterns" jsonschema:"A list of glob patterns to ignore when scanning."`
54+
Recursive bool `json:"recursive" jsonschema:"Scans directory recursively"`
55+
}
56+
57+
func action(ctx context.Context, cmd *cli.Command) error {
58+
s := mcp.NewServer(&mcp.Implementation{
59+
Name: "OSV-Scanner", Version: version.OSVVersion,
60+
}, nil)
61+
62+
mcp.AddTool(s, &mcp.Tool{
63+
Name: "scan_vulnerable_dependencies",
64+
Description: "Scans a source directory for vulnerable dependencies." +
65+
" Walks the given directory and uses osv.dev to query for vulnerabilities matching the found dependencies." +
66+
" Use this tool to check that the user's project is not depending on known vulnerable code.",
67+
}, handleScan)
68+
69+
// TODO(another-rex): Ideally both of the following tools would be resources, but gemini-cli does not support those yet.
70+
mcp.AddTool(s, &mcp.Tool{
71+
Name: "get_vulnerability_details",
72+
Description: "Retrieves the full JSON details for a given vulnerability ID.",
73+
}, handleVulnIDRetrieval)
74+
75+
mcp.AddTool(s, &mcp.Tool{
76+
Name: "ignore_vulnerability",
77+
Description: "Provides instructions for writing a config file to exclude vulnerabilities from the scan report.",
78+
}, handleIgnoreVulnerability)
79+
80+
s.AddPrompt(&mcp.Prompt{
81+
Name: "scan_deps",
82+
Description: "Scans your project dependencies for known vulnerabilities.",
83+
}, handleScanDepsPrompt)
84+
85+
// Provide two options, sse on a network port, or stdio.
86+
if cmd.IsSet("sse") {
87+
sseAddr := cmd.String("sse")
88+
cmdlogger.Infof("Starting SSE server on %s", sseAddr)
89+
handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
90+
return s
91+
}, nil)
92+
srv := &http.Server{
93+
Addr: sseAddr,
94+
Handler: handler,
95+
ReadTimeout: 30 * time.Second,
96+
WriteTimeout: 30 * time.Second,
97+
IdleTimeout: 120 * time.Second,
98+
}
99+
if err := srv.ListenAndServe(); err != nil {
100+
cmdlogger.Errorf("mcp error: %s", err)
101+
return err
102+
}
103+
} else {
104+
cmdlogger.SendEverythingToStderr()
105+
cmdlogger.Infof("Starting MCP server on stdio")
106+
if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil {
107+
cmdlogger.Errorf("mcp error: %s", err)
108+
return err
109+
}
110+
}
111+
112+
return nil
113+
}
114+
115+
func handleScan(_ context.Context, _ *mcp.CallToolRequest, input *scanVulnerableDependenciesInput) (*mcp.CallToolResult, any, error) {
116+
statsCollector := fileOpenedLogger{}
117+
118+
action := osvscanner.ScannerActions{
119+
DirectoryPaths: input.Paths,
120+
ScanLicensesSummary: false,
121+
ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{
122+
StatsCollector: &statsCollector,
123+
},
124+
CallAnalysisStates: map[string]bool{
125+
"go": true,
126+
},
127+
Recursive: input.Recursive,
128+
}
129+
130+
//nolint:contextcheck // passing the context in would be a breaking change
131+
scanResults, err := osvscanner.DoScan(action)
132+
if err != nil && !errors.Is(err, osvscanner.ErrVulnerabilitiesFound) {
133+
return nil, nil, fmt.Errorf("failed to run scanner: %w", err)
134+
}
135+
136+
for _, vuln := range scanResults.Flatten() {
137+
vulnCacheMap.Store(vuln.Vulnerability.ID, &vuln.Vulnerability)
138+
}
139+
140+
if err == nil {
141+
return &mcp.CallToolResult{
142+
Content: []mcp.Content{
143+
&mcp.TextContent{Text: "No issues found"},
144+
},
145+
}, nil, nil
146+
}
147+
148+
buf := strings.Builder{}
149+
150+
for _, s := range statsCollector.collectedLines {
151+
buf.WriteString(s + "\n")
152+
}
153+
154+
text.DisableColors()
155+
output.PrintVerticalResults(&scanResults, &buf, false)
156+
157+
return &mcp.CallToolResult{
158+
Content: []mcp.Content{
159+
&mcp.TextContent{Text: buf.String()},
160+
},
161+
}, nil, nil
162+
}
163+
164+
// getVulnerabilityDetailsInput is the input for the get_vulnerability_details tool.
165+
type getVulnerabilityDetailsInput struct {
166+
VulnID string `json:"vuln_id" jsonschema:"The OSV vulnerability ID to retrieve details for."`
167+
}
168+
169+
func handleVulnIDRetrieval(ctx context.Context, _ *mcp.CallToolRequest, input *getVulnerabilityDetailsInput) (*mcp.CallToolResult, *osvschema.Vulnerability, error) {
170+
vulnAny, found := vulnCacheMap.Load(input.VulnID)
171+
vuln := vulnAny.(*osvschema.Vulnerability)
172+
if !found {
173+
var err error
174+
vuln, err = osvdev.DefaultClient().GetVulnByID(ctx, input.VulnID)
175+
if err != nil {
176+
return nil, nil, fmt.Errorf("vulnerability with ID %s not found: %w", input.VulnID, err)
177+
}
178+
179+
vulnCacheMap.Store(input.VulnID, vuln)
180+
}
181+
182+
return &mcp.CallToolResult{}, vuln, nil
183+
}
184+
185+
// ignoreVulnerabilityInput is a placeholder to enable the tool call,
186+
// as it seems like go-sdk mcp does not support a tool call with no arguments.
187+
type ignoreVulnerabilityInput struct {
188+
// Extra field is needed as a placeholder to prevent the llm from erroring when calling the tool
189+
Verbose bool `json:"verbose" jsonschema:"ignore this parameter"`
190+
}
191+
192+
//go:embed configuration-instructions.md
193+
var configInstructions string
194+
195+
// handleIgnoreVulnerability does not perform any actual actions, but instead provides the instructions of how
196+
// to write an ignore file to the LLM using this tool, so that it can correctly write the ignore file.
197+
func handleIgnoreVulnerability(_ context.Context, _ *mcp.CallToolRequest, _ *ignoreVulnerabilityInput) (*mcp.CallToolResult, any, error) {
198+
return &mcp.CallToolResult{
199+
Content: []mcp.Content{
200+
&mcp.TextContent{Text: configInstructions},
201+
},
202+
}, nil, nil
203+
}
204+
205+
// scanDepsPrompt is the prompt that is sent to the AI model when the scan_deps prompt is requested.
206+
//
207+
//go:embed scan-deps-prompt.md
208+
var scanDepsPrompt string
209+
210+
func handleScanDepsPrompt(_ context.Context, _ *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
211+
return &mcp.GetPromptResult{
212+
Description: "Dependency vulnerability analysis",
213+
Messages: []*mcp.PromptMessage{
214+
{
215+
Role: "assistant",
216+
Content: &mcp.TextContent{
217+
Text: scanDepsPrompt,
218+
},
219+
},
220+
},
221+
}, nil
222+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
layout: page
3+
permalink: /configuration/
4+
nav_order: 5
5+
---
6+
7+
# Configuration
8+
9+
To configure scanning, place an osv-scanner.toml file in the scanned file's directory. This does not propagate to child directories.
10+
11+
**Example:**
12+
13+
```
14+
/Cargo.lock
15+
/osv-scanner.toml (1)
16+
/child-dir/go.mod
17+
/child-dir/osv-scanner.toml (2)
18+
/child-dir/nested-dir/package-lock.json
19+
```
20+
21+
`osv-scanner.toml (1)` will only apply to `Cargo.lock`, `osv-scanner.toml (2)` will only apply to `go.mod`, and no config will apply to `package-lock.json`.
22+
23+
To override `osv-scanner.toml` files, pass the `--config=/path/to/config.toml` flag with the path to the configuration you want to apply instead, this will apply `config.toml` to all files parsed, and ignore `osv-scanner.toml` in all directories.
24+
25+
## Ignore vulnerabilities by ID
26+
27+
To ignore a vulnerability, enter the ID under the `IgnoreVulns` key. Optionally, add an expiry date or reason.
28+
29+
### Example
30+
31+
```toml
32+
[[IgnoredVulns]]
33+
id = "GO-2022-0968"
34+
# ignoreUntil = 2022-11-09 # Optional exception expiry date
35+
reason = "No ssh servers are connected to or hosted in Go lang"
36+
37+
[[IgnoredVulns]]
38+
id = "GO-2022-1059"
39+
# ignoreUntil = 2022-11-09 # Optional exception expiry date
40+
reason = "No external http servers are written in Go lang."
41+
```
42+
43+
Ignoring a vulnerability will also ignore vulnerabilities that are considered aliases of that vulnerability.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
You are a highly skilled senior security analyst.
2+
Your primary task is to conduct a security audit of the vulnerabilities in the dependencies of this project.
3+
Utilizing your skillset, you must operate by strictly following the operating principles defined in your context.
4+
5+
**Step 1: Perform initial scan**
6+
7+
Use the scan_vulnerable_dependencies with recursive on the project, always use the absolute path.
8+
This will return a report of all the relevant lockfiles and all vulnerable dependencies in those files.
9+
10+
**Step 2: Analyse the report**
11+
12+
Go through the report and determine the relevant project lockfiles (ignoring lockfiles in test directories),
13+
and prioritise which vulnerability to fix based on the description and severity.
14+
If more information is needed about a vulnerability, use get_vulnerability_details.
15+
16+
**Step 3: Prioritisation**
17+
18+
Give advice on which vulnerabilities to prioritise fixing, and general advice on how to go about fixing
19+
them by updating. Don't try to automatically update for the user without input.

cmd/osv-scanner/mcp/stats.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package mcp
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/google/osv-scalibr/stats"
8+
"github.com/google/osv-scanner/v2/internal/output"
9+
)
10+
11+
type fileOpenedLogger struct {
12+
stats.NoopCollector
13+
14+
collectedLines []string
15+
}
16+
17+
var _ stats.Collector = &fileOpenedLogger{}
18+
19+
func (c *fileOpenedLogger) AfterExtractorRun(_ string, extractorstats *stats.AfterExtractorStats) {
20+
if extractorstats.Error != nil { // Don't log scanned if error occurred
21+
return
22+
}
23+
24+
pkgsFound := len(extractorstats.Inventory.Packages)
25+
26+
c.collectedLines = append(c.collectedLines,
27+
fmt.Sprintf(
28+
"Scanned %s file and found %d %s",
29+
filepath.Join(extractorstats.Root, extractorstats.Path),
30+
pkgsFound,
31+
output.Form(pkgsFound, "package", "packages"),
32+
))
33+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/google/osv-scalibr v0.3.7-0.20251023161426-90e9ac9cc1b3
2121
github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5
2222
github.com/jedib0t/go-pretty/v6 v6.6.8
23+
github.com/modelcontextprotocol/go-sdk v1.0.0
2324
github.com/muesli/reflow v0.3.0
2425
github.com/opencontainers/go-digest v1.0.0
2526
github.com/ossf/osv-schema/bindings/go v0.0.0-20251012234424-434020c6442f
@@ -116,6 +117,7 @@ require (
116117
github.com/gogo/protobuf v1.3.2 // indirect
117118
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
118119
github.com/google/go-containerregistry v0.20.6 // indirect
120+
github.com/google/jsonschema-go v0.3.0 // indirect
119121
github.com/google/uuid v1.6.0 // indirect
120122
github.com/gorilla/css v1.0.1 // indirect
121123
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@@ -183,6 +185,7 @@ require (
183185
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
184186
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
185187
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
188+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
186189
github.com/yuin/goldmark v1.7.12 // indirect
187190
github.com/yuin/goldmark-emoji v1.0.6 // indirect
188191
github.com/yusufpapurcu/wmi v1.2.4 // indirect

0 commit comments

Comments
 (0)