chore: auto-commit (4 archivos)
- integration_test.go - main.go - tool_search.go - tool_proposal.go Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -199,3 +199,68 @@ func min(a, b int) int {
|
|||||||
}
|
}
|
||||||
return b
|
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))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(listDomainsTool(), mcp.NewTypedToolHandler(deps.handleListDomains))
|
||||||
s.AddTool(usesTool(), mcp.NewTypedToolHandler(deps.handleUses))
|
s.AddTool(usesTool(), mcp.NewTypedToolHandler(deps.handleUses))
|
||||||
s.AddTool(doctorTool(), mcp.NewTypedToolHandler(deps.handleDoctor))
|
s.AddTool(doctorTool(), mcp.NewTypedToolHandler(deps.handleDoctor))
|
||||||
|
s.AddTool(proposalTool(), mcp.NewTypedToolHandler(deps.handleProposal))
|
||||||
|
|
||||||
// Mutating tools — opt-in.
|
// Mutating tools — opt-in.
|
||||||
if cfg.enableRun {
|
if cfg.enableRun {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+32
-2
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ type searchArgs struct {
|
|||||||
Lang string `json:"lang,omitempty"`
|
Lang string `json:"lang,omitempty"`
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
Purity string `json:"purity,omitempty"`
|
Purity string `json:"purity,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
Tags string `json:"tags,omitempty"`
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +55,12 @@ func searchTool() mcp.Tool {
|
|||||||
mcp.Description("Filter by purity (functions only)."),
|
mcp.Description("Filter by purity (functions only)."),
|
||||||
mcp.Enum("pure", "impure"),
|
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.WithNumber("limit",
|
||||||
mcp.Description("Max hits returned (default 50)."),
|
mcp.Description("Max hits returned (default 50)."),
|
||||||
mcp.Min(1),
|
mcp.Min(1),
|
||||||
@@ -68,7 +77,9 @@ func (d *deps) handleSearch(ctx context.Context, _ mcp.CallToolRequest, args sea
|
|||||||
|
|
||||||
q := sanitizeFTS5(args.Query)
|
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 {
|
if err != nil {
|
||||||
return mcp.NewToolResultError("search functions: " + err.Error()), 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).
|
// Types: only when no kind filter (kind applies only to functions).
|
||||||
if args.Kind == "" && len(hits) < limit {
|
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 {
|
if err != nil {
|
||||||
return mcp.NewToolResultError("search types: " + err.Error()), 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, "", " ")
|
b, _ := json.MarshalIndent(out, "", " ")
|
||||||
return mcp.NewToolResultText(string(b)), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user