- Added initialization for LLM Provider and MCP Client Registries during the Load process. - Implemented reload functionality for both registries to ensure they are properly refreshed when needed. - Enhanced error handling to capture and report issues during initialization and reloading of the registries.
483 lines
12 KiB
Go
483 lines
12 KiB
Go
package mcpclient_test
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/yaoapp/gou/mcp"
|
|
mcpTypes "github.com/yaoapp/gou/mcp/types"
|
|
"github.com/yaoapp/gou/store"
|
|
"github.com/yaoapp/yao/config"
|
|
"github.com/yaoapp/yao/mcpclient"
|
|
"github.com/yaoapp/yao/test"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
test.Prepare(nil, config.Conf)
|
|
defer test.Clean()
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func setupRegistry(t *testing.T) *mcpclient.Registry {
|
|
t.Helper()
|
|
test.Prepare(t, config.Conf)
|
|
|
|
err := mcpclient.Init()
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
s, _ := store.Get("__yao.store")
|
|
if s != nil {
|
|
s.Del("mcpclient:*")
|
|
}
|
|
c, _ := store.Get("__yao.cache")
|
|
if c != nil {
|
|
c.Del("mcpclient:*")
|
|
}
|
|
test.Clean()
|
|
})
|
|
|
|
return mcpclient.Global
|
|
}
|
|
|
|
func newTestClient(id string) mcpclient.Client {
|
|
return mcpclient.Client{
|
|
ClientDSL: mcpTypes.ClientDSL{
|
|
ID: id,
|
|
Name: "Test " + id,
|
|
Type: "standard",
|
|
Transport: mcpTypes.TransportStdio,
|
|
Command: "echo",
|
|
Arguments: []string{"hello"},
|
|
},
|
|
Enabled: true,
|
|
Owner: mcpclient.ClientOwner{Type: "system"},
|
|
}
|
|
}
|
|
|
|
func TestCreate(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-stdio")
|
|
created, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test-stdio", created.ID)
|
|
assert.Equal(t, mcpclient.ClientSourceDynamic, created.Source)
|
|
assert.NotEmpty(t, created.RuntimeID)
|
|
|
|
s, _ := store.Get("__yao.store")
|
|
assert.True(t, s.Has("mcpclient:c:test-stdio"))
|
|
}
|
|
|
|
func TestCreateDuplicate(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-dup")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
dup := newTestClient("test-dup")
|
|
_, err = r.Create(&dup)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already exists")
|
|
}
|
|
|
|
func TestGet(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-get")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
got, err := r.Get("test-get")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Test test-get", got.Name)
|
|
assert.Equal(t, mcpTypes.TransportStdio, got.Transport)
|
|
assert.Equal(t, "echo", got.Command)
|
|
}
|
|
|
|
func TestGetNotFound(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
_, err := r.Get("nonexistent")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestGetLazy(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-lazy")
|
|
created, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
// Manually unload the client
|
|
mcp.UnloadClient(created.RuntimeID)
|
|
assert.False(t, mcp.Exists(created.RuntimeID))
|
|
|
|
// Get should lazily re-register
|
|
got, err := r.Get("test-lazy")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test-lazy", got.ID)
|
|
}
|
|
|
|
func TestList(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
clients := []mcpclient.Client{
|
|
{
|
|
ClientDSL: mcpTypes.ClientDSL{ID: "c1", Name: "Client 1", Type: "standard", Transport: mcpTypes.TransportStdio, Command: "echo"},
|
|
Enabled: true,
|
|
Owner: mcpclient.ClientOwner{Type: "system"},
|
|
},
|
|
{
|
|
ClientDSL: mcpTypes.ClientDSL{ID: "c2", Name: "Client 2", Type: "agent", Transport: mcpTypes.TransportSSE, URL: "http://localhost:3001"},
|
|
Enabled: false,
|
|
Owner: mcpclient.ClientOwner{Type: "user", ID: "123"},
|
|
},
|
|
{
|
|
ClientDSL: mcpTypes.ClientDSL{ID: "c3", Name: "Client 3", Type: "standard", Transport: mcpTypes.TransportStdio, Command: "cat"},
|
|
Enabled: true,
|
|
Owner: mcpclient.ClientOwner{Type: "system"},
|
|
},
|
|
}
|
|
for i := range clients {
|
|
_, err := r.Create(&clients[i])
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
t.Run("AllDynamic", func(t *testing.T) {
|
|
list, err := r.List(&mcpclient.ClientFilter{Source: mcpclient.ClientSourceDynamic})
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(list), 3)
|
|
})
|
|
|
|
t.Run("FilterByTransport", func(t *testing.T) {
|
|
tp := mcpTypes.TransportSSE
|
|
list, err := r.List(&mcpclient.ClientFilter{
|
|
Source: mcpclient.ClientSourceDynamic,
|
|
Transport: &tp,
|
|
})
|
|
require.NoError(t, err)
|
|
for _, c := range list {
|
|
assert.Equal(t, mcpTypes.TransportSSE, c.Transport)
|
|
}
|
|
})
|
|
|
|
t.Run("FilterByEnabled", func(t *testing.T) {
|
|
enabled := true
|
|
list, err := r.List(&mcpclient.ClientFilter{
|
|
Source: mcpclient.ClientSourceDynamic,
|
|
Enabled: &enabled,
|
|
})
|
|
require.NoError(t, err)
|
|
for _, c := range list {
|
|
assert.True(t, c.Enabled)
|
|
}
|
|
})
|
|
|
|
t.Run("FilterByOwner", func(t *testing.T) {
|
|
list, err := r.List(&mcpclient.ClientFilter{
|
|
Source: mcpclient.ClientSourceDynamic,
|
|
Owner: &mcpclient.ClientOwner{Type: "user", ID: "123"},
|
|
})
|
|
require.NoError(t, err)
|
|
for _, c := range list {
|
|
assert.Equal(t, "user", c.Owner.Type)
|
|
}
|
|
})
|
|
|
|
t.Run("FilterByType", func(t *testing.T) {
|
|
typ := "agent"
|
|
list, err := r.List(&mcpclient.ClientFilter{
|
|
Source: mcpclient.ClientSourceDynamic,
|
|
Type: &typ,
|
|
})
|
|
require.NoError(t, err)
|
|
for _, c := range list {
|
|
assert.Equal(t, "agent", c.ClientDSL.Type)
|
|
}
|
|
})
|
|
|
|
t.Run("FilterByKeyword", func(t *testing.T) {
|
|
list, err := r.List(&mcpclient.ClientFilter{
|
|
Source: mcpclient.ClientSourceDynamic,
|
|
Keyword: "Client 2",
|
|
})
|
|
require.NoError(t, err)
|
|
found := false
|
|
for _, c := range list {
|
|
if c.ID == "c2" {
|
|
found = true
|
|
}
|
|
}
|
|
assert.True(t, found)
|
|
})
|
|
}
|
|
|
|
func TestUpdate(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-update")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
got, err := r.Get("test-update")
|
|
require.NoError(t, err)
|
|
|
|
updated := *got
|
|
updated.ClientDSL.Name = "Updated Name"
|
|
updated.ClientDSL.Command = "cat"
|
|
|
|
result, err := r.Update("test-update", &updated)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Updated Name", result.Name)
|
|
|
|
got2, err := r.Get("test-update")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "cat", got2.Command)
|
|
}
|
|
|
|
func TestDelete(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-delete")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
err = r.Delete("test-delete")
|
|
require.NoError(t, err)
|
|
|
|
_, err = r.Get("test-delete")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestReload(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-reload")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
// Clear cache
|
|
cache, _ := store.Get("__yao.cache")
|
|
if cache != nil {
|
|
cache.Del("mcpclient:*")
|
|
}
|
|
|
|
err = r.Reload()
|
|
require.NoError(t, err)
|
|
|
|
got, err := r.Get("test-reload")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Test test-reload", got.Name)
|
|
}
|
|
|
|
func TestImportFromClients(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
list, err := r.List(&mcpclient.ClientFilter{Source: mcpclient.ClientSourceAll})
|
|
require.NoError(t, err)
|
|
|
|
builtinCount := 0
|
|
for _, c := range list {
|
|
if c.Source == mcpclient.ClientSourceBuiltIn {
|
|
builtinCount++
|
|
}
|
|
}
|
|
|
|
loadedClients := mcp.ListClients()
|
|
t.Logf("Imported %d builtin clients from mcp.ListClients (total loaded: %d)", builtinCount, len(loadedClients))
|
|
}
|
|
|
|
func TestToolListField(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-toollist")
|
|
c.ClientDSL.Tools = map[string]string{"my-tool": "scripts.MyTool"}
|
|
c.ToolList = []mcpTypes.Tool{
|
|
{Name: "discovered-tool", Description: "A tool discovered at runtime"},
|
|
}
|
|
|
|
created, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
got, err := r.Get(created.ID)
|
|
require.NoError(t, err)
|
|
assert.Len(t, got.ToolList, 1)
|
|
assert.Equal(t, "discovered-tool", got.ToolList[0].Name)
|
|
assert.Equal(t, "scripts.MyTool", got.ClientDSL.Tools["my-tool"])
|
|
}
|
|
|
|
func TestGetMCPClient(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-getmcp")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
// The MCP client may or may not actually start (depends on whether "echo" is a valid MCP server),
|
|
// but we should at least exercise the code path.
|
|
_, err = r.GetMCPClient("test-getmcp")
|
|
// Either it works or returns a "not found" — both are valid for this test fixture
|
|
t.Logf("GetMCPClient result: err=%v", err)
|
|
}
|
|
|
|
func TestGetMCPClientNotFound(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
_, err := r.GetMCPClient("no-such-client")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestCreateEmptyID(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
c := mcpclient.Client{ClientDSL: mcpTypes.ClientDSL{Name: "No ID"}}
|
|
_, err := r.Create(&c)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "id is required")
|
|
}
|
|
|
|
func TestCreateDisabled(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
c := mcpclient.Client{
|
|
ClientDSL: mcpTypes.ClientDSL{ID: "test-disabled", Name: "Disabled", Type: "standard", Transport: mcpTypes.TransportStdio, Command: "echo"},
|
|
Enabled: false,
|
|
Owner: mcpclient.ClientOwner{Type: "system"},
|
|
}
|
|
created, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "unconfigured", created.Status)
|
|
|
|
// Disabled client should not be registered at runtime
|
|
assert.False(t, mcp.Exists(created.RuntimeID), "disabled client should not be registered")
|
|
}
|
|
|
|
func TestOwnerPrefixedRuntimeIDs(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
cases := []struct {
|
|
id string
|
|
owner mcpclient.ClientOwner
|
|
prefix string
|
|
}{
|
|
{"owner-sys", mcpclient.ClientOwner{Type: "system"}, "s."},
|
|
{"owner-usr", mcpclient.ClientOwner{Type: "user", ID: "42"}, "u42."},
|
|
{"owner-team", mcpclient.ClientOwner{Type: "team", ID: "99"}, "t99."},
|
|
{"owner-asst", mcpclient.ClientOwner{Type: "assistant", ID: "a1"}, "aa1."},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.id, func(t *testing.T) {
|
|
c := mcpclient.Client{
|
|
ClientDSL: mcpTypes.ClientDSL{ID: tc.id, Name: tc.id, Type: "standard", Transport: mcpTypes.TransportStdio, Command: "echo"},
|
|
Enabled: true,
|
|
Owner: tc.owner,
|
|
}
|
|
created, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, created.RuntimeID, tc.prefix,
|
|
"RuntimeID for %s owner should contain prefix %s", tc.owner.Type, tc.prefix)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltInFilter(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
builtinList, err := r.List(&mcpclient.ClientFilter{Source: mcpclient.ClientSourceBuiltIn})
|
|
require.NoError(t, err)
|
|
for _, c := range builtinList {
|
|
assert.Equal(t, mcpclient.ClientSourceBuiltIn, c.Source)
|
|
}
|
|
}
|
|
|
|
func TestListAllSources(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
c := newTestClient("test-all-src")
|
|
_, err := r.Create(&c)
|
|
require.NoError(t, err)
|
|
|
|
all, err := r.List(&mcpclient.ClientFilter{Source: mcpclient.ClientSourceAll})
|
|
require.NoError(t, err)
|
|
|
|
hasDynamic := false
|
|
for _, item := range all {
|
|
if item.Source == mcpclient.ClientSourceDynamic {
|
|
hasDynamic = true
|
|
}
|
|
}
|
|
assert.True(t, hasDynamic)
|
|
}
|
|
|
|
func TestUpdateNotFound(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
c := newTestClient("not-exist")
|
|
_, err := r.Update("not-exist", &c)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestDeleteNotFound(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
err := r.Delete("not-exist")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestConcurrency(t *testing.T) {
|
|
r := setupRegistry(t)
|
|
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, 30)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
c := mcpclient.Client{
|
|
ClientDSL: mcpTypes.ClientDSL{
|
|
ID: fmt.Sprintf("conc-%d", idx),
|
|
Name: fmt.Sprintf("Concurrent %d", idx),
|
|
Type: "standard",
|
|
Transport: mcpTypes.TransportStdio,
|
|
Command: "echo",
|
|
},
|
|
Enabled: true,
|
|
Owner: mcpclient.ClientOwner{Type: "system"},
|
|
}
|
|
if _, err := r.Create(&c); err != nil {
|
|
errCh <- err
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
_, err := r.Get(fmt.Sprintf("conc-%d", idx))
|
|
if err != nil {
|
|
errCh <- err
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
if err := r.Delete(fmt.Sprintf("conc-%d", idx)); err != nil {
|
|
errCh <- err
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
|
|
for err := range errCh {
|
|
t.Errorf("concurrent operation error: %v", err)
|
|
}
|
|
}
|