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,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
+ )