Files
zzet--gortex/internal/mcp/tools_query_project.go
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

87 lines
2.7 KiB
Go

package mcp
import (
"context"
"fmt"
"sort"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/query"
)
// handleQueryProject runs a read-only symbol search against another
// project (or a bare tracked-repo prefix) without switching the active
// project. It deliberately bypasses the session-workspace clamp — this
// is the sanctioned cross-project read — and never mutates the active
// project or persists config.
func (s *Server) handleQueryProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
project, err := req.RequireString("project")
if err != nil {
return mcp.NewToolResultError("project is required"), nil
}
q, err := req.RequireString("query")
if err != nil {
return mcp.NewToolResultError("query is required"), nil
}
if s.configManager == nil {
return mcp.NewToolResultError("multi-project configuration is not available"), nil
}
limit := req.GetInt("limit", 20)
if limit <= 0 {
limit = 20
}
gc := s.configManager.Global()
// Resolve the target as a project name / per-repo project tag, or
// fall back to treating it as a bare tracked-repo prefix.
var prefixes []string
if repos, rErr := gc.ResolveRepos(project); rErr == nil {
for _, r := range repos {
prefixes = append(prefixes, config.ResolvePrefix(r))
}
} else if prefix := s.resolveRepoPrefix(project); prefix != "" {
prefixes = []string{prefix}
}
if len(prefixes) == 0 {
available := make([]string, 0, len(gc.Projects))
for name := range gc.Projects {
available = append(available, name)
}
sort.Strings(available)
return NewStructuredErrorResult(StructuredError{
ErrorCode: ErrCodeProjectUnknown,
Message: fmt.Sprintf("no project or tracked repo named %q", project),
Data: map[string]any{"project": project, "available_projects": available},
}), nil
}
allowed := make(map[string]bool, len(prefixes))
for _, p := range prefixes {
allowed[p] = true
}
// Search the base graph unscoped, then confine to the target's
// repos. Using s.engine (not engineFor) keeps this off the session
// overlay and clamp — query_project is an explicit cross-project read.
nodes := s.engine.SearchSymbolsScoped(q, limit*5, query.QueryOptions{})
nodes = filterNodes(nodes, allowed)
if len(nodes) > limit {
nodes = nodes[:limit]
}
nodes = s.withAbsPaths(nodes)
results := make([]map[string]any, 0, len(nodes))
for _, n := range nodes {
results = append(results, n.Brief())
}
sort.Strings(prefixes)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"project": project,
"query": q,
"repos": prefixes,
"total": len(results),
"results": results,
})
}