Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

472 lines
13 KiB
Go

package mcp
import (
"context"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
func (s *Server) registerPrompts() {
s.mcpServer.AddPrompt(
mcp.NewPrompt("pre_commit",
mcp.WithPromptDescription("Review uncommitted changes before committing. Shows changed symbols, blast radius, risk level, affected tests, and run commands."),
mcp.WithArgument("scope",
mcp.ArgumentDescription("Git diff scope: unstaged (default), staged, all, or compare"),
),
mcp.WithArgument("repo",
mcp.ArgumentDescription("Repository prefix or path (multi-repo mode); defaults to the lone tracked repo or the session's cwd-bound repo"),
),
),
s.handlePromptPreCommit,
)
s.mcpServer.AddPrompt(
mcp.NewPrompt("orientation",
mcp.WithPromptDescription("Orient in an unfamiliar codebase. Shows graph stats, functional communities, execution flows, and key symbols."),
),
s.handlePromptOrientation,
)
s.mcpServer.AddPrompt(
mcp.NewPrompt("safe_to_change",
mcp.WithPromptDescription("Analyze whether it's safe to change specific symbols. Shows blast radius, edit plan, affected tests, and risk level."),
mcp.WithArgument("ids",
mcp.ArgumentDescription("Comma-separated symbol IDs to analyze"),
mcp.RequiredArgument(),
),
),
s.handlePromptSafeToChange,
)
}
// promptArg reads a prompts/get argument defensively. mcp-go leaves
// Params.Arguments nil when the client sends a request with no
// `arguments` object; reading a nil map is safe in Go, but routing
// every access through this helper makes the empty-args contract
// explicit — and trims surrounding whitespace — rather than relying
// on library behaviour that a future mcp-go bump could change.
func promptArg(req mcp.GetPromptRequest, key string) string {
if req.Params.Arguments == nil {
return ""
}
return strings.TrimSpace(req.Params.Arguments[key])
}
// validPreCommitScopes are the git-diff scopes handlePromptPreCommit
// accepts. An empty or unrecognised scope falls back to "all" rather
// than reaching analysis.MapGitDiff with junk.
var validPreCommitScopes = map[string]bool{
"unstaged": true,
"staged": true,
"all": true,
"compare": true,
}
func (s *Server) handlePromptPreCommit(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
scope := promptArg(req, "scope")
if !validPreCommitScopes[scope] {
scope = "all"
}
// Resolve the working tree: explicit repo argument, lone tracked repo,
// or the session's cwd-bound repo. The "." fallback keeps the standalone
// (indexer-less) server working from its own cwd.
repoRoot, repoPrefix := s.diffRepoScope(ctx, promptArg(req, "repo"))
if repoRoot == "" {
repoRoot = "."
}
diff, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, "main")
if err != nil {
return promptError("Could not analyze git changes: " + err.Error()), nil
}
var b strings.Builder
b.WriteString("## Pre-Commit Review\n\n")
if len(diff.ChangedSymbols) == 0 {
fmt.Fprintf(&b, "No indexed symbols affected by current changes.\n\n")
fmt.Fprintf(&b, "Changed files: %d\n", len(diff.ChangedFiles))
return promptResult("Pre-commit review: no symbols affected", b.String()), nil
}
symbolIDs := make([]string, len(diff.ChangedSymbols))
for i, cs := range diff.ChangedSymbols {
symbolIDs[i] = cs.ID
}
impact := analysis.AnalyzeImpact(s.graph, symbolIDs, s.getCommunities(), s.getProcesses())
fmt.Fprintf(&b, "**Risk: %s**\n\n", impact.Risk)
fmt.Fprintf(&b, "Changed: %d symbols in %d files\n\n", len(diff.ChangedSymbols), len(diff.ChangedFiles))
b.WriteString("### Changed Symbols\n")
for _, cs := range diff.ChangedSymbols {
fmt.Fprintf(&b, "- `%s` (%s)\n", cs.ID, cs.Kind)
}
if len(impact.ByDepth) > 0 {
b.WriteString("\n### Blast Radius\n")
for depth, entries := range impact.ByDepth {
var label string
switch depth {
case 1:
label = "WILL BREAK"
case 2:
label = "LIKELY AFFECTED"
default:
label = "MAY NEED TESTING"
}
fmt.Fprintf(&b, "\n**d=%d (%s):**\n", depth, label)
for _, entry := range entries {
fmt.Fprintf(&b, "- `%s`\n", entry.ID)
}
}
}
testFiles := s.findTestFiles(ctx, symbolIDs)
if len(testFiles) > 0 {
b.WriteString("\n### Tests to Run\n")
for file, names := range testFiles {
fmt.Fprintf(&b, "- `%s`", file)
if len(names) > 0 && strings.HasSuffix(file, "_test.go") {
dir := file[:strings.LastIndex(file, "/")]
fmt.Fprintf(&b, "\n ```\n go test -run %s ./%s/\n ```", strings.Join(names, "|"), dir)
}
b.WriteString("\n")
}
}
if len(impact.AffectedProcesses) > 0 {
b.WriteString("\n### Affected Processes\n")
for _, p := range impact.AffectedProcesses {
fmt.Fprintf(&b, "- %s\n", p)
}
}
return promptResult("Pre-commit review: "+string(impact.Risk), b.String()), nil
}
func (s *Server) handlePromptOrientation(ctx context.Context, _ mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
// Confine the orientation to the session's workspace. scopedNodes
// returns every node for an unbound session, so the prompt is
// byte-identical to the legacy global view there. inScope is the
// node-ID set used to bound the analyzer-driven sections; nil for
// an unbound session.
scoped := s.scopedNodes(ctx)
_, _, bound := s.sessionScope(ctx)
var inScope map[string]bool
if bound {
inScope = make(map[string]bool, len(scoped))
for _, n := range scoped {
inScope[n.ID] = true
}
}
byLang := make(map[string]int)
byKind := make(map[graph.NodeKind]int)
for _, n := range scoped {
if n.Language != "" {
byLang[n.Language]++
}
byKind[n.Kind]++
}
totalEdges := 0
for _, e := range s.graph.AllEdges() {
if inScope != nil && (!inScope[e.From] || !inScope[e.To]) {
continue
}
totalEdges++
}
var b strings.Builder
b.WriteString("## Codebase Orientation\n\n")
fmt.Fprintf(&b, "**%d nodes, %d edges**\n\n", len(scoped), totalEdges)
if len(byLang) > 0 {
b.WriteString("### Languages\n")
for lang, count := range byLang {
fmt.Fprintf(&b, "- %s: %d nodes\n", lang, count)
}
b.WriteString("\n")
}
if len(byKind) > 0 {
b.WriteString("### Symbol Breakdown\n")
for kind, count := range byKind {
fmt.Fprintf(&b, "- %s: %d\n", kind, count)
}
b.WriteString("\n")
}
comms := s.getCommunities()
if comms != nil && len(comms.Communities) > 0 {
var shown []analysis.Community
for _, c := range comms.Communities {
if inScope == nil {
shown = append(shown, c)
continue
}
for _, m := range c.Members {
if inScope[m] {
shown = append(shown, c)
break
}
}
}
if len(shown) > 0 {
b.WriteString("### Functional Areas (Communities)\n")
for _, c := range shown {
fmt.Fprintf(&b, "- **%s** — %d symbols, %d files (cohesion: %.2f)\n",
c.Label, c.Size, len(c.Files), c.Cohesion)
}
b.WriteString("\n")
}
}
procs := s.getProcesses()
if procs != nil && len(procs.Processes) > 0 {
// A process is in scope when its entry point is — entry points
// are real symbol IDs and must not leak across the boundary.
var shown []analysis.Process
for _, p := range procs.Processes {
if inScope == nil || inScope[p.EntryPoint] {
shown = append(shown, p)
}
}
if len(shown) > 0 {
b.WriteString("### Execution Flows (Processes)\n")
limit := len(shown)
if limit > 10 {
limit = 10
}
for _, p := range shown[:limit] {
fmt.Fprintf(&b, "- **%s** — %d steps, entry: `%s`\n",
p.Name, p.StepCount, p.EntryPoint)
}
if len(shown) > 10 {
fmt.Fprintf(&b, "- ... and %d more\n", len(shown)-10)
}
b.WriteString("\n")
}
}
topRefs := s.findTopReferenced(ctx, 10)
if len(topRefs) > 0 {
b.WriteString("### Most-Referenced Symbols\n")
for _, r := range topRefs {
fmt.Fprintf(&b, "- `%s` — %d references\n", r.id, r.count)
}
b.WriteString("\n")
}
b.WriteString("Use `/gortex-explore` for guided architecture exploration.\n")
return promptResult("Codebase orientation", b.String()), nil
}
func (s *Server) handlePromptSafeToChange(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
idsStr := promptArg(req, "ids")
if idsStr == "" {
return promptError("ids argument is required"), nil
}
ids := strings.Split(idsStr, ",")
for i := range ids {
ids[i] = strings.TrimSpace(ids[i])
}
sessWS, _, _ := s.sessionScope(ctx)
// A symbol outside the session's workspace is treated as not found
// — the prompt must not reveal cross-workspace symbols.
var validIDs, notFound []string
for _, id := range ids {
if n := s.engineFor(ctx).GetSymbol(id); n != nil && s.nodeInSessionScope(ctx, n) {
validIDs = append(validIDs, id)
} else {
notFound = append(notFound, id)
}
}
var b strings.Builder
b.WriteString("## Safety Analysis\n\n")
if len(notFound) > 0 {
fmt.Fprintf(&b, "**Warning:** symbols not found: %s\n\n", strings.Join(notFound, ", "))
}
if len(validIDs) == 0 {
return promptError("No valid symbols found"), nil
}
impact := analysis.AnalyzeImpact(s.graph, validIDs, s.getCommunities(), s.getProcesses())
fmt.Fprintf(&b, "**Risk: %s**\n\n", impact.Risk)
fmt.Fprintf(&b, "Symbols to change: %s\n\n", strings.Join(validIDs, ", "))
if len(impact.ByDepth) > 0 {
b.WriteString("### Blast Radius\n")
for depth, entries := range impact.ByDepth {
var label string
switch depth {
case 1:
label = "WILL BREAK"
case 2:
label = "LIKELY AFFECTED"
default:
label = "MAY NEED TESTING"
}
fmt.Fprintf(&b, "\n**d=%d (%s):**\n", depth, label)
for _, entry := range entries {
fmt.Fprintf(&b, "- `%s`\n", entry.ID)
}
}
b.WriteString("\n")
}
b.WriteString("### Edit Order\n")
for _, id := range validIDs {
node := s.engineFor(ctx).GetSymbol(id)
if node == nil {
continue
}
fmt.Fprintf(&b, "1. `%s` — definition\n", node.FilePath)
if node.Kind == graph.KindInterface {
impls := s.scopedNodeSlice(ctx, s.engineFor(ctx).FindImplementations(id))
for _, impl := range impls {
fmt.Fprintf(&b, "2. `%s` — implements %s\n", impl.FilePath, node.Name)
}
}
dependents := s.engineFor(ctx).GetDependents(id, query.QueryOptions{Depth: 2, Limit: 20, Detail: "brief", WorkspaceID: sessWS})
depFiles := make(map[string]bool)
for _, dn := range dependents.Nodes {
if dn.Kind != graph.KindFile && dn.FilePath != node.FilePath && !isTestFile(dn.FilePath) && !depFiles[dn.FilePath] {
depFiles[dn.FilePath] = true
fmt.Fprintf(&b, "3. `%s` — dependent\n", dn.FilePath)
}
}
}
testFiles := s.findTestFiles(ctx, validIDs)
if len(testFiles) > 0 {
b.WriteString("\n### Tests to Verify\n")
for file := range testFiles {
fmt.Fprintf(&b, "- `%s`\n", file)
}
} else {
b.WriteString("\n**Warning:** No test coverage found for these symbols.\n")
}
if len(impact.AffectedProcesses) > 0 {
b.WriteString("\n### Affected Processes\n")
for _, p := range impact.AffectedProcesses {
fmt.Fprintf(&b, "- %s\n", p)
}
}
return promptResult("Safety analysis: "+string(impact.Risk), b.String()), nil
}
// findTestFiles traces callers of the given symbols and returns test files with function names.
// ctx scopes graph reads to the caller's overlay view (when present)
// so an editor's unsaved test edits surface in the impact summary.
func (s *Server) findTestFiles(ctx context.Context, symbolIDs []string) map[string][]string {
eng := s.engineFor(ctx)
testFiles := make(map[string]map[string]bool)
for _, id := range symbolIDs {
callers := eng.GetCallers(id, query.QueryOptions{Depth: 3, Limit: 50, Detail: "brief"})
for _, cn := range callers.Nodes {
if isTestFile(cn.FilePath) {
if testFiles[cn.FilePath] == nil {
testFiles[cn.FilePath] = make(map[string]bool)
}
if cn.Kind == graph.KindFunction || cn.Kind == graph.KindMethod {
testFiles[cn.FilePath][cn.Name] = true
}
}
}
}
result := make(map[string][]string)
for file, funcs := range testFiles {
var names []string
for n := range funcs {
names = append(names, n)
}
result[file] = names
}
return result
}
type refEntry struct {
id string
count int
}
// findTopReferenced returns the most-referenced symbols, confined to
// the current session's workspace.
func (s *Server) findTopReferenced(ctx context.Context, limit int) []refEntry {
allNodes := s.scopedNodes(ctx)
var refs []refEntry
for _, n := range allNodes {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport || n.Kind == graph.KindVariable {
continue
}
count := 0
for _, e := range s.engineFor(ctx).GetInEdges(n.ID) {
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
count++
}
}
if count >= 3 {
refs = append(refs, refEntry{n.ID, count})
}
}
for i := 0; i < len(refs); i++ {
for j := i + 1; j < len(refs); j++ {
if refs[j].count > refs[i].count {
refs[i], refs[j] = refs[j], refs[i]
}
}
}
if len(refs) > limit {
refs = refs[:limit]
}
return refs
}
func promptResult(description, text string) *mcp.GetPromptResult {
return &mcp.GetPromptResult{
Description: description,
Messages: []mcp.PromptMessage{
{
Role: mcp.RoleUser,
Content: mcp.TextContent{
Type: "text",
Text: text,
},
},
},
}
}
func promptError(msg string) *mcp.GetPromptResult {
return &mcp.GetPromptResult{
Description: "Error",
Messages: []mcp.PromptMessage{
{
Role: mcp.RoleUser,
Content: mcp.TextContent{
Type: "text",
Text: "Error: " + msg,
},
},
},
}
}