Files
higress/plugins/wasm-go/pkg/mcp/server/composed_server_test.go
2026-06-15 20:29:20 +08:00

252 lines
8.3 KiB
Go

// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubTool is a minimal Tool implementation for registry population in tests.
type stubTool struct {
desc string
input map[string]any
output map[string]any
}
func (s *stubTool) Create(_ []byte) Tool { return s }
func (s *stubTool) Call(_ HttpContext, _ Server) error { return nil }
func (s *stubTool) Description() string { return s.desc }
func (s *stubTool) InputSchema() map[string]any { return s.input }
func (s *stubTool) OutputSchema() map[string]any { return s.output }
func newPopulatedRegistry(t *testing.T) *GlobalToolRegistry {
t.Helper()
r := &GlobalToolRegistry{}
r.Initialize()
r.RegisterTool("alpha", "search", &stubTool{
desc: "alpha search",
input: map[string]any{"type": "object", "props": "a"},
output: map[string]any{"type": "string"},
})
r.RegisterTool("alpha", "fetch", &stubTool{
desc: "alpha fetch",
input: map[string]any{"type": "object", "props": "f"},
output: nil,
})
r.RegisterTool("beta", "search", &stubTool{
desc: "beta search",
input: map[string]any{"type": "object", "props": "bs"},
output: map[string]any{"type": "array"},
})
return r
}
func TestComposedMCPServer_NewAndGetName(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("myset", []ServerToolConfig{
{ServerName: "alpha", Tools: []string{"search"}},
}, r)
require.NotNil(t, cs)
assert.Equal(t, "myset", cs.GetName())
}
func TestComposedMCPServer_AddMCPTool_IsNoOp(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("set", []ServerToolConfig{
{ServerName: "alpha", Tools: []string{"search"}},
}, r)
// AddMCPTool should not panic and should be a no-op (tool not added).
ret := cs.AddMCPTool("ignored", &stubTool{desc: "x"})
assert.Same(t, cs, ret, "AddMCPTool should return the server itself")
tools := cs.GetMCPTools()
_, exists := tools["ignored"]
assert.False(t, exists, "no-op AddMCPTool must not register the tool")
// Only the one from registry should remain.
_, found := tools["alpha___search"]
assert.True(t, found, "registered tool should be present")
assert.Len(t, tools, 1)
}
func TestComposedMCPServer_GetMCPTools_AggregatesWithPrefix(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("compound", []ServerToolConfig{
{ServerName: "alpha", Tools: []string{"search", "fetch"}},
{ServerName: "beta", Tools: []string{"search"}},
}, r)
tools := cs.GetMCPTools()
require.Len(t, tools, 3)
// All keys must be prefixed with the original server name and the splitter.
want := []string{"alpha___search", "alpha___fetch", "beta___search"}
for _, k := range want {
_, ok := tools[k]
assert.True(t, ok, "expected composed tool key %q", k)
}
// Descriptions / input schemas are forwarded from the registry's ToolInfo.
dt, ok := tools["alpha___search"].(*DescriptiveTool)
require.True(t, ok)
assert.Equal(t, "alpha search", dt.Description())
assert.Equal(t, "a", dt.InputSchema()["props"])
assert.Equal(t, "string", dt.OutputSchema()["type"])
// Tool without OutputSchema in registry produces a DescriptiveTool with nil output.
dt2, ok := tools["alpha___fetch"].(*DescriptiveTool)
require.True(t, ok)
assert.Nil(t, dt2.OutputSchema())
}
func TestComposedMCPServer_GetMCPTools_MissingToolIsSkipped(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("set", []ServerToolConfig{
{ServerName: "alpha", Tools: []string{"search", "nonexistent"}},
{ServerName: "ghost", Tools: []string{"any"}}, // entire server missing
}, r)
tools := cs.GetMCPTools()
// Only "alpha___search" survives; missing ones are logged and skipped.
assert.Len(t, tools, 1)
_, ok := tools["alpha___search"]
assert.True(t, ok)
}
func TestComposedMCPServer_GetMCPTools_SameSimpleNameDifferentServersDoNotCollide(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("set", []ServerToolConfig{
{ServerName: "alpha", Tools: []string{"search"}},
{ServerName: "beta", Tools: []string{"search"}},
}, r)
tools := cs.GetMCPTools()
require.Len(t, tools, 2)
assert.Contains(t, tools, "alpha___search")
assert.Contains(t, tools, "beta___search")
}
func TestComposedMCPServer_GetMCPTools_EmptyConfig(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("empty", nil, r)
tools := cs.GetMCPTools()
assert.NotNil(t, tools, "should return a non-nil empty map")
assert.Empty(t, tools)
}
func TestComposedMCPServer_SetGetConfig_BytePointer(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("set", nil, r)
// Empty config: GetConfig must not modify the destination.
var dst []byte
dst = []byte("untouched")
cs.GetConfig(&dst)
assert.Equal(t, []byte("untouched"), dst, "GetConfig on empty config must be a no-op")
// After SetConfig, byte-pointer destinations receive the stored bytes.
cs.SetConfig([]byte(`{"k":"v"}`))
var out []byte
cs.GetConfig(&out)
assert.Equal(t, []byte(`{"k":"v"}`), out)
}
func TestComposedMCPServer_GetConfig_UnhandledDestinationType(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("set", nil, r)
cs.SetConfig([]byte(`{"k":"v"}`))
// Non-byte-pointer destinations are logged and left untouched (no panic).
var s string = "untouched"
cs.GetConfig(&s)
assert.Equal(t, "untouched", s)
type holder struct{ K string }
h := holder{K: "untouched"}
cs.GetConfig(&h)
assert.Equal(t, "untouched", h.K)
}
func TestComposedMCPServer_Clone_IndependentConfig(t *testing.T) {
r := newPopulatedRegistry(t)
cs := NewComposedMCPServer("orig", []ServerToolConfig{
{ServerName: "alpha", Tools: []string{"search"}},
}, r)
cs.SetConfig([]byte(`{"a":1}`))
clonedI := cs.Clone()
require.NotNil(t, clonedI)
cloned, ok := clonedI.(*ComposedMCPServer)
require.True(t, ok)
assert.NotSame(t, cs, cloned, "Clone must return a new struct pointer")
assert.Equal(t, cs.GetName(), cloned.GetName())
// Confirm both see the same config initially.
var origBytes, clonedBytes []byte
cs.GetConfig(&origBytes)
cloned.GetConfig(&clonedBytes)
assert.Equal(t, origBytes, clonedBytes)
// Mutating clone's config must not propagate to original.
cloned.SetConfig([]byte(`{"a":2}`))
cs.GetConfig(&origBytes)
assert.Equal(t, []byte(`{"a":1}`), origBytes, "original config must remain unchanged after cloning")
// Cloned still resolves tools through the shared registry.
assert.Contains(t, cloned.GetMCPTools(), "alpha___search")
}
func TestDescriptiveTool_Create_ReturnsNewInstanceWithSameFields(t *testing.T) {
dt := &DescriptiveTool{
description: "d",
inputSchema: map[string]any{"k": "v"},
outputSchema: map[string]any{"o": "w"},
}
created := dt.Create([]byte(`{"ignored":true}`))
require.NotNil(t, created)
cdt, ok := created.(*DescriptiveTool)
require.True(t, ok)
assert.NotSame(t, dt, cdt, "Create must return a new instance")
assert.Equal(t, dt.Description(), cdt.Description())
assert.Equal(t, dt.InputSchema(), cdt.InputSchema())
assert.Equal(t, dt.OutputSchema(), cdt.OutputSchema())
}
func TestDescriptiveTool_Call_ReturnsError(t *testing.T) {
dt := &DescriptiveTool{description: "d"}
err := dt.Call(nil, nil)
require.Error(t, err, "DescriptiveTool.Call is a guard rail — must return an error")
}
func TestDescriptiveTool_Accessors(t *testing.T) {
dt := &DescriptiveTool{
description: "desc",
inputSchema: map[string]any{"in": 1},
outputSchema: map[string]any{"out": 2},
}
assert.Equal(t, "desc", dt.Description())
assert.Equal(t, map[string]any{"in": 1}, dt.InputSchema())
assert.Equal(t, map[string]any{"out": 2}, dt.OutputSchema())
// Nil schemas must round-trip as nil.
empty := &DescriptiveTool{}
assert.Equal(t, "", empty.Description())
assert.Nil(t, empty.InputSchema())
assert.Nil(t, empty.OutputSchema())
}