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,585 @@
1
+ """Calendar tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+ from typing import Literal
7
+
8
+ from kiota_abstractions.base_request_configuration import RequestConfiguration
9
+ from msgraph.generated.models.attendee import Attendee
10
+ from msgraph.generated.models.attendee_base import AttendeeBase
11
+ from msgraph.generated.models.attendee_type import AttendeeType
12
+ from msgraph.generated.models.body_type import BodyType
13
+ from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone
14
+ from msgraph.generated.models.email_address import EmailAddress
15
+ from msgraph.generated.models.event import Event
16
+ from msgraph.generated.models.item_body import ItemBody
17
+ from msgraph.generated.models.location import Location
18
+ from msgraph.generated.models.time_constraint import TimeConstraint
19
+ from msgraph.generated.models.time_slot import TimeSlot
20
+ from msgraph.generated.users.item.calendars.item.calendar_view.calendar_view_request_builder import (
21
+ CalendarViewRequestBuilder,
22
+ )
23
+ from msgraph.generated.users.item.events.item.accept.accept_post_request_body import (
24
+ AcceptPostRequestBody,
25
+ )
26
+ from msgraph.generated.users.item.events.item.cancel.cancel_post_request_body import (
27
+ CancelPostRequestBody,
28
+ )
29
+ from msgraph.generated.users.item.events.item.decline.decline_post_request_body import (
30
+ DeclinePostRequestBody,
31
+ )
32
+ from msgraph.generated.users.item.events.item.tentatively_accept.tentatively_accept_post_request_body import (
33
+ TentativelyAcceptPostRequestBody,
34
+ )
35
+ from msgraph.generated.users.item.find_meeting_times.find_meeting_times_post_request_body import (
36
+ FindMeetingTimesPostRequestBody,
37
+ )
38
+
39
+ from msgraph_mcp.auth.token import NotAuthenticatedError
40
+ from msgraph_mcp.graph.errors import GraphValidationError, map_kiota_error
41
+ from msgraph_mcp.graph.pagination import (
42
+ decode_page_token,
43
+ encode_next_link,
44
+ validate_limit,
45
+ )
46
+ from msgraph_mcp.graph.serialize import calendar_to_dict, event_to_dict
47
+ from msgraph_mcp.graph.trimming import trim_calendar, trim_event
48
+
49
+
50
+ _VALID_RESPONSES = {
51
+ "accept": "accepted",
52
+ "tentativelyAccept": "tentativelyAccepted",
53
+ "decline": "declined",
54
+ }
55
+
56
+
57
+ async def list_calendars(
58
+ *, graph, mailbox: str | None = None, include_raw: bool = False
59
+ ) -> dict:
60
+ """List the user's calendars."""
61
+ try:
62
+ collection = await graph.mailbox(mailbox).calendars.get()
63
+ except NotAuthenticatedError:
64
+ raise
65
+ except Exception as exc: # noqa: BLE001
66
+ raise map_kiota_error(exc) from exc
67
+ items = [
68
+ trim_calendar(calendar_to_dict(c), include_raw=include_raw)
69
+ for c in (collection.value or [])
70
+ ]
71
+ return {"items": items, "next_page_token": None}
72
+
73
+
74
+ def _calendar_view_query(*, start_datetime: str, end_datetime: str, limit: int):
75
+ qp = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters(
76
+ start_date_time=start_datetime,
77
+ end_date_time=end_datetime,
78
+ top=limit,
79
+ orderby=["start/dateTime"],
80
+ )
81
+ return RequestConfiguration[
82
+ CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters
83
+ ](query_parameters=qp)
84
+
85
+
86
+ async def list_events(
87
+ *,
88
+ graph,
89
+ calendar_id: str,
90
+ start_datetime: str | None = None,
91
+ end_datetime: str | None = None,
92
+ mailbox: str | None = None,
93
+ limit: int = 25,
94
+ page_token: str | None = None,
95
+ include_raw: bool = False,
96
+ ) -> dict:
97
+ """List events in a calendar within a date range.
98
+
99
+ Uses Graph's calendarView, which expands recurring events into instances.
100
+
101
+ Args:
102
+ calendar_id: Calendar ID. (Use list_calendars to discover.)
103
+ start_datetime: ISO 8601 datetime, e.g. "2026-05-19T00:00:00Z". Required.
104
+ end_datetime: ISO 8601 datetime. Required.
105
+ mailbox: Optional mailbox.
106
+ limit: 1-100. Default 25.
107
+ page_token: Continuation token.
108
+ include_raw: Include raw payloads.
109
+
110
+ Returns:
111
+ {"items": [trimmed_event, ...], "next_page_token": str | None}
112
+ """
113
+ if not (start_datetime and end_datetime):
114
+ raise GraphValidationError("start_datetime and end_datetime are required")
115
+ limit = validate_limit(limit)
116
+ cal_builder = graph.mailbox(mailbox).calendars.by_calendar_id(calendar_id)
117
+ try:
118
+ if page_token is not None:
119
+ url = decode_page_token(page_token)
120
+ collection = await cal_builder.calendar_view.with_url(url).get()
121
+ else:
122
+ collection = await cal_builder.calendar_view.get(
123
+ request_configuration=_calendar_view_query(
124
+ start_datetime=start_datetime, end_datetime=end_datetime, limit=limit
125
+ )
126
+ )
127
+ except NotAuthenticatedError:
128
+ raise
129
+ except Exception as exc: # noqa: BLE001
130
+ raise map_kiota_error(exc) from exc
131
+
132
+ items = [
133
+ trim_event(event_to_dict(e), include_body=False, include_raw=include_raw)
134
+ for e in (collection.value or [])
135
+ ]
136
+ return {
137
+ "items": items,
138
+ "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None)),
139
+ }
140
+
141
+
142
+ async def get_event(
143
+ *,
144
+ graph,
145
+ event_id: str,
146
+ mailbox: str | None = None,
147
+ include_body: bool = False,
148
+ include_raw: bool = False,
149
+ ) -> dict:
150
+ """Fetch a single event by id."""
151
+ try:
152
+ evt = await graph.mailbox(mailbox).events.by_event_id(event_id).get()
153
+ except NotAuthenticatedError:
154
+ raise
155
+ except Exception as exc: # noqa: BLE001
156
+ raise map_kiota_error(exc) from exc
157
+ return trim_event(event_to_dict(evt), include_body=include_body, include_raw=include_raw)
158
+
159
+
160
+ def _dttz(dt: str, tz: str) -> DateTimeTimeZone:
161
+ obj = DateTimeTimeZone()
162
+ obj.date_time = dt
163
+ obj.time_zone = tz
164
+ return obj
165
+
166
+
167
+ def _build_event(
168
+ *,
169
+ subject: str,
170
+ start_datetime: str,
171
+ end_datetime: str,
172
+ time_zone: str,
173
+ body: str | None,
174
+ body_type: Literal["text", "html"],
175
+ location: str | None,
176
+ attendees: list[str] | None,
177
+ is_online_meeting: bool,
178
+ is_all_day: bool,
179
+ ) -> Event:
180
+ e = Event()
181
+ e.subject = subject
182
+ e.start = _dttz(start_datetime, time_zone)
183
+ e.end = _dttz(end_datetime, time_zone)
184
+ if body is not None:
185
+ b = ItemBody()
186
+ b.content_type = BodyType.Text if body_type == "text" else BodyType.Html
187
+ b.content = body
188
+ e.body = b
189
+ if location is not None:
190
+ loc = Location()
191
+ loc.display_name = location
192
+ e.location = loc
193
+ if attendees:
194
+ atts: list[Attendee] = []
195
+ for addr in attendees:
196
+ a = Attendee()
197
+ ea = EmailAddress()
198
+ ea.address = addr
199
+ a.email_address = ea
200
+ a.type = AttendeeType.Required
201
+ atts.append(a)
202
+ e.attendees = atts
203
+ e.is_online_meeting = is_online_meeting
204
+ e.is_all_day = is_all_day
205
+ return e
206
+
207
+
208
+ async def create_event(
209
+ *,
210
+ graph,
211
+ subject: str,
212
+ start_datetime: str,
213
+ end_datetime: str,
214
+ time_zone: str = "UTC",
215
+ body: str | None = None,
216
+ body_type: Literal["text", "html"] = "text",
217
+ location: str | None = None,
218
+ attendees: list[str] | None = None,
219
+ is_online_meeting: bool = False,
220
+ is_all_day: bool = False,
221
+ calendar_id: str | None = None,
222
+ mailbox: str | None = None,
223
+ include_raw: bool = False,
224
+ ) -> dict:
225
+ """Create a calendar event.
226
+
227
+ Args:
228
+ subject: Event subject.
229
+ start_datetime: ISO 8601 datetime (no offset; pair with time_zone).
230
+ end_datetime: ISO 8601 datetime.
231
+ time_zone: IANA tz id (e.g. "America/Los_Angeles") or "UTC". Default "UTC".
232
+ body, body_type: Optional body and "text"|"html".
233
+ location: Optional location string.
234
+ attendees: Optional list of attendee email addresses (treated as required).
235
+ is_online_meeting: When True, Outlook adds a Teams meeting link.
236
+ is_all_day: All-day event flag.
237
+ calendar_id: Optional calendar id; default is the user's primary calendar.
238
+ mailbox: Optional mailbox.
239
+
240
+ Returns:
241
+ Trimmed created event.
242
+ """
243
+ evt = _build_event(
244
+ subject=subject, start_datetime=start_datetime, end_datetime=end_datetime,
245
+ time_zone=time_zone, body=body, body_type=body_type, location=location,
246
+ attendees=attendees, is_online_meeting=is_online_meeting, is_all_day=is_all_day,
247
+ )
248
+ try:
249
+ target = graph.mailbox(mailbox)
250
+ if calendar_id is not None:
251
+ target = target.calendars.by_calendar_id(calendar_id)
252
+ created = await target.events.post(evt)
253
+ except NotAuthenticatedError:
254
+ raise
255
+ except Exception as exc: # noqa: BLE001
256
+ raise map_kiota_error(exc) from exc
257
+ return trim_event(event_to_dict(created), include_body=False, include_raw=include_raw)
258
+
259
+
260
+ async def update_event(
261
+ *,
262
+ graph,
263
+ event_id: str,
264
+ subject: str | None = None,
265
+ start_datetime: str | None = None,
266
+ end_datetime: str | None = None,
267
+ time_zone: str | None = None,
268
+ body: str | None = None,
269
+ body_type: Literal["text", "html"] = "text",
270
+ location: str | None = None,
271
+ is_online_meeting: bool | None = None,
272
+ is_all_day: bool | None = None,
273
+ mailbox: str | None = None,
274
+ include_raw: bool = False,
275
+ ) -> dict:
276
+ """Patch fields of an existing event. Pass None to leave a field unchanged.
277
+
278
+ If you change start_datetime or end_datetime, you must also pass time_zone.
279
+ """
280
+ if (start_datetime or end_datetime) and not time_zone:
281
+ raise GraphValidationError("time_zone is required when updating start or end")
282
+ patch = Event()
283
+ if subject is not None:
284
+ patch.subject = subject
285
+ if start_datetime is not None:
286
+ patch.start = _dttz(start_datetime, time_zone or "UTC")
287
+ if end_datetime is not None:
288
+ patch.end = _dttz(end_datetime, time_zone or "UTC")
289
+ if body is not None:
290
+ b = ItemBody()
291
+ b.content_type = BodyType.Text if body_type == "text" else BodyType.Html
292
+ b.content = body
293
+ patch.body = b
294
+ if location is not None:
295
+ loc = Location()
296
+ loc.display_name = location
297
+ patch.location = loc
298
+ if is_online_meeting is not None:
299
+ patch.is_online_meeting = is_online_meeting
300
+ if is_all_day is not None:
301
+ patch.is_all_day = is_all_day
302
+ try:
303
+ updated = await graph.mailbox(mailbox).events.by_event_id(event_id).patch(patch)
304
+ except NotAuthenticatedError:
305
+ raise
306
+ except Exception as exc: # noqa: BLE001
307
+ raise map_kiota_error(exc) from exc
308
+ return trim_event(event_to_dict(updated), include_body=False, include_raw=include_raw)
309
+
310
+
311
+ async def delete_event(*, graph, event_id: str, mailbox: str | None = None) -> dict:
312
+ """Delete an event without sending a cancellation to attendees."""
313
+ try:
314
+ await graph.mailbox(mailbox).events.by_event_id(event_id).delete()
315
+ except NotAuthenticatedError:
316
+ raise
317
+ except Exception as exc: # noqa: BLE001
318
+ raise map_kiota_error(exc) from exc
319
+ return {"status": "deleted"}
320
+
321
+
322
+ async def cancel_event(
323
+ *, graph, event_id: str, comment: str | None = None, mailbox: str | None = None
324
+ ) -> dict:
325
+ """Cancel an event and send a cancellation notice to attendees."""
326
+ body = CancelPostRequestBody()
327
+ if comment is not None:
328
+ body.comment = comment
329
+ try:
330
+ await graph.mailbox(mailbox).events.by_event_id(event_id).cancel.post(body)
331
+ except NotAuthenticatedError:
332
+ raise
333
+ except Exception as exc: # noqa: BLE001
334
+ raise map_kiota_error(exc) from exc
335
+ return {"status": "cancelled"}
336
+
337
+
338
+ async def respond_to_event(
339
+ *,
340
+ graph,
341
+ event_id: str,
342
+ response: Literal["accept", "tentativelyAccept", "decline"],
343
+ comment: str | None = None,
344
+ send_response: bool = True,
345
+ mailbox: str | None = None,
346
+ ) -> dict:
347
+ """Respond to a meeting invite.
348
+
349
+ Args:
350
+ response: One of "accept", "tentativelyAccept", "decline".
351
+ comment: Optional comment included with the response.
352
+ send_response: When False, your response status is recorded without
353
+ emailing the organizer.
354
+ """
355
+ if response not in _VALID_RESPONSES:
356
+ raise GraphValidationError(
357
+ f"response must be one of {sorted(_VALID_RESPONSES)}"
358
+ )
359
+ evt_builder = graph.mailbox(mailbox).events.by_event_id(event_id)
360
+ try:
361
+ if response == "accept":
362
+ accept_body = AcceptPostRequestBody()
363
+ accept_body.comment = comment
364
+ accept_body.send_response = send_response
365
+ await evt_builder.accept.post(accept_body)
366
+ elif response == "tentativelyAccept":
367
+ tentative_body = TentativelyAcceptPostRequestBody()
368
+ tentative_body.comment = comment
369
+ tentative_body.send_response = send_response
370
+ await evt_builder.tentatively_accept.post(tentative_body)
371
+ else: # decline
372
+ decline_body = DeclinePostRequestBody()
373
+ decline_body.comment = comment
374
+ decline_body.send_response = send_response
375
+ await evt_builder.decline.post(decline_body)
376
+ except NotAuthenticatedError:
377
+ raise
378
+ except Exception as exc: # noqa: BLE001
379
+ raise map_kiota_error(exc) from exc
380
+ return {"status": _VALID_RESPONSES[response]}
381
+
382
+
383
+ def _duration_td(minutes: int) -> timedelta:
384
+ """Return a timedelta for the given whole minutes.
385
+
386
+ The kiota serializer renders this as ISO 8601 (e.g. PT30M) on the wire.
387
+ """
388
+ if minutes <= 0:
389
+ raise GraphValidationError("duration_minutes must be a positive integer")
390
+ return timedelta(minutes=int(minutes))
391
+
392
+
393
+ async def find_meeting_times(
394
+ *,
395
+ graph,
396
+ attendees: list[str],
397
+ duration_minutes: int,
398
+ start_window: str,
399
+ end_window: str,
400
+ mailbox: str | None = None,
401
+ max_candidates: int = 20,
402
+ include_raw: bool = False,
403
+ ) -> dict:
404
+ """Suggest meeting times that work for the given attendees.
405
+
406
+ Args:
407
+ attendees: List of attendee email addresses.
408
+ duration_minutes: Meeting duration in whole minutes (e.g. 30).
409
+ start_window: ISO 8601 start of the candidate window.
410
+ end_window: ISO 8601 end of the candidate window.
411
+ max_candidates: Cap on returned suggestions (default 20).
412
+
413
+ Returns:
414
+ {
415
+ "suggestions": [
416
+ {"start": {date_time, time_zone}, "end": {...}, "confidence": float, "order_hint": int},
417
+ ...
418
+ ],
419
+ "empty_reason": str | None,
420
+ }
421
+ """
422
+ if not attendees:
423
+ raise GraphValidationError("`attendees` must be a non-empty list")
424
+ body = FindMeetingTimesPostRequestBody()
425
+ body.meeting_duration = _duration_td(duration_minutes)
426
+ body.max_candidates = max_candidates
427
+ body.attendees = []
428
+ for addr in attendees:
429
+ a = AttendeeBase()
430
+ ea = EmailAddress()
431
+ ea.address = addr
432
+ a.email_address = ea
433
+ body.attendees.append(a)
434
+ tc = TimeConstraint()
435
+ slot = TimeSlot()
436
+ slot.start = _dttz(start_window, "UTC")
437
+ slot.end = _dttz(end_window, "UTC")
438
+ tc.time_slots = [slot]
439
+ body.time_constraint = tc
440
+ try:
441
+ result = await graph.mailbox(mailbox).find_meeting_times.post(body)
442
+ except NotAuthenticatedError:
443
+ raise
444
+ except Exception as exc: # noqa: BLE001
445
+ raise map_kiota_error(exc) from exc
446
+
447
+ suggestions_raw = getattr(result, "meeting_time_suggestions", None) or []
448
+ suggestions = []
449
+ for s in suggestions_raw:
450
+ ts = getattr(s, "meeting_time_slot", None)
451
+ suggestions.append(
452
+ {
453
+ "start": {
454
+ "date_time": getattr(getattr(ts, "start", None), "date_time", None),
455
+ "time_zone": getattr(getattr(ts, "start", None), "time_zone", None),
456
+ } if ts else None,
457
+ "end": {
458
+ "date_time": getattr(getattr(ts, "end", None), "date_time", None),
459
+ "time_zone": getattr(getattr(ts, "end", None), "time_zone", None),
460
+ } if ts else None,
461
+ "confidence": getattr(s, "confidence", None),
462
+ "order_hint": getattr(s, "order_hint", None),
463
+ }
464
+ )
465
+ out: dict = {
466
+ "suggestions": suggestions,
467
+ "empty_reason": getattr(result, "empty_suggestions_reason", None),
468
+ }
469
+ if include_raw:
470
+ out["raw"] = getattr(result, "additional_data", {}) or {}
471
+ return out
472
+
473
+
474
+ def register(mcp, *, graph) -> None:
475
+ @mcp.tool(name="list_calendars", description=list_calendars.__doc__ or "")
476
+ async def _list_calendars(mailbox: str | None = None, include_raw: bool = False):
477
+ return await list_calendars(graph=graph, mailbox=mailbox, include_raw=include_raw)
478
+
479
+ @mcp.tool(name="list_events", description=list_events.__doc__ or "")
480
+ async def _list_events(
481
+ calendar_id: str,
482
+ start_datetime: str,
483
+ end_datetime: str,
484
+ mailbox: str | None = None,
485
+ limit: int = 25,
486
+ page_token: str | None = None,
487
+ include_raw: bool = False,
488
+ ):
489
+ return await list_events(
490
+ graph=graph, calendar_id=calendar_id,
491
+ start_datetime=start_datetime, end_datetime=end_datetime,
492
+ mailbox=mailbox, limit=limit, page_token=page_token, include_raw=include_raw,
493
+ )
494
+
495
+ @mcp.tool(name="get_event", description=get_event.__doc__ or "")
496
+ async def _get_event(
497
+ event_id: str,
498
+ mailbox: str | None = None,
499
+ include_body: bool = False,
500
+ include_raw: bool = False,
501
+ ):
502
+ return await get_event(
503
+ graph=graph, event_id=event_id, mailbox=mailbox,
504
+ include_body=include_body, include_raw=include_raw,
505
+ )
506
+
507
+ @mcp.tool(name="create_event", description=create_event.__doc__ or "")
508
+ async def _create_event(
509
+ subject: str, start_datetime: str, end_datetime: str,
510
+ time_zone: str = "UTC",
511
+ body: str | None = None,
512
+ body_type: Literal["text", "html"] = "text",
513
+ location: str | None = None, attendees: list[str] | None = None,
514
+ is_online_meeting: bool = False, is_all_day: bool = False,
515
+ calendar_id: str | None = None, mailbox: str | None = None,
516
+ include_raw: bool = False,
517
+ ):
518
+ return await create_event(
519
+ graph=graph, subject=subject,
520
+ start_datetime=start_datetime, end_datetime=end_datetime,
521
+ time_zone=time_zone, body=body, body_type=body_type, location=location,
522
+ attendees=attendees, is_online_meeting=is_online_meeting, is_all_day=is_all_day,
523
+ calendar_id=calendar_id, mailbox=mailbox, include_raw=include_raw,
524
+ )
525
+
526
+ @mcp.tool(name="update_event", description=update_event.__doc__ or "")
527
+ async def _update_event(
528
+ event_id: str,
529
+ subject: str | None = None,
530
+ start_datetime: str | None = None,
531
+ end_datetime: str | None = None,
532
+ time_zone: str | None = None,
533
+ body: str | None = None,
534
+ body_type: Literal["text", "html"] = "text",
535
+ location: str | None = None,
536
+ is_online_meeting: bool | None = None,
537
+ is_all_day: bool | None = None,
538
+ mailbox: str | None = None,
539
+ include_raw: bool = False,
540
+ ):
541
+ return await update_event(
542
+ graph=graph, event_id=event_id, subject=subject,
543
+ start_datetime=start_datetime, end_datetime=end_datetime, time_zone=time_zone,
544
+ body=body, body_type=body_type, location=location,
545
+ is_online_meeting=is_online_meeting, is_all_day=is_all_day,
546
+ mailbox=mailbox, include_raw=include_raw,
547
+ )
548
+
549
+ @mcp.tool(name="delete_event", description=delete_event.__doc__ or "")
550
+ async def _delete_event(event_id: str, mailbox: str | None = None):
551
+ return await delete_event(graph=graph, event_id=event_id, mailbox=mailbox)
552
+
553
+ @mcp.tool(name="cancel_event", description=cancel_event.__doc__ or "")
554
+ async def _cancel_event(
555
+ event_id: str, comment: str | None = None, mailbox: str | None = None
556
+ ):
557
+ return await cancel_event(
558
+ graph=graph, event_id=event_id, comment=comment, mailbox=mailbox
559
+ )
560
+
561
+ @mcp.tool(name="respond_to_event", description=respond_to_event.__doc__ or "")
562
+ async def _respond(
563
+ event_id: str,
564
+ response: Literal["accept", "tentativelyAccept", "decline"],
565
+ comment: str | None = None,
566
+ send_response: bool = True,
567
+ mailbox: str | None = None,
568
+ ):
569
+ return await respond_to_event(
570
+ graph=graph, event_id=event_id, response=response, comment=comment,
571
+ send_response=send_response, mailbox=mailbox,
572
+ )
573
+
574
+ @mcp.tool(name="find_meeting_times", description=find_meeting_times.__doc__ or "")
575
+ async def _find(
576
+ attendees: list[str], duration_minutes: int,
577
+ start_window: str, end_window: str,
578
+ mailbox: str | None = None, max_candidates: int = 20,
579
+ include_raw: bool = False,
580
+ ):
581
+ return await find_meeting_times(
582
+ graph=graph, attendees=attendees, duration_minutes=duration_minutes,
583
+ start_window=start_window, end_window=end_window,
584
+ mailbox=mailbox, max_candidates=max_candidates, include_raw=include_raw,
585
+ )
@@ -0,0 +1,83 @@
1
+ """Mail workflow shortcuts: archive, mark read/unread, flag/unflag.
2
+
3
+ These are thin wrappers around move_message / update_message but with
4
+ distinct tool names so the agent picks the right one for natural-language
5
+ intents like 'archive this' or 'mark as read'.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from msgraph_mcp.tools.mail_folders import move_message
11
+ from msgraph_mcp.tools.mail_write import update_message
12
+
13
+
14
+ async def archive_message(
15
+ *, graph, message_id: str, mailbox: str | None = None, include_raw: bool = False
16
+ ) -> dict:
17
+ """Archive a message (move it to the Archive folder)."""
18
+ return await move_message(
19
+ graph=graph, message_id=message_id, destination="archive",
20
+ mailbox=mailbox, include_raw=include_raw,
21
+ )
22
+
23
+
24
+ async def mark_read(
25
+ *, graph, message_id: str, mailbox: str | None = None, include_raw: bool = False
26
+ ) -> dict:
27
+ """Mark a message as read."""
28
+ return await update_message(
29
+ graph=graph, message_id=message_id, is_read=True,
30
+ mailbox=mailbox, include_raw=include_raw,
31
+ )
32
+
33
+
34
+ async def mark_unread(
35
+ *, graph, message_id: str, mailbox: str | None = None, include_raw: bool = False
36
+ ) -> dict:
37
+ """Mark a message as unread."""
38
+ return await update_message(
39
+ graph=graph, message_id=message_id, is_read=False,
40
+ mailbox=mailbox, include_raw=include_raw,
41
+ )
42
+
43
+
44
+ async def flag_message(
45
+ *, graph, message_id: str, mailbox: str | None = None, include_raw: bool = False
46
+ ) -> dict:
47
+ """Set the follow-up flag on a message."""
48
+ return await update_message(
49
+ graph=graph, message_id=message_id, flag="flagged",
50
+ mailbox=mailbox, include_raw=include_raw,
51
+ )
52
+
53
+
54
+ async def unflag_message(
55
+ *, graph, message_id: str, mailbox: str | None = None, include_raw: bool = False
56
+ ) -> dict:
57
+ """Clear the follow-up flag on a message."""
58
+ return await update_message(
59
+ graph=graph, message_id=message_id, flag="notFlagged",
60
+ mailbox=mailbox, include_raw=include_raw,
61
+ )
62
+
63
+
64
+ def register(mcp, *, graph) -> None:
65
+ @mcp.tool(name="archive_message", description=archive_message.__doc__ or "")
66
+ async def _archive(message_id: str, mailbox: str | None = None, include_raw: bool = False):
67
+ return await archive_message(graph=graph, message_id=message_id, mailbox=mailbox, include_raw=include_raw)
68
+
69
+ @mcp.tool(name="mark_read", description=mark_read.__doc__ or "")
70
+ async def _mark_read(message_id: str, mailbox: str | None = None, include_raw: bool = False):
71
+ return await mark_read(graph=graph, message_id=message_id, mailbox=mailbox, include_raw=include_raw)
72
+
73
+ @mcp.tool(name="mark_unread", description=mark_unread.__doc__ or "")
74
+ async def _mark_unread(message_id: str, mailbox: str | None = None, include_raw: bool = False):
75
+ return await mark_unread(graph=graph, message_id=message_id, mailbox=mailbox, include_raw=include_raw)
76
+
77
+ @mcp.tool(name="flag_message", description=flag_message.__doc__ or "")
78
+ async def _flag(message_id: str, mailbox: str | None = None, include_raw: bool = False):
79
+ return await flag_message(graph=graph, message_id=message_id, mailbox=mailbox, include_raw=include_raw)
80
+
81
+ @mcp.tool(name="unflag_message", description=unflag_message.__doc__ or "")
82
+ async def _unflag(message_id: str, mailbox: str | None = None, include_raw: bool = False):
83
+ return await unflag_message(graph=graph, message_id=message_id, mailbox=mailbox, include_raw=include_raw)