Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ GOFLAGS ?=
PKGS ?= ./...
COVERPROFILE ?= coverage.out
SEMGREP_IMAGE ?= semgrep/semgrep@sha256:326e5f41cc972bb423b764a14febbb62bbad29ee1c01820805d077dd868fea48
FUZZTIME ?= 5s
FUZZTIME ?= 15s

# Build matrix mirrors ci.yml — keep in sync.
BUILD_MATRIX = \
Expand Down Expand Up @@ -155,15 +155,22 @@ tidy-check: ## Fail if go.mod / go.sum drift from `go mod tidy`.
exit 1; \
fi

fuzz-quick: ## Run each Fuzz* target for FUZZTIME (default 5s).
@found=0; \
fuzz-quick: ## Run each Fuzz* target for FUZZTIME (default 15s) in a fresh corpus.
@cachedir=$$(mktemp -d -t plexara-fuzz-cache.XXXXXX); \
trap 'rm -rf "$$cachedir"' EXIT; \
found=0; \
while IFS= read -r pkg; do \
[ -z "$$pkg" ] && continue; \
while IFS= read -r fuzz; do \
[ -z "$$fuzz" ] && continue; \
found=1; \
printf ' fuzz %s/%s for %s ... ' "$$pkg" "$$fuzz" "$(FUZZTIME)"; \
out=$$($(GO) test "$$pkg" -run='^$$' -fuzz="^$$fuzz\$$" -fuzztime=$(FUZZTIME) 2>&1); \
out=$$($(GO) test "$$pkg" \
-run='^$$' -fuzz="^$$fuzz\$$" \
-fuzztime=$(FUZZTIME) \
-test.fuzzcachedir="$$cachedir" \
-timeout=120s \
2>&1); \
ec=$$?; \
if [ $$ec -ne 0 ]; then echo "FAIL"; echo "$$out"; exit $$ec; fi; \
echo "ok"; \
Expand Down
93 changes: 93 additions & 0 deletions core/mcp/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package mcp

import (
"sort"
"strings"
)

// Catalog aggregates the tool catalog across every connected server.
//
// Tools is the flat list, ordered first by server name then by bare
// tool name. ToolsByServer is the same set indexed by server name.
type Catalog struct {
Tools []Tool
ToolsByServer map[string][]Tool
}

func (c *Catalog) copy() *Catalog {
if c == nil {
return &Catalog{}
}
out := &Catalog{
Tools: make([]Tool, len(c.Tools)),
ToolsByServer: make(map[string][]Tool, len(c.ToolsByServer)),
}
copy(out.Tools, c.Tools)
for k, v := range c.ToolsByServer {
dup := make([]Tool, len(v))
copy(dup, v)
out.ToolsByServer[k] = dup
}
return out
}

// Toolkit is a logical grouping of related tools — typically tools
// from the same MCP "namespace" (e.g. all `datahub_*` tools on a
// Plexara server).
type Toolkit struct {
Name string
Tools []Tool
}

// ToolkitClassifier maps a tool to a toolkit name. Returning the
// empty string places the tool in the "default" toolkit.
type ToolkitClassifier func(t Tool) string

// PrefixClassifier classifies tools by the substring before the first
// underscore in the bare tool name. For Plexara's catalog, this groups
// `datahub_*`, `trino_*`, `s3_*`, and `memory_*` cleanly.
//
// Names with no underscore, an empty bare name, or a leading underscore
// (`_foo`) all fall into the "misc" toolkit.
func PrefixClassifier(t Tool) string {
if t.BareName == "" {
return "misc"
}
idx := strings.Index(t.BareName, "_")
if idx <= 0 {
return "misc"
}
return t.BareName[:idx]
}

// Toolkits groups the catalog using [PrefixClassifier].
func (c *Catalog) Toolkits() []Toolkit {
return c.ToolkitsBy(PrefixClassifier)
}

// ToolkitsBy groups the catalog using a custom classifier. Toolkits
// are returned in stable alphabetical order by name; tools within a
// toolkit are returned in the order they appear in [Catalog.Tools].
func (c *Catalog) ToolkitsBy(fn ToolkitClassifier) []Toolkit {
if c == nil || len(c.Tools) == 0 {
return nil
}
groups := make(map[string][]Tool)
for _, t := range c.Tools {
name := fn(t)
if name == "" {
name = "default"
}
groups[name] = append(groups[name], t)
}
names := make([]string, 0, len(groups))
for name := range groups {
names = append(names, name)
}
sort.Strings(names)
out := make([]Toolkit, 0, len(names))
for _, name := range names {
out = append(out, Toolkit{Name: name, Tools: groups[name]})
}
return out
}
92 changes: 92 additions & 0 deletions core/mcp/catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package mcp_test

import (
"testing"

"github.com/plexara/plexara-agents/core/mcp"
)

func TestCatalog_PrefixToolkits(t *testing.T) {
t.Parallel()

cat := &mcp.Catalog{
Tools: []mcp.Tool{
{Name: "p__datahub_search", Server: "p", BareName: "datahub_search"},
{Name: "p__datahub_get_schema", Server: "p", BareName: "datahub_get_schema"},
{Name: "p__trino_query", Server: "p", BareName: "trino_query"},
{Name: "p__memory_recall", Server: "p", BareName: "memory_recall"},
{Name: "fs__read", Server: "fs", BareName: "read"},
},
}

got := cat.Toolkits()
wantNames := []string{"datahub", "memory", "misc", "trino"}
if len(got) != len(wantNames) {
t.Fatalf("len(toolkits) = %d; want %d (%v)", len(got), len(wantNames), names(got))
}
for i, want := range wantNames {
if got[i].Name != want {
t.Errorf("toolkit[%d].Name = %q; want %q", i, got[i].Name, want)
}
}
if got[0].Name == "datahub" && len(got[0].Tools) != 2 {
t.Errorf("datahub toolkit has %d tools; want 2", len(got[0].Tools))
}
}

func TestCatalog_CustomClassifier(t *testing.T) {
t.Parallel()

cat := &mcp.Catalog{
Tools: []mcp.Tool{
{Server: "a", BareName: "x"},
{Server: "b", BareName: "y"},
},
}
got := cat.ToolkitsBy(func(t mcp.Tool) string { return t.Server })
if len(got) != 2 {
t.Fatalf("len = %d; want 2", len(got))
}
if got[0].Name != "a" || got[1].Name != "b" {
t.Errorf("toolkits = %v; want [a b]", names(got))
}
}

func TestCatalog_Empty(t *testing.T) {
t.Parallel()

var cat *mcp.Catalog
if got := cat.Toolkits(); got != nil {
t.Errorf("nil Catalog.Toolkits = %v; want nil", got)
}
if got := (&mcp.Catalog{}).Toolkits(); got != nil {
t.Errorf("empty Catalog.Toolkits = %v; want nil", got)
}
}

func TestCatalog_DefaultClassifierEmptyName(t *testing.T) {
t.Parallel()

cat := &mcp.Catalog{Tools: []mcp.Tool{{Server: "s", BareName: "noprefix"}}}
got := cat.ToolkitsBy(func(_ mcp.Tool) string { return "" })
if len(got) != 1 || got[0].Name != "default" {
t.Errorf("empty-string classifier: got %v; want a single 'default' toolkit", names(got))
}
}

func TestCatalog_PrefixClassifierLeadingUnderscore(t *testing.T) {
t.Parallel()

// Per PrefixClassifier doc: leading underscore -> "misc".
if got := mcp.PrefixClassifier(mcp.Tool{BareName: "_foo"}); got != "misc" {
t.Errorf("PrefixClassifier(\"_foo\") = %q; want misc", got)
}
}

func names(toolkits []mcp.Toolkit) []string {
out := make([]string, len(toolkits))
for i, tk := range toolkits {
out[i] = tk.Name
}
return out
}
Loading
Loading