@littlebearapps/outlook-assistant 3.12.0 → 3.13.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.
package/auth/tools.js CHANGED
@@ -6,6 +6,13 @@ 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 {
10
+ CONFIG_FILE_NAME,
11
+ isValidClientId,
12
+ saveClientId,
13
+ getEnvClientId,
14
+ getClientIdSource,
15
+ } = require('./client-config');
9
16
  const {
10
17
  initiateDeviceCodeFlow,
11
18
  pollForToken,
@@ -19,6 +26,112 @@ const DEVICE_CODE_STATE_PATH = path.join(
19
26
  '.outlook-assistant-pending-auth.json'
20
27
  );
21
28
 
29
+ const SETUP_GUIDE_URL =
30
+ 'https://github.com/littlebearapps/outlook-assistant/blob/main/docs/how-to/getting-started/connect-outlook-to-claude.md';
31
+ const SAVED_CONFIG_DISPLAY_PATH = `~/${CONFIG_FILE_NAME}`;
32
+
33
+ /**
34
+ * Error shown when no client ID resolves (no env var, nothing saved). Written
35
+ * for the AI client: it tells it what to ask the user and which call to make.
36
+ * @returns {object} - MCP response ({ content, isError: true })
37
+ */
38
+ function buildMissingClientIdResponse() {
39
+ return {
40
+ content: [
41
+ {
42
+ type: 'text',
43
+ text: [
44
+ 'Error: OUTLOOK_CLIENT_ID is not configured, so sign-in cannot start.',
45
+ '',
46
+ '1. Ask the user for the **Application (client) ID** of their Azure app registration (Azure portal → App registrations → their app → Overview). It is a GUID such as `00000000-0000-0000-0000-000000000000`.',
47
+ `2. Call \`auth action=authenticate clientId=<id>\`. The ID is saved to \`${SAVED_CONFIG_DISPLAY_PATH}\` (it is not a secret) and device-code sign-in starts.`,
48
+ '',
49
+ 'The client secret is not needed for device-code sign-in (the default); only the browser flow uses it.',
50
+ `No app registration yet? Follow the setup guide: ${SETUP_GUIDE_URL}`,
51
+ 'Alternatively, set OUTLOOK_CLIENT_ID in the MCP server environment and restart it.',
52
+ ].join('\n'),
53
+ },
54
+ ],
55
+ isError: true,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Validate and save a client ID supplied via `auth action=authenticate`.
61
+ * @param {unknown} clientId
62
+ * @returns {{error: object}|{saved: string}} - An MCP error response, or the saved ID
63
+ */
64
+ function applyClientIdArg(clientId) {
65
+ if (!isValidClientId(clientId)) {
66
+ return {
67
+ error: {
68
+ content: [
69
+ {
70
+ type: 'text',
71
+ text: [
72
+ 'Error: `clientId` is not a valid Azure Application (client) ID.',
73
+ '',
74
+ 'It must be the GUID shown as **Application (client) ID** on the app registration Overview page in the Azure portal, e.g. `00000000-0000-0000-0000-000000000000`. Do not use the Directory (tenant) ID, the Object ID or a client secret.',
75
+ `Setup guide: ${SETUP_GUIDE_URL}`,
76
+ ].join('\n'),
77
+ },
78
+ ],
79
+ isError: true,
80
+ },
81
+ };
82
+ }
83
+
84
+ const env = getEnvClientId();
85
+ if (env && env.value.trim().toLowerCase() !== clientId.trim().toLowerCase()) {
86
+ return {
87
+ error: {
88
+ content: [
89
+ {
90
+ type: 'text',
91
+ text: [
92
+ `Error: the ${env.name} environment variable is set to a different client ID, and it takes precedence over a saved one, so the \`clientId\` you supplied would be ignored.`,
93
+ '',
94
+ `To use the new ID, change or remove ${env.name} in the MCP server configuration, restart the server, then call \`auth action=authenticate\` again. Nothing was saved.`,
95
+ ].join('\n'),
96
+ },
97
+ ],
98
+ isError: true,
99
+ },
100
+ };
101
+ }
102
+
103
+ try {
104
+ return { saved: saveClientId(clientId) };
105
+ } catch (error) {
106
+ return {
107
+ error: {
108
+ content: [
109
+ {
110
+ type: 'text',
111
+ text: `Error: could not save the client ID to ${SAVED_CONFIG_DISPLAY_PATH}: ${error.message}`,
112
+ },
113
+ ],
114
+ isError: true,
115
+ },
116
+ };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Client ID row for `auth about`. The ID itself is never shown.
122
+ * @returns {string}
123
+ */
124
+ function describeClientIdStatus() {
125
+ const source = getClientIdSource();
126
+ if (source === 'env') {
127
+ return `Configured (environment: ${getEnvClientId().name})`;
128
+ }
129
+ if (source === 'saved') {
130
+ return `Configured (saved in ${SAVED_CONFIG_DISPLAY_PATH})`;
131
+ }
132
+ return 'Not set (run `auth action=authenticate clientId=<Application (client) ID>`)';
133
+ }
134
+
22
135
  // Dynamic tool count — set by index.js after TOOLS array is built
23
136
  let _toolCount = 0;
24
137
  function setToolCount(count) {
@@ -122,6 +235,7 @@ async function handleAbout() {
122
235
  `| Setting | Value |`,
123
236
  `|---------|-------|`,
124
237
  `| Mailbox | ${identity} |`,
238
+ `| Client ID | ${describeClientIdStatus()} |`,
125
239
  `| Tools | ${_toolCount} across 9 modules |`,
126
240
  `| Modules | auth, email, calendar, folder, rules, contacts, categories, settings, advanced |`,
127
241
  `| Timezone | ${config.DEFAULT_TIMEZONE} |`,
@@ -185,19 +299,54 @@ async function handleAuthenticate(args) {
185
299
  };
186
300
  }
187
301
 
302
+ // Optional runtime client ID (for clients that can't set env vars, e.g.
303
+ // plugin marketplaces): validate, refuse if an env var would override it,
304
+ // save, then carry on with the normal flow.
305
+ // null / blank counts as not supplied: some clients send empty optionals.
306
+ let savedPrefix;
307
+ const suppliedClientId = args?.clientId;
308
+ if (
309
+ suppliedClientId !== undefined &&
310
+ suppliedClientId !== null &&
311
+ String(suppliedClientId).trim() !== ''
312
+ ) {
313
+ const result = applyClientIdArg(suppliedClientId);
314
+ if (result.error) {
315
+ return result.error;
316
+ }
317
+ savedPrefix = `Saved your Azure Application (client) ID to \`${SAVED_CONFIG_DISPLAY_PATH}\`.`;
318
+ }
319
+
188
320
  const method = args?.method || config.AUTH_CONFIG.defaultAuthMethod;
189
321
 
190
322
  if (method === 'device-code') {
191
- return handleDeviceCodeAuth();
323
+ return handleDeviceCodeAuth(savedPrefix);
192
324
  }
193
325
 
194
326
  // Browser redirect flow (existing behaviour)
195
- const authUrl = `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${config.AUTH_CONFIG.clientId}`;
327
+ const clientId = config.AUTH_CONFIG.clientId;
328
+ if (!clientId) {
329
+ return buildMissingClientIdResponse();
330
+ }
331
+ const authUrl = `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${encodeURIComponent(clientId)}`;
332
+ const lines = [];
333
+ if (savedPrefix) {
334
+ lines.push(savedPrefix, '');
335
+ }
336
+ lines.push(
337
+ `Authentication required. Please visit the following URL to authenticate with Microsoft: ${authUrl}\n\nAfter authentication, you will be redirected back to this application.\n\nNote: The auth server must be running on port 3333. If working remotely, consider using method=device-code instead.`
338
+ );
339
+ if (getClientIdSource() !== 'env') {
340
+ lines.push(
341
+ '',
342
+ 'The browser flow also needs the client secret: the auth server (`npm run auth-server`) reads OUTLOOK_CLIENT_ID and OUTLOOK_CLIENT_SECRET from its own environment and does not use a saved client ID. Device-code sign-in (the default) needs only the client ID.'
343
+ );
344
+ }
196
345
  return {
197
346
  content: [
198
347
  {
199
348
  type: 'text',
200
- text: `Authentication required. Please visit the following URL to authenticate with Microsoft: ${authUrl}\n\nAfter authentication, you will be redirected back to this application.\n\nNote: The auth server must be running on port 3333. If working remotely, consider using method=device-code instead.`,
349
+ text: lines.join('\n'),
201
350
  },
202
351
  ],
203
352
  };
@@ -254,26 +403,20 @@ function loadDeviceCodeState() {
254
403
  * Device code flow step 1 — request a code for the user to enter.
255
404
  * Returns the code + URL immediately. Call device-code-complete to finish.
256
405
  * State is persisted to disk so it survives MCP server restarts.
406
+ * @param {string} [prefix] - Optional leading line (e.g. "client ID saved")
257
407
  * @returns {object} - MCP response
258
408
  */
259
- async function handleDeviceCodeAuth() {
409
+ async function handleDeviceCodeAuth(prefix) {
260
410
  const clientId = config.AUTH_CONFIG.clientId;
261
411
  if (!clientId) {
262
- return {
263
- content: [
264
- {
265
- type: 'text',
266
- text: 'Error: OUTLOOK_CLIENT_ID is not configured.',
267
- },
268
- ],
269
- };
412
+ return buildMissingClientIdResponse();
270
413
  }
271
414
 
272
415
  console.error('[AUTH] Starting device code flow...');
273
416
  // Attempt the configured scope set (base, plus `.Shared` when
274
417
  // OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
275
418
  // `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
276
- return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full');
419
+ return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full', prefix);
277
420
  }
278
421
 
279
422
  /**
@@ -318,13 +461,15 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
318
461
  return buildDeviceCodeErrorResponse(error);
319
462
  }
320
463
 
321
- // Store in memory and persist to disk
464
+ // Store in memory and persist to disk. The client ID is recorded because
465
+ // the device code is bound to it: completion must poll with the same one.
322
466
  pendingDeviceCode = {
323
467
  deviceCode: response.deviceCode,
324
468
  interval: response.interval,
325
469
  expiresIn: response.expiresIn,
326
470
  expiresAt: Date.now() + response.expiresIn * 1000,
327
471
  scopesUsed,
472
+ clientId,
328
473
  };
329
474
  saveDeviceCodeState(pendingDeviceCode);
330
475
 
@@ -430,7 +575,14 @@ async function handleDeviceCodeComplete() {
430
575
  };
431
576
  }
432
577
 
433
- const clientId = config.AUTH_CONFIG.clientId;
578
+ // Poll with the client ID the code was issued to (older state files don't
579
+ // record it, so fall back to the current one).
580
+ const clientId = pendingDeviceCode.clientId || config.AUTH_CONFIG.clientId;
581
+ if (!clientId) {
582
+ pendingDeviceCode = null;
583
+ saveDeviceCodeState(null);
584
+ return buildMissingClientIdResponse();
585
+ }
434
586
  // Capture which scope set this pending flow attempted, before any mutation.
435
587
  const scopesUsed = pendingDeviceCode.scopesUsed || 'full';
436
588
  // The scopes we attempted — used as the granted_scopes fallback when the
@@ -455,7 +607,7 @@ async function handleDeviceCodeComplete() {
455
607
  // Save tokens using TokenStorage — mark as device-code auth
456
608
  const TokenStorage = require('./token-storage');
457
609
  const tokenStorage = new TokenStorage({
458
- clientId: config.AUTH_CONFIG.clientId,
610
+ clientId,
459
611
  clientSecret: config.AUTH_CONFIG.clientSecret,
460
612
  tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
461
613
  scopes: config.AUTH_CONFIG.scopes,
@@ -593,8 +745,12 @@ async function handleCheckAuthStatus() {
593
745
 
594
746
  if (!accessToken) {
595
747
  console.error('[CHECK-AUTH-STATUS] No valid access token');
748
+ const text =
749
+ getClientIdSource() === 'none'
750
+ ? `Not authenticated. No Azure Application (client) ID is configured yet: ask the user for the Application (client) ID of their Azure app registration, then call \`auth action=authenticate clientId=<id>\`. Setup guide: ${SETUP_GUIDE_URL}`
751
+ : 'Not authenticated';
596
752
  return {
597
- content: [{ type: 'text', text: 'Not authenticated' }],
753
+ content: [{ type: 'text', text }],
598
754
  };
599
755
  }
600
756
 
@@ -622,7 +778,7 @@ const authTools = [
622
778
  {
623
779
  name: 'auth',
624
780
  description:
625
- 'Manage authentication with the Microsoft Graph API. action=`status` (default) returns the current auth state and auto-refreshes the access token if it\'s expired but the refresh token is still valid (~90-day window) — call this first to check before other tools. action=`authenticate` starts the OAuth flow: with `method: "device-code"` (default, works headlessly) it returns a code + URL for the user to visit; with `method: "browser"` it opens the local auth server on :3333 (run `npm run auth-server` first). Pass `force: true` to re-authenticate over an existing valid session. action=`device-code-complete` finishes device-code auth after the user enters the code in their browser — call this once authentication shows as successful in the browser. action=`about` returns server version, configured audience, scope list, and other diagnostic info. Tokens persist to `~/.outlook-assistant-tokens.json` and survive server restarts.',
781
+ 'Manage authentication with the Microsoft Graph API. action=`status` (default) returns the current auth state and auto-refreshes the access token if it\'s expired but the refresh token is still valid (~90-day window) — call this first to check before other tools. action=`authenticate` starts the OAuth flow: with `method: "device-code"` (default, works headlessly) it returns a code + URL for the user to visit; with `method: "browser"` it opens the local auth server on :3333 (run `npm run auth-server` first). Pass `force: true` to re-authenticate over an existing valid session. If sign-in reports that OUTLOOK_CLIENT_ID is not configured, ask the user for their Azure Application (client) ID and pass it as `clientId`. action=`device-code-complete` finishes device-code auth after the user enters the code in their browser — call this once authentication shows as successful in the browser. action=`about` returns server version, configured audience, scope list, and other diagnostic info. Tokens persist to `~/.outlook-assistant-tokens.json` and survive server restarts.',
626
782
  annotations: {
627
783
  title: 'Authentication',
628
784
  readOnlyHint: false,
@@ -648,6 +804,11 @@ const authTools = [
648
804
  description:
649
805
  'Force re-authentication even if already authenticated (action=authenticate only)',
650
806
  },
807
+ clientId: {
808
+ type: 'string',
809
+ description:
810
+ "Optional, action=authenticate only. The user's Azure Application (client) ID (a GUID from the app registration's Overview page). Saved to `~/.outlook-assistant-config.json` and used from then on; it is not a secret. The OUTLOOK_CLIENT_ID environment variable takes precedence when set.",
811
+ },
651
812
  },
652
813
  additionalProperties: false,
653
814
  required: [],
@@ -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).