mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2bd272f1 | ||
|
|
1306e5bce9 | ||
|
|
d3191ffd48 | ||
|
|
2fcbd9cec9 | ||
|
|
25e34a0b53 | ||
|
|
3afedd1a32 | ||
|
|
4ba1554f6f |
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable, Collection, Sequence
|
||||
from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore
|
||||
@@ -25,7 +26,7 @@ from mcp.client.websocket import websocket_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.session import RequestResponder
|
||||
from opentelemetry import propagate
|
||||
from opentelemetry import propagate, trace
|
||||
|
||||
from ._tools import FunctionTool
|
||||
from ._types import (
|
||||
@@ -33,6 +34,7 @@ from ._types import (
|
||||
Message,
|
||||
)
|
||||
from .exceptions import ToolException, ToolExecutionException
|
||||
from .observability import OtelAttr, get_mcp_call_span
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
@@ -61,6 +63,11 @@ logger = logging.getLogger(__name__)
|
||||
_MCP_REMOTE_NAME_KEY = "_mcp_remote_name"
|
||||
_MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name"
|
||||
|
||||
# Derive the JSON-RPC protocol version used by the MCP library from its type annotations.
|
||||
# mcp.types.JSONRPCRequest defines `jsonrpc: Literal["2.0"]`; extracting it here ensures
|
||||
# we always emit the version the library actually uses rather than a hardcoded magic string.
|
||||
_JSONRPC_PROTOCOL_VERSION: str = typing.get_args(types.JSONRPCRequest.model_fields["jsonrpc"].annotation)[0]
|
||||
|
||||
# region: Helpers
|
||||
|
||||
LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = {
|
||||
@@ -487,6 +494,7 @@ class MCPTool:
|
||||
self.is_connected: bool = False
|
||||
self._tools_loaded: bool = False
|
||||
self._prompts_loaded: bool = False
|
||||
self._mcp_protocol_version: str | int | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MCPTool(name={self.name}, description={self.description})"
|
||||
@@ -590,7 +598,8 @@ class MCPTool:
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
try:
|
||||
await session.initialize()
|
||||
init_result = await session.initialize()
|
||||
self._mcp_protocol_version = init_result.protocolVersion
|
||||
except Exception as ex:
|
||||
await self._safe_close_exit_stack()
|
||||
# Provide context about initialization failure
|
||||
@@ -605,7 +614,8 @@ class MCPTool:
|
||||
self.session = session
|
||||
elif self.session._request_id == 0: # type: ignore[reportPrivateUsage]
|
||||
# If the session is not initialized, we need to reinitialize it
|
||||
await self.session.initialize()
|
||||
init_result = await self.session.initialize()
|
||||
self._mcp_protocol_version = init_result.protocolVersion
|
||||
logger.debug("Connected to MCP server: %s", self.session)
|
||||
self.is_connected = True
|
||||
if self.load_tools_flag:
|
||||
@@ -927,50 +937,88 @@ class MCPTool:
|
||||
}
|
||||
}
|
||||
|
||||
# Inject OpenTelemetry trace context into MCP _meta for distributed tracing.
|
||||
otel_meta = _inject_otel_into_mcp_meta()
|
||||
|
||||
parser = self.parse_tool_results or _parse_tool_result_from_mcp
|
||||
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore
|
||||
if result.isError:
|
||||
parsed = parser(result)
|
||||
text = (
|
||||
"\n".join(c.text for c in parsed if c.type == "text" and c.text)
|
||||
if isinstance(parsed, list)
|
||||
else str(parsed)
|
||||
)
|
||||
raise ToolExecutionException(text or str(parsed))
|
||||
return parser(result)
|
||||
except ToolExecutionException:
|
||||
raise
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
# First attempt failed, try reconnecting
|
||||
logger.info("MCP connection closed unexpectedly. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
continue # Retry the operation
|
||||
except Exception as reconn_ex:
|
||||
span_attributes: dict[str, Any] = {
|
||||
OtelAttr.MCP_METHOD_NAME: "tools/call",
|
||||
OtelAttr.TOOL_NAME: tool_name,
|
||||
OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION,
|
||||
OtelAttr.JSONRPC_PROTOCOL_VERSION: _JSONRPC_PROTOCOL_VERSION,
|
||||
}
|
||||
if self._mcp_protocol_version:
|
||||
span_attributes[OtelAttr.MCP_PROTOCOL_VERSION] = self._mcp_protocol_version
|
||||
|
||||
with get_mcp_call_span(span_attributes) as span:
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
span_error_set = False
|
||||
for attempt in range(2):
|
||||
try:
|
||||
# Capture the JSON-RPC request ID before the call is made.
|
||||
# The MCP SDK stores the next request ID in the private `_request_id`
|
||||
# attribute; no public API is available. We use getattr with a default
|
||||
# so this degrades gracefully if the attribute is renamed in a future
|
||||
# version of the library.
|
||||
request_id = getattr(self.session, "_request_id", None) # type: ignore[union-attr]
|
||||
if request_id is not None:
|
||||
span.set_attribute(OtelAttr.JSONRPC_REQUEST_ID, str(request_id))
|
||||
|
||||
# Inject OpenTelemetry trace context into MCP _meta for distributed tracing.
|
||||
otel_meta = _inject_otel_into_mcp_meta()
|
||||
|
||||
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore
|
||||
if result.isError:
|
||||
parsed = parser(result)
|
||||
text = (
|
||||
"\n".join(c.text for c in parsed if c.type == "text" and c.text)
|
||||
if isinstance(parsed, list)
|
||||
else str(parsed)
|
||||
)
|
||||
error_msg = text or str(parsed)
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, "ToolError")
|
||||
span.set_status(trace.StatusCode.ERROR, error_msg)
|
||||
span_error_set = True
|
||||
raise ToolExecutionException(error_msg)
|
||||
return parser(result)
|
||||
except ToolExecutionException as ex:
|
||||
if not span_error_set:
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, type(ex).__name__)
|
||||
span.set_status(trace.StatusCode.ERROR, str(ex))
|
||||
raise
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
# First attempt failed, try reconnecting
|
||||
logger.info("MCP connection closed unexpectedly. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
continue # Retry the operation
|
||||
except Exception as reconn_ex:
|
||||
error_type = type(reconn_ex).__name__
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, error_type)
|
||||
span.set_status(trace.StatusCode.ERROR, str(reconn_ex))
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
else:
|
||||
# Second attempt also failed, give up
|
||||
logger.error(f"MCP connection closed unexpectedly after reconnection: {cl_ex}")
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, type(cl_ex).__name__)
|
||||
span.set_status(trace.StatusCode.ERROR, str(cl_ex))
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
else:
|
||||
# Second attempt also failed, give up
|
||||
logger.error(f"MCP connection closed unexpectedly after reconnection: {cl_ex}")
|
||||
raise ToolExecutionException(
|
||||
f"Failed to call tool '{tool_name}' - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.")
|
||||
f"Failed to call tool '{tool_name}' - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, str(mcp_exc.error.code))
|
||||
span.set_status(trace.StatusCode.ERROR, mcp_exc.error.message)
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, type(ex).__name__)
|
||||
span.set_status(trace.StatusCode.ERROR, str(ex))
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, "RetryExhausted")
|
||||
span.set_status(trace.StatusCode.ERROR, f"Failed to call tool '{tool_name}' after retries.")
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.")
|
||||
|
||||
async def get_prompt(self, prompt_name: str, **kwargs: Any) -> str:
|
||||
"""Call a prompt with the given arguments.
|
||||
@@ -995,35 +1043,60 @@ class MCPTool:
|
||||
|
||||
parser = self.parse_prompt_results or _parse_prompt_result_from_mcp
|
||||
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
for attempt in range(2):
|
||||
try:
|
||||
prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs) # type: ignore
|
||||
return parser(prompt_result)
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
# First attempt failed, try reconnecting
|
||||
logger.info("MCP connection closed unexpectedly. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
continue # Retry the operation
|
||||
except Exception as reconn_ex:
|
||||
span_attributes: dict[str, Any] = {
|
||||
OtelAttr.MCP_METHOD_NAME: "prompts/get",
|
||||
OtelAttr.PROMPT_NAME: prompt_name,
|
||||
OtelAttr.JSONRPC_PROTOCOL_VERSION: _JSONRPC_PROTOCOL_VERSION,
|
||||
}
|
||||
if self._mcp_protocol_version:
|
||||
span_attributes[OtelAttr.MCP_PROTOCOL_VERSION] = self._mcp_protocol_version
|
||||
|
||||
with get_mcp_call_span(span_attributes) as span:
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
for attempt in range(2):
|
||||
try:
|
||||
# Capture the JSON-RPC request ID before the call is made.
|
||||
# See call_tool for rationale on using getattr with a default.
|
||||
request_id = getattr(self.session, "_request_id", None) # type: ignore[union-attr]
|
||||
if request_id is not None:
|
||||
span.set_attribute(OtelAttr.JSONRPC_REQUEST_ID, str(request_id))
|
||||
|
||||
prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs) # type: ignore
|
||||
return parser(prompt_result)
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
# First attempt failed, try reconnecting
|
||||
logger.info("MCP connection closed unexpectedly. Reconnecting...")
|
||||
try:
|
||||
await self.connect(reset=True)
|
||||
continue # Retry the operation
|
||||
except Exception as reconn_ex:
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, type(reconn_ex).__name__)
|
||||
span.set_status(trace.StatusCode.ERROR, str(reconn_ex))
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
else:
|
||||
# Second attempt also failed, give up
|
||||
logger.error(f"MCP connection closed unexpectedly after reconnection: {cl_ex}")
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, type(cl_ex).__name__)
|
||||
span.set_status(trace.StatusCode.ERROR, str(cl_ex))
|
||||
raise ToolExecutionException(
|
||||
"Failed to reconnect to MCP server.",
|
||||
inner_exception=reconn_ex,
|
||||
) from reconn_ex
|
||||
else:
|
||||
# Second attempt also failed, give up
|
||||
logger.error(f"MCP connection closed unexpectedly after reconnection: {cl_ex}")
|
||||
raise ToolExecutionException(
|
||||
f"Failed to call prompt '{prompt_name}' - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex
|
||||
raise ToolExecutionException(f"Failed to get prompt '{prompt_name}' after retries.")
|
||||
f"Failed to call prompt '{prompt_name}' - connection lost.",
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, str(mcp_exc.error.code))
|
||||
span.set_status(trace.StatusCode.ERROR, mcp_exc.error.message)
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, type(ex).__name__)
|
||||
span.set_status(trace.StatusCode.ERROR, str(ex))
|
||||
raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex
|
||||
span.set_attribute(OtelAttr.ERROR_TYPE, "RetryExhausted")
|
||||
span.set_status(trace.StatusCode.ERROR, f"Failed to get prompt '{prompt_name}' after retries.")
|
||||
raise ToolExecutionException(f"Failed to get prompt '{prompt_name}' after retries.")
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager.
|
||||
|
||||
@@ -79,6 +79,7 @@ __all__ = [
|
||||
"create_metric_views",
|
||||
"create_resource",
|
||||
"enable_instrumentation",
|
||||
"get_mcp_call_span",
|
||||
"get_meter",
|
||||
"get_tracer",
|
||||
]
|
||||
@@ -272,6 +273,15 @@ class OtelAttr(str, Enum):
|
||||
AGENT_CREATE_OPERATION = "create_agent"
|
||||
AGENT_INVOKE_OPERATION = "invoke_agent"
|
||||
|
||||
# MCP-specific attributes
|
||||
# https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md
|
||||
MCP_METHOD_NAME = "mcp.method.name"
|
||||
MCP_PROTOCOL_VERSION = "mcp.protocol.version"
|
||||
MCP_SESSION_ID = "mcp.session.id"
|
||||
JSONRPC_REQUEST_ID = "jsonrpc.request.id"
|
||||
JSONRPC_PROTOCOL_VERSION = "jsonrpc.protocol.version"
|
||||
PROMPT_NAME = "gen_ai.prompt.name"
|
||||
|
||||
# Agent Framework specific attributes
|
||||
MEASUREMENT_FUNCTION_TAG_NAME = "agent_framework.function.name"
|
||||
MEASUREMENT_FUNCTION_INVOCATION_DURATION = "agent_framework.function.invocation.duration"
|
||||
@@ -1683,6 +1693,35 @@ def get_function_span(
|
||||
)
|
||||
|
||||
|
||||
def get_mcp_call_span(
|
||||
attributes: dict[str, Any],
|
||||
) -> _AgnosticContextManager[trace.Span]:
|
||||
"""Start a CLIENT span for an MCP call (tool or prompt).
|
||||
|
||||
Creates a span following the OTel 1.40.0 semantic conventions for MCP:
|
||||
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md
|
||||
|
||||
Args:
|
||||
attributes: The span attributes. Must contain ``mcp.method.name``.
|
||||
When the call is tool-related, ``gen_ai.tool.name`` is used in the span name.
|
||||
When the call is prompt-related, ``gen_ai.prompt.name`` is used instead.
|
||||
|
||||
Returns:
|
||||
A context manager that starts the span as the current span.
|
||||
"""
|
||||
method_name = attributes.get(OtelAttr.MCP_METHOD_NAME, "unknown")
|
||||
target = attributes.get(OtelAttr.TOOL_NAME) or attributes.get(OtelAttr.PROMPT_NAME)
|
||||
span_name = f"{method_name} {target}" if target else method_name
|
||||
return get_tracer().start_as_current_span(
|
||||
name=span_name,
|
||||
kind=trace.SpanKind.CLIENT,
|
||||
attributes=attributes,
|
||||
set_status_on_exception=False,
|
||||
end_on_exit=True,
|
||||
record_exception=False,
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _get_span(
|
||||
attributes: dict[str, Any],
|
||||
|
||||
@@ -2872,14 +2872,11 @@ async def test_mcp_tool_filters_framework_kwargs():
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"use_span,expect_traceparent",
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
],
|
||||
"use_parent_span",
|
||||
[True, False],
|
||||
)
|
||||
async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_exporter):
|
||||
"""call_tool propagates OTel trace context via meta only when a span is active."""
|
||||
async def test_mcp_tool_call_tool_otel_meta(use_parent_span, span_exporter):
|
||||
"""call_tool always propagates OTel trace context via meta because it creates its own CLIENT span."""
|
||||
from opentelemetry import trace
|
||||
|
||||
class TestServer(MCPTool):
|
||||
@@ -2911,25 +2908,172 @@ async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_e
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
|
||||
if use_span:
|
||||
if use_parent_span:
|
||||
tracer = trace.get_tracer("test")
|
||||
with tracer.start_as_current_span("test_span"):
|
||||
await server.functions[0].invoke(param="test_value")
|
||||
else:
|
||||
# Use an invalid span to ensure no trace context is injected;
|
||||
# call server.call_tool directly to bypass FunctionTool.invoke's own span.
|
||||
with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)):
|
||||
await server.call_tool("test_tool", param="test_value")
|
||||
# call_tool creates its own MCP CLIENT span, so traceparent is always injected.
|
||||
await server.call_tool("test_tool", param="test_value")
|
||||
|
||||
meta = server.session.call_tool.call_args.kwargs.get("meta")
|
||||
if expect_traceparent:
|
||||
# When a valid span is active, we expect some propagation fields to be injected,
|
||||
# but we do not assume any specific header name to keep this test propagator-agnostic.
|
||||
assert meta is not None
|
||||
assert isinstance(meta, dict)
|
||||
assert len(meta) > 0
|
||||
else:
|
||||
assert meta is None
|
||||
# call_tool always creates an OTel span, so traceparent is always propagated.
|
||||
assert meta is not None
|
||||
assert isinstance(meta, dict)
|
||||
assert len(meta) > 0
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region: OTel MCP spans
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_test_server_class():
|
||||
"""Factory for a minimal MCPTool subclass usable in span tests."""
|
||||
|
||||
class _TestServer(MCPTool):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="test_server", **kwargs)
|
||||
self._mock_session = Mock(spec=ClientSession)
|
||||
# MCP SDK stores _request_id as int; call_tool converts it to str for the span.
|
||||
self._mock_session._request_id = 42
|
||||
self._mock_session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="ok")])
|
||||
)
|
||||
self._mock_session.get_prompt = AsyncMock(
|
||||
return_value=types.GetPromptResult(
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text="hello"),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self._mock_session.list_tools = AsyncMock(return_value=types.ListToolsResult(tools=[]))
|
||||
|
||||
async def connect(self):
|
||||
self.session = self._mock_session
|
||||
self._tools_loaded = True
|
||||
self._prompts_loaded = True
|
||||
self.is_connected = True
|
||||
|
||||
def get_mcp_client(self):
|
||||
return None
|
||||
|
||||
return _TestServer
|
||||
|
||||
|
||||
async def test_call_tool_creates_mcp_client_span(span_exporter, mcp_test_server_class):
|
||||
"""call_tool emits a CLIENT span following OTel MCP conventions."""
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
server = mcp_test_server_class()
|
||||
async with server:
|
||||
await server.call_tool("my_tool", param="value")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.name == "tools/call my_tool"
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.attributes["mcp.method.name"] == "tools/call"
|
||||
assert span.attributes["gen_ai.tool.name"] == "my_tool"
|
||||
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
|
||||
assert span.attributes["jsonrpc.protocol.version"] == "2.0"
|
||||
assert span.attributes["jsonrpc.request.id"] == "42"
|
||||
|
||||
|
||||
async def test_call_tool_span_sets_error_on_mcp_error(span_exporter, mcp_test_server_class):
|
||||
"""call_tool sets ERROR status and error.type when McpError is raised."""
|
||||
from mcp.shared.exceptions import McpError
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
server = mcp_test_server_class()
|
||||
async with server:
|
||||
server._mock_session.call_tool = AsyncMock(
|
||||
side_effect=McpError(types.ErrorData(code=-32600, message="bad request"))
|
||||
)
|
||||
with pytest.raises(ToolExecutionException):
|
||||
await server.call_tool("my_tool")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.attributes["error.type"] == "-32600"
|
||||
|
||||
|
||||
async def test_call_tool_span_sets_error_on_tool_error_result(span_exporter, mcp_test_server_class):
|
||||
"""call_tool sets ERROR status when the tool result itself is an error."""
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
server = mcp_test_server_class()
|
||||
async with server:
|
||||
server._mock_session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(
|
||||
isError=True,
|
||||
content=[types.TextContent(type="text", text="tool error")],
|
||||
)
|
||||
)
|
||||
with pytest.raises(ToolExecutionException):
|
||||
await server.call_tool("my_tool")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.attributes["error.type"] == "ToolError"
|
||||
|
||||
|
||||
async def test_call_tool_span_includes_protocol_version(span_exporter, mcp_test_server_class):
|
||||
"""call_tool includes mcp.protocol.version when set."""
|
||||
server = mcp_test_server_class()
|
||||
async with server:
|
||||
server._mcp_protocol_version = "2025-06-18"
|
||||
await server.call_tool("my_tool")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert spans[0].attributes["mcp.protocol.version"] == "2025-06-18"
|
||||
|
||||
|
||||
async def test_get_prompt_creates_mcp_client_span(span_exporter, mcp_test_server_class):
|
||||
"""get_prompt emits a CLIENT span following OTel MCP conventions."""
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
server = mcp_test_server_class()
|
||||
async with server:
|
||||
await server.get_prompt("my_prompt", arg="value")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.name == "prompts/get my_prompt"
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.attributes["mcp.method.name"] == "prompts/get"
|
||||
assert span.attributes["gen_ai.prompt.name"] == "my_prompt"
|
||||
assert span.attributes["jsonrpc.protocol.version"] == "2.0"
|
||||
assert span.attributes["jsonrpc.request.id"] == "42"
|
||||
|
||||
|
||||
async def test_get_prompt_span_sets_error_on_exception(span_exporter, mcp_test_server_class):
|
||||
"""get_prompt sets ERROR status and error.type on exception."""
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
server = mcp_test_server_class()
|
||||
async with server:
|
||||
server._mock_session.get_prompt = AsyncMock(side_effect=RuntimeError("fail"))
|
||||
with pytest.raises(ToolExecutionException):
|
||||
await server.get_prompt("my_prompt")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.attributes["error.type"] == "RuntimeError"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -12,12 +12,20 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to
|
||||
|--------|------|-------------|
|
||||
| **Agent as MCP Server** | [`agent_as_mcp_server.py`](agent_as_mcp_server.py) | Shows how to expose an Agent Framework agent as an MCP server that other AI applications can connect to |
|
||||
| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers |
|
||||
| **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication |
|
||||
| **GitHub Integration with PAT (OpenAI Responses)** | [`mcp_github_pat_openai_responses.py`](mcp_github_pat_openai_responses.py) | Demonstrates connecting to GitHub's MCP server using PAT with OpenAI Responses Client |
|
||||
| **GitHub Integration with PAT (Azure OpenAI)** | [`mcp_github_pat_azure_chat.py`](mcp_github_pat_azure_chat.py) | Demonstrates connecting to GitHub's MCP server using PAT with Azure OpenAI Chat Client |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `OPENAI_API_KEY` environment variable
|
||||
- `OPENAI_RESPONSES_MODEL_ID` environment variable
|
||||
Each sample requires its own set of environment variables. See below for details.
|
||||
|
||||
For `mcp_github_pat.py`:
|
||||
For `mcp_github_pat_openai_responses.py`:
|
||||
- `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens)
|
||||
- `OPENAI_API_KEY` - Your OpenAI API key
|
||||
- `OPENAI_RESPONSES_MODEL_ID` - Your OpenAI model ID
|
||||
|
||||
For `mcp_github_pat_azure_chat.py`:
|
||||
- `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens)
|
||||
- `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint
|
||||
- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` - Your Azure OpenAI chat deployment name
|
||||
- Or use Azure CLI credential for authentication (run `az login`)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import MCPStreamableHTTPTool
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from httpx import AsyncClient
|
||||
|
||||
"""
|
||||
MCP GitHub Integration with Personal Access Token (PAT) using Azure OpenAI Chat Client
|
||||
|
||||
This example demonstrates how to connect to GitHub's remote MCP server using a Personal Access
|
||||
Token (PAT) for authentication with Azure OpenAI Chat Client. The agent can use GitHub operations
|
||||
like searching repositories, reading files, creating issues, and more depending on how you scope
|
||||
your token.
|
||||
|
||||
Prerequisites:
|
||||
1. A GitHub Personal Access Token with appropriate scopes
|
||||
- Create one at: https://github.com/settings/tokens
|
||||
- For read-only operations, you can use more restrictive scopes
|
||||
2. Environment variables:
|
||||
- GITHUB_PAT: Your GitHub Personal Access Token (required)
|
||||
- AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint (required)
|
||||
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: Your Azure OpenAI chat deployment name (required)
|
||||
- Or use Azure CLI credential for authentication (run `az login`)
|
||||
"""
|
||||
|
||||
|
||||
async def github_mcp_example() -> None:
|
||||
"""Example of using GitHub MCP server with PAT authentication and Azure OpenAI Chat Client."""
|
||||
# 1. Load environment variables from .env file if present
|
||||
load_dotenv()
|
||||
|
||||
# 2. Get configuration from environment
|
||||
github_pat = os.getenv("GITHUB_PAT")
|
||||
if not github_pat:
|
||||
raise ValueError(
|
||||
"GITHUB_PAT environment variable must be set. Create a token at https://github.com/settings/tokens"
|
||||
)
|
||||
|
||||
# 3. Create authentication headers with GitHub PAT
|
||||
auth_headers = {
|
||||
"Authorization": f"Bearer {github_pat}",
|
||||
}
|
||||
|
||||
# 4. Create Azure OpenAI Chat Client
|
||||
# For authentication, run `az login` command in terminal or replace AzureCliCredential
|
||||
# with your preferred authentication option (e.g., api_key parameter)
|
||||
client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
# 5. Create HTTP client with authentication headers and MCP tool for GitHub with PAT authentication.
|
||||
# The MCPStreamableHTTPTool manages the connection to the MCP server and makes its tools available.
|
||||
# The HTTP client is used to pass the authentication headers to the MCP server and is closed when
|
||||
# the context manager exits.
|
||||
async with AsyncClient(headers=auth_headers) as http_client, MCPStreamableHTTPTool(
|
||||
name="GitHub",
|
||||
description="GitHub MCP server for interacting with GitHub repositories, issues, and more",
|
||||
url="https://api.githubcopilot.com/mcp/",
|
||||
http_client=http_client, # Pass HTTP client with authentication headers
|
||||
approval_mode="never_require", # For sample brevity; use "always_require" in production
|
||||
) as github_mcp_tool:
|
||||
# 6. Create agent with the GitHub MCP tool
|
||||
agent = client.as_agent(
|
||||
instructions=(
|
||||
"You are a helpful assistant that can help users interact with GitHub. "
|
||||
"You can search for repositories, read file contents, check issues, and more. "
|
||||
"Always be clear about what operations you're performing."
|
||||
),
|
||||
tools=github_mcp_tool,
|
||||
)
|
||||
|
||||
# Example 1: Get authenticated user information
|
||||
query1 = "What is my GitHub username and tell me about my account?"
|
||||
print(f"\nUser: {query1}")
|
||||
result1 = await agent.run(query1)
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# Example 2: List my repositories
|
||||
query2 = "List all the repositories I own on GitHub"
|
||||
print(f"\nUser: {query2}")
|
||||
result2 = await agent.run(query2)
|
||||
print(f"Agent: {result2.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(github_mcp_example())
|
||||
Reference in New Issue
Block a user