Skip to content

Commit 224e336

Browse files
ethanalee-workgopherbot
authored andcommitted
internal/mcp: expose vulncheck tool functionality via mcp
- Expose `go_vulncheck` tool via MCP. - Callers will be able to scan their directory using gopls.vulncheck. - Appended additional steps to instructions.md to induce usage of vulncheck during read and write workflows. - Add additional test case to mcp_test.go to evaluate go_vulncheck. Change-Id: I3f6f4cc7cfe6279703f5cf980e92eda4b9029506 Reviewed-on: https://go-review.googlesource.com/c/tools/+/702376 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Robert Findley <rfindley@google.com> Auto-Submit: Ethan Lee <ethanalee@google.com>
1 parent 9e64e29 commit 224e336

File tree

4 files changed

+235
-3
lines changed

4 files changed

+235
-3
lines changed

gopls/internal/cmd/mcp_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,26 @@ package cmd_test
77
import (
88
"bufio"
99
"bytes"
10+
"context"
11+
"encoding/json"
1012
"fmt"
1113
"net"
1214
"os"
1315
"os/exec"
1416
"path/filepath"
1517
"runtime"
18+
"slices"
1619
"strconv"
1720
"strings"
1821
"testing"
1922
"time"
2023

2124
"github.com/modelcontextprotocol/go-sdk/mcp"
25+
internal_mcp "golang.org/x/tools/gopls/internal/mcp"
26+
"golang.org/x/tools/gopls/internal/test/integration/fake"
27+
"golang.org/x/tools/gopls/internal/vulncheck/vulntest"
2228
"golang.org/x/tools/internal/testenv"
29+
"golang.org/x/tools/txtar"
2330
)
2431

2532
func TestMCPCommandStdio(t *testing.T) {
@@ -258,6 +265,119 @@ func MyFun() {}
258265
}
259266
}
260267

268+
func TestMCPVulncheckCommand(t *testing.T) {
269+
const proxyData = `
270+
-- example.com/vulnmod@v1.0.0/go.mod --
271+
module example.com/vulnmod
272+
go 1.18
273+
-- example.com/vulnmod@v1.0.0/vuln.go --
274+
package vulnmod
275+
276+
// VulnFunc is a vulnerable function.
277+
func VulnFunc() {}
278+
`
279+
const vulnData = `
280+
-- GO-TEST-0001.yaml --
281+
modules:
282+
- module: example.com/vulnmod
283+
versions:
284+
- introduced: "1.0.0"
285+
packages:
286+
- package: example.com/vulnmod
287+
symbols:
288+
- VulnFunc
289+
`
290+
proxyArchive := txtar.Parse([]byte(proxyData))
291+
proxyFiles := make(map[string][]byte)
292+
for _, f := range proxyArchive.Files {
293+
proxyFiles[f.Name] = f.Data
294+
}
295+
goproxy, err := fake.WriteProxy(t.TempDir(), proxyFiles)
296+
if err != nil {
297+
t.Fatal(err)
298+
}
299+
300+
db, err := vulntest.NewDatabase(context.Background(), []byte(vulnData))
301+
if err != nil {
302+
t.Fatal(err)
303+
}
304+
defer db.Clean()
305+
306+
tree := writeTree(t, `
307+
-- go.mod --
308+
module example.com/user
309+
go 1.18
310+
require example.com/vulnmod v1.0.0
311+
-- main.go --
312+
package main
313+
import "example.com/vulnmod"
314+
func main() {
315+
vulnmod.VulnFunc()
316+
}
317+
`)
318+
319+
// Update go.sum before running gopls, to avoid load failures.
320+
tidyCmd := exec.CommandContext(t.Context(), "go", "mod", "tidy")
321+
tidyCmd.Dir = tree
322+
tidyCmd.Env = append(os.Environ(), "GOPROXY="+goproxy, "GOSUMDB=off")
323+
if output, err := tidyCmd.CombinedOutput(); err != nil {
324+
t.Fatalf("go mod tidy failed: %v\n%s", err, output)
325+
}
326+
327+
goplsCmd := exec.Command(os.Args[0], "mcp")
328+
goplsCmd.Env = append(os.Environ(),
329+
"ENTRYPOINT=goplsMain",
330+
"GOPROXY="+goproxy,
331+
"GOSUMDB=off",
332+
"GOVULNDB="+db.URI(),
333+
)
334+
goplsCmd.Dir = tree
335+
336+
ctx := t.Context()
337+
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
338+
mcpSession, err := client.Connect(ctx, &mcp.CommandTransport{Command: goplsCmd}, nil)
339+
if err != nil {
340+
t.Fatal(err)
341+
}
342+
defer func() {
343+
if err := mcpSession.Close(); err != nil {
344+
t.Errorf("closing MCP connection: %v", err)
345+
}
346+
}()
347+
348+
res, err := mcpSession.CallTool(ctx, &mcp.CallToolParams{Name: "go_vulncheck", Arguments: map[string]any{}})
349+
if err != nil {
350+
t.Fatal(err)
351+
}
352+
jsonBytes, err := json.Marshal(res.StructuredContent)
353+
if err != nil {
354+
t.Fatal(err)
355+
}
356+
357+
var result internal_mcp.VulncheckResultOutput
358+
if err := json.Unmarshal(jsonBytes, &result); err != nil {
359+
t.Fatal(err)
360+
}
361+
if len(result.Findings) != 1 {
362+
t.Errorf("expected 1 finding, got %d", len(result.Findings))
363+
} else {
364+
finding := result.Findings[0]
365+
if finding.ID != "GO-TEST-0001" {
366+
t.Errorf("expected ID 'GO-TEST-0001', got %q", finding.ID)
367+
}
368+
expectedPackages := []string{"Go standard library", "example.com/vulnmod"}
369+
if !slices.Equal(finding.AffectedPackages, expectedPackages) {
370+
t.Errorf("expected affected packages %v, got %v", expectedPackages, finding.AffectedPackages)
371+
}
372+
}
373+
374+
if result.Logs == "" {
375+
t.Errorf("expected logs to be non-empty")
376+
} else {
377+
t.Logf("Logs:\n%s", result.Logs)
378+
}
379+
}
380+
261381
// resultText concatenates the textual content of the given result, reporting
262382
// an error if any content values are non-textual.
263383
func resultText(t *testing.T, res *mcp.CallToolResult) string {

gopls/internal/mcp/instructions.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ These instructions describe how to efficiently work in the Go programming langua
44

55
## Detecting a Go workspace
66

7-
At the start of every session, you MUST use the `go_workspace` tool to learn about the Go workspace. The rest of these instructions apply whenever that tool indicates that the user is in a Go workspace.
7+
At the start of every session, you MUST use the `go_workspace` tool to learn about the Go workspace. ONLY if you are in a Go workspace, you MUST run `go_vulncheck` immediately afterwards to identify any existing security risks. The rest of these instructions apply whenever that tool indicates that the user is in a Go workspace.
88

99
## Go programming workflows
1010

@@ -43,5 +43,7 @@ The editing workflow is iterative. You should cycle through these steps until th
4343

4444
5. **Fix errors**: If `go_diagnostics` reports any errors, fix them. The tool may provide suggested quick fixes in the form of diffs. You should review these diffs and apply them if they are correct. Once you've applied a fix, re-run `go_diagnostics` to confirm that the issue is resolved. It is OK to ignore 'hint' or 'info' diagnostics if they are not relevant to the current task. Note that Go diagnostic messages may contain a summary of the source code, which may not match its exact text.
4545

46-
6. **Run tests**: Once `go_diagnostics` reports no errors (and ONLY once there are no errors), run the tests for the packages you have changed. You can do this with `go test [packagePath...]`. Don't run `go test ./...` unless the user explicitly requests it, as doing so may slow down the iteration loop.
46+
6. **Check for vulnerabilities**: If your edits involved adding or updating dependencies in the go.mod file, you MUST run a vulnerability check on the entire workspace. This ensures that the new dependencies do not introduce any security risks. This step should be performed after all build errors are resolved. EXAMPLE: `go_vulncheck({"pattern":"./..."})`
47+
48+
7. **Run tests**: Once `go_diagnostics` reports no errors (and ONLY once there are no errors), run the tests for the packages you have changed. You can do this with `go test [packagePath...]`. Don't run `go test ./...` unless the user explicitly requests it, as doing so may slow down the iteration loop.
4749

gopls/internal/mcp/mcp.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ func newServer(session *cache.Session, lspServer protocol.Server) *mcp.Server {
169169
"go_diagnostics",
170170
"go_symbol_references",
171171
"go_search",
172-
"go_file_context"}
172+
"go_file_context",
173+
"go_vulncheck"}
173174
disabledTools := append(defaultTools,
174175
// The fileMetadata tool is redundant with fileContext.
175176
[]string{"go_file_metadata",
@@ -289,6 +290,15 @@ does the same for a symbol in the imported package "lib".
289290
Name: "go_workspace",
290291
Description: "Summarize the Go programming language workspace",
291292
}, h.workspaceHandler)
293+
case "go_vulncheck":
294+
mcp.AddTool(mcpServer, &mcp.Tool{
295+
Name: "go_vulncheck",
296+
Description: `Runs a vulnerability check on the Go workspace.
297+
298+
The check is performed on a given package pattern within a specified directory.
299+
If no directory is provided, it defaults to the workspace root.
300+
If no pattern is provided, it defaults to "./...".`,
301+
}, h.vulncheckHandler)
292302
}
293303
}
294304

gopls/internal/mcp/vulncheck.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp
6+
7+
import (
8+
"bytes"
9+
"context"
10+
"fmt"
11+
"maps"
12+
"slices"
13+
"sort"
14+
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
"golang.org/x/tools/gopls/internal/vulncheck/scan"
17+
)
18+
19+
type vulncheckParams struct {
20+
Dir string `json:"dir" jsonschema:"directory to run the vulnerability check within"`
21+
Pattern string `json:"pattern" jsonschema:"package pattern to check"`
22+
}
23+
24+
type GroupedVulnFinding struct {
25+
ID string `json:"id"`
26+
Details string `json:"details"`
27+
AffectedPackages []string `json:"affectedPackages"`
28+
}
29+
30+
type VulncheckResultOutput struct {
31+
Findings []GroupedVulnFinding `json:"findings,omitempty"`
32+
Logs string `json:"logs,omitempty"`
33+
}
34+
35+
func (h *handler) vulncheckHandler(ctx context.Context, req *mcp.CallToolRequest, params *vulncheckParams) (*mcp.CallToolResult, *VulncheckResultOutput, error) {
36+
snapshot, release, err := h.snapshot()
37+
if err != nil {
38+
return nil, nil, err
39+
}
40+
defer release()
41+
42+
dir := params.Dir
43+
if dir == "" && len(h.session.Views()) > 0 {
44+
dir = h.session.Views()[0].Root().Path()
45+
}
46+
47+
pattern := params.Pattern
48+
if pattern == "" {
49+
pattern = "./..."
50+
}
51+
52+
var logBuf bytes.Buffer
53+
result, err := scan.RunGovulncheck(ctx, pattern, snapshot, dir, &logBuf)
54+
if err != nil {
55+
return nil, nil, fmt.Errorf("running govulncheck failed: %v\nLogs:\n%s", err, logBuf.String())
56+
}
57+
58+
groupedPkgs := make(map[string]map[string]struct{})
59+
for _, finding := range result.Findings {
60+
if osv := result.Entries[finding.OSV]; osv != nil {
61+
if _, ok := groupedPkgs[osv.ID]; !ok {
62+
groupedPkgs[osv.ID] = make(map[string]struct{})
63+
}
64+
pkg := finding.Trace[0].Package
65+
if pkg == "" {
66+
pkg = "Go standard library"
67+
}
68+
groupedPkgs[osv.ID][pkg] = struct{}{}
69+
}
70+
}
71+
72+
var output VulncheckResultOutput
73+
if len(groupedPkgs) > 0 {
74+
output.Findings = make([]GroupedVulnFinding, 0, len(groupedPkgs))
75+
for id, pkgsSet := range groupedPkgs {
76+
pkgs := slices.Sorted(maps.Keys(pkgsSet))
77+
78+
output.Findings = append(output.Findings, GroupedVulnFinding{
79+
ID: id,
80+
Details: result.Entries[id].Details,
81+
AffectedPackages: pkgs,
82+
})
83+
}
84+
sort.Slice(output.Findings, func(i, j int) bool {
85+
return output.Findings[i].ID < output.Findings[j].ID
86+
})
87+
}
88+
89+
if logBuf.Len() > 0 {
90+
output.Logs = logBuf.String()
91+
}
92+
93+
var summary bytes.Buffer
94+
fmt.Fprintf(&summary, "Vulnerability check for pattern %q complete. Found %d vulnerabilities.", pattern, len(output.Findings))
95+
if output.Logs != "" {
96+
fmt.Fprintf(&summary, "\nLogs are available in the structured output.")
97+
}
98+
99+
return nil, &output, nil
100+
}

0 commit comments

Comments
 (0)