From 89ba92d27c80da9b98014b44b632ad2b5620ba0f Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:02:47 -0700 Subject: [PATCH 1/8] Add sovereign cloud support (GCCH, DoD, China) (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Introduces `CloudEnvironment` frozen dataclass with predefined presets (`PUBLIC`, `US_GOV`, `US_GOV_DOD`, `CHINA`) bundling all cloud-specific service endpoints - Threads cloud environment through `App`, `TokenManager`, `BotTokenClient`, `TokenValidator`, `ApiClient`, and `ApiClientSettings` - Supports `CLOUD` environment variable and programmatic `AppOptions.cloud` configuration - Adds `graph_scope` to `CloudEnvironment` for cloud-aware Microsoft Graph token acquisition - Simplifies `merge_api_client_settings` with `cloud` default to `PUBLIC`, removes `DEFAULT_API_CLIENT_SETTINGS` - Fixes cloud propagation through `ApiClient` -> `BotClient` -> `BotTokenClient` ### Note `graph_base_url` (Graph API endpoint per cloud) is intentionally deferred. This PR focuses on auth/token acquisition. Graph API routing is a separate concern. ### Sources - Bot Framework GCCH/DoD endpoints: https://learn.microsoft.com/en-us/azure/bot-service/how-to-deploy-gov-cloud-high - Bot Framework China endpoints: https://learn.microsoft.com/en-us/azure/bot-service/how-to-deploy-china-cloud - Graph national cloud deployments: https://learn.microsoft.com/en-us/graph/deployments ## Test plan - [x] `pytest packages/api/tests/` -- 168 tests pass - [x] `pytest packages/apps/tests/` -- 389 tests pass (1 pre-existing failure unrelated) - [x] E2E: Echo bot with `CLOUD=USGov` against real GCCH tenant -- message received, echo reply sent - [x] Copilot review feedback addressed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Corina Gum <> Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/microsoft_teams/api/auth/__init__.py | 8 ++ .../api/auth/cloud_environment.py | 107 +++++++++++++++ .../microsoft_teams/api/clients/__init__.py | 3 +- .../microsoft_teams/api/clients/api_client.py | 11 +- .../api/clients/api_client_settings.py | 26 ++-- .../microsoft_teams/api/clients/bot/client.py | 11 +- .../api/clients/bot/token_client.py | 29 ++-- packages/api/tests/unit/test_bot_client.py | 24 ++++ .../api/tests/unit/test_cloud_environment.py | 129 ++++++++++++++++++ packages/apps/src/microsoft_teams/apps/app.py | 11 +- .../apps/auth/token_validator.py | 32 +++-- .../microsoft_teams/apps/http/http_server.py | 5 +- .../apps/src/microsoft_teams/apps/options.py | 12 ++ .../src/microsoft_teams/apps/token_manager.py | 15 +- 14 files changed, 377 insertions(+), 46 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/auth/cloud_environment.py create mode 100644 packages/api/tests/unit/test_cloud_environment.py diff --git a/packages/api/src/microsoft_teams/api/auth/__init__.py b/packages/api/src/microsoft_teams/api/auth/__init__.py index b177eb4b..10f62a86 100644 --- a/packages/api/src/microsoft_teams/api/auth/__init__.py +++ b/packages/api/src/microsoft_teams/api/auth/__init__.py @@ -4,6 +4,11 @@ """ from .caller import CallerIds, CallerType +from .cloud_environment import ( + CloudEnvironment, + with_overrides, +) +from .cloud_environment import from_name as config_from_cloud_name from .credentials import ( ClientCredentials, Credentials, @@ -17,7 +22,9 @@ __all__ = [ "CallerIds", "CallerType", + "CloudEnvironment", "ClientCredentials", + "config_from_cloud_name", "Credentials", "FederatedIdentityCredentials", "ManagedIdentityCredentials", @@ -25,4 +32,5 @@ "TokenProtocol", "JsonWebToken", "JsonWebTokenPayload", + "with_overrides", ] diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py new file mode 100644 index 00000000..b86e0138 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py @@ -0,0 +1,107 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass, replace +from typing import Optional + + +@dataclass(frozen=True) +class CloudEnvironment: + """ + Bundles all cloud-specific service endpoints for a given Azure environment. + Use predefined instances (PUBLIC, US_GOV, US_GOV_DOD, CHINA) + or construct a custom one via with_overrides(). + """ + + login_endpoint: str + """The Azure AD login endpoint (e.g. "https://login.microsoftonline.com").""" + login_tenant: str + """The default multi-tenant login tenant (e.g. "botframework.com").""" + bot_scope: str + """The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default").""" + token_service_url: str + """The Bot Framework token service base URL (e.g. "https://token.botframework.com").""" + openid_metadata_url: str + """The OpenID metadata URL for token validation.""" + token_issuer: str + """The token issuer for Bot Framework tokens (e.g. "https://api.botframework.com").""" + graph_scope: str + """The Microsoft Graph token scope (e.g. "https://graph.microsoft.com/.default").""" + + +PUBLIC = CloudEnvironment( + login_endpoint="https://login.microsoftonline.com", + login_tenant="botframework.com", + bot_scope="https://api.botframework.com/.default", + token_service_url="https://token.botframework.com", + openid_metadata_url="https://login.botframework.com/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.com", + graph_scope="https://graph.microsoft.com/.default", +) +"""Microsoft public (commercial) cloud.""" + +US_GOV = CloudEnvironment( + login_endpoint="https://login.microsoftonline.us", + login_tenant="MicrosoftServices.onmicrosoft.us", + bot_scope="https://api.botframework.us/.default", + token_service_url="https://tokengcch.botframework.azure.us", + openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.us", + graph_scope="https://graph.microsoft.us/.default", +) +"""US Government Community Cloud High (GCCH).""" + +US_GOV_DOD = CloudEnvironment( + login_endpoint="https://login.microsoftonline.us", + login_tenant="MicrosoftServices.onmicrosoft.us", + bot_scope="https://api.botframework.us/.default", + token_service_url="https://apiDoD.botframework.azure.us", + openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.us", + graph_scope="https://dod-graph.microsoft.us/.default", +) +"""US Government Department of Defense (DoD).""" + +CHINA = CloudEnvironment( + login_endpoint="https://login.partner.microsoftonline.cn", + login_tenant="microsoftservices.partner.onmschina.cn", + bot_scope="https://api.botframework.azure.cn/.default", + token_service_url="https://token.botframework.azure.cn", + openid_metadata_url="https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.azure.cn", + graph_scope="https://microsoftgraph.chinacloudapi.cn/.default", +) +"""China cloud (21Vianet).""" + +_CLOUD_ENVIRONMENTS: dict[str, CloudEnvironment] = { + "public": PUBLIC, + "usgov": US_GOV, + "usgovdod": US_GOV_DOD, + "china": CHINA, +} + + +def from_name(name: str) -> CloudEnvironment: + """ + Resolve a cloud environment name (case-insensitive) to its corresponding instance. + Valid names: "Public", "USGov", "USGovDoD", "China". + """ + env = _CLOUD_ENVIRONMENTS.get(name.lower()) + if env is None: + raise ValueError( + f"Unknown cloud environment: '{name}'. Valid values are: Public, USGov, USGovDoD, China." + ) + return env + + +def with_overrides(base: CloudEnvironment, **overrides: Optional[str]) -> CloudEnvironment: + """ + Create a new CloudEnvironment by applying non-None overrides on top of a base. + Returns the same instance if all overrides are None (no allocation). + """ + filtered = {k: v for k, v in overrides.items() if v is not None} + if not filtered: + return base + return replace(base, **filtered) diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py index b744c1e8..683d9c1e 100644 --- a/packages/api/src/microsoft_teams/api/clients/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/__init__.py @@ -5,7 +5,7 @@ from . import bot, conversation, meeting, reaction, team, user from .api_client import ApiClient -from .api_client_settings import DEFAULT_API_CLIENT_SETTINGS, ApiClientSettings, merge_api_client_settings +from .api_client_settings import ApiClientSettings, merge_api_client_settings from .bot import * # noqa: F403 from .conversation import * # noqa: F403 from .meeting import * # noqa: F403 @@ -17,7 +17,6 @@ __all__: list[str] = [ "ApiClient", "ApiClientSettings", - "DEFAULT_API_CLIENT_SETTINGS", "merge_api_client_settings", ] __all__.extend(bot.__all__) diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py index 3576b147..2da68c7d 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -3,7 +3,9 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Union from microsoft_teams.common import Client as HttpClient from microsoft_teams.common import ClientOptions @@ -17,6 +19,9 @@ from .team import TeamClient from .user import UserClient +if TYPE_CHECKING: + from ..auth.cloud_environment import CloudEnvironment + class ApiClient(BaseClient): """Unified client for Microsoft Teams API operations.""" @@ -26,6 +31,7 @@ def __init__( service_url: str, options: Optional[Union[HttpClient, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the unified Teams API client. @@ -33,12 +39,13 @@ def __init__( service_url: The Teams service URL for API calls. options: Either an HTTP client instance or client options. If None, a default client is created. api_client_settings: Optional API client settings. + cloud: Optional cloud environment for sovereign cloud support. """ super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") # Initialize all client types - self.bots = BotClient(self._http, self._api_client_settings) + self.bots = BotClient(self._http, self._api_client_settings, cloud=cloud) self.users = UserClient(self._http, self._api_client_settings) self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings) self.teams = TeamClient(self.service_url, self._http, self._api_client_settings) diff --git a/packages/api/src/microsoft_teams/api/clients/api_client_settings.py b/packages/api/src/microsoft_teams/api/clients/api_client_settings.py index b64faac1..9f39c511 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client_settings.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client_settings.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from typing import Optional +from ..auth.cloud_environment import PUBLIC, CloudEnvironment + @dataclass class ApiClientSettings: @@ -17,29 +19,31 @@ class ApiClientSettings: oauth_url: The URL to use for managing user OAuth tokens. Specify this value if you are using a regional bot. For example: https://europe.token.botframework.com - Default is https://token.botframework.com + Defaults to the cloud environment's token service URL. """ - oauth_url: str = "https://token.botframework.com" - - -DEFAULT_API_CLIENT_SETTINGS = ApiClientSettings() + oauth_url: Optional[str] = None -def merge_api_client_settings(api_client_settings: Optional[ApiClientSettings]) -> ApiClientSettings: +def merge_api_client_settings( + api_client_settings: Optional[ApiClientSettings] = None, + cloud: CloudEnvironment = PUBLIC, +) -> ApiClientSettings: """ Merge API client settings with environment variables and defaults. Args: api_client_settings: Optional API client settings to merge. + cloud: Cloud environment for default oauth_url. Defaults to PUBLIC. Returns: Merged API client settings. """ - if api_client_settings is None: - api_client_settings = ApiClientSettings() - - # Check for environment variable override env_oauth_url = os.environ.get("OAUTH_URL") - return ApiClientSettings(oauth_url=env_oauth_url if env_oauth_url else api_client_settings.oauth_url) + if api_client_settings and api_client_settings.oauth_url: + return api_client_settings + + return ApiClientSettings( + oauth_url=env_oauth_url or cloud.token_service_url + ) diff --git a/packages/api/src/microsoft_teams/api/clients/bot/client.py b/packages/api/src/microsoft_teams/api/clients/bot/client.py index c610591b..c4e170ae 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/client.py @@ -3,7 +3,9 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Union from microsoft_teams.common.http import Client, ClientOptions @@ -12,6 +14,9 @@ from .sign_in_client import BotSignInClient from .token_client import BotTokenClient +if TYPE_CHECKING: + from ...auth.cloud_environment import CloudEnvironment + class BotClient(BaseClient): """Client for managing bot operations.""" @@ -20,15 +25,17 @@ def __init__( self, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the BotClient. Args: options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. api_client_settings: Optional API client settings. + cloud: Optional cloud environment for sovereign cloud support. """ super().__init__(options, api_client_settings) - self.token = BotTokenClient(self.http, self._api_client_settings) + self.token = BotTokenClient(self.http, self._api_client_settings, cloud=cloud) self.sign_in = BotSignInClient(self.http, self._api_client_settings) @property diff --git a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py index a003002b..d2479e78 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py @@ -3,17 +3,23 @@ Licensed under the MIT License. """ +from __future__ import annotations + import inspect -from typing import Literal, Optional, Union +from typing import TYPE_CHECKING, Literal, Optional, Union from microsoft_teams.api.auth.credentials import ClientCredentials from microsoft_teams.common.http import Client, ClientOptions from pydantic import BaseModel from ...auth import Credentials, TokenCredentials +from ...auth.cloud_environment import PUBLIC from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient +if TYPE_CHECKING: + from ...auth.cloud_environment import CloudEnvironment + class GetBotTokenResponse(BaseModel): """Response model for bot token requests.""" @@ -44,15 +50,18 @@ def __init__( self, options: Union[Client, ClientOptions, None] = None, api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the bot token client. Args: options: Optional Client or ClientOptions instance. api_client_settings: Optional API client settings. + cloud: Optional cloud environment for sovereign cloud support. """ super().__init__(options) - self._api_client_settings = merge_api_client_settings(api_client_settings) + self._cloud = cloud or PUBLIC + self._api_client_settings = merge_api_client_settings(api_client_settings, self._cloud) async def get(self, credentials: Credentials) -> GetBotTokenResponse: """Get a bot token. @@ -65,7 +74,7 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse: """ if isinstance(credentials, TokenCredentials): token = credentials.token( - "https://api.botframework.com/.default", + self._cloud.bot_scope, credentials.tenant_id, ) if inspect.isawaitable(token): @@ -81,14 +90,14 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse: "Bot token client currently only supports Credentials with secrets." ) - tenant_id = credentials.tenant_id or "botframework.com" + tenant_id = credentials.tenant_id or self._cloud.login_tenant res = await self.http.post( - f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + f"{self._cloud.login_endpoint}/{tenant_id}/oauth2/v2.0/token", data={ "grant_type": "client_credentials", "client_id": credentials.client_id, "client_secret": credentials.client_secret, - "scope": "https://api.botframework.com/.default", + "scope": self._cloud.bot_scope, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -106,7 +115,7 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: """ if isinstance(credentials, TokenCredentials): token = credentials.token( - "https://graph.microsoft.com/.default", + self._cloud.graph_scope, credentials.tenant_id, ) if inspect.isawaitable(token): @@ -122,14 +131,14 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: "Bot token client currently only supports Credentials with secrets." ) - tenant_id = credentials.tenant_id or "botframework.com" + tenant_id = credentials.tenant_id or self._cloud.login_tenant res = await self.http.post( - f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + f"{self._cloud.login_endpoint}/{tenant_id}/oauth2/v2.0/token", data={ "grant_type": "client_credentials", "client_id": credentials.client_id, "client_secret": credentials.client_secret, - "scope": "https://graph.microsoft.com/.default", + "scope": self._cloud.graph_scope, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) diff --git a/packages/api/tests/unit/test_bot_client.py b/packages/api/tests/unit/test_bot_client.py index 7329b2aa..3e2b17c2 100644 --- a/packages/api/tests/unit/test_bot_client.py +++ b/packages/api/tests/unit/test_bot_client.py @@ -94,3 +94,27 @@ async def test_bot_sign_in_get_resource_with_regional_endpoint(self, mock_http_c response = await client.sign_in.get_resource(params) assert response.sign_in_link is not None assert response.sign_in_link.startswith("http") + + +@pytest.mark.unit +class TestBotClientSovereignCloud: + def test_bot_token_client_receives_cloud(self): + from microsoft_teams.api.auth.cloud_environment import US_GOV + + client = BotClient(cloud=US_GOV) + assert client.token._cloud is US_GOV + assert client.token._cloud.bot_scope == "https://api.botframework.us/.default" + assert client.token._cloud.login_endpoint == "https://login.microsoftonline.us" + + def test_bot_token_client_defaults_to_public(self): + from microsoft_teams.api.auth.cloud_environment import PUBLIC + + client = BotClient() + assert client.token._cloud is PUBLIC + + def test_api_client_passes_cloud_to_bot_client(self): + from microsoft_teams.api import ApiClient + from microsoft_teams.api.auth.cloud_environment import US_GOV + + api = ApiClient("https://example.com", cloud=US_GOV) + assert api.bots.token._cloud is US_GOV diff --git a/packages/api/tests/unit/test_cloud_environment.py b/packages/api/tests/unit/test_cloud_environment.py new file mode 100644 index 00000000..5ad175e3 --- /dev/null +++ b/packages/api/tests/unit/test_cloud_environment.py @@ -0,0 +1,129 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import dataclasses + +import pytest +from microsoft_teams.api.auth.cloud_environment import ( + CHINA, + PUBLIC, + US_GOV, + US_GOV_DOD, + CloudEnvironment, + from_name, + with_overrides, +) + + +@pytest.mark.unit +class TestCloudEnvironmentPresets: + def test_public_has_correct_endpoints(self): + assert PUBLIC.login_endpoint == "https://login.microsoftonline.com" + assert PUBLIC.login_tenant == "botframework.com" + assert PUBLIC.bot_scope == "https://api.botframework.com/.default" + assert PUBLIC.token_service_url == "https://token.botframework.com" + assert PUBLIC.openid_metadata_url == "https://login.botframework.com/v1/.well-known/openidconfiguration" + assert PUBLIC.token_issuer == "https://api.botframework.com" + + def test_us_gov_has_correct_endpoints(self): + assert US_GOV.login_endpoint == "https://login.microsoftonline.us" + assert US_GOV.login_tenant == "MicrosoftServices.onmicrosoft.us" + assert US_GOV.bot_scope == "https://api.botframework.us/.default" + assert US_GOV.token_service_url == "https://tokengcch.botframework.azure.us" + assert US_GOV.openid_metadata_url == "https://login.botframework.azure.us/v1/.well-known/openidconfiguration" + assert US_GOV.token_issuer == "https://api.botframework.us" + + def test_us_gov_dod_has_correct_endpoints(self): + assert US_GOV_DOD.login_endpoint == "https://login.microsoftonline.us" + assert US_GOV_DOD.token_service_url == "https://apiDoD.botframework.azure.us" + assert US_GOV_DOD.token_issuer == "https://api.botframework.us" + + def test_china_has_correct_endpoints(self): + assert CHINA.login_endpoint == "https://login.partner.microsoftonline.cn" + assert CHINA.login_tenant == "microsoftservices.partner.onmschina.cn" + assert CHINA.bot_scope == "https://api.botframework.azure.cn/.default" + assert CHINA.token_service_url == "https://token.botframework.azure.cn" + assert CHINA.token_issuer == "https://api.botframework.azure.cn" + + def test_presets_are_frozen(self): + with pytest.raises(dataclasses.FrozenInstanceError): + PUBLIC.login_endpoint = "https://modified.example.com" # type: ignore[misc] + + +@pytest.mark.unit +class TestFromName: + @pytest.mark.parametrize( + "name,expected", + [ + ("Public", PUBLIC), + ("public", PUBLIC), + ("PUBLIC", PUBLIC), + ("USGov", US_GOV), + ("usgov", US_GOV), + ("USGovDoD", US_GOV_DOD), + ("usgovdod", US_GOV_DOD), + ("China", CHINA), + ("china", CHINA), + ], + ) + def test_resolves_correctly(self, name: str, expected: CloudEnvironment): + assert from_name(name) is expected + + @pytest.mark.parametrize("name", ["invalid", "", "Azure"]) + def test_raises_for_unknown_name(self, name: str): + with pytest.raises(ValueError, match="Unknown cloud environment"): + from_name(name) + + +@pytest.mark.unit +class TestWithOverrides: + def test_returns_same_instance_when_no_overrides(self): + result = with_overrides(PUBLIC) + assert result is PUBLIC + + def test_returns_same_instance_when_all_none(self): + result = with_overrides(PUBLIC, login_endpoint=None, login_tenant=None) + assert result is PUBLIC + + def test_replaces_single_property(self): + result = with_overrides(PUBLIC, login_tenant="my-tenant-id") + assert result is not PUBLIC + assert result.login_tenant == "my-tenant-id" + assert result.login_endpoint == PUBLIC.login_endpoint + assert result.bot_scope == PUBLIC.bot_scope + + def test_replaces_multiple_properties(self): + result = with_overrides( + CHINA, + login_endpoint="https://custom.login.cn", + login_tenant="custom-tenant", + token_service_url="https://custom.token.cn", + ) + assert result.login_endpoint == "https://custom.login.cn" + assert result.login_tenant == "custom-tenant" + assert result.token_service_url == "https://custom.token.cn" + assert result.bot_scope == CHINA.bot_scope + + def test_replaces_all_properties(self): + result = with_overrides( + PUBLIC, + login_endpoint="a", + login_tenant="b", + bot_scope="c", + token_service_url="d", + openid_metadata_url="e", + token_issuer="f", + ) + assert result.login_endpoint == "a" + assert result.login_tenant == "b" + assert result.bot_scope == "c" + assert result.token_service_url == "d" + assert result.openid_metadata_url == "e" + assert result.token_issuer == "f" + + def test_result_is_frozen(self): + result = with_overrides(PUBLIC, login_tenant="test") + with pytest.raises(dataclasses.FrozenInstanceError): + result.login_tenant = "modified" # type: ignore[misc] diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index ca3247b4..cf720a85 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -26,6 +26,8 @@ TokenCredentials, TokenProtocol, ) +from microsoft_teams.api.auth.cloud_environment import PUBLIC +from microsoft_teams.api.auth.cloud_environment import from_name as cloud_from_name from microsoft_teams.cards import AdaptiveCard from microsoft_teams.common import Client, ClientOptions, EventEmitter, LocalStorage @@ -79,6 +81,10 @@ class App(ActivityHandlerMixin): def __init__(self, **options: Unpack[AppOptions]): self.options = InternalAppOptions.from_typeddict(options) + # Resolve cloud environment from options or CLOUD env var + cloud_env_name = os.getenv("CLOUD") + self.cloud = self.options.cloud or (cloud_from_name(cloud_env_name) if cloud_env_name else PUBLIC) + self.storage = self.options.storage or LocalStorage() self.http_client = Client( @@ -94,6 +100,7 @@ def __init__(self, **options: Unpack[AppOptions]): self._token_manager = TokenManager( credentials=self.credentials, + cloud=self.cloud, ) self.container = Container() @@ -111,6 +118,7 @@ def __init__(self, **options: Unpack[AppOptions]): service_url, self.http_client.clone(ClientOptions(token=self._get_bot_token)), self.options.api_client_settings, + cloud=self.cloud, ) plugins: List[PluginBase] = list(self.options.plugins) @@ -159,6 +167,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.credentials.client_id, self.credentials.tenant_id, application_id_uri=self.options.application_id_uri, + cloud=self.cloud, ) @property @@ -203,7 +212,7 @@ async def initialize(self) -> None: # Initialize HttpServer (JWT validation + messaging endpoint route) self.server.on_request = self._process_activity_event - self.server.initialize(credentials=self.credentials, skip_auth=self.options.skip_auth) + self.server.initialize(credentials=self.credentials, skip_auth=self.options.skip_auth, cloud=self.cloud) self._initialized = True logger.info("Teams app initialized successfully") diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py index 969ba5ee..ad04dfca 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -3,11 +3,15 @@ Licensed under the MIT License. """ +from __future__ import annotations + import logging +import re from dataclasses import dataclass from typing import Any, Dict, List, Optional import jwt +from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment JWT_LEEWAY_SECONDS = 300 # Allowable clock skew when validating JWTs @@ -53,19 +57,28 @@ def _default_audiences(app_id: str) -> List[str]: # ----- Factory constructors ----- @classmethod - def for_service(cls, app_id: str, service_url: Optional[str] = None) -> "TokenValidator": + def for_service( + cls, + app_id: str, + service_url: Optional[str] = None, + cloud: Optional[CloudEnvironment] = None, + ) -> TokenValidator: """Create a validator for Bot Framework service tokens. Reference: https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication Args: app_id: The bot's Microsoft App ID (used for audience validation) - service_url: Optional service URL to validate against token claims""" + service_url: Optional service URL to validate against token claims + cloud: Optional cloud environment for sovereign cloud support + """ + env = cloud or PUBLIC + jwks_keys_uri = re.sub(r"/openidconfiguration$", "/keys", env.openid_metadata_url) options = JwtValidationOptions( - valid_issuers=["https://api.botframework.com"], + valid_issuers=[env.token_issuer], valid_audiences=cls._default_audiences(app_id), - jwks_uri="https://login.botframework.com/v1/.well-known/keys", + jwks_uri=jwks_keys_uri, service_url=service_url, ) return cls(options) @@ -77,7 +90,8 @@ def for_entra( tenant_id: Optional[str], scope: Optional[str] = None, application_id_uri: Optional[str] = None, - ) -> "TokenValidator": + cloud: Optional[CloudEnvironment] = None, + ) -> TokenValidator: """Create a validator for Entra ID tokens. Args: @@ -86,12 +100,12 @@ def for_entra( scope: Optional scope that must be present in the token application_id_uri: Optional Application ID URI from Azure portal. Matches webApplicationInfo.resource in the app manifest. - + cloud: Optional cloud environment for sovereign cloud support """ - + env = cloud or PUBLIC valid_issuers: List[str] = [] if tenant_id: - valid_issuers.append(f"https://login.microsoftonline.com/{tenant_id}/v2.0") + valid_issuers.append(f"{env.login_endpoint}/{tenant_id}/v2.0") tenant_id = tenant_id or "common" valid_audiences = cls._default_audiences(app_id) if application_id_uri: @@ -99,7 +113,7 @@ def for_entra( options = JwtValidationOptions( valid_issuers=valid_issuers, valid_audiences=valid_audiences, - jwks_uri=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys", + jwks_uri=f"{env.login_endpoint}/{tenant_id}/discovery/v2.0/keys", scope=scope, ) return cls(options) diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py index 8aab280b..0d695db2 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional, cast from microsoft_teams.api import Credentials, InvokeResponse, TokenProtocol +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment from microsoft_teams.api.auth.json_web_token import JsonWebToken from pydantic import BaseModel @@ -60,6 +61,7 @@ def initialize( self, credentials: Optional[Credentials] = None, skip_auth: bool = False, + cloud: Optional[CloudEnvironment] = None, ) -> None: """ Set up JWT validation and register the messaging endpoint route. @@ -67,6 +69,7 @@ def initialize( Args: credentials: App credentials for JWT validation. skip_auth: Whether to skip JWT validation. + cloud: Optional cloud environment for sovereign cloud support. """ if self._initialized: return @@ -75,7 +78,7 @@ def initialize( app_id = getattr(credentials, "client_id", None) if credentials else None if app_id and not skip_auth: - self._token_validator = TokenValidator.for_service(app_id) + self._token_validator = TokenValidator.for_service(app_id, cloud=cloud) logger.debug("JWT validation enabled for %s", self._messaging_endpoint) self._adapter.register_route("POST", self._messaging_endpoint, self.handle_request) diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index 396d9a04..f0e58845 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -9,6 +9,7 @@ from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast from microsoft_teams.api import ApiClientSettings +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment from microsoft_teams.common import Storage from typing_extensions import Unpack @@ -69,6 +70,15 @@ class AppOptions(TypedDict, total=False): and defaults to https://smba.trafficmanager.net/teams """ + # Cloud environment + cloud: Optional[CloudEnvironment] + """ + Cloud environment for sovereign cloud support. + Accepts a CloudEnvironment instance or uses CLOUD environment variable. + Valid env var values: "Public", "USGov", "USGovDoD", "China". + Defaults to PUBLIC (commercial cloud). + """ + @dataclass class InternalAppOptions: @@ -112,6 +122,8 @@ class InternalAppOptions: """Custom HTTP server adapter. Defaults to FastAPIAdapter if not provided.""" messaging_endpoint: str = "/api/messages" """URL path for the Teams messaging endpoint. Defaults to '/api/messages'.""" + cloud: Optional[CloudEnvironment] = None + """Cloud environment for sovereign cloud support.""" @classmethod def from_typeddict(cls, options: AppOptions) -> "InternalAppOptions": diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index 9dc84718..b0c401f6 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -15,6 +15,7 @@ JsonWebToken, TokenProtocol, ) +from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment from microsoft_teams.api.auth.credentials import ( FederatedIdentityCredentials, ManagedIdentityCredentials, @@ -27,11 +28,7 @@ UserAssignedManagedIdentity, ) -BOT_TOKEN_SCOPE = "https://api.botframework.com/.default" -GRAPH_TOKEN_SCOPE = "https://graph.microsoft.com/.default" -DEFAULT_TENANT_FOR_BOT_TOKEN = "botframework.com" DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common" -DEFAULT_TOKEN_AUTHORITY = "https://login.microsoftonline.com/{tenant_id}" logger = logging.getLogger(__name__) @@ -42,15 +39,17 @@ class TokenManager: def __init__( self, credentials: Optional[Credentials], + cloud: Optional[CloudEnvironment] = None, ): self._credentials = credentials + self._cloud = cloud or PUBLIC self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} self._managed_identity_client: Optional[ManagedIdentityClient] = None async def get_bot_token(self) -> Optional[TokenProtocol]: """Refresh the bot authentication token.""" return await self._get_token( - BOT_TOKEN_SCOPE, tenant_id=self._resolve_tenant_id(None, DEFAULT_TENANT_FOR_BOT_TOKEN) + self._cloud.bot_scope, tenant_id=self._resolve_tenant_id(None, self._cloud.login_tenant) ) async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: @@ -65,7 +64,7 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok The graph token or None if not available """ return await self._get_token( - GRAPH_TOKEN_SCOPE, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) + self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) ) async def _get_token( @@ -134,7 +133,7 @@ async def _get_token_with_federated_identity( confidential_client = ConfidentialClientApplication( credentials.client_id, client_credential={"client_assertion": mi_token}, - authority=DEFAULT_TOKEN_AUTHORITY.format(tenant_id=tenant_id), + authority=f"{self._cloud.login_endpoint}/{tenant_id}", ) token_res: dict[str, Any] = await asyncio.to_thread( @@ -205,7 +204,7 @@ def _get_confidential_client(self, credentials: ClientCredentials, tenant_id: st client: ConfidentialClientApplication = ConfidentialClientApplication( credentials.client_id, client_credential=credentials.client_secret, - authority=f"https://login.microsoftonline.com/{tenant_id}", + authority=f"{self._cloud.login_endpoint}/{tenant_id}", ) self._confidential_clients_by_tenant[tenant_id] = client return client From 11e9cdaf0e46bdd5a5af1c7b66ea8406f448af46 Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:27:21 -0700 Subject: [PATCH 2/8] fix: drain entire queue per flush to avoid streaming tail lag (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Removes the 10-item batch cap in `HttpStream._flush()` so each flush cycle drains the entire queue - Prevents a long streaming tail when the LLM finishes generating faster than chunks are sent to Teams - Port of [microsoft/teams.ts#520](https://github.com/microsoft/teams.ts/pull/520) ## Test plan - [x] Existing `test_http_stream.py` tests pass (11/11) - [x] Updated `test_stream_multiple_emits_with_timer` to verify the first flush drains all 12 messages with no second flush scheduled - [x] Pre-commit hooks (ruff, pyright) pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- packages/apps/src/microsoft_teams/apps/http_stream.py | 10 ++++------ packages/apps/tests/test_http_stream.py | 5 ++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py index 65b2b93c..8ee66185 100644 --- a/packages/apps/src/microsoft_teams/apps/http_stream.py +++ b/packages/apps/src/microsoft_teams/apps/http_stream.py @@ -33,7 +33,7 @@ class HttpStream(StreamerProtocol): Flow: 1. emit() adds activities to a queue - 2. _flush() processes up to 10 queued items under a lock. + 2. _flush() drains the entire queue under a lock. 3. Informative typing updates are sent immediately if no message started. 4. Message text are combined into a typing chunk. 5. Another flush is scheduled if more items remain. @@ -205,10 +205,10 @@ async def _flush(self) -> None: self._timeout.cancel() self._timeout = None - i = 0 informative_updates: List[TypingActivityInput] = [] + start_length = len(self._queue) - while i < 10 and self._queue: + while self._queue: activity = self._queue.popleft() if isinstance(activity, MessageActivityInput): @@ -227,9 +227,7 @@ async def _flush(self) -> None: # And so informative updates cannot be sent. informative_updates.append(activity) - i += 1 - - if i == 0: + if start_length == 0: logger.debug("No activities to flush") return diff --git a/packages/apps/tests/test_http_stream.py b/packages/apps/tests/test_http_stream.py index 50cb4d2d..9a0f0a46 100644 --- a/packages/apps/tests/test_http_stream.py +++ b/packages/apps/tests/test_http_stream.py @@ -91,10 +91,9 @@ async def test_stream_multiple_emits_with_timer(self, http_stream, patch_loop_ca http_stream.emit(f"Message {i + 1}") await asyncio.sleep(0) + # First flush drains the entire queue, no second flush needed assert http_stream._client.send_call_count == 1 - - await self._run_scheduled_flushes(scheduled) - assert http_stream._client.send_call_count == 2 + assert len(scheduled) == 0 @pytest.mark.asyncio async def test_stream_error_handled_gracefully( From 3d46d65e8e9d99925b31e1647eacfd0c782ba1db Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:44:27 -0700 Subject: [PATCH 3/8] Remove with_reply_to_id method (#385) - Remove `with_reply_to_id()` method `reply_to_id` is a field on the activity that is used by the service, but any changes to it on the bot side are completely ignored. After thorough testing to confirm, this PR removes the misleading method `with_reply_to_id`; setting replyToId on outgoing activities is a no-op. Originally part of #321 but separated out due to timing concerns for GA --- packages/api/src/microsoft_teams/api/models/activity.py | 5 ----- packages/api/tests/unit/test_activity.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/models/activity.py b/packages/api/src/microsoft_teams/api/models/activity.py index 46415236..c82ed1c7 100644 --- a/packages/api/src/microsoft_teams/api/models/activity.py +++ b/packages/api/src/microsoft_teams/api/models/activity.py @@ -113,11 +113,6 @@ def with_id(self, value: str) -> Self: self.id = value return self - def with_reply_to_id(self, value: str) -> Self: - """Set the reply_to_id.""" - self.reply_to_id = value - return self - def with_channel_id(self, value: ChannelID) -> Self: """Set the channel_id.""" self.channel_id = value diff --git a/packages/api/tests/unit/test_activity.py b/packages/api/tests/unit/test_activity.py index c2989ace..b937f450 100644 --- a/packages/api/tests/unit/test_activity.py +++ b/packages/api/tests/unit/test_activity.py @@ -63,7 +63,6 @@ def test_should_build( activity = ( test_activity.with_locale("en") .with_recipient(bot) - .with_reply_to_id("3") .with_service_url("http://localhost") .with_timestamp(datetime.now()) .with_local_timestamp(datetime.now()) @@ -75,7 +74,6 @@ def test_should_build( assert activity.from_ == user assert activity.conversation == chat assert activity.recipient == bot - assert activity.reply_to_id == "3" assert activity.service_url == "http://localhost" assert activity.timestamp is not None assert activity.local_timestamp is not None From 4977b599b6559f616489ddbe260f28b3a726f520 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:06:40 -0700 Subject: [PATCH 4/8] Merge User-Agent headers when cloning HTTP client (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `Client.clone()` was called with headers containing a `User-Agent`, it would overwrite the base client's `User-Agent` rather than merging. This loses the SDK's default UA token when callers supply their own. ## Changes - **`packages/common/src/microsoft_teams/common/http/client.py`** - Added `_merge_headers(base, overrides)` helper: performs a case-insensitive `User-Agent` merge (space-concatenated, token-based dedup to avoid substring false positives); all other headers follow standard last-write-wins semantics - `Client.clone()` now calls `_merge_headers()` instead of a plain dict unpack - **`packages/common/tests/test_client.py`** - Added 7 tests covering: preservation without overrides, merge when both sides define UA, non-UA overrides leave UA untouched, case-insensitive key matching, duplicate-token suppression, substring false-positive guard, and override-only UA when base has none ## Example ```python client = Client(ClientOptions(headers={"User-Agent": "teams-py/1.0"})) clone = client.clone(ClientOptions(headers={"User-Agent": "myapp/2.0"})) # Before: "myapp/2.0" (SDK UA lost) # After: "teams-py/1.0 myapp/2.0" print(clone._options.headers["User-Agent"]) ``` > [!WARNING] > >
> Firewall rules blocked me from connecting to one or more addresses (expand for details) > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `releases.astral.sh` > - Triggering command: `/home/REDACTED/.local/bin/uv uv sync --all-packages --group dev` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/microsoft/teams.py/settings/copilot/coding_agent) (admins only) > >
Original prompt > Implement the change described by PR microsoft/teams.ts#508 ("Merge User-Agent headers when cloning HTTP client") in the Python SDK repository @microsoft/teams.py. > > Goal: > - When the HTTP client is cloned (or a new client is created from an existing client/session), ensure that `User-Agent` headers are merged rather than overwritten. > - Preserve any existing default `User-Agent` set by the library while also incorporating a user-provided `User-Agent` (or additional UA tokens) when cloning. > > Requirements: > 1. Locate the HTTP client implementation and any clone/copy method(s) in the repository. > 2. Identify how headers are stored (e.g., requests.Session headers, httpx.Client headers, custom dict) and how cloning currently works. > 3. Modify cloning logic so that: > - If both source and override headers contain `User-Agent`, the resulting `User-Agent` should include both (e.g., concatenated with a space, or comma per convention) without duplicating identical values. > - If only one side has `User-Agent`, keep it. > - Header merging should be case-insensitive for the header key. > 4. Add/adjust unit tests to cover: > - Cloning without overrides preserves original `User-Agent`. > - Cloning with headers override that includes `User-Agent` results in merged value. > - Cloning with headers override without `User-Agent` leaves original UA intact. > - Case-insensitive behavior (`user-agent`, `User-Agent`). > 5. Update any relevant docs or changelog entry if the repo uses one. > > Reference: > - PR: https://github.com/microsoft/teams.ts/pull/508 > > Notes: > - Ensure behavior is consistent across sync/async HTTP clients if both exist. > - Keep backward compatibility with existing public APIs. > > > The following is the prior conversation context from the user's chat exploration (may be truncated): > > User: Can you implement this in @microsoft/teams.py ? > > Assistant: I’ve queued up the implementation for `microsoft/teams.py` and I’m now waiting on your confirmation in the prompt to proceed with making the code changes and opening the PR. > >
*This pull request was created from Copilot chat.* > --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: heyitsaamir <48929123+heyitsaamir@users.noreply.github.com> Co-authored-by: heyitsaamir Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/microsoft_teams/common/http/client.py | 32 +++++++++- packages/common/tests/test_client.py | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/common/src/microsoft_teams/common/http/client.py b/packages/common/src/microsoft_teams/common/http/client.py index 50ac6677..5c930513 100644 --- a/packages/common/src/microsoft_teams/common/http/client.py +++ b/packages/common/src/microsoft_teams/common/http/client.py @@ -19,6 +19,36 @@ logger = logging.getLogger(__name__) +def _merge_headers(base: Dict[str, str], overrides: Dict[str, str]) -> Dict[str, str]: + """ + Merge two header dicts, concatenating User-Agent values when both sides define it. + + For User-Agent headers (case-insensitive key match), the values are merged by + concatenating with a space, skipping tokens that are already present. All other + headers from overrides take precedence over base headers. + + Args: + base: The base headers dict. + overrides: Headers to merge in (may override base headers). + + Returns: + Merged headers dict. + """ + result = dict(base) + for key, value in overrides.items(): + if key.lower() == "user-agent": + base_ua_key = next((k for k in result if k.lower() == "user-agent"), None) + if base_ua_key is not None: + existing = result[base_ua_key] + if value not in existing.split(): + result[base_ua_key] = f"{existing} {value}" + else: + result["User-Agent"] = value + else: + result[key] = value + return result + + def _wrap_response_json(response: httpx.Response) -> None: """ Wrap the response.json method to handle JSONDecodeError gracefully. @@ -421,7 +451,7 @@ def clone(self, overrides: Optional[ClientOptions] = None) -> "Client": overrides = overrides or ClientOptions() merged_options = ClientOptions( base_url=overrides.base_url if overrides.base_url is not None else self._options.base_url, - headers={**self._options.headers, **(overrides.headers or {})}, + headers=_merge_headers(self._options.headers, overrides.headers or {}), timeout=overrides.timeout if overrides.timeout is not None else self._options.timeout, token=overrides.token if overrides.token is not None else self._options.token, interceptors=list(overrides.interceptors) diff --git a/packages/common/tests/test_client.py b/packages/common/tests/test_client.py index d40b9a80..529f38bd 100644 --- a/packages/common/tests/test_client.py +++ b/packages/common/tests/test_client.py @@ -188,3 +188,65 @@ async def test_failing_token_factory(mock_transport): with pytest.raises(ValueError, match="Token factory failed"): await client.get("/token-test") + + +def test_clone_preserves_user_agent_without_overrides(): + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0"})) + clone = client.clone() + assert clone._options.headers["User-Agent"] == "teams-bot/1.0" + + +def test_clone_merges_user_agent_with_override(): + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0"})) + clone = client.clone(ClientOptions(headers={"User-Agent": "myapp/2.0"})) + assert clone._options.headers["User-Agent"] == "teams-bot/1.0 myapp/2.0" + + +def test_clone_with_other_headers_preserves_original_user_agent(): + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0"})) + clone = client.clone(ClientOptions(headers={"X-Custom": "value"})) + assert clone._options.headers["User-Agent"] == "teams-bot/1.0" + assert clone._options.headers["X-Custom"] == "value" + + +def test_clone_user_agent_case_insensitive(): + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0"})) + clone = client.clone(ClientOptions(headers={"user-agent": "myapp/2.0"})) + ua = clone._options.headers["User-Agent"] + assert "teams-bot/1.0" in ua + assert "myapp/2.0" in ua + + +def test_clone_user_agent_no_duplicate_token(): + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0 myapp/2.0"})) + clone = client.clone(ClientOptions(headers={"User-Agent": "myapp/2.0"})) + ua = clone._options.headers["User-Agent"] + assert ua.count("myapp/2.0") == 1 + + +def test_clone_user_agent_no_false_positive_substring(): + # "bot" is a substring of "teams-bot/1.0" but should still be appended as a distinct token + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0"})) + clone = client.clone(ClientOptions(headers={"User-Agent": "bot"})) + ua = clone._options.headers["User-Agent"] + assert ua == "teams-bot/1.0 bot" + + +def test_clone_only_override_user_agent_kept_when_base_has_none(): + client = Client(ClientOptions(headers={})) + clone = client.clone(ClientOptions(headers={"User-Agent": "myapp/2.0"})) + assert clone._options.headers["User-Agent"] == "myapp/2.0" + + +def test_clone_normalizes_user_agent_key_when_base_has_none(): + client = Client(ClientOptions(headers={})) + clone = client.clone(ClientOptions(headers={"user-agent": "myapp/2.0"})) + assert "User-Agent" in clone._options.headers + assert clone._options.headers["User-Agent"] == "myapp/2.0" + + +def test_clone_user_agent_multi_token_override(): + client = Client(ClientOptions(headers={"User-Agent": "teams-bot/1.0"})) + clone = client.clone(ClientOptions(headers={"User-Agent": "myapp/2.0 partner/3.0"})) + ua = clone._options.headers["User-Agent"] + assert ua == "teams-bot/1.0 myapp/2.0 partner/3.0" From fa7a2c1dda20d0dbc34abee0c00153b0a5001752 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:02:42 -0700 Subject: [PATCH 5/8] build(deps-dev): bump pytest from 8.4.2 to 9.0.3 (#387) Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.4.2 to 9.0.3.
Release notes

Sourced from pytest's releases.

9.0.3

pytest 9.0.3 (2026-04-07)

Bug fixes

  • #12444: Fixed pytest.approx which now correctly takes into account ~collections.abc.Mapping keys order to compare them.

  • #13634: Blocking a conftest.py file using the -p no: option is now explicitly disallowed.

    Previously this resulted in an internal assertion failure during plugin loading.

    Pytest now raises a clear UsageError explaining that conftest files are not plugins and cannot be disabled via -p.

  • #13734: Fixed crash when a test raises an exceptiongroup with __tracebackhide__ = True.

  • #14195: Fixed an issue where non-string messages passed to unittest.TestCase.subTest() were not printed.

  • #14343: Fixed use of insecure temporary directory (CVE-2025-71176).

Improved documentation

  • #13388: Clarified documentation for -p vs PYTEST_PLUGINS plugin loading and fixed an incorrect -p example.
  • #13731: Clarified that capture fixtures (e.g. capsys and capfd) take precedence over the -s / --capture=no command-line options in Accessing captured output from a test function <accessing-captured-output>.
  • #14088: Clarified that the default pytest_collection hook sets session.items before it calls pytest_collection_finish, not after.
  • #14255: TOML integer log levels must be quoted: Updating reference documentation.

Contributor-facing changes

  • #12689: The test reports are now published to Codecov from GitHub Actions. The test statistics is visible on the web interface.

    -- by aleguy02

9.0.2

pytest 9.0.2 (2025-12-06)

Bug fixes

  • #13896: The terminal progress feature added in pytest 9.0.0 has been disabled by default, except on Windows, due to compatibility issues with some terminal emulators.

    You may enable it again by passing -p terminalprogress. We may enable it by default again once compatibility improves in the future.

    Additionally, when the environment variable TERM is dumb, the escape codes are no longer emitted, even if the plugin is enabled.

  • #13904: Fixed the TOML type of the tmp_path_retention_count settings in the API reference from number to string.

  • #13946: The private config.inicfg attribute was changed in a breaking manner in pytest 9.0.0. Due to its usage in the ecosystem, it is now restored to working order using a compatibility shim. It will be deprecated in pytest 9.1 and removed in pytest 10.

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=8.4.2&new-version=9.0.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/teams.py/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb8ddc0f..edcfa220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dev = [ "ruff>=0.11.13", "pyright>=1.1.404", "cookiecutter>=2.6.0", - "pytest>=8.0.0", + "pytest>=9.0.3", "pytest-asyncio>=0.23.0", "pytest-cov>=6.0.0", ] diff --git a/uv.lock b/uv.lock index 16551009..ed72946c 100644 --- a/uv.lock +++ b/uv.lock @@ -45,7 +45,7 @@ dev = [ { name = "poethepoet", specifier = ">=0.35.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pyright", specifier = ">=1.1.404" }, - { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.11.13" }, @@ -2797,7 +2797,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2806,22 +2806,22 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.2.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] From ad134eaa7e59887af09932f26b8e457615c207bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:26:22 -0700 Subject: [PATCH 6/8] build(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /examples/tab/Web (#388) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
Commits
  • 0c23a22 Release version 1.16.0 of the npm package.
  • 844c4d3 Add sensitiveHeaders option.
  • 5e8b8d0 ci: add Node.js 24.x to the CI matrix
  • 7953e22 ci: upgrade GitHub Actions to use setup-node@v6 and checkout@v6
  • 86dc1f8 Sanitizing input.
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=follow-redirects&package-manager=npm_and_yarn&previous-version=1.15.11&new-version=1.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/teams.py/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/tab/Web/package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/tab/Web/package-lock.json b/examples/tab/Web/package-lock.json index a64b949e..a6810287 100644 --- a/examples/tab/Web/package-lock.json +++ b/examples/tab/Web/package-lock.json @@ -1678,15 +1678,16 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, From 368b71644d0959652691386aa11d20aeac833c38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:48:42 -0700 Subject: [PATCH 7/8] build(deps): bump python-multipart from 0.0.22 to 0.0.26 (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.22 to 0.0.26.
Release notes

Sourced from python-multipart's releases.

Version 0.0.26

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.25...0.0.26

Version 0.0.25

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.24...0.0.25

Version 0.0.24

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.23...0.0.24

Version 0.0.23

What's Changed

New Contributors

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.22...0.0.23

Changelog

Sourced from python-multipart's changelog.

0.0.26 (2026-04-10)

  • Skip preamble before the first multipart boundary more efficiently #262.
  • Silently discard epilogue data after the closing multipart boundary #259.

0.0.25 (2026-04-10)

  • Add MIME content type info to File #143.
  • Handle CTE values case-insensitively #258.
  • Remove custom FormParser classes #257.
  • Add UPLOAD_DELETE_TMP to FormParser config #254.
  • Emit field_end for trailing bare field names on finalize #230.
  • Handle multipart headers case-insensitively #252.
  • Apply Apache-2.0 properly #247.

0.0.24 (2026-04-05)

  • Validate chunk_size in parse_form() #244.

0.0.23 (2026-04-05)

  • Remove unused trust_x_headers parameter and X-File-Name fallback #196.
  • Return processed length from QuerystringParser._internal_write #229.
  • Cleanup metadata dunders from __init__.py #227.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python-multipart&package-manager=uv&previous-version=0.0.22&new-version=0.0.26)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/teams.py/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index ed72946c..eeaf3c46 100644 --- a/uv.lock +++ b/uv.lock @@ -2861,11 +2861,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] [[package]] From 6db44d7634bd5f9504f78ae2bb172e0c6a10720f Mon Sep 17 00:00:00 2001 From: Mehak Bindra Date: Wed, 15 Apr 2026 23:11:52 -0700 Subject: [PATCH 8/8] MCP Server Example on MCP SDK (#391) This pull request significantly refactors the MCP server Teams bot sample to provide a robust, human-in-the-loop toolkit using the official `mcp` SDK, with clear separation of concerns and improved documentation. The changes introduce a modular structure, new tool definitions for user interaction and approvals, and improved state management. The README and project metadata are also updated for clarity and accuracy. **Key changes:** **1. Major refactor and modularization** - The codebase is split into clear modules: `app.py` (Teams bot handlers), `mcp_tools.py` (MCP tool definitions), `state.py` (in-memory state), and `main.py` (entry point and server wiring), replacing the previous monolithic and example-based implementation. **2. New MCP tools for human-in-the-loop workflows** - Defines five MCP tools in `mcp_tools.py`: `notify`, `ask`, `get_reply`, `request_approval`, and `get_approval`, enabling agents to notify users, ask questions, and request approvals through Teams with polling for responses. - Introduces helper models for tool inputs and outputs in `models.py`. **3. Improved state management** - Implements in-memory dictionaries in `state.py` to track conversations, pending asks, approvals, and user-to-request mappings, supporting the new tools and workflows. **4. Documentation and metadata updates** - Overhauls the `README.md` to explain setup, architecture, tool usage, workflows, and limitations, providing a much clearer guide for users and developers. - Updates `pyproject.toml` to accurately describe the project and its dependencies, switching to the `mcp` SDK. **5. Removal of legacy/demo code** - Removes the previous example tools (`echo`, `get_weather`, `calculate`, `alert`) and the old MCP server plugin setup, replacing them with the new modular and production-like approach. --- examples/mcp-server/README.md | 68 +++++++++-- examples/mcp-server/pyproject.toml | 7 +- examples/mcp-server/src/app.py | 54 +++++++++ examples/mcp-server/src/main.py | 162 ++++----------------------- examples/mcp-server/src/mcp_tools.py | 89 +++++++++++++++ examples/mcp-server/src/models.py | 37 ++++++ examples/mcp-server/src/state.py | 22 ++++ uv.lock | 6 +- 8 files changed, 292 insertions(+), 153 deletions(-) create mode 100644 examples/mcp-server/src/app.py create mode 100644 examples/mcp-server/src/mcp_tools.py create mode 100644 examples/mcp-server/src/models.py create mode 100644 examples/mcp-server/src/state.py diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index 11c0030a..7f1fbafc 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -1,11 +1,65 @@ # Sample: MCP Server -### Available Tools +Exposes human-in-the-loop primitives as MCP tools using the official [`mcp`](https://github.com/modelcontextprotocol/python-sdk) SDK. An AI agent can notify users, ask questions, and request approvals — all delivered through Teams using the bot's proactive messaging and Adaptive Card capabilities. -| Tool | Description | Parameters | Example Usage | -|------|-------------|------------|---------------| -| `echo` | Echo back input text | `input: str` | Echo functionality from docs | -| `get_weather` | Get weather for a location | `location: str` | Always returns "sunny" | -| `calculate` | Basic arithmetic operations | `operation: str, a: float, b: float` | add, subtract, multiply, divide | -| `alert` | Send proactive message to Teams user | `user_id: str, message: str` | Human-in-the-loop notifications | +## Setup +```bash +uv sync +cp .env.example .env # fill in CLIENT_ID, CLIENT_SECRET, TENANT_ID +``` + +## Run + +```bash +uv run python src/main.py +``` + +The bot listens for Teams activity on `POST /api/messages` (port 3978 by default) and serves the MCP endpoint at `http://localhost:3978/mcp`. + +## How it works + +The sample is split across four modules: + +| File | Responsibility | +|------|---------------| +| `app.py` | `App` instance, Teams activity handlers (`on_message`, `on_card_action_execute`) | +| `mcp_tools.py` | `FastMCP` instance, MCP tool definitions (`@mcp.tool()`) | +| `state.py` | Shared in-memory state (conversation map, pending asks, approvals) | +| `main.py` | Entry point — wires the MCP server onto the Teams FastAPI server and starts everything | + +Tools are registered with `@mcp.tool()` on the `FastMCP` instance in `mcp_tools.py`. The MCP server is mounted onto the same FastAPI server that handles Teams activity — `app.initialize()` must be called first so `/api/messages` is registered before the catch-all MCP mount at `/`. + +The bot handler (`on_message`) captures user replies to pending asks. Approval decisions are captured via `on_card_action_execute` when the user clicks Approve or Reject on the card. Both are surfaced to the MCP client via the polling tools. + +All tools return JSON. + +## Available Tools + +| Tool | Description | Parameters | +|------|-------------|------------| +| `notify` | Send a one-way notification to a Teams user | `user_id, message` | +| `ask` | Ask a Teams user a question; returns a `request_id` | `user_id, question` | +| `get_reply` | Poll for the user's reply to an `ask`; returns `pending` until answered | `request_id` | +| `request_approval` | Send an Approve/Reject card to a Teams user; returns an `approval_id` | `user_id, title, description` | +| `get_approval` | Poll for the approval decision: `pending`, `approved`, or `rejected` | `approval_id` | + +## Example agent workflow + +1. `request_approval` — agent sends "Can you approve deployment to prod?" to an on-call engineer +2. Engineer clicks **Approve** on the card in Teams +3. `get_approval` — agent reads `"approved"` and proceeds with the deployment + +## Limitations + +All state (`personal_conversations`, `pending_asks`, `approvals`) is held in memory. A server restart clears everything — pending asks and approvals in flight will be lost. For production use, replace the in-memory dicts with a persistent store (e.g. Redis or a database). + +## Testing with MCP Inspector + +```bash +npx @modelcontextprotocol/inspector +``` + +1. Open the URL printed in the terminal — it includes a `MCP_PROXY_AUTH_TOKEN` query param that must be present. +2. Set transport to **Streamable HTTP** and URL to `http://localhost:3978/mcp`, then connect. +3. Call `ask` or `request_approval` with a `user_id`, then respond in Teams and poll for the result. diff --git a/examples/mcp-server/pyproject.toml b/examples/mcp-server/pyproject.toml index b4bde87c..eea2e8c1 100644 --- a/examples/mcp-server/pyproject.toml +++ b/examples/mcp-server/pyproject.toml @@ -1,14 +1,13 @@ [project] name = "mcp-server" version = "0.1.0" -description = "a test to test out mcp server capabilities" +description = "Human-in-the-loop MCP tools backed by Teams bot proactive messaging" readme = "README.md" requires-python = ">=3.12,<3.15" dependencies = [ "dotenv>=0.9.9", - "microsoft-teams-apps", - "microsoft-teams-mcpplugin", - "microsoft-teams-devtools" + "mcp>=1.13.1", + "microsoft-teams-apps" ] [tool.uv.sources] diff --git a/examples/mcp-server/src/app.py b/examples/mcp-server/src/app.py new file mode 100644 index 00000000..a0bf2f12 --- /dev/null +++ b/examples/mcp-server/src/app.py @@ -0,0 +1,54 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import logging + +from microsoft_teams.api import ( + AdaptiveCardActionMessageResponse, + AdaptiveCardInvokeActivity, + AdaptiveCardInvokeResponse, + MessageActivity, +) +from microsoft_teams.apps import ActivityContext, App +from state import approvals, pending_asks, personal_conversations, user_pending_ask + +app = App() +logger = logging.getLogger(__name__) + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + """Capture user replies and cache 1:1 conversation IDs.""" + user_id = ctx.activity.from_.id + conversation_id = ctx.activity.conversation.id + + if ctx.activity.conversation.conversation_type == "personal": + personal_conversations[user_id] = conversation_id + + request_id = user_pending_ask.pop(user_id, None) + if request_id and request_id in pending_asks: + pending_asks[request_id].reply = ctx.activity.text or "" + pending_asks[request_id].status = "answered" + await ctx.reply("Got it, thank you!") + else: + logger.info( + f"Received message from user {user_id} in conversation {conversation_id}, but no pending ask found." + ) + await ctx.reply("Hi! I'll let you know if I need anything.") + + +@app.on_card_action_execute("approval_response") +async def handle_approval_response(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Capture approve/reject decisions from approval cards.""" + data = ctx.activity.value.action.data + approval_id = data.get("approval_id") + decision = data.get("decision") + if approval_id and approval_id in approvals and decision in ("approved", "rejected"): + approvals[approval_id] = decision + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Response recorded", + ) diff --git a/examples/mcp-server/src/main.py b/examples/mcp-server/src/main.py index 1d15020c..6583c2b2 100644 --- a/examples/mcp-server/src/main.py +++ b/examples/mcp-server/src/main.py @@ -4,151 +4,37 @@ """ import asyncio -from typing import Dict +import logging +import os -from microsoft_teams.ai import Function -from microsoft_teams.api.activities.message.message import MessageActivity -from microsoft_teams.apps import App -from microsoft_teams.apps.routing.activity_context import ActivityContext -from microsoft_teams.devtools import DevToolsPlugin -from microsoft_teams.mcpplugin import McpServerPlugin -from pydantic import BaseModel +from app import app +from mcp_tools import mcp +from microsoft_teams.apps.http.fastapi_adapter import FastAPIAdapter -# Configure MCP server with custom name (as shown in docs) -mcp_server_plugin = McpServerPlugin( - name="test-mcp", -) +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper()) +logger = logging.getLogger(__name__) -# Storage for conversation IDs (for proactive messaging) -conversation_storage: Dict[str, str] = {} +async def main() -> None: + # app.initialize() must be called before mounting the MCP app so that + # /api/messages is registered first — FastAPI routes take priority over + # mounted sub-applications, and the MCP mount uses a catch-all path (/). + await app.initialize() -# Echo tool from documentation example -class EchoParams(BaseModel): - input: str + adapter = app.server.adapter + if not isinstance(adapter, FastAPIAdapter): + raise RuntimeError( + f"This example requires FastAPIAdapter, got {type(adapter).__name__}. " + "Do not pass a custom adapter to App()." + ) + mcp_http_app = mcp.streamable_http_app() + # Register the MCP lifespan so its startup/shutdown hooks run with the server. + adapter.lifespans.append(mcp_http_app.router.lifespan_context) + adapter.app.mount("/", mcp_http_app) -async def echo_handler(params: EchoParams) -> str: - return f"You said {params.input}" - - -# Weather tool (existing) -class GetWeatherParams(BaseModel): - location: str - - -async def get_weather_handler(params: GetWeatherParams): - return f"The weather in {params.location} is sunny" - - -class CalculateParams(BaseModel): - operation: str - a: float - b: float - - -async def calculate_handler(params: CalculateParams) -> str: - match params.operation: - case "add": - return str(params.a + params.b) - case "subtract": - return str(params.a - params.b) - case "multiply": - return str(params.a * params.b) - case "divide": - return str(params.a / params.b) if params.b != 0 else "Cannot divide by zero" - case _: - return "Unknown operation" - - -# Alert tool for proactive messaging (as mentioned in docs) -class AlertParams(BaseModel): - user_id: str - message: str - - -async def alert_handler(params: AlertParams) -> str: - """ - Send proactive message to user via Teams. - This demonstrates the "piping messages to user" feature from docs. - """ - # 1. Validate if the incoming request is allowed to send messages - if not params.user_id or not params.message: - return "Invalid parameters: user_id and message are required" - - # 2. Fetch the correct conversation ID for the given user - conversation_id = conversation_storage.get(params.user_id) - if not conversation_id: - return f"No conversation found for user {params.user_id}. User needs to message the bot first." - - # 3. Send proactive message (simplified - in real implementation would use proper proactive messaging) - await app.send(conversation_id=conversation_id, activity=params.message) - return f"Alert sent to user {params.user_id}: {params.message} (conversation: {conversation_id})" - - -# Register echo tool (from documentation) -mcp_server_plugin.use_tool( - Function( - name="echo", - description="echo back whatever you said", - parameter_schema=EchoParams, - handler=echo_handler, - ) -) - -# Register weather tool -mcp_server_plugin.use_tool( - Function( - name="get_weather", - description="Get a location's weather", - parameter_schema=GetWeatherParams, - handler=get_weather_handler, - ) -) - -# Register calculator tool -mcp_server_plugin.use_tool( - Function( - name="calculate", - description="Perform basic arithmetic operations", - parameter_schema=CalculateParams, - handler=calculate_handler, - ) -) - -# Register alert tool for proactive messaging -mcp_server_plugin.use_tool( - Function( - name="alert", - description="Send proactive message to a Teams user", - parameter_schema=AlertParams, - handler=alert_handler, - ) -) - -app = App(plugins=[mcp_server_plugin, DevToolsPlugin()]) - - -@app.on_message -async def handle_message(ctx: ActivityContext[MessageActivity]): - """ - Handle incoming messages and store conversation IDs for proactive messaging. - This demonstrates the conversation ID storage mentioned in the docs. - """ - # Store conversation ID for this user (for proactive messaging) - user_id = ctx.activity.from_.id - conversation_id = ctx.activity.conversation.id - conversation_storage[user_id] = conversation_id - - print(f"User {ctx.activity.from_} just sent a message!") - - # Echo back the message with info about stored conversation - await ctx.reply( - f"You said: {ctx.activity.text}\n\n" - f"📝 Stored conversation ID `{conversation_id}` for user `{user_id}` " - f"(for proactive messaging via MCP alert tool)" - ) + await app.start() if __name__ == "__main__": - asyncio.run(app.start()) + asyncio.run(main()) diff --git a/examples/mcp-server/src/mcp_tools.py b/examples/mcp-server/src/mcp_tools.py new file mode 100644 index 00000000..f5607e62 --- /dev/null +++ b/examples/mcp-server/src/mcp_tools.py @@ -0,0 +1,89 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import os +import uuid + +from app import app +from mcp.server.fastmcp import FastMCP +from microsoft_teams.api import Account, CreateConversationParams +from microsoft_teams.cards import AdaptiveCard, ExecuteAction, SubmitData, TextBlock +from models import ApprovalRequestResult, ApprovalResult, AskResult, NotifyResult, PendingAsk, ReplyResult +from state import approvals, pending_asks, personal_conversations, user_pending_ask + +mcp = FastMCP("teams-bot") + + +async def _get_or_create_conversation(user_id: str) -> str: + """Return the 1:1 conversation_id for user_id, creating one if needed.""" + if user_id in personal_conversations: + return personal_conversations[user_id] + tenant_id = os.getenv("TENANT_ID") + resource = await app.api.conversations.create( + CreateConversationParams(members=[Account(id=user_id)], tenant_id=tenant_id) + ) + personal_conversations[user_id] = resource.id + return resource.id + + +@mcp.tool() +async def notify(user_id: str, message: str) -> NotifyResult: + """Send a notification to a Teams user. No response expected.""" + conversation_id = await _get_or_create_conversation(user_id) + await app.send(conversation_id=conversation_id, activity=message) + return NotifyResult(notified=True, user_id=user_id) + + +@mcp.tool() +async def ask(user_id: str, question: str) -> AskResult: + """Ask a Teams user a question. Returns a request_id — use get_reply for their response.""" + conversation_id = await _get_or_create_conversation(user_id) + request_id = str(uuid.uuid4()) + await app.send(conversation_id=conversation_id, activity=question) + pending_asks[request_id] = PendingAsk(user_id=user_id) + user_pending_ask[user_id] = request_id + return AskResult(request_id=request_id) + + +@mcp.tool() +async def get_reply(request_id: str) -> ReplyResult: + """Get the reply to a question sent with ask. Returns status 'pending' until the user responds.""" + entry = pending_asks.get(request_id) + if not entry: + raise ValueError(f"No ask found with request_id {request_id}.") + return ReplyResult(status=entry.status, reply=entry.reply) + + +@mcp.tool() +async def request_approval(user_id: str, title: str, description: str) -> ApprovalRequestResult: + """Send an approval request to a Teams user. Returns an approval_id — use get_approval for the decision.""" + conversation_id = await _get_or_create_conversation(user_id) + approval_id = str(uuid.uuid4()) + card = AdaptiveCard( + body=[ + TextBlock(text=title, weight="Bolder", size="Large", wrap=True), + TextBlock(text=description, wrap=True), + ], + actions=[ + ExecuteAction(title="Approve").with_data( + SubmitData("approval_response", {"approval_id": approval_id, "decision": "approved"}) + ), + ExecuteAction(title="Reject").with_data( + SubmitData("approval_response", {"approval_id": approval_id, "decision": "rejected"}) + ), + ], + ) + await app.send(conversation_id=conversation_id, activity=card) + approvals[approval_id] = "pending" + return ApprovalRequestResult(approval_id=approval_id) + + +@mcp.tool() +async def get_approval(approval_id: str) -> ApprovalResult: + """Get the status of an approval request. Returns 'pending', 'approved', or 'rejected'.""" + status = approvals.get(approval_id) + if status is None: + raise ValueError(f"No approval found with approval_id {approval_id}.") + return ApprovalResult(approval_id=approval_id, status=status) diff --git a/examples/mcp-server/src/models.py b/examples/mcp-server/src/models.py new file mode 100644 index 00000000..1193b73d --- /dev/null +++ b/examples/mcp-server/src/models.py @@ -0,0 +1,37 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Literal, Optional + +from pydantic import BaseModel + + +class PendingAsk(BaseModel): + user_id: str + status: Literal["pending", "answered"] = "pending" + reply: Optional[str] = None + + +class NotifyResult(BaseModel): + notified: bool + user_id: str + + +class AskResult(BaseModel): + request_id: str + + +class ReplyResult(BaseModel): + status: Literal["pending", "answered"] + reply: Optional[str] + + +class ApprovalRequestResult(BaseModel): + approval_id: str + + +class ApprovalResult(BaseModel): + approval_id: str + status: Literal["pending", "approved", "rejected"] diff --git a/examples/mcp-server/src/state.py b/examples/mcp-server/src/state.py new file mode 100644 index 00000000..b0f25668 --- /dev/null +++ b/examples/mcp-server/src/state.py @@ -0,0 +1,22 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Dict, Literal + +from models import PendingAsk + +# Maps user_id -> personal conversation_id. +# Populated on first incoming 1:1 message, or on first proactive send. +personal_conversations: Dict[str, str] = {} + +# Maps request_id -> PendingAsk. +pending_asks: Dict[str, PendingAsk] = {} + +# Maps user_id -> request_id for their current pending ask. +# Cleared once the user replies. +user_pending_ask: Dict[str, str] = {} + +# Maps approval_id -> approval status. +approvals: Dict[str, Literal["pending", "approved", "rejected"]] = {} diff --git a/uv.lock b/uv.lock index eeaf3c46..9ccdd318 100644 --- a/uv.lock +++ b/uv.lock @@ -1665,17 +1665,15 @@ version = "0.1.0" source = { virtual = "examples/mcp-server" } dependencies = [ { name = "dotenv" }, + { name = "mcp" }, { name = "microsoft-teams-apps" }, - { name = "microsoft-teams-devtools" }, - { name = "microsoft-teams-mcpplugin" }, ] [package.metadata] requires-dist = [ { name = "dotenv", specifier = ">=0.9.9" }, + { name = "mcp", specifier = ">=1.13.1" }, { name = "microsoft-teams-apps", editable = "packages/apps" }, - { name = "microsoft-teams-devtools", editable = "packages/devtools" }, - { name = "microsoft-teams-mcpplugin", editable = "packages/mcpplugin" }, ] [[package]]