Skip to content

Commit c9a4e51

Browse files
committed
gopls/internal/mcp: add go_rename tool
This tool uses the LSP server's Rename method to rename Go symbols (variables, functions, types, etc.) at a specified position. It supports both preview mode (dryRun: true, default) and actual application (dryRun: false) of changes across the workspace. - Add renameTool() and renameHandler() to the MCP server - Support dryRun parameter: true for preview, false to apply changes - Add TestMCPRename to test the tool for both dryRun modes - Update MCP instructions with go_rename usage examples for both modes
1 parent c212c4a commit c9a4e51

File tree

4 files changed

+429
-6
lines changed

4 files changed

+429
-6
lines changed

gopls/internal/cmd/mcp_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,146 @@ func getRandomPort() int {
285285
return listener.Addr().(*net.TCPAddr).Port
286286
}
287287

288+
func TestMCPRename(t *testing.T) {
289+
// Test the go_rename tool via MCP.
290+
if !(supportsFsnotify(runtime.GOOS)) {
291+
// See golang/go#74580
292+
t.Skipf(`skipping on %s; fsnotify is not supported`, runtime.GOOS)
293+
}
294+
testenv.NeedsExec(t) // stdio transport uses execve(2)
295+
296+
tests := []struct {
297+
name string
298+
dryRunValue interface{} // nil, true, or false
299+
wantStrings []string
300+
}{
301+
{
302+
name: "default_dry_run",
303+
dryRunValue: nil, // omit dryRun parameter
304+
wantStrings: []string{"a.go", "b.go", "c.go", "NewName", "OldName", "Rename would make", "6 changes", "3 files"},
305+
},
306+
{
307+
name: "explicit_dry_run",
308+
dryRunValue: true,
309+
wantStrings: []string{"a.go", "b.go", "c.go", "NewName", "OldName", "Rename would make", "6 changes", "3 files"},
310+
},
311+
{
312+
name: "apply_changes",
313+
dryRunValue: false,
314+
wantStrings: []string{"Successfully applied", "6 changes", "3 files"},
315+
},
316+
}
317+
318+
for _, tt := range tests {
319+
t.Run(tt.name, func(t *testing.T) {
320+
// Test cross-file rename with: function definition (a.go), function calls (b.go, c.go),
321+
// and variable assignments (all files) = 6 total references across 3 files
322+
tree := writeTree(t, `
323+
-- go.mod --
324+
module example.com
325+
go 1.18
326+
327+
-- a.go --
328+
package main
329+
330+
func OldName() {
331+
}
332+
333+
var globalVar = OldName
334+
335+
-- b.go --
336+
package main
337+
338+
func TestFunc() {
339+
OldName()
340+
x := OldName
341+
_ = x
342+
}
343+
344+
-- c.go --
345+
package main
346+
347+
func AnotherFunc() {
348+
OldName()
349+
result := OldName
350+
_ = result
351+
}
352+
`)
353+
354+
goplsCmd := exec.Command(os.Args[0], "mcp")
355+
goplsCmd.Env = append(os.Environ(), "ENTRYPOINT=goplsMain")
356+
goplsCmd.Dir = tree
357+
358+
ctx := t.Context()
359+
client := mcp.NewClient("client", "v0.0.1", nil)
360+
mcpSession, err := client.Connect(ctx, mcp.NewCommandTransport(goplsCmd))
361+
if err != nil {
362+
t.Fatal(err)
363+
}
364+
defer func() {
365+
if err := mcpSession.Close(); err != nil {
366+
t.Errorf("closing MCP connection: %v", err)
367+
}
368+
}()
369+
370+
tool := "go_rename"
371+
args := map[string]any{
372+
"file": filepath.Join(tree, "a.go"),
373+
"line": 2,
374+
"column": 5,
375+
"newName": "NewName",
376+
}
377+
378+
// Add dryRun parameter if specified
379+
if tt.dryRunValue != nil {
380+
args["dryRun"] = tt.dryRunValue
381+
}
382+
383+
res, err := mcpSession.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: args})
384+
if err != nil {
385+
t.Fatal(err)
386+
}
387+
got := resultText(t, res)
388+
389+
// Check expected strings in output
390+
for _, want := range tt.wantStrings {
391+
if !strings.Contains(got, want) {
392+
t.Errorf("CallTool(%s, %v) = %v, want containing %q", tool, args, got, want)
393+
}
394+
}
395+
396+
// Check file modification based on dryRun value across all files
397+
isDryRun := tt.dryRunValue == nil || tt.dryRunValue == true
398+
399+
for _, filename := range []string{"a.go", "b.go", "c.go"} {
400+
content, err := os.ReadFile(filepath.Join(tree, filename))
401+
if err != nil {
402+
t.Fatal(err)
403+
}
404+
405+
if isDryRun {
406+
// Files should NOT be modified in dry run
407+
if strings.Contains(string(content), "NewName") {
408+
t.Errorf("Expected %s to NOT contain 'NewName' in dry run, but it was modified: %s", filename, string(content))
409+
}
410+
if !strings.Contains(string(content), "OldName") {
411+
t.Errorf("Expected %s to still contain 'OldName' in dry run, got: %s", filename, string(content))
412+
}
413+
continue
414+
}
415+
416+
// Files should be modified when applying changes - verify cross-file rename
417+
if !strings.Contains(string(content), "NewName") {
418+
t.Errorf("Expected %s to contain 'NewName' after cross-file rename, got: %s", filename, string(content))
419+
}
420+
if strings.Contains(string(content), "OldName") {
421+
t.Errorf("Expected %s to no longer contain 'OldName' after rename, but found: %s", filename, string(content))
422+
}
423+
}
424+
})
425+
}
426+
}
427+
288428
// supportsFsnotify returns true if fsnotify supports the os.
289429
func supportsFsnotify(os string) bool {
290430
return os == "darwin" || os == "linux" || os == "windows"

gopls/internal/mcp/instructions.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,16 @@ The editing workflow is iterative. You should cycle through these steps until th
3838

3939
3. **Make edits**: Make the required edits, including edits to references you identified in the previous step. Don't proceed to the next step until all planned edits are complete.
4040

41-
4. **Check for errors**: After every code modification, you MUST call the `go_diagnostics` tool. Pass the paths of the files you have edited. This tool will report any build or analysis errors.
41+
4. **Rename symbols**: For renaming symbols across the workspace, use the `go_rename` tool which will automatically find and rename all references. ALWAYS follow this workflow:
42+
- First, run with `dryRun: true` (default) to preview changes: `go_rename({"file":"/path/to/server.go","line":10,"column":5,"newName":"NewServerName"})`
43+
- Show the user what changes would be made and ask for confirmation
44+
- Only if the user approves, apply the changes: `go_rename({"file":"/path/to/server.go","line":10,"column":5,"newName":"NewServerName","dryRun":false})`
45+
- Exception: Only skip the preview step if the user explicitly requests to apply changes immediately
46+
47+
5. **Check for errors**: After every code modification, you MUST call the `go_diagnostics` tool. Pass the paths of the files you have edited. This tool will report any build or analysis errors.
4248
EXAMPLE: `go_diagnostics({"files":["/path/to/server.go"]})`
4349

44-
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.
50+
6. **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.
4551

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.
52+
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.
4753

gopls/internal/mcp/mcp.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ func Serve(ctx context.Context, address string, sessions Sessions, isDaemon bool
7575
}
7676

7777
// StartStdIO starts an MCP server over stdio.
78-
func StartStdIO(ctx context.Context, session *cache.Session, server protocol.Server, rpcLog io.Writer) error {
78+
func StartStdIO(
79+
ctx context.Context, session *cache.Session, server protocol.Server, rpcLog io.Writer,
80+
) error {
7981
transport := mcp.NewStdioTransport()
8082
var t mcp.Transport = transport
8183
if rpcLog != nil {
@@ -166,6 +168,7 @@ func newServer(session *cache.Session, lspServer protocol.Server) *mcp.Server {
166168
h.symbolReferencesTool(),
167169
h.searchTool(),
168170
h.fileContextTool(),
171+
h.renameTool(),
169172
}
170173
disabledTools := append(defaultTools,
171174
// The fileMetadata tool is redundant with fileContext.
@@ -222,7 +225,9 @@ func (h *handler) snapshot() (*cache.Snapshot, func(), error) {
222225
//
223226
// This helps avoid stale packages, but is not a substitute for real file
224227
// watching, as it misses things like files being added to a package.
225-
func (h *handler) fileOf(ctx context.Context, file string) (file.Handle, *cache.Snapshot, func(), error) {
228+
func (h *handler) fileOf(
229+
ctx context.Context, file string,
230+
) (file.Handle, *cache.Snapshot, func(), error) {
226231
uri := protocol.URIFromPath(file)
227232
fh, snapshot, release, err := h.session.FileOf(ctx, uri)
228233
if err != nil {
@@ -260,7 +265,9 @@ func (h *handler) fileOf(ctx context.Context, file string) (file.Handle, *cache.
260265
//
261266
// It also doesn't catch package changes that occur due to added files or
262267
// changes to the go.mod file.
263-
func checkForFileChanges(ctx context.Context, snapshot *cache.Snapshot, id metadata.PackageID) ([]protocol.FileEvent, error) {
268+
func checkForFileChanges(
269+
ctx context.Context, snapshot *cache.Snapshot, id metadata.PackageID,
270+
) ([]protocol.FileEvent, error) {
264271
var events []protocol.FileEvent
265272

266273
seen := make(map[metadata.PackageID]struct{})

0 commit comments

Comments
 (0)