| // Copyright 2025 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package mcp |
| |
| import ( |
| "context" |
| _ "embed" |
| "fmt" |
| "io" |
| "log" |
| "net" |
| "net/http" |
| "os" |
| "sync" |
| |
| "golang.org/x/tools/gopls/internal/cache" |
| "golang.org/x/tools/gopls/internal/cache/metadata" |
| "golang.org/x/tools/gopls/internal/file" |
| "golang.org/x/tools/gopls/internal/protocol" |
| "golang.org/x/tools/gopls/internal/settings" |
| "golang.org/x/tools/gopls/internal/util/moremaps" |
| "golang.org/x/tools/internal/mcp" |
| ) |
| |
| //go:embed instructions.md |
| var instructions string |
| |
| // A handler implements various MCP tools for an LSP session. |
| type handler struct { |
| session *cache.Session |
| lspServer protocol.Server |
| } |
| |
| // Sessions is the interface used to access gopls sessions. |
| type Sessions interface { |
| Session(id string) (*cache.Session, protocol.Server) |
| FirstSession() (*cache.Session, protocol.Server) |
| SetSessionExitFunc(func(string)) |
| } |
| |
| // Serve starts an MCP server serving at the input address. |
| // The server receives LSP session events on the specified channel, which the |
| // caller is responsible for closing. The server runs until the context is |
| // canceled. |
| func Serve(ctx context.Context, address string, sessions Sessions, isDaemon bool) error { |
| log.Printf("Gopls MCP server: starting up on http") |
| listener, err := net.Listen("tcp", address) |
| if err != nil { |
| return err |
| } |
| defer listener.Close() |
| |
| // TODO(hxjiang): expose the MCP server address to the LSP client. |
| if isDaemon { |
| log.Printf("Gopls MCP daemon: listening on address %s...", listener.Addr()) |
| } |
| defer log.Printf("Gopls MCP server: exiting") |
| |
| svr := http.Server{ |
| Handler: HTTPHandler(sessions, isDaemon), |
| BaseContext: func(net.Listener) context.Context { |
| return ctx |
| }, |
| } |
| |
| // Run the server until cancellation. |
| go func() { |
| <-ctx.Done() |
| svr.Close() // ignore error |
| }() |
| log.Printf("mcp http server listening") |
| return svr.Serve(listener) |
| } |
| |
| // StartStdIO starts an MCP server over stdio. |
| func StartStdIO(ctx context.Context, session *cache.Session, server protocol.Server, rpcLog io.Writer) error { |
| transport := mcp.NewStdioTransport() |
| var t mcp.Transport = transport |
| if rpcLog != nil { |
| t = mcp.NewLoggingTransport(transport, rpcLog) |
| } |
| s := newServer(session, server) |
| return s.Run(ctx, t) |
| } |
| |
| func HTTPHandler(sessions Sessions, isDaemon bool) http.Handler { |
| var ( |
| mu sync.Mutex // lock for mcpHandlers. |
| mcpHandlers = make(map[string]*mcp.SSEHandler) // map from lsp session ids to MCP sse handlers. |
| ) |
| mux := http.NewServeMux() |
| |
| // In daemon mode, gopls serves mcp server at ADDRESS/sessions/$SESSIONID. |
| // Otherwise, gopls serves mcp server at ADDRESS. |
| if isDaemon { |
| mux.HandleFunc("/sessions/{id}", func(w http.ResponseWriter, r *http.Request) { |
| sessionID := r.PathValue("id") |
| |
| mu.Lock() |
| handler, ok := mcpHandlers[sessionID] |
| if !ok { |
| if s, svr := sessions.Session(sessionID); s != nil { |
| handler = mcp.NewSSEHandler(func(request *http.Request) *mcp.Server { |
| return newServer(s, svr) |
| }) |
| mcpHandlers[sessionID] = handler |
| } |
| } |
| mu.Unlock() |
| |
| if handler == nil { |
| http.Error(w, fmt.Sprintf("session %s not established", sessionID), http.StatusNotFound) |
| return |
| } |
| |
| handler.ServeHTTP(w, r) |
| }) |
| } else { |
| // TODO(hxjiang): should gopls serve only at a specific path? |
| mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| mu.Lock() |
| // When not in daemon mode, gopls has at most one LSP session. |
| _, handler, ok := moremaps.Arbitrary(mcpHandlers) |
| if !ok { |
| s, svr := sessions.FirstSession() |
| handler = mcp.NewSSEHandler(func(request *http.Request) *mcp.Server { |
| return newServer(s, svr) |
| }) |
| mcpHandlers[s.ID()] = handler |
| } |
| mu.Unlock() |
| |
| if handler == nil { |
| http.Error(w, "session not established", http.StatusNotFound) |
| return |
| } |
| |
| handler.ServeHTTP(w, r) |
| }) |
| } |
| sessions.SetSessionExitFunc(func(sessionID string) { |
| mu.Lock() |
| defer mu.Unlock() |
| // TODO(rfindley): add a way to close SSE handlers (and therefore |
| // close their transports). Otherwise, we leak JSON-RPC goroutines. |
| delete(mcpHandlers, sessionID) |
| }) |
| return mux |
| } |
| |
| func newServer(session *cache.Session, lspServer protocol.Server) *mcp.Server { |
| h := handler{ |
| session: session, |
| lspServer: lspServer, |
| } |
| mcpServer := mcp.NewServer("gopls", "v0.1.0", &mcp.ServerOptions{ |
| Instructions: instructions, |
| }) |
| |
| defaultTools := []*mcp.ServerTool{ |
| h.workspaceTool(), |
| h.outlineTool(), |
| h.workspaceDiagnosticsTool(), |
| h.symbolReferencesTool(), |
| h.searchTool(), |
| h.fileContextTool(), |
| } |
| disabledTools := append(defaultTools, |
| // The fileMetadata tool is redundant with fileContext. |
| h.fileMetadataTool(), |
| // The context tool returns context for all imports, which can consume a |
| // lot of tokens. Conservatively, rely on the model selecting the imports |
| // to summarize using the outline tool. |
| h.contextTool(), |
| // The fileDiagnosticsTool only returns diagnostics for the current file, |
| // but often changes will cause breakages in other tools. The |
| // workspaceDiagnosticsTool always returns breakages, and supports running |
| // deeper diagnostics in selected files. |
| h.fileDiagnosticsTool(), |
| // The references tool requires a location, which models tend to get wrong. |
| // The symbolic variant seems to be easier to get right, albeit less |
| // powerful. |
| h.referencesTool(), |
| ) |
| var toolConfig map[string]bool // non-default settings |
| // For testing, poke through to the gopls server to access its options, |
| // and enable some of the disabled tools. |
| if hasOpts, ok := lspServer.(interface{ Options() *settings.Options }); ok { |
| toolConfig = hasOpts.Options().MCPTools |
| } |
| var tools []*mcp.ServerTool |
| for _, tool := range defaultTools { |
| if enabled, ok := toolConfig[tool.Tool.Name]; !ok || enabled { |
| tools = append(tools, tool) |
| } |
| } |
| // Disabled tools must be explicitly enabled. |
| for _, tool := range disabledTools { |
| if toolConfig[tool.Tool.Name] { |
| tools = append(tools, tool) |
| } |
| } |
| mcpServer.AddTools(tools...) |
| |
| return mcpServer |
| } |
| |
| // snapshot returns the best default snapshot to use for workspace queries. |
| func (h *handler) snapshot() (*cache.Snapshot, func(), error) { |
| views := h.session.Views() |
| if len(views) == 0 { |
| return nil, nil, fmt.Errorf("No active builds.") |
| } |
| return views[0].Snapshot() |
| } |
| |
| // fileOf is like [cache.Session.FileOf], but does a sanity check for file |
| // changes. Currently, it checks for modified files in the transitive closure |
| // of the file's narrowest package. |
| // |
| // This helps avoid stale packages, but is not a substitute for real file |
| // watching, as it misses things like files being added to a package. |
| func (h *handler) fileOf(ctx context.Context, file string) (file.Handle, *cache.Snapshot, func(), error) { |
| uri := protocol.URIFromPath(file) |
| fh, snapshot, release, err := h.session.FileOf(ctx, uri) |
| if err != nil { |
| return nil, nil, nil, err |
| } |
| md, err := snapshot.NarrowestMetadataForFile(ctx, uri) |
| if err != nil { |
| release() |
| return nil, nil, nil, err |
| } |
| fileEvents, err := checkForFileChanges(ctx, snapshot, md.ID) |
| if err != nil { |
| release() |
| return nil, nil, nil, err |
| } |
| if len(fileEvents) == 0 { |
| return fh, snapshot, release, nil |
| } |
| release() // snapshot is not latest |
| |
| // We detect changed files: process them before getting the snapshot. |
| if err := h.lspServer.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{ |
| Changes: fileEvents, |
| }); err != nil { |
| return nil, nil, nil, err |
| } |
| return h.session.FileOf(ctx, uri) |
| } |
| |
| // checkForFileChanges checks for file changes in the transitive closure of |
| // the given package, by checking file modification time. Since it does not |
| // actually read file contents, it may miss changes that occur within the mtime |
| // resolution of the current file system (on some operating systems, this may |
| // be as much as a second). |
| // |
| // It also doesn't catch package changes that occur due to added files or |
| // changes to the go.mod file. |
| func checkForFileChanges(ctx context.Context, snapshot *cache.Snapshot, id metadata.PackageID) ([]protocol.FileEvent, error) { |
| var events []protocol.FileEvent |
| |
| seen := make(map[metadata.PackageID]struct{}) |
| var checkPkg func(id metadata.PackageID) error |
| checkPkg = func(id metadata.PackageID) error { |
| if _, ok := seen[id]; ok { |
| return nil |
| } |
| seen[id] = struct{}{} |
| |
| mp := snapshot.Metadata(id) |
| for _, uri := range mp.CompiledGoFiles { |
| fh, err := snapshot.ReadFile(ctx, uri) |
| if err != nil { |
| return err // context cancelled |
| } |
| |
| mtime, mtimeErr := fh.ModTime() |
| fi, err := os.Stat(uri.Path()) |
| switch { |
| case err != nil: |
| if mtimeErr == nil { |
| // file existed, and doesn't anymore, so the file was deleted |
| events = append(events, protocol.FileEvent{URI: uri, Type: protocol.Deleted}) |
| } |
| case mtimeErr != nil: |
| // err == nil (from above), so the file was created |
| events = append(events, protocol.FileEvent{URI: uri, Type: protocol.Created}) |
| case !mtime.IsZero() && fi.ModTime().After(mtime): |
| events = append(events, protocol.FileEvent{URI: uri, Type: protocol.Changed}) |
| } |
| } |
| for _, depID := range mp.DepsByPkgPath { |
| if err := checkPkg(depID); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| return events, checkPkg(id) |
| } |
| |
| func textResult(text string) *mcp.CallToolResultFor[any] { |
| return &mcp.CallToolResultFor[any]{ |
| Content: []*mcp.Content{ |
| mcp.NewTextContent(text), |
| }, |
| } |
| } |