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/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" }, 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/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 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/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/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 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( 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" 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..9ccdd318 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" }, @@ -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]] @@ -2797,7 +2795,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 +2804,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]] @@ -2861,11 +2859,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]]