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

FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git


The following commit(s) were added to refs/heads/master by this push:
     new abbb3f5  feat: propagate sanitized MCP trace context (#149)
abbb3f5 is described below

commit abbb3f54c76b3f1d5ee1145c1203a7adb31e622c
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 13:33:50 2026 +0800

    feat: propagate sanitized MCP trace context (#149)
---
 CHANGELOG.md                          |   3 +
 README.md                             |  25 +++
 doris_mcp_server/protocol.py          |   5 +
 doris_mcp_server/trace_context.py     | 248 ++++++++++++++++++++++
 doris_mcp_server/utils/redaction.py   |   5 +
 test/protocol/test_trace_context.py   | 374 ++++++++++++++++++++++++++++++++++
 test/protocol/trace_context_server.py | 156 ++++++++++++++
 7 files changed, 816 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a4fa10f..b8d6721 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,9 @@ under **Unreleased** until a new version is selected and 
published.
   Streamable HTTP and stdio.
 - HMAC-authenticated explicit state handles with principal, scope, resource,
   expiry, and shared-worker key binding instead of protocol-session state.
+- W3C `traceparent`, `tracestate`, and `baggage` propagation from request
+  `_meta`, with value-safe validation, credential-like baggage redaction,
+  per-request isolation, and no trace metadata in model-facing results.
 - Real Doris process tests covering Streamable HTTP and stdio.
 
 ### Changed
diff --git a/README.md b/README.md
index 307a24a..006da86 100644
--- a/README.md
+++ b/README.md
@@ -535,6 +535,31 @@ Stdio carries the same JSON-RPC request metadata in the 
message body, but it
 does not use HTTP headers. Do not write logs or other diagnostics to stdout in
 stdio mode; stdout is reserved for MCP protocol messages.
 
+### OpenTelemetry Trace Context
+
+Clients may propagate the W3C `traceparent`, `tracestate`, and `baggage`
+carrier fields in `params._meta`, as defined by
+[MCP SEP-414](https://modelcontextprotocol.io/seps/414-request-meta),
+[W3C Trace Context](https://www.w3.org/TR/trace-context/), and
+[W3C Baggage](https://www.w3.org/TR/baggage/). The same message-level carrier
+works on Streamable HTTP and stdio; these values are not separate MCP HTTP
+headers.
+
+When an OpenTelemetry provider and exporter are configured, each MCP operation
+span is parented to the valid incoming trace context. The active context is
+available to instrumented downstream work for the lifetime of that operation
+and is reset before the next request. Trace carrier fields are never passed to
+the Doris tool, resource, or prompt managers and are never copied into model
+content or structured results.
+
+The server validates trace carrier values before the SDK propagator sees them.
+Malformed, oversized, duplicate, or orphaned fields are ignored independently,
+and warning logs identify only the field name—never its supplied value.
+Values under credential-like baggage keys such as `token`, `secret`, or
+`authorization` are replaced with `[REDACTED]` before propagation. `baggage`
+can still contain other deployment-sensitive correlation data, so clients
+should send only values approved by their telemetry data-handling policy.
+
 ### List Pagination
 
 `resources/list`, `tools/list`, and `prompts/list` return at most
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 1972fec..423655d 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -73,6 +73,7 @@ from .state_handles import (
     DEFAULT_STATE_HANDLE_TTL_SECONDS,
     StateHandleCodec,
 )
+from .trace_context import TraceContextSanitizingMiddleware
 from .utils.redaction import (
     redact_error_payload,
     redact_sensitive_text,
@@ -558,6 +559,10 @@ def create_doris_mcp_server(
         on_list_prompts=list_prompts,
         on_get_prompt=get_prompt,
     )
+    # The SDK's OpenTelemetry middleware extracts W3C carrier values. Run the
+    # value-safe sanitizer before it so malformed tracestate/baggage cannot be
+    # echoed by dependency warning logs and never reaches the handler layer.
+    server.middleware.insert(0, TraceContextSanitizingMiddleware(logger))
 
     async def hide_unhandled_errors(
         ctx: ServerRequestContext,
diff --git a/doris_mcp_server/trace_context.py 
b/doris_mcp_server/trace_context.py
new file mode 100644
index 0000000..489ada1
--- /dev/null
+++ b/doris_mcp_server/trace_context.py
@@ -0,0 +1,248 @@
+# 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.
+"""Safe W3C trace-context handling for MCP request ``_meta``."""
+
+from __future__ import annotations
+
+import logging
+import re
+from collections.abc import Mapping
+from dataclasses import replace
+from typing import Any, cast
+from urllib.parse import unquote_plus
+
+from mcp.server import ServerRequestContext
+from mcp.server.context import CallNext, HandlerResult
+from mcp.types import RequestParamsMeta
+from opentelemetry.trace import get_current_span
+from opentelemetry.trace.propagation.tracecontext import (
+    TraceContextTextMapPropagator,
+)
+
+from .utils.redaction import REDACTED, is_sensitive_key
+
+TRACEPARENT_META_KEY = "traceparent"
+TRACESTATE_META_KEY = "tracestate"
+BAGGAGE_META_KEY = "baggage"
+
+_TRACEPARENT_MAX_LENGTH = 512
+_TRACESTATE_MAX_LENGTH = 512
+_TRACESTATE_MAX_MEMBERS = 32
+_BAGGAGE_MAX_LENGTH = 8192
+_BAGGAGE_MAX_MEMBER_LENGTH = 4096
+_BAGGAGE_MAX_MEMBERS = 180
+
+# W3C Trace Context section 3.3.1.3. The expression intentionally mirrors
+# the specification's ASCII bounds rather than accepting arbitrary Unicode.
+_TRACESTATE_MEMBER_RE = re.compile(
+    r"(?P<key>"
+    r"[a-z][_0-9a-z\-*/]{0,255}"
+    r"|[a-z0-9][_0-9a-z\-*/]{0,240}@[a-z][_0-9a-z\-*/]{0,13}"
+    r")="
+    r"[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}"
+    r"[\x21-\x2b\x2d-\x3c\x3e-\x7e]"
+    r"[ \t]*"
+)
+_LIST_DELIMITER_RE = re.compile(r"[ \t]*,[ \t]*")
+
+# W3C Baggage section 3.2.1. The value expression includes optional
+# semicolon-delimited properties and remains aligned with the OTel W3C
+# propagator's accepted wire surface.
+_BAGGAGE_KEY_RE = re.compile(
+    r"[\x21\x23-\x27\x2a\x2b\x2d\x2e"
+    r"\x30-\x39\x41-\x5a\x5e-\x7a\x7c\x7e]+"
+)
+_BAGGAGE_VALUE_RE = re.compile(
+    r"[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*"
+)
+_BAGGAGE_PROPERTY_RE = re.compile(
+    r"[ \t]*"
+    r"[\x21\x23-\x27\x2a\x2b\x2d\x2e"
+    r"\x30-\x39\x41-\x5a\x5e-\x7a\x7c\x7e]+"
+    r"(?:[ \t]*=[ \t]*"
+    r"[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*)?"
+    r"[ \t]*"
+)
+_TRACEPARENT_PROPAGATOR = TraceContextTextMapPropagator()
+
+
+def _valid_traceparent(value: object) -> bool:
+    if (
+        not isinstance(value, str)
+        or not value
+        or len(value) > _TRACEPARENT_MAX_LENGTH
+    ):
+        return False
+    context = _TRACEPARENT_PROPAGATOR.extract(
+        {TRACEPARENT_META_KEY: value}
+    )
+    return get_current_span(context).get_span_context().is_valid
+
+
+def _valid_tracestate(value: object) -> bool:
+    if (
+        not isinstance(value, str)
+        or not value
+        or len(value) > _TRACESTATE_MAX_LENGTH
+    ):
+        return False
+    members = _LIST_DELIMITER_RE.split(value)
+    if len(members) > _TRACESTATE_MAX_MEMBERS:
+        return False
+    keys: set[str] = set()
+    for member in members:
+        match = _TRACESTATE_MEMBER_RE.fullmatch(member)
+        if match is None or match.group("key") in keys:
+            return False
+        keys.add(match.group("key"))
+    return True
+
+
+def _sanitize_baggage(value: object) -> str | None:
+    if (
+        not isinstance(value, str)
+        or not value
+        or len(value) > _BAGGAGE_MAX_LENGTH
+    ):
+        return None
+    members = _LIST_DELIMITER_RE.split(value)
+    if len(members) > _BAGGAGE_MAX_MEMBERS:
+        return None
+    sanitized_members: list[str] = []
+    decoded_keys: set[str] = set()
+    for member in members:
+        if not member or len(member) > _BAGGAGE_MAX_MEMBER_LENGTH:
+            return None
+        try:
+            key, raw_value = member.split("=", 1)
+        except ValueError:
+            return None
+        if _BAGGAGE_KEY_RE.fullmatch(key) is None:
+            return None
+        decoded_key = unquote_plus(key).strip()
+        if decoded_key in decoded_keys:
+            return None
+        decoded_keys.add(decoded_key)
+        value_and_properties = raw_value.split(";")
+        if _BAGGAGE_VALUE_RE.fullmatch(value_and_properties[0]) is None:
+            return None
+        if any(
+            _BAGGAGE_PROPERTY_RE.fullmatch(item) is None
+            for item in value_and_properties[1:]
+        ):
+            return None
+        if is_sensitive_key(decoded_key):
+            sanitized_members.append(f"{key}={REDACTED}")
+            continue
+
+        sanitized_properties: list[str] = []
+        for item in value_and_properties[1:]:
+            if "=" not in item:
+                sanitized_properties.append(item)
+                continue
+            property_key, _property_value = item.split("=", 1)
+            if is_sensitive_key(unquote_plus(property_key).strip()):
+                sanitized_properties.append(
+                    f"{property_key.rstrip()}={REDACTED}"
+                )
+            else:
+                sanitized_properties.append(item)
+        sanitized_members.append(
+            ";".join(
+                [
+                    f"{key}={value_and_properties[0]}",
+                    *sanitized_properties,
+                ]
+            )
+        )
+    return ",".join(sanitized_members)
+
+
+def sanitize_trace_meta(
+    meta: Mapping[str, Any] | None,
+    *,
+    logger: logging.Logger,
+) -> RequestParamsMeta | None:
+    """Drop malformed trace fields without logging their untrusted values."""
+    if meta is None:
+        return None
+
+    sanitized = dict(meta)
+    traceparent_valid = (
+        TRACEPARENT_META_KEY not in sanitized
+        or _valid_traceparent(sanitized[TRACEPARENT_META_KEY])
+    )
+    if not traceparent_valid:
+        sanitized.pop(TRACEPARENT_META_KEY, None)
+        logger.warning(
+            "Ignoring invalid MCP trace metadata field %s",
+            TRACEPARENT_META_KEY,
+        )
+
+    if TRACESTATE_META_KEY in sanitized:
+        tracestate_valid = (
+            traceparent_valid
+            and TRACEPARENT_META_KEY in sanitized
+            and _valid_tracestate(sanitized[TRACESTATE_META_KEY])
+        )
+        if not tracestate_valid:
+            sanitized.pop(TRACESTATE_META_KEY, None)
+            logger.warning(
+                "Ignoring invalid MCP trace metadata field %s",
+                TRACESTATE_META_KEY,
+            )
+
+    if BAGGAGE_META_KEY in sanitized:
+        sanitized_baggage = _sanitize_baggage(sanitized[BAGGAGE_META_KEY])
+        if sanitized_baggage is None:
+            sanitized.pop(BAGGAGE_META_KEY, None)
+            logger.warning(
+                "Ignoring invalid MCP trace metadata field %s",
+                BAGGAGE_META_KEY,
+            )
+        else:
+            sanitized[BAGGAGE_META_KEY] = sanitized_baggage
+
+    return cast(RequestParamsMeta, sanitized)
+
+
+class TraceContextSanitizingMiddleware:
+    """Sanitize trace carrier fields before the SDK OTel middleware runs."""
+
+    def __init__(self, logger: logging.Logger) -> None:
+        self._logger = logger
+
+    async def __call__(
+        self,
+        ctx: ServerRequestContext[Any, Any],
+        call_next: CallNext,
+    ) -> HandlerResult:
+        sanitized_meta = sanitize_trace_meta(ctx.meta, logger=self._logger)
+        if sanitized_meta == ctx.meta:
+            return await call_next(ctx)
+
+        sanitized_params = ctx.params
+        if ctx.params is not None and "_meta" in ctx.params:
+            sanitized_params = dict(ctx.params)
+            sanitized_params["_meta"] = sanitized_meta or {}
+        return await call_next(
+            replace(
+                ctx,
+                meta=sanitized_meta,
+                params=sanitized_params,
+            )
+        )
diff --git a/doris_mcp_server/utils/redaction.py 
b/doris_mcp_server/utils/redaction.py
index ce8abe1..c3007b9 100644
--- a/doris_mcp_server/utils/redaction.py
+++ b/doris_mcp_server/utils/redaction.py
@@ -122,6 +122,11 @@ def _normalized_key(key: object) -> str:
     return re.sub(r"[^a-z0-9]", "", str(key).casefold())
 
 
+def is_sensitive_key(key: object) -> bool:
+    """Return whether a semantic field name is treated as credential data."""
+    return _normalized_key(key) in _SENSITIVE_KEYS
+
+
 def redact_sql_literals(value: str) -> str:
     """Remove values and comments from SQL while retaining diagnostic shape."""
     if not _SQL_KEYWORD_RE.search(value):
diff --git a/test/protocol/test_trace_context.py 
b/test/protocol/test_trace_context.py
new file mode 100644
index 0000000..10c2677
--- /dev/null
+++ b/test/protocol/test_trace_context.py
@@ -0,0 +1,374 @@
+# 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.
+"""OpenTelemetry request ``_meta`` propagation and redaction tests."""
+
+from __future__ import annotations
+
+import json
+import logging
+import secrets
+import sys
+from collections.abc import Generator
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+
+import httpx2
+import mcp.shared._otel as mcp_otel
+import pytest
+from mcp import Client, StdioServerParameters
+from mcp.client.stdio import stdio_client
+from opentelemetry.baggage import get_all
+from opentelemetry.trace import (
+    NonRecordingSpan,
+    SpanContext,
+    TraceFlags,
+    get_current_span,
+    use_span,
+)
+
+from doris_mcp_server.protocol import create_transport_security
+from doris_mcp_server.trace_context import sanitize_trace_meta
+from test.protocol.test_mcp_v2_protocol import (
+    create_test_server,
+    modern_tool_headers,
+    modern_tool_request,
+)
+
+_TRACE_ID = "0af7651916cd43dd8448eb211c80319c"
+_PARENT_SPAN_ID = "00f067aa0ba902b7"
+
+
+class _CapturingTracer:
+    def __init__(self) -> None:
+        self.records: list[dict[str, Any]] = []
+
+    @contextmanager
+    def start_as_current_span(
+        self,
+        name: str,
+        *,
+        context: Any = None,
+        attributes: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> Generator[NonRecordingSpan]:
+        del attributes, kwargs
+        parent = get_current_span(context).get_span_context()
+        trace_id = parent.trace_id if parent.is_valid else 
secrets.randbits(128) or 1
+        child_context = SpanContext(
+            trace_id=trace_id,
+            span_id=secrets.randbits(64) or 1,
+            is_remote=False,
+            trace_flags=parent.trace_flags if parent.is_valid else 
TraceFlags(0),
+            trace_state=parent.trace_state if parent.is_valid else None,
+        )
+        baggage = get_all(context=context)
+        self.records.append(
+            {
+                "name": name,
+                "parentTraceId": (
+                    f"{parent.trace_id:032x}" if parent.is_valid else None
+                ),
+                "parentSpanId": (
+                    f"{parent.span_id:016x}" if parent.is_valid else None
+                ),
+                "baggageCount": len(baggage),
+                "redactedBaggageCount": sum(
+                    value == "[REDACTED]" for value in baggage.values()
+                ),
+            }
+        )
+        span = NonRecordingSpan(child_context)
+        with use_span(span, end_on_exit=False):
+            yield span
+
+
+def _trace_meta(*, baggage: str, tracestate: str = "vendor=opaque") -> dict:
+    return {
+        "traceparent": f"00-{_TRACE_ID}-{_PARENT_SPAN_ID}-01",
+        "tracestate": tracestate,
+        "baggage": baggage,
+    }
+
+
[email protected](
+    ("meta", "remaining_trace_keys"),
+    [
+        (
+            {
+                "traceparent": "not-a-traceparent",
+                "tracestate": "vendor=opaque",
+                "baggage": "tenant=blue",
+            },
+            {"baggage"},
+        ),
+        ({"tracestate": "vendor=opaque"}, set()),
+        (
+            {
+                "traceparent": (
+                    f"00-{_TRACE_ID}-{_PARENT_SPAN_ID}-01"
+                ),
+                "tracestate": "vendor=one,vendor=two",
+            },
+            {"traceparent"},
+        ),
+        (
+            {
+                "traceparent": (
+                    f"00-{_TRACE_ID}-{_PARENT_SPAN_ID}-01"
+                ),
+                "baggage": "missing-value",
+            },
+            {"traceparent"},
+        ),
+        (
+            {
+                "traceparent": (
+                    f"00-{_TRACE_ID}-{_PARENT_SPAN_ID}-01"
+                ),
+                "baggage": "key=" + "x" * 8193,
+            },
+            {"traceparent"},
+        ),
+    ],
+)
+def test_trace_meta_sanitizer_drops_only_invalid_carrier_fields(
+    meta: dict[str, Any],
+    remaining_trace_keys: set[str],
+    caplog: pytest.LogCaptureFixture,
+):
+    secret = "sanitizer-must-not-log-values"
+    supplied = {**meta, "custom": secret}
+    logger = logging.getLogger("test.trace-context-sanitizer")
+    caplog.set_level(logging.WARNING)
+
+    sanitized = sanitize_trace_meta(supplied, logger=logger)
+
+    assert sanitized is not None
+    assert sanitized["custom"] == secret
+    assert (
+        {"traceparent", "tracestate", "baggage"} & set(sanitized)
+        == remaining_trace_keys
+    )
+    assert secret not in caplog.text
+
+
+def test_trace_meta_sanitizer_redacts_sensitive_baggage_values():
+    logger = logging.getLogger("test.trace-context-sanitizer")
+
+    sanitized = sanitize_trace_meta(
+        {
+            "baggage": (
+                "tenant=blue,secret=must-not-propagate,"
+                "token%2Dsecret=also-private,"
+                "region=west;authorization=private;readonly"
+            )
+        },
+        logger=logger,
+    )
+
+    assert sanitized is not None
+    assert sanitized["baggage"] == (
+        "tenant=blue,secret=[REDACTED],token%2Dsecret=[REDACTED],"
+        "region=west;authorization=[REDACTED];readonly"
+    )
+
+
[email protected]
+async def test_http_propagates_trace_context_without_model_or_log_leakage(
+    monkeypatch: pytest.MonkeyPatch,
+    caplog: pytest.LogCaptureFixture,
+):
+    tracer = _CapturingTracer()
+    monkeypatch.setattr(mcp_otel, "_tracer", tracer)
+    app = create_test_server().streamable_http_app(
+        json_response=True,
+        stateless_http=True,
+        host="127.0.0.1",
+        transport_security=create_transport_security("127.0.0.1"),
+    )
+    valid_secret = "http-valid-baggage-secret"
+    invalid_secret = "http-invalid-baggage-secret"
+    caplog.set_level(logging.WARNING)
+
+    async with (
+        app.router.lifespan_context(app),
+        httpx2.ASGITransport(app) as transport,
+        httpx2.AsyncClient(
+            transport=transport,
+            base_url="http://127.0.0.1:3000";,
+        ) as client,
+    ):
+        traced_request = modern_tool_request(1, "echo", {"value": "safe"})
+        traced_request["params"]["_meta"].update(
+            _trace_meta(baggage=f"tenant=blue,secret={valid_secret}")
+        )
+        traced = await client.post(
+            "/mcp",
+            json=traced_request,
+            headers=modern_tool_headers("echo"),
+        )
+        assert traced.status_code == 200
+        assert traced.json()["result"]["structuredContent"] == {
+            "name": "echo",
+            "arguments": {"value": "safe"},
+        }
+        assert _TRACE_ID not in traced.text
+        assert valid_secret not in traced.text
+
+        sanitized_request = modern_tool_request(2, "echo", {"value": "safe"})
+        sanitized_request["params"]["_meta"].update(
+            _trace_meta(
+                baggage=f"secret={invalid_secret},missing-value",
+                tracestate=f"vendor=opaque,{invalid_secret}",
+            )
+        )
+        sanitized = await client.post(
+            "/mcp",
+            json=sanitized_request,
+            headers=modern_tool_headers("echo"),
+        )
+        assert sanitized.status_code == 200
+        assert invalid_secret not in sanitized.text
+
+        untraced = await client.post(
+            "/mcp",
+            json=modern_tool_request(3, "echo", {"value": "safe"}),
+            headers=modern_tool_headers("echo"),
+        )
+        assert untraced.status_code == 200
+
+    tool_records = [
+        record
+        for record in tracer.records
+        if record["name"] == "tools/call echo"
+    ]
+    assert tool_records == [
+        {
+            "name": "tools/call echo",
+            "parentTraceId": _TRACE_ID,
+            "parentSpanId": _PARENT_SPAN_ID,
+            "baggageCount": 2,
+            "redactedBaggageCount": 1,
+        },
+        {
+            "name": "tools/call echo",
+            "parentTraceId": _TRACE_ID,
+            "parentSpanId": _PARENT_SPAN_ID,
+            "baggageCount": 0,
+            "redactedBaggageCount": 0,
+        },
+        {
+            "name": "tools/call echo",
+            "parentTraceId": None,
+            "parentSpanId": None,
+            "baggageCount": 0,
+            "redactedBaggageCount": 0,
+        },
+    ]
+    log_text = caplog.text
+    assert "Ignoring invalid MCP trace metadata field tracestate" in log_text
+    assert "Ignoring invalid MCP trace metadata field baggage" in log_text
+    assert valid_secret not in log_text
+    assert invalid_secret not in log_text
+
+
[email protected]
+async def test_true_subprocess_stdio_propagates_and_sanitizes_trace_meta(
+    tmp_path: Path,
+):
+    server_script = Path(__file__).with_name("trace_context_server.py")
+    observations = tmp_path / "trace-observations.jsonl"
+    log_path = tmp_path / "trace-context.log"
+    server_params = StdioServerParameters(
+        command=sys.executable,
+        args=[str(server_script)],
+        env={
+            "DORIS_MCP_TRACE_OBSERVATIONS": str(observations),
+            "DORIS_MCP_TRACE_LOG": str(log_path),
+        },
+    )
+    valid_secret = "stdio-valid-baggage-secret"
+    invalid_secret = "stdio-invalid-baggage-secret"
+
+    async with Client(stdio_client(server_params)) as client:
+        traced = await client.call_tool(
+            "echo",
+            {},
+            meta=_trace_meta(
+                baggage=f"tenant=blue,secret={valid_secret}"
+            ),
+        )
+        assert traced.structured_content == {"ok": True}
+        traced_wire = json.dumps(
+            traced.model_dump(by_alias=True, mode="json"),
+            sort_keys=True,
+        )
+        assert _TRACE_ID not in traced_wire
+        assert valid_secret not in traced_wire
+
+        sanitized = await client.call_tool(
+            "echo",
+            {},
+            meta=_trace_meta(
+                baggage=f"secret={invalid_secret},missing-value",
+                tracestate=f"vendor=opaque,{invalid_secret}",
+            ),
+        )
+        assert sanitized.structured_content == {"ok": True}
+
+        untraced = await client.call_tool("echo", {})
+        assert untraced.structured_content == {"ok": True}
+
+    records = [
+        json.loads(line)
+        for line in observations.read_text(encoding="utf-8").splitlines()
+    ]
+    tool_records = [
+        record for record in records if record["name"] == "tools/call echo"
+    ]
+    assert tool_records == [
+        {
+            "name": "tools/call echo",
+            "parentTraceId": _TRACE_ID,
+            "parentSpanId": _PARENT_SPAN_ID,
+            "baggageCount": 2,
+            "redactedBaggageCount": 1,
+        },
+        {
+            "name": "tools/call echo",
+            "parentTraceId": _TRACE_ID,
+            "parentSpanId": _PARENT_SPAN_ID,
+            "baggageCount": 0,
+            "redactedBaggageCount": 0,
+        },
+        {
+            "name": "tools/call echo",
+            "parentTraceId": None,
+            "parentSpanId": None,
+            "baggageCount": 0,
+            "redactedBaggageCount": 0,
+        },
+    ]
+    observed_text = observations.read_text(encoding="utf-8")
+    log_text = log_path.read_text(encoding="utf-8")
+    assert valid_secret not in observed_text
+    assert invalid_secret not in observed_text
+    assert "Ignoring invalid MCP trace metadata field tracestate" in log_text
+    assert "Ignoring invalid MCP trace metadata field baggage" in log_text
+    assert valid_secret not in log_text
+    assert invalid_secret not in log_text
diff --git a/test/protocol/trace_context_server.py 
b/test/protocol/trace_context_server.py
new file mode 100644
index 0000000..ad5e768
--- /dev/null
+++ b/test/protocol/trace_context_server.py
@@ -0,0 +1,156 @@
+# 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.
+"""True-subprocess stdio fixture for MCP OpenTelemetry propagation."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import secrets
+from collections.abc import Generator
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+
+import mcp.shared._otel as mcp_otel
+from mcp.server.stdio import stdio_server
+from mcp.types import GetPromptResult, Prompt, Resource, Tool
+from opentelemetry.baggage import get_all
+from opentelemetry.trace import (
+    NonRecordingSpan,
+    SpanContext,
+    TraceFlags,
+    get_current_span,
+    use_span,
+)
+
+from doris_mcp_server import __version__
+from doris_mcp_server.protocol import create_doris_mcp_server
+
+
+class _ResourcesManager:
+    async def list_resources(self) -> list[Resource]:
+        return []
+
+    async def read_resource(self, uri: str) -> str:
+        return json.dumps({"uri": uri})
+
+
+class _ToolsManager:
+    async def list_tools(self) -> list[Tool]:
+        return [
+            Tool(
+                name="echo",
+                description="Return a fixed response without request 
metadata.",
+                input_schema={"type": "object", "properties": {}},
+            )
+        ]
+
+    async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
+        del name, arguments
+        return json.dumps({"ok": True})
+
+
+class _PromptsManager:
+    async def list_prompts(self) -> list[Prompt]:
+        return []
+
+    async def get_prompt(
+        self,
+        name: str,
+        arguments: dict[str, Any],
+    ) -> GetPromptResult:
+        del name, arguments
+        raise AssertionError("prompt fixture is not called")
+
+
+class _ObservationTracer:
+    """Small API-only tracer that records propagation without an SDK 
exporter."""
+
+    def __init__(self, output_path: Path) -> None:
+        self._output_path = output_path
+
+    @contextmanager
+    def start_as_current_span(
+        self,
+        name: str,
+        *,
+        context: Any = None,
+        attributes: dict[str, Any] | None = None,
+        **kwargs: Any,
+    ) -> Generator[NonRecordingSpan]:
+        del attributes, kwargs
+        parent = get_current_span(context).get_span_context()
+        trace_id = parent.trace_id if parent.is_valid else 
secrets.randbits(128) or 1
+        child_context = SpanContext(
+            trace_id=trace_id,
+            span_id=secrets.randbits(64) or 1,
+            is_remote=False,
+            trace_flags=parent.trace_flags if parent.is_valid else 
TraceFlags(0),
+            trace_state=parent.trace_state if parent.is_valid else None,
+        )
+        baggage = get_all(context=context)
+        record = {
+            "name": name,
+            "parentTraceId": (
+                f"{parent.trace_id:032x}" if parent.is_valid else None
+            ),
+            "parentSpanId": (
+                f"{parent.span_id:016x}" if parent.is_valid else None
+            ),
+            "baggageCount": len(baggage),
+            "redactedBaggageCount": sum(
+                value == "[REDACTED]" for value in baggage.values()
+            ),
+        }
+        with self._output_path.open("a", encoding="utf-8") as stream:
+            stream.write(json.dumps(record, sort_keys=True) + "\n")
+        span = NonRecordingSpan(child_context)
+        with use_span(span, end_on_exit=False):
+            yield span
+
+
+async def main() -> None:
+    observation_path = Path(os.environ["DORIS_MCP_TRACE_OBSERVATIONS"])
+    log_path = Path(os.environ["DORIS_MCP_TRACE_LOG"])
+    logging.basicConfig(
+        filename=log_path,
+        level=logging.WARNING,
+        force=True,
+    )
+    mcp_otel._tracer = _ObservationTracer(observation_path)
+    logger = logging.getLogger("doris_mcp_server.trace_context_fixture")
+    server = create_doris_mcp_server(
+        resources_manager=_ResourcesManager(),
+        tools_manager=_ToolsManager(),
+        prompts_manager=_PromptsManager(),
+        name="doris-mcp-trace-context-test",
+        version=__version__,
+        logger=logger,
+    )
+    async with stdio_server() as (read_stream, write_stream):
+        await server.run(
+            read_stream,
+            write_stream,
+            server.create_initialization_options(),
+        )
+
+
+if __name__ == "__main__":
+    asyncio.run(main())


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to