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

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-mcp.git


The following commit(s) were added to refs/heads/main by this push:
     new 9827da7  fix: support v1 trace query protocol for non-BanyanDB 
storages (#43)
9827da7 is described below

commit 9827da7572d68ea39817833f75b0ad8265f6bff9
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Mon Jul 6 18:06:41 2026 +0800

    fix: support v1 trace query protocol for non-BanyanDB storages (#43)
---
 .github/workflows/publish-docker.yaml |   6 +-
 CLAUDE.md                             |  12 +++
 internal/tools/trace.go               | 112 ++++++++++++++++++++-
 internal/tools/trace_test.go          | 180 ++++++++++++++++++++++++++++++++++
 4 files changed, 305 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/publish-docker.yaml 
b/.github/workflows/publish-docker.yaml
index 8fdb5d3..c7eab96 100644
--- a/.github/workflows/publish-docker.yaml
+++ b/.github/workflows/publish-docker.yaml
@@ -42,7 +42,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Docker Buildx
-        uses: 
docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
+        uses: 
docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
       - name: Resolve image version
         run: |
           if [ "${{ github.event_name }}" = "release" ]; then
@@ -53,14 +53,14 @@ jobs:
           echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
           echo "Resolved VERSION=${VERSION}"
       - name: Log in to GHCR
-        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
+        uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 
v4.4.0
         if: github.event_name == 'push' && github.ref == 'refs/heads/main'
         with:
           registry: ghcr.io
           username: ${{ github.actor }}
           password: ${{ secrets.GITHUB_TOKEN }}
       - name: Log in to Docker Hub
-        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
+        uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 
v4.4.0
         if: github.event_name == 'release'
         with:
           registry: docker.io
diff --git a/CLAUDE.md b/CLAUDE.md
index 71b52de..ed610e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -112,6 +112,18 @@ All `.go` files must have the Apache 2.0 license header 
(17-line block). Run `ma
 ### Error Handling in Tools
 Tool handlers should return `(mcp.NewToolResultError(...), nil)` for expected 
query failures (bad input, OAP errors), not `(nil, err)`. Reserve Go errors for 
truly unexpected failures. Use the `ErrMarshalFailed` constant for JSON marshal 
errors.
 
+### Comments
+Add a comment only when the code is not clear on its own — explain the 
non-obvious *why* (a workaround, a compatibility constraint, a surprising side 
effect), not the *what*. Do not write doc comments that merely restate a 
function/const name. Example worth keeping: `queryTraceV1GQL` omits the 
`duration` arg because it does not exist on OAP < 10.3.0.
+
 ## CI & Merge Policy
 
 Squash-merge only. PRs to `main` require 1 approval and passing `Required` 
status check (license + lint + docker build). Go 1.25.
+
+### GitHub Actions & the ASF allowed-actions list
+This repo is under `apache/*` and is governed by the ASF org-wide 
allowed-actions policy, enforced on every workflow run:
+- Actions in `apache/*`, `actions/*`, and `github/*` are always allowed — no 
pinning required.
+- Every **third-party** action (e.g. `docker/*`) must be pinned to a specific 
commit **SHA** that is on the ASF approved list. The list **rotates**: old SHAs 
are pruned as new action versions are approved, so a pin that passed months ago 
can later be rejected with "action is not allowed". `publish-docker.yaml` only 
runs on push-to-`main`/release, so a stale pin stays latent until the next 
publish.
+- Before relying on or bumping a third-party action, check the SHA against the 
approved list: 
https://github.com/apache/infrastructure-actions/blob/main/approved_patterns.yml
 (entries are `owner/action@<SHA>`; pick the newest approved SHA of that 
action). Only if a needed action/SHA is absent does it require a PR to 
`apache/infrastructure-actions`.
+
+### Commit authorship
+Commits produced with AI assistance credit the assistant as a co-author, e.g. 
a `Co-Authored-By: Claude <...>` trailer, so the contribution is attributed 
transparently.
diff --git a/internal/tools/trace.go b/internal/tools/trace.go
index 208a17c..785e65a 100644
--- a/internal/tools/trace.go
+++ b/internal/tools/trace.go
@@ -101,6 +101,107 @@ func tracesV2(ctx context.Context, condition 
*api.TraceQueryCondition) (api.Trac
        return response["result"], err
 }
 
+const hasQueryTracesV2SupportGQL = `
+query {
+       result: hasQueryTracesV2Support
+}`
+
+const queryBasicTracesV1GQL = `
+query ($condition: TraceQueryCondition!) {
+       result: queryBasicTraces(condition: $condition) {
+               traces { segmentId endpointNames duration start isError 
traceIds }
+       }
+}`
+
+// queryTraceV1GQL omits the queryTrace `duration` argument deliberately: it 
is BanyanDB-only
+// and absent on OAP < 10.3.0, so including it would break this fallback on 
older backends.
+const queryTraceV1GQL = `
+query ($traceId: ID!) {
+       result: queryTrace(traceId: $traceId) {
+               spans {
+                       traceId segmentId spanId parentSpanId
+                       refs { traceId parentSegmentId parentSpanId type }
+                       serviceCode serviceInstanceName
+                       startTime endTime endpointName type peer component 
isError layer
+                       tags { key value }
+                       logs { time data { key value } }
+                       attachedEvents {
+                               startTime { seconds nanos } event endTime { 
seconds nanos }
+                               tags { key value } summary { key value }
+                       }
+               }
+       }
+}`
+
+func queryTracesAuto(ctx context.Context, condition *api.TraceQueryCondition) 
(api.TraceList, error) {
+       if supportsTraceV2(ctx) {
+               return tracesV2(ctx, condition)
+       }
+       return tracesV1(ctx, condition)
+}
+
+// supportsTraceV2 treats any error (field absent on an older OAP, or an 
unreachable backend)
+// as "not supported" so the v1 path is used; a broken connection then 
surfaces on that query.
+func supportsTraceV2(ctx context.Context) bool {
+       var response map[string]bool
+       request := graphql.NewRequest(hasQueryTracesV2SupportGQL)
+       if err := client.ExecuteQuery(ctx, request, &response); err != nil {
+               return false
+       }
+       return response["result"]
+}
+
+func basicTracesV1(ctx context.Context, condition *api.TraceQueryCondition) 
(api.TraceBrief, error) {
+       var response map[string]api.TraceBrief
+       request := graphql.NewRequest(queryBasicTracesV1GQL)
+       request.Var("condition", condition)
+       err := client.ExecuteQuery(ctx, request, &response)
+       return response["result"], err
+}
+
+func traceV1(ctx context.Context, traceID string) (api.Trace, error) {
+       var response map[string]api.Trace
+       request := graphql.NewRequest(queryTraceV1GQL)
+       request.Var("traceId", traceID)
+       err := client.ExecuteQuery(ctx, request, &response)
+       return response["result"], err
+}
+
+// tracesV1 fetches each unique trace's spans via queryTrace because 
queryBasicTraces returns
+// summaries only. queryBasicTraces paginates over segments on non-BanyanDB 
storage, so
+// page_size bounds segments (not traces) and segments of one trace are 
de-duplicated here —
+// a broad query may return fewer than page_size traces (narrow by 
service/endpoint).
+func tracesV1(ctx context.Context, condition *api.TraceQueryCondition) 
(api.TraceList, error) {
+       brief, err := basicTracesV1(ctx, condition)
+       if err != nil {
+               return api.TraceList{}, err
+       }
+
+       traceList := api.TraceList{Traces: make([]*api.TraceV2, 0, 
len(brief.Traces))}
+       seen := make(map[string]struct{}, len(brief.Traces))
+       for _, basic := range brief.Traces {
+               if basic == nil || len(basic.TraceIds) == 0 {
+                       continue
+               }
+               traceID := basic.TraceIds[0]
+               if _, ok := seen[traceID]; ok {
+                       continue
+               }
+               seen[traceID] = struct{}{}
+
+               trace, err := traceV1(ctx, traceID)
+               if err != nil {
+                       return api.TraceList{}, err
+               }
+               if len(trace.Spans) == 0 {
+                       // Trace vanished between the two calls; skip so it is 
not counted as empty.
+                       continue
+               }
+               traceList.Traces = append(traceList.Traces, &api.TraceV2{Spans: 
trace.Spans})
+       }
+       return traceList, nil
+}
+
 // Trace-specific constants
 const (
        DefaultTracePageSize = 20
@@ -393,8 +494,10 @@ func searchTraces(ctx context.Context, req 
*TracesQueryRequest) (*mcp.CallToolRe
                return mcp.NewToolResultError(err.Error()), nil
        }
 
-       // Execute query using queryTraces (v2 protocol)
-       traceList, err := tracesV2(ctx, condition)
+       // Execute the query, automatically selecting the trace protocol 
supported by
+       // the backend storage: queryTraces (v2) for BanyanDB, or the legacy
+       // queryBasicTraces + queryTrace (v1) for Elasticsearch/JDBC and older 
OAP.
+       traceList, err := queryTracesAuto(ctx, condition)
        if err != nil {
                return 
mcp.NewToolResultError(fmt.Sprintf(ErrFailedToQueryTraces, err)), nil
        }
@@ -608,6 +711,11 @@ Important Notes:
 - SkyWalking OAP requires either a time range or 'trace_id' to be specified
 - If no time range and no trace_id are provided, a default duration of "1h" 
(last 1 hour) will be used
 - This ensures the query always has a valid time range or specific trace to 
search
+- The trace query protocol is selected automatically based on the backend 
storage, so this
+  tool works with BanyanDB, Elasticsearch, and JDBC storages
+- On non-BanyanDB storages, page_size bounds trace segments rather than whole 
traces, so a
+  broad query may return fewer distinct traces than page_size; narrow it by 
service or
+  endpoint for more complete results
 
 View Options:
 - 'full': (Default) Complete raw data for detailed analysis
diff --git a/internal/tools/trace_test.go b/internal/tools/trace_test.go
new file mode 100644
index 0000000..7beaef3
--- /dev/null
+++ b/internal/tools/trace_test.go
@@ -0,0 +1,180 @@
+// Licensed to 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. Apache Software Foundation (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 (
+       "context"
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "testing"
+
+       "github.com/mark3labs/mcp-go/mcp"
+       api "skywalking.apache.org/repo/goapi/query"
+
+       "github.com/apache/skywalking-cli/pkg/contextkey"
+)
+
+// fakeOAPServer emulates the OAP GraphQL endpoint for trace queries. It 
routes on
+// the query text: the v2-support probe, the v1 basic-traces query, the v1
+// single-trace query, and the v2 queryTraces query.
+func fakeOAPServer(t *testing.T, v2Supported bool) *httptest.Server {
+       t.Helper()
+       return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, 
r *http.Request) {
+               var body struct {
+                       Query     string                 `json:"query"`
+                       Variables map[string]interface{} `json:"variables"`
+               }
+               if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+                       http.Error(w, err.Error(), http.StatusBadRequest)
+                       return
+               }
+               w.Header().Set("Content-Type", "application/json")
+
+               switch {
+               case strings.Contains(body.Query, "hasQueryTracesV2Support"):
+                       fmt.Fprintf(w, `{"data":{"result":%t}}`, v2Supported)
+               case strings.Contains(body.Query, "queryBasicTraces"):
+                       // Three summaries referencing two unique trace IDs (t1 
appears twice).
+                       _, _ = w.Write([]byte(`{"data":{"result":{"traces":[` +
+                               
`{"segmentId":"s1","endpointNames":["/a"],"duration":100,"start":"1000","isError":false,"traceIds":["t1"]},`
 +
+                               
`{"segmentId":"s2","endpointNames":["/a"],"duration":100,"start":"1000","isError":false,"traceIds":["t1"]},`
 +
+                               
`{"segmentId":"s3","endpointNames":["/b"],"duration":200,"start":"2000","isError":true,"traceIds":["t2"]}`
 +
+                               `]}}}`))
+               case strings.Contains(body.Query, "queryTraces("):
+                       // v2 path.
+                       _, _ = 
w.Write([]byte(`{"data":{"result":{"traces":[{"spans":[` +
+                               
`{"traceId":"v2","segmentId":"seg","spanId":0,"parentSpanId":-1,"serviceCode":"svcV2",`
 +
+                               
`"startTime":10,"endTime":20,"endpointName":"/v2","type":"Entry","isError":false}`
 +
+                               
`]}],"retrievedTimeRange":{"startTime":0,"endTime":0}}}}`))
+               case strings.Contains(body.Query, "queryTrace("):
+                       traceID, _ := body.Variables["traceId"].(string)
+                       svc, ep, isErr, st, et := "svcA", "/a", "false", 1000, 
1100
+                       if traceID == "t2" {
+                               svc, ep, isErr, st, et = "svcB", "/b", "true", 
2000, 2200
+                       }
+                       fmt.Fprintf(w, `{"data":{"result":{"spans":[`+
+                               
`{"traceId":%q,"segmentId":"seg","spanId":0,"parentSpanId":-1,"serviceCode":%q,`+
+                               
`"serviceInstanceName":"i","startTime":%d,"endTime":%d,"endpointName":%q,"type":"Entry",`+
+                               
`"isError":%s,"refs":[],"tags":[],"logs":[],"attachedEvents":[]}`+
+                               `]}}}`, traceID, svc, st, et, ep, isErr)
+               default:
+                       http.Error(w, "unexpected query", http.StatusBadRequest)
+               }
+       }))
+}
+
+// traceTestContext builds a context carrying the base URL and (required) 
insecure flag
+// expected by the skywalking-cli GraphQL client.
+func traceTestContext(url string) context.Context {
+       ctx := context.WithValue(context.Background(), contextkey.BaseURL{}, 
url)
+       return context.WithValue(ctx, contextkey.Insecure{}, false)
+}
+
+func TestSupportsTraceV2(t *testing.T) {
+       tsTrue := fakeOAPServer(t, true)
+       defer tsTrue.Close()
+       if !supportsTraceV2(traceTestContext(tsTrue.URL)) {
+               t.Fatal("expected v2 support to be true")
+       }
+
+       tsFalse := fakeOAPServer(t, false)
+       defer tsFalse.Close()
+       if supportsTraceV2(traceTestContext(tsFalse.URL)) {
+               t.Fatal("expected v2 support to be false")
+       }
+
+       // An unreachable backend must degrade to "not supported" so the v1 
path is used.
+       if supportsTraceV2(traceTestContext("http://127.0.0.1:1";)) {
+               t.Fatal("expected v2 support to be false on connection error")
+       }
+}
+
+func TestQueryTracesAutoUsesV1WhenV2Unsupported(t *testing.T) {
+       ts := fakeOAPServer(t, false)
+       defer ts.Close()
+
+       list, err := queryTracesAuto(traceTestContext(ts.URL), 
&api.TraceQueryCondition{})
+       if err != nil {
+               t.Fatalf("queryTracesAuto returned error: %v", err)
+       }
+
+       // Three basic-trace rows collapse to two unique traces (t1 is 
deduplicated).
+       if len(list.Traces) != 2 {
+               t.Fatalf("expected 2 deduplicated traces, got %d", 
len(list.Traces))
+       }
+       for _, tr := range list.Traces {
+               if len(tr.Spans) == 0 {
+                       t.Fatal("expected each assembled trace to carry its 
spans from queryTrace")
+               }
+       }
+
+       // The summary is derived from the fetched spans, including the error 
state.
+       result, err := processTracesResult(&list, ViewSummary, 0)
+       if err != nil {
+               t.Fatalf("processTracesResult returned error: %v", err)
+       }
+       if result.IsError {
+               t.Fatalf("unexpected tool error: %v", result)
+       }
+       text, ok := result.Content[0].(mcp.TextContent)
+       if !ok {
+               t.Fatalf("unexpected content type: %T", result.Content[0])
+       }
+       var summary TracesSummary
+       if err := json.Unmarshal([]byte(text.Text), &summary); err != nil {
+               t.Fatalf("failed to decode summary: %v", err)
+       }
+       if summary.TotalTraces != 2 {
+               t.Fatalf("total_traces = %d, want 2", summary.TotalTraces)
+       }
+       if summary.ErrorCount != 1 {
+               t.Fatalf("error_count = %d, want 1", summary.ErrorCount)
+       }
+       if summary.SuccessCount != 1 {
+               t.Fatalf("success_count = %d, want 1", summary.SuccessCount)
+       }
+}
+
+// TestQueryTraceV1OmitsDurationArgument guards against re-introducing the 
queryTrace
+// `duration` argument, which does not exist on OAP releases older than 10.3.0 
(and is
+// BanyanDB-only where it does), and would break the v1 fallback on the exact 
older,
+// non-BanyanDB backends it targets.
+func TestQueryTraceV1OmitsDurationArgument(t *testing.T) {
+       if strings.Contains(queryTraceV1GQL, "duration") {
+               t.Fatal("queryTraceV1GQL must not reference the duration 
argument (breaks OAP < 10.3.0)")
+       }
+}
+
+func TestQueryTracesAutoUsesV2WhenSupported(t *testing.T) {
+       ts := fakeOAPServer(t, true)
+       defer ts.Close()
+
+       list, err := queryTracesAuto(traceTestContext(ts.URL), 
&api.TraceQueryCondition{})
+       if err != nil {
+               t.Fatalf("queryTracesAuto returned error: %v", err)
+       }
+       if len(list.Traces) != 1 {
+               t.Fatalf("expected 1 trace from v2 path, got %d", 
len(list.Traces))
+       }
+       if len(list.Traces[0].Spans) == 0 || 
list.Traces[0].Spans[0].ServiceCode != "svcV2" {
+               t.Fatalf("expected v2 span with serviceCode svcV2, got %+v", 
list.Traces[0].Spans)
+       }
+}

Reply via email to