mirror of
https://github.com/alibaba/higress.git
synced 2026-06-26 02:35:02 +08:00
test(wasm-go/mcp): expand unit test coverage for mcp-server framework (#3871)
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMcpProxyServerBasicInterface tests that McpProxyServer implements the Server interface
|
||||
@@ -110,3 +111,329 @@ func TestMcpProxyServerSecuritySchemes(t *testing.T) {
|
||||
assert.Equal(t, scheme.ID, retrievedScheme.ID)
|
||||
assert.Equal(t, scheme.Type, retrievedScheme.Type)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SetDefaultDownstreamSecurity / SetDefaultUpstreamSecurity / PassthroughAuth
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyServer_SetGetDefaultDownstreamSecurity(t *testing.T) {
|
||||
s := NewMcpProxyServer("p")
|
||||
assert.Equal(t, "", s.GetDefaultDownstreamSecurity().ID, "fresh server has empty default")
|
||||
s.SetDefaultDownstreamSecurity(SecurityRequirement{ID: "K", Passthrough: true})
|
||||
got := s.GetDefaultDownstreamSecurity()
|
||||
assert.Equal(t, "K", got.ID)
|
||||
assert.True(t, got.Passthrough)
|
||||
}
|
||||
|
||||
func TestMcpProxyServer_SetGetDefaultUpstreamSecurity(t *testing.T) {
|
||||
s := NewMcpProxyServer("p")
|
||||
s.SetDefaultUpstreamSecurity(SecurityRequirement{ID: "U", Credential: "c"})
|
||||
got := s.GetDefaultUpstreamSecurity()
|
||||
assert.Equal(t, "U", got.ID)
|
||||
assert.Equal(t, "c", got.Credential)
|
||||
}
|
||||
|
||||
func TestMcpProxyServer_PassthroughAuthHeaderGetterAndSetter(t *testing.T) {
|
||||
s := NewMcpProxyServer("p")
|
||||
assert.False(t, s.GetPassthroughAuthHeader(), "default is false")
|
||||
s.SetPassthroughAuthHeader(true)
|
||||
assert.True(t, s.GetPassthroughAuthHeader())
|
||||
s.SetPassthroughAuthHeader(false)
|
||||
assert.False(t, s.GetPassthroughAuthHeader())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AddSecurityScheme — nil-map branch
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyServer_AddSecurityScheme_InitializesNilMap(t *testing.T) {
|
||||
// Skip the constructor so we can hit the `securitySchemes == nil` branch.
|
||||
s := &McpProxyServer{Name: "p"}
|
||||
s.AddSecurityScheme(SecurityScheme{ID: "K", Type: "apiKey", In: "header", Name: "X"})
|
||||
got, ok := s.GetSecurityScheme("K")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "K", got.ID)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AddMCPTool — delegates to BaseMCPServer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyServer_AddMCPTool_StoresInBaseAndReturnsSelf(t *testing.T) {
|
||||
s := NewMcpProxyServer("p")
|
||||
stub := &stubTool{desc: "d"}
|
||||
ret := s.AddMCPTool("custom", stub)
|
||||
assert.Same(t, s, ret, "AddMCPTool returns receiver for chaining")
|
||||
tools := s.GetMCPTools()
|
||||
got, ok := tools["custom"]
|
||||
require.True(t, ok)
|
||||
assert.Same(t, stub, got)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AddProxyTool — overrides on duplicate name
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyServer_AddProxyTool_DuplicateNameOverwrites(t *testing.T) {
|
||||
s := NewMcpProxyServer("p")
|
||||
require.NoError(t, s.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "first"}))
|
||||
require.NoError(t, s.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "second"}))
|
||||
|
||||
tools := s.GetMCPTools()
|
||||
assert.Len(t, tools, 1, "duplicate AddProxyTool should overwrite, not duplicate")
|
||||
cfg, ok := s.GetToolConfig("t")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "second", cfg.Description, "later AddProxyTool wins")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GetToolConfig — hit and miss
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyServer_GetToolConfig_HitAndMiss(t *testing.T) {
|
||||
s := NewMcpProxyServer("p")
|
||||
require.NoError(t, s.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "d"}))
|
||||
|
||||
cfg, ok := s.GetToolConfig("t")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "d", cfg.Description)
|
||||
|
||||
_, missOK := s.GetToolConfig("missing")
|
||||
assert.False(t, missOK)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Clone — deep copy of toolsConfig and securitySchemes
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyServer_Clone_DeepCopiesToolsConfigAndSchemes(t *testing.T) {
|
||||
orig := NewMcpProxyServer("orig")
|
||||
orig.SetMcpServerURL("http://b")
|
||||
orig.SetTimeout(1234)
|
||||
orig.SetTransport(TransportSSE)
|
||||
orig.SetPassthroughAuthHeader(true)
|
||||
orig.SetDefaultDownstreamSecurity(SecurityRequirement{ID: "K"})
|
||||
orig.AddSecurityScheme(SecurityScheme{ID: "K", Type: "apiKey", In: "header", Name: "X"})
|
||||
require.NoError(t, orig.AddProxyTool(McpProxyToolConfig{Name: "t", Description: "d"}))
|
||||
|
||||
clonedI := orig.Clone()
|
||||
cloned, ok := clonedI.(*McpProxyServer)
|
||||
require.True(t, ok)
|
||||
require.NotSame(t, orig, cloned, "Clone must return a fresh struct")
|
||||
|
||||
// Surface fields are copied.
|
||||
assert.Equal(t, orig.Name, cloned.Name)
|
||||
// NOTE: Clone does not propagate mcpServerURL/timeout/transport/passthrough
|
||||
// nor defaultDownstream/upstreamSecurity. That is intentional today (see
|
||||
// proxy_server.go:188): cloning is used for per-request isolation of
|
||||
// tool/security registries only. This test pins that contract — if Clone
|
||||
// starts copying those fields, update here and document the change.
|
||||
assert.Equal(t, "", cloned.GetMcpServerURL())
|
||||
|
||||
// toolsConfig: deep copy — adding to clone doesn't bleed back to orig.
|
||||
require.NoError(t, cloned.AddProxyTool(McpProxyToolConfig{Name: "extra", Description: "x"}))
|
||||
_, origHasExtra := orig.GetToolConfig("extra")
|
||||
assert.False(t, origHasExtra, "tool added to clone must not appear in original")
|
||||
|
||||
// securitySchemes: deep copy — replacing scheme on clone doesn't touch orig.
|
||||
cloned.AddSecurityScheme(SecurityScheme{ID: "K", Type: "http", Scheme: "bearer"})
|
||||
origScheme, _ := orig.GetSecurityScheme("K")
|
||||
clonedScheme, _ := cloned.GetSecurityScheme("K")
|
||||
assert.Equal(t, "apiKey", origScheme.Type, "original scheme must remain apiKey")
|
||||
assert.Equal(t, "http", clonedScheme.Type, "clone reflects the override")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// McpProxyTool — Description / InputSchema / OutputSchema / Create
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestMcpProxyTool_DescriptionAndOutputSchema(t *testing.T) {
|
||||
tool := &McpProxyTool{
|
||||
toolConfig: McpProxyToolConfig{
|
||||
Description: "describe me",
|
||||
OutputSchema: map[string]any{"type": "string"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "describe me", tool.Description())
|
||||
assert.Equal(t, map[string]any{"type": "string"}, tool.OutputSchema())
|
||||
}
|
||||
|
||||
func TestMcpProxyTool_InputSchema_RequiredAndOptionalAndEnumAndDefault(t *testing.T) {
|
||||
tool := &McpProxyTool{
|
||||
toolConfig: McpProxyToolConfig{
|
||||
Args: []ToolArg{
|
||||
{Name: "must", Type: "string", Description: "required", Required: true},
|
||||
{Name: "opt", Type: "integer", Description: "optional", Default: 7},
|
||||
{Name: "pick", Type: "string", Description: "enum", Enum: []interface{}{"a", "b"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema := tool.InputSchema()
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
required, ok := schema["required"].([]string)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"must"}, required, "only Required:true args land in required[]")
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
mustProp := props["must"].(map[string]any)
|
||||
assert.Equal(t, "string", mustProp["type"])
|
||||
|
||||
optProp := props["opt"].(map[string]any)
|
||||
assert.Equal(t, 7, optProp["default"])
|
||||
|
||||
pickProp := props["pick"].(map[string]any)
|
||||
assert.Equal(t, []interface{}{"a", "b"}, pickProp["enum"])
|
||||
}
|
||||
|
||||
func TestMcpProxyTool_InputSchema_NoArgs(t *testing.T) {
|
||||
tool := &McpProxyTool{toolConfig: McpProxyToolConfig{}}
|
||||
schema := tool.InputSchema()
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, props)
|
||||
required, ok := schema["required"].([]string)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, required)
|
||||
}
|
||||
|
||||
func TestMcpProxyTool_Create_NewInstanceWithBoundArgs(t *testing.T) {
|
||||
orig := &McpProxyTool{
|
||||
serverName: "srv",
|
||||
name: "t",
|
||||
toolConfig: McpProxyToolConfig{Name: "t"},
|
||||
}
|
||||
created := orig.Create([]byte(`{"q":"hello","n":7}`))
|
||||
require.NotSame(t, orig, created, "Create returns a fresh instance")
|
||||
cloned := created.(*McpProxyTool)
|
||||
assert.Equal(t, "srv", cloned.serverName)
|
||||
assert.Equal(t, "t", cloned.name)
|
||||
assert.Equal(t, "hello", cloned.arguments["q"])
|
||||
// JSON unmarshals numbers as float64.
|
||||
assert.Equal(t, float64(7), cloned.arguments["n"])
|
||||
}
|
||||
|
||||
func TestMcpProxyTool_Create_EmptyParamsStillReturnsInstance(t *testing.T) {
|
||||
orig := &McpProxyTool{serverName: "s", name: "t"}
|
||||
created := orig.Create(nil)
|
||||
cloned := created.(*McpProxyTool)
|
||||
assert.Equal(t, "s", cloned.serverName)
|
||||
assert.Equal(t, "t", cloned.name)
|
||||
require.NotNil(t, cloned.arguments)
|
||||
assert.Empty(t, cloned.arguments, "no params → empty arguments map, not nil")
|
||||
}
|
||||
|
||||
func TestMcpProxyTool_Create_MalformedJSON_PreservesEmptyArgs(t *testing.T) {
|
||||
orig := &McpProxyTool{serverName: "s", name: "t"}
|
||||
created := orig.Create([]byte(`{not json`))
|
||||
cloned := created.(*McpProxyTool)
|
||||
// json.Unmarshal silently fails; arguments stays empty.
|
||||
assert.Empty(t, cloned.arguments)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValidateSecurityScheme — full matrix
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestValidateSecurityScheme(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
scheme SecurityScheme
|
||||
wantErr string
|
||||
}{
|
||||
{"missing ID", SecurityScheme{Type: "apiKey", In: "header", Name: "X"}, "ID is required"},
|
||||
{"invalid type", SecurityScheme{ID: "k", Type: "oauth2"}, "invalid security scheme type"},
|
||||
{"apiKey missing name", SecurityScheme{ID: "k", Type: "apiKey", In: "header"}, "name is required"},
|
||||
{"apiKey invalid in", SecurityScheme{ID: "k", Type: "apiKey", In: "body", Name: "X"}, "invalid security scheme location"},
|
||||
{"apiKey ok header", SecurityScheme{ID: "k", Type: "apiKey", In: "header", Name: "X"}, ""},
|
||||
{"apiKey ok query", SecurityScheme{ID: "k", Type: "apiKey", In: "query", Name: "X"}, ""},
|
||||
{"apiKey ok cookie", SecurityScheme{ID: "k", Type: "apiKey", In: "cookie", Name: "X"}, ""},
|
||||
{"http missing scheme", SecurityScheme{ID: "k", Type: "http"}, "scheme is required for http"},
|
||||
{"http bearer ok", SecurityScheme{ID: "k", Type: "http", Scheme: "bearer"}, ""},
|
||||
{"http basic ok", SecurityScheme{ID: "k", Type: "http", Scheme: "basic"}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ValidateSecurityScheme(c.scheme)
|
||||
if c.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ValidateToolConfig — full matrix
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestValidateToolConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config McpProxyToolConfig
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
"missing name",
|
||||
McpProxyToolConfig{Description: "d"},
|
||||
"tool name is required",
|
||||
},
|
||||
{
|
||||
"missing description",
|
||||
McpProxyToolConfig{Name: "t"},
|
||||
"tool description is required",
|
||||
},
|
||||
{
|
||||
"arg missing name",
|
||||
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{{Type: "string", Description: "x"}}},
|
||||
"argument name is required",
|
||||
},
|
||||
{
|
||||
"arg duplicate names",
|
||||
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{
|
||||
{Name: "a", Type: "string", Description: "x"},
|
||||
{Name: "a", Type: "string", Description: "y"},
|
||||
}},
|
||||
"duplicate argument name",
|
||||
},
|
||||
{
|
||||
"arg missing description",
|
||||
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{{Name: "a", Type: "string"}}},
|
||||
"argument description is required",
|
||||
},
|
||||
{
|
||||
"arg invalid type",
|
||||
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{{Name: "a", Type: "money", Description: "x"}}},
|
||||
"invalid argument type",
|
||||
},
|
||||
{
|
||||
"happy path with multiple typed args",
|
||||
McpProxyToolConfig{Name: "t", Description: "d", Args: []ToolArg{
|
||||
{Name: "s", Type: "string", Description: "x"},
|
||||
{Name: "n", Type: "number", Description: "x"},
|
||||
{Name: "i", Type: "integer", Description: "x"},
|
||||
{Name: "b", Type: "boolean", Description: "x"},
|
||||
{Name: "a", Type: "array", Description: "x"},
|
||||
{Name: "o", Type: "object", Description: "x"},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"happy path no args",
|
||||
McpProxyToolConfig{Name: "t", Description: "d"},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ValidateToolConfig(c.config)
|
||||
if c.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user