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

440 lines
15 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"
)
// TestMcpProxyServerBasicInterface tests that McpProxyServer implements the Server interface
func TestMcpProxyServerBasicInterface(t *testing.T) {
// This test will fail until we implement McpProxyServer
server := NewMcpProxyServer("test-proxy")
// Test Server interface implementation
assert.NotNil(t, server)
assert.Equal(t, "test-proxy", server.Name)
// Test that it implements all required methods
tools := server.GetMCPTools()
assert.NotNil(t, tools)
assert.Equal(t, 0, len(tools))
// Test Clone method
cloned := server.Clone()
assert.NotNil(t, cloned)
}
// TestMcpProxyServerConfiguration tests configuration setting and getting
func TestMcpProxyServerConfiguration(t *testing.T) {
server := NewMcpProxyServer("test-proxy")
// Set server fields directly
server.SetMcpServerURL("http://backend.example.com/mcp")
server.SetTimeout(5000)
// Add security scheme
scheme := SecurityScheme{
ID: "test-auth",
Type: "apiKey",
In: "header",
Name: "X-API-Key",
}
server.AddSecurityScheme(scheme)
// Verify server fields
assert.Equal(t, "http://backend.example.com/mcp", server.GetMcpServerURL())
assert.Equal(t, 5000, server.GetTimeout())
// Verify security scheme
retrievedScheme, exists := server.GetSecurityScheme("test-auth")
assert.True(t, exists)
assert.Equal(t, "test-auth", retrievedScheme.ID)
assert.Equal(t, "apiKey", retrievedScheme.Type)
}
// TestMcpProxyServerAddTool tests adding proxy tools
func TestMcpProxyServerAddTool(t *testing.T) {
server := NewMcpProxyServer("test-proxy")
toolConfig := McpProxyToolConfig{
Name: "test-tool",
Description: "Test tool for proxy",
Args: []ToolArg{
{
Name: "input",
Description: "Test input",
Type: "string",
Required: true,
},
},
}
err := server.AddProxyTool(toolConfig)
assert.NoError(t, err)
tools := server.GetMCPTools()
assert.Len(t, tools, 1)
assert.Contains(t, tools, "test-tool")
}
// TestMcpProxyServerSecuritySchemes tests security scheme management
func TestMcpProxyServerSecuritySchemes(t *testing.T) {
server := NewMcpProxyServer("test-proxy")
scheme := SecurityScheme{
ID: "test-auth",
Type: "apiKey",
In: "header",
Name: "X-API-Key",
}
server.AddSecurityScheme(scheme)
retrievedScheme, exists := server.GetSecurityScheme("test-auth")
assert.True(t, exists)
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)
}
})
}
}