blob: ae2b4707a0c3ce7103b49cdc07e23e778618d9f6 [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 mcp
// This file defines the "context" operation, which returns a summary of the
// specified package.
import (
"bytes"
"context"
"fmt"
"go/ast"
"go/token"
"slices"
"strings"
"golang.org/x/tools/gopls/internal/cache"
"golang.org/x/tools/gopls/internal/cache/metadata"
"golang.org/x/tools/gopls/internal/cache/parsego"
"golang.org/x/tools/gopls/internal/file"
"golang.org/x/tools/gopls/internal/golang"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/gopls/internal/util/astutil"
"golang.org/x/tools/internal/analysisinternal"
"golang.org/x/tools/internal/mcp"
)
type ContextParams struct {
File string `json:"file"`
}
func (h *handler) contextTool() *mcp.ServerTool {
return mcp.NewServerTool(
"go_context",
"Provide context for a region within a Go file",
h.contextHandler,
mcp.Input(
mcp.Property("file", mcp.Description("the absolute path to the file")),
),
)
}
func (h *handler) contextHandler(ctx context.Context, _ *mcp.ServerSession, params *mcp.CallToolParamsFor[ContextParams]) (*mcp.CallToolResultFor[any], error) {
fh, snapshot, release, err := h.fileOf(ctx, params.Arguments.File)
if err != nil {
return nil, err
}
defer release()
// TODO(hxjiang): support context for GoMod.
if snapshot.FileKind(fh) != file.Go {
return nil, fmt.Errorf("can't provide context for non-Go file")
}
pkg, pgf, err := golang.NarrowestPackageForFile(ctx, snapshot, fh.URI())
if err != nil {
return nil, err
}
var result strings.Builder
fmt.Fprintf(&result, "Current package %q (package %s):\n\n", pkg.Metadata().PkgPath, pkg.Metadata().Name)
// Write context of the current file.
{
fmt.Fprintf(&result, "%s (current file):\n", pgf.URI.Base())
result.WriteString("```go\n")
if err := writeFileSummary(ctx, snapshot, pgf.URI, &result, false, nil); err != nil {
return nil, err
}
result.WriteString("```\n\n")
}
// Write context of the rest of the files in the current package.
{
for _, file := range pkg.CompiledGoFiles() {
if file.URI == pgf.URI {
continue
}
fmt.Fprintf(&result, "%s:\n", file.URI.Base())
result.WriteString("```go\n")
if err := writeFileSummary(ctx, snapshot, file.URI, &result, false, nil); err != nil {
return nil, err
}
result.WriteString("```\n\n")
}
}
// Write dependencies context of current file.
if len(pgf.File.Imports) > 0 {
// Write import decls of the current file.
{
fmt.Fprintf(&result, "Current file %q contains this import declaration:\n", pgf.URI.Base())
result.WriteString("```go\n")
// Add all import decl to output including all floating comment by
// using GenDecl's start and end position.
for _, decl := range pgf.File.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT {
continue
}
text, err := pgf.NodeText(genDecl)
if err != nil {
return nil, err
}
result.Write(text)
result.WriteString("\n")
}
result.WriteString("```\n\n")
}
var toSummarize []*ast.ImportSpec
for _, spec := range pgf.File.Imports {
// Skip the standard library to reduce token usage, operating on
// the assumption that the LLM is already familiar with its
// symbols and documentation.
if analysisinternal.IsStdPackage(spec.Path.Value) {
continue
}
toSummarize = append(toSummarize, spec)
}
// Write summaries from imported packages.
if len(toSummarize) > 0 {
result.WriteString("The imported packages declare the following symbols:\n\n")
for _, spec := range toSummarize {
path := metadata.UnquoteImportPath(spec)
id := pkg.Metadata().DepsByImpPath[path]
if id == "" {
continue // ignore error
}
md := snapshot.Metadata(id)
if md == nil {
continue // ignore error
}
if summary := summarizePackage(ctx, snapshot, md); summary != "" {
result.WriteString(summary)
}
}
}
}
return textResult(result.String()), nil
}
func summarizePackage(ctx context.Context, snapshot *cache.Snapshot, md *metadata.Package) string {
var buf strings.Builder
fmt.Fprintf(&buf, "%q (package %s)\n", md.PkgPath, md.Name)
for _, f := range md.CompiledGoFiles {
fmt.Fprintf(&buf, "%s:\n", f.Base())
buf.WriteString("```go\n")
if err := writeFileSummary(ctx, snapshot, f, &buf, true, nil); err != nil {
return "" // ignore error
}
buf.WriteString("```\n\n")
}
return buf.String()
}
// writeFileSummary writes the file summary to the string builder based on
// the input file URI.
func writeFileSummary(ctx context.Context, snapshot *cache.Snapshot, f protocol.DocumentURI, out *strings.Builder, onlyExported bool, declsToSummarize map[string]bool) error {
fh, err := snapshot.ReadFile(ctx, f)
if err != nil {
return err
}
pgf, err := snapshot.ParseGo(ctx, fh, parsego.Full)
if err != nil {
return err
}
// If we're summarizing specific declarations, we don't need to copy the header.
if declsToSummarize == nil {
// Copy everything before the first non-import declaration:
// package decl, imports decl(s), and all comments (excluding copyright).
{
endPos := pgf.File.FileEnd
outerloop:
for _, decl := range pgf.File.Decls {
switch decl := decl.(type) {
case *ast.FuncDecl:
if decl.Doc != nil {
endPos = decl.Doc.Pos()
} else {
endPos = decl.Pos()
}
break outerloop
case *ast.GenDecl:
if decl.Tok == token.IMPORT {
continue
}
if decl.Doc != nil {
endPos = decl.Doc.Pos()
} else {
endPos = decl.Pos()
}
break outerloop
}
}
startPos := pgf.File.FileStart
if copyright := golang.CopyrightComment(pgf.File); copyright != nil {
startPos = copyright.End()
}
text, err := pgf.PosText(startPos, endPos)
if err != nil {
return err
}
out.Write(bytes.TrimSpace(text))
out.WriteString("\n\n")
}
}
// Write func decl and gen decl.
for _, decl := range pgf.File.Decls {
switch decl := decl.(type) {
case *ast.FuncDecl:
if declsToSummarize != nil {
if _, ok := declsToSummarize[decl.Name.Name]; !ok {
continue
}
}
if onlyExported {
if !decl.Name.IsExported() {
continue
}
if decl.Recv != nil && len(decl.Recv.List) > 0 {
_, rname, _ := astutil.UnpackRecv(decl.Recv.List[0].Type)
if !rname.IsExported() {
continue
}
}
}
// Write doc comment and func signature.
startPos := decl.Pos()
if decl.Doc != nil {
startPos = decl.Doc.Pos()
}
text, err := pgf.PosText(startPos, decl.Type.End())
if err != nil {
return err
}
out.Write(text)
out.WriteString("\n\n")
case *ast.GenDecl:
if decl.Tok == token.IMPORT {
continue
}
// If we are summarizing specific decls, check if any of them are in this GenDecl.
if declsToSummarize != nil {
found := false
for _, spec := range decl.Specs {
switch spec := spec.(type) {
case *ast.TypeSpec:
if _, ok := declsToSummarize[spec.Name.Name]; ok {
found = true
}
case *ast.ValueSpec:
for _, name := range spec.Names {
if _, ok := declsToSummarize[name.Name]; ok {
found = true
}
}
}
}
if !found {
continue
}
}
// Dump the entire GenDecl (exported or unexported)
// including doc comment without any filtering to the output.
if !onlyExported {
startPos := decl.Pos()
if decl.Doc != nil {
startPos = decl.Doc.Pos()
}
text, err := pgf.PosText(startPos, decl.End())
if err != nil {
return err
}
out.Write(text)
out.WriteString("\n")
continue
}
// Write only the GenDecl with exported identifier to the output.
var buf bytes.Buffer
if decl.Doc != nil {
text, err := pgf.NodeText(decl.Doc)
if err != nil {
return err
}
buf.Write(text)
buf.WriteString("\n")
}
buf.WriteString(decl.Tok.String() + " ")
if decl.Lparen.IsValid() {
buf.WriteString("(\n")
}
var anyExported bool
for _, spec := range decl.Specs {
// Captures the full byte range of the spec, including
// its associated doc comments and line comments.
// This range also covers any floating comments as these
// can be valuable for context. Like
// ```
// type foo struct { // floating comment.
// // floating comment.
//
// x int
// }
// ```
var startPos, endPos token.Pos
switch spec := spec.(type) {
case *ast.TypeSpec:
if declsToSummarize != nil {
if _, ok := declsToSummarize[spec.Name.Name]; !ok {
continue
}
}
// TODO(hxjiang): only keep the exported field of
// struct spec and exported method of interface spec.
if !spec.Name.IsExported() {
continue
}
anyExported = true
// Include preceding doc comment, if any.
if spec.Doc == nil {
startPos = spec.Pos()
} else {
startPos = spec.Doc.Pos()
}
// Include trailing line comment, if any.
if spec.Comment == nil {
endPos = spec.End()
} else {
endPos = spec.Comment.End()
}
case *ast.ValueSpec:
if declsToSummarize != nil {
found := false
for _, name := range spec.Names {
if _, ok := declsToSummarize[name.Name]; ok {
found = true
}
}
if !found {
continue
}
}
// TODO(hxjiang): only keep the exported identifier.
if !slices.ContainsFunc(spec.Names, (*ast.Ident).IsExported) {
continue
}
anyExported = true
if spec.Doc == nil {
startPos = spec.Pos()
} else {
startPos = spec.Doc.Pos()
}
if spec.Comment == nil {
endPos = spec.End()
} else {
endPos = spec.Comment.End()
}
}
indent, err := pgf.Indentation(startPos)
if err != nil {
return err
}
buf.WriteString(indent)
text, err := pgf.PosText(startPos, endPos)
if err != nil {
return err
}
buf.Write(text)
buf.WriteString("\n")
}
if decl.Lparen.IsValid() {
buf.WriteString(")\n")
}
// Only write the summary of the genDecl if there is
// any exported spec.
if anyExported {
out.Write(buf.Bytes())
out.WriteString("\n")
}
}
}
return nil
}