blob: ad5e7688ab142388fb1d0518981de525c7b24d23 [file]
# 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())