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

179 lines
6.8 KiB
Go

package mcp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/gitcmd"
)
// registerCitationTools wires verify_citation — the substring-grounded
// citation gate. Pairs with the gortex-pr-review skill's "did the
// agent's quoted spans actually exist at the SHA they claim?" step;
// can also run as a Stop-hook validator that catches hallucinated
// quotes before they reach the user.
func (s *Server) registerCitationTools() {
s.addTool(
mcp.NewTool("verify_citation",
mcp.WithDescription("Verifies that a cited code span actually exists in `file_path` at the given git `sha`. Runs `git show <sha>:<file_path>` and checks for the verbatim substring (whitespace-significant). Returns {verified, sha_resolved, file_path, span_first_line, match_count, error?}. Use as a Stop-hook auto-validator or before quoting code into PR comments / postmortems."),
mcp.WithString("span", mcp.Description("Verbatim code span the caller claims is in the file. Whitespace-sensitive — exactly as it should appear.")),
mcp.WithString("file_path", mcp.Description("Repo-relative or absolute path to the file at the cited SHA.")),
mcp.WithString("sha", mcp.Description("Git revision: full or abbreviated commit hash, branch, or tag. Defaults to HEAD.")),
mcp.WithString("repo", mcp.Description("(Multi-repo only) repo prefix. Resolves which working tree to run git in. Inferred from `file_path` when the path is repo-prefixed.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleVerifyCitation,
)
}
// handleVerifyCitation runs the substring check. Errors from git or
// missing files are reported in the `error` field with verified=false
// rather than as MCP errors — the caller wants the verdict, not an
// exception they have to catch.
func (s *Server) handleVerifyCitation(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
span := req.GetString("span", "")
if span == "" {
return mcp.NewToolResultError("verify_citation requires `span`"), nil
}
rawPath := strings.TrimSpace(req.GetString("file_path", ""))
if rawPath == "" {
return mcp.NewToolResultError("verify_citation requires `file_path`"), nil
}
sha := strings.TrimSpace(req.GetString("sha", ""))
if sha == "" {
sha = "HEAD"
}
// Resolve the working tree this file lives in. Multi-repo mode
// uses the repo prefix on `file_path`; single-repo mode uses the
// indexer root. The shared resolveFilePath already encodes both
// rules and rejects escapes.
absPath, relPath, err := s.resolveFilePath(rawPath)
if err != nil {
return s.respondJSONOrTOON(ctx, req, citationFailure(sha, rawPath, fmt.Sprintf("resolve path: %v", err)))
}
workTree := repoRootContaining(absPath)
if workTree == "" {
return s.respondJSONOrTOON(ctx, req, citationFailure(sha, rawPath, "file is not inside a git working tree"))
}
// `git show` uses tree-relative paths. We rewrite the relPath to
// be relative to the resolved git work tree — the indexer's relPath
// is relative to the indexer root, which may differ from the git
// root when the indexer is anchored on a subdirectory.
gitRelPath, gitRelErr := filepath.Rel(workTree, absPath)
if gitRelErr != nil {
return s.respondJSONOrTOON(ctx, req, citationFailure(sha, relPath, fmt.Sprintf("rel(%s,%s): %v", workTree, absPath, gitRelErr)))
}
// Expand abbreviated / symbolic refs to a full hex SHA so the
// caller can persist a stable citation. `git rev-parse --verify`
// fails loudly when the ref doesn't resolve, which we surface as
// verified=false rather than ambiguous output.
fullSHA, shaErr := runGit(ctx, workTree, "rev-parse", "--verify", sha+"^{commit}")
if shaErr != nil {
return s.respondJSONOrTOON(ctx, req, citationFailure(sha, relPath, fmt.Sprintf("rev-parse %s: %v", sha, shaErr)))
}
fullSHA = strings.TrimSpace(fullSHA)
content, showErr := runGit(ctx, workTree, "show", fullSHA+":"+filepath.ToSlash(gitRelPath))
if showErr != nil {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"verified": false,
"sha": sha,
"sha_resolved": fullSHA,
"file_path": relPath,
"span_first_line": 0,
"match_count": 0,
"error": fmt.Sprintf("git show: %v", showErr),
})
}
first, count := substringMatchInfo(content, span)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"verified": count > 0,
"sha": sha,
"sha_resolved": fullSHA,
"file_path": relPath,
"span_first_line": first,
"match_count": count,
})
}
// citationFailure builds the result shape used when we can't even
// execute the substring check (path resolution failed, not a git
// tree, sha doesn't parse). Keeps the wire shape stable for callers.
func citationFailure(sha, filePath, msg string) map[string]any {
return map[string]any{
"verified": false,
"sha": sha,
"sha_resolved": "",
"file_path": filePath,
"span_first_line": 0,
"match_count": 0,
"error": msg,
}
}
// repoRootContaining walks up from path until it finds a directory
// holding a `.git` entry (file or dir — submodules use a file).
// Returns "" if no enclosing git tree exists.
func repoRootContaining(path string) string {
dir := path
if info, err := os.Stat(path); err != nil || !info.IsDir() {
dir = filepath.Dir(path)
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// substringMatchInfo returns the 1-based line of the first match and
// the total number of matches. Whitespace and newline-sensitive — a
// cited span that spans multiple lines must match exactly.
func substringMatchInfo(haystack, needle string) (firstLine, count int) {
if needle == "" {
return 0, 0
}
idx := strings.Index(haystack, needle)
if idx < 0 {
return 0, 0
}
firstLine = 1 + strings.Count(haystack[:idx], "\n")
count = strings.Count(haystack, needle)
return firstLine, count
}
// runGit executes `git <args...>` with cwd=dir and returns raw stdout.
// Routed through gitcmd (the concurrency-gated chokepoint) so it shares
// the process-wide git limiter. Bounded by the caller's request context —
// git invocations are expected to complete in milliseconds, but we still
// cancel on session teardown; when the ctx carries no deadline we bound it
// at 30s. Run returns raw stdout (callers TrimSpace themselves).
func runGit(ctx context.Context, dir string, args ...string) (string, error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
}
out, err := gitcmd.Run(ctx, dir, args...)
if err != nil {
return "", err
}
return string(out), nil
}