package mcp import ( "context" "os" "sort" "strings" mcp "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/artifacts" "github.com/zzet/gortex/internal/config" ) // maxArtifactContentBytes caps the file body get_artifact inlines. const maxArtifactContentBytes = 64 * 1024 // SetArtifacts installs the `.gortex.yaml::artifacts` manifest so the // search_artifacts / get_artifact tools can materialise it. Called by // the server / daemon entrypoint right after NewServer. func (s *Server) SetArtifacts(entries []config.ArtifactEntry) { s.artifactEntries = entries } // registerArtifactTools wires search_artifacts and get_artifact — the // MCP surface over the non-code knowledge manifest. func (s *Server) registerArtifactTools() { s.addTool( mcp.NewTool("search_artifacts", mcp.WithDescription("Search the `.gortex.yaml::artifacts` manifest — non-code knowledge files (DB schemas, API specs, infra configs, ADRs) tracked as first-class KindArtifact graph nodes. Each artifact carries a content hash and EdgeReferences edges to the symbols it mentions. Returns matching artifacts with {id, path, name, kind, size, content_hash, reference_count}. Use get_artifact to read one in full."), mcp.WithString("query", mcp.Description("Case-insensitive substring matched against the artifact name and path. Omit to list every artifact.")), mcp.WithString("kind", mcp.Description("Filter to one kind — schema | api | infra | doc.")), mcp.WithNumber("limit", mcp.Description("Cap the result set (default: 50).")), mcp.WithString("format", mcp.Description("Output format: json (default) or toon")), ), s.handleSearchArtifacts, ) s.addTool( mcp.NewTool("get_artifact", mcp.WithDescription("Read one manifest artifact in full — its metadata, the symbols it references, and its file content (capped at 64 KiB). Pass either id (artifact::) or path."), mcp.WithString("id", mcp.Description("Artifact node ID — artifact::. One of id / path is required.")), mcp.WithString("path", mcp.Description("Artifact file path. One of id / path is required.")), mcp.WithString("format", mcp.Description("Output format: json (default) or toon")), ), s.handleGetArtifact, ) } type artifactRow struct { ID string `json:"id"` Path string `json:"path"` Name string `json:"name"` Kind string `json:"kind"` Size int `json:"size"` Hash string `json:"content_hash"` References int `json:"reference_count"` } func (s *Server) handleSearchArtifacts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { query := strings.ToLower(strings.TrimSpace(req.GetString("query", ""))) kind := strings.ToLower(strings.TrimSpace(req.GetString("kind", ""))) limit := max(req.GetInt("limit", 50), 1) s.ensureArtifacts() list := s.snapshotArtifacts() rows := make([]artifactRow, 0, len(list)) for _, a := range list { if kind != "" && strings.ToLower(a.Kind) != kind { continue } if query != "" && !strings.Contains(strings.ToLower(a.Name), query) && !strings.Contains(strings.ToLower(a.Path), query) { continue } rows = append(rows, artifactRow{ ID: a.ID, Path: a.Path, Name: a.Name, Kind: a.Kind, Size: a.Size, Hash: a.ContentHash, References: len(a.References), }) } sort.Slice(rows, func(i, j int) bool { return rows[i].Path < rows[j].Path }) truncated := false if len(rows) > limit { rows = rows[:limit] truncated = true } return s.respondJSONOrTOON(ctx, req, map[string]any{ "artifacts": rows, "total": len(rows), "truncated": truncated, }) } func (s *Server) handleGetArtifact(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := strings.TrimSpace(req.GetString("id", "")) path := strings.TrimSpace(req.GetString("path", "")) if id == "" && path == "" { return mcp.NewToolResultError("one of id or path is required"), nil } s.ensureArtifacts() art, ok := s.findArtifact(id, path) if !ok { return mcp.NewToolResultError("artifact not found — search_artifacts lists the manifest"), nil } type refRow struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` File string `json:"file"` } refs := make([]refRow, 0, len(art.References)) for _, symID := range art.References { if n := s.graph.GetNode(symID); n != nil { refs = append(refs, refRow{ID: n.ID, Name: n.Name, Kind: string(n.Kind), File: n.FilePath}) } } result := map[string]any{ "id": art.ID, "path": art.Path, "name": art.Name, "kind": art.Kind, "size": art.Size, "content_hash": art.ContentHash, "references": refs, } if abs, _, err := s.resolveFilePath(art.Path); err == nil { if data, err := os.ReadFile(abs); err == nil { //nolint:gosec // path resolved from the indexed manifest content := data truncated := false if len(content) > maxArtifactContentBytes { content = content[:maxArtifactContentBytes] truncated = true } result["content"] = string(content) result["content_truncated"] = truncated } } return s.respondJSONOrTOON(ctx, req, result) } // findArtifact resolves an artifact by node ID or by path. A path is // matched exactly or as a suffix so a caller can pass either the // repo-relative or the graph (repo-prefixed) form. func (s *Server) findArtifact(id, path string) (artifacts.Artifact, bool) { for _, a := range s.snapshotArtifacts() { if id != "" && a.ID == id { return a, true } if path != "" && (a.Path == path || strings.HasSuffix(a.Path, "/"+path)) { return a, true } } return artifacts.Artifact{}, false } // ensureArtifacts materialises the manifest exactly once per daemon // lifetime. Safe for concurrent callers. func (s *Server) ensureArtifacts() { s.artifactsOnce.Do(s.materializeArtifacts) } func (s *Server) materializeArtifacts() { if len(s.artifactEntries) == 0 { return } var all []artifacts.Artifact for prefix, root := range s.collectRepoRoots("") { all = append(all, artifacts.Materialize(s.graph, root, s.artifactEntries, prefix)...) } s.artifactsMu.Lock() s.artifactList = all s.artifactsMu.Unlock() } func (s *Server) snapshotArtifacts() []artifacts.Artifact { s.artifactsMu.RLock() defer s.artifactsMu.RUnlock() return s.artifactList }