fix: refactored mcp server auto discovery logic and fix some issue (#2382)

Co-authored-by: johnlanni <zty98751@alibaba-inc.com>
This commit is contained in:
EricaLiu
2025-06-10 17:11:34 +08:00
committed by GitHub
parent 69d877c116
commit d2f09fe8c5
15 changed files with 1822 additions and 832 deletions

View File

@@ -0,0 +1,546 @@
// 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 (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/nacos-group/nacos-sdk-go/v2/clients"
"github.com/nacos-group/nacos-sdk-go/v2/clients/config_client"
"github.com/nacos-group/nacos-sdk-go/v2/clients/naming_client"
"github.com/nacos-group/nacos-sdk-go/v2/common/constant"
"github.com/nacos-group/nacos-sdk-go/v2/model"
"github.com/nacos-group/nacos-sdk-go/v2/vo"
)
const McpServerVersionGroup = "mcp-server-versions"
const McpServerSpecGroup = "mcp-server"
const McpToolSpecGroup = "mcp-tools"
const SystemConfigIdPrefix = "system-"
const CredentialPrefix = "credentials-"
const DefaultNacosListConfigMode = "blur"
const ListMcpServerConfigIdPattern = "*mcp-versions.json"
const DefaultNacosListConfigPageSize = 50
type ServerSpecInfo struct {
RemoteServerConfig *RemoteServerConfig `json:"remoteServerConfig"`
}
type RemoteServerConfig struct {
ServiceRef *ServiceRef `json:"serviceRef"`
}
type ServiceRef struct {
ServiceName string `json:"serviceName"`
GroupName string `json:"groupName"`
NamespaceId string `json:"namespaceId"`
}
type NacosRegistryClient struct {
namespaceId string
configClient config_client.IConfigClient
namingClient naming_client.INamingClient
servers map[string]*ServerContext
}
type VersionedMcpServerInfo struct {
serverInfo *BasicMcpServerInfo
version string
}
type ServerContext struct {
id string
versionedMcpServerInfo *VersionedMcpServerInfo
serverChangeListener McpServerListener
configsMap map[string]*ConfigListenerWrap
serviceInfo *model.Service
namingCallback func(services []model.Instance, err error)
}
type McpServerConfig struct {
ServerSpecConfig string
ToolsSpecConfig string
ServiceInfo *model.Service
Credentials map[string]interface{}
}
type ConfigListenerWrap struct {
dataId string
group string
data string
listener func(namespace, group, dataId, data string)
}
type BasicMcpServerInfo struct {
Name string `json:"name"`
Id string `json:"id"`
FrontProtocol string `json:"frontProtocol"`
Protocol string `json:"protocol"`
}
type VersionsMcpServerInfo struct {
BasicMcpServerInfo
LatestPublishedVersion string `json:"latestPublishedVersion"`
Versions []*VersionDetail `json:"versionDetails"`
}
type VersionDetail struct {
Version string `json:"version"`
IsLatest bool `json:"is_latest"`
}
type McpServerListener func(info *McpServerConfig)
func NewMcpRegistryClient(clientConfig *constant.ClientConfig, serverConfig []constant.ServerConfig, namespaceId string) (*NacosRegistryClient, error) {
clientParam := vo.NacosClientParam{
ClientConfig: clientConfig,
ServerConfigs: serverConfig,
}
configClient, err := clients.NewConfigClient(clientParam)
namingClient, err := clients.NewNamingClient(clientParam)
if err != nil {
return nil, err
}
return &NacosRegistryClient{
namespaceId: namespaceId,
configClient: configClient,
namingClient: namingClient,
servers: map[string]*ServerContext{},
}, nil
}
func (n *NacosRegistryClient) listMcpServerConfigs() ([]model.ConfigItem, error) {
currentPageNum := 1
result := make([]model.ConfigItem, 0)
for {
configPage, err := n.configClient.SearchConfig(vo.SearchConfigParam{
Search: DefaultNacosListConfigMode,
DataId: ListMcpServerConfigIdPattern,
Group: McpServerVersionGroup,
PageNo: currentPageNum,
PageSize: DefaultNacosListConfigPageSize,
})
if err != nil {
mcpServerLog.Errorf("List mcp server configs for page size %d, page number %d error %v", currentPageNum, DefaultNacosListConfigPageSize)
}
if configPage == nil {
mcpServerLog.Errorf("List mcp server configs for page size %d, page number %d null %v", currentPageNum, DefaultNacosListConfigPageSize)
continue
}
result = append(result, configPage.PageItems...)
if configPage.PageNumber >= configPage.PagesAvailable {
break
}
currentPageNum += 1
}
return result, nil
}
// ListMcpServer List all mcp server from nacos mcp registry /**
func (n *NacosRegistryClient) ListMcpServer() ([]BasicMcpServerInfo, error) {
configs, err := n.listMcpServerConfigs()
if err != nil {
return nil, err
}
var result []BasicMcpServerInfo
for _, config := range configs {
mcpServerBasicConfig, err := n.configClient.GetConfig(vo.ConfigParam{
Group: McpServerVersionGroup,
DataId: config.DataId,
})
if err != nil {
mcpServerLog.Errorf("Get mcp server version config (dataId: %s) error, %v", config.DataId, err)
continue
}
if mcpServerBasicConfig == "" {
mcpServerLog.Infof("get empty mcp server version config (dataId: %s)", config.DataId)
continue
}
mcpServer := BasicMcpServerInfo{}
err = json.Unmarshal([]byte(mcpServerBasicConfig), &mcpServer)
if err != nil {
mcpServerLog.Errorf("Parse mcp server version config error %v", err)
continue
}
if !isMcpServerShouldBeDiscoveryForGateway(mcpServer) {
mcpServerLog.Infof("mcp server %s don't need to be discovered for gateway, skip it", mcpServerBasicConfig)
continue
}
result = append(result, mcpServer)
}
return result, nil
}
func isMcpServerShouldBeDiscoveryForGateway(info BasicMcpServerInfo) bool {
return "mcp-sse" == info.FrontProtocol || "mcp-streamable" == info.FrontProtocol
}
// ListenToMcpServer Listen to mcp server config and backend service
func (n *NacosRegistryClient) ListenToMcpServer(id string, listener McpServerListener) error {
versionConfigId := fmt.Sprintf("%s-mcp-versions.json", id)
serverVersionConfig, err := n.configClient.GetConfig(vo.ConfigParam{
Group: McpServerVersionGroup,
DataId: versionConfigId,
})
if err != nil {
mcpServerLog.Errorf("Get mcp server(id: %s) version config error, %v", id, err)
} else {
mcpServerLog.Infof("Get mcp server(id: %s) version config success, config is:\n %v", id, serverVersionConfig)
}
versionConfigCallBack := func(namespace string, group string, dataId string, content string) {
mcpServerLog.Infof("Call back to mcp server %s", id)
info := VersionsMcpServerInfo{}
err = json.Unmarshal([]byte(content), &info)
if err != nil {
mcpServerLog.Errorf("Parse mcp server (id: %s) version config callback error, %v", id, err)
return
}
latestVersion := info.LatestPublishedVersion
ctx := n.servers[id]
if ctx.versionedMcpServerInfo == nil {
ctx.versionedMcpServerInfo = &VersionedMcpServerInfo{}
}
ctx.versionedMcpServerInfo.serverInfo = &info.BasicMcpServerInfo
// first time the version is empty so it will trigger the change finally.
if ctx.versionedMcpServerInfo.version != latestVersion {
ctx.versionedMcpServerInfo.version = latestVersion
n.onServerVersionChanged(ctx)
n.triggerMcpServerChange(id)
}
}
n.servers[id] = &ServerContext{
id: id,
serverChangeListener: listener,
configsMap: map[string]*ConfigListenerWrap{
McpServerVersionGroup: {
dataId: versionConfigId,
group: McpServerVersionGroup,
listener: versionConfigCallBack,
},
},
}
// trigger callback manually
versionConfigCallBack(n.namespaceId, McpServerVersionGroup, versionConfigId, serverVersionConfig)
// Listen after get config to avoid multi-callback on same version
err = n.configClient.ListenConfig(vo.ConfigParam{
Group: McpServerVersionGroup,
DataId: versionConfigId,
OnChange: versionConfigCallBack,
})
if err != nil {
return err
}
return nil
}
func (n *NacosRegistryClient) onServerVersionChanged(ctx *ServerContext) {
id := ctx.versionedMcpServerInfo.serverInfo.Id
version := ctx.versionedMcpServerInfo.version
configsMap := map[string]string{
McpServerSpecGroup: fmt.Sprintf("%s-%s-mcp-server.json", id, version),
McpToolSpecGroup: fmt.Sprintf("%s-%s-mcp-tools.json", id, version),
}
for group, dataId := range configsMap {
configsKey := fmt.Sprintf(SystemConfigIdPrefix+"%s@@%s", id, group)
// Only listen to the last version of the server, so we should exist and cancel it first
if data, exist := ctx.configsMap[configsKey]; exist {
err := n.cancelListenToConfig(data)
if err != nil {
mcpServerLog.Errorf("cancel listen to old config %v error %v", dataId, err)
}
}
configListenerWrap, err := n.ListenToConfig(ctx, dataId, group)
if err != nil {
mcpServerLog.Errorf("listen to config %v error %v", dataId, err)
continue
}
ctx.configsMap[configsKey] = configListenerWrap
}
}
func (n *NacosRegistryClient) triggerMcpServerChange(id string) {
if context, exist := n.servers[id]; exist {
if config := mapConfigMapToServerConfig(context); config != nil {
context.serverChangeListener(config)
}
}
}
func mapConfigMapToServerConfig(ctx *ServerContext) *McpServerConfig {
result := &McpServerConfig{
Credentials: map[string]interface{}{},
}
configMaps := ctx.configsMap
for key, data := range configMaps {
if strings.HasPrefix(key, SystemConfigIdPrefix) {
group := strings.Split(key, "@@")[1]
if group == McpServerSpecGroup {
result.ServerSpecConfig = data.data
} else if group == McpToolSpecGroup {
result.ToolsSpecConfig = data.data
}
} else if strings.HasPrefix(key, CredentialPrefix) {
credentialId := strings.ReplaceAll(key, CredentialPrefix, "")
var credData interface{}
if err := json.Unmarshal([]byte(data.data), &credData); err != nil {
mcpServerLog.Errorf("parse credential %v error %v", credentialId, err)
// keep origin data if data is not an object
result.Credentials[credentialId] = data.data
} else {
result.Credentials[credentialId] = credData
}
}
}
result.ServiceInfo = ctx.serviceInfo
return result
}
func (n *NacosRegistryClient) replaceTemplateAndExactConfigsItems(ctx *ServerContext, config *ConfigListenerWrap) map[string]*ConfigListenerWrap {
result := map[string]*ConfigListenerWrap{}
compile := regexp.MustCompile("\\$\\{nacos\\.([a-zA-Z0-9-_:\\\\.]+/[a-zA-Z0-9-_:\\\\.]+)}")
allConfigs := compile.FindAllString(config.data, -1)
allConfigsMap := map[string]string{}
for _, config := range allConfigs {
allConfigsMap[config] = config
}
newContent := config.data
for _, data := range allConfigsMap {
dataIdAndGroup := strings.ReplaceAll(data, "${nacos.", "")
dataIdAndGroup = dataIdAndGroup[0 : len(dataIdAndGroup)-1]
dataIdAndGroupArray := strings.Split(dataIdAndGroup, "/")
dataId := strings.TrimSpace(dataIdAndGroupArray[0])
group := strings.TrimSpace(dataIdAndGroupArray[1])
configWrap, err := n.ListenToConfig(ctx, dataId, group)
if err != nil {
mcpServerLog.Errorf("extract configs %v from content error %v", dataId, err)
continue
}
result[CredentialPrefix+configWrap.group+"_"+configWrap.dataId] = configWrap
newContent = strings.Replace(newContent, data, ".config.credentials."+group+"_"+dataId, -1)
}
config.data = newContent
return result
}
func (n *NacosRegistryClient) resetNacosTemplateConfigs(ctx *ServerContext, config *ConfigListenerWrap) {
newCredentials := n.replaceTemplateAndExactConfigsItems(ctx, config)
// cancel all old config listener
for key, wrap := range ctx.configsMap {
if strings.HasPrefix(key, CredentialPrefix) {
if _, ok := newCredentials[key]; !ok {
err := n.cancelListenToConfig(wrap)
if err != nil {
mcpServerLog.Errorf("cancel listen to old credential listener error %v", err)
continue
}
}
}
}
for _, data := range newCredentials {
ctx.configsMap[CredentialPrefix+data.group+"_"+data.dataId] = data
}
}
func (n *NacosRegistryClient) refreshServiceListenerIfNeeded(ctx *ServerContext, serverConfig string) {
var serverInfo ServerSpecInfo
err := json.Unmarshal([]byte(serverConfig), &serverInfo)
if err != nil {
mcpServerLog.Errorf("parse server config error %v", err)
return
}
if serverInfo.RemoteServerConfig != nil && serverInfo.RemoteServerConfig.ServiceRef != nil {
ref := serverInfo.RemoteServerConfig.ServiceRef
if ctx.serviceInfo != nil {
if ctx.serviceInfo.Name == ref.ServiceName && ctx.serviceInfo.GroupName == ref.GroupName {
return
}
err := n.namingClient.Unsubscribe(&vo.SubscribeParam{
GroupName: ctx.serviceInfo.GroupName,
ServiceName: ctx.serviceInfo.Name,
SubscribeCallback: ctx.namingCallback,
})
if err != nil {
mcpServerLog.Errorf("unsubscribe service error:%v, groupName:%s, serviceName:%s", err, ctx.serviceInfo.GroupName, ctx.serviceInfo.Name)
}
}
service, err := n.namingClient.GetService(vo.GetServiceParam{
GroupName: ref.GroupName,
ServiceName: ref.ServiceName,
})
if err != nil {
mcpServerLog.Errorf("get service error:%v, groupName:%s, serviceName:%s", err, ref.GroupName, ref.ServiceName)
return
}
ctx.serviceInfo = &service
if ctx.namingCallback == nil {
ctx.namingCallback = func(services []model.Instance, err error) {
if ctx.serviceInfo == nil {
ctx.serviceInfo = &model.Service{
GroupName: ctx.serviceInfo.GroupName,
Name: ctx.serviceInfo.Name,
}
}
ctx.serviceInfo.Name = ref.ServiceName
ctx.serviceInfo.GroupName = ref.GroupName
ctx.serviceInfo.Hosts = services
n.triggerMcpServerChange(ctx.id)
}
}
err = n.namingClient.Subscribe(&vo.SubscribeParam{
GroupName: ctx.serviceInfo.GroupName,
ServiceName: ctx.serviceInfo.Name,
SubscribeCallback: ctx.namingCallback,
})
if err != nil {
mcpServerLog.Errorf("subscribe service error:%v, groupName:%s, serviceName:%s", err, ctx.serviceInfo.GroupName, ctx.serviceInfo.Name)
}
}
}
func (n *NacosRegistryClient) ListenToConfig(ctx *ServerContext, dataId string, group string) (*ConfigListenerWrap, error) {
wrap := ConfigListenerWrap{
dataId: dataId,
group: group,
}
configListener := func(namespace, group, dataId, data string) {
if group == McpToolSpecGroup {
n.resetNacosTemplateConfigs(ctx, &wrap)
} else if group == McpServerSpecGroup {
n.refreshServiceListenerIfNeeded(ctx, data)
}
if ctx.serverChangeListener != nil && wrap.data != data {
wrap.data = data
n.triggerMcpServerChange(ctx.versionedMcpServerInfo.serverInfo.Id)
}
}
config, err := n.configClient.GetConfig(vo.ConfigParam{
DataId: dataId,
Group: group,
})
if err != nil {
return nil, err
}
wrap.listener = configListener
wrap.data = config
if group == McpToolSpecGroup {
n.resetNacosTemplateConfigs(ctx, &wrap)
} else if group == McpServerSpecGroup {
n.refreshServiceListenerIfNeeded(ctx, wrap.data)
}
err = n.configClient.ListenConfig(vo.ConfigParam{
DataId: dataId,
Group: group,
OnChange: configListener,
})
if err != nil {
return nil, err
}
return &wrap, nil
}
func (n *NacosRegistryClient) cancelListenToConfig(wrap *ConfigListenerWrap) error {
return n.configClient.CancelListenConfig(vo.ConfigParam{
DataId: wrap.dataId,
Group: wrap.group,
OnChange: wrap.listener,
})
}
func (n *NacosRegistryClient) CancelListenToServer(id string) error {
if server, exist := n.servers[id]; exist && server != nil {
defer delete(n.servers, id)
for _, wrap := range server.configsMap {
if wrap != nil {
err := n.configClient.CancelListenConfig(vo.ConfigParam{
DataId: wrap.dataId,
Group: wrap.group,
OnChange: wrap.listener,
})
if err != nil {
mcpServerLog.Errorf("cancel listen config error:%v, dataId:%s, group:%s", err, wrap.dataId, wrap.group)
continue
}
}
}
if server.serviceInfo != nil {
err := n.namingClient.Unsubscribe(&vo.SubscribeParam{
GroupName: server.serviceInfo.GroupName,
ServiceName: server.serviceInfo.Name,
SubscribeCallback: server.namingCallback,
})
if err != nil {
mcpServerLog.Errorf("unsubscribe service error:%v, groupName:%s, serviceName:%s", err, server.serviceInfo.GroupName, server.serviceInfo.Name)
return err
}
}
}
return nil
}
func (n *NacosRegistryClient) CloseClient() {
n.namingClient.CloseClient()
n.configClient.CloseClient()
}

View File

@@ -1,180 +0,0 @@
// 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"
"github.com/nacos-group/nacos-sdk-go/v2/clients/config_client"
"github.com/nacos-group/nacos-sdk-go/v2/clients/naming_client"
"github.com/nacos-group/nacos-sdk-go/v2/model"
"github.com/nacos-group/nacos-sdk-go/v2/vo"
)
type MultiConfigListener struct {
configClient config_client.IConfigClient
onChange func(map[string]string)
configCache map[string]string
innerCallback func(string, string, string, string)
}
func NewMultiConfigListener(configClient config_client.IConfigClient, onChange func(map[string]string)) *MultiConfigListener {
result := &MultiConfigListener{
configClient: configClient,
configCache: make(map[string]string),
onChange: onChange,
}
result.innerCallback = func(namespace string, group string, dataId string, content string) {
result.configCache[group+DefaultJoiner+dataId] = content
result.onChange(result.configCache)
}
return result
}
func (l *MultiConfigListener) StartListen(configs []vo.ConfigParam) error {
for _, config := range configs {
content, err := l.configClient.GetConfig(vo.ConfigParam{
DataId: config.DataId,
Group: config.Group,
})
if err != nil {
return fmt.Errorf("get config %s/%s err: %v", config.Group, config.DataId, err)
}
l.configCache[config.Group+DefaultJoiner+config.DataId] = content
err = l.configClient.ListenConfig(vo.ConfigParam{
DataId: config.DataId,
Group: config.Group,
OnChange: l.innerCallback,
})
if err != nil {
return fmt.Errorf("listener to config %s/%s error: %w", config.Group, config.DataId, err)
}
}
l.onChange(l.configCache)
return nil
}
func (l *MultiConfigListener) Stop() {
l.configClient.CloseClient()
}
func (l *MultiConfigListener) CancelListen(configs []vo.ConfigParam) error {
for _, config := range configs {
if _, ok := l.configCache[config.Group+DefaultJoiner+config.DataId]; ok {
err := l.configClient.CancelListenConfig(vo.ConfigParam{
DataId: config.DataId,
Group: config.Group,
})
if err != nil {
return fmt.Errorf("cancel config %s/%s error: %w", config.Group, config.DataId, err)
}
delete(l.configCache, config.Group+config.DataId)
}
}
return nil
}
type ServiceCache struct {
services map[string]*NacosServiceRef
client naming_client.INamingClient
}
type NacosServiceRef struct {
refs map[string]func([]model.Instance)
callback func(services []model.Instance, err error)
instances *[]model.Instance
}
func NewServiceCache(client naming_client.INamingClient) *ServiceCache {
return &ServiceCache{
client: client,
services: make(map[string]*NacosServiceRef),
}
}
func (c *ServiceCache) AddListener(group string, serviceName string, key string, callback func([]model.Instance)) error {
uniqueServiceName := c.makeServiceUniqueName(group, serviceName)
if _, ok := c.services[uniqueServiceName]; !ok {
instances, err := c.client.SelectAllInstances(vo.SelectAllInstancesParam{
GroupName: group,
ServiceName: serviceName,
})
if err != nil {
return err
}
ref := &NacosServiceRef{
refs: map[string]func([]model.Instance){},
instances: &instances,
}
ref.callback = func(services []model.Instance, err error) {
ref.instances = &services
for _, refCallback := range ref.refs {
refCallback(*ref.instances)
}
}
c.services[uniqueServiceName] = ref
err = c.client.Subscribe(&vo.SubscribeParam{
GroupName: group,
ServiceName: serviceName,
SubscribeCallback: ref.callback,
})
if err != nil {
return err
}
}
ref := c.services[uniqueServiceName]
ref.refs[key] = callback
callback(*ref.instances)
return nil
}
func (c *ServiceCache) RemoveListener(group string, serviceName string, key string) error {
if ref, ok := c.services[c.makeServiceUniqueName(group, serviceName)]; ok {
delete(ref.refs, key)
if len(ref.refs) == 0 {
err := c.client.Unsubscribe(&vo.SubscribeParam{
GroupName: group,
ServiceName: serviceName,
SubscribeCallback: ref.callback,
})
delete(c.services, c.makeServiceUniqueName(group, serviceName))
if err != nil {
return err
}
}
}
return nil
}
func (c *ServiceCache) makeServiceUniqueName(group string, serviceName string) string {
return fmt.Sprintf("%s-%s", group, serviceName)
}
func (c *ServiceCache) Stop() {
c.client.CloseClient()
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,585 @@
// 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)
}
})
}
}