msgraph-mcp-server 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ """Teams chat read tools: list the user's chats and their message history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kiota_abstractions.base_request_configuration import RequestConfiguration
6
+ from msgraph.generated.chats.chats_request_builder import ChatsRequestBuilder
7
+ from msgraph.generated.chats.item.messages.messages_request_builder import (
8
+ MessagesRequestBuilder as ChatMessagesRequestBuilder,
9
+ )
10
+
11
+ from msgraph_mcp.auth.token import NotAuthenticatedError
12
+ from msgraph_mcp.graph.errors import map_kiota_error
13
+ from msgraph_mcp.graph.pagination import decode_page_token, encode_next_link, validate_limit
14
+ from msgraph_mcp.graph.serialize import chat_message_to_dict, chat_to_dict
15
+ from msgraph_mcp.graph.trimming import trim_chat, trim_chat_message
16
+
17
+
18
+ def _list_chats_query(*, limit: int):
19
+ qp = ChatsRequestBuilder.ChatsRequestBuilderGetQueryParameters(
20
+ top=limit,
21
+ expand=["members", "lastMessagePreview"],
22
+ )
23
+ return RequestConfiguration[ChatsRequestBuilder.ChatsRequestBuilderGetQueryParameters](
24
+ query_parameters=qp
25
+ )
26
+
27
+
28
+ def _list_chat_messages_query(*, limit: int):
29
+ qp = ChatMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=limit)
30
+ return RequestConfiguration[
31
+ ChatMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters
32
+ ](query_parameters=qp)
33
+
34
+
35
+ async def list_chats(
36
+ *,
37
+ graph,
38
+ limit: int = 25,
39
+ page_token: str | None = None,
40
+ include_raw: bool = False,
41
+ ) -> dict:
42
+ """List the signed-in user's Teams chats (1:1, group, meeting).
43
+
44
+ Members are expanded so 1:1 chats (which have no topic) are identifiable
45
+ by participant name. Results come back in Graph's default order (most
46
+ recent message activity first).
47
+
48
+ Each item carries two timestamps; use the right one:
49
+ - last_message_time: created time of the most recent message (from the
50
+ expanded lastMessagePreview). This is the true "last activity" time.
51
+ - metadata_updated: Graph's lastUpdatedDateTime, which only changes on
52
+ rename/membership events (e.g. someone joining a meeting call), NOT on
53
+ new messages. Do not use it to judge recency -- a busy 1:1 can show a
54
+ months-old metadata_updated.
55
+
56
+ Args:
57
+ limit: 1-100. Default 25.
58
+ page_token: Pass next_page_token from a previous result to continue.
59
+ include_raw: Include the raw Graph payload under "raw" on each item.
60
+
61
+ Returns:
62
+ {"items": [trimmed_chat, ...], "next_page_token": str | None}
63
+ """
64
+ limit = validate_limit(limit)
65
+ builder = graph.raw.me.chats
66
+ try:
67
+ if page_token is not None:
68
+ url = decode_page_token(page_token)
69
+ collection = await builder.with_url(url).get()
70
+ else:
71
+ collection = await builder.get(request_configuration=_list_chats_query(limit=limit))
72
+ except NotAuthenticatedError:
73
+ raise
74
+ except Exception as exc: # noqa: BLE001
75
+ raise map_kiota_error(exc) from exc
76
+
77
+ items = [trim_chat(chat_to_dict(c), include_raw=include_raw) for c in (collection.value or [])]
78
+ return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
79
+
80
+
81
+ async def list_chat_messages(
82
+ *,
83
+ graph,
84
+ chat_id: str,
85
+ limit: int = 25,
86
+ page_token: str | None = None,
87
+ include_body: bool = False,
88
+ include_raw: bool = False,
89
+ ) -> dict:
90
+ """List messages in a chat, newest first.
91
+
92
+ Args:
93
+ chat_id: Graph chat id (from list_chats).
94
+ limit: 1-100. Default 25.
95
+ page_token: Continuation token from a previous result.
96
+ include_body: When True, include each message's full body and any
97
+ attachment card payloads (e.g. Adaptive Card JSON for bot posts).
98
+ Default False (snippet only; card text still feeds the snippet).
99
+ include_raw: Include the raw Graph payload under "raw" on each item.
100
+
101
+ Returns:
102
+ {"items": [trimmed_chat_message, ...], "next_page_token": str | None}
103
+ """
104
+ limit = validate_limit(limit)
105
+ builder = graph.raw.chats.by_chat_id(chat_id).messages
106
+ try:
107
+ if page_token is not None:
108
+ url = decode_page_token(page_token)
109
+ collection = await builder.with_url(url).get()
110
+ else:
111
+ collection = await builder.get(request_configuration=_list_chat_messages_query(limit=limit))
112
+ except NotAuthenticatedError:
113
+ raise
114
+ except Exception as exc: # noqa: BLE001
115
+ raise map_kiota_error(exc) from exc
116
+
117
+ items = [
118
+ trim_chat_message(chat_message_to_dict(m), include_body=include_body, include_raw=include_raw)
119
+ for m in (collection.value or [])
120
+ ]
121
+ return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
122
+
123
+
124
+ def register(mcp, *, graph) -> None:
125
+ @mcp.tool(name="list_chats", description=list_chats.__doc__ or "")
126
+ async def _list_chats(limit: int = 25, page_token: str | None = None, include_raw: bool = False):
127
+ return await list_chats(graph=graph, limit=limit, page_token=page_token, include_raw=include_raw)
128
+
129
+ @mcp.tool(name="list_chat_messages", description=list_chat_messages.__doc__ or "")
130
+ async def _list_chat_messages(
131
+ chat_id: str,
132
+ limit: int = 25,
133
+ page_token: str | None = None,
134
+ include_body: bool = False,
135
+ include_raw: bool = False,
136
+ ):
137
+ return await list_chat_messages(
138
+ graph=graph, chat_id=chat_id, limit=limit,
139
+ page_token=page_token, include_body=include_body, include_raw=include_raw,
140
+ )
@@ -0,0 +1,150 @@
1
+ """Teams inline hosted-content download.
2
+
3
+ Inline images and other hosted content live behind an authenticated Graph
4
+ endpoint (.../hostedContents/{id}/$value) that requires the server's
5
+ delegated token, so an agent cannot fetch them directly. This tool proxies
6
+ that authenticated fetch and returns base64, parallel to download_attachment
7
+ for mail. SharePoint/OneDrive-backed file attachments are a different Graph
8
+ surface and are out of scope (see README).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+
16
+ from msgraph_mcp.auth.token import NotAuthenticatedError
17
+ from msgraph_mcp.graph.errors import GraphValidationError, map_kiota_error
18
+ from msgraph_mcp.graph.trimming import trim_hosted_content_download
19
+ from msgraph_mcp.tools import _binary
20
+
21
+
22
+ def _sniff_content_type(data: bytes) -> str | None:
23
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
24
+ return "image/png"
25
+ if data.startswith(b"\xff\xd8\xff"):
26
+ return "image/jpeg"
27
+ if data[:6] in (b"GIF87a", b"GIF89a"):
28
+ return "image/gif"
29
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
30
+ return "image/webp"
31
+ return None
32
+
33
+
34
+ def _hosted_content_builder(graph, *, chat_id, team_id, channel_id, message_id, hosted_content_id):
35
+ if chat_id is not None:
36
+ message = graph.raw.chats.by_chat_id(chat_id).messages.by_chat_message_id(message_id)
37
+ else:
38
+ message = (
39
+ graph.raw.teams.by_team_id(team_id)
40
+ .channels.by_channel_id(channel_id)
41
+ .messages.by_chat_message_id(message_id)
42
+ )
43
+ return message.hosted_contents.by_chat_message_hosted_content_id(hosted_content_id).content
44
+
45
+
46
+ async def download_hosted_content(
47
+ *,
48
+ graph,
49
+ message_id: str,
50
+ hosted_content_id: str,
51
+ chat_id: str | None = None,
52
+ team_id: str | None = None,
53
+ channel_id: str | None = None,
54
+ save_path: str | None = None,
55
+ include_raw: bool = False,
56
+ ) -> dict | list:
57
+ """Download one inline hosted-content item (usually an image).
58
+
59
+ Specify the message location with exactly one of:
60
+ - chat_id (for a chat message), or
61
+ - team_id AND channel_id (for a channel message).
62
+
63
+ Get message_id and hosted_content_id from a message's hosted_content_refs
64
+ (returned by list_chat_messages / list_channel_messages / list_message_replies).
65
+
66
+ Args:
67
+ message_id: Graph id of the message the content is attached to.
68
+ hosted_content_id: Graph hosted-content id (from hosted_content_refs).
69
+ chat_id: Chat id, if the message is in a chat.
70
+ team_id: Team id, if the message is in a channel.
71
+ channel_id: Channel id, if the message is in a channel.
72
+ save_path: Write the bytes to this file path (or into this existing
73
+ directory) instead of returning content. Returns
74
+ {"path", "content_type", "size_bytes"} with no content payload.
75
+ include_raw: Include the raw payload under "raw" (ignored when the
76
+ result is an image block or a saved file).
77
+
78
+ Returns:
79
+ - Image content (PNG/JPEG/GIF/WebP, no save_path): metadata plus the
80
+ image itself as a native MCP image block, viewable directly.
81
+ - With save_path: {"path": str, "content_type": str | None, "size_bytes": int}.
82
+ - Otherwise: {"content_type": str | None, "size_bytes": int,
83
+ "content_base64": str | None}. content_type is sniffed from the
84
+ bytes (Graph does not return it on the $value endpoint); it may be
85
+ None for unrecognized formats.
86
+ """
87
+ has_chat = chat_id is not None
88
+ has_team = team_id is not None
89
+ has_channel = channel_id is not None
90
+ if has_chat and (has_team or has_channel):
91
+ raise GraphValidationError("Provide either chat_id or (team_id and channel_id), not both.")
92
+ if not has_chat and not (has_team and has_channel):
93
+ raise GraphValidationError(
94
+ "Provide a message location: chat_id, or both team_id and channel_id."
95
+ )
96
+
97
+ builder = _hosted_content_builder(
98
+ graph, chat_id=chat_id, team_id=team_id, channel_id=channel_id,
99
+ message_id=message_id, hosted_content_id=hosted_content_id,
100
+ )
101
+ try:
102
+ data = await builder.get()
103
+ except NotAuthenticatedError:
104
+ raise
105
+ except Exception as exc: # noqa: BLE001
106
+ raise map_kiota_error(exc) from exc
107
+
108
+ if not isinstance(data, (bytes, bytearray)):
109
+ raw = {"contentType": None, "size": 0, "contentBytes": None}
110
+ return trim_hosted_content_download(raw, include_raw=include_raw)
111
+
112
+ data = bytes(data)
113
+ content_type = _sniff_content_type(data)
114
+
115
+ if save_path is not None:
116
+ # hosted_content_id is opaque base64 (unsafe as a filename); hash it.
117
+ digest = hashlib.sha256(hosted_content_id.encode()).hexdigest()[:8]
118
+ ext = _binary.ext_for(content_type)
119
+ default_name = f"hosted-content-{message_id}-{digest}{ext}"
120
+ path = _binary.write_bytes(save_path, data, default_name=default_name)
121
+ return {"path": path, "content_type": content_type, "size_bytes": len(data)}
122
+
123
+ if _binary.is_image(content_type):
124
+ meta = {"content_type": content_type, "size_bytes": len(data)}
125
+ return _binary.image_result(meta, data, content_type)
126
+
127
+ raw = {
128
+ "contentType": content_type,
129
+ "size": len(data),
130
+ "contentBytes": base64.b64encode(data).decode("ascii"),
131
+ }
132
+ return trim_hosted_content_download(raw, include_raw=include_raw)
133
+
134
+
135
+ def register(mcp, *, graph) -> None:
136
+ @mcp.tool(name="download_hosted_content", description=download_hosted_content.__doc__ or "")
137
+ async def _download_hosted_content(
138
+ message_id: str,
139
+ hosted_content_id: str,
140
+ chat_id: str | None = None,
141
+ team_id: str | None = None,
142
+ channel_id: str | None = None,
143
+ save_path: str | None = None,
144
+ include_raw: bool = False,
145
+ ):
146
+ return await download_hosted_content(
147
+ graph=graph, message_id=message_id, hosted_content_id=hosted_content_id,
148
+ chat_id=chat_id, team_id=team_id, channel_id=channel_id,
149
+ save_path=save_path, include_raw=include_raw,
150
+ )
@@ -0,0 +1,35 @@
1
+ """Utility tools (whoami)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from msgraph_mcp.auth.token import NotAuthenticatedError
6
+ from msgraph_mcp.graph.errors import map_kiota_error
7
+ from msgraph_mcp.graph.serialize import user_to_dict
8
+ from msgraph_mcp.graph.trimming import trim_user
9
+
10
+
11
+ async def whoami(*, graph, include_raw: bool = False) -> dict:
12
+ """Return the signed-in user's profile.
13
+
14
+ Also a smoke test that authentication is working.
15
+
16
+ Args:
17
+ include_raw: when True, include the raw Graph payload under "raw".
18
+
19
+ Returns:
20
+ Trimmed user object: {id, display_name, user_principal_name, mail, job_title}
21
+ """
22
+ try:
23
+ user = await graph.mailbox(None).get()
24
+ except NotAuthenticatedError:
25
+ # Surface the login-required hint directly; don't wrap as a Graph error.
26
+ raise
27
+ except Exception as exc: # noqa: BLE001
28
+ raise map_kiota_error(exc) from exc
29
+ return trim_user(user_to_dict(user), include_raw=include_raw)
30
+
31
+
32
+ def register(mcp, *, graph) -> None:
33
+ @mcp.tool(name="whoami", description=whoami.__doc__ or "")
34
+ async def _whoami(include_raw: bool = False) -> dict:
35
+ return await whoami(graph=graph, include_raw=include_raw)
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.5
2
+ Name: msgraph-mcp-server
3
+ Version: 0.3.0
4
+ Summary: MCP server for Microsoft Graph: Outlook mail and calendar, plus read-only Microsoft Teams
5
+ Project-URL: Homepage, https://github.com/timfurlong/msgraph-mcp
6
+ Project-URL: Repository, https://github.com/timfurlong/msgraph-mcp
7
+ Project-URL: Issues, https://github.com/timfurlong/msgraph-mcp/issues
8
+ Author: Tim Furlong
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: calendar,email,mcp,microsoft-graph,model-context-protocol,outlook,teams
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications :: Email
20
+ Classifier: Topic :: Office/Business :: Scheduling
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: mcp>=1.0
23
+ Requires-Dist: msal>=1.31
24
+ Requires-Dist: msgraph-sdk>=1.0
25
+ Requires-Dist: python-dotenv>=1.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # MSGraph MCP
29
+
30
+ <!-- mcp-name: io.github.timfurlong/msgraph-mcp -->
31
+
32
+ A Model Context Protocol (MCP) server for **Microsoft Graph**. It exposes Microsoft Outlook **mail** and **calendar**, plus read-only Microsoft **Teams** message history, to AI agents via the Microsoft Graph SDK. Acts as the signed-in user (delegated permissions, MSAL device code flow).
33
+
34
+ > Formerly published as `outlook-mcp`. Renamed because the scope grew beyond Outlook (Teams today, potentially other Graph surfaces later). Outlook mail and calendar remain first-class capabilities. See [Migrating from outlook-mcp](#migrating-from-outlook-mcp).
35
+
36
+ ## What it does
37
+
38
+ Workflow-oriented tools covering common mail, calendar, and read-only Teams operations:
39
+
40
+ | Group | Tools |
41
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
42
+ | Util | `whoami` |
43
+ | Mail — read | `list_messages`, `search_messages`, `get_message`, `list_attachments`, `download_attachment` |
44
+ | Mail — write | `send_message`, `create_draft`, `reply_message`, `reply_all_message`, `forward_message`, `update_message`, `delete_message` |
45
+ | Mail — folders | `list_folders`, `create_folder`, `update_folder`, `delete_folder`, `move_message` |
46
+ | Mail — actions | `archive_message`, `mark_read`, `mark_unread`, `flag_message`, `unflag_message` |
47
+ | Mail — rules | `list_rules`, `get_rule`, `create_rule`, `update_rule`, `delete_rule` |
48
+ | Calendar | `list_calendars`, `list_events`, `get_event`, `create_event`, `update_event`, `delete_event`, `cancel_event`, `respond_to_event`, `find_meeting_times` |
49
+ | Teams (read) | `list_chats`, `list_chat_messages`, `list_joined_teams`, `list_channels`, `list_channel_messages`, `list_message_replies`, `download_hosted_content` |
50
+
51
+ Every tool that touches a mailbox or calendar accepts an optional `mailbox` argument (email or user ID) to target shared mailboxes/calendars. Omit it to use the signed-in user's own mailbox.
52
+
53
+ Every tool that returns objects accepts `include_raw=true` to also include the full Graph payload.
54
+
55
+ List/search tools support pagination via `limit` (1-100, default 25) and `page_token`.
56
+
57
+ ## Prerequisites
58
+
59
+ - Python ≥ 3.11
60
+ - `uv`: https://docs.astral.sh/uv/
61
+ - An Entra (Azure AD) app registration with the right permissions (see "Entra setup" below)
62
+
63
+ ## Install
64
+
65
+ From PyPI (package `msgraph-mcp-server`; the commands it installs are `msgraph-mcp` and `msgraph-mcp-login`):
66
+
67
+ ```bash
68
+ uv tool install msgraph-mcp-server # or: pip install msgraph-mcp-server
69
+ ```
70
+
71
+ Then use `msgraph-mcp-login` / `msgraph-mcp` directly wherever the quickstart below says `uv run ...`, and wire the host with `claude mcp add msgraph -- msgraph-mcp`.
72
+
73
+ ## Quickstart (from source)
74
+
75
+ ```bash
76
+ # 1. Install dependencies
77
+ uv sync
78
+
79
+ # 2. Configure environment
80
+ cp .env.example .env
81
+ # Fill in MSGRAPH_MCP_CLIENT_ID and MSGRAPH_MCP_TENANT_ID
82
+
83
+ # 3. One-time sign-in (device code flow)
84
+ uv run msgraph-mcp-login
85
+ # Follow the prompt: visit the URL, enter the code, complete sign-in.
86
+ # A token cache is written to ~/.msgraph-mcp/token_cache.bin (mode 0600).
87
+
88
+ # 4. Wire the MCP into your host
89
+ # - Claude Code:
90
+ claude mcp add msgraph -- uv --directory "$(pwd)" run msgraph-mcp
91
+
92
+ # - Anything else: configure the host to launch `uv run msgraph-mcp` (stdio).
93
+ ```
94
+
95
+ ## Entra setup
96
+
97
+ The app registration (e.g. "MSGraph MCP") requires:
98
+
99
+ - **Account type:** single tenant
100
+ - **Redirect URI (public client):** `https://login.microsoftonline.com/common/oauth2/nativeclient`
101
+ - **Delegated permissions** (Microsoft Graph):
102
+ - `Mail.ReadWrite`
103
+ - `Mail.ReadWrite.Shared`
104
+ - `Mail.Send`
105
+ - `MailboxSettings.ReadWrite`
106
+ - `Calendars.ReadWrite`
107
+ - `Calendars.ReadWrite.Shared`
108
+ - `User.Read`
109
+ - `Chat.Read` (Teams)
110
+ - `Team.ReadBasic.All` (Teams)
111
+ - `Channel.ReadBasic.All` (Teams)
112
+ - `ChannelMessage.Read.All` (Teams)
113
+ - Admin consent: required for `ChannelMessage.Read.All` (always), plus the `*.Shared` permissions if your tenant requires it.
114
+
115
+ > If you signed in before any of these scopes were added to the app (for example `MailboxSettings.ReadWrite`, or the Teams scopes), re-run `uv run msgraph-mcp-login` so the cached token picks up the new scopes. Without them, calls needing the missing scope fail with a consent error.
116
+
117
+ The CLI uses public-client device code flow — **no client secret** is needed or stored.
118
+
119
+ ## Environment variables
120
+
121
+ | Var | Required | Default | Purpose |
122
+ | ------------------------------ | -------- | -------------------------------- | ----------------------------------- |
123
+ | `MSGRAPH_MCP_CLIENT_ID` | yes | — | Entra (Azure AD) app client ID |
124
+ | `MSGRAPH_MCP_TENANT_ID` | yes | — | Tenant ID (single-tenant authority) |
125
+ | `MSGRAPH_MCP_TOKEN_CACHE_PATH` | no | `~/.msgraph-mcp/token_cache.bin` | Override token cache file location |
126
+
127
+ Process env wins; `.env` at the repo root is loaded as a dev fallback.
128
+
129
+ Legacy `OUTLOOK_MCP_*` names are honored as a fallback for each variable (the `MSGRAPH_MCP_*` name wins when both are set).
130
+
131
+ ## Migrating from outlook-mcp
132
+
133
+ This project was named `outlook-mcp` through v0.2.0. What changed in the rename:
134
+
135
+ | Old | New |
136
+ | ---------------------------------- | ---------------------------------- |
137
+ | package `outlook-mcp` | package `msgraph-mcp` |
138
+ | module `outlook_mcp` | module `msgraph_mcp` |
139
+ | `uv run outlook-mcp` | `uv run msgraph-mcp` |
140
+ | `uv run outlook-mcp-login` | `uv run msgraph-mcp-login` |
141
+ | `OUTLOOK_MCP_*` env vars | `MSGRAPH_MCP_*` env vars |
142
+ | `~/.outlook-mcp/token_cache.bin` | `~/.msgraph-mcp/token_cache.bin` |
143
+
144
+ Backward compatibility, so an existing setup keeps working without re-authenticating:
145
+
146
+ - `OUTLOOK_MCP_*` env vars are still read as a fallback.
147
+ - If `~/.msgraph-mcp/token_cache.bin` does not exist but `~/.outlook-mcp/token_cache.bin` does, the legacy cache is used. To move to the new location: `mv ~/.outlook-mcp ~/.msgraph-mcp`.
148
+
149
+ You do need to update anything that launches the server by script name (MCP host configs): `outlook-mcp` → `msgraph-mcp`.
150
+
151
+ ## Security
152
+
153
+ - The token cache contains your **refresh token**, which can mint access tokens for your mail, calendar, and Teams data. Treat it like a credential.
154
+ - Default location: `~/.msgraph-mcp/token_cache.bin`, mode `0600`, parent dir mode `0700`.
155
+ - To **revoke** access: sign in to https://account.microsoft.com or your org's identity portal, revoke the app, then `rm ~/.msgraph-mcp/token_cache.bin`.
156
+ - To **switch accounts**: `rm ~/.msgraph-mcp/token_cache.bin` and re-run `msgraph-mcp-login`.
157
+
158
+ ## Recipes
159
+
160
+ ### Route a sender into a new folder
161
+
162
+ ```
163
+ # 1. Make a folder for the notifications.
164
+ create_folder(display_name="Notifications")
165
+ # -> {"id": "AAMkFolderId", "display_name": "Notifications", ...}
166
+
167
+ # 2. Create an inbox rule that moves matching senders into it.
168
+ create_rule(
169
+ display_name="Notifications",
170
+ sender_contains=["example.com"],
171
+ move_to_folder="AAMkFolderId",
172
+ stop_processing_rules=True,
173
+ )
174
+ ```
175
+
176
+ Conditions inside one rule are AND-ed by Outlook. Pass a list to a single condition (e.g. `sender_contains=["example.com", "monitor.io"]`) for OR within that condition. Rules only run against the inbox — Graph's `messageRules` endpoint is hardcoded there and does not support per-folder rules.
177
+
178
+ `create_rule` requires at least one condition and one action. `update_rule` patches a rule in place but **replaces** the `conditions` or `actions` block whenever you pass any condition/action arg — call `get_rule` first if you need to preserve existing values.
179
+
180
+ ## Microsoft Teams (read-only)
181
+
182
+ Read Teams message history as the signed-in user:
183
+
184
+ - `list_chats`, `list_chat_messages`: your 1:1 and group chats.
185
+ - `list_joined_teams`, `list_channels`, `list_channel_messages`, `list_message_replies`: team channels and their threads.
186
+ - `download_hosted_content`: download an inline image referenced by a message (`hosted_content_refs`). Images come back as a native MCP image block the agent can view directly; pass `save_path` (file or existing directory) to write the bytes to disk and get back a path instead.
187
+
188
+ ### Permissions and consent
189
+
190
+ These delegated scopes are required (already listed in `SCOPES`):
191
+
192
+ - `Chat.Read`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`: user-consentable.
193
+ - `ChannelMessage.Read.All`: requires tenant administrator consent.
194
+
195
+ Setup:
196
+
197
+ 1. Add the four delegated permissions to the app registration.
198
+ 2. Grant tenant admin consent for `ChannelMessage.Read.All`.
199
+ 3. Because the scope set changed, re-run the device-code login so the cached token carries the new scopes.
200
+
201
+ ### Notes and limits
202
+
203
+ - Reading is delegated-only: you can read your own chats, not other users' chats.
204
+ - Channel message and reply pages are capped at 50 by Graph.
205
+ - SharePoint/OneDrive-backed file attachments are not downloadable here. In Teams, shared files are attachments whose `contentUrl` points into SharePoint, which is a different Graph surface (needs `Files.Read.All` / `Sites.Read.All` and the driveItem APIs). `download_hosted_content` covers inline hosted content (images), not shared files. This is deferred.
206
+
207
+ ## Future work
208
+
209
+ Not implemented; reasonable additions:
210
+
211
+ - **Graph `$batch` requests.** Performance optimization that bundles multiple Graph calls into one HTTP round-trip; would speed up multi-step workflows but adds complexity. Defer until profiling proves the win.
212
+
213
+ Other known gaps (intentionally out of scope): chunked attachment upload (>3 MB), category master-list management, mail signatures, contacts/To-Do/OneNote, multi-account switching, change-notification subscriptions, force-delete of non-empty folders.
214
+
215
+ ## Development
216
+
217
+ ```bash
218
+ # Run unit tests
219
+ uv run pytest
220
+
221
+ # Run unit + live integration smoke (requires a valid token cache)
222
+ MSGRAPH_MCP_INTEGRATION=1 uv run pytest
223
+
224
+ # Type check
225
+ uv run pyright src tests
226
+
227
+ # Lint
228
+ uv run ruff check .
229
+ ```
230
+
231
+ ## Troubleshooting
232
+
233
+ | Symptom | Fix |
234
+ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
235
+ | `NotAuthenticatedError: Not authenticated. Run \`msgraph-mcp-login\`...` | Run `uv run msgraph-mcp-login`. |
236
+ | `ConfigError: Missing required env var: MSGRAPH_MCP_CLIENT_ID` | Set the var in `.env` or in your MCP host's env config. |
237
+ | `Graph API 403: ErrorAccessDenied — ...` | Permission mismatch on the Entra app. Verify the delegated permissions list above and re-consent. |
238
+ | `Graph API 400: BadRequest — Syntax error: character ... is not valid at position N` from `search_messages` | The query is passed to Graph's `$search` as-is. Wrap literal/multi-character tokens in double quotes (e.g. `"weekly report"`), or use KQL fielded forms (e.g. `from:alice subject:"report"`). Bare alphanumeric strings with embedded digits are invalid KQL. |
239
+ | Server boots but tools 404 in the host | Confirm the host is launching `uv run msgraph-mcp` with the right working directory. |
240
+
241
+ ---
242
+
243
+ Spec: `docs/specs/2026-05-19-outlook-mcp-design.md` (gitignored — local working doc).
@@ -0,0 +1,33 @@
1
+ msgraph_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ msgraph_mcp/config.py,sha256=VVw3qHv2Q16VjNQssa56zlcVNHMCPedakKHMe5yXRzE,2175
3
+ msgraph_mcp/server.py,sha256=oiUxFJ_vMcqumqmonXN5meGZN6_5k3nk00e60z4A9Pk,2234
4
+ msgraph_mcp/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ msgraph_mcp/auth/cli.py,sha256=46izPWtNAaddzDeLgeM3luHD4rerRFVApYJ2x_01OA0,1270
6
+ msgraph_mcp/auth/msal_app.py,sha256=LTiUEt5srNIQTFTtbPE6O6RhU_dLxFwKbfwI3PmC8BY,1652
7
+ msgraph_mcp/auth/token.py,sha256=WJ438Avi7LDc5TZeXYNO7VLRraM0vlZPXQ1EX5rZVo4,986
8
+ msgraph_mcp/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ msgraph_mcp/graph/auth_provider.py,sha256=kaB3UYSeS0hDu3xGdJn6bm39HS0iIhut8dvLozvRwVs,1065
10
+ msgraph_mcp/graph/batch.py,sha256=4bZedIQJBT5MaGz54r1L1afXsstx_4ZBEffPtqf9sqk,7412
11
+ msgraph_mcp/graph/client.py,sha256=ZzaHNecrrS79gDDbGddgyuDoocd0hqB4Cq8tf_GX5yM,1108
12
+ msgraph_mcp/graph/errors.py,sha256=93weFyp_QlVxoKVLMULGexTXUgpdx0Qm8F_5nQkpTiM,1525
13
+ msgraph_mcp/graph/pagination.py,sha256=ITj63_VTMRmOdVvRLiaEQc7ucnubewiDtaOPiId-27g,1438
14
+ msgraph_mcp/graph/serialize.py,sha256=8LakliwK7ldYL_y-x5atW2GDTKVPe0QBU6a5MhOIqKY,14288
15
+ msgraph_mcp/graph/trimming.py,sha256=3e57kBe_1sXmo4zNeu3p0uCixmr19zXvQWXkra79JoM,13049
16
+ msgraph_mcp/tools/__init__.py,sha256=qJbwUSS3BZyu-NFp20RWNPYZfJGO7XLt2N263rHS1N8,944
17
+ msgraph_mcp/tools/_binary.py,sha256=7Gz4nZHwzPHi6-0UlkWC-ERx949s7DWcUB_j-yMmqJw,1582
18
+ msgraph_mcp/tools/calendar.py,sha256=MMdTiBx1e4LC7rjULpdIXzftBiS57U65B3i5-rLPGtw,21208
19
+ msgraph_mcp/tools/mail_actions.py,sha256=nib4cmd4HIgXGkIIqhfdnKBhcSS9UTjD_eA8G5ajzso,3464
20
+ msgraph_mcp/tools/mail_batch.py,sha256=HJt4QLS-NjE5uJLsg6LUmeuAvckifPnDg5hh-kdIDsA,7457
21
+ msgraph_mcp/tools/mail_folders.py,sha256=-i717AC9_f6j1P5MaLxQLta78kMgOpASSj459jE9wH4,9403
22
+ msgraph_mcp/tools/mail_read.py,sha256=YpbsB9QM4PntT9iJ2arCzlEU65UKIVQXNUaIr0PLxIY,12027
23
+ msgraph_mcp/tools/mail_rules.py,sha256=Gj5kbHYpNZ2Wdyiz8tFGfwY_zfScqT4JlufJWcAExPQ,17665
24
+ msgraph_mcp/tools/mail_write.py,sha256=UoyD3IP90vPaTWGdU8kq7neDrNEQA4bQRk5jC_f5mx0,13979
25
+ msgraph_mcp/tools/teams_channels.py,sha256=DzSwDhPwMedQqP31eKIwWQjJwwdzjwuylnGcWnUYL9U,9367
26
+ msgraph_mcp/tools/teams_chats.py,sha256=eVm0-hkGkkWamm6rJojAZjIkH_uXv_wD1O3MdzPN_bs,5556
27
+ msgraph_mcp/tools/teams_content.py,sha256=XpNnAIaaxux36wuP7kAnJcmoslAgMtQB9YVXVhhf6aA,6123
28
+ msgraph_mcp/tools/util.py,sha256=fCAIZCxph47vA_APrIWD-Eb3-mHHg-Gsjw4Ff6_7_u4,1203
29
+ msgraph_mcp_server-0.3.0.dist-info/METADATA,sha256=5Y4gBKk-Iuogwy4wunq5hynEySWefkrqWhwQGbj-6l4,14650
30
+ msgraph_mcp_server-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
31
+ msgraph_mcp_server-0.3.0.dist-info/entry_points.txt,sha256=PMHqZ65ru-Znc4qmLqiCNBIpEN6OL6mRcK8RkDem2Nw,102
32
+ msgraph_mcp_server-0.3.0.dist-info/licenses/LICENSE,sha256=0eHAEVNIZbTK0y1z0-MUmfjxhtN7bsJnJNCTieeHWgQ,1068
33
+ msgraph_mcp_server-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ msgraph-mcp = msgraph_mcp.server:main
3
+ msgraph-mcp-login = msgraph_mcp.auth.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tim Furlong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.