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

452 lines
15 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"
)
type ResolvedScope struct {
WorkspaceID string
ProjectID string
RepoAllow map[string]bool
Applied string
}
type ToolIntent string
const (
IntentLocate ToolIntent = "locate"
IntentReach ToolIntent = "reach"
IntentAnalyze ToolIntent = "analyze"
)
func (s *Server) resolveScope(ctx context.Context, req mcp.CallToolRequest, intent ToolIntent) (ResolvedScope, *mcp.CallToolResult) {
scope, err := s.resolveScopeForRequest(ctx, req, intent)
if err != nil {
return ResolvedScope{}, mcp.NewToolResultError(err.Error())
}
return scope, nil
}
func (s *Server) resolveScopeForRequest(ctx context.Context, req mcp.CallToolRequest, intent ToolIntent) (ResolvedScope, error) {
intent = normalizeToolIntent(intent, req.Params.Name)
repo := strings.TrimSpace(req.GetString("repo", ""))
// The selector may be a filesystem path (the CLI defaults to the
// caller's working directory) -- normalize to the tracked prefix so
// the filter matches what the workspace knows the repo as.
if repo != "*" {
if p := s.resolveRepoPrefix(repo); p != "" {
repo = p
}
}
project := strings.TrimSpace(req.GetString("project", ""))
ref := strings.TrimSpace(req.GetString("ref", ""))
workspaceArg := strings.TrimSpace(req.GetString("workspace", ""))
scopeArg := strings.TrimSpace(req.GetString("scope", ""))
if gitDiffScopes[scopeArg] {
scopeArg = ""
}
explicitNarrowing := repo != "" || project != "" || ref != "" || workspaceArg != "" || scopeArg != ""
var scopeRepos map[string]bool
if scopeArg != "" && repo == "" && project == "" && ref == "" {
sc, ok := s.lookupScope(scopeArg)
if !ok {
return ResolvedScope{}, fmt.Errorf("unknown scope %q — run list_scopes to see saved scopes, or create one with save_scope", scopeArg)
}
scopeRepos = s.scopeRepoSet(sc)
if len(scopeRepos) == 0 {
return ResolvedScope{}, fmt.Errorf("saved scope %q names no repositories", scopeArg)
}
}
if sessWS, sessProj, bound := s.sessionScope(ctx); bound {
return s.resolveBoundSessionScope(ctx, sessWS, sessProj, workspaceArg, project, repo, ref, scopeArg, scopeRepos, intent, explicitNarrowing)
}
return s.resolveUnboundScope(ctx, workspaceArg, project, repo, ref, scopeArg, scopeRepos, intent, explicitNarrowing)
}
func normalizeToolIntent(intent ToolIntent, toolName string) ToolIntent {
switch intent {
case IntentLocate, IntentReach, IntentAnalyze:
return intent
default:
return toolIntentForName(toolName)
}
}
func (s *Server) resolveBoundSessionScope(ctx context.Context, sessWS, sessProj, workspaceArg, project, repo, ref, scopeArg string, scopeRepos map[string]bool, intent ToolIntent, explicitNarrowing bool) (ResolvedScope, error) {
resolved := ResolvedScope{
WorkspaceID: sessWS,
ProjectID: sessProj,
Applied: appliedProjectOrWorkspace(sessProj),
}
if project != "" {
resolved.ProjectID = project
resolved.Applied = "project:" + project
}
// A `workspace` arg may only name the session's own workspace. Any
// other value is a cross-workspace escape attempt -- reject it
// outright rather than silently honouring the boundary and
// returning a confusing empty result.
if workspaceArg != "" && workspaceArg != sessWS {
return ResolvedScope{}, fmt.Errorf(
"workspace %q is outside the active workspace %q; cross-workspace queries are not permitted",
workspaceArg, sessWS)
}
if workspaceArg != "" && project == "" && repo == "" && ref == "" && scopeRepos == nil {
resolved.ProjectID = ""
resolved.RepoAllow = nil
resolved.Applied = "workspace"
return resolved, nil
}
wsRepos := map[string]bool{}
if s.multiIndexer != nil {
wsRepos = s.multiIndexer.ReposInWorkspace(sessWS)
}
if !explicitNarrowing {
if s.scopeIntentDefaultsEnabled() {
return s.applyIntentDefault(ctx, resolved, intent), nil
}
// Layer-A compatibility: no explicit narrowing in a bound session
// stays on the session project, with the repo allow-set clamped to
// the whole workspace for handlers that filter by repo prefix.
resolved.RepoAllow = wsRepos
resolved.Applied = appliedProjectOrWorkspace(resolved.ProjectID)
return resolved, nil
}
// A named scope, intersected with the workspace so it can only ever
// narrow -- a scope is a convenience, never a clamp escape.
if scopeRepos != nil {
intersected := intersectRepoSets(scopeRepos, wsRepos)
if len(intersected) == 0 {
return ResolvedScope{}, fmt.Errorf(
"saved scope %q resolves to nothing inside the active workspace %q",
scopeArg, sessWS)
}
resolved.ProjectID = ""
resolved.RepoAllow = intersected
resolved.Applied = "scope:" + scopeArg
return resolved, nil
}
if repo == "*" && project == "" && ref == "" {
resolved.ProjectID = ""
resolved.RepoAllow = nil
resolved.Applied = "workspace"
return resolved, nil
}
if repo == "*" {
repo = ""
}
// Explicit narrowing: resolve the args, then intersect with the
// workspace so a repo/project/ref arg can never escape it.
narrowed, err := s.resolveRepoFilterArgs(repo, project, ref, false)
if err != nil {
return ResolvedScope{}, err
}
if narrowed == nil {
resolved.ProjectID = project
resolved.RepoAllow = nil
resolved.Applied = appliedForExplicit(project, repo, ref, nil)
return resolved, nil
}
intersected := intersectRepoSets(narrowed, wsRepos)
if len(intersected) == 0 {
return ResolvedScope{}, fmt.Errorf(
"repo/project/ref filter resolves to nothing inside the active workspace %q; cross-workspace queries are not permitted",
sessWS)
}
resolved.ProjectID = ""
if project != "" {
resolved.ProjectID = project
}
resolved.RepoAllow = intersected
resolved.Applied = appliedForExplicit(project, repo, ref, intersected)
return resolved, nil
}
func (s *Server) resolveUnboundScope(ctx context.Context, workspaceArg, project, repo, ref, scopeArg string, scopeRepos map[string]bool, intent ToolIntent, explicitNarrowing bool) (ResolvedScope, error) {
resolved := ResolvedScope{
WorkspaceID: s.scopeWorkspace,
ProjectID: s.scopeProject,
Applied: appliedProjectOrWorkspace(s.scopeProject),
}
if workspaceArg != "" {
resolved.WorkspaceID = workspaceArg
if project == "" {
resolved.ProjectID = ""
resolved.Applied = "workspace"
}
}
if project != "" {
resolved.ProjectID = project
resolved.Applied = "project:" + project
}
if !explicitNarrowing && s.scopeIntentDefaultsEnabled() {
return s.applyIntentDefault(ctx, resolved, intent), nil
}
if scopeRepos != nil {
resolved.ProjectID = ""
resolved.RepoAllow = scopeRepos
resolved.Applied = "scope:" + scopeArg
return resolved, nil
}
if repo == "*" && project == "" && ref == "" {
resolved.ProjectID = ""
resolved.RepoAllow = nil
resolved.Applied = "workspace"
return resolved, nil
}
if repo == "*" {
repo = ""
}
allowed, err := s.resolveRepoFilterArgs(repo, project, ref, !explicitNarrowing)
if err != nil {
return ResolvedScope{}, err
}
resolved.RepoAllow = allowed
if repo != "" || project != "" || ref != "" {
resolved.Applied = appliedForExplicit(project, repo, ref, allowed)
} else if allowed != nil {
resolved.Applied = appliedForExplicit(s.activeProject, "", "", allowed)
}
return resolved, nil
}
func (s *Server) scopeIntentDefaultsEnabled() bool {
if s == nil {
return true
}
return s.scopeIntentDefaults
}
func (s *Server) applyIntentDefault(ctx context.Context, resolved ResolvedScope, intent ToolIntent) ResolvedScope {
resolved.ProjectID = ""
resolved.RepoAllow = nil
resolved.Applied = "workspace"
if intent != IntentLocate {
return resolved
}
repo, _ := s.sessionLocality(ctx)
if repo == "" {
return resolved
}
resolved.RepoAllow = map[string]bool{repo: true}
resolved.Applied = "repo:" + repo
return resolved
}
func appliedProjectOrWorkspace(project string) string {
if project != "" {
return "project:" + project
}
return "workspace"
}
func appliedForExplicit(project, repo, ref string, allowed map[string]bool) string {
switch {
case repo != "" && repo != "*":
return "repo:" + repo
case project != "":
return "project:" + project
case ref != "":
return "ref:" + ref
case len(allowed) == 1:
for p := range allowed {
return "repo:" + p
}
case len(allowed) > 1:
return fmt.Sprintf("repos:%d", len(allowed))
}
return "workspace"
}
func scopeApplied(scope ResolvedScope) string {
if scope.Applied != "" {
return scope.Applied
}
if len(scope.RepoAllow) == 1 {
for repo := range scope.RepoAllow {
return "repo:" + repo
}
}
if len(scope.RepoAllow) > 1 {
return fmt.Sprintf("repos:%d", len(scope.RepoAllow))
}
return appliedProjectOrWorkspace(scope.ProjectID)
}
func decorateResultWithScope(res *mcp.CallToolResult, scope ResolvedScope) *mcp.CallToolResult {
if res == nil {
return nil
}
fields := map[string]any{
"scope_applied": scopeApplied(scope),
"scope_widen_hint": `to widen: pass repo:"*" or project:<name> or scope:<name>`,
}
if res.Meta == nil {
res.Meta = mcp.NewMetaFromMap(fields)
return res
}
if res.Meta.AdditionalFields == nil {
res.Meta.AdditionalFields = map[string]any{}
}
for k, v := range fields {
res.Meta.AdditionalFields[k] = v
}
return res
}
// scopeZeroNote is the body-visible variant of the scope disclosure for a
// Locate result that came back EMPTY while repo narrowing was active. The
// scope_applied / scope_widen_hint fields ride in _meta, which CLI output
// and most MCP clients never render — without this note a scope-narrowed
// zero is indistinguishable from "not in the graph". outside is the number
// of candidates a workspace-wide recheck found beyond the narrowed scope;
// pass -1 when no recheck ran.
func scopeZeroNote(scope ResolvedScope, outside int) string {
label := scopeApplied(scope)
switch {
case outside > 0:
return fmt.Sprintf("0 results within the active scope (%s); %d candidate(s) match outside it — widen with repo:\"*\" or pass an explicit repo:/project:", label, outside)
case outside == 0:
return fmt.Sprintf("0 results within the active scope (%s); a workspace-wide recheck also found nothing", label)
default:
return fmt.Sprintf("0 results within the active scope (%s) — widen with repo:\"*\" or pass an explicit repo:/project:", label)
}
}
func withScopeResult(res *mcp.CallToolResult, err error, scope ResolvedScope) (*mcp.CallToolResult, error) {
if err != nil {
return res, err
}
return decorateResultWithScope(res, scope), nil
}
func (s *Server) respondScopedJSONOrTOON(ctx context.Context, req mcp.CallToolRequest, payload any, scope ResolvedScope) (*mcp.CallToolResult, error) {
res, err := s.respondJSONOrTOON(ctx, req, payload)
return withScopeResult(res, err, scope)
}
func (s *Server) returnScopedSubGraph(ctx context.Context, req mcp.CallToolRequest, sg *query.SubGraph, scope ResolvedScope) (*mcp.CallToolResult, error) {
res, err := s.returnSubGraph(ctx, req, sg)
return withScopeResult(res, err, scope)
}
func intersectRepoSets(a, b map[string]bool) map[string]bool {
out := make(map[string]bool)
for p := range a {
if b[p] {
out[p] = true
}
}
return out
}
// sessionWorkspaceRepoSet returns the set of repo prefixes inside the
// current session's workspace and whether the session is workspace-
// bound. An unbound session (no cwd / no multi-repo indexer) returns
// (nil, false) — its callers must not clamp. This is the workspace
// ceiling for the analyze kinds that run a global graph algorithm and
// cannot honour the repo allow-set in v1.
func (s *Server) sessionWorkspaceRepoSet(ctx context.Context) (map[string]bool, bool) {
sessWS, _, bound := s.sessionScope(ctx)
if !bound || s.multiIndexer == nil {
return nil, false
}
return s.multiIndexer.ReposInWorkspace(sessWS), true
}
// communitiesInSessionScope returns a workspace-clamped copy of cr for a
// workspace-bound session: each community keeps only the members / files
// inside the session workspace, and communities left with no in-workspace
// member are dropped. Community detection runs over the whole index (one
// shared partition), so without this the community analyze kinds
// (clusters / concepts / suggest_boundaries) would surface clusters from
// sibling workspaces — a breach of the workspace isolation boundary. The
// cached partition is never mutated: the copy gets fresh member / file
// slices. An unbound session gets cr back unchanged.
func (s *Server) communitiesInSessionScope(ctx context.Context, cr *analysis.CommunityResult) *analysis.CommunityResult {
wsRepos, bound := s.sessionWorkspaceRepoSet(ctx)
if !bound || len(wsRepos) == 0 || cr == nil {
return cr
}
out := *cr
out.Communities = make([]analysis.Community, 0, len(cr.Communities))
for _, c := range cr.Communities {
members := make([]string, 0, len(c.Members))
for _, m := range c.Members {
if wsRepos[graph.RepoPrefixOfID(m)] {
members = append(members, m)
}
}
if len(members) == 0 {
continue
}
files := make([]string, 0, len(c.Files))
for _, f := range c.Files {
if wsRepos[graph.RepoPrefixOfID(f)] {
files = append(files, f)
}
}
c.Members = members
c.Files = files
c.Size = len(members)
out.Communities = append(out.Communities, c)
}
return &out
}
// scopeNarrowingRequested reports whether the caller passed an explicit
// repo / project / scope / ref narrowing arg (other than the "*"
// all-repos escape hatch).
func scopeNarrowingRequested(req mcp.CallToolRequest) bool {
if repo := strings.TrimSpace(req.GetString("repo", "")); repo != "" && repo != "*" {
return true
}
return strings.TrimSpace(req.GetString("project", "")) != "" ||
strings.TrimSpace(req.GetString("scope", "")) != "" ||
strings.TrimSpace(req.GetString("ref", "")) != ""
}
// workspaceScopeBlock returns a body-visible disclosure of the scope a
// not-repo-narrowed analyze kind actually applied: it honours the session
// workspace boundary but cannot apply a repo / project narrow in v1.
// Unlike the _meta scope_note (which several clients, Claude Code among
// them, do not surface), this rides in the response payload so an agent
// always sees it. Returns nil for an unbound session — nothing to
// disclose, and the result was never clamped.
func (s *Server) workspaceScopeBlock(ctx context.Context, req mcp.CallToolRequest, kind string) map[string]any {
sessWS, _, bound := s.sessionScope(ctx)
if !bound {
return nil
}
applied := "workspace"
if sessWS != "" {
applied = "workspace:" + sessWS
}
blk := map[string]any{"applied": applied}
if scopeNarrowingRequested(req) {
blk["note"] = "kind '" + kind + "' is clamped to the session workspace but is not repo/project-narrowed in v1; the requested narrowing was widened to the whole workspace"
}
return blk
}