Compare commits

...
6 changed files with 449 additions and 96 deletions
+145 -72
View File
@@ -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],
+164 -20
View File
@@ -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 -4
View File
@@ -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())