This is an automated email from the ASF dual-hosted git repository.

robocanic pushed a commit to branch ai
in repository https://gitbox.apache.org/repos/asf/dubbo-admin.git


The following commit(s) were added to refs/heads/ai by this push:
     new bbc02189 feat: mcp-log (#1483)
bbc02189 is described below

commit bbc02189ccba46dfb58ac6c13dd997d15b462575
Author: Oxidaner <[email protected]>
AuthorDate: Sun Jun 28 17:52:28 2026 +0800

    feat: mcp-log (#1483)
    
    * feat: add loki log mcp tools
    
    * docs: add loki log config example
    
    * feat:The joint debugging
     of interface error-analyse
    
    * docs: add alloy loki values example
    
    * feat:The joint debugging
     of interface error-analyse
    
    * feat:support traceId
    
    * feat:add get_log_capabilities
    
    * feat:Make modifications based on copilot's suggestions
---
 app/dubbo-admin/dubbo-admin.yaml              |  23 +-
 docs/examples/observability/alloy-values.yaml | 169 +++++++
 pkg/config/observability/config.go            |   7 +
 pkg/config/observability/logs.go              |  93 ++++
 pkg/console/component.go                      |   8 +-
 pkg/mcp/register.go                           |  32 ++
 pkg/mcp/tools/log/analyzer.go                 |  94 ++++
 pkg/mcp/tools/log/loki.go                     | 622 ++++++++++++++++++++++++++
 pkg/mcp/tools/log/model.go                    |  83 ++++
 pkg/mcp/tools/log/tools.go                    | 164 +++++++
 pkg/mcp/tools/log/tools_test.go               | 440 ++++++++++++++++++
 11 files changed, 1725 insertions(+), 10 deletions(-)

diff --git a/app/dubbo-admin/dubbo-admin.yaml b/app/dubbo-admin/dubbo-admin.yaml
index 1e74b47e..b348772c 100644
--- a/app/dubbo-admin/dubbo-admin.yaml
+++ b/app/dubbo-admin/dubbo-admin.yaml
@@ -29,12 +29,23 @@ log:
 # [Optional] config for observability.
 observability:
   # [Optional] config for grafana and prometheus.
-  grafana: http://101.34.253.152:30300
-  # [Optional] config for prometheus.
-  prometheus: http://101.34.253.152:30900/
-
-# [Optional] config for console.
-console:
+  grafana: http://101.34.253.152:30300
+  # [Optional] config for prometheus.
+  prometheus: http://101.34.253.152:30900/
+  # [Optional] config for log providers used by MCP log tools.
+  # defaultProvider must match one provider name in providers.
+  # Currently only Loki is supported. endpoint should be the Loki HTTP API 
root.
+  # tenant is optional and will be sent as X-Scope-OrgID for multi-tenant Loki.
+  # logs:
+  #   defaultProvider: loki-main
+  #   providers:
+  #     - name: loki-main
+  #       type: loki
+  #       endpoint: http://localhost:3100
+  #       tenant: ""
+
+# [Optional] config for console.
+console:
   # [Optional] port of http server, default is 8888.
   port: 8888
   # [Optional] running mode of Gin, options are debug, release, test, default 
is release.
diff --git a/docs/examples/observability/alloy-values.yaml 
b/docs/examples/observability/alloy-values.yaml
new file mode 100644
index 00000000..64302506
--- /dev/null
+++ b/docs/examples/observability/alloy-values.yaml
@@ -0,0 +1,169 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+# Example values for the Grafana Alloy Helm chart.
+# The generated Loki labels match the MCP log tools:
+# - appName filters app/appName labels
+# - serviceName filters service/serviceName/service_name labels
+# - instanceName filters instance/instanceName/pod labels
+# - mesh filters the mesh label
+#
+# traceId is intentionally not promoted to a Loki label in this example.
+# Trace IDs are usually high-cardinality values; use the MCP keywords filter
+# or add custom log parsing if your deployment requires trace-id lookup.
+
+global:
+  image:
+    registry: m.daocloud.io/docker.io
+
+image:
+  registry: m.daocloud.io/docker.io
+  repository: grafana/alloy
+  pullPolicy: IfNotPresent
+
+controller:
+  type: deployment
+  replicas: 1
+
+configReloader:
+  enabled: false
+
+rbac:
+  create: true
+
+serviceAccount:
+  create: true
+
+alloy:
+  enableReporting: false
+
+  mounts:
+    varlog: false
+    dockercontainers: false
+
+  resources:
+    requests:
+      cpu: 50m
+      memory: 128Mi
+    limits:
+      cpu: 300m
+      memory: 512Mi
+
+  configMap:
+    create: true
+    content: |-
+      logging {
+        level  = "info"
+        format = "logfmt"
+      }
+
+      discovery.kubernetes "pods" {
+        role = "pod"
+      }
+
+      discovery.relabel "dubbo_pod_logs" {
+        targets = discovery.kubernetes.pods.targets
+
+        rule {
+          source_labels = ["__meta_kubernetes_namespace"]
+          regex = "default|dubbo-system|dubbo-samples-shop|logging"
+          action = "keep"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_namespace"]
+          target_label = "namespace"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_pod_name"]
+          target_label = "pod"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_pod_name"]
+          target_label = "instance"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_pod_container_name"]
+          target_label = "container"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_pod_container_name"]
+          regex = "(.+)"
+          target_label = "service_name"
+          action = "replace"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_pod_label_app"]
+          regex = "(.+)"
+          target_label = "service_name"
+          action = "replace"
+        }
+
+        rule {
+          source_labels = 
["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
+          regex = "(.+)"
+          target_label = "service_name"
+          action = "replace"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_pod_label_app"]
+          regex = "(.+)"
+          target_label = "app"
+          action = "replace"
+        }
+
+        rule {
+          source_labels = 
["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
+          regex = "(.+)"
+          target_label = "app"
+          action = "replace"
+        }
+
+        rule {
+          source_labels = ["__meta_kubernetes_namespace", 
"__meta_kubernetes_pod_container_name"]
+          separator = "/"
+          target_label = "job"
+        }
+      }
+
+      loki.source.kubernetes "dubbo_pod_logs" {
+        targets    = discovery.relabel.dubbo_pod_logs.output
+        forward_to = [loki.process.dubbo_logs.receiver]
+      }
+
+      loki.process "dubbo_logs" {
+        forward_to = [loki.write.local.receiver]
+
+        stage.decolorize {}
+
+        stage.static_labels {
+          values = {
+            cluster = "k8s1.28.2",
+            mesh    = "nacos2.5",
+          }
+        }
+      }
+
+      loki.write "local" {
+        endpoint {
+          url = "http://loki.logging.svc.cluster.local:3100/loki/api/v1/push";
+        }
+      }
diff --git a/pkg/config/observability/config.go 
b/pkg/config/observability/config.go
index 879e2299..920e8a31 100644
--- a/pkg/config/observability/config.go
+++ b/pkg/config/observability/config.go
@@ -33,6 +33,8 @@ type Config struct {
        Grafana string `json:"grafana" yaml:"grafana"`
        // Prometheus is the url of prometheus
        Prometheus string `json:"prometheus" yaml:"prometheus"`
+       // Logs configures the log query provider.
+       Logs *LogsConfig `json:"logs,omitempty" yaml:"logs,omitempty"`
 
        GrafanaBaseURL    *url.URL `json:"-" yaml:"-"`
        PrometheusBaseURL *url.URL `json:"-" yaml:"-"`
@@ -55,6 +57,11 @@ func (c *Config) Validate() error {
                }
                c.GrafanaBaseURL = grafanaBaseURL
        }
+       if c.Logs != nil {
+               if err := c.Logs.Validate(); err != nil {
+                       return err
+               }
+       }
        return nil
 }
 
diff --git a/pkg/config/observability/logs.go b/pkg/config/observability/logs.go
new file mode 100644
index 00000000..d9f01182
--- /dev/null
+++ b/pkg/config/observability/logs.go
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 observability
+
+import (
+       "fmt"
+       "net/url"
+
+       "github.com/duke-git/lancet/v2/strutil"
+
+       "github.com/apache/dubbo-admin/pkg/common/bizerror"
+)
+
+type LogProviderType string
+
+const (
+       LogProviderLoki LogProviderType = "loki"
+)
+
+type LogsConfig struct {
+       DefaultProvider string              `json:"defaultProvider" 
yaml:"defaultProvider"`
+       Providers       []LogProviderConfig `json:"providers" yaml:"providers"`
+}
+
+type LogProviderConfig struct {
+       Name     string          `json:"name" yaml:"name"`
+       Type     LogProviderType `json:"type" yaml:"type"`
+       Endpoint string          `json:"endpoint" yaml:"endpoint"`
+       Tenant   string          `json:"tenant,omitempty" 
yaml:"tenant,omitempty"`
+}
+
+func (c *LogsConfig) Validate() error {
+       if c == nil || len(c.Providers) == 0 {
+               return nil
+       }
+       if strutil.IsBlank(c.DefaultProvider) {
+               return bizerror.New(bizerror.ConfigError, "default log provider 
is required")
+       }
+
+       foundDefault := false
+       for _, provider := range c.Providers {
+               if strutil.IsBlank(provider.Name) {
+                       return bizerror.New(bizerror.ConfigError, "log provider 
name is required")
+               }
+               if provider.Name == c.DefaultProvider {
+                       foundDefault = true
+               }
+               if provider.Type != LogProviderLoki {
+                       return bizerror.New(bizerror.ConfigError, 
fmt.Sprintf("unsupported log provider type: %s", provider.Type))
+               }
+               if strutil.IsBlank(provider.Endpoint) {
+                       return bizerror.New(bizerror.ConfigError, "log provider 
endpoint is required")
+               }
+               parsed, err := url.Parse(provider.Endpoint)
+               if err != nil {
+                       return bizerror.Wrap(err, bizerror.ConfigError, 
fmt.Sprintf("invalid log provider endpoint: %s", provider.Endpoint))
+               }
+               if parsed.Scheme == "" || parsed.Host == "" {
+                       return bizerror.New(bizerror.ConfigError, 
fmt.Sprintf("invalid log provider endpoint: %s", provider.Endpoint))
+               }
+       }
+       if !foundDefault {
+               return bizerror.New(bizerror.ConfigError, fmt.Sprintf("default 
log provider %q is not configured", c.DefaultProvider))
+       }
+       return nil
+}
+
+func (c *LogsConfig) Default() (LogProviderConfig, bool) {
+       if c == nil {
+               return LogProviderConfig{}, false
+       }
+       for _, provider := range c.Providers {
+               if provider.Name == c.DefaultProvider {
+                       return provider, true
+               }
+       }
+       return LogProviderConfig{}, false
+}
diff --git a/pkg/console/component.go b/pkg/console/component.go
index d74289bc..ea4be76c 100644
--- a/pkg/console/component.go
+++ b/pkg/console/component.go
@@ -48,10 +48,10 @@ func init() {
 }
 
 type consoleWebServer struct {
-       Engine   *gin.Engine
-       cfg      *console.Config
-       cs       consolectx.Context
-       mcpPath  string // MCP端点路径,用于auth中间件跳过认证
+       Engine    *gin.Engine
+       cfg       *console.Config
+       cs        consolectx.Context
+       mcpPath   string // MCP端点路径,用于auth中间件跳过认证
        mcpAPIKey string // MCP API密钥,用于认证
 }
 
diff --git a/pkg/mcp/register.go b/pkg/mcp/register.go
index cec3acb4..92ccbab3 100644
--- a/pkg/mcp/register.go
+++ b/pkg/mcp/register.go
@@ -20,6 +20,7 @@ package mcp
 import (
        "github.com/apache/dubbo-admin/pkg/mcp/common"
        "github.com/apache/dubbo-admin/pkg/mcp/tools"
+       logtools "github.com/apache/dubbo-admin/pkg/mcp/tools/log"
 )
 
 // RegisterTools 注册所有 MCP 工具
@@ -338,4 +339,35 @@ func RegisterTools(server *Server) {
                },
                Handler: tools.GetApplicationServices,
        })
+
+       logSearchProperties := logtools.LogSearchProperties()
+       server.RegisterTool(&common.ToolDef{
+               Name:        "search_logs",
+               Description: "查询 Dubbo 服务日志,支持按应用、服务、实例、TraceID 和关键字过滤",
+               InputSchema: common.InputSchema{
+                       Type:       "object",
+                       Properties: logSearchProperties,
+               },
+               Handler: logtools.SearchLogs,
+       })
+
+       server.RegisterTool(&common.ToolDef{
+               Name:        "analyze_error_logs",
+               Description: "分析错误日志并按错误模式聚合",
+               InputSchema: common.InputSchema{
+                       Type:       "object",
+                       Properties: logSearchProperties,
+               },
+               Handler: logtools.AnalyzeErrorLogs,
+       })
+
+       server.RegisterTool(&common.ToolDef{
+               Name:        "get_log_capabilities",
+               Description: "获取日志查询能力,返回 Loki 当前可用 labels 以及查询参数到 labels 的映射",
+               InputSchema: common.InputSchema{
+                       Type:       "object",
+                       Properties: logtools.LogCapabilitiesProperties(),
+               },
+               Handler: logtools.GetLogCapabilities,
+       })
 }
diff --git a/pkg/mcp/tools/log/analyzer.go b/pkg/mcp/tools/log/analyzer.go
new file mode 100644
index 00000000..f09fe34e
--- /dev/null
+++ b/pkg/mcp/tools/log/analyzer.go
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 log
+
+import (
+       "fmt"
+       "regexp"
+       "sort"
+       "strings"
+)
+
+var volatileTokenPattern = regexp.MustCompile(`(?i)\b[0-9a-f]{8,}\b|\b\d+\b`)
+
+func analyzeErrors(logs []LogItem, sourceEngine string) *AnalyzeErrorLogsResp {
+       patternsByName := map[string]*ErrorPattern{}
+       for _, item := range logs {
+               if !isErrorLog(item) {
+                       continue
+               }
+               patternName := normalizeMessagePattern(item.Message)
+               pattern := patternsByName[patternName]
+               if pattern == nil {
+                       pattern = &ErrorPattern{
+                               Pattern:   patternName,
+                               Example:   item.Message,
+                               FirstSeen: item.Timestamp,
+                               LastSeen:  item.Timestamp,
+                       }
+                       patternsByName[patternName] = pattern
+               }
+               pattern.Count++
+               if pattern.FirstSeen == "" || item.Timestamp < 
pattern.FirstSeen {
+                       pattern.FirstSeen = item.Timestamp
+               }
+               if pattern.LastSeen == "" || item.Timestamp > pattern.LastSeen {
+                       pattern.LastSeen = item.Timestamp
+               }
+               if len(pattern.Examples) < 3 {
+                       pattern.Examples = append(pattern.Examples, item)
+               }
+       }
+
+       patterns := make([]ErrorPattern, 0, len(patternsByName))
+       total := 0
+       for _, pattern := range patternsByName {
+               total += pattern.Count
+               patterns = append(patterns, *pattern)
+       }
+       sort.Slice(patterns, func(i, j int) bool {
+               if patterns[i].Count == patterns[j].Count {
+                       return patterns[i].Pattern < patterns[j].Pattern
+               }
+               return patterns[i].Count > patterns[j].Count
+       })
+
+       return &AnalyzeErrorLogsResp{
+               Summary:      fmt.Sprintf("found %d error log entries across %d 
patterns", total, len(patterns)),
+               TotalErrors:  total,
+               Patterns:     patterns,
+               SourceEngine: sourceEngine,
+       }
+}
+
+func isErrorLog(item LogItem) bool {
+       if strings.EqualFold(item.Severity, "error") {
+               return true
+       }
+       message := strings.ToLower(item.Message)
+       return strings.Contains(message, "error") || strings.Contains(message, 
"exception") || strings.Contains(message, "failed")
+}
+
+func normalizeMessagePattern(message string) string {
+       pattern := volatileTokenPattern.ReplaceAllString(message, "?")
+       pattern = strings.Join(strings.Fields(pattern), " ")
+       if pattern == "" {
+               return "unknown"
+       }
+       return pattern
+}
diff --git a/pkg/mcp/tools/log/loki.go b/pkg/mcp/tools/log/loki.go
new file mode 100644
index 00000000..b9318bc5
--- /dev/null
+++ b/pkg/mcp/tools/log/loki.go
@@ -0,0 +1,622 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 log
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "regexp"
+       "sort"
+       "strconv"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/dubbo-admin/pkg/common/bizerror"
+       observabilitycfg 
"github.com/apache/dubbo-admin/pkg/config/observability"
+)
+
+const (
+       defaultQueryWindow = time.Hour
+       labelCacheTTL      = 5 * time.Minute
+)
+
+var fallbackSelectorPriority = []string{
+       "namespace",
+       "job",
+       "app",
+       "appName",
+       "service_name",
+       "serviceName",
+       "service",
+       "pod",
+       "container",
+       "instance",
+       "instanceName",
+       "level",
+}
+
+var lokiLabelsCache = struct {
+       sync.Mutex
+       items map[string]cachedLokiLabels
+}{
+       items: map[string]cachedLokiLabels{},
+}
+
+type cachedLokiLabels struct {
+       labels    map[string]struct{}
+       expiresAt time.Time
+}
+
+type lokiClient struct {
+       config observabilitycfg.LogProviderConfig
+       client *http.Client
+}
+
+type lokiQueryRangeResp struct {
+       Status string `json:"status"`
+       Data   struct {
+               Result []lokiStream `json:"result"`
+       } `json:"data"`
+       Error string `json:"error,omitempty"`
+}
+
+type lokiLabelsResp struct {
+       Status string   `json:"status"`
+       Data   []string `json:"data"`
+       Error  string   `json:"error,omitempty"`
+}
+
+type lokiStream struct {
+       Stream map[string]string `json:"stream"`
+       Values [][]string        `json:"values"`
+}
+
+func newLokiClient(cfg observabilitycfg.LogProviderConfig) *lokiClient {
+       return &lokiClient{
+               config: cfg,
+               client: &http.Client{Timeout: 30 * time.Second},
+       }
+}
+
+func (c *lokiClient) search(ctx context.Context, req *SearchLogsReq) 
(*SearchLogsResp, error) {
+       if req.Limit <= 0 {
+               req.Limit = defaultLogLimit
+       }
+       start, end, err := resolveTimeRange(req.StartTime, req.EndTime)
+       if err != nil {
+               return nil, err
+       }
+
+       labelNames, _ := c.labelNames(ctx, start, end)
+       queries := buildLogQLQueriesWithLabels(req, labelNames)
+       merged := &SearchLogsResp{SourceEngine: "loki", Logs: make([]LogItem, 
0, req.Limit)}
+       seen := map[string]struct{}{}
+       for _, query := range queries {
+               logs, err := c.queryRange(ctx, query, start, end, req.Limit)
+               if err != nil {
+                       return nil, err
+               }
+               // remove duplicates
+               for _, item := range logs {
+                       key := dedupeKey(item)
+                       if _, ok := seen[key]; ok {
+                               continue
+                       }
+                       seen[key] = struct{}{}
+                       merged.Logs = append(merged.Logs, item)
+                       if len(merged.Logs) >= req.Limit {
+                               break
+                       }
+               }
+               if len(merged.Logs) >= req.Limit {
+                       break
+               }
+       }
+
+       sort.SliceStable(merged.Logs, func(i, j int) bool {
+               return merged.Logs[i].Timestamp > merged.Logs[j].Timestamp
+       })
+       return merged, nil
+}
+
+func (c *lokiClient) capabilities(ctx context.Context, req 
*LogCapabilitiesReq) (*LogCapabilitiesResp, error) {
+       start, end, err := resolveTimeRange(req.StartTime, req.EndTime)
+       if err != nil {
+               return nil, err
+       }
+       labelNames, err := c.labelNames(ctx, start, end)
+       if err != nil {
+               return nil, err
+       }
+
+       return &LogCapabilitiesResp{
+               AvailableLabels: supportedLabels(labelNames),
+               SupportedFilters: []string{
+                       "mesh",
+                       "appName",
+                       "serviceName",
+                       "instanceName",
+                       "traceId",
+                       "keywords",
+                       "startTime",
+                       "endTime",
+                       "limit",
+               },
+               LabelFilters: map[string][]string{
+                       "mesh":         matchingLabels([]string{"mesh"}, 
labelNames),
+                       "appName":      matchingLabels([]string{"app", 
"appName"}, labelNames),
+                       "serviceName":  matchingLabels([]string{"service", 
"serviceName", "service_name"}, labelNames),
+                       "instanceName": matchingLabels([]string{"instance", 
"instanceName", "pod"}, labelNames),
+               },
+               ContentFilters: []string{"traceId", "keywords"},
+               FallbackLabel:  fallbackSelectorLabel(labelNames),
+               SourceEngine:   "loki",
+       }, nil
+}
+
+func (c *lokiClient) queryRange(ctx context.Context, query string, start, end 
time.Time, limit int) ([]LogItem, error) {
+       queryURL, err := c.queryRangeURL(query, start, end, limit)
+       if err != nil {
+               return nil, err
+       }
+       httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, 
queryURL, nil)
+       if err != nil {
+               return nil, bizerror.Wrap(err, bizerror.InternalError, "failed 
to create loki query request")
+       }
+       if c.config.Tenant != "" {
+               httpReq.Header.Set("X-Scope-OrgID", c.config.Tenant)
+       }
+
+       httpResp, err := c.client.Do(httpReq)
+       if err != nil {
+               return nil, bizerror.Wrap(err, bizerror.NetWorkError, "failed 
to query loki")
+       }
+       defer httpResp.Body.Close()
+       if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= 
http.StatusMultipleChoices {
+               body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 4096))
+               return nil, bizerror.New(bizerror.NetWorkError,
+                       fmt.Sprintf("loki query failed with status %d: %s", 
httpResp.StatusCode, strings.TrimSpace(string(body))))
+       }
+
+       var lokiResp lokiQueryRangeResp
+       if err := json.NewDecoder(httpResp.Body).Decode(&lokiResp); err != nil {
+               return nil, bizerror.Wrap(err, bizerror.JsonError, "failed to 
decode loki query response")
+       }
+       if lokiResp.Status != "success" {
+               if lokiResp.Error != "" {
+                       return nil, bizerror.New(bizerror.NetWorkError, 
fmt.Sprintf("loki query failed: %s", lokiResp.Error))
+               }
+               return nil, bizerror.New(bizerror.NetWorkError, 
fmt.Sprintf("loki query returned status %q", lokiResp.Status))
+       }
+       return normalizeLokiLogs(lokiResp), nil
+}
+
+func (c *lokiClient) labelNames(ctx context.Context, start, end time.Time) 
(map[string]struct{}, error) {
+       cacheKey := c.labelCacheKey()
+       now := time.Now()
+       lokiLabelsCache.Lock()
+       if cached, ok := lokiLabelsCache.items[cacheKey]; ok && 
now.Before(cached.expiresAt) {
+               labels := cloneLabelSet(cached.labels)
+               lokiLabelsCache.Unlock()
+               return labels, nil
+       }
+       lokiLabelsCache.Unlock()
+
+       labelsURL, err := c.labelsURL(start, end)
+       if err != nil {
+               return nil, err
+       }
+       httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, 
labelsURL, nil)
+       if err != nil {
+               return nil, bizerror.Wrap(err, bizerror.InternalError, "failed 
to create loki labels request")
+       }
+       if c.config.Tenant != "" {
+               httpReq.Header.Set("X-Scope-OrgID", c.config.Tenant)
+       }
+
+       httpResp, err := c.client.Do(httpReq)
+       if err != nil {
+               return nil, bizerror.Wrap(err, bizerror.NetWorkError, "failed 
to query loki labels")
+       }
+       defer httpResp.Body.Close()
+       if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= 
http.StatusMultipleChoices {
+               body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 4096))
+               return nil, bizerror.New(bizerror.NetWorkError,
+                       fmt.Sprintf("loki labels query failed with status %d: 
%s", httpResp.StatusCode, strings.TrimSpace(string(body))))
+       }
+
+       var labelsResp lokiLabelsResp
+       if err := json.NewDecoder(httpResp.Body).Decode(&labelsResp); err != 
nil {
+               return nil, bizerror.Wrap(err, bizerror.JsonError, "failed to 
decode loki labels response")
+       }
+       if labelsResp.Status != "success" {
+               if labelsResp.Error != "" {
+                       return nil, bizerror.New(bizerror.NetWorkError, 
fmt.Sprintf("loki labels query failed: %s", labelsResp.Error))
+               }
+               return nil, bizerror.New(bizerror.NetWorkError, 
fmt.Sprintf("loki labels query returned status %q", labelsResp.Status))
+       }
+
+       labels := make(map[string]struct{}, len(labelsResp.Data))
+       for _, label := range labelsResp.Data {
+               labels[label] = struct{}{}
+       }
+
+       lokiLabelsCache.Lock()
+       lokiLabelsCache.items[cacheKey] = cachedLokiLabels{
+               labels:    cloneLabelSet(labels),
+               expiresAt: now.Add(labelCacheTTL),
+       }
+       lokiLabelsCache.Unlock()
+       return labels, nil
+}
+
+func (c *lokiClient) labelCacheKey() string {
+       return c.config.Endpoint + "|" + c.config.Tenant
+}
+
+func cloneLabelSet(labels map[string]struct{}) map[string]struct{} {
+       if labels == nil {
+               return nil
+       }
+       cloned := make(map[string]struct{}, len(labels))
+       for label := range labels {
+               cloned[label] = struct{}{}
+       }
+       return cloned
+}
+
+// e.g: endpoint: 
{endpoint}/loki/api/v1/query_range?query={app="order-service"}&start=1717200000000000000&end=1717203600000000000&limit=100&direction=backward
+func (c *lokiClient) queryRangeURL(logQL string, start, end time.Time, limit 
int) (string, error) {
+       baseURL, err := url.Parse(c.config.Endpoint)
+       if err != nil {
+               return "", bizerror.Wrap(err, bizerror.ConfigError, "invalid 
loki endpoint")
+       }
+       baseURL.Path = strings.TrimRight(baseURL.Path, "/") + 
"/loki/api/v1/query_range"
+
+       query := baseURL.Query()
+       query.Set("query", logQL)
+       query.Set("start", strconv.FormatInt(start.UnixNano(), 10))
+       query.Set("end", strconv.FormatInt(end.UnixNano(), 10))
+       query.Set("limit", strconv.Itoa(limit))
+       query.Set("direction", "backward")
+       baseURL.RawQuery = query.Encode()
+       return baseURL.String(), nil
+}
+
+func (c *lokiClient) labelsURL(start, end time.Time) (string, error) {
+       baseURL, err := url.Parse(c.config.Endpoint)
+       if err != nil {
+               return "", bizerror.Wrap(err, bizerror.ConfigError, "invalid 
loki endpoint")
+       }
+       baseURL.Path = strings.TrimRight(baseURL.Path, "/") + 
"/loki/api/v1/labels"
+
+       query := baseURL.Query()
+       query.Set("start", strconv.FormatInt(start.UnixNano(), 10))
+       query.Set("end", strconv.FormatInt(end.UnixNano(), 10))
+       baseURL.RawQuery = query.Encode()
+       return baseURL.String(), nil
+}
+
+func buildLogQLQueries(req *SearchLogsReq) []string {
+       return buildLogQLQueriesWithLabels(req, nil)
+}
+
+func buildLogQLQueriesWithLabels(req *SearchLogsReq, labelNames 
map[string]struct{}) []string {
+       selectors := buildStreamSelectorsWithLabels(req, labelNames)
+       queries := make([]string, 0, len(selectors))
+       for _, selector := range selectors {
+               query := selector
+               if req.Keywords != "" {
+                       query += " |= " + strconv.Quote(req.Keywords)
+               }
+               if req.TraceID != "" {
+                       query += " |= " + strconv.Quote(req.TraceID)
+               }
+               queries = append(queries, query)
+       }
+       return queries
+}
+
+func buildStreamSelectors(req *SearchLogsReq) []string {
+       return buildStreamSelectorsWithLabels(req, nil)
+}
+
+func buildStreamSelectorsWithLabels(req *SearchLogsReq, labelNames 
map[string]struct{}) []string {
+       labelGroups := make([][]string, 0, 4)
+       if req.Mesh != "" {
+               labelGroups = append(labelGroups, 
labelMatchersWithLabels([]string{"mesh"}, req.Mesh, labelNames))
+       }
+       if req.AppName != "" {
+               labelGroups = append(labelGroups, 
labelMatchersWithLabels([]string{"app", "appName"}, req.AppName, labelNames))
+       }
+       if req.ServiceName != "" {
+               labelGroups = append(labelGroups, 
labelMatchersWithLabels([]string{"service", "serviceName", "service_name"}, 
req.ServiceName, labelNames))
+       }
+       if req.InstanceName != "" {
+               labelGroups = append(labelGroups, 
labelMatchersWithLabels([]string{"instance", "instanceName", "pod"}, 
req.InstanceName, labelNames))
+       }
+       if len(labelGroups) == 0 {
+               return []string{fmt.Sprintf("{%s=~%s}", 
fallbackSelectorLabel(labelNames), strconv.Quote(".+"))}
+       }
+
+       // Cartesian product
+       selectors := []string{""}
+       for _, group := range labelGroups {
+               next := make([]string, 0, len(selectors)*len(group))
+               for _, prefix := range selectors {
+                       for _, matcher := range group {
+                               if prefix == "" {
+                                       next = append(next, matcher)
+                               } else {
+                                       next = append(next, prefix+", "+matcher)
+                               }
+                       }
+               }
+               selectors = next
+       }
+
+       result := make([]string, 0, len(selectors))
+       for _, selector := range selectors {
+               result = append(result, fmt.Sprintf("{%s}", selector))
+       }
+       return result
+}
+
+func labelMatchers(names []string, value string) []string {
+       return labelMatchersWithLabels(names, value, nil)
+}
+
+func labelMatchersWithLabels(names []string, value string, labelNames 
map[string]struct{}) []string {
+       selected := selectExistingLabels(names, labelNames)
+       matchers := make([]string, 0, len(names))
+       for _, name := range selected {
+               matchers = append(matchers, labelMatcher(name, value))
+       }
+       return matchers
+}
+
+func selectExistingLabels(names []string, labelNames map[string]struct{}) 
[]string {
+       if len(labelNames) == 0 {
+               return names
+       }
+       selected := make([]string, 0, len(names))
+       for _, name := range names {
+               if _, ok := labelNames[name]; ok {
+                       selected = append(selected, name)
+               }
+       }
+       if len(selected) == 0 {
+               return names
+       }
+       return selected
+}
+
+func matchingLabels(names []string, labelNames map[string]struct{}) []string {
+       selected := make([]string, 0, len(names))
+       for _, name := range names {
+               if _, ok := labelNames[name]; ok {
+                       selected = append(selected, name)
+               }
+       }
+       return selected
+}
+
+func fallbackSelectorLabel(labelNames map[string]struct{}) string {
+       if len(labelNames) == 0 {
+               return "namespace"
+       }
+       for _, label := range fallbackSelectorPriority {
+               if _, ok := labelNames[label]; ok {
+                       return label
+               }
+       }
+       return "namespace"
+}
+
+func supportedLabels(labelNames map[string]struct{}) []string {
+       labels := make([]string, 0, len(labelNames))
+       for label := range labelNames {
+               labels = append(labels, label)
+       }
+       sort.Strings(labels)
+       return labels
+}
+
+func labelMatcher(name, value string) string {
+       return fmt.Sprintf("%s=%s", name, strconv.Quote(value))
+}
+
+func resolveTimeRange(startRaw, endRaw string) (time.Time, time.Time, error) {
+       end := time.Now()
+       if endRaw != "" {
+               parsed, err := parseLogTime("endTime", endRaw)
+               if err != nil {
+                       return time.Time{}, time.Time{}, err
+               }
+               end = parsed
+       }
+       start := end.Add(-defaultQueryWindow)
+       if startRaw != "" {
+               parsed, err := parseLogTime("startTime", startRaw)
+               if err != nil {
+                       return time.Time{}, time.Time{}, err
+               }
+               start = parsed
+       }
+       if start.After(end) {
+               return time.Time{}, time.Time{}, 
bizerror.New(bizerror.InvalidArgument, "startTime must be less than or equal to 
endTime")
+       }
+       return start, end, nil
+}
+
+func parseLogTime(field, value string) (time.Time, error) {
+       if ts, err := time.Parse(time.RFC3339Nano, value); err == nil {
+               return ts, nil
+       }
+       if ns, err := strconv.ParseInt(value, 10, 64); err == nil {
+               return time.Unix(0, ns), nil
+       }
+       return time.Time{}, bizerror.New(bizerror.InvalidArgument,
+               fmt.Sprintf("%s must be RFC3339, RFC3339Nano, or Unix 
nanoseconds", field))
+}
+
+func normalizeLokiLogs(resp lokiQueryRangeResp) []LogItem {
+       logs := make([]LogItem, 0)
+       for _, stream := range resp.Data.Result {
+               for _, value := range stream.Values {
+                       if len(value) < 2 {
+                               continue
+                       }
+                       raw := value[1]
+                       message := extractLogField(raw, "msg", "message")
+                       if message == "" {
+                               message = raw
+                       }
+                       logs = append(logs, LogItem{
+                               Timestamp:    normalizeLogTimestamp(value[0], 
extractLogField(raw, "time", "timestamp")),
+                               AppName:      firstLabel(stream.Stream, "app", 
"appName"),
+                               ServiceName:  firstLabel(stream.Stream, 
"service", "serviceName", "service_name"),
+                               InstanceName: firstLabel(stream.Stream, 
"instance", "instanceName", "pod"),
+                               Severity:     
firstNonEmpty(extractLogField(raw, "level", "severity"), 
firstLabel(stream.Stream, "level", "severity", "detected_level")),
+                               Message:      message,
+                               TraceID:      
firstNonEmpty(extractLogField(raw, "trace_id", "traceId", "traceid"), 
firstLabel(stream.Stream, "trace_id", "traceId", "traceid")),
+                               SpanID:       
firstNonEmpty(extractLogField(raw, "span_id", "spanId", "spanid"), 
firstLabel(stream.Stream, "span_id", "spanId", "spanid")),
+                               TraceFlags:   
firstNonEmpty(extractLogField(raw, "trace_flags", "traceFlags", "traceflags"), 
firstLabel(stream.Stream, "trace_flags", "traceFlags", "traceflags")),
+                               Attributes:   extraLabels(stream.Stream),
+                               Raw:          raw,
+                       })
+               }
+       }
+       return logs
+}
+
+func normalizeLogTimestamp(lokiTimestamp, logTimestamp string) string {
+       if logTimestamp != "" {
+               if parsed, err := time.Parse(time.RFC3339Nano, logTimestamp); 
err == nil {
+                       return parsed.UTC().Format(time.RFC3339Nano)
+               }
+       }
+       return normalizeLokiTimestamp(lokiTimestamp)
+}
+
+func normalizeLokiTimestamp(value string) string {
+       ns, err := strconv.ParseInt(value, 10, 64)
+       if err != nil {
+               return value
+       }
+       return time.Unix(0, ns).UTC().Format(time.RFC3339Nano)
+}
+
+// firstLabel returns the first label value that matches any of the keys, or 
an empty string if none matches
+func firstLabel(labels map[string]string, keys ...string) string {
+       for _, key := range keys {
+               if value := labels[key]; value != "" {
+                       return value
+               }
+       }
+       return ""
+}
+
+func firstNonEmpty(values ...string) string {
+       for _, value := range values {
+               if value != "" {
+                       return value
+               }
+       }
+       return ""
+}
+
+func extractLogField(message string, keys ...string) string {
+       if value := extractJSONLogField(message, keys...); value != "" {
+               return value
+       }
+       return extractTextLogField(message, keys...)
+}
+
+func extractJSONLogField(message string, keys ...string) string {
+       var payload map[string]any
+       if err := json.Unmarshal([]byte(message), &payload); err != nil {
+               return ""
+       }
+       for _, key := range keys {
+               if value := stringifyLogField(payload[key]); value != "" {
+                       return value
+               }
+       }
+       return ""
+}
+
+func stringifyLogField(value any) string {
+       switch v := value.(type) {
+       case string:
+               return v
+       case float64, bool:
+               return fmt.Sprint(v)
+       default:
+               return ""
+       }
+}
+
+// cache the compiled regex per key and reuse it.
+var textLogFieldPatterns sync.Map // map[string]*regexp.Regexp
+func textLogFieldPattern(key string) *regexp.Regexp {
+       if re, ok := textLogFieldPatterns.Load(key); ok {
+               return re.(*regexp.Regexp)
+       }
+       compiled := regexp.MustCompile(`(?i)(?:^|[\s{,])"?` + 
regexp.QuoteMeta(key) + `"?\s*[:=]\s*"?([^"\s,}]+)`)
+       actual, _ := textLogFieldPatterns.LoadOrStore(key, compiled)
+       return actual.(*regexp.Regexp)
+}
+
+func extractTextLogField(message string, keys ...string) string {
+       for _, key := range keys {
+               pattern := textLogFieldPattern(key)
+               if matches := pattern.FindStringSubmatch(message); len(matches) 
== 2 {
+                       return matches[1]
+               }
+       }
+       return ""
+}
+
+// extraLabels returns all labels except the ones used to filter the logs
+func extraLabels(labels map[string]string) map[string]string {
+       attrs := make(map[string]string)
+       for key, value := range labels {
+               switch key {
+               case "mesh", "app", "appName", "service", "serviceName", 
"service_name", "instance", "instanceName",
+                       "pod", "level", "severity", "detected_level", 
"trace_id", "traceId", "traceid", "span_id", "spanId",
+                       "spanid", "trace_flags", "traceFlags", "traceflags":
+                       continue
+               default:
+                       attrs[key] = value
+               }
+       }
+       if len(attrs) == 0 {
+               return nil
+       }
+       return attrs
+}
+
+func dedupeKey(item LogItem) string {
+       return strings.Join([]string{item.Timestamp, item.Message, 
item.TraceID, item.SpanID}, "\x00")
+}
diff --git a/pkg/mcp/tools/log/model.go b/pkg/mcp/tools/log/model.go
new file mode 100644
index 00000000..bbf63656
--- /dev/null
+++ b/pkg/mcp/tools/log/model.go
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 log
+
+type SearchLogsReq struct {
+       Mesh         string `json:"mesh,omitempty"`
+       AppName      string `json:"appName,omitempty"`
+       ServiceName  string `json:"serviceName,omitempty"`
+       InstanceName string `json:"instanceName,omitempty"`
+       TraceID      string `json:"traceId,omitempty"`
+       Keywords     string `json:"keywords,omitempty"`
+       StartTime    string `json:"startTime,omitempty"`
+       EndTime      string `json:"endTime,omitempty"`
+       Limit        int    `json:"limit,omitempty"`
+}
+
+type LogItem struct {
+       Timestamp    string            `json:"timestamp"`
+       AppName      string            `json:"appName,omitempty"`
+       ServiceName  string            `json:"serviceName,omitempty"`
+       InstanceName string            `json:"instanceName,omitempty"`
+       Severity     string            `json:"severity,omitempty"`
+       Message      string            `json:"message"`
+       TraceID      string            `json:"traceId,omitempty"`
+       SpanID       string            `json:"spanId,omitempty"`
+       TraceFlags   string            `json:"traceFlags,omitempty"`
+       Attributes   map[string]string `json:"attributes,omitempty"`
+       Raw          string            `json:"raw,omitempty"`
+}
+
+type SearchLogsResp struct {
+       Logs         []LogItem `json:"logs"`
+       SourceEngine string    `json:"sourceEngine"`
+}
+
+type AnalyzeErrorLogsReq struct {
+       SearchLogsReq
+}
+
+type LogCapabilitiesReq struct {
+       StartTime string `json:"startTime,omitempty"`
+       EndTime   string `json:"endTime,omitempty"`
+}
+
+type LogCapabilitiesResp struct {
+       AvailableLabels  []string            `json:"availableLabels"`
+       SupportedFilters []string            `json:"supportedFilters"`
+       LabelFilters     map[string][]string `json:"labelFilters"`
+       ContentFilters   []string            `json:"contentFilters"`
+       FallbackLabel    string              `json:"fallbackLabel"`
+       SourceEngine     string              `json:"sourceEngine"`
+}
+
+type ErrorPattern struct {
+       Pattern   string    `json:"pattern"`
+       Count     int       `json:"count"`
+       Example   string    `json:"example,omitempty"`
+       FirstSeen string    `json:"firstSeen,omitempty"`
+       LastSeen  string    `json:"lastSeen,omitempty"`
+       Examples  []LogItem `json:"examples,omitempty"`
+}
+
+type AnalyzeErrorLogsResp struct {
+       Summary      string         `json:"summary"`
+       TotalErrors  int            `json:"totalErrors"`
+       Patterns     []ErrorPattern `json:"patterns"`
+       SourceEngine string         `json:"sourceEngine"`
+}
diff --git a/pkg/mcp/tools/log/tools.go b/pkg/mcp/tools/log/tools.go
new file mode 100644
index 00000000..a801ce2f
--- /dev/null
+++ b/pkg/mcp/tools/log/tools.go
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 log
+
+import (
+       "context"
+       "fmt"
+
+       consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+       "github.com/apache/dubbo-admin/pkg/mcp/common"
+)
+
+const defaultLogLimit = 100
+
+func SearchLogs(ctx consolectx.Context, args map[string]any) 
(*common.ToolResult, error) {
+       client, err := lokiClientFromContext(ctx)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       resp, err := client.search(requestContext(ctx), 
buildSearchLogsReq(args))
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       return common.JsonResult(resp)
+}
+
+func AnalyzeErrorLogs(ctx consolectx.Context, args map[string]any) 
(*common.ToolResult, error) {
+       client, err := lokiClientFromContext(ctx)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       req := buildSearchLogsReq(args)
+       if req.Keywords == "" {
+               req.Keywords = "Error"
+       }
+       searchResp, err := client.search(requestContext(ctx), req)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       return common.JsonResult(analyzeErrors(searchResp.Logs, 
searchResp.SourceEngine))
+}
+
+func GetLogCapabilities(ctx consolectx.Context, args map[string]any) 
(*common.ToolResult, error) {
+       client, err := lokiClientFromContext(ctx)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       resp, err := client.capabilities(requestContext(ctx), 
buildLogCapabilitiesReq(args))
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       return common.JsonResult(resp)
+}
+
+func lokiClientFromContext(ctx consolectx.Context) (*lokiClient, error) {
+       if ctx == nil || ctx.Config().Observability == nil || 
ctx.Config().Observability.Logs == nil {
+               return nil, fmt.Errorf("loki log provider is not configured")
+       }
+       provider, ok := ctx.Config().Observability.Logs.Default()
+       if !ok {
+               return nil, fmt.Errorf("default loki log provider is not 
configured")
+       }
+       return newLokiClient(provider), nil
+}
+
+func requestContext(ctx consolectx.Context) context.Context {
+       if ctx == nil || ctx.AppContext() == nil {
+               return context.Background()
+       }
+       return ctx.AppContext()
+}
+
+func buildSearchLogsReq(args map[string]any) *SearchLogsReq {
+       helper := common.NewArgsHelper(args)
+       return &SearchLogsReq{
+               Mesh:         helper.GetString("mesh", ""),
+               AppName:      helper.GetString("appName", ""),
+               ServiceName:  helper.GetString("serviceName", ""),
+               InstanceName: helper.GetString("instanceName", ""),
+               TraceID:      helper.GetString("traceId", ""),
+               Keywords:     helper.GetString("keywords", ""),
+               StartTime:    helper.GetString("startTime", ""),
+               EndTime:      helper.GetString("endTime", ""),
+               Limit:        helper.GetInt("limit", defaultLogLimit),
+       }
+}
+
+func buildLogCapabilitiesReq(args map[string]any) *LogCapabilitiesReq {
+       helper := common.NewArgsHelper(args)
+       return &LogCapabilitiesReq{
+               StartTime: helper.GetString("startTime", ""),
+               EndTime:   helper.GetString("endTime", ""),
+       }
+}
+
+func LogSearchProperties() map[string]common.PropertyDef {
+       return map[string]common.PropertyDef{
+               "mesh": {
+                       Type:        "string",
+                       Description: "Mesh 名称,用于显式按 mesh label 过滤",
+               },
+               "appName": {
+                       Type:        "string",
+                       Description: "应用名称",
+               },
+               "serviceName": {
+                       Type:        "string",
+                       Description: "服务名称",
+               },
+               "instanceName": {
+                       Type:        "string",
+                       Description: "实例、Pod 或主机名称",
+               },
+               "traceId": {
+                       Type:        "string",
+                       Description: "TraceID",
+               },
+               "keywords": {
+                       Type:        "string",
+                       Description: "日志关键字",
+               },
+               "startTime": {
+                       Type:        "string",
+                       Description: "开始时间,支持 RFC3339/RFC3339Nano 或 Unix 纳秒",
+               },
+               "endTime": {
+                       Type:        "string",
+                       Description: "结束时间,支持 RFC3339/RFC3339Nano 或 Unix 纳秒",
+               },
+               "limit": {
+                       Type:        "integer",
+                       Description: "返回日志条数上限",
+                       Default:     defaultLogLimit,
+               },
+       }
+}
+
+func LogCapabilitiesProperties() map[string]common.PropertyDef {
+       return map[string]common.PropertyDef{
+               "startTime": {
+                       Type:        "string",
+                       Description: "开始时间,支持 RFC3339/RFC3339Nano 或 Unix 纳秒",
+               },
+               "endTime": {
+                       Type:        "string",
+                       Description: "结束时间,支持 RFC3339/RFC3339Nano 或 Unix 纳秒",
+               },
+       }
+}
diff --git a/pkg/mcp/tools/log/tools_test.go b/pkg/mcp/tools/log/tools_test.go
new file mode 100644
index 00000000..08e2b412
--- /dev/null
+++ b/pkg/mcp/tools/log/tools_test.go
@@ -0,0 +1,440 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 log
+
+import (
+       "context"
+       "encoding/json"
+       "net/http"
+       "net/http/httptest"
+       "testing"
+
+       "github.com/apache/dubbo-admin/pkg/config/app"
+       "github.com/apache/dubbo-admin/pkg/config/observability"
+       consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+       "github.com/apache/dubbo-admin/pkg/console/counter"
+       "github.com/apache/dubbo-admin/pkg/core/lock"
+       "github.com/apache/dubbo-admin/pkg/core/manager"
+)
+
+func TestLogToolPropertiesAreAvailable(t *testing.T) {
+       searchProperties := LogSearchProperties()
+       for _, name := range []string{"mesh", "appName", "serviceName", 
"instanceName", "traceId", "keywords", "startTime", "endTime", "limit"} {
+               if _, ok := searchProperties[name]; !ok {
+                       t.Fatalf("search property %s was not configured", name)
+               }
+       }
+
+       capabilityProperties := LogCapabilitiesProperties()
+       for _, name := range []string{"startTime", "endTime"} {
+               if _, ok := capabilityProperties[name]; !ok {
+                       t.Fatalf("capability property %s was not configured", 
name)
+               }
+       }
+}
+
+func TestGetLogCapabilitiesReturnsAvailableLabelsAndResolvedFilters(t 
*testing.T) {
+       loki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               if r.URL.Path != "/loki/api/v1/labels" {
+                       t.Fatalf("unexpected loki path: %s", r.URL.Path)
+               }
+               w.Header().Set("Content-Type", "application/json")
+               _, _ = 
w.Write([]byte(`{"status":"success","data":["pod","namespace","service_name","mesh"]}`))
+       }))
+       defer loki.Close()
+
+       result, err := GetLogCapabilities(newLogToolTestContext(loki.URL), 
map[string]any{
+               "startTime": "2026-04-01T00:00:00Z",
+               "endTime":   "2026-04-01T01:00:00Z",
+       })
+       if err != nil {
+               t.Fatalf("GetLogCapabilities returned unexpected error: %v", 
err)
+       }
+       if result.IsError {
+               t.Fatalf("GetLogCapabilities returned error result: %s", 
result.Content[0].Text)
+       }
+
+       var payload LogCapabilitiesResp
+       if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err 
!= nil {
+               t.Fatalf("failed to decode tool result: %v", err)
+       }
+       expectedLabels := []string{"mesh", "namespace", "pod", "service_name"}
+       if len(payload.AvailableLabels) != len(expectedLabels) {
+               t.Fatalf("expected labels %v, got %v", expectedLabels, 
payload.AvailableLabels)
+       }
+       for i := range expectedLabels {
+               if payload.AvailableLabels[i] != expectedLabels[i] {
+                       t.Fatalf("label[%d] expected %q, got %q", i, 
expectedLabels[i], payload.AvailableLabels[i])
+               }
+       }
+       if payload.FallbackLabel != "namespace" {
+               t.Fatalf("expected fallbackLabel namespace, got %q", 
payload.FallbackLabel)
+       }
+       if got := payload.LabelFilters["serviceName"]; len(got) != 1 || got[0] 
!= "service_name" {
+               t.Fatalf("expected serviceName to resolve to service_name, got 
%v", got)
+       }
+       if got := payload.LabelFilters["instanceName"]; len(got) != 1 || got[0] 
!= "pod" {
+               t.Fatalf("expected instanceName to resolve to pod, got %v", got)
+       }
+       if got := payload.LabelFilters["appName"]; len(got) != 0 {
+               t.Fatalf("expected appName to have no available labels, got 
%v", got)
+       }
+       if len(payload.ContentFilters) != 2 || payload.ContentFilters[0] != 
"traceId" || payload.ContentFilters[1] != "keywords" {
+               t.Fatalf("unexpected content filters: %v", 
payload.ContentFilters)
+       }
+       if payload.SourceEngine != "loki" {
+               t.Fatalf("expected sourceEngine loki, got %q", 
payload.SourceEngine)
+       }
+}
+
+func TestBuildLogQLQueriesUsesCommonLabelAliases(t *testing.T) {
+       queries := buildLogQLQueries(&SearchLogsReq{
+               ServiceName: "org.apache.DemoService",
+               Keywords:    "Error",
+       })
+
+       expected := []string{
+               `{service="org.apache.DemoService"} |= "Error"`,
+               `{serviceName="org.apache.DemoService"} |= "Error"`,
+               `{service_name="org.apache.DemoService"} |= "Error"`,
+       }
+       if len(queries) != len(expected) {
+               t.Fatalf("expected %d queries, got %d: %v", len(expected), 
len(queries), queries)
+       }
+       for i := range expected {
+               if queries[i] != expected[i] {
+                       t.Fatalf("query[%d] expected %q, got %q", i, 
expected[i], queries[i])
+               }
+       }
+}
+
+func TestBuildLogQLQueriesFiltersTraceIDFromLogContent(t *testing.T) {
+       queries := buildLogQLQueries(&SearchLogsReq{
+               ServiceName: "org.apache.DemoService",
+               TraceID:     "trace-1",
+               Keywords:    "ERROR",
+       })
+
+       expected := []string{
+               `{service="org.apache.DemoService"} |= "ERROR" |= "trace-1"`,
+               `{serviceName="org.apache.DemoService"} |= "ERROR" |= 
"trace-1"`,
+               `{service_name="org.apache.DemoService"} |= "ERROR" |= 
"trace-1"`,
+       }
+       if len(queries) != len(expected) {
+               t.Fatalf("expected %d queries, got %d: %v", len(expected), 
len(queries), queries)
+       }
+       for i := range expected {
+               if queries[i] != expected[i] {
+                       t.Fatalf("query[%d] expected %q, got %q", i, 
expected[i], queries[i])
+               }
+       }
+}
+
+func TestBuildLogQLQueriesUsesNamespaceSelectorWhenOnlyTraceIDIsProvided(t 
*testing.T) {
+       queries := buildLogQLQueries(&SearchLogsReq{
+               TraceID: "trace-1",
+       })
+
+       expected := []string{
+               `{namespace=~".+"} |= "trace-1"`,
+       }
+       if len(queries) != len(expected) {
+               t.Fatalf("expected %d queries, got %d: %v", len(expected), 
len(queries), queries)
+       }
+       for i := range expected {
+               if queries[i] != expected[i] {
+                       t.Fatalf("query[%d] expected %q, got %q", i, 
expected[i], queries[i])
+               }
+       }
+}
+
+func TestNormalizeLokiLogsExtractsTraceContextFromMessage(t *testing.T) {
+       logs := normalizeLokiLogs(lokiQueryRangeResp{
+               Status: "success",
+               Data: struct {
+                       Result []lokiStream `json:"result"`
+               }{
+                       Result: []lokiStream{{
+                               Stream: map[string]string{
+                                       "app":      "demo-provider",
+                                       "trace_id": "label-trace",
+                                       "span_id":  "label-span",
+                               },
+                               Values: [][]string{{
+                                       "1777110661783444000",
+                                       `ERROR trace_id=message-trace 
span_id=message-span metadata report failed`,
+                               }},
+                       }},
+               },
+       })
+
+       if len(logs) != 1 {
+               t.Fatalf("expected one log, got %d", len(logs))
+       }
+       if logs[0].TraceID != "message-trace" || logs[0].SpanID != 
"message-span" {
+               t.Fatalf("expected trace context from message, got traceID=%q 
spanID=%q", logs[0].TraceID, logs[0].SpanID)
+       }
+}
+
+func TestNormalizeLokiLogsParsesDubboGoJSONLog(t *testing.T) {
+       raw := `{"level":"error","msg":"error 
message","span_id":"36d69a3dd9bea02c","time":"2026-06-02T14:56:37+08:00","trace_flags":"01","trace_id":"faba6a688ea3070b1613f50fb081c578"}`
+       logs := normalizeLokiLogs(lokiQueryRangeResp{
+               Status: "success",
+               Data: struct {
+                       Result []lokiStream `json:"result"`
+               }{
+                       Result: []lokiStream{{
+                               Stream: map[string]string{
+                                       "app":          "dubbo-go-provider",
+                                       "service_name": 
"org.apache.DemoService",
+                               },
+                               Values: [][]string{{
+                                       "1777110661783444000",
+                                       raw,
+                               }},
+                       }},
+               },
+       })
+
+       if len(logs) != 1 {
+               t.Fatalf("expected one log, got %d", len(logs))
+       }
+       logItem := logs[0]
+       if logItem.Timestamp != "2026-06-02T06:56:37Z" {
+               t.Fatalf("expected timestamp from JSON log time, got %q", 
logItem.Timestamp)
+       }
+       if logItem.Severity != "error" || logItem.Message != "error message" {
+               t.Fatalf("expected level and msg from JSON log, got severity=%q 
message=%q", logItem.Severity, logItem.Message)
+       }
+       if logItem.TraceID != "faba6a688ea3070b1613f50fb081c578" || 
logItem.SpanID != "36d69a3dd9bea02c" || logItem.TraceFlags != "01" {
+               t.Fatalf("unexpected trace context: traceID=%q spanID=%q 
traceFlags=%q", logItem.TraceID, logItem.SpanID, logItem.TraceFlags)
+       }
+       if logItem.Raw != raw {
+               t.Fatalf("expected raw JSON log to be preserved, got %q", 
logItem.Raw)
+       }
+}
+
+func TestSearchLogsQueriesLoki(t *testing.T) {
+       var seenQueries []string
+       loki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               w.Header().Set("Content-Type", "application/json")
+               switch r.URL.Path {
+               case "/loki/api/v1/labels":
+                       http.Error(w, "labels unavailable", 
http.StatusInternalServerError)
+               case "/loki/api/v1/query_range":
+                       seenQueries = append(seenQueries, 
r.URL.Query().Get("query"))
+                       _, _ = w.Write([]byte(`{
+                               "status": "success",
+                               "data": {
+                                       "resultType": "streams",
+                                       "result": [{
+                                               "stream": {
+                                                       "app": "demo-provider",
+                                                       "service_name": 
"org.apache.DemoService",
+                                                       "instance": 
"127.0.0.1:20880",
+                                                       "level": "ERROR",
+                                                       "namespace": 
"dubbo-system"
+                                               },
+                                               "values": 
[["1777110661783444000", "ERROR trace_id=trace-1 span_id=span-1 test log"]]
+                                       }]
+                               }
+                       }`))
+               default:
+                       t.Fatalf("unexpected loki path: %s", r.URL.Path)
+               }
+       }))
+       defer loki.Close()
+
+       result, err := SearchLogs(newLogToolTestContext(loki.URL), 
map[string]any{
+               "serviceName": "org.apache.DemoService",
+               "keywords":    "ERROR",
+               "startTime":   "2026-04-01T00:00:00Z",
+               "endTime":     "2026-04-01T01:00:00Z",
+               "limit":       float64(10),
+       })
+       if err != nil {
+               t.Fatalf("SearchLogs returned unexpected error: %v", err)
+       }
+       if result.IsError {
+               t.Fatalf("SearchLogs returned error result: %s", 
result.Content[0].Text)
+       }
+
+       var payload SearchLogsResp
+       if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err 
!= nil {
+               t.Fatalf("failed to decode tool result: %v", err)
+       }
+       if len(payload.Logs) != 1 {
+               t.Fatalf("expected one deduplicated log, got %d", 
len(payload.Logs))
+       }
+       logItem := payload.Logs[0]
+       if logItem.ServiceName != "org.apache.DemoService" || logItem.TraceID 
!= "trace-1" || logItem.Severity != "ERROR" {
+               t.Fatalf("unexpected normalized log: %+v", logItem)
+       }
+       if len(seenQueries) != 3 {
+               t.Fatalf("expected three service label alias queries, got %d: 
%v", len(seenQueries), seenQueries)
+       }
+}
+
+func TestSearchLogsUsesLokiLabelsToReduceAliasQueries(t *testing.T) {
+       var seenQueries []string
+       labelsRequested := false
+       loki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               w.Header().Set("Content-Type", "application/json")
+               switch r.URL.Path {
+               case "/loki/api/v1/labels":
+                       labelsRequested = true
+                       _, _ = 
w.Write([]byte(`{"status":"success","data":["namespace","service_name","pod"]}`))
+               case "/loki/api/v1/query_range":
+                       seenQueries = append(seenQueries, 
r.URL.Query().Get("query"))
+                       _, _ = w.Write([]byte(`{
+                               "status": "success",
+                               "data": {
+                                       "result": [{
+                                               "stream": {"service_name": 
"org.apache.DemoService", "namespace": "dubbo-system"},
+                                               "values": 
[["1777110661783444000", "ERROR trace_id=trace-1 test log"]]
+                                       }]
+                               }
+                       }`))
+               default:
+                       t.Fatalf("unexpected loki path: %s", r.URL.Path)
+               }
+       }))
+       defer loki.Close()
+
+       result, err := SearchLogs(newLogToolTestContext(loki.URL), 
map[string]any{
+               "serviceName": "org.apache.DemoService",
+               "keywords":    "ERROR",
+               "startTime":   "2026-04-01T00:00:00Z",
+               "endTime":     "2026-04-01T01:00:00Z",
+       })
+       if err != nil {
+               t.Fatalf("SearchLogs returned unexpected error: %v", err)
+       }
+       if result.IsError {
+               t.Fatalf("SearchLogs returned error result: %s", 
result.Content[0].Text)
+       }
+       if !labelsRequested {
+               t.Fatal("expected labels endpoint to be requested")
+       }
+       expected := []string{`{service_name="org.apache.DemoService"} |= 
"ERROR"`}
+       if len(seenQueries) != len(expected) {
+               t.Fatalf("expected %d queries, got %d: %v", len(expected), 
len(seenQueries), seenQueries)
+       }
+       for i := range expected {
+               if seenQueries[i] != expected[i] {
+                       t.Fatalf("query[%d] expected %q, got %q", i, 
expected[i], seenQueries[i])
+               }
+       }
+}
+
+func TestSearchLogsUsesAvailableFallbackLabelWhenOnlyTraceIDIsProvided(t 
*testing.T) {
+       var seenQueries []string
+       loki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               w.Header().Set("Content-Type", "application/json")
+               switch r.URL.Path {
+               case "/loki/api/v1/labels":
+                       _, _ = 
w.Write([]byte(`{"status":"success","data":["pod","job"]}`))
+               case "/loki/api/v1/query_range":
+                       seenQueries = append(seenQueries, 
r.URL.Query().Get("query"))
+                       _, _ = 
w.Write([]byte(`{"status":"success","data":{"result":[]}}`))
+               default:
+                       t.Fatalf("unexpected loki path: %s", r.URL.Path)
+               }
+       }))
+       defer loki.Close()
+
+       result, err := SearchLogs(newLogToolTestContext(loki.URL), 
map[string]any{
+               "traceId":   "trace-1",
+               "startTime": "2026-04-01T00:00:00Z",
+               "endTime":   "2026-04-01T01:00:00Z",
+       })
+       if err != nil {
+               t.Fatalf("SearchLogs returned unexpected error: %v", err)
+       }
+       if result.IsError {
+               t.Fatalf("SearchLogs returned error result: %s", 
result.Content[0].Text)
+       }
+       expected := []string{`{job=~".+"} |= "trace-1"`}
+       if len(seenQueries) != len(expected) {
+               t.Fatalf("expected %d queries, got %d: %v", len(expected), 
len(seenQueries), seenQueries)
+       }
+       for i := range expected {
+               if seenQueries[i] != expected[i] {
+                       t.Fatalf("query[%d] expected %q, got %q", i, 
expected[i], seenQueries[i])
+               }
+       }
+}
+
+func TestAnalyzeErrorsGroupsPatterns(t *testing.T) {
+       resp := analyzeErrors([]LogItem{
+               {Timestamp: "2026-04-01T00:00:00Z", Severity: "ERROR", Message: 
"Error 500 for request 123"},
+               {Timestamp: "2026-04-01T00:01:00Z", Severity: "ERROR", Message: 
"Error 503 for request 456"},
+               {Timestamp: "2026-04-01T00:02:00Z", Severity: "INFO", Message: 
"normal log"},
+       }, "loki")
+
+       if resp.TotalErrors != 2 {
+               t.Fatalf("expected 2 errors, got %d", resp.TotalErrors)
+       }
+       if len(resp.Patterns) != 1 {
+               t.Fatalf("expected one pattern, got %d", len(resp.Patterns))
+       }
+       if resp.Patterns[0].Pattern != "Error ? for request ?" || 
resp.Patterns[0].Count != 2 {
+               t.Fatalf("unexpected pattern: %+v", resp.Patterns[0])
+       }
+}
+
+func newLogToolTestContext(endpoint string) consolectx.Context {
+       return &logToolTestContext{
+               config: app.AdminConfig{
+                       Observability: &observability.Config{
+                               Logs: &observability.LogsConfig{
+                                       DefaultProvider: "loki-main",
+                                       Providers: 
[]observability.LogProviderConfig{{
+                                               Name:     "loki-main",
+                                               Type:     
observability.LogProviderLoki,
+                                               Endpoint: endpoint,
+                                       }},
+                               },
+                       },
+               },
+       }
+}
+
+type logToolTestContext struct {
+       config app.AdminConfig
+}
+
+func (c *logToolTestContext) ResourceManager() manager.ResourceManager {
+       return nil
+}
+
+func (c *logToolTestContext) CounterManager() counter.CounterManager {
+       return nil
+}
+
+func (c *logToolTestContext) Config() app.AdminConfig {
+       return c.config
+}
+
+func (c *logToolTestContext) AppContext() context.Context {
+       return context.Background()
+}
+
+func (c *logToolTestContext) LockManager() lock.Lock {
+       return nil
+}

Reply via email to