diff --git a/pkg/ingress/config/ingress_config.go b/pkg/ingress/config/ingress_config.go index e63143197..6779bb9cb 100644 --- a/pkg/ingress/config/ingress_config.go +++ b/pkg/ingress/config/ingress_config.go @@ -63,6 +63,7 @@ import ( "github.com/alibaba/higress/pkg/ingress/kube/ingress" "github.com/alibaba/higress/pkg/ingress/kube/ingressv1" "github.com/alibaba/higress/pkg/ingress/kube/mcpbridge" + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" "github.com/alibaba/higress/pkg/ingress/kube/secret" "github.com/alibaba/higress/pkg/ingress/kube/util" "github.com/alibaba/higress/pkg/ingress/kube/wasmplugin" @@ -158,6 +159,8 @@ type IngressConfig struct { // secretConfigMgr manages secret dependencies secretConfigMgr *SecretConfigMgr + + mcpServerCache mcpserver.McpServerCache } // getSecretValue implements the getValue function for secret references @@ -224,6 +227,7 @@ func NewIngressConfig(localKubeClient kube.Client, xdsUpdater istiomodel.XDSUpda higressConfigController := configmap.NewController(localKubeClient, clusterId, namespace) config.configmapMgr = configmap.NewConfigmapMgr(xdsUpdater, namespace, higressConfigController, higressConfigController.Lister()) + config.configmapMgr.RegisterMcpServerProvider(&config.mcpServerCache) httpsConfigMgr, _ := cert.NewConfigMgr(namespace, localKubeClient.Kube()) config.httpsConfigMgr = httpsConfigMgr @@ -421,6 +425,10 @@ func (m *IngressConfig) createWrapperConfigs(configs []config.Config) []common.W m.watchedSecretSet = globalContext.WatchedSecrets m.mutex.Unlock() + if m.mcpServerCache.SetMcpServers(globalContext.McpServers) { + m.notifyXDSFullUpdate(mcpserver.GvkMcpServer, "mcp-server-annotation-change", nil) + } + return wrapperConfigs } @@ -590,7 +598,7 @@ func (m *IngressConfig) convertVirtualService(configs []common.WrapperConfig) [] Spec: vs, }) } - // add vs from naco3 for mcp server + // add vs from nacos3 for mcp server if m.RegistryReconciler != nil { allConfigsFromMcp := m.RegistryReconciler.GetAllConfigs(gvk.VirtualService) for _, cfg := range allConfigsFromMcp { @@ -1208,9 +1216,9 @@ func (m *IngressConfig) AddOrUpdateMcpBridge(clusterNamespacedName util.ClusterN f(config.Config{Meta: efMetadata}, config.Config{Meta: efMetadata}, istiomodel.EventUpdate) } }, m.localKubeClient, m.namespace, m.clusterId.String()) + m.configmapMgr.RegisterMcpServerProvider(m.RegistryReconciler) } reconciler := m.RegistryReconciler - m.configmapMgr.SetMcpReconciler(m.RegistryReconciler) err = reconciler.Reconcile(mcpbridge) if err != nil { IngressLog.Errorf("Mcpbridge reconcile failed, err:%v", err) @@ -1776,3 +1784,19 @@ func (m *IngressConfig) Patch(config.Config, config.PatchFunc) (string, error) { func (m *IngressConfig) Delete(config.GroupVersionKind, string, string, *string) error { return common.ErrUnsupportedOp } + +func (m *IngressConfig) notifyXDSFullUpdate(gvk config.GroupVersionKind, reason istiomodel.TriggerReason, updatedConfigName *util.ClusterNamespacedName) { + var configsUpdated map[istiomodel.ConfigKey]struct{} + if updatedConfigName != nil { + configsUpdated = map[istiomodel.ConfigKey]struct{}{{ + Kind: kind.MustFromGVK(gvk), + Name: updatedConfigName.Name, + Namespace: updatedConfigName.Namespace, + }: {}} + } + m.XDSUpdater.ConfigUpdate(&istiomodel.PushRequest{ + Full: true, + ConfigsUpdated: configsUpdated, + Reason: istiomodel.NewReasonStats(reason), + }) +} diff --git a/pkg/ingress/kube/annotations/annotations.go b/pkg/ingress/kube/annotations/annotations.go index 36e4a6dea..d24073925 100644 --- a/pkg/ingress/kube/annotations/annotations.go +++ b/pkg/ingress/kube/annotations/annotations.go @@ -21,6 +21,8 @@ import ( "istio.io/istio/pkg/cluster" "istio.io/istio/pkg/util/sets" listersv1 "k8s.io/client-go/listers/core/v1" + + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" ) type GlobalContext struct { @@ -30,6 +32,8 @@ type GlobalContext struct { ClusterSecretLister map[cluster.ID]listersv1.SecretLister ClusterServiceList map[cluster.ID]listersv1.ServiceLister + + McpServers []*mcpserver.McpServer } type Meta struct { @@ -169,6 +173,7 @@ func NewAnnotationHandlerManager() AnnotationHandler { match{}, headerControl{}, http2rpc{}, + mcpServer{}, }, gatewayHandlers: []GatewayHandler{ downstreamTLS{}, diff --git a/pkg/ingress/kube/annotations/mcpserver.go b/pkg/ingress/kube/annotations/mcpserver.go new file mode 100644 index 000000000..26a977633 --- /dev/null +++ b/pkg/ingress/kube/annotations/mcpserver.go @@ -0,0 +1,94 @@ +// Copyright (c) 2023 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 annotations + +import ( + "strings" + + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" + "github.com/alibaba/higress/pkg/ingress/log" +) + +const ( + enableMcpServer = "mcp-server" + mcpServerMatchRuleDomains = "mcp-server-match-rule-domains" + mcpServerMatchRuleType = "mcp-server-match-rule-type" + mcpServerMatchRuleValue = "mcp-server-match-rule-value" + mcpServerUpstreamType = "mcp-server-upstream-type" + mcpServerEnablePathRewrite = "mcp-server-enable-path-rewrite" + mcpServerPathRewritePrefix = "mcp-server-path-rewrite-prefix" +) + +// help to conform mcpServer implements method of Parse +var ( + _ Parser = &mcpServer{} +) + +type mcpServer struct{} + +func (a mcpServer) Parse(annotations Annotations, config *Ingress, globalContext *GlobalContext) error { + if globalContext == nil { + return nil + } + + ingressKey := config.Namespace + "/" + config.Name + + enabled, _ := annotations.ParseBoolASAP(enableMcpServer) + if !enabled { + return nil + } + + var matchRuleDomains []string + rawMatchRuleDomains, _ := annotations.ParseStringASAP(mcpServerMatchRuleDomains) + if rawMatchRuleDomains == "" || rawMatchRuleDomains == "*" { + // Match all domains. Leave an empty slice. + } else if strings.Contains(rawMatchRuleDomains, ",") { + matchRuleDomains = strings.Split(rawMatchRuleDomains, ",") + } else { + matchRuleDomains = []string{rawMatchRuleDomains} + } + + matchRuleType, _ := annotations.ParseStringASAP(mcpServerMatchRuleType) + if matchRuleType == "" { + log.IngressLog.Errorf("ingress %s: mcp-server-match-rule-path-type is empty", ingressKey) + return nil + } else if !mcpserver.ValidPathMatchTypes[matchRuleType] { + log.IngressLog.Errorf("ingress %s: mcp-server-match-rule-path-type %s is not supported", ingressKey, matchRuleType) + return nil + } + + matchRuleValue, _ := annotations.ParseStringASAP(mcpServerMatchRuleValue) + + upstreamType, _ := annotations.ParseStringASAP(mcpServerUpstreamType) + if upstreamType != "" && !mcpserver.ValidUpstreamTypes[upstreamType] { + log.IngressLog.Errorf("mcp-server-upstream-type %s is not supported", upstreamType) + return nil + } + + enablePathRewrite, _ := annotations.ParseBoolASAP(mcpServerEnablePathRewrite) + pathRewritePrefix, _ := annotations.ParseStringASAP(mcpServerPathRewritePrefix) + + globalContext.McpServers = append(globalContext.McpServers, &mcpserver.McpServer{ + Name: ingressKey, + Domains: matchRuleDomains, + PathMatchType: matchRuleType, + PathMatchValue: matchRuleValue, + UpstreamType: upstreamType, + EnablePathRewrite: enablePathRewrite, + PathRewritePrefix: pathRewritePrefix, + }) + + return nil +} diff --git a/pkg/ingress/kube/annotations/mcpserver_test.go b/pkg/ingress/kube/annotations/mcpserver_test.go new file mode 100644 index 000000000..613810ec7 --- /dev/null +++ b/pkg/ingress/kube/annotations/mcpserver_test.go @@ -0,0 +1,257 @@ +// Copyright (c) 2025 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 annotations + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" +) + +func TestMCPServer_Parse(t *testing.T) { + parser := mcpServer{} + testCases := []struct { + skip bool + input Annotations + expect *mcpserver.McpServer + }{ + { + // No annotation + input: Annotations{}, + expect: nil, + }, + { + // Not enabled + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "false", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "", + }, + expect: nil, + }, + { + // Enabled but no match rule type + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "", + }, + expect: nil, + }, + { + // Enabled but empty match rule type + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "", + }, + expect: nil, + }, + { + // Enabled but bad match rule type + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "bad-type", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "", + }, + expect: nil, + }, + { + // Enabled but bad upstream type + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "bad-type", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "", + }, + expect: nil, + }, + { + // Enabled and rewrite not enabled + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "false", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "/", + }, + expect: &mcpserver.McpServer{ + Name: "default/route", + Domains: []string{"www.foo.com"}, + PathMatchType: "prefix", + PathMatchValue: "/mcp", + UpstreamType: "rest", + EnablePathRewrite: false, + PathRewritePrefix: "/", + }, + }, + { + // Enabled and rewrite not enabled and empty domain + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "false", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "/", + }, + expect: &mcpserver.McpServer{ + Name: "default/route", + Domains: nil, + PathMatchType: "prefix", + PathMatchValue: "/mcp", + UpstreamType: "rest", + EnablePathRewrite: false, + PathRewritePrefix: "/", + }, + }, + { + // Enabled and rewrite not enabled and wildcard domain + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "*", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "false", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "/", + }, + expect: &mcpserver.McpServer{ + Name: "default/route", + Domains: nil, + PathMatchType: "prefix", + PathMatchValue: "/mcp", + UpstreamType: "rest", + EnablePathRewrite: false, + PathRewritePrefix: "/", + }, + }, + { + // Enabled and rewrite enabled with root + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "true", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "/", + }, + expect: &mcpserver.McpServer{ + Name: "default/route", + Domains: []string{"www.foo.com"}, + PathMatchType: "prefix", + PathMatchValue: "/mcp", + UpstreamType: "rest", + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + { + // Enabled and rewrite enabled with root + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "prefix", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "rest", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "true", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "/mcp-api", + }, + expect: &mcpserver.McpServer{ + Name: "default/route", + Domains: []string{"www.foo.com"}, + PathMatchType: "prefix", + PathMatchValue: "/mcp", + UpstreamType: "rest", + EnablePathRewrite: true, + PathRewritePrefix: "/mcp-api", + }, + }, + { + // Enabled and multiple domains + input: Annotations{ + buildHigressAnnotationKey(enableMcpServer): "true", + buildHigressAnnotationKey(mcpServerMatchRuleDomains): "www.foo.com,www.bar.com", + buildHigressAnnotationKey(mcpServerMatchRuleType): "exact", + buildHigressAnnotationKey(mcpServerMatchRuleValue): "/mcp", + buildHigressAnnotationKey(mcpServerUpstreamType): "sse", + buildHigressAnnotationKey(mcpServerEnablePathRewrite): "true", + buildHigressAnnotationKey(mcpServerPathRewritePrefix): "/", + }, + expect: &mcpserver.McpServer{ + Name: "default/route", + Domains: []string{"www.foo.com", "www.bar.com"}, + PathMatchType: "exact", + PathMatchValue: "/mcp", + UpstreamType: "sse", + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + } + + for _, tt := range testCases { + if tt.skip { + return + } + + t.Run("", func(t *testing.T) { + config := &Ingress{Meta: Meta{ + Namespace: "default", + Name: "route", + }} + globalContext := &GlobalContext{} + _ = parser.Parse(tt.input, config, globalContext) + if tt.expect == nil { + if len(globalContext.McpServers) != 0 { + t.Fatalf("globalContext.McpServers is not empty: %v", globalContext.McpServers) + } + return + } + + if len(globalContext.McpServers) != 1 { + t.Fatalf("globalContext.McpServers length is not 1: %v", globalContext.McpServers) + } + + if diff := cmp.Diff(tt.expect, globalContext.McpServers[0]); diff != "" { + t.Fatalf("TestMCPServer_Parse() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/ingress/kube/configmap/controller.go b/pkg/ingress/kube/configmap/controller.go index d48cfd26a..04b03add3 100644 --- a/pkg/ingress/kube/configmap/controller.go +++ b/pkg/ingress/kube/configmap/controller.go @@ -18,7 +18,6 @@ import ( "reflect" "sync/atomic" - "github.com/alibaba/higress/registry/reconcile" "istio.io/istio/pilot/pkg/model" "istio.io/istio/pkg/cluster" "istio.io/istio/pkg/config" @@ -33,6 +32,7 @@ import ( "sigs.k8s.io/yaml" "github.com/alibaba/higress/pkg/ingress/kube/controller" + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" "github.com/alibaba/higress/pkg/ingress/kube/util" . "github.com/alibaba/higress/pkg/ingress/log" ) @@ -59,7 +59,6 @@ type ItemController interface { ValidHigressConfig(higressConfig *HigressConfig) error ConstructEnvoyFilters() ([]*config.Config, error) RegisterItemEventHandler(eventHandler ItemEventHandler) - RegisterMcpReconciler(reconciler *reconcile.Reconciler) } type ConfigmapMgr struct { @@ -113,9 +112,11 @@ func (c *ConfigmapMgr) GetHigressConfig() *HigressConfig { return nil } -func (c *ConfigmapMgr) SetMcpReconciler(reconciler *reconcile.Reconciler) { +func (c *ConfigmapMgr) RegisterMcpServerProvider(provider mcpserver.McpServerProvider) { for _, itemController := range c.ItemControllers { - itemController.RegisterMcpReconciler(reconciler) + if mcpRouteProviderAware, ok := itemController.(mcpserver.McpRouteProviderAware); ok { + mcpRouteProviderAware.RegisterMcpServerProvider(provider) + } } } diff --git a/pkg/ingress/kube/configmap/global.go b/pkg/ingress/kube/configmap/global.go index f1fdcaa05..0804062d3 100644 --- a/pkg/ingress/kube/configmap/global.go +++ b/pkg/ingress/kube/configmap/global.go @@ -21,7 +21,6 @@ import ( "github.com/alibaba/higress/pkg/ingress/kube/util" . "github.com/alibaba/higress/pkg/ingress/log" - "github.com/alibaba/higress/registry/reconcile" networking "istio.io/api/networking/v1alpha3" "istio.io/istio/pkg/config" "istio.io/istio/pkg/config/schema/gvk" @@ -377,9 +376,6 @@ func (g *GlobalOptionController) RegisterItemEventHandler(eventHandler ItemEvent g.eventHandler = eventHandler } -func (g *GlobalOptionController) RegisterMcpReconciler(reconciler *reconcile.Reconciler) { -} - // generateDownstreamEnvoyFilter generates the downstream envoy filter. func (g *GlobalOptionController) generateDownstreamEnvoyFilter(downstreamValueStruct string, bufferLimitStruct string, routeTimeoutStruct string, namespace string) []*networking.EnvoyFilter_EnvoyConfigObjectPatch { var downstreamConfig []*networking.EnvoyFilter_EnvoyConfigObjectPatch diff --git a/pkg/ingress/kube/configmap/gzip.go b/pkg/ingress/kube/configmap/gzip.go index 216f9fc3f..766b418e9 100644 --- a/pkg/ingress/kube/configmap/gzip.go +++ b/pkg/ingress/kube/configmap/gzip.go @@ -23,7 +23,6 @@ import ( "github.com/alibaba/higress/pkg/ingress/kube/util" . "github.com/alibaba/higress/pkg/ingress/log" - "github.com/alibaba/higress/registry/reconcile" networking "istio.io/api/networking/v1alpha3" "istio.io/istio/pkg/config" "istio.io/istio/pkg/config/schema/gvk" @@ -292,9 +291,6 @@ func (g *GzipController) RegisterItemEventHandler(eventHandler ItemEventHandler) g.eventHandler = eventHandler } -func (g *GzipController) RegisterMcpReconciler(reconciler *reconcile.Reconciler) { -} - func (g *GzipController) constructGzipStruct(gzip *Gzip, namespace string) string { gzipConfig := "" contentType := "" diff --git a/pkg/ingress/kube/configmap/mcp_server.go b/pkg/ingress/kube/configmap/mcp_server.go index 6b942a0c5..fd173c046 100644 --- a/pkg/ingress/kube/configmap/mcp_server.go +++ b/pkg/ingress/kube/configmap/mcp_server.go @@ -22,12 +22,13 @@ import ( "strings" "sync/atomic" - "github.com/alibaba/higress/pkg/ingress/kube/util" - . "github.com/alibaba/higress/pkg/ingress/log" - "github.com/alibaba/higress/registry/reconcile" networking "istio.io/api/networking/v1alpha3" "istio.io/istio/pkg/config" "istio.io/istio/pkg/config/schema/gvk" + + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" + "github.com/alibaba/higress/pkg/ingress/kube/util" + . "github.com/alibaba/higress/pkg/ingress/log" ) // RedisConfig defines the configuration for Redis connection @@ -232,18 +233,19 @@ func deepCopyMcpServer(mcp *McpServer) (*McpServer, error) { } type McpServerController struct { - Namespace string - mcpServer atomic.Value - Name string - eventHandler ItemEventHandler - reconciler *reconcile.Reconciler + Namespace string + mcpServer atomic.Value + Name string + eventHandler ItemEventHandler + mcpServerProviders map[mcpserver.McpServerProvider]bool } func NewMcpServerController(namespace string) *McpServerController { mcpController := &McpServerController{ - Namespace: namespace, - mcpServer: atomic.Value{}, - Name: "mcpServer", + Namespace: namespace, + Name: "mcpServer", + mcpServer: atomic.Value{}, + mcpServerProviders: make(map[mcpserver.McpServerProvider]bool), } mcpController.SetMcpServer(NewDefaultMcpServer()) return mcpController @@ -310,8 +312,11 @@ func (m *McpServerController) RegisterItemEventHandler(eventHandler ItemEventHan m.eventHandler = eventHandler } -func (m *McpServerController) RegisterMcpReconciler(reconciler *reconcile.Reconciler) { - m.reconciler = reconciler +func (m *McpServerController) RegisterMcpServerProvider(provider mcpserver.McpServerProvider) { + if m.mcpServerProviders == nil { + m.mcpServerProviders = make(map[mcpserver.McpServerProvider]bool) + } + m.mcpServerProviders[provider] = true } func (m *McpServerController) ConstructEnvoyFilters() ([]*config.Config, error) { @@ -406,10 +411,36 @@ func (m *McpServerController) ConstructEnvoyFilters() ([]*config.Config, error) func (m *McpServerController) constructMcpSessionStruct(mcp *McpServer) string { // Build match_list configuration - matchList := "[]" - var matchConfigs []string - if len(mcp.MatchList) > 0 { - for _, rule := range mcp.MatchList { + var matchList []*MatchRule + matchList = append(matchList, mcp.MatchList...) + for provider, _ := range m.mcpServerProviders { + servers := provider.GetMcpServers() + if len(servers) == 0 { + continue + } + for _, server := range servers { + matchRuleDomain := "" + if len(server.Domains) != 0 { + if len(server.Domains) > 1 { + matchRuleDomain = fmt.Sprintf("(%s)", strings.Join(server.Domains, "|")) + } else { + matchRuleDomain = server.Domains[0] + } + } + matchList = append(matchList, &MatchRule{ + MatchRuleDomain: matchRuleDomain, + MatchRuleType: server.PathMatchType, + MatchRulePath: server.PathMatchValue, + UpstreamType: server.UpstreamType, + EnablePathRewrite: server.EnablePathRewrite, + PathRewritePrefix: server.PathRewritePrefix, + }) + } + } + matchListConfig := "[]" + if len(matchList) > 0 { + matchConfigs := make([]string, 0, len(matchList)) + for _, rule := range matchList { matchConfigs = append(matchConfigs, fmt.Sprintf(`{ "match_rule_domain": "%s", "match_rule_path": "%s", @@ -419,28 +450,9 @@ func (m *McpServerController) constructMcpSessionStruct(mcp *McpServer) string { "path_rewrite_prefix": "%s" }`, rule.MatchRuleDomain, rule.MatchRulePath, rule.MatchRuleType, rule.UpstreamType, rule.EnablePathRewrite, rule.PathRewritePrefix)) } + matchListConfig = fmt.Sprintf("[%s]", strings.Join(matchConfigs, ",")) } - if m.reconciler != nil { - vsFromMcp := m.reconciler.GetAllConfigs(gvk.VirtualService) - for _, c := range vsFromMcp { - vs := c.Spec.(*networking.VirtualService) - var host string - if len(vs.Hosts) > 1 { - host = fmt.Sprintf("(%s)", strings.Join(vs.Hosts, "|")) - } else { - host = vs.Hosts[0] - } - path := vs.Http[0].Match[0].Uri.GetPrefix() - matchConfigs = append(matchConfigs, fmt.Sprintf(`{ - "match_rule_domain": "%s", - "match_rule_path": "%s", - "match_rule_type": "prefix" - }`, host, path)) - } - } - matchList = fmt.Sprintf("[%s]", strings.Join(matchConfigs, ",")) - // Build redis configuration redisConfig := "null" if mcp.Redis != nil { @@ -492,7 +504,7 @@ func (m *McpServerController) constructMcpSessionStruct(mcp *McpServer) string { redisConfig, rateLimitConfig, mcp.SSEPathSuffix, - matchList, + matchListConfig, mcp.EnableUserLevelServer) } diff --git a/pkg/ingress/kube/configmap/tracing.go b/pkg/ingress/kube/configmap/tracing.go index b3c0fe422..0529ccf85 100644 --- a/pkg/ingress/kube/configmap/tracing.go +++ b/pkg/ingress/kube/configmap/tracing.go @@ -21,7 +21,6 @@ import ( "reflect" "sync/atomic" - "github.com/alibaba/higress/registry/reconcile" "istio.io/istio/pkg/config" "istio.io/istio/pkg/config/schema/gvk" @@ -238,9 +237,6 @@ func (t *TracingController) RegisterItemEventHandler(eventHandler ItemEventHandl t.eventHandler = eventHandler } -func (t *TracingController) RegisterMcpReconciler(reconciler *reconcile.Reconciler) { -} - func (t *TracingController) ConstructEnvoyFilters() ([]*config.Config, error) { configs := make([]*config.Config, 0) tracing := t.GetTracing() diff --git a/pkg/ingress/kube/mcpserver/model.go b/pkg/ingress/kube/mcpserver/model.go new file mode 100644 index 000000000..335196f73 --- /dev/null +++ b/pkg/ingress/kube/mcpserver/model.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025 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 ( + "istio.io/istio/pkg/config" +) + +var ( + GvkMcpServer = config.GroupVersionKind{Group: "networking.higress.io", Version: "v1alpha1", Kind: "McpServer"} +) + +const ( + UpstreamTypeRest string = "rest" + UpstreamTypeSSE string = "sse" + UpstreamTypeStreamable string = "streamable" + + ExactMatchType string = "exact" + PrefixMatchType string = "prefix" + SuffixMatchType string = "suffix" + ContainsMatchType string = "contains" + RegexMatchType string = "regex" +) + +var ( + ValidUpstreamTypes = map[string]bool{ + UpstreamTypeRest: true, + UpstreamTypeSSE: true, + UpstreamTypeStreamable: true, + } + ValidPathMatchTypes = map[string]bool{ + ExactMatchType: true, + PrefixMatchType: true, + SuffixMatchType: true, + ContainsMatchType: true, + RegexMatchType: true, + } +) + +type McpServer struct { + Name string `json:"name,omitempty"` + Domains []string `json:"domains,omitempty"` + PathMatchType string `json:"path_match_type,omitempty"` + PathMatchValue string `json:"path_match_value,omitempty"` + UpstreamType string `json:"upstream_type,omitempty"` + EnablePathRewrite bool `json:"enable_path_rewrite,omitempty"` + PathRewritePrefix string `json:"path_rewrite_prefix,omitempty"` +} diff --git a/pkg/ingress/kube/mcpserver/provider.go b/pkg/ingress/kube/mcpserver/provider.go new file mode 100644 index 000000000..2851c6b92 --- /dev/null +++ b/pkg/ingress/kube/mcpserver/provider.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025 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 ( + "reflect" + "slices" + "strings" + "sync" +) + +type McpServerProvider interface { + GetMcpServers() []*McpServer +} + +type McpRouteProviderAware interface { + RegisterMcpServerProvider(provider McpServerProvider) +} + +type McpServerCache struct { + mcpServers []*McpServer + mutex sync.RWMutex +} + +func (c *McpServerCache) GetMcpServers() []*McpServer { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.mcpServers +} + +// SetMcpServers sets the mcp servers and returns true if the cached list is changed +func (c *McpServerCache) SetMcpServers(mcpServers []*McpServer) bool { + c.mutex.Lock() + defer c.mutex.Unlock() + + sortedMcpServers := make([]*McpServer, 0, len(mcpServers)) + sortedMcpServers = append(sortedMcpServers, mcpServers...) + // Sort the mcp servers by PathMatchValue in descending order + slices.SortFunc(sortedMcpServers, func(a, b *McpServer) int { + return strings.Compare(a.Name, b.Name) + }) + + if len(c.mcpServers) == len(sortedMcpServers) { + changed := false + for i := range c.mcpServers { + if !reflect.DeepEqual(c.mcpServers[i], sortedMcpServers[i]) { + changed = true + break + } + } + if !changed { + return false + } + } + + c.mcpServers = sortedMcpServers + return true +} diff --git a/pkg/ingress/kube/mcpserver/provider_test.go b/pkg/ingress/kube/mcpserver/provider_test.go new file mode 100644 index 000000000..8243c776b --- /dev/null +++ b/pkg/ingress/kube/mcpserver/provider_test.go @@ -0,0 +1,654 @@ +// Copyright (c) 2025 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 ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestMcpServerCache_GetSet(t *testing.T) { + testCases := []struct { + name string + skip bool + init []*McpServer + input []*McpServer + expect []*McpServer + changed bool + }{ + { + name: "nil", + init: nil, + input: nil, + changed: false, + expect: nil, + }, + { + name: "nil to non-nil", + init: nil, + input: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + changed: true, + expect: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + { + name: "non-nil to non-nil (length increase)", + init: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + }, + input: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + changed: true, + expect: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + { + name: "non-nil to non-nil (length decrease)", + init: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + input: []*McpServer{ + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + }, + changed: true, + expect: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + { + name: "non-nil to non-nil (length unchanged + name field changed)", + init: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + input: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3-1", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + changed: true, + expect: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3-1", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + { + name: "non-nil to non-nil (length unchanged + non-name field changed)", + init: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + input: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar-2.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test4", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + changed: true, + expect: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar-2.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test4", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + { + name: "non-nil to non-nil (content unchanged + order unchanged)", + init: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + input: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + changed: false, + expect: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + { + name: "non-nil to non-nil (content unchanged + order changed)", + init: []*McpServer{ + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + }, + input: []*McpServer{ + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + }, + changed: false, + expect: []*McpServer{ + { + Name: "test1", + Domains: nil, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test1", + UpstreamType: UpstreamTypeRest, + EnablePathRewrite: false, + PathRewritePrefix: "", + }, + { + Name: "test2", + Domains: []string{"www.foo.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test2", + UpstreamType: UpstreamTypeSSE, + EnablePathRewrite: true, + PathRewritePrefix: "/test", + }, + { + Name: "test3", + Domains: []string{"www.bar.com"}, + PathMatchType: ExactMatchType, + PathMatchValue: "/mcp/test3", + UpstreamType: UpstreamTypeStreamable, + EnablePathRewrite: true, + PathRewritePrefix: "/", + }, + }, + }, + } + + for _, tt := range testCases { + if tt.skip { + continue + } + t.Run(tt.name, func(t *testing.T) { + provider := &McpServerCache{} + + if provider.GetMcpServers() != nil { + t.Fatalf("GetMcpServers doesn't return nil before testing.") + } + + _ = provider.SetMcpServers(tt.init) + + changed := provider.SetMcpServers(tt.input) + if changed != tt.changed { + t.Fatalf("actual changed %t != expect changed %t", changed, tt.changed) + return + } + + actual := provider.GetMcpServers() + + if len(actual) != len(tt.expect) { + t.Fatalf("actual length %d != expect length %d", len(actual), len(tt.expect)) + } + for i := range actual { + if diff := cmp.Diff(tt.expect[i], actual[i]); diff != "" { + t.Fatalf("TestMcpServerCache_GetSet() mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/plugins/golang-filter/mcp-session/filter.go b/plugins/golang-filter/mcp-session/filter.go index e0f15d651..acc034e9c 100644 --- a/plugins/golang-filter/mcp-session/filter.go +++ b/plugins/golang-filter/mcp-session/filter.go @@ -143,50 +143,9 @@ func (f *filter) processMcpRequestHeadersForRestUpstream(header api.RequestHeade func (f *filter) processMcpRequestHeadersForSSEUpstream(header api.RequestHeaderMap, endStream bool) api.StatusType { // We don't need to process the request body for SSE upstream. f.skipRequestBody = true - f.rewritePathForSSEUpstream(header) return api.Continue } -func (f *filter) rewritePathForSSEUpstream(header api.RequestHeaderMap) { - matchedRule := f.matchedRule - if !matchedRule.EnablePathRewrite || matchedRule.MatchRuleType != common.PrefixMatch { - // No rewrite required, so we don't need to process the response body, either. - f.skipResponseBody = true - return - } - - path := f.req.URL.Path - if !strings.HasPrefix(path, matchedRule.MatchRulePath) { - api.LogWarnf("Unexpected: Path %s does not match the configured prefix %s", path, matchedRule.MatchRulePath) - return - } - - rewrittenPath := path[len(matchedRule.MatchRulePath):] - - if rewrittenPath == "" { - rewrittenPath = matchedRule.PathRewritePrefix - } else { - rewritePrefixHasTrailingSlash := strings.HasSuffix(matchedRule.PathRewritePrefix, "/") - pathSuffixHasLeadingSlash := strings.HasPrefix(rewrittenPath, "/") - if rewritePrefixHasTrailingSlash != pathSuffixHasLeadingSlash { - // One has, the other doesn't have. - rewrittenPath = matchedRule.PathRewritePrefix + rewrittenPath - } else if pathSuffixHasLeadingSlash { - // Both have. - rewrittenPath = matchedRule.PathRewritePrefix + rewrittenPath[1:] - } else { - // Neither have. - rewrittenPath = matchedRule.PathRewritePrefix + "/" + rewrittenPath - } - } - - if f.req.URL.RawQuery != "" { - rewrittenPath = rewrittenPath + "?" + f.req.URL.RawQuery - } - - header.SetPath(rewrittenPath) -} - // DecodeData might be called multiple times during handling the request body. // The endStream is true when handling the last piece of the body. func (f *filter) DecodeData(buffer api.BufferInstance, endStream bool) api.StatusType { diff --git a/registry/mcp_model.go b/registry/mcp_model.go index 6c8aae60f..e53a5a9df 100644 --- a/registry/mcp_model.go +++ b/registry/mcp_model.go @@ -22,6 +22,7 @@ const ( IstioMcpAutoGeneratedSeName = IstioMcpAutoGeneratedPrefix + "-se" IstioMcpAutoGeneratedDrName = IstioMcpAutoGeneratedPrefix + "-dr" IstioMcpAutoGeneratedHttpRouteName = IstioMcpAutoGeneratedPrefix + "-httproute" + IstioMcpAutoGeneratedMcpServerName = IstioMcpAutoGeneratedPrefix + "-mcpserver" DefaultMcpToolsGroup = "mcp-tools" DefaultMcpCredentialsGroup = "credentials" diff --git a/registry/memory/cache.go b/registry/memory/cache.go index 9da200c6e..d7fa91e45 100644 --- a/registry/memory/cache.go +++ b/registry/memory/cache.go @@ -317,7 +317,7 @@ func (s *store) GetAllDestinationRuleWrapper() []*ingress.WrapperDestinationRule dr := cfg.Spec.(*v1alpha3.DestinationRule) drwList = append(drwList, &ingress.WrapperDestinationRule{ DestinationRule: dr, - ServiceKey: ingress.ServiceKey{ServiceFQDN: dr.Host}, + ServiceKey: ingress.ServiceKey{Namespace: "mcp", Name: dr.Host, ServiceFQDN: dr.Host}, }) } diff --git a/registry/nacos/mcpserver/watcher.go b/registry/nacos/mcpserver/watcher.go index ec26be60a..92e6d1084 100644 --- a/registry/nacos/mcpserver/watcher.go +++ b/registry/nacos/mcpserver/watcher.go @@ -27,6 +27,7 @@ import ( apiv1 "github.com/alibaba/higress/api/networking/v1" "github.com/alibaba/higress/pkg/common" common2 "github.com/alibaba/higress/pkg/ingress/kube/common" + "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" provider "github.com/alibaba/higress/registry" "github.com/alibaba/higress/registry/memory" "github.com/golang/protobuf/ptypes/wrappers" @@ -56,7 +57,26 @@ const ( DefaultRefreshIntervalLimit = time.Second * 10 DefaultFetchPageSize = 50 DefaultJoiner = "@@" - NacosV3LabelKey = "isV3" +) + +var ( + supportedProtocols = map[string]bool{ + provider.HttpProtocol: true, + provider.McpSSEProtocol: true, + provider.McpStreambleProtocol: true, + } + protocolUpstreamTypeMapping = map[string]string{ + provider.HttpProtocol: mcpserver.UpstreamTypeRest, + provider.McpSSEProtocol: mcpserver.UpstreamTypeSSE, + provider.McpStreambleProtocol: mcpserver.UpstreamTypeStreamable, + } + routeRewriteProtocols = map[string]bool{ + provider.McpSSEProtocol: true, + provider.McpStreambleProtocol: true, + } + mcpServerRewriteProtocols = map[string]bool{ + provider.McpSSEProtocol: true, + } ) var mcpServerLog = log.RegisterScope("McpServer", "Nacos Mcp Server Watcher process.") @@ -431,7 +451,7 @@ func (w *watcher) getConfigCallback(namespace, group, dataId, data string) { mcpServerLog.Errorf("Unmarshal config data to mcp server error:%v, namespace:%s, groupName:%s, dataId:%s", err, namespace, group, dataId) return } - if mcpServer.Protocol == provider.StdioProtocol || mcpServer.Protocol == provider.DubboProtocol || mcpServer.Protocol == provider.McpSSEProtocol { + if !supportedProtocols[mcpServer.Protocol] { return } // process mcp service @@ -670,7 +690,9 @@ func (w *watcher) getServiceCallback(server *provider.McpServer, configGroup, da } namespace := server.RemoteServerConfig.ServiceRef.NamespaceId serviceName := server.RemoteServerConfig.ServiceRef.ServiceName - path := server.RemoteServerConfig.ExportPath + // Higress doesn't care about the MCP export path configured in nacos. + // Any path of the mcp server are supported in request routing. + path := "/" protocol := server.Protocol host := getNacosServiceFullHost(groupName, namespace, serviceName) @@ -708,6 +730,8 @@ func (w *watcher) getServiceCallback(server *provider.McpServer, configGroup, da w.cache.UpdateConfigCache(gvk.ServiceEntry, configKey, se, false) vs := w.buildVirtualServiceForMcpServer(serviceEntry, configGroup, dataId, path, server) w.cache.UpdateConfigCache(gvk.VirtualService, configKey, vs, false) + mcpServer := w.buildMcpServerForMcpServer(vs.Spec.(*v1alpha3.VirtualService), configGroup, dataId, path, server) + w.cache.UpdateConfigCache(mcpserver.GvkMcpServer, configKey, mcpServer, false) } } @@ -735,16 +759,28 @@ func (w *watcher) buildVirtualServiceForMcpServer(serviceentry *v1alpha3.Service if path != "/" { mergePath = mergePath + "/" + strings.TrimPrefix(path, "/") } + mergePath = strings.TrimSuffix(mergePath, "/") vs := &v1alpha3.VirtualService{ Hosts: hosts, Gateways: gateways, Http: []*v1alpha3.HTTPRoute{{ Name: routeName, + // We need to use both exact and prefix matches here to ensure a proper matching. + // Also otherwise, prefix rewrite won't work correctly for Streamable HTTP transport, either. + // Example: + // Assume mergePath=/mcp/test prefixRewrite=/ requestPath=/mcp/test/abc + // If we only use prefix match, the rewritten path will be //abc. Match: []*v1alpha3.HTTPMatchRequest{{ + Uri: &v1alpha3.StringMatch{ + MatchType: &v1alpha3.StringMatch_Exact{ + Exact: mergePath, + }, + }, + }, { Uri: &v1alpha3.StringMatch{ MatchType: &v1alpha3.StringMatch_Prefix{ - Prefix: mergePath, + Prefix: mergePath + "/", }, }, }}, @@ -759,9 +795,9 @@ func (w *watcher) buildVirtualServiceForMcpServer(serviceentry *v1alpha3.Service }}, } - if server.Protocol == provider.McpStreambleProtocol { + if routeRewriteProtocols[server.Protocol] { vs.Http[0].Rewrite = &v1alpha3.HTTPRewrite{ - Uri: path, + Uri: "/", } } @@ -777,6 +813,49 @@ func (w *watcher) buildVirtualServiceForMcpServer(serviceentry *v1alpha3.Service } } +func (w *watcher) buildMcpServerForMcpServer(vs *v1alpha3.VirtualService, group, dataId, path string, server *provider.McpServer) *config.Config { + if vs == nil { + return nil + } + domains := w.McpServerExportDomains + if len(domains) == 0 { + domains = []string{"*"} + } + name := fmt.Sprintf("%s-%s-%s", provider.IstioMcpAutoGeneratedMcpServerName, group, strings.TrimSuffix(dataId, ".json")) + httpRoute := vs.Http[0] + pathMatchValue := "" + for _, match := range httpRoute.Match { + if match.Uri != nil && match.Uri.GetExact() != "" { + pathMatchValue = match.Uri.GetExact() + break + } + } + protocol := server.Protocol + + mcpServer := &mcpserver.McpServer{ + Name: name, + Domains: domains, + PathMatchType: mcpserver.PrefixMatchType, + PathMatchValue: pathMatchValue, + UpstreamType: protocolUpstreamTypeMapping[protocol], + } + if mcpServerRewriteProtocols[protocol] { + mcpServer.EnablePathRewrite = true + mcpServer.PathRewritePrefix = "/" + } + + mcpServerLog.Debugf("construct mcpserver %v", mcpServer) + + return &config.Config{ + Meta: config.Meta{ + GroupVersionKind: mcpserver.GvkMcpServer, + Name: name, + Namespace: w.namespace, + }, + Spec: mcpServer, + } +} + func (w *watcher) generateServiceEntry(host string, services []model.Instance) *v1alpha3.ServiceEntry { portList := make([]*v1alpha3.ServicePort, 0) endpoints := make([]*v1alpha3.WorkloadEntry, 0) @@ -980,3 +1059,13 @@ func isValidIP(ipStr string) bool { ip := net.ParseIP(ipStr) return ip != nil } + +func normalizeRewritePathPrefix(path string) string { + if path == "" || path == "/" { + return "/" + } + if path[0] != '/' { + path = "/" + path + } + return strings.TrimSuffix(path, "/") +} diff --git a/registry/reconcile/reconcile.go b/registry/reconcile/reconcile.go index edeaf8ad4..180c3701a 100644 --- a/registry/reconcile/reconcile.go +++ b/registry/reconcile/reconcile.go @@ -23,11 +23,12 @@ import ( "sync" "time" - "github.com/alibaba/higress/registry/nacos/mcpserver" "istio.io/pkg/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv1 "github.com/alibaba/higress/api/networking/v1" v1 "github.com/alibaba/higress/client/pkg/apis/networking/v1" + higressmcpserver "github.com/alibaba/higress/pkg/ingress/kube/mcpserver" "github.com/alibaba/higress/pkg/kube" . "github.com/alibaba/higress/registry" "github.com/alibaba/higress/registry/consul" @@ -35,9 +36,9 @@ import ( "github.com/alibaba/higress/registry/eureka" "github.com/alibaba/higress/registry/memory" "github.com/alibaba/higress/registry/nacos" + "github.com/alibaba/higress/registry/nacos/mcpserver" nacosv2 "github.com/alibaba/higress/registry/nacos/v2" "github.com/alibaba/higress/registry/zookeeper" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -320,6 +321,17 @@ func (r *Reconciler) getAuthOption(registry *apiv1.RegistryConfig) (AuthOption, return authOption, nil } +func (r *Reconciler) GetMcpServers() []*higressmcpserver.McpServer { + mcpServersFromMcp := r.GetAllConfigs(higressmcpserver.GvkMcpServer) + servers := make([]*higressmcpserver.McpServer, 0, len(mcpServersFromMcp)) + for _, c := range mcpServersFromMcp { + if server, ok := c.Spec.(*higressmcpserver.McpServer); ok { + servers = append(servers, server) + } + } + return servers +} + type RegistryWatcherStatus struct { Name string `json:"name"` Type string `json:"type"`