@littlebearapps/outlook-assistant 3.12.0 → 3.12.1

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.
package/.env.example CHANGED
@@ -31,6 +31,14 @@ USE_TEST_MODE=false
31
31
  # receivedBefore rather than raising this if you can.
32
32
  # OUTLOOK_SEARCH_SCAN_LIMIT=500
33
33
 
34
+ # Inactivity timeout for each Graph request attempt (milliseconds): an attempt
35
+ # that receives no data for this long is abandoned with a timeout error. It is
36
+ # not an overall deadline — a slow response that keeps arriving isn't cut off.
37
+ # Throttled (429) and busy (503/504) responses are retried automatically (up to
38
+ # 3 times, honouring Retry-After); POST requests such as sending mail are only
39
+ # retried on a 429 asking for a short wait (10 s or less, 20 s in total).
40
+ # OUTLOOK_REQUEST_TIMEOUT_MS=60000
41
+
34
42
  # Optional: Default authentication method (device-code or browser)
35
43
  # device-code: No auth server needed, works remotely/headless
36
44
  # browser: Traditional OAuth redirect via localhost:3333
package/README.md CHANGED
@@ -143,7 +143,7 @@ Outlook Assistant is designed with safety-first principles for AI-driven email a
143
143
 
144
144
  **Input and file hardening** — IDs containing `.` or `..` path segments are refused before any request is made, continuation links (`deltaToken`) must point at `graph.microsoft.com`, and attachment downloads and exports write sanitised filenames inside the output directory without overwriting existing files or following symlinks.
145
145
 
146
- **Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway.
146
+ **Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway. `update`, `send` and `delete` refuse any ID that is not an unsent draft, so a received or sent message is never edited, deleted or re-sent.
147
147
 
148
148
  **Token-optimised architecture** — Tools are consolidated using the STRAP (Single Tool, Resource, Action Pattern) approach. 22 tools instead of 55 reduces per-turn overhead by ~11,000 tokens (~64%), keeping more of the AI's context window available for your actual conversation. Fewer tools also means the AI selects the right tool more accurately — research shows tool selection degrades beyond ~40 tools.
149
149
 
@@ -166,7 +166,7 @@ npx @littlebearapps/outlook-assistant
166
166
  To check which version you have, or to see the available options:
167
167
 
168
168
  ```bash
169
- outlook-assistant --version # prints e.g. 3.12.0
169
+ outlook-assistant --version # prints e.g. 3.12.1
170
170
  outlook-assistant --help # usage, options and key environment variables
171
171
  ```
172
172
 
@@ -374,6 +374,7 @@ USE_TEST_MODE=false
374
374
  | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, and rule forwards. | unrestricted |
375
375
  | `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support (work/school only). `read` requests `Mail.Read.Shared`; `true` (or `readwrite`/`1`) also requests `Mail.ReadWrite.Shared`. Unset leaves sign-in unchanged. After enabling, restart and run `auth action=authenticate force=true`. | unset (off) |
376
376
  | `OUTLOOK_SEARCH_SCAN_LIMIT` | How many recent messages the client-side search fallback scans. Personal accounts match `to` locally within this window, so the default caps how far back a `to` search reaches. Max 5000. | `500` |
377
+ | `OUTLOOK_REQUEST_TIMEOUT_MS` | Inactivity timeout for each Graph request attempt, in milliseconds: an attempt that receives no data for this long is abandoned with a timeout error. It isn't an overall deadline, so a slow response that keeps arriving isn't cut off. Throttled (`429`) and busy (`503`/`504`) responses are retried automatically, honouring `Retry-After`. | `60000` |
377
378
 
378
379
  ### MCP Client Configuration
379
380
 
@@ -451,7 +452,9 @@ outlook-assistant/
451
452
  │ ├── conversations.js # Thread listing/export
452
453
  │ ├── attachments.js # Attachment operations
453
454
  │ └── ...
454
- ├── calendar/ # Calendar module (3 tools; list.js builds list-events filters)
455
+ ├── calendar/ # Calendar module (3 tools)
456
+ │ ├── attendees.js # Attendee builder (email or {email, type})
457
+ │ └── list.js # list-events filters
455
458
  ├── contacts/ # Contacts module (2 tools)
456
459
  ├── categories/ # Categories module (3 tools)
457
460
  ├── settings/ # Settings module (1 tool)
@@ -462,6 +465,8 @@ outlook-assistant/
462
465
  ├── graph-api.js # Microsoft Graph API client (includes $batch, path guards)
463
466
  ├── mailbox.js # me vs users/{sharedMailbox} prefix, shared-mailbox opt-in
464
467
  ├── safety.js # Rate limiting, recipient allowlist, dry-run
468
+ ├── safe-write.js # Exclusive, outputDir-confined file writes
469
+ ├── datetime.js # ISO 8601 parsing and timezone conversion
465
470
  ├── odata-helpers.js # OData query building
466
471
  ├── field-presets.js # Token-efficient field selections
467
472
  ├── response-formatter.js # Verbosity levels
package/advanced/index.js CHANGED
@@ -20,6 +20,12 @@ const {
20
20
  } = require('../utils/mailbox');
21
21
  const { resolveFolder } = require('../folder/resolve');
22
22
  const { getAllFoldersHierarchy } = require('../folder/list');
23
+ const {
24
+ InvalidDateTimeError,
25
+ toGraphDateTimeTimeZone,
26
+ zonedParts,
27
+ zonedWallTimeToUtcMs,
28
+ } = require('../utils/datetime');
23
29
 
24
30
  /**
25
31
  * Format an email for display (simplified)
@@ -408,6 +414,49 @@ async function handleListSharedMailboxFolders(sharedMailbox, args) {
408
414
  }
409
415
  }
410
416
 
417
+ /**
418
+ * Default follow-up start for a flag with only a due date: 09:00 in
419
+ * DEFAULT_TIMEZONE on the due's local date, or the due itself when the due is
420
+ * earlier than that (a start after the due would be nonsense).
421
+ */
422
+ function deriveFlagStart(due) {
423
+ let wall;
424
+ if (due.timeZone === DEFAULT_TIMEZONE) {
425
+ wall = due.dateTime;
426
+ } else {
427
+ const parts = zonedParts(Date.parse(`${due.dateTime}Z`), DEFAULT_TIMEZONE);
428
+ if (!parts) return { ...due };
429
+ wall = `${parts.date}T${parts.time}`;
430
+ }
431
+ const nineAm = `${wall.slice(0, 10)}T09:00:00`;
432
+ // Same zone and fixed-width prefix, so string order is time order.
433
+ return wall < nineAm
434
+ ? { ...due }
435
+ : { dateTime: nineAm, timeZone: DEFAULT_TIMEZONE };
436
+ }
437
+
438
+ /**
439
+ * Describe a flag dateTimeTimeZone envelope as UTC plus DEFAULT_TIMEZONE,
440
+ * e.g. "2026-03-01 09:00 UTC (2026-03-01 20:00 Australia/Melbourne)".
441
+ */
442
+ function describeFlagTime(envelope) {
443
+ const ms =
444
+ envelope.timeZone === 'UTC'
445
+ ? Date.parse(`${envelope.dateTime}Z`)
446
+ : zonedWallTimeToUtcMs(envelope.dateTime, envelope.timeZone);
447
+ const local = (date, time) =>
448
+ `${date} ${time.slice(0, 5)} ${DEFAULT_TIMEZONE}`;
449
+ if (Number.isNaN(ms)) {
450
+ // DEFAULT_TIMEZONE isn't an IANA zone Intl knows; show it as given.
451
+ return local(envelope.dateTime.slice(0, 10), envelope.dateTime.slice(11));
452
+ }
453
+ const iso = new Date(ms).toISOString();
454
+ const utc = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
455
+ const parts =
456
+ DEFAULT_TIMEZONE === 'UTC' ? null : zonedParts(ms, DEFAULT_TIMEZONE);
457
+ return parts ? `${utc} (${local(parts.date, parts.time)})` : utc;
458
+ }
459
+
411
460
  /**
412
461
  * Set message flag handler
413
462
  */
@@ -435,43 +484,38 @@ async function handleSetMessageFlag(args) {
435
484
  };
436
485
  }
437
486
 
487
+ // Build flag object. Zoned values (Z/offset) are sent as the same instant in
488
+ // UTC; zone-less values are read in DEFAULT_TIMEZONE. Bad dates are refused
489
+ // here, before authenticating or calling Graph.
490
+ const flag = {
491
+ flagStatus: 'flagged',
492
+ };
438
493
  try {
439
- const accessToken = await ensureAuthenticated();
440
-
441
- // Build flag object
442
- const flag = {
443
- flagStatus: 'flagged',
444
- };
445
-
494
+ if (startDateTime) {
495
+ flag.startDateTime = toGraphDateTimeTimeZone(
496
+ startDateTime,
497
+ 'startDateTime'
498
+ );
499
+ }
446
500
  if (dueDateTime) {
447
- // Graph API expects { dateTime, timeZone } envelope without trailing Z
448
- // When timeZone is specified, the dateTime value is interpreted in that zone
449
- const dueDt = dueDateTime.replace(/Z$/i, '');
450
- flag.dueDateTime = {
451
- dateTime: dueDt,
452
- timeZone: DEFAULT_TIMEZONE,
453
- };
454
-
455
- // Graph API requires startDateTime when dueDateTime is set
456
- // Default to start of the same day if not explicitly provided
457
- if (startDateTime) {
458
- flag.startDateTime = {
459
- dateTime: startDateTime.replace(/Z$/i, ''),
460
- timeZone: DEFAULT_TIMEZONE,
461
- };
462
- } else {
463
- const startOfDay = `${dueDt.split('T')[0]}T09:00:00`;
464
- flag.startDateTime = {
465
- dateTime: startOfDay,
466
- timeZone: DEFAULT_TIMEZONE,
467
- };
501
+ flag.dueDateTime = toGraphDateTimeTimeZone(dueDateTime, 'dueDateTime');
502
+ // Graph requires startDateTime when dueDateTime is set.
503
+ if (!flag.startDateTime) {
504
+ flag.startDateTime = deriveFlagStart(flag.dueDateTime);
468
505
  }
469
- } else if (startDateTime) {
470
- flag.startDateTime = {
471
- dateTime: startDateTime.replace(/Z$/i, ''),
472
- timeZone: DEFAULT_TIMEZONE,
506
+ }
507
+ } catch (error) {
508
+ if (error instanceof InvalidDateTimeError) {
509
+ return {
510
+ content: [{ type: 'text', text: error.message }],
511
+ isError: true,
473
512
  };
474
513
  }
514
+ throw error;
515
+ }
516
+
517
+ try {
518
+ const accessToken = await ensureAuthenticated();
475
519
 
476
520
  // Process all messages
477
521
  const results = [];
@@ -493,11 +537,11 @@ async function handleSetMessageFlag(args) {
493
537
  if (results.length > 0) {
494
538
  output.push(`Flagged ${results.length} message(s) for follow-up`);
495
539
 
496
- if (dueDateTime) {
497
- output.push(`**Due**: ${new Date(dueDateTime).toLocaleString()}`);
540
+ if (flag.dueDateTime) {
541
+ output.push(`**Due**: ${describeFlagTime(flag.dueDateTime)}`);
498
542
  }
499
- if (startDateTime) {
500
- output.push(`**Start**: ${new Date(startDateTime).toLocaleString()}`);
543
+ if (flag.startDateTime) {
544
+ output.push(`**Start**: ${describeFlagTime(flag.startDateTime)}`);
501
545
  }
502
546
  }
503
547
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared attendee builder for create-event and manage-event update (#249).
3
+ *
4
+ * Callers pass each attendee as a plain email string or as
5
+ * `{ email, type }` where type is `required`, `optional` or `resource`.
6
+ * Graph's PATCH on `attendees` replaces the whole list, so an entry
7
+ * without a type takes the type that address already has on the event
8
+ * (matched case-insensitively) and only falls back to `required` for a
9
+ * new address. An explicit type always wins.
10
+ */
11
+
12
+ const ATTENDEE_TYPES = ['required', 'optional', 'resource'];
13
+ const ATTENDEE_FIELDS = new Set(['email', 'type']);
14
+
15
+ function invalid(index, reason) {
16
+ return new Error(`Invalid attendee at position ${index + 1}: ${reason}`);
17
+ }
18
+
19
+ /**
20
+ * Validate one attendee entry.
21
+ * @param {string|object} entry - Email string or {email, type}
22
+ * @param {number} index - Position in the list (for error messages)
23
+ * @returns {{email: string, type: (string|undefined)}}
24
+ * @throws {Error} 'Invalid attendee at position N: …'
25
+ */
26
+ function normaliseAttendeeInput(entry, index) {
27
+ if (typeof entry === 'string') {
28
+ const email = entry.trim();
29
+ if (!email) throw invalid(index, 'email address is empty.');
30
+ return { email, type: undefined };
31
+ }
32
+
33
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
34
+ throw invalid(
35
+ index,
36
+ `expected an email address or an {email, type} object, got ${JSON.stringify(entry)}.`
37
+ );
38
+ }
39
+
40
+ const unknown = Object.keys(entry).find((k) => !ATTENDEE_FIELDS.has(k));
41
+ if (unknown) {
42
+ throw invalid(
43
+ index,
44
+ `unknown attendee field '${unknown}'. Use {email, type}.`
45
+ );
46
+ }
47
+ if (typeof entry.email !== 'string' || !entry.email.trim()) {
48
+ throw invalid(index, 'email address is missing or empty.');
49
+ }
50
+
51
+ const type = entry.type ?? undefined;
52
+ if (type !== undefined && !ATTENDEE_TYPES.includes(type)) {
53
+ throw invalid(
54
+ index,
55
+ `type '${type}' must be one of: ${ATTENDEE_TYPES.join(', ')}.`
56
+ );
57
+ }
58
+ return { email: entry.email.trim(), type };
59
+ }
60
+
61
+ /**
62
+ * Validate a whole attendee list.
63
+ * @param {Array} list
64
+ * @returns {Array<{email: string, type: (string|undefined)}>}
65
+ */
66
+ function normaliseAttendees(list) {
67
+ if (!Array.isArray(list)) {
68
+ throw new Error(
69
+ 'Invalid attendees: expected a list of email addresses or {email, type} objects.'
70
+ );
71
+ }
72
+ return list.map((entry, index) => normaliseAttendeeInput(entry, index));
73
+ }
74
+
75
+ /**
76
+ * Build the Graph `attendees` array.
77
+ * @param {Array} list - Email strings and/or {email, type} objects
78
+ * @param {Array} current - The event's current Graph attendees (update only)
79
+ * @returns {Array<{emailAddress: {address: string}, type: string}>}
80
+ */
81
+ function buildAttendees(list, current = []) {
82
+ const currentTypes = new Map();
83
+ for (const attendee of current || []) {
84
+ const address = attendee?.emailAddress?.address;
85
+ if (typeof address === 'string' && ATTENDEE_TYPES.includes(attendee.type)) {
86
+ currentTypes.set(address.toLowerCase(), attendee.type);
87
+ }
88
+ }
89
+
90
+ return normaliseAttendees(list).map(({ email, type }) => ({
91
+ emailAddress: { address: email },
92
+ type: type || currentTypes.get(email.toLowerCase()) || 'required',
93
+ }));
94
+ }
95
+
96
+ module.exports = {
97
+ ATTENDEE_TYPES,
98
+ normaliseAttendeeInput,
99
+ normaliseAttendees,
100
+ buildAttendees,
101
+ };
@@ -30,10 +30,11 @@ async function handleCancelEvent(args) {
30
30
  // Build API endpoint
31
31
  const endpoint = `me/events/${eventId}/cancel`;
32
32
 
33
- // Request body
34
- const body = {
35
- comment: comment || 'Cancelled via API',
36
- };
33
+ // Only send a comment the caller gave; no placeholder text.
34
+ const body = {};
35
+ if (typeof comment === 'string' && comment.trim() !== '') {
36
+ body.comment = comment;
37
+ }
37
38
 
38
39
  // Make API call
39
40
  await callGraphAPI(accessToken, 'POST', endpoint, body);
@@ -4,6 +4,7 @@
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
6
  const { DEFAULT_TIMEZONE } = require('../config');
7
+ const { buildAttendees } = require('./attendees');
7
8
 
8
9
  /**
9
10
  * Create event handler
@@ -24,6 +25,19 @@ async function handleCreateEvent(args) {
24
25
  };
25
26
  }
26
27
 
28
+ // Plain strings are required attendees; {email, type} sets the type (#249).
29
+ let graphAttendees;
30
+ if (attendees) {
31
+ try {
32
+ graphAttendees = buildAttendees(attendees);
33
+ } catch (error) {
34
+ return {
35
+ content: [{ type: 'text', text: error.message }],
36
+ isError: true,
37
+ };
38
+ }
39
+ }
40
+
27
41
  try {
28
42
  // Get access token
29
43
  const accessToken = await ensureAuthenticated();
@@ -42,10 +56,7 @@ async function handleCreateEvent(args) {
42
56
  dateTime: end.dateTime || end,
43
57
  timeZone: end.timeZone || DEFAULT_TIMEZONE,
44
58
  },
45
- attendees: attendees?.map((email) => ({
46
- emailAddress: { address: email },
47
- type: 'required',
48
- })),
59
+ attendees: graphAttendees,
49
60
  body: { contentType: 'HTML', content: body || '' },
50
61
  };
51
62
 
@@ -10,7 +10,7 @@ const { ensureAuthenticated } = require('../auth');
10
10
  * @returns {object} - MCP response
11
11
  */
12
12
  async function handleDeclineEvent(args) {
13
- const { eventId, comment } = args;
13
+ const { eventId, comment, sendResponse } = args;
14
14
 
15
15
  if (!eventId) {
16
16
  return {
@@ -30,10 +30,15 @@ async function handleDeclineEvent(args) {
30
30
  // Build API endpoint
31
31
  const endpoint = `me/events/${eventId}/decline`;
32
32
 
33
- // Request body
34
- const body = {
35
- comment: comment || 'Declined via API',
36
- };
33
+ // Only send what the caller gave: no placeholder comment, and Graph's
34
+ // own default (notify the organiser) unless sendResponse is set.
35
+ const body = {};
36
+ if (typeof comment === 'string' && comment.trim() !== '') {
37
+ body.comment = comment;
38
+ }
39
+ if (typeof sendResponse === 'boolean') {
40
+ body.sendResponse = sendResponse;
41
+ }
37
42
 
38
43
  // Make API call
39
44
  await callGraphAPI(accessToken, 'POST', endpoint, body);
package/calendar/index.js CHANGED
@@ -7,13 +7,31 @@ const handleCreateEvent = require('./create');
7
7
  const handleCancelEvent = require('./cancel');
8
8
  const handleDeleteEvent = require('./delete');
9
9
  const handleUpdateEvent = require('./update');
10
+ const { ATTENDEE_TYPES } = require('./attendees');
11
+
12
+ // One attendee: an email string, or {email, type} (#249). schema-coerce
13
+ // doesn't validate inside array items, so calendar/attendees.js re-checks.
14
+ const ATTENDEE_ITEM_SCHEMA = {
15
+ oneOf: [
16
+ { type: 'string' },
17
+ {
18
+ type: 'object',
19
+ properties: {
20
+ email: { type: 'string' },
21
+ type: { type: 'string', enum: [...ATTENDEE_TYPES] },
22
+ },
23
+ required: ['email'],
24
+ additionalProperties: false,
25
+ },
26
+ ],
27
+ };
10
28
 
11
29
  // Calendar tool definitions (consolidated: 5 → 3)
12
30
  const calendarTools = [
13
31
  {
14
32
  name: 'list-events',
15
33
  description:
16
- 'List calendar events for the signed-in user (read-only). By default returns upcoming events (start ≥ now). Optional `startAfter`, `startBefore` and `subject` filters find past, current or specifically-named events; supplying any of them replaces the default "now" lower bound and the filters are AND-ed together. Results are oldest first, except when the search only looks backwards (`startBefore` without `startAfter`, or `subject` alone), where they are newest first. Returns an array of events with id, subject, start/end, attendees, location, organiser, and webLink. Use `count` (default 10, max 50) to control page size. Each start/end is returned as a canonical UTC ISO-8601 instant (e.g. `2026-04-02T22:00:00.000Z`) followed by a labelled local rendering in the configured display timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`) — the UTC value is authoritative, so consumers never have to guess the zone.',
34
+ 'List calendar events for the signed-in user (read-only). By default returns upcoming events (start ≥ now). Optional `startAfter`, `startBefore` and `subject` filters find past, current or specifically-named events; supplying any of them replaces the default "now" lower bound and the filters are AND-ed together. Results are oldest first, except when the search only looks backwards (`startBefore` without `startAfter`, or `subject` alone), where they are newest first. Each event shows its subject, location, start/end, a body preview and its id. Use `count` (default 10, max 100) to control page size. Each start/end is returned as a canonical UTC ISO-8601 instant (e.g. `2026-04-02T22:00:00.000Z`) followed by a labelled local rendering in the configured display timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`) — the UTC value is authoritative, so consumers never have to guess the zone.',
17
35
  annotations: {
18
36
  title: 'List Calendar Events',
19
37
  readOnlyHint: true,
@@ -24,7 +42,7 @@ const calendarTools = [
24
42
  properties: {
25
43
  count: {
26
44
  type: 'number',
27
- description: 'Number of events to retrieve (default: 10, max: 50)',
45
+ description: 'Number of events to retrieve (default: 10, max: 100)',
28
46
  },
29
47
  startAfter: {
30
48
  type: 'string',
@@ -77,10 +95,9 @@ const calendarTools = [
77
95
  },
78
96
  attendees: {
79
97
  type: 'array',
80
- items: {
81
- type: 'string',
82
- },
83
- description: 'List of attendee email addresses',
98
+ items: ATTENDEE_ITEM_SCHEMA,
99
+ description:
100
+ "Attendees: email address strings (required attendees) or {email, type} objects, where type is 'required', 'optional' or 'resource' (a room or equipment)",
84
101
  },
85
102
  body: {
86
103
  type: 'string',
@@ -95,7 +112,7 @@ const calendarTools = [
95
112
  {
96
113
  name: 'manage-event',
97
114
  description:
98
- "Manage an existing calendar event (destructive: covers update/decline/cancel/delete — use dryRun where supported to preview). action=`update` edits fields in place via PATCH (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart) — only fields you pass are changed; pass `dryRun: true` to preview the PATCH payload. action=`decline` declines an invitation (optional `comment`). action=`cancel` cancels an event you organised and notifies attendees. action=`delete` permanently removes the event. Returns the updated event on update; status confirmation otherwise. Note: there is no `accept` action — accept invitations in the Outlook UI (Graph's accept verb is unreliable across personal/M365).",
115
+ "Manage an existing calendar event (destructive: covers update/decline/cancel/delete — use dryRun where supported to preview). action=`update` edits fields in place via PATCH (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart) — only fields you pass are changed; pass `dryRun: true` to preview the PATCH payload. action=`decline` declines an invitation (optional `comment`; `sendResponse: false` declines without notifying the organiser). action=`cancel` cancels an event you organised and notifies attendees. action=`delete` removes the event from your calendar (Graph doesn't document a guaranteed recovery path, so don't count on restoring it); deleting a meeting you organised that has attendees still emails them a cancellation, so use `cancel` (with an optional `comment`) when you want to control that message. Returns the updated event on update; status confirmation otherwise. Note: there is no `accept` action — accept invitations in the Outlook UI (Graph's accept verb is unreliable across personal/M365).",
99
116
  annotations: {
100
117
  title: 'Manage Calendar Event',
101
118
  readOnlyHint: false,
@@ -121,7 +138,13 @@ const calendarTools = [
121
138
  },
122
139
  comment: {
123
140
  type: 'string',
124
- description: 'Optional comment for declining or cancelling the event',
141
+ description:
142
+ 'Message sent with a decline or cancel (optional; omitted if not given)',
143
+ },
144
+ sendResponse: {
145
+ type: 'boolean',
146
+ description:
147
+ 'Send the decline to the organiser (action=decline only; default true). Pass false to decline without notifying the organiser.',
125
148
  },
126
149
  subject: {
127
150
  type: 'string',
@@ -161,9 +184,9 @@ const calendarTools = [
161
184
  },
162
185
  attendees: {
163
186
  type: 'array',
164
- items: { type: 'string' },
187
+ items: ATTENDEE_ITEM_SCHEMA,
165
188
  description:
166
- 'Full replacement attendee list — pass complete desired list, or [] to clear (action=update only)',
189
+ "Full replacement attendee list — pass the complete desired list, or [] to clear (action=update only). Each entry is an email address string or an {email, type} object (type 'required', 'optional' or 'resource'). A string, or an object without a type, keeps the type that address already has on the event (new addresses are required); an explicit type always wins.",
167
190
  },
168
191
  body: {
169
192
  type: 'string',
package/calendar/list.js CHANGED
@@ -8,13 +8,8 @@ const {
8
8
  escapeODataString,
9
9
  buildODataFilter,
10
10
  } = require('../utils/odata-helpers');
11
+ const { parseIsoInstant } = require('../utils/datetime');
11
12
 
12
- // An ISO 8601 instant with an explicit zone: `Z` or a ±hh:mm offset. A
13
- // zone-less value would be read in the server's local timezone by Date.parse,
14
- // so results would differ between machines; date-only values are rejected for
15
- // the same reason.
16
- const ISO_INSTANT =
17
- /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
18
13
  const MAX_SUBJECT_LENGTH = 255;
19
14
 
20
15
  /**
@@ -31,18 +26,8 @@ class ListEventsArgumentError extends Error {}
31
26
  * does not enforce JSON Schema `format`, so we enforce here at runtime.
32
27
  */
33
28
  function toUtcIsoDateTime(value, paramName) {
34
- const s = typeof value === 'string' ? value.trim() : '';
35
- const m = ISO_INSTANT.exec(s);
36
- const parsed = m ? Date.parse(s) : NaN;
37
- const valid =
38
- m &&
39
- !Number.isNaN(parsed) &&
40
- Number(m[1]) >= 1900 &&
41
- // Reject dates Date.parse would roll over, e.g. 2026-02-30 -> 2 March.
42
- new Date(
43
- Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
44
- ).getUTCDate() === Number(m[3]);
45
- if (!valid) {
29
+ const parsed = parseIsoInstant(value);
30
+ if (Number.isNaN(parsed)) {
46
31
  throw new ListEventsArgumentError(
47
32
  `Invalid ${paramName}: expected an ISO 8601 datetime with "Z" or a ±hh:mm offset, e.g. "2026-01-01T00:00:00Z" (got ${JSON.stringify(String(value).slice(0, 40))}).`
48
33
  );
@@ -224,7 +209,13 @@ function formatLocal(utcIso, tz) {
224
209
  * @returns {object} - MCP response
225
210
  */
226
211
  async function handleListEvents(args) {
227
- const count = Math.min(args.count || 10, config.MAX_RESULT_COUNT);
212
+ // Whole number in 1..MAX_RESULT_COUNT: Graph rejects $top below 1 or
213
+ // fractional, and the schema promises the cap.
214
+ const requested = args.count == null ? 10 : Math.floor(args.count);
215
+ const count = Math.min(
216
+ Math.max(Number.isFinite(requested) ? requested : 10, 1),
217
+ config.MAX_RESULT_COUNT
218
+ );
228
219
 
229
220
  // Validate arguments before authenticating, so a bad argument is reported
230
221
  // as such (and never reaches the network).