@littlebearapps/outlook-assistant 3.11.2 → 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.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
package/auth/tools.js CHANGED
@@ -6,7 +6,12 @@ const { getAuthErrorHints } = require('./auth-errors');
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const tokenManager = require('./token-manager');
9
- const { initiateDeviceCodeFlow, pollForToken } = require('./device-code');
9
+ const {
10
+ initiateDeviceCodeFlow,
11
+ pollForToken,
12
+ isScopeConsentError,
13
+ isConsentRequiredError,
14
+ } = require('./device-code');
10
15
 
11
16
  // Path for persisting device code state across MCP server restarts
12
17
  const DEVICE_CODE_STATE_PATH = path.join(
@@ -20,6 +25,49 @@ function setToolCount(count) {
20
25
  _toolCount = count;
21
26
  }
22
27
 
28
+ /**
29
+ * Scopes recorded as granted in a stored token object. Prefers the
30
+ * `granted_scopes` array; falls back to the token response's `scope` string
31
+ * (token files written before granted_scopes existed). Full-URI forms such as
32
+ * `https://graph.microsoft.com/Mail.Read` are reduced to the bare scope name.
33
+ * @param {object|null} tokens
34
+ * @returns {string[]|null} - null when no token is stored
35
+ */
36
+ function grantedScopesOf(tokens) {
37
+ if (!tokens) return null;
38
+ let raw = [];
39
+ if (Array.isArray(tokens.granted_scopes) && tokens.granted_scopes.length) {
40
+ raw = tokens.granted_scopes;
41
+ } else if (typeof tokens.scope === 'string') {
42
+ raw = tokens.scope.split(' ');
43
+ }
44
+ return raw.map((scope) => String(scope).split('/').pop()).filter(Boolean);
45
+ }
46
+
47
+ /**
48
+ * Human-readable shared-mailbox status for `auth about`.
49
+ * @param {string[]|null} granted - Granted scopes, or null when signed out
50
+ * @returns {string}
51
+ */
52
+ function describeSharedMailboxStatus(granted) {
53
+ if (config.SHARED_MAILBOX_MODE === 'off' || !config.SHARED_SCOPES.length) {
54
+ return 'Disabled (opt-in, work/school only: set `OUTLOOK_SHARED_MAILBOX=read` or `=true`, restart, then `auth action=authenticate force=true`)';
55
+ }
56
+ const lowerGranted = (granted || []).map((s) => s.toLowerCase());
57
+ const parts = config.SHARED_SCOPES.map(
58
+ (scope) =>
59
+ `${scope} ${lowerGranted.includes(scope.toLowerCase()) ? 'granted' : 'not granted'}`
60
+ );
61
+ let status = `Enabled (${config.SHARED_MAILBOX_MODE}): ${parts.join(', ')}`;
62
+ if (!granted) {
63
+ status += ' (not signed in)';
64
+ } else if (parts.some((p) => p.endsWith('not granted'))) {
65
+ status +=
66
+ ' (re-authenticate with `auth action=authenticate force=true`; personal accounts cannot be granted these)';
67
+ }
68
+ return status;
69
+ }
70
+
23
71
  /**
24
72
  * About tool handler
25
73
  * @returns {object} - MCP response
@@ -43,6 +91,17 @@ async function handleAbout() {
43
91
  // GET /me round-trip when a valid token is available; degrades
44
92
  // gracefully when not authenticated.
45
93
  let identity = 'Not authenticated (run `auth action=authenticate`)';
94
+ // Granted scopes come from the stored token file (scope names only — the
95
+ // tokens themselves are never surfaced).
96
+ let granted = null;
97
+ try {
98
+ const { tokenStorage } = require('./index');
99
+ if (tokenStorage && typeof tokenStorage.getTokens === 'function') {
100
+ granted = grantedScopesOf(await tokenStorage.getTokens());
101
+ }
102
+ } catch (_e) {
103
+ // Leave granted as unknown
104
+ }
46
105
  try {
47
106
  const { ensureAuthenticated } = require('./index');
48
107
  const { callGraphAPI } = require('../utils/graph-api');
@@ -70,8 +129,10 @@ async function handleAbout() {
70
129
  `| Rate Limit | ${rateLimit} |`,
71
130
  `| Recipient Allowlist | ${allowlist} |`,
72
131
  `| Scopes | ${scopes.length} configured |`,
132
+ `| Shared mailboxes | ${describeSharedMailboxStatus(granted)} |`,
73
133
  ``,
74
- `**Scopes**: ${scopes.join(', ')}`,
134
+ `**Configured scopes**: ${scopes.join(', ')}`,
135
+ `**Granted scopes**: ${granted ? granted.filter((s) => s !== 'offline_access').join(', ') || 'none recorded' : 'not signed in'}`,
75
136
  ];
76
137
 
77
138
  // F-1 / F-48: warn when no safety belts are wired up. AI-assisted
@@ -209,6 +270,23 @@ async function handleDeviceCodeAuth() {
209
270
  }
210
271
 
211
272
  console.error('[AUTH] Starting device code flow...');
273
+ // Attempt the configured scope set (base, plus `.Shared` when
274
+ // OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
275
+ // `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
276
+ return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full');
277
+ }
278
+
279
+ /**
280
+ * Shared helper: request a device code for a given scope set, persist the
281
+ * pending state (tagging which scope set was used so the completion step can
282
+ * decide whether a fallback is still available), and build the MCP response.
283
+ * @param {string[]} scopes - OAuth scopes to request
284
+ * @param {'full'|'base'} scopesUsed - Label recording which scope set was used
285
+ * @param {string} [prefix] - Optional leading line (used for the fallback case)
286
+ * @returns {Promise<object>} - MCP response
287
+ */
288
+ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
289
+ const clientId = config.AUTH_CONFIG.clientId;
212
290
 
213
291
  // #213 — initiation can throw (blocked network egress in a sandboxed
214
292
  // connector, AADSTS9002331 audience mismatch, invalid_client, non-JSON
@@ -218,11 +296,25 @@ async function handleDeviceCodeAuth() {
218
296
  // (handleDeviceCodeComplete) already has, and surface actionable hints.
219
297
  let response;
220
298
  try {
221
- response = await initiateDeviceCodeFlow(
222
- clientId,
223
- config.AUTH_CONFIG.scopes
224
- );
299
+ response = await initiateDeviceCodeFlow(clientId, scopes);
225
300
  } catch (error) {
301
+ // The `.Shared` scopes can also be rejected when the code is requested
302
+ // (before sign-in). Same single fallback as at completion.
303
+ if (
304
+ config.SHARED_SCOPES.length > 0 &&
305
+ scopesUsed === 'full' &&
306
+ !isConsentRequiredError(error) &&
307
+ isScopeConsentError(error)
308
+ ) {
309
+ console.error(
310
+ '[AUTH] Shared-mailbox scopes rejected at device-code request; retrying with base scopes.'
311
+ );
312
+ return initiateDeviceCode(
313
+ config.AUTH_CONFIG.fallbackScopes,
314
+ 'base',
315
+ "Your account doesn't support shared-mailbox access; signing in with the standard scopes instead."
316
+ );
317
+ }
226
318
  return buildDeviceCodeErrorResponse(error);
227
319
  }
228
320
 
@@ -232,24 +324,31 @@ async function handleDeviceCodeAuth() {
232
324
  interval: response.interval,
233
325
  expiresIn: response.expiresIn,
234
326
  expiresAt: Date.now() + response.expiresIn * 1000,
327
+ scopesUsed,
235
328
  };
236
329
  saveDeviceCodeState(pendingDeviceCode);
237
330
 
238
331
  console.error(
239
- `[AUTH] Device code: ${response.userCode}, expires in ${response.expiresIn}s`
332
+ `[AUTH] Device code (${scopesUsed} scopes): ${response.userCode}, expires in ${response.expiresIn}s`
333
+ );
334
+
335
+ const lines = [];
336
+ if (prefix) {
337
+ lines.push(prefix, '');
338
+ }
339
+ lines.push(
340
+ `## Device Code Authentication\n`,
341
+ `Visit: **${response.verificationUri}**`,
342
+ `Enter code: **${response.userCode}**\n`,
343
+ `The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
344
+ `After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`
240
345
  );
241
346
 
242
347
  return {
243
348
  content: [
244
349
  {
245
350
  type: 'text',
246
- text: [
247
- `## Device Code Authentication\n`,
248
- `Visit: **${response.verificationUri}**`,
249
- `Enter code: **${response.userCode}**\n`,
250
- `The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
251
- `After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`,
252
- ].join('\n'),
351
+ text: lines.join('\n'),
253
352
  },
254
353
  ],
255
354
  };
@@ -332,6 +431,14 @@ async function handleDeviceCodeComplete() {
332
431
  }
333
432
 
334
433
  const clientId = config.AUTH_CONFIG.clientId;
434
+ // Capture which scope set this pending flow attempted, before any mutation.
435
+ const scopesUsed = pendingDeviceCode.scopesUsed || 'full';
436
+ // The scopes we attempted — used as the granted_scopes fallback when the
437
+ // token response omits `scope`.
438
+ const attemptedScopes =
439
+ scopesUsed === 'base'
440
+ ? config.AUTH_CONFIG.fallbackScopes
441
+ : config.AUTH_CONFIG.scopes;
335
442
 
336
443
  try {
337
444
  console.error('[AUTH] Polling for device code completion...');
@@ -355,12 +462,20 @@ async function handleDeviceCodeComplete() {
355
462
  tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
356
463
  });
357
464
 
465
+ // Persist the GRANTED scopes so token refresh re-requests exactly what was
466
+ // granted (not the full configured set) — otherwise a base-only fallback
467
+ // would re-request `.Shared` on refresh ~1h later and log the user out.
468
+ const grantedScopes = tokenResponse.scope
469
+ ? tokenResponse.scope.split(' ').filter(Boolean)
470
+ : attemptedScopes;
471
+
358
472
  tokenStorage.tokens = {
359
473
  access_token: tokenResponse.access_token,
360
474
  refresh_token: tokenResponse.refresh_token,
361
475
  expires_in: tokenResponse.expires_in,
362
476
  expires_at: Date.now() + tokenResponse.expires_in * 1000,
363
477
  scope: tokenResponse.scope,
478
+ granted_scopes: grantedScopes,
364
479
  token_type: tokenResponse.token_type,
365
480
  auth_method: 'device-code',
366
481
  };
@@ -377,8 +492,75 @@ async function handleDeviceCodeComplete() {
377
492
  ],
378
493
  };
379
494
  } catch (error) {
495
+ // Scope-consent rejection while attempting the FULL set → re-issue with
496
+ // base scopes. This is the personal-account path: one extra device code.
497
+ // Only meaningful when shared-mailbox support is on: with the flag off the
498
+ // attempted set already IS the base set, so there is nothing to drop.
499
+ // Consent-required (AADSTS65001) is checked first: it is remediable and
500
+ // must surface below, never trigger a silent downgrade.
501
+ if (
502
+ config.SHARED_SCOPES.length > 0 &&
503
+ scopesUsed === 'full' &&
504
+ !isConsentRequiredError(error) &&
505
+ isScopeConsentError(error)
506
+ ) {
507
+ console.error(
508
+ '[AUTH] Shared-mailbox scopes rejected; falling back to base scopes.'
509
+ );
510
+ // Do NOT clear pendingDeviceCode — initiateDeviceCode replaces it.
511
+ try {
512
+ const fallbackResponse = await initiateDeviceCode(
513
+ config.AUTH_CONFIG.fallbackScopes,
514
+ 'base',
515
+ "Your account doesn't support shared-mailbox access; enter this new code to finish signing in."
516
+ );
517
+ // initiateDeviceCode reports its own failures as { isError: true }
518
+ // instead of throwing. Clear the rejected full-scope pending state so
519
+ // a later completion attempt doesn't retry it.
520
+ if (fallbackResponse && fallbackResponse.isError) {
521
+ pendingDeviceCode = null;
522
+ saveDeviceCodeState(null);
523
+ }
524
+ return fallbackResponse;
525
+ } catch (reissueError) {
526
+ pendingDeviceCode = null;
527
+ saveDeviceCodeState(null);
528
+ return {
529
+ content: [
530
+ {
531
+ type: 'text',
532
+ text: `Authentication failed: ${reissueError.message}`,
533
+ },
534
+ ],
535
+ };
536
+ }
537
+ }
538
+
380
539
  pendingDeviceCode = null;
381
540
  saveDeviceCodeState(null);
541
+
542
+ // Consent required (AADSTS65001) — remediable, so surface it instead of
543
+ // silently downgrading to base scopes (which would strip shared-mailbox
544
+ // access for every future refresh).
545
+ // Only when the shared scopes were requested — otherwise the generic
546
+ // path below (with its AADSTS hint table) is unchanged.
547
+ if (config.SHARED_SCOPES.length > 0 && isConsentRequiredError(error)) {
548
+ return {
549
+ content: [
550
+ {
551
+ type: 'text',
552
+ text: [
553
+ 'Authentication failed: consent was not granted (AADSTS65001).',
554
+ '',
555
+ `An administrator may need to grant consent for the shared-mailbox scopes (${config.SHARED_SCOPES.join(', ')}), or re-run \`auth action=authenticate\` and approve every requested permission.`,
556
+ 'If your organisation will not consent to them, unset OUTLOOK_SHARED_MAILBOX and restart the server to sign in with the standard scopes.',
557
+ 'No scopes were changed — your configured capability is unchanged.',
558
+ ].join('\n'),
559
+ },
560
+ ],
561
+ };
562
+ }
563
+
382
564
  return {
383
565
  content: [
384
566
  {
@@ -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 upcoming calendar events for the signed-in user (read-only). 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; this tool does not filter — use the Outlook UI or specific date ranges via Graph for filtered queries. 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,25 @@ 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)',
46
+ },
47
+ startAfter: {
48
+ type: 'string',
49
+ format: 'date-time',
50
+ description:
51
+ 'Optional ISO 8601 datetime with `Z` or a ±hh:mm offset (required). Only return events whose start is on or after this time. Replaces the default "now" lower bound when supplied. Example: "2026-01-01T00:00:00Z" or "2026-01-01T09:00:00+10:00".',
52
+ },
53
+ startBefore: {
54
+ type: 'string',
55
+ format: 'date-time',
56
+ description:
57
+ 'Optional ISO 8601 datetime with `Z` or a ±hh:mm offset (required). Only return events whose start is strictly before this time. Combine with `startAfter` to bound a window; on its own, results are newest first. Example: "2026-02-01T00:00:00Z".',
58
+ },
59
+ subject: {
60
+ type: 'string',
61
+ description:
62
+ 'Optional substring (max 255 characters) to match against the event subject, case-insensitive (Graph `contains()`). Useful for finding past or current events by name; on its own, results are newest first.',
63
+ maxLength: 255,
28
64
  },
29
65
  },
30
66
  additionalProperties: false,
@@ -59,10 +95,9 @@ const calendarTools = [
59
95
  },
60
96
  attendees: {
61
97
  type: 'array',
62
- items: {
63
- type: 'string',
64
- },
65
- 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)",
66
101
  },
67
102
  body: {
68
103
  type: 'string',
@@ -77,7 +112,7 @@ const calendarTools = [
77
112
  {
78
113
  name: 'manage-event',
79
114
  description:
80
- "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).",
81
116
  annotations: {
82
117
  title: 'Manage Calendar Event',
83
118
  readOnlyHint: false,
@@ -103,7 +138,13 @@ const calendarTools = [
103
138
  },
104
139
  comment: {
105
140
  type: 'string',
106
- 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.',
107
148
  },
108
149
  subject: {
109
150
  type: 'string',
@@ -143,9 +184,9 @@ const calendarTools = [
143
184
  },
144
185
  attendees: {
145
186
  type: 'array',
146
- items: { type: 'string' },
187
+ items: ATTENDEE_ITEM_SCHEMA,
147
188
  description:
148
- '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.",
149
190
  },
150
191
  body: {
151
192
  type: 'string',