blob: 468dbaaca43720da9ead8e81d7ac0fae31c29a4d [file] [log] [blame]
// 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 cmd
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"sync"
"time"
"golang.org/x/tools/gopls/internal/cache"
"golang.org/x/tools/gopls/internal/filewatcher"
"golang.org/x/tools/gopls/internal/mcp"
"golang.org/x/tools/gopls/internal/protocol"
)
type headlessMCP struct {
app *Application
Address string `flag:"listen" help:"the address on which to run the mcp server"`
Logfile string `flag:"logfile" help:"filename to log to; if unset, logs to stderr"`
RPCTrace bool `flag:"rpc.trace" help:"print MCP rpc traces; cannot be used with -listen"`
Instructions bool `flag:"instructions" help:"if set, print gopls' MCP instructions and exit"`
}
func (m *headlessMCP) Name() string { return "mcp" }
func (m *headlessMCP) Parent() string { return m.app.Name() }
func (m *headlessMCP) Usage() string { return "[mcp-flags]" }
func (m *headlessMCP) ShortHelp() string { return "start the gopls MCP server in headless mode" }
func (m *headlessMCP) DetailedHelp(f *flag.FlagSet) {
fmt.Fprint(f.Output(), `
Starts the gopls MCP server in headless mode, without needing an LSP client.
Starts the server over stdio or sse with http, depending on whether the listen flag is provided.
Examples:
$ gopls mcp -listen=localhost:3000
$ gopls mcp //start over stdio
`)
printFlagDefaults(f)
}
func (m *headlessMCP) Run(ctx context.Context, args ...string) error {
if m.Instructions {
fmt.Println(mcp.Instructions)
return nil
}
if m.Address != "" && m.RPCTrace {
// There's currently no way to plumb logging instrumentation into the SSE
// transport that is created on connections to the HTTP handler, so we must
// disallow the -rpc.trace flag when using -listen.
return fmt.Errorf("-listen is incompatible with -rpc.trace")
}
if m.Logfile != "" {
f, err := os.Create(m.Logfile)
if err != nil {
return fmt.Errorf("opening logfile: %v", err)
}
log.SetOutput(f)
defer f.Close()
}
// Start a new in-process gopls session and create a fake client
// to connect to it.
cli, sess, err := m.app.connect(ctx)
if err != nil {
return err
}
defer cli.terminate(ctx)
var (
queueMu sync.Mutex
queue []protocol.FileEvent
nonempty = make(chan struct{}) // receivable when len(queue) > 0
stop = make(chan struct{}) // closed when Run returns
)
defer close(stop)
// This goroutine forwards file change events to the LSP server.
go func() {
for {
select {
case <-stop:
return
case <-nonempty:
queueMu.Lock()
q := queue
queue = nil
queueMu.Unlock()
if len(q) > 0 {
if err := cli.server.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{
Changes: q,
}); err != nil {
log.Printf("failed to notify changed files: %v", err)
}
}
}
}
}()
w, err := filewatcher.New(500*time.Millisecond, nil, func(events []protocol.FileEvent, err error) {
if err != nil {
log.Printf("watch error: %v", err)
return
}
if len(events) == 0 {
return
}
// Since there is no promise [protocol.Server.DidChangeWatchedFiles]
// will return immediately, we should buffer the captured events and
// sent them whenever available in a separate go routine.
queueMu.Lock()
queue = append(queue, events...)
queueMu.Unlock()
select {
case nonempty <- struct{}{}:
default:
}
})
if err != nil {
return err
}
defer w.Close()
// TODO(hxjiang): replace this with LSP initial param workspace root.
dir, err := os.Getwd()
if err != nil {
return err
}
if err := w.WatchDir(dir); err != nil {
return err
}
if m.Address != "" {
countHeadlessMCPSSE.Inc()
return mcp.Serve(ctx, m.Address, &staticSessions{sess, cli.server}, false)
} else {
countHeadlessMCPStdIO.Inc()
var rpcLog io.Writer
if m.RPCTrace {
rpcLog = log.Writer() // possibly redirected by -logfile above
}
log.Printf("Listening for MCP messages on stdin...")
return mcp.StartStdIO(ctx, sess, cli.server, rpcLog)
}
}
// staticSessions implements the [mcp.Sessions] interface for a single gopls
// session.
type staticSessions struct {
session *cache.Session
server protocol.Server
}
func (s *staticSessions) SetSessionExitFunc(func(string)) {}
func (s *staticSessions) FirstSession() (*cache.Session, protocol.Server) {
return s.session, s.server
}
func (s *staticSessions) Session(id string) (*cache.Session, protocol.Server) {
if s.session.ID() == id {
return s.session, s.server
}
return nil, nil
}