package main import ( "context" "encoding/json" "strings" "github.com/mark3labs/mcp-go/mcp" "fn-registry/registry" ) type searchArgs struct { Query string `json:"query"` Kind string `json:"kind,omitempty"` Lang string `json:"lang,omitempty"` Domain string `json:"domain,omitempty"` Purity string `json:"purity,omitempty"` Tag string `json:"tag,omitempty"` Tags string `json:"tags,omitempty"` Limit int `json:"limit,omitempty"` } type searchHit struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` Lang string `json:"lang"` Domain string `json:"domain"` Purity string `json:"purity"` Signature string `json:"signature,omitempty"` Description string `json:"description"` Algebraic string `json:"algebraic,omitempty"` Entity string `json:"entity"` // "function" | "type" } func searchTool() mcp.Tool { return mcp.NewTool("fn_search", mcp.WithDescription("Search the registry (functions + types) via FTS5. Free-text query with optional filters. Returns up to `limit` hits ordered by name. Use this BEFORE writing new code to avoid duplicates."), mcp.WithString("query", mcp.Required(), mcp.Description("FTS5 expression or free text. Examples: 'slice', 'name:filter*', 'description:\"single-page\"', 'sqlite OR fts5'."), ), mcp.WithString("kind", mcp.Description("Filter by kind: function, pipeline, component."), mcp.Enum("function", "pipeline", "component"), ), mcp.WithString("lang", mcp.Description("Filter by language: go, py, bash, ts, cpp, ps."), ), mcp.WithString("domain", mcp.Description("Filter by domain: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, ..."), ), mcp.WithString("purity", mcp.Description("Filter by purity (functions only)."), mcp.Enum("pure", "impure"), ), mcp.WithString("tag", mcp.Description("Filter by single tag (exact match against tags JSON array). Ej: 'notebook', 'metabase'."), ), mcp.WithString("tags", mcp.Description("Filter by multiple tags (CSV). AND across tags: all must be present. Ej: 'metabase,client'."), ), mcp.WithNumber("limit", mcp.Description("Max hits returned (default 50)."), mcp.Min(1), mcp.Max(500), ), ) } func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args searchArgs) (*mcp.CallToolResult, error) { limit := args.Limit if limit <= 0 { limit = 50 } q := sanitizeFTS5(args.Query) tagFilters := parseTagFilters(args.Tag, args.Tags) fns, err := d.db.SearchFunctions(q, registry.Kind(args.Kind), registry.Purity(args.Purity), args.Lang, args.Domain, tagFilters...) if err != nil { return mcp.NewToolResultError("search functions: " + err.Error()), nil } var hits []searchHit for _, f := range fns { hits = append(hits, searchHit{ ID: f.ID, Name: f.Name, Kind: string(f.Kind), Lang: f.Lang, Domain: f.Domain, Purity: string(f.Purity), Signature: f.Signature, Description: f.Description, Entity: "function", }) if len(hits) >= limit { break } } // Types: only when no kind filter (kind applies only to functions). if args.Kind == "" && len(hits) < limit { ts, err := d.db.SearchTypes(q, args.Lang, args.Domain, tagFilters...) if err != nil { return mcp.NewToolResultError("search types: " + err.Error()), nil } for _, t := range ts { hits = append(hits, searchHit{ ID: t.ID, Name: t.Name, Lang: t.Lang, Domain: t.Domain, Algebraic: string(t.Algebraic), Description: t.Description, Entity: "type", }) if len(hits) >= limit { break } } } out := map[string]any{ "query": args.Query, "count": len(hits), "limit": limit, "results": hits, } b, _ := json.MarshalIndent(out, "", " ") return mcp.NewToolResultText(string(b)), nil } // parseTagFilters merges single `tag` and CSV `tags` into a deduplicated slice. func parseTagFilters(tag, csv string) []string { seen := map[string]bool{} var out []string add := func(s string) { s = strings.TrimSpace(s) if s == "" || seen[s] { return } seen[s] = true out = append(out, s) } add(tag) for _, t := range strings.Split(csv, ",") { add(t) } return out }