diff --git a/format.go b/format.go index be104ab..37b1837 100644 --- a/format.go +++ b/format.go @@ -110,6 +110,41 @@ func renderTypeMarkdown(t *registry.Type) string { return b.String() } +// renderModuleMarkdown returns a markdown card for a Module. +func renderModuleMarkdown(m *registry.Module) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s (module)\n\n", m.ID) + fmt.Fprintf(&b, "- name: %s\n", m.Name) + fmt.Fprintf(&b, "- version: %s\n", m.Version) + fmt.Fprintf(&b, "- lang: %s\n", m.Lang) + if len(m.Members) > 0 { + fmt.Fprintf(&b, "- members: %s\n", strings.Join(m.Members, ", ")) + } + if len(m.Tags) > 0 { + fmt.Fprintf(&b, "- tags: %s\n", strings.Join(m.Tags, ", ")) + } + if m.DirPath != "" { + fmt.Fprintf(&b, "- dir_path: %s\n", m.DirPath) + } + if m.RepoURL != "" { + fmt.Fprintf(&b, "- repo_url: %s\n", m.RepoURL) + } + b.WriteString("\n") + + if m.Description != "" { + b.WriteString(m.Description) + b.WriteString("\n\n") + } + if m.Documentation != "" { + b.WriteString(m.Documentation) + b.WriteString("\n\n") + } + if m.Notes != "" { + fmt.Fprintf(&b, "\n## notes\n\n%s\n", m.Notes) + } + return b.String() +} + // langFence maps registry lang codes to markdown fence labels. func langFence(lang string) string { switch lang { diff --git a/tool_search.go b/tool_search.go index 1b7ab87..603762a 100644 --- a/tool_search.go +++ b/tool_search.go @@ -18,6 +18,7 @@ type searchArgs struct { 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"` } @@ -28,10 +29,11 @@ type searchHit struct { 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" + Entity string `json:"entity"` // "function" | "type" | "module" } func searchTool() mcp.Tool { @@ -61,6 +63,10 @@ func searchTool() mcp.Tool { 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), @@ -79,31 +85,66 @@ func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args sea 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 + + // 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 } - 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 + // 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: only when no kind filter (kind applies only to functions). - if args.Kind == "" && len(hits) < limit { + // 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 @@ -134,6 +175,24 @@ func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args sea 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{} diff --git a/tool_show.go b/tool_show.go index eef72bc..b2df2dc 100644 --- a/tool_show.go +++ b/tool_show.go @@ -37,5 +37,11 @@ func (d *deps) handleShow(ctx context.Context, _ mcp.CallToolRequest, args showA b, _ := json.MarshalIndent(out, "", " ") return mcp.NewToolResultText(string(b)), nil } + if m, err := d.db.GetModule(args.ID); err == nil { + md := truncate(renderModuleMarkdown(m), 50_000) + out := map[string]any{"id": m.ID, "entity": "module", "markdown": md} + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil + } return mcp.NewToolResultError("id not found: " + args.ID), nil }