e69b6ab6de
- integration_test.go - main.go - tool_search.go - tool_proposal.go Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
7.7 KiB
Go
267 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/server"
|
|
|
|
"fn-registry/registry"
|
|
)
|
|
|
|
// findRegistryRoot walks up from cwd looking for registry.db. Tests skip if
|
|
// the registry isn't reachable (e.g. running outside the workspace).
|
|
func findRegistryRoot(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
for {
|
|
// Treat as the root only if both registry.db AND the registry/ pkg
|
|
// dir are present — avoids picking up stray *.db inside apps/.
|
|
_, dbErr := os.Stat(filepath.Join(dir, "registry.db"))
|
|
_, pkgErr := os.Stat(filepath.Join(dir, "registry", "models.go"))
|
|
if dbErr == nil && pkgErr == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
t.Skip("registry.db not found in any parent — skipping integration test")
|
|
return ""
|
|
}
|
|
|
|
// pipePair returns a pair of (clientWriter, serverReader, serverWriter, clientReader).
|
|
type pipePair struct {
|
|
clientToServer *io.PipeWriter
|
|
serverFromClient *io.PipeReader
|
|
serverToClient *io.PipeWriter
|
|
clientFromServer *io.PipeReader
|
|
}
|
|
|
|
func newPipePair() *pipePair {
|
|
c2sR, c2sW := io.Pipe()
|
|
s2cR, s2cW := io.Pipe()
|
|
return &pipePair{
|
|
clientToServer: c2sW,
|
|
serverFromClient: c2sR,
|
|
serverToClient: s2cW,
|
|
clientFromServer: s2cR,
|
|
}
|
|
}
|
|
|
|
// startServer wires the MCP server to the in-memory pipes and runs it in a
|
|
// goroutine. Returns a wait-fn so tests can shut it down deterministically.
|
|
func startServer(t *testing.T, root string, p *pipePair) func() {
|
|
t.Helper()
|
|
dbPath := filepath.Join(root, "registry.db")
|
|
t.Logf("opening registry at %s", dbPath)
|
|
db, err := registry.Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open registry: %v", err)
|
|
}
|
|
var count int
|
|
if err := db.Conn().QueryRow("SELECT COUNT(*) FROM functions").Scan(&count); err != nil {
|
|
t.Fatalf("count functions: %v", err)
|
|
}
|
|
t.Logf("functions in db: %d", count)
|
|
srv := server.NewMCPServer("registry_mcp_test", "0.0.0", server.WithToolCapabilities(true))
|
|
registerTools(srv, db, root, config{})
|
|
|
|
stdio := server.NewStdioServer(srv)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_ = stdio.Listen(ctx, p.serverFromClient, p.serverToClient)
|
|
}()
|
|
|
|
return func() {
|
|
cancel()
|
|
_ = p.clientToServer.Close()
|
|
_ = p.serverToClient.Close()
|
|
wg.Wait()
|
|
db.Close()
|
|
}
|
|
}
|
|
|
|
func sendJSON(t *testing.T, w io.Writer, msg map[string]any) {
|
|
t.Helper()
|
|
b, err := json.Marshal(msg)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if _, err := w.Write(append(b, '\n')); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
func recvJSONUntilID(t *testing.T, r *bufio.Reader, id int) map[string]any {
|
|
t.Helper()
|
|
for {
|
|
line, err := r.ReadBytes('\n')
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
var msg map[string]any
|
|
if err := json.Unmarshal(line, &msg); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", line, err)
|
|
}
|
|
if got, ok := msg["id"].(float64); ok && int(got) == id {
|
|
return msg
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntegration_StdioListSearchShow(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",
|
|
})
|
|
|
|
sendJSON(t, p.clientToServer, map[string]any{
|
|
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
|
})
|
|
listMsg := recvJSONUntilID(t, br, 2)
|
|
tools := listMsg["result"].(map[string]any)["tools"].([]any)
|
|
if len(tools) < 6 {
|
|
t.Errorf("expected >=6 read-only tools, got %d", len(tools))
|
|
}
|
|
names := map[string]bool{}
|
|
for _, tt := range tools {
|
|
names[tt.(map[string]any)["name"].(string)] = true
|
|
}
|
|
for _, want := range []string{"fn_search", "fn_show", "fn_code", "fn_list_domains", "fn_uses", "fn_doctor"} {
|
|
if !names[want] {
|
|
t.Errorf("tools list missing %q", want)
|
|
}
|
|
}
|
|
|
|
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": "filter slice", "lang": "go", "purity": "pure", "limit": 5},
|
|
},
|
|
})
|
|
searchMsg := recvJSONUntilID(t, br, 3)
|
|
text := searchMsg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
if !strings.Contains(text, "filter_slice_go_core") {
|
|
t.Errorf("expected filter_slice_go_core in search results:\n%s", text)
|
|
}
|
|
|
|
sendJSON(t, p.clientToServer, map[string]any{
|
|
"jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
|
"params": map[string]any{
|
|
"name": "fn_show",
|
|
"arguments": map[string]any{"id": "filter_slice_go_core"},
|
|
},
|
|
})
|
|
showMsg := recvJSONUntilID(t, br, 4)
|
|
text = showMsg["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
if !strings.Contains(text, "filter_slice_go_core") || !strings.Contains(text, "```go") {
|
|
t.Errorf("show result missing markdown:\n%s", text[:min(400, len(text))])
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
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))])
|
|
}
|
|
}
|