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.
- msgraph_mcp/__init__.py +0 -0
- msgraph_mcp/auth/__init__.py +0 -0
- msgraph_mcp/auth/cli.py +45 -0
- msgraph_mcp/auth/msal_app.py +47 -0
- msgraph_mcp/auth/token.py +32 -0
- msgraph_mcp/config.py +79 -0
- msgraph_mcp/graph/__init__.py +0 -0
- msgraph_mcp/graph/auth_provider.py +29 -0
- msgraph_mcp/graph/batch.py +206 -0
- msgraph_mcp/graph/client.py +37 -0
- msgraph_mcp/graph/errors.py +43 -0
- msgraph_mcp/graph/pagination.py +47 -0
- msgraph_mcp/graph/serialize.py +351 -0
- msgraph_mcp/graph/trimming.py +359 -0
- msgraph_mcp/server.py +61 -0
- msgraph_mcp/tools/__init__.py +35 -0
- msgraph_mcp/tools/_binary.py +48 -0
- msgraph_mcp/tools/calendar.py +585 -0
- msgraph_mcp/tools/mail_actions.py +83 -0
- msgraph_mcp/tools/mail_batch.py +220 -0
- msgraph_mcp/tools/mail_folders.py +263 -0
- msgraph_mcp/tools/mail_read.py +333 -0
- msgraph_mcp/tools/mail_rules.py +468 -0
- msgraph_mcp/tools/mail_write.py +403 -0
- msgraph_mcp/tools/teams_channels.py +223 -0
- msgraph_mcp/tools/teams_chats.py +140 -0
- msgraph_mcp/tools/teams_content.py +150 -0
- msgraph_mcp/tools/util.py +35 -0
- msgraph_mcp_server-0.3.0.dist-info/METADATA +243 -0
- msgraph_mcp_server-0.3.0.dist-info/RECORD +33 -0
- msgraph_mcp_server-0.3.0.dist-info/WHEEL +4 -0
- msgraph_mcp_server-0.3.0.dist-info/entry_points.txt +3 -0
- msgraph_mcp_server-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Mail read tools: list, search, get, list/download attachments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
|
|
7
|
+
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
|
8
|
+
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
|
|
9
|
+
MessagesRequestBuilder as FolderMessagesRequestBuilder,
|
|
10
|
+
)
|
|
11
|
+
from msgraph.generated.users.item.messages.messages_request_builder import (
|
|
12
|
+
MessagesRequestBuilder,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
16
|
+
from msgraph_mcp.graph.errors import map_kiota_error
|
|
17
|
+
from msgraph_mcp.graph.pagination import (
|
|
18
|
+
decode_page_token,
|
|
19
|
+
encode_next_link,
|
|
20
|
+
validate_limit,
|
|
21
|
+
)
|
|
22
|
+
from msgraph_mcp.graph.serialize import (
|
|
23
|
+
attachment_to_dict,
|
|
24
|
+
message_to_dict,
|
|
25
|
+
)
|
|
26
|
+
from msgraph_mcp.graph.trimming import (
|
|
27
|
+
trim_attachment_download,
|
|
28
|
+
trim_attachment_list,
|
|
29
|
+
trim_message,
|
|
30
|
+
)
|
|
31
|
+
from msgraph_mcp.tools import _binary
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _list_messages_query(*, limit: int, filter_expr: str | None = None):
|
|
35
|
+
qp = FolderMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
|
|
36
|
+
top=limit,
|
|
37
|
+
orderby=["receivedDateTime DESC"],
|
|
38
|
+
filter=filter_expr,
|
|
39
|
+
)
|
|
40
|
+
return RequestConfiguration[
|
|
41
|
+
FolderMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters
|
|
42
|
+
](query_parameters=qp)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_list_filter(*, unread_only: bool, filter: str | None) -> str | None:
|
|
46
|
+
parts: list[str] = []
|
|
47
|
+
if unread_only:
|
|
48
|
+
parts.append("isRead eq false")
|
|
49
|
+
if filter:
|
|
50
|
+
parts.append(f"({filter})")
|
|
51
|
+
return " and ".join(parts) if parts else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _search_query(*, limit: int, query: str):
|
|
55
|
+
qp = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
|
|
56
|
+
top=limit,
|
|
57
|
+
search=query,
|
|
58
|
+
)
|
|
59
|
+
return RequestConfiguration[
|
|
60
|
+
MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters
|
|
61
|
+
](query_parameters=qp)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def list_messages(
|
|
65
|
+
*,
|
|
66
|
+
graph,
|
|
67
|
+
folder_id: str = "inbox",
|
|
68
|
+
mailbox: str | None = None,
|
|
69
|
+
limit: int = 25,
|
|
70
|
+
page_token: str | None = None,
|
|
71
|
+
include_raw: bool = False,
|
|
72
|
+
unread_only: bool = False,
|
|
73
|
+
filter: str | None = None,
|
|
74
|
+
) -> dict:
|
|
75
|
+
"""List messages from a mail folder, newest first.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
folder_id: Folder id, or a well-known name (inbox, sentitems, drafts,
|
|
79
|
+
deleteditems, archive, junkemail). Default: inbox.
|
|
80
|
+
mailbox: Optional mailbox (email or user ID). Default: signed-in user.
|
|
81
|
+
limit: 1-100. Default 25.
|
|
82
|
+
page_token: Pass next_page_token from a previous result to continue.
|
|
83
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
84
|
+
unread_only: When True, only return messages where isRead is false.
|
|
85
|
+
Adds `isRead eq false` to the server-side $filter.
|
|
86
|
+
filter: Raw OData $filter expression applied server-side. Combined
|
|
87
|
+
with `unread_only` using `and`. Examples:
|
|
88
|
+
`from/emailAddress/address eq 'a@x.com'`,
|
|
89
|
+
`hasAttachments eq true`,
|
|
90
|
+
`contains(subject,'report')`,
|
|
91
|
+
`receivedDateTime ge 2026-05-01T00:00:00Z`.
|
|
92
|
+
Cannot be combined with $search — use `search_messages` for that.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
{"items": [trimmed_message, ...], "next_page_token": str | None}
|
|
96
|
+
"""
|
|
97
|
+
limit = validate_limit(limit)
|
|
98
|
+
filter_expr = _build_list_filter(unread_only=unread_only, filter=filter)
|
|
99
|
+
builder = graph.mailbox(mailbox).mail_folders.by_mail_folder_id(folder_id).messages
|
|
100
|
+
try:
|
|
101
|
+
if page_token is not None:
|
|
102
|
+
url = decode_page_token(page_token)
|
|
103
|
+
collection = await builder.with_url(url).get()
|
|
104
|
+
else:
|
|
105
|
+
collection = await builder.get(
|
|
106
|
+
request_configuration=_list_messages_query(limit=limit, filter_expr=filter_expr)
|
|
107
|
+
)
|
|
108
|
+
except NotAuthenticatedError:
|
|
109
|
+
raise
|
|
110
|
+
except Exception as exc: # noqa: BLE001
|
|
111
|
+
raise map_kiota_error(exc) from exc
|
|
112
|
+
|
|
113
|
+
items = [
|
|
114
|
+
trim_message(message_to_dict(m), include_body=False, include_raw=include_raw)
|
|
115
|
+
for m in (collection.value or [])
|
|
116
|
+
]
|
|
117
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def search_messages(
|
|
121
|
+
*,
|
|
122
|
+
graph,
|
|
123
|
+
query: str,
|
|
124
|
+
mailbox: str | None = None,
|
|
125
|
+
limit: int = 25,
|
|
126
|
+
page_token: str | None = None,
|
|
127
|
+
include_raw: bool = False,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Search messages across all folders using Graph $search.
|
|
130
|
+
|
|
131
|
+
The query is passed to Graph as-is — supply quoting yourself if you need
|
|
132
|
+
a literal phrase (e.g. '"weekly report"') or KQL fielded predicates
|
|
133
|
+
(e.g. 'from:alice subject:"report"').
|
|
134
|
+
|
|
135
|
+
Graph does not allow combining $search with $filter or $orderby, and KQL
|
|
136
|
+
has no `isread`/`unread`/`flag`/`in` predicates. To filter by isRead /
|
|
137
|
+
sender / date / attachments while keyword-matching, use `list_messages`
|
|
138
|
+
with `filter="contains(subject,'…')"` (or `contains(body/content,'…')`)
|
|
139
|
+
instead — that path supports the full $filter grammar.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
query: Graph $search expression. Plain tokens match across common
|
|
143
|
+
mail fields; quoted phrases match literally; KQL `field:value`
|
|
144
|
+
forms target specific fields.
|
|
145
|
+
mailbox: Optional mailbox.
|
|
146
|
+
limit: 1-100.
|
|
147
|
+
page_token: Continuation token.
|
|
148
|
+
include_raw: Include raw payloads.
|
|
149
|
+
"""
|
|
150
|
+
limit = validate_limit(limit)
|
|
151
|
+
builder = graph.mailbox(mailbox).messages
|
|
152
|
+
try:
|
|
153
|
+
if page_token is not None:
|
|
154
|
+
url = decode_page_token(page_token)
|
|
155
|
+
collection = await builder.with_url(url).get()
|
|
156
|
+
else:
|
|
157
|
+
collection = await builder.get(request_configuration=_search_query(limit=limit, query=query))
|
|
158
|
+
except NotAuthenticatedError:
|
|
159
|
+
raise
|
|
160
|
+
except Exception as exc: # noqa: BLE001
|
|
161
|
+
raise map_kiota_error(exc) from exc
|
|
162
|
+
|
|
163
|
+
items = [
|
|
164
|
+
trim_message(message_to_dict(m), include_body=False, include_raw=include_raw)
|
|
165
|
+
for m in (collection.value or [])
|
|
166
|
+
]
|
|
167
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def get_message(
|
|
171
|
+
*,
|
|
172
|
+
graph,
|
|
173
|
+
message_id: str,
|
|
174
|
+
mailbox: str | None = None,
|
|
175
|
+
include_body: bool = False,
|
|
176
|
+
include_raw: bool = False,
|
|
177
|
+
) -> dict:
|
|
178
|
+
"""Fetch a single message by id.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
message_id: Graph message id.
|
|
182
|
+
mailbox: Optional mailbox.
|
|
183
|
+
include_body: When True, the full body is returned. Default False
|
|
184
|
+
(snippet only).
|
|
185
|
+
include_raw: Include the raw Graph payload under "raw".
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Trimmed message object.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
msg = await graph.mailbox(mailbox).messages.by_message_id(message_id).get()
|
|
192
|
+
except NotAuthenticatedError:
|
|
193
|
+
raise
|
|
194
|
+
except Exception as exc: # noqa: BLE001
|
|
195
|
+
raise map_kiota_error(exc) from exc
|
|
196
|
+
return trim_message(message_to_dict(msg), include_body=include_body, include_raw=include_raw)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def list_attachments(
|
|
200
|
+
*,
|
|
201
|
+
graph,
|
|
202
|
+
message_id: str,
|
|
203
|
+
mailbox: str | None = None,
|
|
204
|
+
include_raw: bool = False,
|
|
205
|
+
) -> dict:
|
|
206
|
+
"""List attachments on a message (metadata only — no content)."""
|
|
207
|
+
try:
|
|
208
|
+
collection = await graph.mailbox(mailbox).messages.by_message_id(message_id).attachments.get()
|
|
209
|
+
except NotAuthenticatedError:
|
|
210
|
+
raise
|
|
211
|
+
except Exception as exc: # noqa: BLE001
|
|
212
|
+
raise map_kiota_error(exc) from exc
|
|
213
|
+
items = [
|
|
214
|
+
trim_attachment_list(attachment_to_dict(a, include_content=False), include_raw=include_raw)
|
|
215
|
+
for a in (collection.value or [])
|
|
216
|
+
]
|
|
217
|
+
return {"items": items, "next_page_token": None}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def download_attachment(
|
|
221
|
+
*,
|
|
222
|
+
graph,
|
|
223
|
+
message_id: str,
|
|
224
|
+
attachment_id: str,
|
|
225
|
+
mailbox: str | None = None,
|
|
226
|
+
save_path: str | None = None,
|
|
227
|
+
include_raw: bool = False,
|
|
228
|
+
) -> dict | list:
|
|
229
|
+
"""Download a single attachment.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
save_path: Write the bytes to this file path (or into this existing
|
|
233
|
+
directory, using the attachment's name) instead of returning
|
|
234
|
+
content. Returns {"path", "name", "content_type", "size_bytes"}.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
- Image attachments (PNG/JPEG/GIF/WebP, no save_path): metadata plus
|
|
238
|
+
the image itself as a native MCP image block, viewable directly.
|
|
239
|
+
- With save_path: {"path", "name", "content_type", "size_bytes"}.
|
|
240
|
+
- Otherwise: {name, content_type, size_bytes, content_base64}.
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
att = await (
|
|
244
|
+
graph.mailbox(mailbox)
|
|
245
|
+
.messages.by_message_id(message_id)
|
|
246
|
+
.attachments.by_attachment_id(attachment_id)
|
|
247
|
+
.get()
|
|
248
|
+
)
|
|
249
|
+
except NotAuthenticatedError:
|
|
250
|
+
raise
|
|
251
|
+
except Exception as exc: # noqa: BLE001
|
|
252
|
+
raise map_kiota_error(exc) from exc
|
|
253
|
+
|
|
254
|
+
content = getattr(att, "content_bytes", None)
|
|
255
|
+
if isinstance(content, str):
|
|
256
|
+
content = base64.b64decode(content)
|
|
257
|
+
if isinstance(content, (bytes, bytearray)):
|
|
258
|
+
content = bytes(content)
|
|
259
|
+
name = getattr(att, "name", None)
|
|
260
|
+
content_type = getattr(att, "content_type", None)
|
|
261
|
+
|
|
262
|
+
if save_path is not None:
|
|
263
|
+
path = _binary.write_bytes(
|
|
264
|
+
save_path, content,
|
|
265
|
+
default_name=name or f"attachment-{attachment_id}{_binary.ext_for(content_type)}",
|
|
266
|
+
)
|
|
267
|
+
return {
|
|
268
|
+
"path": path, "name": name,
|
|
269
|
+
"content_type": content_type, "size_bytes": len(content),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if _binary.is_image(content_type):
|
|
273
|
+
meta = {"name": name, "content_type": content_type, "size_bytes": len(content)}
|
|
274
|
+
return _binary.image_result(meta, content, content_type)
|
|
275
|
+
|
|
276
|
+
return trim_attachment_download(attachment_to_dict(att, include_content=True), include_raw=include_raw)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def register(mcp, *, graph) -> None:
|
|
280
|
+
@mcp.tool(name="list_messages", description=list_messages.__doc__ or "")
|
|
281
|
+
async def _list_messages(
|
|
282
|
+
folder_id: str = "inbox",
|
|
283
|
+
mailbox: str | None = None,
|
|
284
|
+
limit: int = 25,
|
|
285
|
+
page_token: str | None = None,
|
|
286
|
+
include_raw: bool = False,
|
|
287
|
+
unread_only: bool = False,
|
|
288
|
+
filter: str | None = None,
|
|
289
|
+
):
|
|
290
|
+
return await list_messages(
|
|
291
|
+
graph=graph, folder_id=folder_id, mailbox=mailbox,
|
|
292
|
+
limit=limit, page_token=page_token, include_raw=include_raw,
|
|
293
|
+
unread_only=unread_only, filter=filter,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@mcp.tool(name="search_messages", description=search_messages.__doc__ or "")
|
|
297
|
+
async def _search_messages(
|
|
298
|
+
query: str,
|
|
299
|
+
mailbox: str | None = None,
|
|
300
|
+
limit: int = 25,
|
|
301
|
+
page_token: str | None = None,
|
|
302
|
+
include_raw: bool = False,
|
|
303
|
+
):
|
|
304
|
+
return await search_messages(
|
|
305
|
+
graph=graph, query=query, mailbox=mailbox,
|
|
306
|
+
limit=limit, page_token=page_token, include_raw=include_raw,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@mcp.tool(name="get_message", description=get_message.__doc__ or "")
|
|
310
|
+
async def _get_message(
|
|
311
|
+
message_id: str,
|
|
312
|
+
mailbox: str | None = None,
|
|
313
|
+
include_body: bool = False,
|
|
314
|
+
include_raw: bool = False,
|
|
315
|
+
):
|
|
316
|
+
return await get_message(
|
|
317
|
+
graph=graph, message_id=message_id, mailbox=mailbox,
|
|
318
|
+
include_body=include_body, include_raw=include_raw,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
@mcp.tool(name="list_attachments", description=list_attachments.__doc__ or "")
|
|
322
|
+
async def _list_attachments(message_id: str, mailbox: str | None = None, include_raw: bool = False):
|
|
323
|
+
return await list_attachments(graph=graph, message_id=message_id, mailbox=mailbox, include_raw=include_raw)
|
|
324
|
+
|
|
325
|
+
@mcp.tool(name="download_attachment", description=download_attachment.__doc__ or "")
|
|
326
|
+
async def _download_attachment(
|
|
327
|
+
message_id: str, attachment_id: str, mailbox: str | None = None,
|
|
328
|
+
save_path: str | None = None, include_raw: bool = False,
|
|
329
|
+
):
|
|
330
|
+
return await download_attachment(
|
|
331
|
+
graph=graph, message_id=message_id, attachment_id=attachment_id,
|
|
332
|
+
mailbox=mailbox, save_path=save_path, include_raw=include_raw,
|
|
333
|
+
)
|