msgraph-mcp-server 0.3.2__tar.gz → 0.5.0__tar.gz

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.
Files changed (71) hide show
  1. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/PKG-INFO +1 -1
  2. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/pyproject.toml +1 -1
  3. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/server.json +2 -2
  4. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/serialize.py +4 -0
  5. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/trimming.py +2 -0
  6. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/calendar.py +90 -0
  7. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/fixtures/event_minimal.json +2 -0
  8. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_serialize.py +12 -0
  9. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_trimming.py +7 -0
  10. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_calendar.py +194 -0
  11. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/uv.lock +1 -1
  12. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/.env.example +0 -0
  13. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/.github/dependabot.yml +0 -0
  14. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/.github/workflows/ci.yml +0 -0
  15. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/.github/workflows/release.yml +0 -0
  16. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/.gitignore +0 -0
  17. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/CONTRIBUTING.md +0 -0
  18. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/LICENSE +0 -0
  19. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/README.md +0 -0
  20. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/__init__.py +0 -0
  21. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/auth/__init__.py +0 -0
  22. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/auth/cli.py +0 -0
  23. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/auth/msal_app.py +0 -0
  24. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/auth/token.py +0 -0
  25. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/config.py +0 -0
  26. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/__init__.py +0 -0
  27. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/auth_provider.py +0 -0
  28. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/batch.py +0 -0
  29. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/client.py +0 -0
  30. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/errors.py +0 -0
  31. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/graph/pagination.py +0 -0
  32. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/server.py +0 -0
  33. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/__init__.py +0 -0
  34. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/_binary.py +0 -0
  35. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/mail_actions.py +0 -0
  36. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/mail_batch.py +0 -0
  37. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/mail_folders.py +0 -0
  38. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/mail_read.py +0 -0
  39. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/mail_rules.py +0 -0
  40. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/mail_write.py +0 -0
  41. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/teams_channels.py +0 -0
  42. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/teams_chats.py +0 -0
  43. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/teams_content.py +0 -0
  44. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/src/msgraph_mcp/tools/util.py +0 -0
  45. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/__init__.py +0 -0
  46. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/conftest.py +0 -0
  47. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/fixtures/__init__.py +0 -0
  48. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/fixtures/message_minimal.json +0 -0
  49. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/integration/__init__.py +0 -0
  50. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/integration/test_live_smoke.py +0 -0
  51. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/integration/test_rules_and_folders_live.py +0 -0
  52. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_auth_cli.py +0 -0
  53. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_auth_msal_app.py +0 -0
  54. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_auth_token.py +0 -0
  55. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_config.py +0 -0
  56. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_auth_provider.py +0 -0
  57. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_batch.py +0 -0
  58. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_client.py +0 -0
  59. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_errors.py +0 -0
  60. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_graph_pagination.py +0 -0
  61. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_server.py +0 -0
  62. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_mail_actions.py +0 -0
  63. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_mail_batch.py +0 -0
  64. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_mail_folders.py +0 -0
  65. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_mail_read.py +0 -0
  66. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_mail_rules.py +0 -0
  67. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_mail_write.py +0 -0
  68. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_teams_channels.py +0 -0
  69. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_teams_chats.py +0 -0
  70. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_teams_content.py +0 -0
  71. {msgraph_mcp_server-0.3.2 → msgraph_mcp_server-0.5.0}/tests/test_tools_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: msgraph-mcp-server
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: MCP server for Microsoft Graph: Outlook mail and calendar, plus read-only Microsoft Teams
5
5
  Project-URL: Homepage, https://github.com/timfurlong/msgraph-mcp
6
6
  Project-URL: Repository, https://github.com/timfurlong/msgraph-mcp
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "msgraph-mcp-server"
3
- version = "0.3.2"
3
+ version = "0.5.0"
4
4
  description = "MCP server for Microsoft Graph: Outlook mail and calendar, plus read-only Microsoft Teams"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/timfurlong/msgraph-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.3.2",
10
+ "version": "0.5.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "msgraph-mcp-server",
15
- "version": "0.3.2",
15
+ "version": "0.5.0",
16
16
  "runtimeHint": "uvx",
17
17
  "transport": {
18
18
  "type": "stdio"
@@ -188,6 +188,10 @@ def event_to_dict(evt: Any) -> dict:
188
188
  "body": _body_to_dict(getattr(evt, "body", None)),
189
189
  "showAs": _enum_value(getattr(evt, "show_as", None)),
190
190
  "sensitivity": _enum_value(getattr(evt, "sensitivity", None)),
191
+ "isReminderOn": getattr(evt, "is_reminder_on", None),
192
+ "reminderMinutesBeforeStart": getattr(
193
+ evt, "reminder_minutes_before_start", None
194
+ ),
191
195
  "recurrence": getattr(evt, "recurrence", None),
192
196
  "webLink": getattr(evt, "web_link", None),
193
197
  }
@@ -101,6 +101,8 @@ def trim_event(raw: dict, *, include_body: bool, include_raw: bool) -> dict:
101
101
  "body_preview": raw.get("bodyPreview"),
102
102
  "show_as": raw.get("showAs"),
103
103
  "sensitivity": raw.get("sensitivity"),
104
+ "is_reminder_on": raw.get("isReminderOn"),
105
+ "reminder_minutes_before_start": raw.get("reminderMinutesBeforeStart"),
104
106
  "recurrence": raw.get("recurrence"),
105
107
  "web_link": raw.get("webLink"),
106
108
  }
@@ -13,6 +13,7 @@ from msgraph.generated.models.body_type import BodyType
13
13
  from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone
14
14
  from msgraph.generated.models.email_address import EmailAddress
15
15
  from msgraph.generated.models.event import Event
16
+ from msgraph.generated.models.free_busy_status import FreeBusyStatus
16
17
  from msgraph.generated.models.item_body import ItemBody
17
18
  from msgraph.generated.models.location import Location
18
19
  from msgraph.generated.models.time_constraint import TimeConstraint
@@ -53,6 +54,16 @@ _VALID_RESPONSES = {
53
54
  "decline": "declined",
54
55
  }
55
56
 
57
+ ShowAs = Literal["free", "tentative", "busy", "oof", "workingElsewhere"]
58
+
59
+ _SHOW_AS = {
60
+ "free": FreeBusyStatus.Free,
61
+ "tentative": FreeBusyStatus.Tentative,
62
+ "busy": FreeBusyStatus.Busy,
63
+ "oof": FreeBusyStatus.Oof,
64
+ "workingElsewhere": FreeBusyStatus.WorkingElsewhere,
65
+ }
66
+
56
67
 
57
68
  async def list_calendars(
58
69
  *, graph, mailbox: str | None = None, include_raw: bool = False
@@ -170,6 +181,34 @@ def _dttz(dt: str, tz: str) -> DateTimeTimeZone:
170
181
  return obj
171
182
 
172
183
 
184
+ def _free_busy(show_as: str) -> FreeBusyStatus:
185
+ if show_as not in _SHOW_AS:
186
+ raise GraphValidationError(f"show_as must be one of {sorted(_SHOW_AS)}")
187
+ return _SHOW_AS[show_as]
188
+
189
+
190
+ def _apply_reminder(
191
+ evt: Event,
192
+ *,
193
+ is_reminder_on: bool | None,
194
+ reminder_minutes_before_start: int | None,
195
+ ) -> None:
196
+ """Set reminder fields on evt. Giving minutes turns the reminder on."""
197
+ if reminder_minutes_before_start is not None:
198
+ if reminder_minutes_before_start < 0:
199
+ raise GraphValidationError(
200
+ "reminder_minutes_before_start must be zero or greater"
201
+ )
202
+ if is_reminder_on is False:
203
+ raise GraphValidationError(
204
+ "reminder_minutes_before_start cannot be set when is_reminder_on is False"
205
+ )
206
+ evt.reminder_minutes_before_start = reminder_minutes_before_start
207
+ evt.is_reminder_on = True
208
+ elif is_reminder_on is not None:
209
+ evt.is_reminder_on = is_reminder_on
210
+
211
+
173
212
  def _build_event(
174
213
  *,
175
214
  subject: str,
@@ -182,6 +221,9 @@ def _build_event(
182
221
  attendees: list[str] | None,
183
222
  is_online_meeting: bool,
184
223
  is_all_day: bool,
224
+ show_as: ShowAs | None,
225
+ is_reminder_on: bool | None,
226
+ reminder_minutes_before_start: int | None,
185
227
  ) -> Event:
186
228
  e = Event()
187
229
  e.subject = subject
@@ -208,6 +250,13 @@ def _build_event(
208
250
  e.attendees = atts
209
251
  e.is_online_meeting = is_online_meeting
210
252
  e.is_all_day = is_all_day
253
+ if show_as is not None:
254
+ e.show_as = _free_busy(show_as)
255
+ _apply_reminder(
256
+ e,
257
+ is_reminder_on=is_reminder_on,
258
+ reminder_minutes_before_start=reminder_minutes_before_start,
259
+ )
211
260
  return e
212
261
 
213
262
 
@@ -224,6 +273,9 @@ async def create_event(
224
273
  attendees: list[str] | None = None,
225
274
  is_online_meeting: bool = False,
226
275
  is_all_day: bool = False,
276
+ show_as: ShowAs | None = None,
277
+ is_reminder_on: bool | None = None,
278
+ reminder_minutes_before_start: int | None = None,
227
279
  calendar_id: str | None = None,
228
280
  mailbox: str | None = None,
229
281
  include_raw: bool = False,
@@ -240,6 +292,12 @@ async def create_event(
240
292
  attendees: Optional list of attendee email addresses (treated as required).
241
293
  is_online_meeting: When True, Outlook adds a Teams meeting link.
242
294
  is_all_day: All-day event flag.
295
+ show_as: Free/busy status shown to others: "free", "tentative", "busy",
296
+ "oof", or "workingElsewhere". Omit for Outlook's default ("busy").
297
+ is_reminder_on: False creates the event with no reminder. Omit for
298
+ Outlook's default (a reminder 15 minutes before start).
299
+ reminder_minutes_before_start: Minutes before start to alert (0 or
300
+ more). Setting this turns the reminder on.
243
301
  calendar_id: Optional calendar id; default is the user's primary calendar.
244
302
  mailbox: Optional mailbox.
245
303
 
@@ -257,6 +315,9 @@ async def create_event(
257
315
  attendees=attendees,
258
316
  is_online_meeting=is_online_meeting,
259
317
  is_all_day=is_all_day,
318
+ show_as=show_as,
319
+ is_reminder_on=is_reminder_on,
320
+ reminder_minutes_before_start=reminder_minutes_before_start,
260
321
  )
261
322
  try:
262
323
  target = graph.mailbox(mailbox)
@@ -285,12 +346,22 @@ async def update_event(
285
346
  location: str | None = None,
286
347
  is_online_meeting: bool | None = None,
287
348
  is_all_day: bool | None = None,
349
+ show_as: ShowAs | None = None,
350
+ is_reminder_on: bool | None = None,
351
+ reminder_minutes_before_start: int | None = None,
288
352
  mailbox: str | None = None,
289
353
  include_raw: bool = False,
290
354
  ) -> dict:
291
355
  """Patch fields of an existing event. Pass None to leave a field unchanged.
292
356
 
293
357
  If you change start_datetime or end_datetime, you must also pass time_zone.
358
+
359
+ show_as sets the free/busy status shown to others: "free", "tentative",
360
+ "busy", "oof", or "workingElsewhere".
361
+
362
+ is_reminder_on=False removes the reminder. reminder_minutes_before_start
363
+ sets how many minutes before start to alert (0 or more) and turns the
364
+ reminder on.
294
365
  """
295
366
  if (start_datetime or end_datetime) and not time_zone:
296
367
  raise GraphValidationError("time_zone is required when updating start or end")
@@ -314,6 +385,13 @@ async def update_event(
314
385
  patch.is_online_meeting = is_online_meeting
315
386
  if is_all_day is not None:
316
387
  patch.is_all_day = is_all_day
388
+ if show_as is not None:
389
+ patch.show_as = _free_busy(show_as)
390
+ _apply_reminder(
391
+ patch,
392
+ is_reminder_on=is_reminder_on,
393
+ reminder_minutes_before_start=reminder_minutes_before_start,
394
+ )
317
395
  try:
318
396
  updated = await graph.mailbox(mailbox).events.by_event_id(event_id).patch(patch)
319
397
  except NotAuthenticatedError:
@@ -547,6 +625,9 @@ def register(mcp, *, graph) -> None:
547
625
  attendees: list[str] | None = None,
548
626
  is_online_meeting: bool = False,
549
627
  is_all_day: bool = False,
628
+ show_as: ShowAs | None = None,
629
+ is_reminder_on: bool | None = None,
630
+ reminder_minutes_before_start: int | None = None,
550
631
  calendar_id: str | None = None,
551
632
  mailbox: str | None = None,
552
633
  include_raw: bool = False,
@@ -563,6 +644,9 @@ def register(mcp, *, graph) -> None:
563
644
  attendees=attendees,
564
645
  is_online_meeting=is_online_meeting,
565
646
  is_all_day=is_all_day,
647
+ show_as=show_as,
648
+ is_reminder_on=is_reminder_on,
649
+ reminder_minutes_before_start=reminder_minutes_before_start,
566
650
  calendar_id=calendar_id,
567
651
  mailbox=mailbox,
568
652
  include_raw=include_raw,
@@ -580,6 +664,9 @@ def register(mcp, *, graph) -> None:
580
664
  location: str | None = None,
581
665
  is_online_meeting: bool | None = None,
582
666
  is_all_day: bool | None = None,
667
+ show_as: ShowAs | None = None,
668
+ is_reminder_on: bool | None = None,
669
+ reminder_minutes_before_start: int | None = None,
583
670
  mailbox: str | None = None,
584
671
  include_raw: bool = False,
585
672
  ):
@@ -595,6 +682,9 @@ def register(mcp, *, graph) -> None:
595
682
  location=location,
596
683
  is_online_meeting=is_online_meeting,
597
684
  is_all_day=is_all_day,
685
+ show_as=show_as,
686
+ is_reminder_on=is_reminder_on,
687
+ reminder_minutes_before_start=reminder_minutes_before_start,
598
688
  mailbox=mailbox,
599
689
  include_raw=include_raw,
600
690
  )
@@ -20,6 +20,8 @@
20
20
  "body": {"contentType": "html", "content": "<p>Daily standup</p>"},
21
21
  "showAs": "busy",
22
22
  "sensitivity": "normal",
23
+ "isReminderOn": true,
24
+ "reminderMinutesBeforeStart": 15,
23
25
  "recurrence": null,
24
26
  "webLink": "https://outlook.office.com/owa/?ItemID=evt"
25
27
  }
@@ -151,6 +151,18 @@ def test_message_rule_predicates_to_dict_minimal():
151
151
  assert d["fromAddresses"] == []
152
152
 
153
153
 
154
+ def test_event_to_dict_includes_reminder():
155
+ evt = SimpleNamespace(
156
+ id="e1",
157
+ is_reminder_on=True,
158
+ reminder_minutes_before_start=30,
159
+ additional_data={},
160
+ )
161
+ d = serialize.event_to_dict(evt)
162
+ assert d["isReminderOn"] is True
163
+ assert d["reminderMinutesBeforeStart"] == 30
164
+
165
+
154
166
  def test_message_rule_actions_to_dict_minimal():
155
167
  actions = SimpleNamespace(
156
168
  assign_categories=None,
@@ -84,6 +84,13 @@ def test_trim_event_minimal():
84
84
  assert "body" not in t
85
85
 
86
86
 
87
+ def test_trim_event_includes_reminder():
88
+ raw = load("event_minimal.json")
89
+ t = trimming.trim_event(raw, include_body=False, include_raw=False)
90
+ assert t["is_reminder_on"] is True
91
+ assert t["reminder_minutes_before_start"] == 15
92
+
93
+
87
94
  def test_trim_event_with_body_and_raw():
88
95
  raw = load("event_minimal.json")
89
96
  t = trimming.trim_event(raw, include_body=True, include_raw=True)
@@ -2,6 +2,7 @@ from types import SimpleNamespace
2
2
  from unittest.mock import AsyncMock, MagicMock
3
3
 
4
4
  import pytest
5
+ from msgraph.generated.models.free_busy_status import FreeBusyStatus
5
6
 
6
7
  from msgraph_mcp.graph.errors import GraphValidationError
7
8
  from msgraph_mcp.tools import calendar as cal_tools
@@ -134,6 +135,199 @@ async def test_update_event_patches():
134
135
  assert result["id"] == "e1"
135
136
 
136
137
 
138
+ @pytest.mark.asyncio
139
+ async def test_create_event_sets_show_as():
140
+ graph = MagicMock()
141
+ mb = MagicMock()
142
+ mb.events.post = AsyncMock(return_value=_fake_event(id_="new1"))
143
+ graph.mailbox = MagicMock(return_value=mb)
144
+
145
+ await cal_tools.create_event(
146
+ graph=graph,
147
+ subject="Focus time",
148
+ start_datetime="2026-05-19T15:00:00",
149
+ end_datetime="2026-05-19T15:30:00",
150
+ show_as="free",
151
+ )
152
+ assert mb.events.post.await_args is not None
153
+ posted = mb.events.post.await_args.args[0]
154
+ assert posted.show_as == FreeBusyStatus.Free
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_create_event_leaves_show_as_unset_by_default():
159
+ graph = MagicMock()
160
+ mb = MagicMock()
161
+ mb.events.post = AsyncMock(return_value=_fake_event(id_="new1"))
162
+ graph.mailbox = MagicMock(return_value=mb)
163
+
164
+ await cal_tools.create_event(
165
+ graph=graph,
166
+ subject="Standup",
167
+ start_datetime="2026-05-19T15:00:00",
168
+ end_datetime="2026-05-19T15:30:00",
169
+ )
170
+ assert mb.events.post.await_args is not None
171
+ posted = mb.events.post.await_args.args[0]
172
+ assert posted.show_as is None
173
+
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_create_event_rejects_bad_show_as():
177
+ graph = MagicMock()
178
+ with pytest.raises(GraphValidationError):
179
+ await cal_tools.create_event(
180
+ graph=graph,
181
+ subject="Standup",
182
+ start_datetime="2026-05-19T15:00:00",
183
+ end_datetime="2026-05-19T15:30:00",
184
+ show_as="available", # type: ignore[arg-type]
185
+ )
186
+
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_update_event_patches_show_as():
190
+ graph = MagicMock()
191
+ mb = MagicMock()
192
+ evt_builder = MagicMock(patch=AsyncMock(return_value=_fake_event(id_="e1")))
193
+ mb.events.by_event_id = MagicMock(return_value=evt_builder)
194
+ graph.mailbox = MagicMock(return_value=mb)
195
+
196
+ await cal_tools.update_event(graph=graph, event_id="e1", show_as="workingElsewhere")
197
+ patched = evt_builder.patch.await_args.args[0]
198
+ assert patched.show_as == FreeBusyStatus.WorkingElsewhere
199
+
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_update_event_rejects_bad_show_as():
203
+ graph = MagicMock()
204
+ with pytest.raises(GraphValidationError):
205
+ await cal_tools.update_event(
206
+ graph=graph,
207
+ event_id="e1",
208
+ show_as="available", # type: ignore[arg-type]
209
+ )
210
+
211
+
212
+ def test_register_exposes_show_as_on_create_and_update():
213
+ import inspect
214
+
215
+ registered: dict = {}
216
+
217
+ class FakeMCP:
218
+ def tool(self, *, name, description=""): # noqa: ARG002
219
+ def deco(fn):
220
+ registered[name] = fn
221
+ return fn
222
+
223
+ return deco
224
+
225
+ cal_tools.register(FakeMCP(), graph=MagicMock())
226
+
227
+ for name in ("create_event", "update_event"):
228
+ assert "show_as" in inspect.signature(registered[name]).parameters
229
+
230
+
231
+ async def _create_with(**kwargs):
232
+ graph = MagicMock()
233
+ mb = MagicMock()
234
+ mb.events.post = AsyncMock(return_value=_fake_event(id_="new1"))
235
+ graph.mailbox = MagicMock(return_value=mb)
236
+ await cal_tools.create_event(
237
+ graph=graph,
238
+ subject="Standup",
239
+ start_datetime="2026-05-19T15:00:00",
240
+ end_datetime="2026-05-19T15:30:00",
241
+ **kwargs,
242
+ )
243
+ assert mb.events.post.await_args is not None
244
+ return mb.events.post.await_args.args[0]
245
+
246
+
247
+ async def _update_with(**kwargs):
248
+ graph = MagicMock()
249
+ mb = MagicMock()
250
+ evt_builder = MagicMock(patch=AsyncMock(return_value=_fake_event(id_="e1")))
251
+ mb.events.by_event_id = MagicMock(return_value=evt_builder)
252
+ graph.mailbox = MagicMock(return_value=mb)
253
+ await cal_tools.update_event(graph=graph, event_id="e1", **kwargs)
254
+ return evt_builder.patch.await_args.args[0]
255
+
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_create_event_can_turn_reminder_off():
259
+ posted = await _create_with(is_reminder_on=False)
260
+ assert posted.is_reminder_on is False
261
+ assert posted.reminder_minutes_before_start is None
262
+
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_create_event_reminder_minutes_turns_reminder_on():
266
+ posted = await _create_with(reminder_minutes_before_start=60)
267
+ assert posted.reminder_minutes_before_start == 60
268
+ assert posted.is_reminder_on is True
269
+
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_create_event_leaves_reminder_unset_by_default():
273
+ posted = await _create_with()
274
+ assert posted.is_reminder_on is None
275
+ assert posted.reminder_minutes_before_start is None
276
+
277
+
278
+ @pytest.mark.asyncio
279
+ async def test_create_event_rejects_negative_reminder_minutes():
280
+ with pytest.raises(GraphValidationError):
281
+ await _create_with(reminder_minutes_before_start=-5)
282
+
283
+
284
+ @pytest.mark.asyncio
285
+ async def test_create_event_rejects_minutes_with_reminder_off():
286
+ with pytest.raises(GraphValidationError):
287
+ await _create_with(is_reminder_on=False, reminder_minutes_before_start=10)
288
+
289
+
290
+ @pytest.mark.asyncio
291
+ async def test_update_event_can_turn_reminder_off():
292
+ patched = await _update_with(is_reminder_on=False)
293
+ assert patched.is_reminder_on is False
294
+ assert patched.reminder_minutes_before_start is None
295
+
296
+
297
+ @pytest.mark.asyncio
298
+ async def test_update_event_reminder_minutes_turns_reminder_on():
299
+ patched = await _update_with(reminder_minutes_before_start=0)
300
+ assert patched.reminder_minutes_before_start == 0
301
+ assert patched.is_reminder_on is True
302
+
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_update_event_rejects_negative_reminder_minutes():
306
+ with pytest.raises(GraphValidationError):
307
+ await _update_with(reminder_minutes_before_start=-1)
308
+
309
+
310
+ def test_register_exposes_reminder_params_on_create_and_update():
311
+ import inspect
312
+
313
+ registered: dict = {}
314
+
315
+ class FakeMCP:
316
+ def tool(self, *, name, description=""): # noqa: ARG002
317
+ def deco(fn):
318
+ registered[name] = fn
319
+ return fn
320
+
321
+ return deco
322
+
323
+ cal_tools.register(FakeMCP(), graph=MagicMock())
324
+
325
+ for name in ("create_event", "update_event"):
326
+ params = inspect.signature(registered[name]).parameters
327
+ assert "is_reminder_on" in params
328
+ assert "reminder_minutes_before_start" in params
329
+
330
+
137
331
  @pytest.mark.asyncio
138
332
  async def test_delete_event_calls_delete():
139
333
  graph = MagicMock()
@@ -827,7 +827,7 @@ wheels = [
827
827
 
828
828
  [[package]]
829
829
  name = "msgraph-mcp-server"
830
- version = "0.3.2"
830
+ version = "0.5.0"
831
831
  source = { editable = "." }
832
832
  dependencies = [
833
833
  { name = "mcp" },