@littlebearapps/outlook-assistant 3.11.1 → 3.12.0

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.
@@ -73,10 +73,19 @@ async function initiateDeviceCodeFlow(clientId, scopes) {
73
73
  const { statusCode, body } = await postRequest(endpoint, postData);
74
74
 
75
75
  if (statusCode < 200 || statusCode >= 300) {
76
- throw new Error(
76
+ const error = new Error(
77
77
  body.error_description ||
78
78
  `Device code request failed with status ${statusCode}`
79
79
  );
80
+ // Same classification payload as pollForToken, so a scope rejection at
81
+ // initiation can be recognised by isScopeConsentError.
82
+ error.oauth = {
83
+ error: body.error,
84
+ error_codes: body.error_codes,
85
+ suberror: body.suberror,
86
+ error_description: body.error_description,
87
+ };
88
+ throw error;
80
89
  }
81
90
 
82
91
  return {
@@ -133,11 +142,22 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
133
142
  throw new Error(
134
143
  'Device code expired. Please restart the authentication process.'
135
144
  );
136
- default:
137
- throw new Error(
145
+ default: {
146
+ // Attach the raw OAuth payload so callers (e.g. handleDeviceCodeComplete)
147
+ // can classify the failure — notably scope-consent rejections that should
148
+ // trigger a base-scopes fallback. Keep the existing message text.
149
+ const e = new Error(
138
150
  body.error_description ||
139
151
  `Token polling failed: ${body.error || `status ${statusCode}`}`
140
152
  );
153
+ e.oauth = {
154
+ error: body.error,
155
+ error_codes: body.error_codes,
156
+ suberror: body.suberror,
157
+ error_description: body.error_description,
158
+ };
159
+ throw e;
160
+ }
141
161
  }
142
162
  }
143
163
 
@@ -146,7 +166,84 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
146
166
  );
147
167
  }
148
168
 
169
+ // AADSTS codes meaning "this scope value isn't supported for this account" —
170
+ // the only signals that justify a SILENT, DURABLE downgrade to base scopes:
171
+ // 650053 — "The application asked for scope '<x>' that doesn't exist on the
172
+ // resource" (the personal-account `.Shared` rejection)
173
+ // 70011 — invalid scope value
174
+ // Deliberately NOT here:
175
+ // 65001 — consent required (remediable: user/admin consent) → see
176
+ // isConsentRequiredError; must not silently strip capability
177
+ // 28000 — generic invalid request, not scope-specific
178
+ // invalid_grant (bare) — MFA/conditional access, revoked grant, tenant policy
179
+ const SCOPE_UNSUPPORTED_AADSTS_CODES = ['650053', '70011'];
180
+ const CONSENT_REQUIRED_AADSTS_CODES = ['65001'];
181
+
182
+ /**
183
+ * Does `err` carry one of `codes` in `oauth.error_codes` (array) or as an
184
+ * `AADSTS<code>` substring in `oauth.error_description` / `err.message`?
185
+ * @param {Error & {oauth?: object}} err
186
+ * @param {string[]} codes
187
+ * @returns {boolean}
188
+ */
189
+ // The OAuth error payload is attacker-influencable HTTP data — fields may
190
+ // arrive as arrays or objects instead of strings. Coerce before substring
191
+ // checks so `includes` is always String.prototype.includes.
192
+ function asString(value) {
193
+ return typeof value === 'string' ? value : '';
194
+ }
195
+
196
+ function hasAadstsCode(err, codes) {
197
+ const oauth = err.oauth || {};
198
+ if (Array.isArray(oauth.error_codes)) {
199
+ const found = oauth.error_codes.map(String);
200
+ if (found.some((c) => codes.includes(c))) {
201
+ return true;
202
+ }
203
+ }
204
+ const haystack = `${asString(oauth.error_description)} ${asString(err.message)}`;
205
+ // Whole-code match: `AADSTS70011` must not match `AADSTS700110`.
206
+ return codes.some((code) =>
207
+ new RegExp(`AADSTS${code}(?!\\d)`).test(haystack)
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Predicate: is this a "requested scope isn't supported for this account"
213
+ * rejection that warrants falling back to base scopes? Deliberately narrow —
214
+ * a false positive silently and permanently strips shared-mailbox access.
215
+ * @param {Error & {oauth?: object}} err
216
+ * @returns {boolean}
217
+ */
218
+ function isScopeConsentError(err) {
219
+ if (!err) {
220
+ return false;
221
+ }
222
+ // Consent-required takes precedence: it is remediable, so it must surface
223
+ // rather than silently downgrade the scope set.
224
+ if (isConsentRequiredError(err)) {
225
+ return false;
226
+ }
227
+ const oauth = err.oauth || {};
228
+ return (
229
+ oauth.error === 'invalid_scope' ||
230
+ hasAadstsCode(err, SCOPE_UNSUPPORTED_AADSTS_CODES)
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Predicate: consent required (AADSTS65001). Remediable via user/admin consent
236
+ * — surface it, never downgrade the scope set.
237
+ * @param {Error & {oauth?: object}} err
238
+ * @returns {boolean}
239
+ */
240
+ function isConsentRequiredError(err) {
241
+ return Boolean(err) && hasAadstsCode(err, CONSENT_REQUIRED_AADSTS_CODES);
242
+ }
243
+
149
244
  module.exports = {
150
245
  initiateDeviceCodeFlow,
151
246
  pollForToken,
247
+ isScopeConsentError,
248
+ isConsentRequiredError,
152
249
  };
@@ -5,6 +5,36 @@ const https = require('https');
5
5
  const querystring = require('querystring');
6
6
  const { describeAuthError } = require('./auth-errors');
7
7
 
8
+ /**
9
+ * Decide which scopes a refresh request should use. Prefer the scopes that were
10
+ * actually GRANTED (so a base-only fallback never re-requests `.Shared` on
11
+ * refresh and gets logged out ~1h later). Falls back to the parsed `scope`
12
+ * string, then to the configured scopes for back-compat with token files
13
+ * written before granted_scopes existed.
14
+ *
15
+ * `offline_access` is always included. Microsoft's token responses list only
16
+ * the scopes the access token is valid for — `offline_access` is not among
17
+ * them — and the token endpoint issues a new refresh_token only when
18
+ * `offline_access` is requested. Refreshing with the bare granted list would
19
+ * stop refresh-token rotation and eventually log the user out.
20
+ * @param {object|null} tokens - Stored token object
21
+ * @param {string[]} configScopes - Configured scope set (back-compat fallback)
22
+ * @returns {string[]} - Scopes to send in the refresh request
23
+ */
24
+ function resolveRefreshScopes(tokens, configScopes) {
25
+ let scopes = configScopes;
26
+ if (tokens) {
27
+ if (Array.isArray(tokens.granted_scopes) && tokens.granted_scopes.length) {
28
+ scopes = tokens.granted_scopes;
29
+ } else if (typeof tokens.scope === 'string' && tokens.scope.trim()) {
30
+ scopes = tokens.scope.split(' ').filter(Boolean);
31
+ }
32
+ }
33
+ return scopes.includes('offline_access')
34
+ ? scopes
35
+ : [...scopes, 'offline_access'];
36
+ }
37
+
8
38
  class TokenStorage {
9
39
  constructor(config) {
10
40
  this.config = {
@@ -179,7 +209,9 @@ class TokenStorage {
179
209
  client_id: this.config.clientId,
180
210
  grant_type: 'refresh_token',
181
211
  refresh_token: this.tokens.refresh_token,
182
- scope: this.config.scopes.join(' '),
212
+ // Use the GRANTED scopes, not the full configured set. After a base-only
213
+ // fallback, re-requesting `.Shared` here would fail and log the user out.
214
+ scope: resolveRefreshScopes(this.tokens, this.config.scopes).join(' '),
183
215
  };
184
216
  if (!isDeviceCode) {
185
217
  refreshParams.client_secret = this.config.clientSecret;
@@ -272,13 +304,14 @@ class TokenStorage {
272
304
  );
273
305
  }
274
306
  console.log('Exchanging authorization code for tokens...');
307
+ const requestedScopes = this.config.scopes;
275
308
  const postData = querystring.stringify({
276
309
  client_id: this.config.clientId,
277
310
  client_secret: this.config.clientSecret,
278
311
  grant_type: 'authorization_code',
279
312
  code: authCode,
280
313
  redirect_uri: this.config.redirectUri,
281
- scope: this.config.scopes.join(' '),
314
+ scope: requestedScopes.join(' '),
282
315
  });
283
316
 
284
317
  const requestOptions = {
@@ -306,6 +339,14 @@ class TokenStorage {
306
339
  expires_in: responseBody.expires_in,
307
340
  expires_at: Date.now() + responseBody.expires_in * 1000,
308
341
  scope: responseBody.scope,
342
+ // Persist granted scopes so refresh re-requests exactly what
343
+ // was granted (mirrors the device-code path). If the token
344
+ // response omits `scope`, fall back to what we requested.
345
+ granted_scopes:
346
+ typeof responseBody.scope === 'string' &&
347
+ responseBody.scope.trim()
348
+ ? responseBody.scope.split(' ').filter(Boolean)
349
+ : requestedScopes,
309
350
  token_type: responseBody.token_type,
310
351
  };
311
352
  try {
@@ -379,4 +420,5 @@ class TokenStorage {
379
420
  }
380
421
 
381
422
  module.exports = TokenStorage;
423
+ module.exports.resolveRefreshScopes = resolveRefreshScopes;
382
424
  // Adding a newline at the end of the file as requested by Gemini Code Assist
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
  {
package/calendar/index.js CHANGED
@@ -13,7 +13,7 @@ const calendarTools = [
13
13
  {
14
14
  name: 'list-events',
15
15
  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.',
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.',
17
17
  annotations: {
18
18
  title: 'List Calendar Events',
19
19
  readOnlyHint: true,
@@ -26,6 +26,24 @@ const calendarTools = [
26
26
  type: 'number',
27
27
  description: 'Number of events to retrieve (default: 10, max: 50)',
28
28
  },
29
+ startAfter: {
30
+ type: 'string',
31
+ format: 'date-time',
32
+ description:
33
+ '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".',
34
+ },
35
+ startBefore: {
36
+ type: 'string',
37
+ format: 'date-time',
38
+ description:
39
+ '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".',
40
+ },
41
+ subject: {
42
+ type: 'string',
43
+ description:
44
+ '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.',
45
+ maxLength: 255,
46
+ },
29
47
  },
30
48
  additionalProperties: false,
31
49
  required: [],