diff --git a/integration_test.go b/integration_test.go index 2128643..91e3a79 100644 --- a/integration_test.go +++ b/integration_test.go @@ -199,3 +199,68 @@ func min(a, b int) int { } return b } + +func TestIntegration_SearchByTag(t *testing.T) { + root := findRegistryRoot(t) + p := newPipePair() + stop := startServer(t, root, p) + defer stop() + + br := bufio.NewReader(p.clientFromServer) + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "test", "version": "0"}, + }, + }) + _ = recvJSONUntilID(t, br, 1) + + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "method": "notifications/initialized", + }) + + // Single tag filter: 'notebook' must include the jupyter_* family. + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": map[string]any{ + "name": "fn_search", + "arguments": map[string]any{"query": "", "tag": "notebook", "limit": 20}, + }, + }) + msg := recvJSONUntilID(t, br, 2) + text := msg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "jupyter_discover_py_notebook") { + t.Errorf("tag=notebook should return jupyter_discover_py_notebook:\n%s", text[:min(800, len(text))]) + } + + // CSV tags: AND across — narrower result. + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": map[string]any{ + "name": "fn_search", + "arguments": map[string]any{"query": "", "tags": "notebook,jupyter", "limit": 20}, + }, + }) + msg = recvJSONUntilID(t, br, 3) + text = msg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + // Should still match jupyter_* since they have both tags (if tagged). + // If 'jupyter' tag does not exist this just returns 0 — accept either as valid. + _ = text + + // Bogus tag: must return 0. + sendJSON(t, p.clientToServer, map[string]any{ + "jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": map[string]any{ + "name": "fn_search", + "arguments": map[string]any{"query": "", "tag": "this-tag-does-not-exist-xyz", "limit": 5}, + }, + }) + msg = recvJSONUntilID(t, br, 4) + text = msg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "\"count\": 0") { + t.Errorf("bogus tag should return count=0:\n%s", text[:min(400, len(text))]) + } +} diff --git a/main.go b/main.go index 4c56fbc..8b080bd 100644 --- a/main.go +++ b/main.go @@ -147,6 +147,7 @@ func registerTools(s *server.MCPServer, db *registry.DB, root string, cfg config s.AddTool(listDomainsTool(), mcp.NewTypedToolHandler(deps.handleListDomains)) s.AddTool(usesTool(), mcp.NewTypedToolHandler(deps.handleUses)) s.AddTool(doctorTool(), mcp.NewTypedToolHandler(deps.handleDoctor)) + s.AddTool(proposalTool(), mcp.NewTypedToolHandler(deps.handleProposal)) // Mutating tools — opt-in. if cfg.enableRun { diff --git a/tool_proposal.go b/tool_proposal.go new file mode 100644 index 0000000..32f94e0 --- /dev/null +++ b/tool_proposal.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" + + "fn-registry/registry" +) + +type proposalArgs struct { + ID string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Status string `json:"status,omitempty"` + Limit int `json:"limit,omitempty"` +} + +func proposalTool() mcp.Tool { + return mcp.NewTool("fn_proposal", + mcp.WithDescription("Read proposals from registry.db. With `id`, returns that proposal in full (including evidence). Without `id`, lists proposals filtered by `kind`/`status`, ordered by created_at DESC. Replaces `sqlite3 registry.db \"SELECT ... FROM proposals\"` inline queries."), + mcp.WithString("id", + mcp.Description("Proposal ID. If set, ignores kind/status filters and returns single record."), + ), + mcp.WithString("kind", + mcp.Description("Filter by kind: new_function, new_type, improve_function, improve_type, new_pipeline."), + mcp.Enum("new_function", "new_type", "improve_function", "improve_type", "new_pipeline"), + ), + mcp.WithString("status", + mcp.Description("Filter by status: pending, approved, rejected, implemented."), + mcp.Enum("pending", "approved", "rejected", "implemented"), + ), + mcp.WithNumber("limit", + mcp.Description("Max rows returned (default 100)."), + mcp.Min(1), + mcp.Max(1000), + ), + ) +} + +func (d *deps) handleProposal(ctx context.Context, _ mcp.CallToolRequest, args proposalArgs) (*mcp.CallToolResult, error) { + if args.ID != "" { + p, err := d.db.GetProposal(args.ID) + if err != nil { + return mcp.NewToolResultError("proposal not found: " + args.ID), nil + } + b, _ := json.MarshalIndent(p, "", " ") + return mcp.NewToolResultText(string(b)), nil + } + + limit := args.Limit + if limit <= 0 { + limit = 100 + } + + ps, err := d.db.ListProposals(registry.ProposalKind(args.Kind), registry.ProposalStatus(args.Status)) + if err != nil { + return mcp.NewToolResultError("list proposals: " + err.Error()), nil + } + + if len(ps) > limit { + ps = ps[:limit] + } + + out := map[string]any{ + "count": len(ps), + "limit": limit, + "kind": args.Kind, + "status": args.Status, + "proposals": ps, + } + b, _ := json.MarshalIndent(out, "", " ") + return mcp.NewToolResultText(string(b)), nil +} diff --git a/tool_search.go b/tool_search.go index 8950177..1b7ab87 100644 --- a/tool_search.go +++ b/tool_search.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "strings" "github.com/mark3labs/mcp-go/mcp" @@ -15,6 +16,8 @@ type searchArgs struct { 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"` } @@ -52,6 +55,12 @@ func searchTool() mcp.Tool { 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), @@ -68,7 +77,9 @@ func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args sea q := sanitizeFTS5(args.Query) - fns, err := d.db.SearchFunctions(q, registry.Kind(args.Kind), registry.Purity(args.Purity), args.Lang, args.Domain) + 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 } @@ -93,7 +104,7 @@ func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args sea // 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) + ts, err := d.db.SearchTypes(q, args.Lang, args.Domain, tagFilters...) if err != nil { return mcp.NewToolResultError("search types: " + err.Error()), nil } @@ -122,3 +133,22 @@ func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args sea 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 +}