Skip to content

Commit fdfbc31

Browse files
committed
Scanner MCP
1 parent 6a10b66 commit fdfbc31

12 files changed

Lines changed: 332 additions & 20 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: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Package mcp implements the `mcp` command for osv-scanner.
2+
package mcp
3+
4+
import (
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"strings"
10+
11+
"net/http"
12+
13+
"github.com/google/osv-scanner/v2/internal/cmdlogger"
14+
"github.com/google/osv-scanner/v2/internal/output"
15+
"github.com/google/osv-scanner/v2/internal/version"
16+
"github.com/google/osv-scanner/v2/pkg/osvscanner"
17+
"github.com/jedib0t/go-pretty/v6/text"
18+
"github.com/modelcontextprotocol/go-sdk/mcp"
19+
"github.com/ossf/osv-schema/bindings/go/osvschema"
20+
"github.com/urfave/cli/v3"
21+
"osv.dev/bindings/go/osvdev"
22+
)
23+
24+
var vulnCacheMap = map[string]*osvschema.Vulnerability{}
25+
26+
func Command(_, _ io.Writer) *cli.Command {
27+
return &cli.Command{
28+
Name: "experimental-mcp",
29+
Usage: "Run osv-scanner as an MCP service (experimental)",
30+
Description: "Run osv-scanner as an MCP service, speaking the MCP protocol over stdin/stdout.",
31+
Flags: []cli.Flag{
32+
&cli.StringFlag{
33+
Name: "sse",
34+
DefaultText: "localhost:8080",
35+
Value: "localhost:8080",
36+
Usage: "The listening address for the SSE server, e.g. localhost:8080",
37+
},
38+
},
39+
Action: action,
40+
}
41+
}
42+
43+
type ScanVulnerableDependenciesInput struct {
44+
Paths []string `json:"paths" jsonschema:"A list of absolute or relative path to a file or directory to scan.,required"`
45+
IgnoreGlobPatterns []string `json:"ignore_glob_patterns" jsonschema:"A list of glob patterns to ignore when scanning."`
46+
Recursive bool `json:"recursive" jsonschema:"Scans directory recursively"`
47+
}
48+
49+
type GetVulnerabilityDetailsInput struct {
50+
VulnID string `json:"vuln_id" jsonschema:"The OSV vulnerability ID to retrieve details for.,required"`
51+
}
52+
53+
func action(ctx context.Context, cmd *cli.Command) error {
54+
s := mcp.NewServer(&mcp.Implementation{
55+
Name: "OSV-Scanner", Version: version.OSVVersion,
56+
}, nil)
57+
58+
mcp.AddTool(s, &mcp.Tool{
59+
Name: "scan_vulnerable_dependencies",
60+
Description: "Scans a source directory for vulnerable dependencies." +
61+
" Walks the given directory and uses osv.dev to query for vulnerabilities matching the found dependencies." +
62+
" Use this tool to check that the user's project is not depending on known vulnerable code.",
63+
}, handleScan)
64+
65+
// TODO(another-rex): Ideally this would be a template resource, but gemini-cli does not support those yet.
66+
mcp.AddTool(s, &mcp.Tool{
67+
Name: "get_vulnerability_details",
68+
Description: "Retrieves the full JSON details for a given vulnerability ID.",
69+
}, handleVulnIDRetrieval)
70+
71+
s.AddPrompt(&mcp.Prompt{
72+
Name: "scan_deps",
73+
Description: "Scans your project dependencies for known vulnerabilities.",
74+
}, handleCodeReview)
75+
76+
if cmd.IsSet("sse") {
77+
sseAddr := cmd.String("sse")
78+
cmdlogger.Infof("Starting SSE server on %s", sseAddr)
79+
handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
80+
return s
81+
}, nil)
82+
//nolint:gosec // Having no timeouts is unlikely to cause problems as this is meant to be run locally.
83+
if err := http.ListenAndServe(sseAddr, handler); err != nil {
84+
cmdlogger.Errorf("mcp error: %s", err)
85+
return err
86+
}
87+
} else {
88+
cmdlogger.SendEverythingToStderr()
89+
cmdlogger.Infof("Starting MCP server on stdio")
90+
if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil {
91+
cmdlogger.Errorf("mcp error: %s", err)
92+
return err
93+
}
94+
}
95+
96+
return nil
97+
}
98+
99+
func handleScan(_ context.Context, _ *mcp.CallToolRequest, input *ScanVulnerableDependenciesInput) (*mcp.CallToolResult, any, error) {
100+
// Security: validate path
101+
// if !isValidPath(path) {
102+
// return mcp.NewToolResultError(fmt.Sprintf("invalid path: %s", path)), nil
103+
//}
104+
105+
statsCollector := fileOpenedLogger{}
106+
107+
action := osvscanner.ScannerActions{
108+
DirectoryPaths: input.Paths,
109+
ScanLicensesSummary: false,
110+
ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{
111+
StatsCollector: &statsCollector,
112+
},
113+
CallAnalysisStates: map[string]bool{
114+
"go": true,
115+
},
116+
Recursive: input.Recursive,
117+
}
118+
119+
//nolint:contextcheck // passing the context in would be a breaking change
120+
scanResults, err := osvscanner.DoScan(action)
121+
if err != nil && !errors.Is(err, osvscanner.ErrVulnerabilitiesFound) {
122+
return nil, nil, fmt.Errorf("failed to run scanner: %w", err)
123+
}
124+
125+
for _, vuln := range scanResults.Flatten() {
126+
vulnCacheMap[vuln.Vulnerability.ID] = &vuln.Vulnerability
127+
}
128+
129+
if err == nil {
130+
return &mcp.CallToolResult{
131+
Content: []mcp.Content{
132+
&mcp.TextContent{Text: "No issues found"},
133+
},
134+
}, nil, nil
135+
}
136+
137+
buf := strings.Builder{}
138+
139+
for _, s := range statsCollector.collectedLines {
140+
buf.WriteString(s + "\n")
141+
}
142+
143+
text.DisableColors()
144+
output.PrintVerticalResults(&scanResults, &buf, false)
145+
146+
return &mcp.CallToolResult{
147+
Content: []mcp.Content{
148+
&mcp.TextContent{Text: buf.String()},
149+
},
150+
}, nil, nil
151+
}
152+
153+
func handleVulnIDRetrieval(ctx context.Context, _ *mcp.CallToolRequest, input *GetVulnerabilityDetailsInput) (*mcp.CallToolResult, *osvschema.Vulnerability, error) {
154+
vuln, found := vulnCacheMap[input.VulnID]
155+
if !found {
156+
var err error
157+
vuln, err = osvdev.DefaultClient().GetVulnByID(ctx, input.VulnID)
158+
if err != nil {
159+
return nil, nil, fmt.Errorf("vulnerability with ID %s not found: %w", input.VulnID, err)
160+
}
161+
162+
vulnCacheMap[input.VulnID] = vuln
163+
}
164+
165+
return &mcp.CallToolResult{}, vuln, nil
166+
}
167+
168+
func handleCodeReview(_ context.Context, _ *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
169+
return &mcp.GetPromptResult{
170+
Description: "Dependency vulnerability analysis",
171+
Messages: []*mcp.PromptMessage{
172+
{
173+
Role: "assistant",
174+
Content: &mcp.TextContent{
175+
Text: `
176+
177+
You are a highly skilled senior security analyst.
178+
Your primary task is to conduct a security audit of the vulnerabilities in the dependencies of this project.
179+
Utilizing your skillset, you must operate by strictly following the operating principles defined in your context.
180+
181+
**Step 1: Perform initial scan**
182+
183+
Use the scan_vulnerable_dependencies with recursive on the project, always use the absolute path.
184+
This will return a report of all the relevant lockfiles and all vulnerable dependencies in those files.
185+
186+
**Step 2: Analyse the report**
187+
188+
Go through the report and determine the relevant project lockfiles (ignoring lockfiles in test directories),
189+
and prioritise which vulnerability to fix based on the description and severity.
190+
If more information is needed about a vulnerability, use get_vulnerability_details.
191+
192+
**Step 3: Prioritisation**
193+
194+
Give advice on which vulnerabilities to prioritise fixing, and general advice on how to go about fixing
195+
them by updating. Don't try to automatically update for the user without input.
196+
`,
197+
},
198+
},
199+
},
200+
}, nil
201+
}

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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/google/osv-scalibr v0.3.6-0.20251017043508-d55b1c134c72
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
@@ -109,12 +110,13 @@ require (
109110
github.com/go-logr/stdr v1.2.2 // indirect
110111
github.com/go-ole/go-ole v1.2.6 // indirect
111112
github.com/go-restruct/restruct v1.2.0-alpha // indirect
112-
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
113+
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
113114
github.com/gobwas/glob v0.2.3 // indirect
114115
github.com/goccy/go-yaml v1.18.0 // indirect
115116
github.com/gogo/protobuf v1.3.2 // indirect
116117
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
117118
github.com/google/go-containerregistry v0.20.6 // indirect
119+
github.com/google/jsonschema-go v0.3.0 // indirect
118120
github.com/google/uuid v1.6.0 // indirect
119121
github.com/gorilla/css v1.0.1 // indirect
120122
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@@ -173,14 +175,15 @@ require (
173175
github.com/tklauser/go-sysconf v0.3.15 // indirect
174176
github.com/tklauser/numcpus v0.10.0 // indirect
175177
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
176-
github.com/ulikunitz/xz v0.5.15 // indirect
178+
github.com/ulikunitz/xz v0.5.11 // indirect
177179
github.com/vbatts/tar-split v0.12.1 // indirect
178180
github.com/xanzy/ssh-agent v0.3.3 // indirect
179181
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
180182
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
181183
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
182184
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
183185
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
186+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
184187
github.com/yuin/goldmark v1.7.12 // indirect
185188
github.com/yuin/goldmark-emoji v1.0.6 // indirect
186189
github.com/yusufpapurcu/wmi v1.2.4 // indirect

go.sum

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSr
210210
github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk=
211211
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
212212
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
213-
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
214-
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
213+
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
214+
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
215215
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
216216
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
217217
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -250,6 +250,8 @@ github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB
250250
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
251251
github.com/google/go-cpy v0.0.0-20211218193943-a9c933c06932 h1:5/4TSDzpDnHQ8rKEEQBjRlYx77mHOvXu08oGchxej7o=
252252
github.com/google/go-cpy v0.0.0-20211218193943-a9c933c06932/go.mod h1:cC6EdPbj/17GFCPDK39NRarlMI+kt+O60S12cNB5J9Y=
253+
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
254+
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
253255
github.com/google/osv-scalibr v0.3.6-0.20251017043508-d55b1c134c72 h1:xtvTNa8pnkQGcFXDyqD6cJi41ZO+nyyW3KOdnPv9Dy4=
254256
github.com/google/osv-scalibr v0.3.6-0.20251017043508-d55b1c134c72/go.mod h1:U+HMf5wN0mLn5cfAy7mEx1nlM/0cXWXbEh6ndGRF34M=
255257
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
@@ -328,6 +330,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
328330
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
329331
github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA=
330332
github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
333+
github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
334+
github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
331335
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
332336
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
333337
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@@ -450,8 +454,8 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
450454
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
451455
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE=
452456
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE=
453-
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
454-
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
457+
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
458+
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
455459
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
456460
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
457461
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
@@ -469,6 +473,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8
469473
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
470474
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
471475
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
476+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
477+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
472478
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
473479
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
474480
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

0 commit comments

Comments
 (0)