Skip to content

Design: Teams Access#

Overview#

Purpose: Expose Microsoft Teams capabilities to AI assistants via the MCP protocol, enabling users to browse teams, channels, and chats, read messages, and search across conversations using delegated read-only permissions.

Scope: - Eight MCP tools: list_teams, list_channels, list_channel_messages, get_channel_message_replies, list_chats, list_chat_messages, list_chat_members, search_messages - Read-only access to the authenticated user's Teams data via Microsoft Graph API - Cross-channel and cross-chat search via Microsoft Search API - OAuth2 On-Behalf-Of (OBO) authentication — see DESIGN:OAuthAuthentication

Out of Scope: - Message creation, editing, or deletion - Channel creation or management - Team membership management - File or tab operations in Teams - Calling, meetings, or video capabilities - Cross-user access (admin scenarios)


Architecture#

┌────────────────────┐     ┌──────────────────────┐     ┌──────────────────────┐
│  AI Assistant      │────▶│  Teams MCP Server    │────▶│  Microsoft Graph API │
│  (Claude / MCP     │     │  (FastMCP / ECS)     │     │  /v1.0/me/joinedTeams│
│   client)          │◀────│                      │◀────│  /v1.0/teams/...     │
└────────────────────┘     └──────────────────────┘     │  /v1.0/me/chats      │
                                      │                 │  /v1.0/search/query  │
                                      ▼                 └──────────────────────┘
                            ┌──────────────────────┐
                            │  OBOAuthenticator    │
                            │  (auth.py)           │
                            └──────────────────────┘
                            ┌──────────────────────┐
                            │  Token Storage       │
                            │  (DynamoDB / Memory) │
                            └──────────────────────┘

Components: - FastMCP Server (server.py) — ASGI HTTP server, MCP protocol handler, tool registration - Browse Tools (tools/browse.py) — MCP tool wrappers for teams, channels, chats, messages - Search Tools (tools/search.py) — MCP tool wrapper for cross-conversation message search - Graph Teams Client (graph/teams.py) — Microsoft Graph API calls for teams and channels resources - Graph Messages Client (graph/messages.py) — Microsoft Graph API calls for channel and chat messages - Graph Members Client (graph/members.py) — Microsoft Graph API calls for chat members - Graph Search Client (graph/search.py) — Microsoft Search API calls for message search - Graph Client (graph/client.py) — Authenticated HTTP client (OBO token injection) - OBOAuthenticator (auth.py) — On-Behalf-Of token exchange; see DESIGN:OAuthAuthentication - Microsoft Graph API — Upstream data source; enforces user-scoped authorization


Tech Stack#

Backend: Python 3.13, FastMCP ≥3.0.0, Starlette (ASGI) HTTP Client: httpx (async) OAuth: MSAL 1.28+, OBO flow via auth.py Token Storage: py-key-value-aio (DynamoDB in production, memory in dev) Infrastructure: AWS ECS Fargate, Application Load Balancer, ECR Secret Management: AWS SSM Parameter Store (KMS encrypted) Deployment: Docker (multi-stage), GitHub Actions CI/CD


MCP Tool API#

list_teams#

Lists all Teams the authenticated user is a member of.

Parameters: None

Returns: List of { id, displayName, description }

Graph endpoint: GET /v1.0/me/joinedTeams

Note: Uses /me/joinedTeams rather than /users/{id}/joinedTeams to enforce user-scoped access structurally (same pattern as OutlookMail and OutlookCalendar).


list_channels#

Lists channels in a specific team.

Parameters: - team_id (str, required) — unique team ID from list_teams - name (str, optional) — filter by channel name (case-sensitive substring match) - membership_type (str, optional) — filter by channel type: private, shared, or standard

Returns: List of { id, displayName, description, membershipType, webUrl }

Graph endpoint: GET /v1.0/teams/{team_id}/channels

Note: Membership filtering is applied client-side (Graph API does not support $filter on membershipType).


list_channel_messages#

Lists messages in a specific channel, with optional inline reply expansion.

Parameters: - team_id (str, required) — unique team ID - channel_id (str, required) — unique channel ID from list_channels - expand (str, optional) — if set to "replies", includes all reply threads inline (reduces round-trips) - count (int, default: 25) — maximum messages to return (1–50)

Returns: List of { id, subject, body, attachments, from, createdDateTime, webUrl, replies? }

Graph endpoint: GET /v1.0/teams/{team_id}/channels/{channel_id}/messages

Note: When expand="replies", Graph returns the full reply tree for each message. This eliminates the need to call get_channel_message_replies separately when full thread context is required (e.g., AI assistant analyzing a discussion).


get_channel_message_replies#

Fetches all replies to a specific channel message.

Parameters: - team_id (str, required) — unique team ID - channel_id (str, required) — unique channel ID - message_id (str, required) — unique message ID from list_channel_messages - count (int, default: 25) — maximum replies to return (1–50)

Returns: List of { id, subject, body, attachments, from, createdDateTime, webUrl }

Graph endpoint: GET /v1.0/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies

Note: Use this when list_channel_messages was called without expand="replies" and the user wants to drill into a specific thread.


list_chats#

Lists all chats (1:1, group, or meeting chats) the authenticated user is part of.

Parameters: - expand (str, optional) — if set to "lastMessagePreview", includes the most recent message in each chat (use sparingly; adds latency) - chat_type (str, optional) — filter by chat type: oneOnOne, group, or meeting - order_by (str, optional) — if set to "lastMessagePreview/createdDateTime desc", returns chats sorted by most recent message (requires expand="lastMessagePreview") - count (int, default: 25) — maximum chats to return (1–50)

Returns: List of { id, topic, chatType, createdDateTime, viewpoint, webUrl, lastMessagePreview? }

Graph endpoint: GET /v1.0/me/chats

Note: Uses /me/chats (not /users/{id}/chats) to enforce user-scoped access. The order_by parameter provides most-recent-first ordering for AI assistants needing conversation context.


list_chat_messages#

Lists messages in a specific chat.

Parameters: - chat_id (str, required) — unique chat ID from list_chats - sent_before (datetime, optional) — ISO 8601 datetime filter (e.g., 2026-04-22T12:00:00Z) - count (int, default: 25) — maximum messages to return (1–50)

Returns: List of { id, subject, body, attachments, from, createdDateTime, webUrl }

Graph endpoint: GET /v1.0/me/chats/{chat_id}/messages

Note: Messages are returned in reverse chronological order (most recent first). sent_before enables pagination through older messages.


list_chat_members#

Lists all members in a specific chat.

Parameters: - chat_id (str, required) — unique chat ID from list_chats

Returns: List of { id, displayName, email }

Graph endpoint: GET /v1.0/me/chats/{chat_id}/members

Note: Returns both active members and users who have left the chat (Graph API does not distinguish).


search_messages#

Searches messages across all teams, channels, and chats using Microsoft Search API.

Parameters: - query_text (str, optional) — full-text search query (KQL syntax supported) - from_user (str, optional) — filter by sender name or email initials - to_user (str, optional) — filter by recipient name or email initials - sent_after (datetime, optional) — ISO 8601 datetime lower bound - sent_before (datetime, optional) — ISO 8601 datetime upper bound - has_attachment (bool, optional) — filter for messages with attachments - is_mentioned (bool, optional) — filter for messages where the authenticated user is @mentioned - size (int, default: 25) — maximum results to return (1–1000) - offset (int, default: 0) — pagination offset

Returns: List of { id, createdDateTime, subject, from, channelIdentity, chatId, webLink, summary }

Graph endpoint: POST /v1.0/search/query (Microsoft Search API with entity type chatMessage)

Note: Returns a summary field containing a snippet of the message body with search term highlighting. This is a dedicated search API, not a scan-and-filter operation — it indexes all Teams content for the user.


Security#

Authentication: OAuth2 OBO flow — see DESIGN:OAuthAuthentication

Authorization: - All Graph API calls use /me/ endpoints for teams and chats — access scoped to the authenticated user - Team and channel access verified by Microsoft Graph API (returns only teams/channels the user is a member of) - No team ID, channel ID, or chat ID validation on the MCP server side — Graph API enforces access control - No user ID parameters accepted from MCP clients (prevents cross-user access)

Graph Permission Scopes: Team.ReadBasic.All, Channel.ReadBasic.All, ChannelMessage.Read.All, Chat.Read, ChatMessage.Read, offline_access

Data Protection: - Teams message data never persisted — stateless HTTP mode (stateless_http=True) - DynamoDB stores OAuth tokens only — no message content, member data, or metadata - Message body, subject, sender, and recipient fields excluded from application logs - LOG_LEVEL=INFO in production — DEBUG logging disabled - Transport: TLS 1.3 (enforced at ALB layer)

Member Privacy: - Member names and email addresses returned only within the active MCP session - Not stored, cached, or logged anywhere in the system - User consented to Chat.Read and Channel.ReadBasic.All scopes during OAuth2 authorization flow


Data Model#

No persistent data model for Teams content. The only data stored is OAuth tokens:

DynamoDB table: mcp-oauth-storage-teams-mcp-{env}
  Partition key: user_id (string)
  Sort key:      session_id (string)
  Attributes:    access_token, refresh_token, expires_at
  TTL:           expires_at + 90 days

All Teams data is fetched on-demand from Microsoft Graph API and returned directly to the MCP client. No caching, no secondary storage.


Integrations#

Microsoft Graph API - Protocol: REST over HTTPS - Auth: OAuth2 Bearer token (OBO-exchanged, user-scoped) - Scopes: Team.ReadBasic.All, Channel.ReadBasic.All, ChannelMessage.Read.All, Chat.Read, ChatMessage.Read, offline_access - Base URL: https://graph.microsoft.com/v1.0 - Rate limits: 10,000 requests per 10 minutes per user (same as Outlook Mail/Calendar), but Teams APIs have additional per-resource throttling (e.g., 3 requests/second per channel for message reads) - Error handling: HTTP 429 propagated to client (respects Retry-After); HTTP 401/403 re-raised; transient 5xx errors surfaced as MCP tool errors with user-friendly message

Microsoft Search API - Protocol: REST over HTTPS (Graph API /search/query endpoint) - Entity type: chatMessage - Indexing: User-scoped — only messages visible to the authenticated user are indexed - Latency: ~100–500ms for typical queries - Pagination: from (offset) + size parameters

Azure AD (authentication) - See DESIGN:OAuthAuthentication for full flow - Tenant: Novo Nordisk single-tenant


Performance#

Expected Load: Low — per-user assistant sessions, not batch operations Response Time Target: < 2s for list_teams, list_chats, list_channels; < 3s for list_channel_messages with expand="replies" (heavier payload); < 2s for search_messages OBO Exchange Overhead: ~200–500ms per tool call (see DESIGN:OAuthAuthentication)

Rate Limiting Notes: - Teams Graph APIs have stricter per-resource throttling than mail/calendar APIs - Example: GET /teams/{id}/channels/{id}/messages is throttled at ~3 requests/second per channel - If an AI assistant scans multiple channels rapidly, HTTP 429 errors are likely - The MCP server propagates 429 errors to the client — no automatic retry logic (caller must back off)

Optimization Notes: - No Graph token caching (security > latency) - expand="replies" reduces round-trips but increases payload size and Graph API processing time — use only when full thread context is needed - search_messages is more efficient than scanning multiple channels individually when searching across teams - list_chats with order_by="lastMessagePreview/createdDateTime desc" requires expand="lastMessagePreview", which adds latency — omit both for fastest listing


Deployment#

Environments:

Env URL ECS sizing Storage
Dev teams.dev.connectors.novo-genai.com 0.25 vCPU / 512 MB Memory
Prod teams.connectors.novo-genai.com 0.5 vCPU / 1 GB DynamoDB

CI/CD: .github/workflows/deploy-teams-mcp.yml - lintbuild (push to dev ECR) → deploy-devdeploy-prod (gated: environment: Production) - Prod promotes image from dev ECR (no rebuild)

Secrets (SSM Parameter Store):

/aiconnectors/{env}/teams-mcp/client_id
/aiconnectors/{env}/teams-mcp/client_secret
/aiconnectors/{env}/teams-mcp/dynamodb_table_name

Azure AD App Registration Client IDs: - Dev: 7e0b850a-21c6-4393-ab47-bfdd5ac59f6b - Prod: ac2bcfb1-babc-449c-a54d-7169d7cacb71


Monitoring#

Logs: JSON structured logs → CloudWatch Logs (/ecs/teams-mcp-{env}) Retention: 90 days (GDPR Article 30 compliance)

Key Events Logged: - teams_listed — user identity, count - channels_listed — user identity, team ID, count, filters - channel_messages_listed — user identity, team ID, channel ID, count, expand flag - chats_listed — user identity, count, expand flag, order_by - chat_messages_listed — user identity, chat ID, count - chat_members_listed — user identity, chat ID, count - messages_searched — user identity, filters (no query text), result count - graph_request — method, endpoint, HTTP status (no content) - obo_exchange_failed — error code, user identity

NOT Logged: - Message body, subject, or summary text - Sender or recipient names/email addresses - Chat topic or team names - OAuth tokens or authorization codes

Alerts: - Error rate > 5% over 5 minutes - Graph API 5xx rate > 10 per minute - Graph API 429 rate > 20 per minute (indicates aggressive polling) - ECS health check failures


Key Decisions#

Why expand="replies" in list_channel_messages?#

Decision: list_channel_messages supports an optional expand="replies" parameter that returns the full reply tree inline rather than requiring a separate get_channel_message_replies call per message.

Rationale: AI assistants analyzing a discussion thread need full context. Without expand, retrieving 10 messages with 5 replies each requires 1 + 10 = 11 Graph API calls. With expand, it's a single call. The increased payload size and Graph API processing time (~500ms extra) is acceptable for the reduced round-trip latency. The parameter is optional — callers can omit it when only top-level messages are needed.

Why search_messages uses Microsoft Search API instead of per-channel scan?#

Decision: The search_messages tool uses POST /v1.0/search/query with entity type chatMessage rather than scanning channels and chats individually.

Rationale: Cross-workspace search in one call. The Search API indexes all Teams content for the user and returns ranked results with summary snippets. Scanning channels individually would require: list teams → list channels per team → list messages per channel → filter client-side. This is 10s of API calls with high latency and throttling risk. The Search API is purpose-built for this use case.

Why list_chats supports order_by="lastMessagePreview/createdDateTime desc"?#

Decision: The order_by parameter enables sorting chats by most recent message timestamp rather than chat creation time.

Rationale: AI assistants need most-recent-first ordering for context ("what are my recent conversations?"). Without this, the default order is by chat creation date, which is not useful (old chats with recent activity appear buried). The Graph API requires expand="lastMessagePreview" to enable this sort, which adds latency — but the trade-off is acceptable for the correct behavior.

Why /me/joinedTeams and /me/chats endpoints (not /users/{id}/)?#

Decision: All team and chat access uses /me/ endpoints — no parameterized user ID paths.

Rationale: Same as DESIGN:OutlookMail and DESIGN:OutlookCalendar — prevents cross-user access structurally (R3 in risk assessment), avoids requiring Team.ReadBasic.All and Chat.ReadAll.All application permissions (which are admin-consent only), and ensures Microsoft Graph API enforces access from the OBO token's identity claim. If a malicious client attempts to pass a modified user ID, it has no effect — the Graph API ignores it and uses the token's oid claim.


URS: @URS:TeamsAccess Risk Assessment: @RISK:TeamsAccess OAuth Design: @DESIGN:OAuthAuthentication

Risks Addressed by This Design: - R1 (token compromise) — OBO flow, read-only scopes, TLS - R2 (data in logs) — structured logging with field exclusion, LOG_LEVEL=INFO - R3 (cross-user access) — /me/ endpoints only, no user ID parameters - R4 (member privacy) — no persistence, no logging of member fields, user consent via OAuth2 - R5 (Graph API availability) — graceful error messages, 429 Retry-After respected - R7 (Teams API throttling) — 429 errors propagated to client (no automatic retry), documentation notes per-resource throttling


Version: 1.0 Date: 2026-04-22 Author: AILab Approved by: Pending PR review