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 836b3140 feat: add PromQL and trace diagnosis tools (#1499)
836b3140 is described below

commit 836b3140ce2bd6a0962f29ec2b2769dc2022f467
Author: ThunGuo <[email protected]>
AuthorDate: Sat Aug 22 08:45:04 2026 +0800

    feat: add PromQL and trace diagnosis tools (#1499)
    
    * feat: add PromQL and trace diagnosis tools
    
    * Potential fix for pull request finding
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    * Potential fix for pull request finding
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    ---------
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 ai/prompts/agentObserve.txt              |   5 +
 ai/prompts/agentReasonAct.txt            |  41 ++++
 app/dubbo-admin/dubbo-admin.yaml         |  10 +
 pkg/config/observability/config.go       |  14 ++
 pkg/config/observability/tracing.go      | 133 ++++++++++
 pkg/config/observability/tracing_test.go |  79 ++++++
 pkg/mcp/DEVELOPMENT.md                   |  29 +++
 pkg/mcp/register.go                      |  42 ++++
 pkg/mcp/register_test.go                 |  37 +++
 pkg/mcp/server.go                        |  17 +-
 pkg/mcp/tools/metrics/model.go           |  54 ++++
 pkg/mcp/tools/metrics/prometheus.go      | 329 +++++++++++++++++++++++++
 pkg/mcp/tools/metrics/prometheus_test.go | 106 ++++++++
 pkg/mcp/tools/metrics/tools.go           |  89 +++++++
 pkg/mcp/tools/observability.go           |  56 +++++
 pkg/mcp/tools/trace/jaeger.go            | 406 +++++++++++++++++++++++++++++++
 pkg/mcp/tools/trace/jaeger_test.go       |  99 ++++++++
 pkg/mcp/tools/trace/model.go             |  55 +++++
 pkg/mcp/tools/trace/provider.go          |  39 +++
 pkg/mcp/tools/trace/tools.go             |  83 +++++++
 20 files changed, 1721 insertions(+), 2 deletions(-)

diff --git a/ai/prompts/agentObserve.txt b/ai/prompts/agentObserve.txt
index 439a8194..e0c1254e 100644
--- a/ai/prompts/agentObserve.txt
+++ b/ai/prompts/agentObserve.txt
@@ -9,6 +9,11 @@ Given conversation history and tool outputs, return a short 
structured decision.
 - Set `heartbeat=false` when current evidence is enough to answer the user.
 - If the same or similar RAG/tool search failed multiple times, stop and 
answer with available knowledge.
 - Do not repeat long raw tool content.
+- For metrics, cite the evaluated time or range, key labels, and values that 
support the conclusion.
+- For traces, cite the relevant service, operation, span ID, parent span ID, 
duration, and error status.
+- Distinguish explicitly between no data, provider/query failure, and evidence 
of a healthy state.
+- A single metric spike is not a root cause. Continue when logs, trace 
evidence, or a comparison dimension is still needed to validate the hypothesis.
+- Stop when the available metrics and trace evidence identify the affected 
scope and the most likely failure boundary, or when configured observability 
cannot provide further evidence.
 
 # Latest-Turn Rules (STRICT)
 - You must answer the latest user message in this session only.
diff --git a/ai/prompts/agentReasonAct.txt b/ai/prompts/agentReasonAct.txt
index be19de68..4b349084 100644
--- a/ai/prompts/agentReasonAct.txt
+++ b/ai/prompts/agentReasonAct.txt
@@ -55,6 +55,47 @@ Only call a tool when it is genuinely needed:
 
 When uncertain whether retrieval adds value, answer directly with no tool call.
 
+# Observability Diagnosis Rules
+- For live Dubbo incidents, prefer runtime evidence over documentation
+  retrieval.
+- Before the first metrics or trace query, call
+  `get_observability_capabilities` when provider availability is unknown.
+- Use `query_prometheus` for a current snapshot and `query_prometheus_range`
+  only for trends or a specific incident window. Range queries must stay within
+  24 hours and use a step of at least 15 seconds.
+- Use metric and label names that exist in Dubbo Admin dashboards. Important
+  labels include `application_name`, `instance`, `pod`, `interface`, `method`,
+  and `version`.
+- Common provider metrics include `dubbo_provider_qps_total`,
+  `dubbo_provider_requests_total`, `dubbo_provider_requests_succeed_total`,
+  `dubbo_provider_requests_failed_total`,
+  `dubbo_provider_requests_timeout_total`,
+  `dubbo_provider_requests_processing`,
+  `dubbo_provider_rt_avg_milliseconds_aggregate`,
+  `dubbo_provider_rt_milliseconds_p95`, and
+  `dubbo_provider_rt_milliseconds_p99`.
+- Equivalent consumer metrics use the `dubbo_consumer_` prefix. Thread-pool
+  signals include `dubbo_thread_pool_active_size`,
+  `dubbo_thread_pool_core_size`, and `dubbo_thread_pool_queue_size`.
+- Evaluate counters with `rate` or `increase` over a bounded window. For 
success
+  rate, divide successful requests by total requests and protect against an
+  empty denominator.
+- Begin with an application-level aggregation, then narrow to `instance` or
+  `pod`; narrow to `interface` and `method` only when service-level evidence
+  requires it. Never issue an unbounded high-cardinality PromQL query.
+- When the user supplies a TraceID, or another tool returns one, call
+  `get_trace_by_id` before guessing where the failure occurred. Use
+  `parentSpanId`, `serviceName`, `durationMicros`, and error status to locate
+  propagation failures and bottlenecks.
+- Correlate evidence in this order when available: service metadata, metrics,
+  logs, then trace spans.
+- Treat no data, provider or query failure, and healthy data as distinct
+  outcomes. Missing data is not evidence of health.
+- Do not repeat an identical successful query. Change the dimension or time
+  window only to test a stated hypothesis.
+- Never invent metric names, label values, TraceIDs, provider names, or tool
+  results.
+
 # Memory Rules
 The recent conversation history is already provided to you in context. When the
 user refers back to earlier turns — "previous", "before", "earlier", "之前",
diff --git a/app/dubbo-admin/dubbo-admin.yaml b/app/dubbo-admin/dubbo-admin.yaml
index b348772c..4161c39a 100644
--- a/app/dubbo-admin/dubbo-admin.yaml
+++ b/app/dubbo-admin/dubbo-admin.yaml
@@ -43,6 +43,16 @@ observability:
   #       type: loki
   #       endpoint: http://localhost:3100
   #       tenant: ""
+  # [Optional] trace providers used by MCP diagnosis tools.
+  # The bundled monitoring manifests use the Jaeger query API on port 16686.
+  # tracing:
+  #   defaultProvider: jaeger-main
+  #   providers:
+  #     - name: jaeger-main
+  #       type: jaeger
+  #       endpoint: http://jaeger.monitoring.svc:16686
+  #       bearerToken: ""
+  #       tenant: ""
 
 # [Optional] config for console.
 console:
diff --git a/pkg/config/observability/config.go 
b/pkg/config/observability/config.go
index 920e8a31..b5047064 100644
--- a/pkg/config/observability/config.go
+++ b/pkg/config/observability/config.go
@@ -35,6 +35,8 @@ type Config struct {
        Prometheus string `json:"prometheus" yaml:"prometheus"`
        // Logs configures the log query provider.
        Logs *LogsConfig `json:"logs,omitempty" yaml:"logs,omitempty"`
+       // Tracing configures trace query providers used by diagnostic tools.
+       Tracing *TracingConfig `json:"tracing,omitempty" 
yaml:"tracing,omitempty"`
 
        GrafanaBaseURL    *url.URL `json:"-" yaml:"-"`
        PrometheusBaseURL *url.URL `json:"-" yaml:"-"`
@@ -62,9 +64,21 @@ func (c *Config) Validate() error {
                        return err
                }
        }
+       if c.Tracing != nil {
+               if err := c.Tracing.Validate(); err != nil {
+                       return err
+               }
+       }
        return nil
 }
 
+func (c *Config) Sanitize() {
+       if c == nil || c.Tracing == nil {
+               return
+       }
+       c.Tracing.Sanitize()
+}
+
 func DefaultObservabilityConfig() *Config {
        return &Config{}
 }
diff --git a/pkg/config/observability/tracing.go 
b/pkg/config/observability/tracing.go
new file mode 100644
index 00000000..45215429
--- /dev/null
+++ b/pkg/config/observability/tracing.go
@@ -0,0 +1,133 @@
+/*
+ * 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"
+       "github.com/apache/dubbo-admin/pkg/config"
+)
+
+// TraceProviderType identifies a supported trace query backend.
+type TraceProviderType string
+
+const (
+       // TraceProviderJaeger queries traces through the Jaeger query API.
+       TraceProviderJaeger TraceProviderType = "jaeger"
+)
+
+// TracingConfig selects the default trace provider and contains provider 
definitions.
+type TracingConfig struct {
+       DefaultProvider string                `json:"defaultProvider" 
yaml:"defaultProvider"`
+       Providers       []TraceProviderConfig `json:"providers" 
yaml:"providers"`
+}
+
+// TraceProviderConfig contains connection and optional authentication 
settings for one backend.
+type TraceProviderConfig struct {
+       Name        string            `json:"name" yaml:"name"`
+       Type        TraceProviderType `json:"type" yaml:"type"`
+       Endpoint    string            `json:"endpoint" yaml:"endpoint"`
+       BearerToken string            `json:"bearerToken,omitempty" 
yaml:"bearerToken,omitempty"`
+       Tenant      string            `json:"tenant,omitempty" 
yaml:"tenant,omitempty"`
+}
+
+// Validate verifies provider uniqueness, endpoint safety, and 
default-provider selection.
+func (c *TracingConfig) Validate() error {
+       if c == nil || len(c.Providers) == 0 {
+               return nil
+       }
+       if strutil.IsBlank(c.DefaultProvider) {
+               return bizerror.New(bizerror.ConfigError, "default trace 
provider is required")
+       }
+
+       seen := make(map[string]struct{}, len(c.Providers))
+       foundDefault := false
+       for _, provider := range c.Providers {
+               if strutil.IsBlank(provider.Name) {
+                       return bizerror.New(bizerror.ConfigError, "trace 
provider name is required")
+               }
+               if _, ok := seen[provider.Name]; ok {
+                       return bizerror.New(bizerror.ConfigError, 
fmt.Sprintf("duplicate trace provider name: %s", provider.Name))
+               }
+               seen[provider.Name] = struct{}{}
+               if provider.Name == c.DefaultProvider {
+                       foundDefault = true
+               }
+               if provider.Type != TraceProviderJaeger {
+                       return bizerror.New(bizerror.ConfigError, 
fmt.Sprintf("unsupported trace provider type: %s", provider.Type))
+               }
+               if err := validateTraceEndpoint(provider.Endpoint); err != nil {
+                       return err
+               }
+       }
+       if !foundDefault {
+               return bizerror.New(bizerror.ConfigError, fmt.Sprintf("default 
trace provider %q is not configured", c.DefaultProvider))
+       }
+       return nil
+}
+
+func validateTraceEndpoint(endpoint string) error {
+       if strutil.IsBlank(endpoint) {
+               return bizerror.New(bizerror.ConfigError, "trace provider 
endpoint is required")
+       }
+       parsed, err := url.Parse(endpoint)
+       if err != nil {
+               return bizerror.Wrap(err, bizerror.ConfigError, 
fmt.Sprintf("invalid trace provider endpoint: %s", endpoint))
+       }
+       if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host 
== "" {
+               return bizerror.New(bizerror.ConfigError, fmt.Sprintf("invalid 
trace provider endpoint: %s", endpoint))
+       }
+       return nil
+}
+
+// Default returns the configured default trace provider.
+func (c *TracingConfig) Default() (TraceProviderConfig, bool) {
+       if c == nil {
+               return TraceProviderConfig{}, false
+       }
+       return c.Get(c.DefaultProvider)
+}
+
+// Get returns a named trace provider.
+func (c *TracingConfig) Get(name string) (TraceProviderConfig, bool) {
+       if c == nil {
+               return TraceProviderConfig{}, false
+       }
+       for _, provider := range c.Providers {
+               if provider.Name == name {
+                       return provider, true
+               }
+       }
+       return TraceProviderConfig{}, false
+}
+
+// Sanitize masks provider credentials before configuration is displayed.
+func (c *TracingConfig) Sanitize() {
+       if c == nil {
+               return
+       }
+       for i := range c.Providers {
+               if c.Providers[i].BearerToken != "" {
+                       c.Providers[i].BearerToken = config.SanitizedValue
+               }
+       }
+}
diff --git a/pkg/config/observability/tracing_test.go 
b/pkg/config/observability/tracing_test.go
new file mode 100644
index 00000000..43eccb9e
--- /dev/null
+++ b/pkg/config/observability/tracing_test.go
@@ -0,0 +1,79 @@
+/*
+ * 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 (
+       "testing"
+
+       "github.com/apache/dubbo-admin/pkg/config"
+)
+
+func TestTracingConfigValidate(t *testing.T) {
+       cfg := &TracingConfig{
+               DefaultProvider: "jaeger-main",
+               Providers: []TraceProviderConfig{{
+                       Name: "jaeger-main", Type: TraceProviderJaeger, 
Endpoint: "http://jaeger:16686";,
+               }},
+       }
+       if err := cfg.Validate(); err != nil {
+               t.Fatalf("expected valid tracing config, got %v", err)
+       }
+
+       cfg.Providers[0].Endpoint = "jaeger:16686"
+       if err := cfg.Validate(); err == nil {
+               t.Fatal("expected endpoint without scheme to be rejected")
+       }
+}
+
+func TestTracingConfigRejectsDuplicateAndMissingDefault(t *testing.T) {
+       cfg := &TracingConfig{
+               DefaultProvider: "missing",
+               Providers: []TraceProviderConfig{
+                       {Name: "same", Type: TraceProviderJaeger, Endpoint: 
"http://jaeger:16686"},
+                       {Name: "same", Type: TraceProviderJaeger, Endpoint: 
"http://jaeger-2:16686"},
+               },
+       }
+       if err := cfg.Validate(); err == nil {
+               t.Fatal("expected duplicate provider names to be rejected")
+       }
+
+       cfg.Providers = cfg.Providers[:1]
+       if err := cfg.Validate(); err == nil {
+               t.Fatal("expected missing default provider to be rejected")
+       }
+}
+
+func TestObservabilitySanitizeTraceToken(t *testing.T) {
+       cfg := &Config{Tracing: &TracingConfig{Providers: 
[]TraceProviderConfig{{BearerToken: "secret"}}}}
+       cfg.Sanitize()
+       if got := cfg.Tracing.Providers[0].BearerToken; got != 
config.SanitizedValue {
+               t.Fatalf("expected sanitized token, got %q", got)
+       }
+}
+
+func TestTracingConfigRejectsUnsupportedProvider(t *testing.T) {
+       cfg := &TracingConfig{
+               DefaultProvider: "tempo-main",
+               Providers: []TraceProviderConfig{{
+                       Name: "tempo-main", Type: TraceProviderType("tempo"), 
Endpoint: "http://tempo:3200";,
+               }},
+       }
+       if err := cfg.Validate(); err == nil {
+               t.Fatal("expected unsupported provider to be rejected")
+       }
+}
diff --git a/pkg/mcp/DEVELOPMENT.md b/pkg/mcp/DEVELOPMENT.md
index afe77ecd..79f4121d 100644
--- a/pkg/mcp/DEVELOPMENT.md
+++ b/pkg/mcp/DEVELOPMENT.md
@@ -407,3 +407,32 @@ const (
 - 使用 `common.JsonResult` 返回结果
 - 使用 `common.ErrorResult` 处理错误
 - 使用 `common.DefaultPageSize` 和 `common.DefaultPageNumber` 常量
+
+## 可观测性诊断工具
+
+Dubbo Admin MCP 提供以下只读诊断工具:
+
+| 工具 | 数据源 | 用途 |
+|------|--------|------|
+| `query_prometheus` | Prometheus | 执行即时 PromQL 查询 |
+| `query_prometheus_range` | Prometheus | 执行最长 24 小时的区间 PromQL 查询 |
+| `get_trace_by_id` | Jaeger | 按 TraceID 查询规范化调用链 |
+| `get_observability_capabilities` | 本地配置 | 查询数据源和查询限制 |
+
+Prometheus 工具复用 `observability.prometheus`。区间查询的 `step` 不得低于 15 秒,返回结果最多包含 100 
个时间序列和 5000 个采样点。结果被裁剪时,响应中的 `truncated` 为 `true`。
+
+Jaeger 查询需要配置 trace provider:
+
+```yaml
+observability:
+  tracing:
+    defaultProvider: jaeger-main
+    providers:
+      - name: jaeger-main
+        type: jaeger
+        endpoint: http://jaeger.monitoring.svc:16686
+        bearerToken: "" # 可选
+        tenant: ""      # 可选,通过 X-Scope-OrgID 发送
+```
+
+诊断工具不会记录完整 PromQL、Bearer Token 或完整 trace attributes。工具只读,不提供流量、配置或部署变更能力。
diff --git a/pkg/mcp/register.go b/pkg/mcp/register.go
index 92ccbab3..2d8856a5 100644
--- a/pkg/mcp/register.go
+++ b/pkg/mcp/register.go
@@ -21,6 +21,8 @@ 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"
+       metrictools "github.com/apache/dubbo-admin/pkg/mcp/tools/metrics"
+       tracetools "github.com/apache/dubbo-admin/pkg/mcp/tools/trace"
 )
 
 // RegisterTools 注册所有 MCP 工具
@@ -370,4 +372,44 @@ func RegisterTools(server *Server) {
                },
                Handler: logtools.GetLogCapabilities,
        })
+
+       server.RegisterTool(&common.ToolDef{
+               Name:        "query_prometheus",
+               Description: "Execute an instant PromQL query and return 
normalized metric series",
+               InputSchema: common.InputSchema{
+                       Type:       "object",
+                       Properties: metrictools.InstantProperties(),
+                       Required:   []string{"query"},
+               },
+               Handler: metrictools.QueryPrometheus,
+       })
+
+       server.RegisterTool(&common.ToolDef{
+               Name:        "query_prometheus_range",
+               Description: "Execute a bounded PromQL range query for trend 
and incident-window analysis",
+               InputSchema: common.InputSchema{
+                       Type:       "object",
+                       Properties: metrictools.RangeProperties(),
+                       Required:   []string{"query", "startTime", "endTime", 
"step"},
+               },
+               Handler: metrictools.QueryPrometheusRange,
+       })
+
+       server.RegisterTool(&common.ToolDef{
+               Name:        "get_trace_by_id",
+               Description: "Query a distributed trace by TraceID and return 
services, span relationships, latency, and error status",
+               InputSchema: common.InputSchema{
+                       Type:       "object",
+                       Properties: tracetools.Properties(),
+                       Required:   []string{"traceId"},
+               },
+               Handler: tracetools.GetTraceByID,
+       })
+
+       server.RegisterTool(&common.ToolDef{
+               Name:        "get_observability_capabilities",
+               Description: "Report Prometheus and trace providers, 
capabilities, and enforced query limits",
+               InputSchema: common.InputSchema{Type: "object"},
+               Handler:     tools.GetObservabilityCapabilities,
+       })
 }
diff --git a/pkg/mcp/register_test.go b/pkg/mcp/register_test.go
index c9806f73..5d423b65 100644
--- a/pkg/mcp/register_test.go
+++ b/pkg/mcp/register_test.go
@@ -55,3 +55,40 @@ func TestRegisterServiceDetailTools(t *testing.T) {
                }
        }
 }
+
+func TestRegisterObservabilityTools(t *testing.T) {
+       server := NewServer("test", "dev")
+       RegisterTools(server)
+
+       tests := []struct {
+               name     string
+               required []string
+       }{
+               {name: "query_prometheus", required: []string{"query"}},
+               {name: "query_prometheus_range", required: []string{"query", 
"startTime", "endTime", "step"}},
+               {name: "get_trace_by_id", required: []string{"traceId"}},
+               {name: "get_observability_capabilities"},
+       }
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       tool, ok := server.tools[test.name]
+                       if !ok {
+                               t.Fatalf("tool %q is not registered", test.name)
+                       }
+                       if tool.Handler == nil {
+                               t.Fatalf("tool %q has no handler", test.name)
+                       }
+                       if len(tool.InputSchema.Required) != len(test.required) 
{
+                               t.Fatalf("tool %q required fields = %v, want 
%v", test.name, tool.InputSchema.Required, test.required)
+                       }
+                       for i, field := range test.required {
+                               if tool.InputSchema.Required[i] != field {
+                                       t.Fatalf("tool %q required fields = %v, 
want %v", test.name, tool.InputSchema.Required, test.required)
+                               }
+                               if _, ok := tool.InputSchema.Properties[field]; 
!ok {
+                                       t.Fatalf("tool %q is missing schema 
property %q", test.name, field)
+                               }
+                       }
+               })
+       }
+}
diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go
index e1719afb..ba5b588c 100644
--- a/pkg/mcp/server.go
+++ b/pkg/mcp/server.go
@@ -24,8 +24,10 @@ import (
        "io"
        "net/http"
        "sync"
+       "time"
 
        consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+       "github.com/apache/dubbo-admin/pkg/core/logger"
        "github.com/gin-gonic/gin"
 
        "github.com/apache/dubbo-admin/pkg/mcp/common"
@@ -43,9 +45,9 @@ type Server struct {
 // NewServer 创建 MCP 服务器
 func NewServer(name, version string) *Server {
        return &Server{
-               name:  name,
+               name:    name,
                version: version,
-               tools: make(map[string]*common.ToolDef),
+               tools:   make(map[string]*common.ToolDef),
        }
 }
 
@@ -217,8 +219,10 @@ func (s *Server) handleToolsCall(req 
*common.JSONRPCRequest) *common.JSONRPCResp
                return s.newErrorResponse(req.ID, common.ErrCodeInvalidParams, 
err.Error())
        }
 
+       startedAt := time.Now()
        result, err := tool.Handler(ctx, arguments)
        if err != nil {
+               logger.Sugar().Warnw("MCP tool call failed", "tool", name, 
"request_id", req.ID, "elapsed_ms", time.Since(startedAt).Milliseconds(), 
"error", err)
                return &common.JSONRPCResponse{
                        JSONRPC: common.JSONRPCVersion,
                        ID:      req.ID,
@@ -228,6 +232,15 @@ func (s *Server) handleToolsCall(req 
*common.JSONRPCRequest) *common.JSONRPCResp
                        },
                }
        }
+       if result == nil {
+               logger.Sugar().Errorw("MCP tool returned nil result", "tool", 
name, "request_id", req.ID, "elapsed_ms", time.Since(startedAt).Milliseconds())
+               return s.newErrorResponse(req.ID, common.ErrCodeInternalError, 
"Tool returned no result")
+       }
+       if result.IsError {
+               logger.Sugar().Warnw("MCP tool call completed with error", 
"tool", name, "request_id", req.ID, "elapsed_ms", 
time.Since(startedAt).Milliseconds(), "content_blocks", len(result.Content))
+       } else {
+               logger.Sugar().Infow("MCP tool call completed", "tool", name, 
"request_id", req.ID, "elapsed_ms", time.Since(startedAt).Milliseconds(), 
"content_blocks", len(result.Content))
+       }
 
        return &common.JSONRPCResponse{
                JSONRPC: common.JSONRPCVersion,
diff --git a/pkg/mcp/tools/metrics/model.go b/pkg/mcp/tools/metrics/model.go
new file mode 100644
index 00000000..d2a33553
--- /dev/null
+++ b/pkg/mcp/tools/metrics/model.go
@@ -0,0 +1,54 @@
+/*
+ * 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 metrics
+
+// Sample is one Prometheus timestamp-value pair. Value remains a string to 
preserve NaN and infinities.
+type Sample struct {
+       Timestamp float64 `json:"timestamp"`
+       Value     string  `json:"value"`
+}
+
+// Series contains labels and either an instant value or range values.
+type Series struct {
+       Labels map[string]string `json:"labels"`
+       Value  *Sample           `json:"value,omitempty"`
+       Values []Sample          `json:"values,omitempty"`
+}
+
+// QueryResponse is the stable, bounded MCP representation of a Prometheus 
query result.
+type QueryResponse struct {
+       QueryType       string   `json:"queryType"`
+       ResultType      string   `json:"resultType"`
+       Series          []Series `json:"series,omitempty"`
+       Value           *Sample  `json:"value,omitempty"`
+       Warnings        []string `json:"warnings,omitempty"`
+       Truncated       bool     `json:"truncated"`
+       ReturnedSeries  int      `json:"returnedSeries"`
+       ReturnedSamples int      `json:"returnedSamples"`
+}
+
+// QueryLimits describes server-enforced PromQL resource limits.
+type QueryLimits struct {
+       TimeoutSeconds   int    `json:"timeoutSeconds"`
+       MaxQueryBytes    int    `json:"maxQueryBytes"`
+       MaxRange         string `json:"maxRange"`
+       MinStep          string `json:"minStep"`
+       MaxSeries        int    `json:"maxSeries"`
+       MaxSamples       int    `json:"maxSamples"`
+       MaxResponseBytes int64  `json:"maxResponseBytes"`
+}
diff --git a/pkg/mcp/tools/metrics/prometheus.go 
b/pkg/mcp/tools/metrics/prometheus.go
new file mode 100644
index 00000000..294b6f9c
--- /dev/null
+++ b/pkg/mcp/tools/metrics/prometheus.go
@@ -0,0 +1,329 @@
+/*
+ * 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 metrics
+
+import (
+       "context"
+       "crypto/sha256"
+       "encoding/hex"
+       "encoding/json"
+       "fmt"
+       "io"
+       "math"
+       "net/http"
+       "net/url"
+       "strconv"
+       "strings"
+       "time"
+
+       "github.com/apache/dubbo-admin/pkg/core/logger"
+)
+
+const (
+       queryTimeout      = 15 * time.Second
+       maxQueryBytes     = 8 * 1024
+       maxQueryRange     = 24 * time.Hour
+       minQueryStep      = 15 * time.Second
+       maxSeries         = 100
+       maxSamples        = 5000
+       maxResponseBytes  = int64(4 * 1024 * 1024)
+       maxErrorBodyBytes = int64(4096)
+)
+
+type prometheusClient struct {
+       baseURL *url.URL
+       client  *http.Client
+}
+
+var prometheusTransport = &http.Transport{
+       Proxy:                 http.ProxyFromEnvironment,
+       MaxIdleConns:          20,
+       MaxIdleConnsPerHost:   10,
+       IdleConnTimeout:       90 * time.Second,
+       ResponseHeaderTimeout: queryTimeout,
+}
+
+type prometheusResponse struct {
+       Status string `json:"status"`
+       Data   struct {
+               ResultType string          `json:"resultType"`
+               Result     json.RawMessage `json:"result"`
+       } `json:"data"`
+       ErrorType string   `json:"errorType,omitempty"`
+       Error     string   `json:"error,omitempty"`
+       Warnings  []string `json:"warnings,omitempty"`
+}
+
+type prometheusSeries struct {
+       Metric map[string]string   `json:"metric"`
+       Value  []json.RawMessage   `json:"value,omitempty"`
+       Values [][]json.RawMessage `json:"values,omitempty"`
+}
+
+func newPrometheusClient(baseURL *url.URL) *prometheusClient {
+       return &prometheusClient{
+               baseURL: baseURL,
+               client: &http.Client{
+                       Timeout:   queryTimeout,
+                       Transport: prometheusTransport,
+               },
+       }
+}
+
+func (c *prometheusClient) instant(ctx context.Context, query, evaluationTime 
string) (*QueryResponse, error) {
+       values := url.Values{"query": []string{query}}
+       if evaluationTime != "" {
+               if _, err := parsePrometheusTime("time", evaluationTime); err 
!= nil {
+                       return nil, err
+               }
+               values.Set("time", evaluationTime)
+       }
+       return c.query(ctx, "instant", "/api/v1/query", values)
+}
+
+func (c *prometheusClient) rangeQuery(ctx context.Context, query, startRaw, 
endRaw, stepRaw string) (*QueryResponse, error) {
+       start, err := parsePrometheusTime("startTime", startRaw)
+       if err != nil {
+               return nil, err
+       }
+       end, err := parsePrometheusTime("endTime", endRaw)
+       if err != nil {
+               return nil, err
+       }
+       if !end.After(start) {
+               return nil, fmt.Errorf("endTime must be after startTime")
+       }
+       if end.Sub(start) > maxQueryRange {
+               return nil, fmt.Errorf("query range must not exceed %s", 
maxQueryRange)
+       }
+       step, err := parsePrometheusStep(stepRaw)
+       if err != nil {
+               return nil, err
+       }
+       if step < minQueryStep {
+               return nil, fmt.Errorf("step must be at least %s", minQueryStep)
+       }
+
+       values := url.Values{
+               "query": []string{query},
+               "start": []string{startRaw},
+               "end":   []string{endRaw},
+               "step":  []string{stepRaw},
+       }
+       return c.query(ctx, "range", "/api/v1/query_range", values)
+}
+
+func (c *prometheusClient) query(ctx context.Context, queryType, path string, 
values url.Values) (*QueryResponse, error) {
+       query := values.Get("query")
+       if strings.TrimSpace(query) == "" {
+               return nil, fmt.Errorf("query is required")
+       }
+       if len(query) > maxQueryBytes {
+               return nil, fmt.Errorf("query must not exceed %d bytes", 
maxQueryBytes)
+       }
+
+       endpoint := *c.baseURL
+       endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path
+       startedAt := time.Now()
+       req, err := http.NewRequestWithContext(ctx, http.MethodPost, 
endpoint.String(), strings.NewReader(values.Encode()))
+       if err != nil {
+               return nil, fmt.Errorf("failed to create prometheus request: 
%w", err)
+       }
+       req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       req.Header.Set("Accept", "application/json")
+
+       resp, err := c.client.Do(req)
+       if err != nil {
+               logPrometheusFailure(queryType, query, startedAt, 0, 
"transport", err)
+               return nil, fmt.Errorf("prometheus query failed: %w", err)
+       }
+       defer resp.Body.Close()
+       if resp.StatusCode < http.StatusOK || resp.StatusCode >= 
http.StatusMultipleChoices {
+               body, _ := io.ReadAll(io.LimitReader(resp.Body, 
maxErrorBodyBytes))
+               logPrometheusFailure(queryType, query, startedAt, 
resp.StatusCode, "upstream_status", nil)
+               return nil, fmt.Errorf("prometheus query failed with status %d: 
%s", resp.StatusCode, strings.TrimSpace(string(body)))
+       }
+
+       body, err := readBoundedBody(resp.Body, maxResponseBytes)
+       if err != nil {
+               logPrometheusFailure(queryType, query, startedAt, 
resp.StatusCode, "response_limit", err)
+               return nil, fmt.Errorf("failed to read prometheus response: 
%w", err)
+       }
+       var upstream prometheusResponse
+       if err := json.Unmarshal(body, &upstream); err != nil {
+               logPrometheusFailure(queryType, query, startedAt, 
resp.StatusCode, "decode", err)
+               return nil, fmt.Errorf("failed to decode prometheus response: 
%w", err)
+       }
+       if upstream.Status != "success" {
+               message := upstream.Error
+               if message == "" {
+                       message = "unknown prometheus error"
+               }
+               errorType := upstream.ErrorType
+               if errorType == "" {
+                       errorType = "unknown"
+               }
+               logPrometheusFailure(queryType, query, startedAt, 
resp.StatusCode, errorType, nil)
+               return nil, fmt.Errorf("prometheus %s error: %s", errorType, 
message)
+       }
+       result, err := normalizePrometheusResult(queryType, &upstream)
+       if err != nil {
+               logPrometheusFailure(queryType, query, startedAt, 
resp.StatusCode, "normalize", err)
+               return nil, err
+       }
+       logger.Sugar().Infow("prometheus query completed", "query_type", 
queryType, "query_hash", queryHash(query), "elapsed_ms", 
time.Since(startedAt).Milliseconds(), "result_type", result.ResultType, 
"series", result.ReturnedSeries, "samples", result.ReturnedSamples, 
"truncated", result.Truncated)
+       return result, nil
+}
+
+func normalizePrometheusResult(queryType string, upstream *prometheusResponse) 
(*QueryResponse, error) {
+       result := &QueryResponse{
+               QueryType:  queryType,
+               ResultType: upstream.Data.ResultType,
+               Warnings:   upstream.Warnings,
+               Series:     []Series{},
+       }
+       switch upstream.Data.ResultType {
+       case "vector", "matrix":
+               var rawSeries []prometheusSeries
+               if err := json.Unmarshal(upstream.Data.Result, &rawSeries); err 
!= nil {
+                       return nil, fmt.Errorf("failed to decode prometheus %s 
result: %w", upstream.Data.ResultType, err)
+               }
+               for i, raw := range rawSeries {
+                       if i >= maxSeries || result.ReturnedSamples >= 
maxSamples {
+                               result.Truncated = true
+                               break
+                       }
+                       series := Series{Labels: raw.Metric}
+                       if upstream.Data.ResultType == "vector" {
+                               sample, err := decodeSample(raw.Value)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               series.Value = &sample
+                               result.ReturnedSamples++
+                       } else {
+                               for _, rawSample := range raw.Values {
+                                       if result.ReturnedSamples >= maxSamples 
{
+                                               result.Truncated = true
+                                               break
+                                       }
+                                       sample, err := decodeSample(rawSample)
+                                       if err != nil {
+                                               return nil, err
+                                       }
+                                       series.Values = append(series.Values, 
sample)
+                                       result.ReturnedSamples++
+                               }
+                       }
+                       result.Series = append(result.Series, series)
+                       result.ReturnedSeries++
+               }
+       case "scalar", "string":
+               var raw []json.RawMessage
+               if err := json.Unmarshal(upstream.Data.Result, &raw); err != 
nil {
+                       return nil, fmt.Errorf("failed to decode prometheus %s 
result: %w", upstream.Data.ResultType, err)
+               }
+               sample, err := decodeSample(raw)
+               if err != nil {
+                       return nil, err
+               }
+               result.Value = &sample
+               result.ReturnedSamples = 1
+       default:
+               return nil, fmt.Errorf("unsupported prometheus result type %q", 
upstream.Data.ResultType)
+       }
+       return result, nil
+}
+
+func decodeSample(raw []json.RawMessage) (Sample, error) {
+       if len(raw) != 2 {
+               return Sample{}, fmt.Errorf("invalid prometheus sample: 
expected timestamp and value")
+       }
+       var timestamp float64
+       if err := json.Unmarshal(raw[0], &timestamp); err != nil {
+               return Sample{}, fmt.Errorf("invalid prometheus sample 
timestamp: %w", err)
+       }
+       var value string
+       if err := json.Unmarshal(raw[1], &value); err != nil {
+               return Sample{}, fmt.Errorf("invalid prometheus sample value: 
%w", err)
+       }
+       return Sample{Timestamp: timestamp, Value: value}, nil
+}
+
+func parsePrometheusTime(field, value string) (time.Time, error) {
+       if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
+               return parsed, nil
+       }
+       seconds, err := strconv.ParseFloat(value, 64)
+       if err != nil || math.IsNaN(seconds) || math.IsInf(seconds, 0) || 
seconds > float64(math.MaxInt64) || seconds < float64(math.MinInt64) {
+               return time.Time{}, fmt.Errorf("%s must be RFC3339 or Unix 
seconds", field)
+       }
+       whole, fraction := math.Modf(seconds)
+       return time.Unix(int64(whole), int64(fraction*float64(time.Second))), 
nil
+}
+
+func parsePrometheusStep(value string) (time.Duration, error) {
+       if parsed, err := time.ParseDuration(value); err == nil {
+               return parsed, nil
+       }
+       seconds, err := strconv.ParseFloat(value, 64)
+       if err != nil || seconds <= 0 {
+               return 0, fmt.Errorf("step must be a positive duration or 
number of seconds")
+       }
+       return time.Duration(seconds * float64(time.Second)), nil
+}
+
+func readBoundedBody(reader io.Reader, limit int64) ([]byte, error) {
+       body, err := io.ReadAll(io.LimitReader(reader, limit+1))
+       if err != nil {
+               return nil, err
+       }
+       if int64(len(body)) > limit {
+               return nil, fmt.Errorf("response exceeds %d bytes", limit)
+       }
+       return body, nil
+}
+
+func queryHash(query string) string {
+       digest := sha256.Sum256([]byte(query))
+       return hex.EncodeToString(digest[:6])
+}
+
+func logPrometheusFailure(queryType, query string, startedAt time.Time, status 
int, errorType string, err error) {
+       fields := []any{"query_type", queryType, "query_hash", 
queryHash(query), "elapsed_ms", time.Since(startedAt).Milliseconds(), 
"error_type", errorType}
+       if status != 0 {
+               fields = append(fields, "upstream_status", status)
+       }
+       if err != nil {
+               fields = append(fields, "error", err)
+       }
+       logger.Sugar().Warnw("prometheus query failed", fields...)
+}
+
+// Limits returns the resource limits enforced by the Prometheus MCP tools.
+func Limits() QueryLimits {
+       return QueryLimits{
+               TimeoutSeconds:   int(queryTimeout / time.Second),
+               MaxQueryBytes:    maxQueryBytes,
+               MaxRange:         maxQueryRange.String(),
+               MinStep:          minQueryStep.String(),
+               MaxSeries:        maxSeries,
+               MaxSamples:       maxSamples,
+               MaxResponseBytes: maxResponseBytes,
+       }
+}
diff --git a/pkg/mcp/tools/metrics/prometheus_test.go 
b/pkg/mcp/tools/metrics/prometheus_test.go
new file mode 100644
index 00000000..73b973bb
--- /dev/null
+++ b/pkg/mcp/tools/metrics/prometheus_test.go
@@ -0,0 +1,106 @@
+/*
+ * 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 metrics
+
+import (
+       "context"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+       "strings"
+       "testing"
+)
+
+func TestPrometheusInstantQueryNormalizesVector(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               if r.Method != http.MethodPost || r.URL.Path != "/api/v1/query" 
{
+                       t.Fatalf("unexpected request %s %s", r.Method, 
r.URL.Path)
+               }
+               if err := r.ParseForm(); err != nil {
+                       t.Fatal(err)
+               }
+               if r.Form.Get("query") != "up" {
+                       t.Fatalf("unexpected query %q", r.Form.Get("query"))
+               }
+               w.Header().Set("Content-Type", "application/json")
+               _, _ = 
w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{"job":"dubbo"},"value":[1710000000.5,"NaN"]}]},"warnings":["partial
 data"]}`))
+       }))
+       defer server.Close()
+
+       baseURL, _ := url.Parse(server.URL)
+       result, err := 
newPrometheusClient(baseURL).instant(context.Background(), "up", "")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if result.ResultType != "vector" || result.ReturnedSeries != 1 || 
result.ReturnedSamples != 1 {
+               t.Fatalf("unexpected result: %#v", result)
+       }
+       if result.Series[0].Value.Value != "NaN" || 
result.Series[0].Labels["job"] != "dubbo" {
+               t.Fatalf("unexpected normalized series: %#v", result.Series[0])
+       }
+}
+
+func TestPrometheusRangeValidation(t *testing.T) {
+       baseURL, _ := url.Parse("http://prometheus:9090";)
+       client := newPrometheusClient(baseURL)
+       _, err := client.rangeQuery(context.Background(), "rate(x[5m])", 
"2026-01-01T00:00:00Z", "2026-01-02T00:00:01Z", "15s")
+       if err == nil || !strings.Contains(err.Error(), "must not exceed") {
+               t.Fatalf("expected range limit error, got %v", err)
+       }
+       _, err = client.rangeQuery(context.Background(), "rate(x[5m])", 
"2026-01-01T00:00:00Z", "2026-01-01T01:00:00Z", "5s")
+       if err == nil || !strings.Contains(err.Error(), "at least") {
+               t.Fatalf("expected step limit error, got %v", err)
+       }
+}
+
+func TestNormalizePrometheusMatrixTruncatesSamples(t *testing.T) {
+       values := make([]string, 0, maxSamples+1)
+       for i := 0; i < maxSamples+1; i++ {
+               values = append(values, `[1710000000,"1"]`)
+       }
+       upstream := &prometheusResponse{Status: "success"}
+       upstream.Data.ResultType = "matrix"
+       upstream.Data.Result = []byte(`[{"metric":{"job":"dubbo"},"values":[` + 
strings.Join(values, ",") + `]}]`)
+       result, err := normalizePrometheusResult("range", upstream)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if !result.Truncated || result.ReturnedSamples != maxSamples {
+               t.Fatalf("expected sample truncation, got %#v", result)
+       }
+}
+
+func TestPrometheusReturnsUpstreamError(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               w.WriteHeader(http.StatusUnprocessableEntity)
+               _, _ = w.Write([]byte("bad query"))
+       }))
+       defer server.Close()
+       baseURL, _ := url.Parse(server.URL)
+       _, err := newPrometheusClient(baseURL).instant(context.Background(), 
"bad(", "")
+       if err == nil || !strings.Contains(err.Error(), "status 422") {
+               t.Fatalf("expected upstream status error, got %v", err)
+       }
+}
+
+func TestReadBoundedBodyRejectsOversizedResponse(t *testing.T) {
+       _, err := readBoundedBody(strings.NewReader("12345"), 4)
+       if err == nil || !strings.Contains(err.Error(), "exceeds") {
+               t.Fatalf("expected response limit error, got %v", err)
+       }
+}
diff --git a/pkg/mcp/tools/metrics/tools.go b/pkg/mcp/tools/metrics/tools.go
new file mode 100644
index 00000000..8af6ba95
--- /dev/null
+++ b/pkg/mcp/tools/metrics/tools.go
@@ -0,0 +1,89 @@
+/*
+ * 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 metrics
+
+import (
+       "context"
+       "fmt"
+
+       consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+       "github.com/apache/dubbo-admin/pkg/mcp/common"
+)
+
+// QueryPrometheus executes an instant PromQL query against the configured 
Prometheus endpoint.
+func QueryPrometheus(ctx consolectx.Context, args map[string]any) 
(*common.ToolResult, error) {
+       client, requestCtx, err := clientFromContext(ctx)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       helper := common.NewArgsHelper(args)
+       resp, err := client.instant(requestCtx, helper.GetString("query", ""), 
helper.GetString("time", ""))
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       return common.JsonResult(resp)
+}
+
+// QueryPrometheusRange executes a bounded PromQL range query.
+func QueryPrometheusRange(ctx consolectx.Context, args map[string]any) 
(*common.ToolResult, error) {
+       client, requestCtx, err := clientFromContext(ctx)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       helper := common.NewArgsHelper(args)
+       resp, err := client.rangeQuery(
+               requestCtx,
+               helper.GetString("query", ""),
+               helper.GetString("startTime", ""),
+               helper.GetString("endTime", ""),
+               helper.GetString("step", ""),
+       )
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       return common.JsonResult(resp)
+}
+
+func clientFromContext(ctx consolectx.Context) (*prometheusClient, 
context.Context, error) {
+       if ctx == nil || ctx.Config().Observability == nil || 
ctx.Config().Observability.PrometheusBaseURL == nil {
+               return nil, nil, fmt.Errorf("prometheus is not configured")
+       }
+       requestCtx := context.Background()
+       if ctx.AppContext() != nil {
+               requestCtx = ctx.AppContext()
+       }
+       return 
newPrometheusClient(ctx.Config().Observability.PrometheusBaseURL), requestCtx, 
nil
+}
+
+// InstantProperties returns the MCP input schema properties for instant 
queries.
+func InstantProperties() map[string]common.PropertyDef {
+       return map[string]common.PropertyDef{
+               "query": {Type: "string", Description: "PromQL expression"},
+               "time":  {Type: "string", Description: "Optional evaluation 
time in RFC3339 or Unix seconds"},
+       }
+}
+
+// RangeProperties returns the MCP input schema properties for range queries.
+func RangeProperties() map[string]common.PropertyDef {
+       return map[string]common.PropertyDef{
+               "query":     {Type: "string", Description: "PromQL expression"},
+               "startTime": {Type: "string", Description: "Start time in 
RFC3339 or Unix seconds"},
+               "endTime":   {Type: "string", Description: "End time in RFC3339 
or Unix seconds"},
+               "step":      {Type: "string", Description: "Query step, for 
example 30s or 60"},
+       }
+}
diff --git a/pkg/mcp/tools/observability.go b/pkg/mcp/tools/observability.go
new file mode 100644
index 00000000..2914a08c
--- /dev/null
+++ b/pkg/mcp/tools/observability.go
@@ -0,0 +1,56 @@
+/*
+ * 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 tools
+
+import (
+       "sort"
+
+       consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+       "github.com/apache/dubbo-admin/pkg/mcp/common"
+       metrictools "github.com/apache/dubbo-admin/pkg/mcp/tools/metrics"
+)
+
+type observabilityCapabilities struct {
+       PrometheusConfigured bool                    
`json:"prometheusConfigured"`
+       TraceConfigured      bool                    `json:"traceConfigured"`
+       TraceProviders       []traceCapability       `json:"traceProviders"`
+       Limits               metrictools.QueryLimits `json:"limits"`
+}
+
+type traceCapability struct {
+       Name string `json:"name"`
+       Type string `json:"type"`
+}
+
+// GetObservabilityCapabilities reports configured providers and enforced 
PromQL limits without secrets.
+func GetObservabilityCapabilities(ctx consolectx.Context, _ map[string]any) 
(*common.ToolResult, error) {
+       resp := observabilityCapabilities{Limits: metrictools.Limits()}
+       if ctx == nil || ctx.Config().Observability == nil {
+               return common.JsonResult(resp)
+       }
+       cfg := ctx.Config().Observability
+       resp.PrometheusConfigured = cfg.PrometheusBaseURL != nil
+       resp.TraceConfigured = cfg.Tracing != nil && len(cfg.Tracing.Providers) 
> 0
+       if cfg.Tracing != nil {
+               for _, provider := range cfg.Tracing.Providers {
+                       resp.TraceProviders = append(resp.TraceProviders, 
traceCapability{Name: provider.Name, Type: string(provider.Type)})
+               }
+               sort.Slice(resp.TraceProviders, func(i, j int) bool { return 
resp.TraceProviders[i].Name < resp.TraceProviders[j].Name })
+       }
+       return common.JsonResult(resp)
+}
diff --git a/pkg/mcp/tools/trace/jaeger.go b/pkg/mcp/tools/trace/jaeger.go
new file mode 100644
index 00000000..354c5ebf
--- /dev/null
+++ b/pkg/mcp/tools/trace/jaeger.go
@@ -0,0 +1,406 @@
+/*
+ * 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 trace
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+       "unicode/utf8"
+
+       observabilitycfg 
"github.com/apache/dubbo-admin/pkg/config/observability"
+       "github.com/apache/dubbo-admin/pkg/core/logger"
+)
+
+const (
+       traceQueryTimeout      = 15 * time.Second
+       maxTraceResponseBytes  = int64(8 * 1024 * 1024)
+       maxTraceErrorBodyBytes = int64(4096)
+       maxTraceSpans          = 500
+       maxTraceRoots          = 50
+       maxTraceServices       = 100
+       maxSpanAttributes      = 32
+       maxSpanEvents          = 10
+       maxEventAttributes     = 16
+       maxAttributeValueBytes = 512
+       tenantHeader           = "X-Scope-OrgID"
+)
+
+type jaegerProvider struct {
+       config  observabilitycfg.TraceProviderConfig
+       baseURL *url.URL
+       client  *http.Client
+}
+
+var jaegerTransport = &http.Transport{
+       Proxy:                 http.ProxyFromEnvironment,
+       MaxIdleConns:          20,
+       MaxIdleConnsPerHost:   10,
+       IdleConnTimeout:       90 * time.Second,
+       ResponseHeaderTimeout: traceQueryTimeout,
+}
+
+type jaegerResponse struct {
+       Data   []jaegerTrace `json:"data"`
+       Errors []struct {
+               Code    int    `json:"code"`
+               Message string `json:"msg"`
+       } `json:"errors,omitempty"`
+}
+
+type jaegerTrace struct {
+       TraceID   string                   `json:"traceID"`
+       Spans     []jaegerSpan             `json:"spans"`
+       Processes map[string]jaegerProcess `json:"processes"`
+}
+
+type jaegerSpan struct {
+       TraceID       string            `json:"traceID"`
+       SpanID        string            `json:"spanID"`
+       OperationName string            `json:"operationName"`
+       References    []jaegerReference `json:"references"`
+       StartTime     int64             `json:"startTime"`
+       Duration      int64             `json:"duration"`
+       Tags          []jaegerKeyValue  `json:"tags"`
+       Logs          []jaegerLog       `json:"logs"`
+       ProcessID     string            `json:"processID"`
+       Process       *jaegerProcess    `json:"process,omitempty"`
+}
+
+type jaegerReference struct {
+       RefType string `json:"refType"`
+       TraceID string `json:"traceID"`
+       SpanID  string `json:"spanID"`
+}
+
+type jaegerProcess struct {
+       ServiceName string           `json:"serviceName"`
+       Tags        []jaegerKeyValue `json:"tags"`
+}
+
+type jaegerLog struct {
+       Timestamp int64            `json:"timestamp"`
+       Fields    []jaegerKeyValue `json:"fields"`
+}
+
+type jaegerKeyValue struct {
+       Key   string `json:"key"`
+       Type  string `json:"type"`
+       Value any    `json:"value"`
+}
+
+func newJaegerProvider(cfg observabilitycfg.TraceProviderConfig) 
(*jaegerProvider, error) {
+       baseURL, err := url.Parse(cfg.Endpoint)
+       if err != nil {
+               return nil, fmt.Errorf("invalid jaeger endpoint: %w", err)
+       }
+       return &jaegerProvider{
+               config:  cfg,
+               baseURL: baseURL,
+               client: &http.Client{
+                       Timeout:   traceQueryTimeout,
+                       Transport: jaegerTransport,
+               },
+       }, nil
+}
+
+func (p *jaegerProvider) GetTraceByID(ctx context.Context, traceID string) 
(*Trace, error) {
+       endpoint := *p.baseURL
+       endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/traces/" 
+ traceID
+       startedAt := time.Now()
+       req, err := http.NewRequestWithContext(ctx, http.MethodGet, 
endpoint.String(), nil)
+       if err != nil {
+               return nil, fmt.Errorf("failed to create jaeger request: %w", 
err)
+       }
+       req.Header.Set("Accept", "application/json")
+       if p.config.BearerToken != "" {
+               req.Header.Set("Authorization", "Bearer "+p.config.BearerToken)
+       }
+       if p.config.Tenant != "" {
+               req.Header.Set(tenantHeader, p.config.Tenant)
+       }
+
+       resp, err := p.client.Do(req)
+       if err != nil {
+               p.logFailure(traceID, startedAt, 0, "transport", err)
+               return nil, fmt.Errorf("jaeger trace query failed: %w", err)
+       }
+       defer resp.Body.Close()
+       if resp.StatusCode == http.StatusNotFound {
+               p.logFailure(traceID, startedAt, resp.StatusCode, "not_found", 
nil)
+               return nil, fmt.Errorf("trace %s was not found", traceID)
+       }
+       if resp.StatusCode < http.StatusOK || resp.StatusCode >= 
http.StatusMultipleChoices {
+               body, _ := io.ReadAll(io.LimitReader(resp.Body, 
maxTraceErrorBodyBytes))
+               p.logFailure(traceID, startedAt, resp.StatusCode, 
"upstream_status", nil)
+               return nil, fmt.Errorf("jaeger trace query failed with status 
%d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+       }
+       body, err := readTraceBody(resp.Body)
+       if err != nil {
+               p.logFailure(traceID, startedAt, resp.StatusCode, 
"response_limit", err)
+               return nil, err
+       }
+       var upstream jaegerResponse
+       if err := json.Unmarshal(body, &upstream); err != nil {
+               p.logFailure(traceID, startedAt, resp.StatusCode, "decode", err)
+               return nil, fmt.Errorf("failed to decode jaeger response: %w", 
err)
+       }
+       if len(upstream.Errors) > 0 {
+               p.logFailure(traceID, startedAt, resp.StatusCode, 
"upstream_error", nil)
+               return nil, fmt.Errorf("jaeger trace query failed: %s", 
upstream.Errors[0].Message)
+       }
+       if len(upstream.Data) == 0 {
+               p.logFailure(traceID, startedAt, resp.StatusCode, "not_found", 
nil)
+               return nil, fmt.Errorf("trace %s was not found", traceID)
+       }
+       if len(upstream.Data) != 1 || 
!strings.EqualFold(upstream.Data[0].TraceID, traceID) {
+               p.logFailure(traceID, startedAt, resp.StatusCode, 
"inconsistent_response", nil)
+               return nil, fmt.Errorf("jaeger returned an inconsistent 
response for trace %s", traceID)
+       }
+       result := normalizeJaegerTrace(upstream.Data[0])
+       logger.Sugar().Infow("trace query completed", "provider", 
p.config.Name, "engine", p.config.Type, "trace_id", maskedTraceID(traceID), 
"elapsed_ms", time.Since(startedAt).Milliseconds(), "spans", result.SpanCount, 
"services", result.ServiceCount, "error_spans", result.ErrorSpanCount, 
"truncated", result.Truncated)
+       return result, nil
+}
+
+func normalizeJaegerTrace(input jaegerTrace) *Trace {
+       result := &Trace{TraceID: input.TraceID, SourceEngine: "jaeger", 
SpanCount: len(input.Spans), Spans: []Span{}}
+       serviceSet := map[string]struct{}{}
+       var minStart, maxEnd int64
+       spans := input.Spans
+       sort.SliceStable(spans, func(i, j int) bool { return spans[i].StartTime 
< spans[j].StartTime })
+       for _, rawSpan := range spans {
+               process := input.Processes[rawSpan.ProcessID]
+               if rawSpan.Process != nil {
+                       process = *rawSpan.Process
+               }
+               if process.ServiceName != "" {
+                       serviceSet[process.ServiceName] = struct{}{}
+               }
+               if errorSpan, _ := spanStatus(rawSpan.Tags); errorSpan {
+                       result.ErrorSpanCount++
+               }
+               if parentSpanID(rawSpan.References) == "" {
+                       if len(result.RootSpanIDs) < maxTraceRoots {
+                               result.RootSpanIDs = append(result.RootSpanIDs, 
rawSpan.SpanID)
+                       } else {
+                               result.Truncated = true
+                       }
+               }
+               end := rawSpan.StartTime + rawSpan.Duration
+               if minStart == 0 || rawSpan.StartTime < minStart {
+                       minStart = rawSpan.StartTime
+               }
+               if end > maxEnd {
+                       maxEnd = end
+               }
+       }
+       if len(spans) > maxTraceSpans {
+               spans = spans[:maxTraceSpans]
+               result.Truncated = true
+       }
+       for _, rawSpan := range spans {
+               process := input.Processes[rawSpan.ProcessID]
+               if rawSpan.Process != nil {
+                       process = *rawSpan.Process
+               }
+               span := normalizeJaegerSpan(rawSpan, process)
+               result.Spans = append(result.Spans, span)
+               if span.Truncated {
+                       result.Truncated = true
+               }
+       }
+       result.ReturnedSpanCount = len(result.Spans)
+       for service := range serviceSet {
+               result.Services = append(result.Services, service)
+       }
+       sort.Strings(result.Services)
+       result.ServiceCount = len(result.Services)
+       if len(result.Services) > maxTraceServices {
+               result.Services = result.Services[:maxTraceServices]
+               result.Truncated = true
+       }
+       if minStart > 0 {
+               result.StartTime = jaegerMicrosTime(minStart)
+               result.DurationMicros = maxEnd - minStart
+       }
+       return result
+}
+
+func normalizeJaegerSpan(input jaegerSpan, process jaegerProcess) Span {
+       attributes, attributesTruncated := normalizeKeyValues(input.Tags, 
maxSpanAttributes)
+       for _, processTag := range process.Tags {
+               key := "process." + processTag.Key
+               if _, exists := attributes[key]; exists {
+                       continue
+               }
+               if len(attributes) >= maxSpanAttributes {
+                       attributesTruncated = true
+                       break
+               }
+               value := formatAttributeValue(processTag.Value)
+               if truncatedValue, truncated := truncateUTF8(value, 
maxAttributeValueBytes); truncated {
+                       value = truncatedValue
+                       attributesTruncated = true
+               }
+               attributes[key] = value
+       }
+       span := Span{
+               SpanID:         input.SpanID,
+               ParentSpanID:   parentSpanID(input.References),
+               ServiceName:    process.ServiceName,
+               OperationName:  input.OperationName,
+               StartTime:      jaegerMicrosTime(input.StartTime),
+               DurationMicros: input.Duration,
+               Status:         "UNSET",
+               Attributes:     attributes,
+               Truncated:      attributesTruncated,
+       }
+       span.Error, span.Status = spanStatus(input.Tags)
+       for i, logEntry := range input.Logs {
+               if i >= maxSpanEvents {
+                       span.Truncated = true
+                       break
+               }
+               eventAttributes, truncated := 
normalizeKeyValues(logEntry.Fields, maxEventAttributes)
+               span.Events = append(span.Events, Event{Timestamp: 
jaegerMicrosTime(logEntry.Timestamp), Attributes: eventAttributes})
+               span.Truncated = span.Truncated || truncated
+       }
+       return span
+}
+
+func normalizeKeyValues(values []jaegerKeyValue, limit int) 
(map[string]string, bool) {
+       result := make(map[string]string, min(limit, len(values)))
+       truncated := false
+       for _, item := range values {
+               if len(result) >= limit {
+                       truncated = true
+                       break
+               }
+               value := formatAttributeValue(item.Value)
+               if truncatedValue, valueTruncated := truncateUTF8(value, 
maxAttributeValueBytes); valueTruncated {
+                       value = truncatedValue
+                       truncated = true
+               }
+               result[item.Key] = value
+       }
+       return result, truncated
+}
+
+func spanStatus(tags []jaegerKeyValue) (bool, string) {
+       status := "UNSET"
+       errorSpan := false
+       for _, tag := range tags {
+               key := strings.ToLower(tag.Key)
+               value := strings.ToLower(formatAttributeValue(tag.Value))
+               switch key {
+               case "error":
+                       if value == "true" || value == "1" {
+                               errorSpan = true
+                               status = "ERROR"
+                       }
+               case "otel.status_code", "status.code":
+                       if value == "error" {
+                               errorSpan = true
+                               status = "ERROR"
+                       } else if value == "ok" && !errorSpan {
+                               status = "OK"
+                       }
+               }
+       }
+       return errorSpan, status
+}
+
+func parentSpanID(references []jaegerReference) string {
+       for _, reference := range references {
+               if strings.EqualFold(reference.RefType, "CHILD_OF") {
+                       return reference.SpanID
+               }
+       }
+       if len(references) > 0 {
+               return references[0].SpanID
+       }
+       return ""
+}
+
+func formatAttributeValue(value any) string {
+       switch typed := value.(type) {
+       case string:
+               return typed
+       case bool:
+               return strconv.FormatBool(typed)
+       case float64:
+               return strconv.FormatFloat(typed, 'g', -1, 64)
+       default:
+               encoded, err := json.Marshal(typed)
+               if err != nil {
+                       return fmt.Sprint(typed)
+               }
+               return string(encoded)
+       }
+}
+
+func jaegerMicrosTime(value int64) string {
+       return time.Unix(0, 
value*int64(time.Microsecond)).UTC().Format(time.RFC3339Nano)
+}
+
+func readTraceBody(reader io.Reader) ([]byte, error) {
+       body, err := io.ReadAll(io.LimitReader(reader, maxTraceResponseBytes+1))
+       if err != nil {
+               return nil, fmt.Errorf("failed to read jaeger response: %w", 
err)
+       }
+       if int64(len(body)) > maxTraceResponseBytes {
+               return nil, fmt.Errorf("jaeger response exceeds %d bytes", 
maxTraceResponseBytes)
+       }
+       return body, nil
+}
+
+func maskedTraceID(traceID string) string {
+       if len(traceID) <= 8 {
+               return traceID
+       }
+       return "..." + traceID[len(traceID)-8:]
+}
+
+func truncateUTF8(value string, limit int) (string, bool) {
+       if len(value) <= limit {
+               return value, false
+       }
+       value = value[:limit]
+       for !utf8.ValidString(value) {
+               value = value[:len(value)-1]
+       }
+       return value, true
+}
+
+func (p *jaegerProvider) logFailure(traceID string, startedAt time.Time, 
status int, errorType string, err error) {
+       fields := []any{"provider", p.config.Name, "engine", p.config.Type, 
"trace_id", maskedTraceID(traceID), "elapsed_ms", 
time.Since(startedAt).Milliseconds(), "error_type", errorType}
+       if status != 0 {
+               fields = append(fields, "upstream_status", status)
+       }
+       if err != nil {
+               fields = append(fields, "error", err)
+       }
+       logger.Sugar().Warnw("trace query failed", fields...)
+}
diff --git a/pkg/mcp/tools/trace/jaeger_test.go 
b/pkg/mcp/tools/trace/jaeger_test.go
new file mode 100644
index 00000000..8132b611
--- /dev/null
+++ b/pkg/mcp/tools/trace/jaeger_test.go
@@ -0,0 +1,99 @@
+/*
+ * 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 trace
+
+import (
+       "context"
+       "net/http"
+       "net/http/httptest"
+       "testing"
+
+       observabilitycfg 
"github.com/apache/dubbo-admin/pkg/config/observability"
+)
+
+const testTraceID = "faba6a688ea3070b1613f50fb081c578"
+
+func TestJaegerProviderQueriesAndNormalizesTrace(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               if r.URL.Path != "/api/traces/"+testTraceID {
+                       t.Fatalf("unexpected path %s", r.URL.Path)
+               }
+               if r.Header.Get("Authorization") != "Bearer secret" || 
r.Header.Get(tenantHeader) != "tenant-a" {
+                       t.Fatalf("missing auth headers: %#v", r.Header)
+               }
+               _, _ = w.Write([]byte(`{"data":[{"traceID":"` + testTraceID + 
`","spans":[{"traceID":"` + testTraceID + 
`","spanID":"01","operationName":"root","references":[],"startTime":1000000,"duration":5000,"tags":[],"logs":[],"processID":"p1"},{"traceID":"`
 + testTraceID + 
`","spanID":"02","operationName":"child","references":[{"refType":"CHILD_OF","traceID":"`
 + testTraceID + 
`","spanID":"01"}],"startTime":1001000,"duration":2000,"tags":[{"key":"error","type":"bool","value":true}],"logs":[],"pr
 [...]
+       }))
+       defer server.Close()
+
+       provider, err := newJaegerProvider(observabilitycfg.TraceProviderConfig{
+               Name: "jaeger-main", Type: 
observabilitycfg.TraceProviderJaeger, Endpoint: server.URL, BearerToken: 
"secret", Tenant: "tenant-a",
+       })
+       if err != nil {
+               t.Fatal(err)
+       }
+       result, err := provider.GetTraceByID(context.Background(), testTraceID)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if result.SpanCount != 2 || result.ReturnedSpanCount != 2 || 
result.ErrorSpanCount != 1 || result.ServiceCount != 2 {
+               t.Fatalf("unexpected trace summary: %#v", result)
+       }
+       if result.Spans[1].ParentSpanID != "01" || result.Spans[1].Status != 
"ERROR" {
+               t.Fatalf("unexpected child span: %#v", result.Spans[1])
+       }
+}
+
+func TestTraceIDValidation(t *testing.T) {
+       for _, value := range []string{"abc", "../trace", "1234567890abcdefg"} {
+               if traceIDPattern.MatchString(value) {
+                       t.Fatalf("expected invalid trace ID %q", value)
+               }
+       }
+       for _, value := range []string{"1234567890abcdef", testTraceID} {
+               if !traceIDPattern.MatchString(value) {
+                       t.Fatalf("expected valid trace ID %q", value)
+               }
+       }
+}
+
+func TestJaegerProviderReturnsNotFound(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, _ *http.Request) {
+               w.WriteHeader(http.StatusNotFound)
+       }))
+       defer server.Close()
+       provider, err := newJaegerProvider(observabilitycfg.TraceProviderConfig{
+               Name: "jaeger-main", Type: 
observabilitycfg.TraceProviderJaeger, Endpoint: server.URL,
+       })
+       if err != nil {
+               t.Fatal(err)
+       }
+       if _, err := provider.GetTraceByID(context.Background(), testTraceID); 
err == nil {
+               t.Fatal("expected trace not found error")
+       }
+}
+
+func TestNormalizeJaegerTraceTruncatesSpans(t *testing.T) {
+       input := jaegerTrace{TraceID: testTraceID, Spans: make([]jaegerSpan, 
maxTraceSpans+1)}
+       for i := range input.Spans {
+               input.Spans[i] = jaegerSpan{SpanID: string(rune(i + 1)), 
StartTime: int64(i + 1), Duration: 1}
+       }
+       result := normalizeJaegerTrace(input)
+       if !result.Truncated || result.SpanCount != maxTraceSpans+1 || 
result.ReturnedSpanCount != maxTraceSpans {
+               t.Fatalf("expected trace truncation, got %#v", result)
+       }
+}
diff --git a/pkg/mcp/tools/trace/model.go b/pkg/mcp/tools/trace/model.go
new file mode 100644
index 00000000..78d07ce6
--- /dev/null
+++ b/pkg/mcp/tools/trace/model.go
@@ -0,0 +1,55 @@
+/*
+ * 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 trace
+
+// Trace is a backend-independent, bounded representation of a distributed 
trace.
+type Trace struct {
+       TraceID           string   `json:"traceId"`
+       StartTime         string   `json:"startTime"`
+       DurationMicros    int64    `json:"durationMicros"`
+       SpanCount         int      `json:"spanCount"`
+       ReturnedSpanCount int      `json:"returnedSpanCount"`
+       ServiceCount      int      `json:"serviceCount"`
+       ErrorSpanCount    int      `json:"errorSpanCount"`
+       Services          []string `json:"services"`
+       RootSpanIDs       []string `json:"rootSpanIds"`
+       Spans             []Span   `json:"spans"`
+       SourceEngine      string   `json:"sourceEngine"`
+       Truncated         bool     `json:"truncated"`
+}
+
+// Span contains the diagnostic fields needed to reconstruct latency and error 
propagation.
+type Span struct {
+       SpanID         string            `json:"spanId"`
+       ParentSpanID   string            `json:"parentSpanId,omitempty"`
+       ServiceName    string            `json:"serviceName"`
+       OperationName  string            `json:"operationName"`
+       StartTime      string            `json:"startTime"`
+       DurationMicros int64             `json:"durationMicros"`
+       Status         string            `json:"status"`
+       Error          bool              `json:"error"`
+       Attributes     map[string]string `json:"attributes,omitempty"`
+       Events         []Event           `json:"events,omitempty"`
+       Truncated      bool              `json:"truncated"`
+}
+
+// Event represents a bounded Jaeger span log entry.
+type Event struct {
+       Timestamp  string            `json:"timestamp"`
+       Attributes map[string]string `json:"attributes,omitempty"`
+}
diff --git a/pkg/mcp/tools/trace/provider.go b/pkg/mcp/tools/trace/provider.go
new file mode 100644
index 00000000..863256af
--- /dev/null
+++ b/pkg/mcp/tools/trace/provider.go
@@ -0,0 +1,39 @@
+/*
+ * 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 trace
+
+import (
+       "context"
+       "fmt"
+
+       observabilitycfg 
"github.com/apache/dubbo-admin/pkg/config/observability"
+)
+
+// Provider hides backend wire formats behind a stable trace lookup contract.
+type Provider interface {
+       GetTraceByID(ctx context.Context, traceID string) (*Trace, error)
+}
+
+func newProvider(cfg observabilitycfg.TraceProviderConfig) (Provider, error) {
+       switch cfg.Type {
+       case observabilitycfg.TraceProviderJaeger:
+               return newJaegerProvider(cfg)
+       default:
+               return nil, fmt.Errorf("unsupported trace provider type: %s", 
cfg.Type)
+       }
+}
diff --git a/pkg/mcp/tools/trace/tools.go b/pkg/mcp/tools/trace/tools.go
new file mode 100644
index 00000000..d5931875
--- /dev/null
+++ b/pkg/mcp/tools/trace/tools.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 trace
+
+import (
+       "context"
+       "fmt"
+       "regexp"
+
+       observabilitycfg 
"github.com/apache/dubbo-admin/pkg/config/observability"
+       consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+       "github.com/apache/dubbo-admin/pkg/mcp/common"
+)
+
+var traceIDPattern = 
regexp.MustCompile(`^(?:[0-9a-fA-F]{16}|[0-9a-fA-F]{32})$`)
+
+// GetTraceByID queries a configured provider using a validated Jaeger trace 
identifier.
+func GetTraceByID(ctx consolectx.Context, args map[string]any) 
(*common.ToolResult, error) {
+       helper := common.NewArgsHelper(args)
+       traceID := helper.GetString("traceId", "")
+       if !traceIDPattern.MatchString(traceID) {
+               return common.ErrorResult(fmt.Errorf("traceId must be a 16 or 
32 character hexadecimal value")), nil
+       }
+       providerConfig, err := providerConfigFromContext(ctx, 
helper.GetString("provider", ""))
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       provider, err := newProvider(providerConfig)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       requestCtx := context.Background()
+       if ctx.AppContext() != nil {
+               requestCtx = ctx.AppContext()
+       }
+       result, err := provider.GetTraceByID(requestCtx, traceID)
+       if err != nil {
+               return common.ErrorResult(err), nil
+       }
+       return common.JsonResult(result)
+}
+
+func providerConfigFromContext(ctx consolectx.Context, name string) 
(observabilitycfg.TraceProviderConfig, error) {
+       if ctx == nil || ctx.Config().Observability == nil || 
ctx.Config().Observability.Tracing == nil {
+               return observabilitycfg.TraceProviderConfig{}, 
fmt.Errorf("trace provider is not configured")
+       }
+       tracing := ctx.Config().Observability.Tracing
+       if name == "" {
+               provider, ok := tracing.Default()
+               if !ok {
+                       return observabilitycfg.TraceProviderConfig{}, 
fmt.Errorf("default trace provider is not configured")
+               }
+               return provider, nil
+       }
+       provider, ok := tracing.Get(name)
+       if !ok {
+               return observabilitycfg.TraceProviderConfig{}, 
fmt.Errorf("trace provider %q is not configured", name)
+       }
+       return provider, nil
+}
+
+// Properties returns the MCP input schema properties for trace lookup.
+func Properties() map[string]common.PropertyDef {
+       return map[string]common.PropertyDef{
+               "traceId":  {Type: "string", Description: "Jaeger 64-bit or 
128-bit hexadecimal TraceID"},
+               "provider": {Type: "string", Description: "Optional trace 
provider name; defaults to defaultProvider"},
+       }
+}

Reply via email to