46577a6e3e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
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"`
|
|
Entity string `json:"entity,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"`
|
|
Version string `json:"version,omitempty"`
|
|
Signature string `json:"signature,omitempty"`
|
|
Description string `json:"description"`
|
|
Algebraic string `json:"algebraic,omitempty"`
|
|
Entity string `json:"entity"` // "function" | "type" | "module"
|
|
}
|
|
|
|
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.WithString("entity",
|
|
mcp.Description("Restrict search to a single entity type: functions | types | modules. Default: functions + types."),
|
|
mcp.Enum("functions", "types", "modules"),
|
|
),
|
|
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)
|
|
|
|
var hits []searchHit
|
|
|
|
// Modules-only path.
|
|
if args.Entity == "modules" {
|
|
mods, err := d.db.SearchModules(q, args.Lang)
|
|
if err != nil {
|
|
return mcp.NewToolResultError("search modules: " + err.Error()), nil
|
|
}
|
|
for _, m := range mods {
|
|
if !matchAllTags(m.Tags, tagFilters) {
|
|
continue
|
|
}
|
|
hits = append(hits, searchHit{
|
|
ID: m.ID,
|
|
Name: m.Name,
|
|
Lang: m.Lang,
|
|
Version: m.Version,
|
|
Description: m.Description,
|
|
Entity: "module",
|
|
})
|
|
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
|
|
}
|
|
|
|
// Default path: functions + types (optionally filtered to one of them).
|
|
if args.Entity == "" || args.Entity == "functions" {
|
|
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
|
|
}
|
|
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: when no kind filter (kind applies only to functions) and entity allows.
|
|
if args.Kind == "" && len(hits) < limit && (args.Entity == "" || args.Entity == "types") {
|
|
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
|
|
}
|
|
|
|
// matchAllTags returns true if every tag in want is present in have.
|
|
// Empty want matches everything.
|
|
func matchAllTags(have, want []string) bool {
|
|
if len(want) == 0 {
|
|
return true
|
|
}
|
|
set := make(map[string]bool, len(have))
|
|
for _, t := range have {
|
|
set[t] = true
|
|
}
|
|
for _, w := range want {
|
|
if !set[w] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|