diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 81227c7e73..272b291705 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -8,6 +8,7 @@ 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.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 @@ 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 @@ class MCPSpecificApproval(TypedDict, total=False): _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 @@ def __init__( 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 @@ async def connect(self, *, reset: bool = False) -> None: 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 @@ async def connect(self, *, reset: bool = False) -> None: 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 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: } } - # 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 @@ async def get_prompt(self, prompt_name: str, **kwargs: Any) -> str: 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. diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index bcc4e1365d..714129e0f1 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -79,6 +79,7 @@ "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], diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 70aff972fe..318272dfd7 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2872,14 +2872,11 @@ class MockResponseFormat(BaseModel): @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 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: 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 diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index 1df1a449b6..cd5899cfed 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -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`) diff --git a/python/samples/02-agents/mcp/mcp_github_pat_azure_chat.py b/python/samples/02-agents/mcp/mcp_github_pat_azure_chat.py new file mode 100644 index 0000000000..d50168fc23 --- /dev/null +++ b/python/samples/02-agents/mcp/mcp_github_pat_azure_chat.py @@ -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()) diff --git a/python/samples/02-agents/mcp/mcp_github_pat.py b/python/samples/02-agents/mcp/mcp_github_pat_openai_responses.py similarity index 100% rename from python/samples/02-agents/mcp/mcp_github_pat.py rename to python/samples/02-agents/mcp/mcp_github_pat_openai_responses.py