Files
higress/registry/nacos/mcpserver/watcher_test.go
2025-06-10 17:11:34 +08:00

586 lines
16 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 mcpserver
import (
"fmt"
"reflect"
"strings"
"sync"
"testing"
apiv1 "github.com/alibaba/higress/api/networking/v1"
common2 "github.com/alibaba/higress/pkg/ingress/kube/common"
provider "github.com/alibaba/higress/registry"
"github.com/alibaba/higress/registry/memory"
"github.com/nacos-group/nacos-sdk-go/v2/model"
"github.com/stretchr/testify/mock"
wrappers "google.golang.org/protobuf/types/known/wrapperspb"
"istio.io/api/networking/v1alpha3"
"istio.io/istio/pkg/config"
"istio.io/istio/pkg/config/constants"
"istio.io/istio/pkg/config/schema/gvk"
)
type mockWatcher struct {
watcher
mock.Mock
}
func newTestWatcher(cache memory.Cache, opts ...WatcherOption) mockWatcher {
w := &watcher{
watchingConfig: make(map[string]bool),
RegistryType: "mcpserver",
Status: provider.UnHealthy,
cache: cache,
mutex: &sync.Mutex{},
stop: make(chan struct{}),
}
w.NacosRefreshInterval = int64(DefaultRefreshInterval)
for _, opt := range opts {
opt(w)
}
if w.NacosNamespace == "" {
w.NacosNamespace = w.NacosNamespaceId
}
return mockWatcher{watcher: *w, Mock: mock.Mock{}}
}
func testCallback(msc *McpServerConfig) memory.Cache {
registryConfig := &apiv1.RegistryConfig{
Type: string(provider.Nacos),
Name: "mse-nacos-public",
Domain: "",
Port: 8848,
NacosAddressServer: "",
NacosAccessKey: "ak",
NacosSecretKey: "sk",
NacosNamespaceId: "",
NacosNamespace: "public",
NacosGroups: []string{"dev"},
NacosRefreshInterval: 0,
EnableMCPServer: wrappers.Bool(true),
McpServerExportDomains: []string{"mcp.com"},
McpServerBaseUrl: "/mcp-servers/",
EnableScopeMcpServers: wrappers.Bool(true),
AllowMcpServers: []string{"mcp-server-1", "mcp-server-2"},
Metadata: map[string]*apiv1.InnerMap{
"routeName": &apiv1.InnerMap{
InnerMap: map[string]string{"mcp-server-1": "mcp-route-1", "mcp-server-2": "mcp-route-2"},
},
},
}
localCache := memory.NewCache()
testWatcher := newTestWatcher(localCache,
WithType(registryConfig.Type),
WithName(registryConfig.Name),
WithNacosAddressServer(registryConfig.NacosAddressServer),
WithDomain(registryConfig.Domain),
WithPort(registryConfig.Port),
WithNacosNamespaceId(registryConfig.NacosNamespaceId),
WithNacosNamespace(registryConfig.NacosNamespace),
WithNacosGroups(registryConfig.NacosGroups),
WithNacosAccessKey(registryConfig.NacosAccessKey),
WithNacosSecretKey(registryConfig.NacosSecretKey),
WithNacosRefreshInterval(registryConfig.NacosRefreshInterval),
WithMcpExportDomains(registryConfig.McpServerExportDomains),
WithMcpBaseUrl(registryConfig.McpServerBaseUrl),
WithEnableMcpServer(registryConfig.EnableMCPServer))
testWatcher.AppendServiceUpdateHandler(func() {
fmt.Println("testWatcher service update success")
})
callback := testWatcher.mcpServerListener("mock-data-id")
callback(msc)
return localCache
}
func Test_Watcher(t *testing.T) {
dataId := "mock-data-id"
testCase := []struct {
name string
msc *McpServerConfig
dataId string
wantConfig map[string]*config.Config
}{
{
name: "normal case",
dataId: dataId,
msc: &McpServerConfig{
Credentials: map[string]interface{}{
"test-server": map[string]string{"data": "value"},
},
ServiceInfo: &model.Service{
Hosts: []model.Instance{
{
Ip: "127.0.0.1",
Port: 8080,
Metadata: map[string]string{"protocol": "http"},
},
},
},
ServerSpecConfig: `{
"name": "explore",
"protocol": "http",
"description": "explore",
"remoteServerConfig": {
"serviceRef": {
"namespaceId": "public",
"groupName": "DEFAULT_GROUP",
"serviceName": "explore"
},
"exportPath": ""
},
"enabled": true
}`,
ToolsSpecConfig: `{
"tools": [
{
"name": "explore",
"description": "find name from tag",
"inputSchema": {
"type": "object",
"properties": {
"tags": {
"type": "string",
"description": "tag"
}
}
}
}
],
"toolsMeta": {
"explore": {
"enabled": true,
"templates": {
"json-go-template": {
"requestTemplate": {
"method": "GET",
"url": "/v0/explore",
"argsToUrlParam": true
}
}
}
}
}
}`,
},
wantConfig: map[string]*config.Config{
gvk.ServiceEntry.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.ServiceEntry,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedSeName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.ServiceEntry{
Hosts: []string{"explore.DEFAULT-GROUP.public.nacos"},
Ports: []*v1alpha3.ServicePort{
{
Number: 8080,
Name: "HTTP",
Protocol: "HTTP",
},
},
Location: v1alpha3.ServiceEntry_MESH_INTERNAL,
Resolution: v1alpha3.ServiceEntry_STATIC,
Endpoints: []*v1alpha3.WorkloadEntry{
{
Address: "127.0.0.1",
Ports: map[string]uint32{
"HTTP": 8080,
},
Labels: map[string]string{
"protocol": "http",
},
},
},
},
},
gvk.VirtualService.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.VirtualService,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedVsName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.VirtualService{
Gateways: []string{"/" + common2.CleanHost("mcp.com"), common2.CreateConvertedName(constants.IstioIngressGatewayName, common2.CleanHost("mcp.com"))},
Hosts: []string{"mcp.com"},
Http: []*v1alpha3.HTTPRoute{
{
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedHttpRouteName, strings.TrimSuffix(dataId, ".json")),
Match: []*v1alpha3.HTTPMatchRequest{
{
Uri: &v1alpha3.StringMatch{
MatchType: &v1alpha3.StringMatch_Exact{
Exact: "/mcp-servers/explore",
},
},
},
{
Uri: &v1alpha3.StringMatch{
MatchType: &v1alpha3.StringMatch_Prefix{
Prefix: "/mcp-servers/explore/",
},
},
},
},
Route: []*v1alpha3.HTTPRouteDestination{
{
Destination: &v1alpha3.Destination{
Host: "explore.DEFAULT-GROUP.public.nacos",
},
},
},
},
},
},
},
},
},
{
name: "sse and dns endpoint case",
dataId: dataId,
msc: &McpServerConfig{
Credentials: map[string]interface{}{
"test-server": map[string]string{"data": "value"},
},
ServiceInfo: &model.Service{
Hosts: []model.Instance{
{
Ip: "example.com",
Port: 8080,
Metadata: map[string]string{"protocol": "http"},
},
},
},
ServerSpecConfig: `{
"name": "explore",
"protocol": "mcp-sse",
"description": "explore",
"remoteServerConfig": {
"serviceRef": {
"namespaceId": "public",
"groupName": "DEFAULT_GROUP",
"serviceName": "explore"
},
"exportPath": ""
},
"enabled": true
}`,
ToolsSpecConfig: `{
"tools": [
{
"name": "explore",
"description": "find name from tag",
"inputSchema": {
"type": "object",
"properties": {
"tags": {
"type": "string",
"description": "tag"
}
}
}
}
],
"toolsMeta": {
"explore": {
"enabled": true,
"templates": {
"json-go-template": {
"requestTemplate": {
"method": "GET",
"url": "/v0/explore",
"argsToUrlParam": true
}
}
}
}
}
}`,
},
wantConfig: map[string]*config.Config{
gvk.ServiceEntry.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.ServiceEntry,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedSeName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.ServiceEntry{
Hosts: []string{"explore.DEFAULT-GROUP.public.nacos"},
Ports: []*v1alpha3.ServicePort{
{
Number: 8080,
Name: "HTTP",
Protocol: "HTTP",
},
},
Location: v1alpha3.ServiceEntry_MESH_INTERNAL,
Resolution: v1alpha3.ServiceEntry_DNS,
Endpoints: []*v1alpha3.WorkloadEntry{
{
Address: "example.com",
Ports: map[string]uint32{
"HTTP": 8080,
},
Labels: map[string]string{
"protocol": "http",
},
},
},
},
},
gvk.VirtualService.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.VirtualService,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedVsName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.VirtualService{
Gateways: []string{"/" + common2.CleanHost("mcp.com"), common2.CreateConvertedName(constants.IstioIngressGatewayName, common2.CleanHost("mcp.com"))},
Hosts: []string{"mcp.com"},
Http: []*v1alpha3.HTTPRoute{
{
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedHttpRouteName, strings.TrimSuffix(dataId, ".json")),
Match: []*v1alpha3.HTTPMatchRequest{
{
Uri: &v1alpha3.StringMatch{
MatchType: &v1alpha3.StringMatch_Exact{
Exact: "/mcp-servers/explore",
},
},
},
{
Uri: &v1alpha3.StringMatch{
MatchType: &v1alpha3.StringMatch_Prefix{
Prefix: "/mcp-servers/explore/",
},
},
},
},
Route: []*v1alpha3.HTTPRouteDestination{
{
Destination: &v1alpha3.Destination{
Host: "explore.DEFAULT-GROUP.public.nacos",
},
},
},
Rewrite: &v1alpha3.HTTPRewrite{
Uri: "/",
Authority: "example.com",
},
},
},
},
},
gvk.DestinationRule.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.DestinationRule,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedDrName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.DestinationRule{
Host: "explore.DEFAULT-GROUP.public.nacos",
TrafficPolicy: &v1alpha3.TrafficPolicy{
LoadBalancer: &v1alpha3.LoadBalancerSettings{
LbPolicy: &v1alpha3.LoadBalancerSettings_ConsistentHash{
ConsistentHash: &v1alpha3.LoadBalancerSettings_ConsistentHashLB{
HashKey: &v1alpha3.LoadBalancerSettings_ConsistentHashLB_UseSourceIp{
UseSourceIp: true,
},
},
},
},
},
},
},
},
},
{
name: "https and dns case",
dataId: dataId,
msc: &McpServerConfig{
Credentials: map[string]interface{}{
"test-server": map[string]string{"data": "value"},
},
ServiceInfo: &model.Service{
Hosts: []model.Instance{
{
Ip: "example.com",
Port: 8080,
Metadata: map[string]string{"protocol": "https"},
},
},
},
ServerSpecConfig: `{
"name": "explore",
"protocol": "https",
"description": "explore",
"remoteServerConfig": {
"serviceRef": {
"namespaceId": "public",
"groupName": "DEFAULT_GROUP",
"serviceName": "explore"
},
"exportPath": ""
},
"enabled": true
}`,
ToolsSpecConfig: `{
"tools": [
{
"name": "explore",
"description": "find name from tag",
"inputSchema": {
"type": "object",
"properties": {
"tags": {
"type": "string",
"description": "tag"
}
}
}
}
],
"toolsMeta": {
"explore": {
"enabled": true,
"templates": {
"json-go-template": {
"requestTemplate": {
"method": "GET",
"url": "/v0/explore",
"argsToUrlParam": true
}
}
}
}
}
}`,
},
wantConfig: map[string]*config.Config{
gvk.ServiceEntry.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.ServiceEntry,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedSeName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.ServiceEntry{
Hosts: []string{"explore.DEFAULT-GROUP.public.nacos"},
Ports: []*v1alpha3.ServicePort{
{
Number: 8080,
Name: "HTTPS",
Protocol: "HTTPS",
},
},
Location: v1alpha3.ServiceEntry_MESH_INTERNAL,
Resolution: v1alpha3.ServiceEntry_DNS,
Endpoints: []*v1alpha3.WorkloadEntry{
{
Address: "example.com",
Ports: map[string]uint32{
"HTTPS": 8080,
},
Labels: map[string]string{
"protocol": "https",
},
},
},
},
},
gvk.VirtualService.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.VirtualService,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedVsName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.VirtualService{
Gateways: []string{"/" + common2.CleanHost("mcp.com"), common2.CreateConvertedName(constants.IstioIngressGatewayName, common2.CleanHost("mcp.com"))},
Hosts: []string{"mcp.com"},
Http: []*v1alpha3.HTTPRoute{
{
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedHttpRouteName, strings.TrimSuffix(dataId, ".json")),
Match: []*v1alpha3.HTTPMatchRequest{
{
Uri: &v1alpha3.StringMatch{
MatchType: &v1alpha3.StringMatch_Exact{
Exact: "/mcp-servers/explore",
},
},
},
{
Uri: &v1alpha3.StringMatch{
MatchType: &v1alpha3.StringMatch_Prefix{
Prefix: "/mcp-servers/explore/",
},
},
},
},
Route: []*v1alpha3.HTTPRouteDestination{
{
Destination: &v1alpha3.Destination{
Host: "explore.DEFAULT-GROUP.public.nacos",
},
},
},
Rewrite: &v1alpha3.HTTPRewrite{
Authority: "example.com",
},
},
},
},
},
gvk.DestinationRule.String(): &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.DestinationRule,
Name: fmt.Sprintf("%s-%s", provider.IstioMcpAutoGeneratedDrName, strings.TrimSuffix(dataId, ".json")),
},
Spec: &v1alpha3.DestinationRule{
Host: "explore.DEFAULT-GROUP.public.nacos",
TrafficPolicy: &v1alpha3.TrafficPolicy{
Tls: &v1alpha3.ClientTLSSettings{
Mode: v1alpha3.ClientTLSSettings_SIMPLE,
},
},
},
},
},
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
localCache := testCallback(tc.msc)
se := localCache.GetAllConfigs(gvk.ServiceEntry)[dataId]
wantSe := tc.wantConfig[gvk.ServiceEntry.String()]
if !reflect.DeepEqual(se, wantSe) {
t.Errorf("se is not equal, want %v\n, got %v", wantSe, se)
}
vs := localCache.GetAllConfigs(gvk.VirtualService)[dataId]
wantVs := tc.wantConfig[gvk.VirtualService.String()]
if !reflect.DeepEqual(vs, wantVs) {
t.Errorf("vs is not equal, want %v\n, got %v", wantVs, vs)
}
dr := localCache.GetAllConfigs(gvk.DestinationRule)[dataId]
wantDr := tc.wantConfig[gvk.DestinationRule.String()]
if !reflect.DeepEqual(dr, wantDr) {
t.Errorf("dr is not equal, want %v\n, got %v", wantDr, dr)
}
})
}
}