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: or scope:`, } 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 }