From 5b697518283d651a6587d0fdaaf08daa8e5c1a83 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 11:01:51 -0700 Subject: [PATCH 01/12] Adding factory methods for app-based graph clients --- .../hosting/core/authorization/connections.py | 8 +- .../hosting/msteams/_graph.py | 123 ++++++++++++++++-- .../hosting/msteams/_utils.py | 16 +++ .../hosting/msteams/teams_agent_extension.py | 38 +++++- .../hosting/msteams/teams_turn_context.py | 58 ++++++++- 5 files changed, 230 insertions(+), 13 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py index e11103e22..cf07767f7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py @@ -2,7 +2,9 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol +from typing import Protocol, overload + +from microsoft_agents.activity import Activity from .agent_auth_configuration import AgentAuthConfiguration from .access_token_provider_base import AccessTokenProviderBase @@ -27,7 +29,9 @@ def get_default_connection(self) -> AccessTokenProviderBase: @abstractmethod def get_token_provider( - self, claims_identity: ClaimsIdentity, service_url: str + self, + claims_identity: ClaimsIdentity, + service_url: str | None = None, ) -> AccessTokenProviderBase: """ Get the OAuth token provider for the agent. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 4d4b18aba..d83c0518c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -9,6 +9,7 @@ """ from typing import Any +from urllib.parse import urlparse from kiota_abstractions.request_information import RequestInformation from kiota_abstractions.authentication import AuthenticationProvider @@ -17,11 +18,15 @@ from microsoft_agents.hosting.core import ( AgentApplication, + Authorization, TurnContext, + AccessTokenProviderBase, ) +_DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" -class _SDKAuthenticationProvider(AuthenticationProvider): + +class _SDKUserAuthenticationProvider(AuthenticationProvider): """Kiota authentication provider backed by the agent's authorization. Acquires an access token for the current turn via @@ -31,7 +36,7 @@ class _SDKAuthenticationProvider(AuthenticationProvider): def __init__( self, - app: AgentApplication, + auth: Authorization, context: TurnContext, handler_name: str | None = None, ): @@ -41,7 +46,7 @@ def __init__( :param context: The current turn context. :param handler_name: The auth handler name used to acquire the token. """ - self._app = app + self._auth = auth self._context = context self._handler_name = handler_name @@ -59,14 +64,51 @@ async def authenticate_request( if additional_authentication_context is None: additional_authentication_context = {} - token_response = await self._app.auth.get_token( - self._context, self._handler_name - ) + token_response = await self._auth.get_token(self._context, self._handler_name) if token_response: request.headers["Authorization"] = f"Bearer {token_response.token}" -def _create_graph_service_client( +class _SDKAuthenticationProvider(AuthenticationProvider): + + def __init__( + self, + token_provider: AccessTokenProviderBase, + resource_url: str, + scopes: list[str], + ): + """Capture the context needed to resolve a token at request time. + + :param token_provider: The access token provider for the agent application. + :param resource_url: The resource URL for which to acquire the token. + :param scopes: The scopes for which to acquire the token. + """ + self._token_provider = token_provider + self._resource_url = resource_url + self._scopes = scopes + + async def authenticate_request( + self, + request: RequestInformation, + additional_authentication_context: dict[str, Any] | None = None, + ) -> None: + """Attach a bearer token to the outgoing Graph request. + + :param request: The request to authenticate. + :param additional_authentication_context: Optional Kiota authentication + context; unused but accepted to satisfy the provider interface. + """ + if additional_authentication_context is None: + additional_authentication_context = {} + + token = await self._token_provider.get_access_token( + self._resource_url, self._scopes + ) + if token: + request.headers["Authorization"] = f"Bearer {token}" + + +def _create_user_graph_service_client( app: AgentApplication, context: TurnContext, handler_name: str | None = None, @@ -81,6 +123,71 @@ def _create_graph_service_client( """ return GraphServiceClient( request_adapter=GraphRequestAdapter( - _SDKAuthenticationProvider(app, context, handler_name) + _SDKUserAuthenticationProvider(app.auth, context, handler_name) + ) + ) + + +def _create_app_graph_service_client( + token_provider: AccessTokenProviderBase, + graph_base_url: str, +) -> GraphServiceClient: + """Create a Graph client authenticated for the agent application. + + :param app: The agent application whose authorization issues tokens. + :param handler_name: Optional auth handler name used to acquire the token. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + url_parsed = urlparse(graph_base_url) + resource_url = f"{url_parsed.scheme}://{url_parsed.netloc}" + scopes = [f"{resource_url}/.default"] + return GraphServiceClient( + request_adapter=GraphRequestAdapter( + _SDKAuthenticationProvider(token_provider, resource_url, scopes) ) ) + + +def _common_get_app_graph_client( + app: AgentApplication, + context: TurnContext, + graph_base_url: str = "https://graph.microsoft.com/v1.0", +) -> GraphServiceClient: + """Get a Graph client authenticated for the agent application. + + :param app: The agent application whose authorization issues tokens. + :param context: The current turn context. + :param connection_name: Optional connection name to select a specific token provider. + :param graph_base_url: The base URL for the Graph API. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + if not context.identity: + raise ValueError("TurnContext.identity is required to get a Graph client.") + token_provider = app.connection_manager.get_token_provider( + context.identity, context.activity.service_url + ) + return _create_app_graph_service_client(token_provider, graph_base_url) + + +def _common_get_app_graph_client_for_connection( + app: AgentApplication, + connection_name: str | None = None, + graph_base_url: str = "https://graph.microsoft.com/v1.0", +) -> GraphServiceClient: + """Get a Graph client authenticated for the agent application. + + :param app: The agent application whose authorization issues tokens. + :param connection_name: Optional connection name to select a specific token provider. + :param graph_base_url: The base URL for the Graph API. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + token_provider: AccessTokenProviderBase + if not connection_name: + token_provider = app.connection_manager.get_default_connection() + else: + token_provider = app.connection_manager.get_connection(connection_name) + + return _create_app_graph_service_client(token_provider, graph_base_url) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index 570e1889e..0eb1b9d26 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -146,3 +146,19 @@ async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), ) ) + + +def _get_app_graph_client( + app: AgentApplication, + context: TurnContext, + graph_base_url: str, + connection_name: str | None = None, +) -> GraphServiceClient: + """Get a Graph client authenticated for the agent application. + + :param context: The current turn context. + :param graph_base_url: The base URL for the Graph API. + :param connection_name: Optional connection name to select a specific token provider. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 68f620541..8a6a10660 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -50,7 +50,13 @@ from .task_module import TaskModule from .team import Team -from ._graph import _create_graph_service_client +from ._graph import ( + _DEFAULT_GRAPH_BASE_URL, + _create_user_graph_service_client, + _common_get_app_graph_client, + _common_get_app_graph_client_for_connection, +) + from ._teams_api_client import ( _get_teams_api_client, _set_teams_api_client, @@ -306,4 +312,32 @@ def get_graph_client( :param handler_name: The name of the handler. :return: The Graph Service client. """ - return _create_graph_service_client(self._app, context, handler_name) + return _create_user_graph_service_client(self._app, context, handler_name) + + def get_app_graph_client( + self, context: TurnContext, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL + ) -> GraphServiceClient: + """Get the Graph Service client for the agent application. + + :param context: The turn context. + :param connection_name: The name of the connection to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + :return: The Graph Service client. + """ + return _common_get_app_graph_client( + self._app, context, graph_base_url=graph_base_url + ) + + def get_app_graph_client_for_connection( + self, connection_name: str, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL + ) -> GraphServiceClient: + """Get the Graph Service client for the agent application using a specific connection. + + :param context: The turn context. + :param connection_name: The name of the connection to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + :return: The Graph Service client. + """ + return _common_get_app_graph_client_for_connection( + self._app, connection_name=connection_name, graph_base_url=graph_base_url + ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 9685bfb6c..7693f75d2 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -7,6 +7,8 @@ from typing import cast +from msgraph import GraphServiceClient + from microsoft_teams.api import ApiClient from microsoft_agents.activity import ( @@ -15,8 +17,18 @@ ActivityTreatmentTypes, ResourceResponse, ) -from microsoft_agents.hosting.core import AgentApplication, TurnContext +from microsoft_agents.hosting.core import ( + AccessTokenProviderBase, + AgentApplication, + TurnContext, +) +from ._graph import ( + _DEFAULT_GRAPH_BASE_URL, + _create_user_graph_service_client, + _common_get_app_graph_client, + _common_get_app_graph_client_for_connection, +) from ._teams_api_client import _get_teams_api_client, _set_teams_api_client from .teams_activity import TeamsActivity @@ -122,3 +134,47 @@ async def send_targeted_activities( for activity in activities: TeamsTurnContext._make_targeted_activity(activity) return await self.send_activities(activities) + + def get_graph_client(self, hanlder_name: str | None = None) -> GraphServiceClient: + """ + Get a Graph client for the current turn context. + + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + return _create_user_graph_service_client(self._app, self, hanlder_name) + + def get_app_graph_client( + self, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, + ) -> GraphServiceClient: + """ + Get a Graph client for the current turn context. + + :param connection_name: Optional connection name to select a specific token provider. + :param graph_base_url: The base URL for the Microsoft Graph API. + + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + return _common_get_app_graph_client( + self._app, self, graph_base_url=graph_base_url + ) + + def get_app_graph_client_for_connection( + self, + connection_name: str, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, + ) -> GraphServiceClient: + """ + Get a Graph client for the current turn context using a specific connection. + + :param connection_name: The name of the connection to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + return _common_get_app_graph_client_for_connection( + self._app, connection_name, graph_base_url=graph_base_url + ) From 39777ff93c70a4adc75bc3d68f55808a5b0fa062 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 11:04:03 -0700 Subject: [PATCH 02/12] Undoing changes --- .../hosting/core/authorization/connections.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py index cf07767f7..c109eeca1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py @@ -2,9 +2,7 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol, overload - -from microsoft_agents.activity import Activity +from typing import Protocol from .agent_auth_configuration import AgentAuthConfiguration from .access_token_provider_base import AccessTokenProviderBase @@ -31,7 +29,7 @@ def get_default_connection(self) -> AccessTokenProviderBase: def get_token_provider( self, claims_identity: ClaimsIdentity, - service_url: str | None = None, + service_url: str, ) -> AccessTokenProviderBase: """ Get the OAuth token provider for the agent. From 8df48c5f2ae287fb1bb206f39d683292bbc62225 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Jul 2026 11:04:31 -0700 Subject: [PATCH 03/12] Undoing formatting --- .../hosting/core/authorization/connections.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py index c109eeca1..e11103e22 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py @@ -27,9 +27,7 @@ def get_default_connection(self) -> AccessTokenProviderBase: @abstractmethod def get_token_provider( - self, - claims_identity: ClaimsIdentity, - service_url: str, + self, claims_identity: ClaimsIdentity, service_url: str ) -> AccessTokenProviderBase: """ Get the OAuth token provider for the agent. From 24f81ede32200a8310e88cd28dc75fd54b2e1163 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 11:33:36 -0700 Subject: [PATCH 04/12] Fixing issues with graph client acquisition --- .../hosting/msteams/_graph.py | 14 +- .../hosting/msteams/_utils.py | 16 -- .../hosting/msteams/teams_turn_context.py | 1 - .../conversation-agent/src/agent.py | 1 - .../hosting_msteams/graph-clients/README.md | 32 ++++ .../graph-clients/env.TEMPLATE | 8 + .../graph-clients/pyproject.toml | 20 +++ .../graph-clients/src/__init__.py | 2 + .../graph-clients/src/agent.py | 150 ++++++++++++++++++ .../hosting_msteams/graph-clients/src/main.py | 12 ++ .../graph-clients/src/start_server.py | 32 ++++ .../message-extensions/src/agent.py | 1 - .../hosting_msteams/task-modules/src/agent.py | 1 - 13 files changed, 263 insertions(+), 27 deletions(-) create mode 100644 test_samples/hosting_msteams/graph-clients/README.md create mode 100644 test_samples/hosting_msteams/graph-clients/env.TEMPLATE create mode 100644 test_samples/hosting_msteams/graph-clients/pyproject.toml create mode 100644 test_samples/hosting_msteams/graph-clients/src/__init__.py create mode 100644 test_samples/hosting_msteams/graph-clients/src/agent.py create mode 100644 test_samples/hosting_msteams/graph-clients/src/main.py create mode 100644 test_samples/hosting_msteams/graph-clients/src/start_server.py diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index d83c0518c..a6cb313c5 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -65,8 +65,8 @@ async def authenticate_request( additional_authentication_context = {} token_response = await self._auth.get_token(self._context, self._handler_name) - if token_response: - request.headers["Authorization"] = f"Bearer {token_response.token}" + if token_response and token_response.token: + request.headers.add("Authorization", f"Bearer {token_response.token}") class _SDKAuthenticationProvider(AuthenticationProvider): @@ -105,7 +105,7 @@ async def authenticate_request( self._resource_url, self._scopes ) if token: - request.headers["Authorization"] = f"Bearer {token}" + request.headers.add("Authorization", f"Bearer {token}") def _create_user_graph_service_client( @@ -142,11 +142,11 @@ def _create_app_graph_service_client( url_parsed = urlparse(graph_base_url) resource_url = f"{url_parsed.scheme}://{url_parsed.netloc}" scopes = [f"{resource_url}/.default"] - return GraphServiceClient( - request_adapter=GraphRequestAdapter( - _SDKAuthenticationProvider(token_provider, resource_url, scopes) - ) + request_adapter = GraphRequestAdapter( + _SDKAuthenticationProvider(token_provider, resource_url, scopes) ) + request_adapter.base_url = graph_base_url.rstrip("/") + "/" + return GraphServiceClient(request_adapter=request_adapter) def _common_get_app_graph_client( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index 0eb1b9d26..570e1889e 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -146,19 +146,3 @@ async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), ) ) - - -def _get_app_graph_client( - app: AgentApplication, - context: TurnContext, - graph_base_url: str, - connection_name: str | None = None, -) -> GraphServiceClient: - """Get a Graph client authenticated for the agent application. - - :param context: The current turn context. - :param graph_base_url: The base URL for the Graph API. - :param connection_name: Optional connection name to select a specific token provider. - :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. - """ diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 7693f75d2..85b08683d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -18,7 +18,6 @@ ResourceResponse, ) from microsoft_agents.hosting.core import ( - AccessTokenProviderBase, AgentApplication, TurnContext, ) diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index 119dc1133..87fe6cfad 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -51,7 +51,6 @@ from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() diff --git a/test_samples/hosting_msteams/graph-clients/README.md b/test_samples/hosting_msteams/graph-clients/README.md new file mode 100644 index 000000000..7e120230d --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/README.md @@ -0,0 +1,32 @@ +# Teams Graph Clients Sample + +Demonstrates creating and using Microsoft Graph clients from Teams route +handlers: + +| Command | Graph client | Endpoint | +|---------|--------------|----------| +| `/appgraph apps` | app-only client | `GET /applications?$top=1&$select=id,appId,displayName` | +| `/usergraph me` | delegated user client | `GET /me?$select=id,displayName,userPrincipalName,mail` | +| `/signout` | delegated user auth handler | signs out the `GRAPH` handler | + +## Permissions + +The app-only command uses the service connection credentials and requires +Microsoft Graph application permissions such as `Application.Read.All` with +admin consent. + +The user command uses the configured Azure Bot OAuth connection and requires +delegated Microsoft Graph permissions such as `User.Read`. + +## Running + +1. Copy `env.TEMPLATE` to `.env`. +2. Fill in your bot/app credentials and configure the `GRAPH` OAuth connection. +3. Start the sample: + + ```powershell + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. Expose it with a +dev tunnel and side-load the app manifest into Teams. diff --git a/test_samples/hosting_msteams/graph-clients/env.TEMPLATE b/test_samples/hosting_msteams/graph-clients/env.TEMPLATE new file mode 100644 index 000000000..2f6bed103 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/env.TEMPLATE @@ -0,0 +1,8 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__OBOCONNECTIONNAME=SERVICE_CONNECTION +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/test_samples/hosting_msteams/graph-clients/pyproject.toml b/test_samples/hosting_msteams/graph-clients/pyproject.toml new file mode 100644 index 000000000..483a02db5 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "conversation-agent" +version = "0.1.0" +description = "Teams Conversation Agent sample — demonstrates channel/team lifecycle, member events, and message commands" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-msteams", + "python-dotenv", + "aiohttp", +] diff --git a/test_samples/hosting_msteams/graph-clients/src/__init__.py b/test_samples/hosting_msteams/graph-clients/src/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/test_samples/hosting_msteams/graph-clients/src/agent.py b/test_samples/hosting_msteams/graph-clients/src/agent.py new file mode 100644 index 000000000..87b7748b8 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/agent.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams sample that creates and uses app-only and delegated Graph clients.""" + +import logging +from os import environ +from xml.etree import ElementTree + +from aiohttp import ClientSession +from dotenv import load_dotenv +from kiota_abstractions.method import Method +from kiota_abstractions.request_information import RequestInformation + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + TurnState, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + + +def _none_if_empty(value: str | None) -> str | None: + return value if value else None + + +@teams.message("/appgraph metadata") +async def on_app_graph_metadata(context: TeamsTurnContext, state: TurnState) -> None: + """Create an app-only Graph client and read the Graph metadata document.""" + graph = context.get_app_graph_client() + request_info = RequestInformation() + request_info.http_method = Method.GET + request_info.url = "https://graph.microsoft.com/v1.0/$metadata" + native_request = await graph.request_adapter.convert_to_native_async(request_info) + + async with ClientSession() as session: + async with session.request( + native_request.method, + str(native_request.url), + headers=dict(native_request.headers), + ) as response: + response.raise_for_status() + metadata = await response.text() + + root = ElementTree.fromstring(metadata) + entity_types = root.findall( + ".//{http://docs.oasis-open.org/odata/ns/edm}EntityType" + ) + await context.send_activity( + "App Graph client returned Microsoft Graph metadata:\n\n" + f"- Metadata document size: {len(metadata):,} characters\n" + f"- Entity types described: {len(entity_types)}" + ) + + +@teams.message("/appgraph apps") +async def on_app_graph_apps(context: TeamsTurnContext, state: TurnState) -> None: + """Create an app-only Graph client and read one application registration.""" + graph = context.get_app_graph_client() + query = graph.applications.ApplicationsRequestBuilderGetQueryParameters( + top=1, + select=["id", "appId", "displayName"], + ) + config_cls = graph.applications.ApplicationsRequestBuilderGetRequestConfiguration + request_config = config_cls(query_parameters=query) + response = await graph.applications.get(request_config) + applications = response.value if response and response.value else [] + + if not applications: + await context.send_activity("No application registrations were returned.") + return + + app = applications[0] + display_name = _none_if_empty(app.display_name) or "(no display name)" + await context.send_activity( + "App Graph client returned an application:\n\n" + f"- Display name: {display_name}\n" + f"- App ID: {_none_if_empty(app.app_id) or '(none)'}\n" + f"- Object ID: {_none_if_empty(app.id) or '(none)'}" + ) + + +@teams.message("/usergraph me", auth_handlers=["GRAPH"]) +async def on_user_graph_me(context: TeamsTurnContext, state: TurnState) -> None: + """Create a delegated user Graph client and read the signed-in user.""" + graph = context.get_graph_client("GRAPH") + query = graph.me.UserItemRequestBuilderGetQueryParameters( + select=["id", "displayName", "userPrincipalName", "mail"], + ) + request_config = graph.me.UserItemRequestBuilderGetRequestConfiguration( + query_parameters=query + ) + user = await graph.me.get(request_config) + + if not user: + await context.send_activity("Microsoft Graph did not return a user.") + return + + display_name = _none_if_empty(user.display_name) or "(no display name)" + user_principal_name = _none_if_empty(user.user_principal_name) or "(no UPN)" + mail = _none_if_empty(user.mail) or "(no mail)" + await context.send_activity( + "User Graph client returned the signed-in user:\n\n" + f"- Display name: {display_name}\n" + f"- UPN: {user_principal_name}\n" + f"- Mail: {mail}\n" + f"- Object ID: {_none_if_empty(user.id) or '(none)'}" + ) + + +@teams.message("/signout") +async def on_sign_out(context: TeamsTurnContext, state: TurnState) -> None: + await AGENT_APP.auth.sign_out(context, "GRAPH") + await context.send_activity(f"Signed out of GRAPH.") + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity( + "Graph clients sample commands:\n\n" + "- `/appgraph metadata` - app-only Graph client call to /$metadata\n" + "- `/appgraph apps` - app-only Graph client call to /applications\n" + "- `/usergraph me` - delegated user Graph client call to /me\n" + "- `/signout` - sign out of the delegated Graph auth handler" + ) diff --git a/test_samples/hosting_msteams/graph-clients/src/main.py b/test_samples/hosting_msteams/graph-clients/src/main.py new file mode 100644 index 000000000..530d25744 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/main.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/hosting_msteams/graph-clients/src/start_server.py b/test_samples/hosting_msteams/graph-clients/src/start_server.py new file mode 100644 index 000000000..97792f47d --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/start_server.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py index 2d7f9c33f..1bb95ce69 100644 --- a/test_samples/hosting_msteams/message-extensions/src/agent.py +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -44,7 +44,6 @@ from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() diff --git a/test_samples/hosting_msteams/task-modules/src/agent.py b/test_samples/hosting_msteams/task-modules/src/agent.py index c4288035a..4e24e0435 100644 --- a/test_samples/hosting_msteams/task-modules/src/agent.py +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -35,7 +35,6 @@ from .card_loader import load_card_json -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() From 98031eb8ffb506cd820fc175df70b2730595e394 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 12:00:54 -0700 Subject: [PATCH 05/12] Removing route --- .../graph-clients/src/agent.py | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/test_samples/hosting_msteams/graph-clients/src/agent.py b/test_samples/hosting_msteams/graph-clients/src/agent.py index 87b7748b8..31fd47df5 100644 --- a/test_samples/hosting_msteams/graph-clients/src/agent.py +++ b/test_samples/hosting_msteams/graph-clients/src/agent.py @@ -5,12 +5,8 @@ import logging from os import environ -from xml.etree import ElementTree -from aiohttp import ClientSession from dotenv import load_dotenv -from kiota_abstractions.method import Method -from kiota_abstractions.request_information import RequestInformation from microsoft_agents.activity import load_configuration_from_env from microsoft_agents.authentication.msal import MsalConnectionManager @@ -49,35 +45,6 @@ def _none_if_empty(value: str | None) -> str | None: return value if value else None -@teams.message("/appgraph metadata") -async def on_app_graph_metadata(context: TeamsTurnContext, state: TurnState) -> None: - """Create an app-only Graph client and read the Graph metadata document.""" - graph = context.get_app_graph_client() - request_info = RequestInformation() - request_info.http_method = Method.GET - request_info.url = "https://graph.microsoft.com/v1.0/$metadata" - native_request = await graph.request_adapter.convert_to_native_async(request_info) - - async with ClientSession() as session: - async with session.request( - native_request.method, - str(native_request.url), - headers=dict(native_request.headers), - ) as response: - response.raise_for_status() - metadata = await response.text() - - root = ElementTree.fromstring(metadata) - entity_types = root.findall( - ".//{http://docs.oasis-open.org/odata/ns/edm}EntityType" - ) - await context.send_activity( - "App Graph client returned Microsoft Graph metadata:\n\n" - f"- Metadata document size: {len(metadata):,} characters\n" - f"- Entity types described: {len(entity_types)}" - ) - - @teams.message("/appgraph apps") async def on_app_graph_apps(context: TeamsTurnContext, state: TurnState) -> None: """Create an app-only Graph client and read one application registration.""" @@ -143,7 +110,6 @@ async def on_sign_out(context: TeamsTurnContext, state: TurnState) -> None: async def on_message(context: TeamsTurnContext, state: TurnState) -> None: await context.send_activity( "Graph clients sample commands:\n\n" - "- `/appgraph metadata` - app-only Graph client call to /$metadata\n" "- `/appgraph apps` - app-only Graph client call to /applications\n" "- `/usergraph me` - delegated user Graph client call to /me\n" "- `/signout` - sign out of the delegated Graph auth handler" From 01f382b73e445e21570ada90c25b889c2d7ccf60 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 12:04:55 -0700 Subject: [PATCH 06/12] Addressing PR feedback --- .../microsoft_agents/hosting/msteams/_graph.py | 9 ++++----- .../hosting_msteams/graph-clients/pyproject.toml | 4 ++-- test_samples/hosting_msteams/graph-clients/src/agent.py | 1 - 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index a6cb313c5..0ad5b0cfb 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -134,10 +134,10 @@ def _create_app_graph_service_client( ) -> GraphServiceClient: """Create a Graph client authenticated for the agent application. - :param app: The agent application whose authorization issues tokens. - :param handler_name: Optional auth handler name used to acquire the token. + :param token_provider: The access token provider for the agent application. + :param graph_base_url: The base URL for the Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the token provider. """ url_parsed = urlparse(graph_base_url) resource_url = f"{url_parsed.scheme}://{url_parsed.netloc}" @@ -158,7 +158,6 @@ def _common_get_app_graph_client( :param app: The agent application whose authorization issues tokens. :param context: The current turn context. - :param connection_name: Optional connection name to select a specific token provider. :param graph_base_url: The base URL for the Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via the agent's authorization. @@ -182,7 +181,7 @@ def _common_get_app_graph_client_for_connection( :param connection_name: Optional connection name to select a specific token provider. :param graph_base_url: The base URL for the Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the token provider from the connection. """ token_provider: AccessTokenProviderBase if not connection_name: diff --git a/test_samples/hosting_msteams/graph-clients/pyproject.toml b/test_samples/hosting_msteams/graph-clients/pyproject.toml index 483a02db5..53387405c 100644 --- a/test_samples/hosting_msteams/graph-clients/pyproject.toml +++ b/test_samples/hosting_msteams/graph-clients/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] -name = "conversation-agent" +name = "graph-clients" version = "0.1.0" -description = "Teams Conversation Agent sample — demonstrates channel/team lifecycle, member events, and message commands" +description = "Teams Graph clients sample — demonstrates app-only and delegated Microsoft Graph clients" authors = [{name = "Microsoft Corporation"}] license = "MIT" requires-python = ">=3.11" diff --git a/test_samples/hosting_msteams/graph-clients/src/agent.py b/test_samples/hosting_msteams/graph-clients/src/agent.py index 31fd47df5..ab07b02fe 100644 --- a/test_samples/hosting_msteams/graph-clients/src/agent.py +++ b/test_samples/hosting_msteams/graph-clients/src/agent.py @@ -20,7 +20,6 @@ from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() From c645b123bb2ea7f89931cbb3544ba9c5c1955765 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 12:21:59 -0700 Subject: [PATCH 07/12] Improving interfaces --- .../microsoft_agents/hosting/msteams/_graph.py | 9 +++++---- .../hosting/msteams/teams_agent_extension.py | 12 ++++++++---- .../hosting/msteams/teams_turn_context.py | 14 +++++++++++--- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 0ad5b0cfb..fafacaecc 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -112,6 +112,7 @@ def _create_user_graph_service_client( app: AgentApplication, context: TurnContext, handler_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, ) -> GraphServiceClient: """Create a Graph client authenticated for the current turn. @@ -121,11 +122,11 @@ def _create_user_graph_service_client( :return: A :class:`GraphServiceClient` that authenticates each request via the agent's authorization. """ - return GraphServiceClient( - request_adapter=GraphRequestAdapter( - _SDKUserAuthenticationProvider(app.auth, context, handler_name) - ) + adapter = GraphRequestAdapter( + _SDKUserAuthenticationProvider(app.auth, context, handler_name) ) + adapter.base_url = graph_base_url.rstrip("/") + "/" + return GraphServiceClient(request_adapter=adapter) def _create_app_graph_service_client( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 8a6a10660..3070843b7 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -304,15 +304,21 @@ def get_teams_api_client(self, context: TurnContext) -> ApiClient: return _get_teams_api_client(context) def get_graph_client( - self, context: TurnContext, handler_name: str | None = None + self, + context: TurnContext, + handler_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, ) -> GraphServiceClient: """Get the Graph Service client. :param context: The turn context. :param handler_name: The name of the handler. + :param graph_base_url: The base URL for the Microsoft Graph API. :return: The Graph Service client. """ - return _create_user_graph_service_client(self._app, context, handler_name) + return _create_user_graph_service_client( + self._app, context, handler_name, graph_base_url=graph_base_url + ) def get_app_graph_client( self, context: TurnContext, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL @@ -320,7 +326,6 @@ def get_app_graph_client( """Get the Graph Service client for the agent application. :param context: The turn context. - :param connection_name: The name of the connection to use for authentication. :param graph_base_url: The base URL for the Microsoft Graph API. :return: The Graph Service client. """ @@ -333,7 +338,6 @@ def get_app_graph_client_for_connection( ) -> GraphServiceClient: """Get the Graph Service client for the agent application using a specific connection. - :param context: The turn context. :param connection_name: The name of the connection to use for authentication. :param graph_base_url: The base URL for the Microsoft Graph API. :return: The Graph Service client. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 85b08683d..5e162ad3b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -134,14 +134,23 @@ async def send_targeted_activities( TeamsTurnContext._make_targeted_activity(activity) return await self.send_activities(activities) - def get_graph_client(self, hanlder_name: str | None = None) -> GraphServiceClient: + def get_graph_client( + self, + handler_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, + ) -> GraphServiceClient: """ Get a Graph client for the current turn context. + :param handler_name: Optional name of the handler to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + :return: A :class:`GraphServiceClient` that authenticates each request via the agent's authorization. """ - return _create_user_graph_service_client(self._app, self, hanlder_name) + return _create_user_graph_service_client( + self._app, self, handler_name, graph_base_url=graph_base_url + ) def get_app_graph_client( self, @@ -150,7 +159,6 @@ def get_app_graph_client( """ Get a Graph client for the current turn context. - :param connection_name: Optional connection name to select a specific token provider. :param graph_base_url: The base URL for the Microsoft Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via From 26a817ab13a28b417d13f16b2f4b2f65ce16fcdd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 12:24:22 -0700 Subject: [PATCH 08/12] Fixing docstrings --- .../microsoft_agents/hosting/msteams/_graph.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index fafacaecc..7159b445d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -42,9 +42,9 @@ def __init__( ): """Capture the context needed to resolve a token at request time. - :param app: The agent application whose authorization issues tokens. + :param auth: The agent application's authorization. :param context: The current turn context. - :param handler_name: The auth handler name used to acquire the token. + :param handler_name: Optional name of the handler to use for authentication. """ self._auth = auth self._context = context @@ -153,7 +153,7 @@ def _create_app_graph_service_client( def _common_get_app_graph_client( app: AgentApplication, context: TurnContext, - graph_base_url: str = "https://graph.microsoft.com/v1.0", + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, ) -> GraphServiceClient: """Get a Graph client authenticated for the agent application. @@ -174,7 +174,7 @@ def _common_get_app_graph_client( def _common_get_app_graph_client_for_connection( app: AgentApplication, connection_name: str | None = None, - graph_base_url: str = "https://graph.microsoft.com/v1.0", + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, ) -> GraphServiceClient: """Get a Graph client authenticated for the agent application. From 8d3bf2f9207da3b94c556d29901e8f6471db9345 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 12:30:34 -0700 Subject: [PATCH 09/12] Adding graph client helper tests --- tests/hosting_msteams/test_graph_clients.py | 164 ++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/hosting_msteams/test_graph_clients.py diff --git a/tests/hosting_msteams/test_graph_clients.py b/tests/hosting_msteams/test_graph_clients.py new file mode 100644 index 000000000..024a76f2e --- /dev/null +++ b/tests/hosting_msteams/test_graph_clients.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for Microsoft Graph client creation helpers in Teams hosting.""" + +from types import SimpleNamespace + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from kiota_abstractions.method import Method + from kiota_abstractions.request_information import RequestInformation + + from microsoft_agents.activity import TokenResponse + from microsoft_agents.hosting.msteams._graph import ( + _common_get_app_graph_client, + _common_get_app_graph_client_for_connection, + _create_app_graph_service_client, + _create_user_graph_service_client, + ) + + +class _RecordingAuthorization: + def __init__(self): + self.calls = [] + + async def get_token(self, context, handler_name): + self.calls.append((context, handler_name)) + return TokenResponse(token="delegated-token") + + +class _RecordingTokenProvider: + def __init__(self): + self.calls = [] + + async def get_access_token( + self, resource_url: str, scopes: list[str], force_refresh: bool = False + ) -> str: + self.calls.append((resource_url, scopes, force_refresh)) + return "app-token" + + +class _RecordingConnectionManager: + def __init__(self): + self.default_provider = _RecordingTokenProvider() + self.named_provider = _RecordingTokenProvider() + self.turn_provider = _RecordingTokenProvider() + self.calls = [] + + def get_token_provider(self, identity, service_url): + self.calls.append(("get_token_provider", identity, service_url)) + return self.turn_provider + + def get_connection(self, connection_name): + self.calls.append(("get_connection", connection_name)) + return self.named_provider + + def get_default_connection(self): + self.calls.append(("get_default_connection",)) + return self.default_provider + + +@pytest.mark.asyncio +async def test_delegated_graph_client_gets_token_from_authorization_handler(): + authorization = _RecordingAuthorization() + context = SimpleNamespace() + app = SimpleNamespace(auth=authorization) + graph = _create_user_graph_service_client(app, context, "GRAPH") + request = RequestInformation() + request.http_method = Method.GET + request.url = "https://graph.microsoft.com/v1.0/me" + + native_request = await graph.request_adapter.convert_to_native_async(request) + + assert authorization.calls == [(context, "GRAPH")] + assert "authorization" in native_request.headers + + +def test_delegated_graph_client_uses_custom_graph_base_url(): + authorization = _RecordingAuthorization() + context = SimpleNamespace() + app = SimpleNamespace(auth=authorization) + graph = _create_user_graph_service_client( + app, + context, + graph_base_url="https://graph.microsoft.us/v1.0", + ) + + assert graph.request_adapter.base_url == "https://graph.microsoft.us/v1.0/" + + +@pytest.mark.asyncio +async def test_app_graph_client_uses_default_scope_for_custom_graph_cloud(): + token_provider = _RecordingTokenProvider() + graph = _create_app_graph_service_client( + token_provider, + "https://graph.microsoft.us/v1.0", + ) + request = RequestInformation() + request.http_method = Method.GET + request.url = "https://graph.microsoft.us/v1.0/applications" + + native_request = await graph.request_adapter.convert_to_native_async(request) + + assert token_provider.calls == [ + ( + "https://graph.microsoft.us", + ["https://graph.microsoft.us/.default"], + False, + ) + ] + assert "authorization" in native_request.headers + + +def test_context_app_graph_client_resolves_connection_from_turn_identity_and_service_url(): + connection_manager = _RecordingConnectionManager() + app = SimpleNamespace(connection_manager=connection_manager) + identity = SimpleNamespace() + context = SimpleNamespace( + identity=identity, + activity=SimpleNamespace(service_url="https://smba.trafficmanager.net/teams/"), + ) + + graph = _common_get_app_graph_client(app, context) + + assert graph.request_adapter.base_url == "https://graph.microsoft.com/v1.0/" + assert connection_manager.calls == [ + ( + "get_token_provider", + identity, + "https://smba.trafficmanager.net/teams/", + ) + ] + + +def test_named_app_graph_client_uses_named_connection(): + connection_manager = _RecordingConnectionManager() + app = SimpleNamespace(connection_manager=connection_manager) + + graph = _common_get_app_graph_client_for_connection(app, "SERVICE_CONNECTION_2") + + assert graph.request_adapter.base_url == "https://graph.microsoft.com/v1.0/" + assert connection_manager.calls == [ + ("get_connection", "SERVICE_CONNECTION_2"), + ] + + +def test_default_app_graph_client_uses_default_connection_when_name_is_omitted(): + connection_manager = _RecordingConnectionManager() + app = SimpleNamespace(connection_manager=connection_manager) + + graph = _common_get_app_graph_client_for_connection(app, None) + + assert graph.request_adapter.base_url == "https://graph.microsoft.com/v1.0/" + assert connection_manager.calls == [ + ("get_default_connection",), + ] From c3da6f3a43ff51663e396f0d30b7515304e938aa Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 13:48:42 -0700 Subject: [PATCH 10/12] Minor tweak to docstrings --- .../hosting/core/client/agent_conversation_reference.py | 1 - .../hosting/core/client/channel_factory_protocol.py | 3 ++- .../microsoft_agents/hosting/msteams/_graph.py | 2 +- .../microsoft_agents/hosting/msteams/teams_turn_context.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py index 4d1352267..f5ac00fec 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py @@ -3,7 +3,6 @@ from microsoft_agents.activity import AgentsModel, ConversationReference - class AgentConversationReference(AgentsModel): conversation_reference: ConversationReference oauth_scope: str diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py index a55001cc1..b7dcb47cd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py @@ -9,5 +9,6 @@ class ChannelFactoryProtocol(Protocol): + def create_channel(self, token_access: AccessTokenProviderBase) -> ChannelProtocol: - pass + raise NotImplementedError("create_channel must be implemented by subclasses") diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 7159b445d..97b04465f 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -161,7 +161,7 @@ def _common_get_app_graph_client( :param context: The current turn context. :param graph_base_url: The base URL for the Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ if not context.identity: raise ValueError("TurnContext.identity is required to get a Graph client.") diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 5e162ad3b..81509842e 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -162,7 +162,7 @@ def get_app_graph_client( :param graph_base_url: The base URL for the Microsoft Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ return _common_get_app_graph_client( self._app, self, graph_base_url=graph_base_url @@ -180,7 +180,7 @@ def get_app_graph_client_for_connection( :param graph_base_url: The base URL for the Microsoft Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ return _common_get_app_graph_client_for_connection( self._app, connection_name, graph_base_url=graph_base_url From 16019b7af08ddb2f965e5bdf2bbeb97b05ab6a3f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 13:52:35 -0700 Subject: [PATCH 11/12] Minor tweak to docstrings --- .../microsoft_agents/hosting/msteams/_graph.py | 4 ++-- .../microsoft_agents/hosting/msteams/teams_turn_context.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 7159b445d..8ade54452 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -120,7 +120,7 @@ def _create_user_graph_service_client( :param context: The current turn context. :param handler_name: Optional auth handler name used to acquire the token. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ adapter = GraphRequestAdapter( _SDKUserAuthenticationProvider(app.auth, context, handler_name) @@ -161,7 +161,7 @@ def _common_get_app_graph_client( :param context: The current turn context. :param graph_base_url: The base URL for the Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ if not context.identity: raise ValueError("TurnContext.identity is required to get a Graph client.") diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 5e162ad3b..ac21568de 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -180,7 +180,7 @@ def get_app_graph_client_for_connection( :param graph_base_url: The base URL for the Microsoft Graph API. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ return _common_get_app_graph_client_for_connection( self._app, connection_name, graph_base_url=graph_base_url From 10cf0c43ba559f0a7f795bba7c071acf57fe20ce Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 24 Jul 2026 13:53:19 -0700 Subject: [PATCH 12/12] Formatting --- .../hosting/core/client/agent_conversation_reference.py | 1 + .../hosting/core/client/channel_factory_protocol.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py index f5ac00fec..4d1352267 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py @@ -3,6 +3,7 @@ from microsoft_agents.activity import AgentsModel, ConversationReference + class AgentConversationReference(AgentsModel): conversation_reference: ConversationReference oauth_scope: str diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py index b7dcb47cd..f7e5d05eb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py @@ -9,6 +9,6 @@ class ChannelFactoryProtocol(Protocol): - + def create_channel(self, token_access: AccessTokenProviderBase) -> ChannelProtocol: raise NotImplementedError("create_channel must be implemented by subclasses")