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
472 lines
13 KiB
Go
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,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|