@littlebearapps/outlook-assistant 3.12.1 → 3.14.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.
Files changed (69) hide show
  1. package/.env.example +27 -3
  2. package/README.md +108 -33
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/client-config.js +142 -0
  6. package/auth/index.js +4 -2
  7. package/auth/oauth-server.js +12 -2
  8. package/auth/token-manager.js +7 -3
  9. package/auth/token-storage.js +46 -33
  10. package/auth/tools.js +223 -93
  11. package/calendar/attendees.js +36 -0
  12. package/calendar/cancel.js +9 -25
  13. package/calendar/create.js +42 -48
  14. package/calendar/decline.js +10 -25
  15. package/calendar/delete.js +10 -25
  16. package/calendar/index.js +20 -37
  17. package/calendar/list.js +4 -16
  18. package/calendar/preview.js +335 -0
  19. package/calendar/update.js +42 -86
  20. package/categories/index.js +59 -264
  21. package/config.js +36 -2
  22. package/contacts/index.js +72 -128
  23. package/email/attachments.js +42 -124
  24. package/email/conversations.js +44 -78
  25. package/email/delta.js +10 -34
  26. package/email/draft.js +140 -96
  27. package/email/export.js +141 -110
  28. package/email/folder-utils.js +3 -2
  29. package/email/headers.js +11 -49
  30. package/email/index.js +85 -109
  31. package/email/list.js +4 -17
  32. package/email/mail-tips.js +86 -57
  33. package/email/mark-as-read.js +13 -49
  34. package/email/mime.js +14 -49
  35. package/email/read.js +16 -50
  36. package/email/search.js +46 -86
  37. package/email/send.js +82 -48
  38. package/folder/create.js +6 -25
  39. package/folder/delete.js +117 -38
  40. package/folder/index.js +17 -16
  41. package/folder/list.js +5 -17
  42. package/folder/move.js +13 -42
  43. package/folder/resolve.js +11 -6
  44. package/folder/stats.js +6 -20
  45. package/index.js +23 -45
  46. package/llms-install.md +31 -7
  47. package/llms.txt +19 -10
  48. package/outlook-auth-server.js +10 -3
  49. package/package.json +6 -2
  50. package/request-handler.js +217 -116
  51. package/rules/create.js +27 -70
  52. package/rules/index.js +30 -92
  53. package/rules/list.js +5 -17
  54. package/rules/rule-builder.js +57 -20
  55. package/rules/update.js +26 -60
  56. package/server.js +37 -0
  57. package/settings/index.js +142 -143
  58. package/tools.js +30 -0
  59. package/utils/field-presets.js +4 -2
  60. package/utils/graph-api.js +65 -22
  61. package/utils/logger.js +251 -0
  62. package/utils/mock-data.js +91 -2
  63. package/utils/read-only.js +59 -0
  64. package/utils/response-formatter.js +54 -15
  65. package/utils/risk-classes.js +324 -0
  66. package/utils/safe-write.js +372 -6
  67. package/utils/safety.js +109 -25
  68. package/utils/server-instructions.js +62 -0
  69. package/utils/tool-error.js +33 -0
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Create event functionality
3
3
  */
4
+ const { randomUUID } = require('crypto');
4
5
  const { callGraphAPI } = require('../utils/graph-api');
5
6
  const { ensureAuthenticated } = require('../auth');
6
7
  const { DEFAULT_TIMEZONE } = require('../config');
7
- const { buildAttendees } = require('./attendees');
8
+ const { buildAttendees, checkAttendeeAllowlist } = require('./attendees');
9
+ const { checkRateLimit } = require('../utils/safety');
10
+ const { toolError, authRequiredError } = require('../utils/tool-error');
11
+ const { previewCreateEvent } = require('./preview');
8
12
 
9
13
  /**
10
14
  * Create event handler
@@ -12,17 +16,12 @@ const { buildAttendees } = require('./attendees');
12
16
  * @returns {object} - MCP response
13
17
  */
14
18
  async function handleCreateEvent(args) {
15
- const { subject, start, end, attendees, body } = args;
19
+ const { subject, start, end, attendees, body, dryRun = false } = args;
16
20
 
17
21
  if (!subject || !start || !end) {
18
- return {
19
- content: [
20
- {
21
- type: 'text',
22
- text: 'Subject, start, and end times are required to create an event.',
23
- },
24
- ],
25
- };
22
+ return toolError(
23
+ 'Subject, start, and end times are required to create an event.'
24
+ );
26
25
  }
27
26
 
28
27
  // Plain strings are required attendees; {email, type} sets the type (#249).
@@ -36,37 +35,46 @@ async function handleCreateEvent(args) {
36
35
  isError: true,
37
36
  };
38
37
  }
38
+ const allowlistError = checkAttendeeAllowlist(graphAttendees, { dryRun });
39
+ if (allowlistError) return allowlistError;
39
40
  }
40
41
 
42
+ // Request body
43
+ const bodyContent = {
44
+ subject,
45
+ start: {
46
+ dateTime: start.dateTime || start,
47
+ timeZone: start.timeZone || DEFAULT_TIMEZONE,
48
+ },
49
+ end: {
50
+ dateTime: end.dateTime || end,
51
+ timeZone: end.timeZone || DEFAULT_TIMEZONE,
52
+ },
53
+ attendees: graphAttendees,
54
+ body: { contentType: 'HTML', content: body || '' },
55
+ };
56
+
41
57
  try {
58
+ // dryRun: say who would be invited; create nothing (#274).
59
+ if (dryRun) return await previewCreateEvent(bodyContent);
60
+
61
+ // Counts real creates only; unlimited unless a limit is configured.
62
+ const rateLimitError = checkRateLimit('create-event');
63
+ if (rateLimitError) return rateLimitError;
64
+
42
65
  // Get access token
43
66
  const accessToken = await ensureAuthenticated();
44
67
 
45
68
  // Build API endpoint
46
69
  const endpoint = `me/events`;
47
70
 
48
- // Request body
49
- const bodyContent = {
50
- subject,
51
- start: {
52
- dateTime: start.dateTime || start,
53
- timeZone: start.timeZone || DEFAULT_TIMEZONE,
54
- },
55
- end: {
56
- dateTime: end.dateTime || end,
57
- timeZone: end.timeZone || DEFAULT_TIMEZONE,
58
- },
59
- attendees: graphAttendees,
60
- body: { contentType: 'HTML', content: body || '' },
61
- };
62
-
63
- // Make API call
64
- const response = await callGraphAPI(
65
- accessToken,
66
- 'POST',
67
- endpoint,
68
- bodyContent
69
- );
71
+ // A fresh transactionId per call (#280): Graph treats POSTs that share
72
+ // one as the same event, so a 429 retry in utils/graph-api.js (which
73
+ // re-sends this exact body) can't book the meeting twice.
74
+ const response = await callGraphAPI(accessToken, 'POST', endpoint, {
75
+ ...bodyContent,
76
+ transactionId: randomUUID(),
77
+ });
70
78
 
71
79
  const output = [`Event '${subject}' has been successfully created.`];
72
80
  if (response.id) {
@@ -102,24 +110,10 @@ async function handleCreateEvent(args) {
102
110
  };
103
111
  } catch (error) {
104
112
  if (error.message === 'Authentication required') {
105
- return {
106
- content: [
107
- {
108
- type: 'text',
109
- text: "Authentication required. Please use the 'authenticate' tool first.",
110
- },
111
- ],
112
- };
113
+ return authRequiredError();
113
114
  }
114
115
 
115
- return {
116
- content: [
117
- {
118
- type: 'text',
119
- text: `Error creating event: ${error.message}`,
120
- },
121
- ],
122
- };
116
+ return toolError(`Error creating event: ${error.message}`);
123
117
  }
124
118
  }
125
119
 
@@ -3,6 +3,8 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
+ const { toolError, authRequiredError } = require('../utils/tool-error');
7
+ const { previewDeclineEvent } = require('./preview');
6
8
 
7
9
  /**
8
10
  * Decline event handler
@@ -10,23 +12,20 @@ const { ensureAuthenticated } = require('../auth');
10
12
  * @returns {object} - MCP response
11
13
  */
12
14
  async function handleDeclineEvent(args) {
13
- const { eventId, comment, sendResponse } = args;
15
+ const { eventId, comment, sendResponse, dryRun = false } = args;
14
16
 
15
17
  if (!eventId) {
16
- return {
17
- content: [
18
- {
19
- type: 'text',
20
- text: 'Event ID is required to decline an event.',
21
- },
22
- ],
23
- };
18
+ return toolError('Event ID is required to decline an event.');
24
19
  }
25
20
 
26
21
  try {
27
22
  // Get access token
28
23
  const accessToken = await ensureAuthenticated();
29
24
 
25
+ // dryRun: read the event and say whether the organiser would be
26
+ // emailed; send nothing.
27
+ if (dryRun) return await previewDeclineEvent(accessToken, args);
28
+
30
29
  // Build API endpoint
31
30
  const endpoint = `me/events/${eventId}/decline`;
32
31
 
@@ -53,24 +52,10 @@ async function handleDeclineEvent(args) {
53
52
  };
54
53
  } catch (error) {
55
54
  if (error.message === 'Authentication required') {
56
- return {
57
- content: [
58
- {
59
- type: 'text',
60
- text: "Authentication required. Please use the 'authenticate' tool first.",
61
- },
62
- ],
63
- };
55
+ return authRequiredError();
64
56
  }
65
57
 
66
- return {
67
- content: [
68
- {
69
- type: 'text',
70
- text: `Error declining event: ${error.message}`,
71
- },
72
- ],
73
- };
58
+ return toolError(`Error declining event: ${error.message}`);
74
59
  }
75
60
  }
76
61
 
@@ -3,6 +3,8 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
+ const { toolError, authRequiredError } = require('../utils/tool-error');
7
+ const { previewDeleteEvent } = require('./preview');
6
8
 
7
9
  /**
8
10
  * Delete event handler
@@ -10,23 +12,20 @@ const { ensureAuthenticated } = require('../auth');
10
12
  * @returns {object} - MCP response
11
13
  */
12
14
  async function handleDeleteEvent(args) {
13
- const { eventId } = args;
15
+ const { eventId, dryRun = false } = args;
14
16
 
15
17
  if (!eventId) {
16
- return {
17
- content: [
18
- {
19
- type: 'text',
20
- text: 'Event ID is required to delete an event.',
21
- },
22
- ],
23
- };
18
+ return toolError('Event ID is required to delete an event.');
24
19
  }
25
20
 
26
21
  try {
27
22
  // Get access token
28
23
  const accessToken = await ensureAuthenticated();
29
24
 
25
+ // dryRun: read the event and say who (if anyone) would get a
26
+ // cancellation; delete nothing.
27
+ if (dryRun) return await previewDeleteEvent(accessToken, args);
28
+
30
29
  // Build API endpoint
31
30
  const endpoint = `me/events/${eventId}`;
32
31
 
@@ -43,24 +42,10 @@ async function handleDeleteEvent(args) {
43
42
  };
44
43
  } catch (error) {
45
44
  if (error.message === 'Authentication required') {
46
- return {
47
- content: [
48
- {
49
- type: 'text',
50
- text: "Authentication required. Please use the 'authenticate' tool first.",
51
- },
52
- ],
53
- };
45
+ return authRequiredError();
54
46
  }
55
47
 
56
- return {
57
- content: [
58
- {
59
- type: 'text',
60
- text: `Error deleting event: ${error.message}`,
61
- },
62
- ],
63
- };
48
+ return toolError(`Error deleting event: ${error.message}`);
64
49
  }
65
50
  }
66
51
 
package/calendar/index.js CHANGED
@@ -8,6 +8,8 @@ const handleCancelEvent = require('./cancel');
8
8
  const handleDeleteEvent = require('./delete');
9
9
  const handleUpdateEvent = require('./update');
10
10
  const { ATTENDEE_TYPES } = require('./attendees');
11
+ const { toolMetadata } = require('../utils/risk-classes');
12
+ const { toolError } = require('../utils/tool-error');
11
13
 
12
14
  // One attendee: an email string, or {email, type} (#249). schema-coerce
13
15
  // doesn't validate inside array items, so calendar/attendees.js re-checks.
@@ -32,11 +34,7 @@ const calendarTools = [
32
34
  name: 'list-events',
33
35
  description:
34
36
  '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.',
35
- annotations: {
36
- title: 'List Calendar Events',
37
- readOnlyHint: true,
38
- openWorldHint: false,
39
- },
37
+ ...toolMetadata('list-events', 'List Calendar Events'),
40
38
  inputSchema: {
41
39
  type: 'object',
42
40
  properties: {
@@ -71,13 +69,8 @@ const calendarTools = [
71
69
  {
72
70
  name: 'create-event',
73
71
  description:
74
- "Create a new calendar event on the signed-in user's default calendar. Returns the created event with its `id`, `webLink`, and (if attendees are present) an auto-generated online-meeting URL — attendees receive invitations on save. Times use the configured timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`); omit the `Z` suffix to send local time. Use `manage-event` action=`update` to modify an event after creation, or `manage-event` action=`cancel`/`delete` to remove it.",
75
- annotations: {
76
- title: 'Create Calendar Event',
77
- readOnlyHint: false,
78
- destructiveHint: false,
79
- openWorldHint: false,
80
- },
72
+ "Create a new calendar event on the signed-in user's default calendar. Returns the created event with its `id`, `webLink`, and (if attendees are present) an auto-generated online-meeting URL — attendees receive invitations on save, so pass `dryRun: true` first to preview who would be invited (and how many are external) without creating anything. When `OUTLOOK_ALLOWED_RECIPIENTS` is set, every attendee must be allowed or nothing is created. Times use the configured timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`); omit the `Z` suffix to send local time. Use `manage-event` action=`update` to modify an event after creation, or `manage-event` action=`cancel`/`delete` to remove it.",
73
+ ...toolMetadata('create-event', 'Create Calendar Event'),
81
74
  inputSchema: {
82
75
  type: 'object',
83
76
  properties: {
@@ -103,6 +96,11 @@ const calendarTools = [
103
96
  type: 'string',
104
97
  description: 'Optional body content for the event',
105
98
  },
99
+ dryRun: {
100
+ type: 'boolean',
101
+ description:
102
+ 'Preview only: nothing is created and no invitations are sent. Shows who would be invited and how many are external (default false).',
103
+ },
106
104
  },
107
105
  additionalProperties: false,
108
106
  required: ['subject', 'start', 'end'],
@@ -112,13 +110,8 @@ const calendarTools = [
112
110
  {
113
111
  name: 'manage-event',
114
112
  description:
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).",
116
- annotations: {
117
- title: 'Manage Calendar Event',
118
- readOnlyHint: false,
119
- destructiveHint: true,
120
- openWorldHint: false,
121
- },
113
+ "Manage an existing calendar event. `dryRun: true` previews any action without changing or sending anything: who would be emailed, with an external count (decline/cancel/delete), or the PATCH body (update). action=`update` edits fields via PATCH (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart); only fields you pass change. action=`decline` declines an invitation (optional `comment`; `sendResponse: false` declines without notifying the organiser). action=`cancel` cancels an event you organised and emails attendees. action=`delete` removes the event from your calendar (Graph documents no guaranteed recovery); deleting a meeting you organised that has attendees still emails them a cancellation, so use `cancel` with a `comment` to control that message. Returns the updated event on update; a confirmation otherwise. There is no `accept` action: accept invitations in the Outlook UI, as Graph's accept verb is unreliable.",
114
+ ...toolMetadata('manage-event', 'Manage Calendar Event'),
122
115
  inputSchema: {
123
116
  type: 'object',
124
117
  properties: {
@@ -186,7 +179,7 @@ const calendarTools = [
186
179
  type: 'array',
187
180
  items: ATTENDEE_ITEM_SCHEMA,
188
181
  description:
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.",
182
+ "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. When OUTLOOK_ALLOWED_RECIPIENTS is set, every address on the list must be allowed or the update is refused.",
190
183
  },
191
184
  body: {
192
185
  type: 'string',
@@ -236,7 +229,7 @@ const calendarTools = [
236
229
  dryRun: {
237
230
  type: 'boolean',
238
231
  description:
239
- 'Preview the PATCH without applying it (action=update only). Returns the body that would be sent to Graph.',
232
+ 'Preview only: nothing is changed or sent. Shows who would be emailed (decline/cancel/delete) or the PATCH body (update). Default false.',
240
233
  },
241
234
  },
242
235
  additionalProperties: false,
@@ -251,14 +244,9 @@ const calendarTools = [
251
244
  normalised.eventId = normalised.id;
252
245
  }
253
246
  if (!normalised.eventId) {
254
- return {
255
- content: [
256
- {
257
- type: 'text',
258
- text: 'Required parameter `eventId` (or alias `id`) is missing.',
259
- },
260
- ],
261
- };
247
+ return toolError(
248
+ 'Required parameter `eventId` (or alias `id`) is missing.'
249
+ );
262
250
  }
263
251
  args = normalised;
264
252
  switch (args.action) {
@@ -271,14 +259,9 @@ const calendarTools = [
271
259
  case 'delete':
272
260
  return handleDeleteEvent(args);
273
261
  default:
274
- return {
275
- content: [
276
- {
277
- type: 'text',
278
- text: "Invalid action. Use 'update', 'decline', 'cancel', or 'delete'.",
279
- },
280
- ],
281
- };
262
+ return toolError(
263
+ "Invalid action. Use 'update', 'decline', 'cancel', or 'delete'."
264
+ );
282
265
  }
283
266
  },
284
267
  },
package/calendar/list.js CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  buildODataFilter,
10
10
  } = require('../utils/odata-helpers');
11
11
  const { parseIsoInstant } = require('../utils/datetime');
12
+ const { toolError, authRequiredError } = require('../utils/tool-error');
12
13
 
13
14
  const MAX_SUBJECT_LENGTH = 255;
14
15
 
@@ -312,24 +313,10 @@ async function handleListEvents(args) {
312
313
  };
313
314
  } catch (error) {
314
315
  if (error.message === 'Authentication required') {
315
- return {
316
- content: [
317
- {
318
- type: 'text',
319
- text: "Authentication required. Please use the 'authenticate' tool first.",
320
- },
321
- ],
322
- };
316
+ return authRequiredError();
323
317
  }
324
318
 
325
- return {
326
- content: [
327
- {
328
- type: 'text',
329
- text: `Error listing events: ${error.message}`,
330
- },
331
- ],
332
- };
319
+ return toolError(`Error listing events: ${error.message}`);
333
320
  }
334
321
  }
335
322
 
@@ -337,6 +324,7 @@ async function handleListEvents(args) {
337
324
  // and tests). Helpers are attached for unit testing without changing callers.
338
325
  handleListEvents.toUtcIso = toUtcIso;
339
326
  handleListEvents.formatLocal = formatLocal;
327
+ handleListEvents.toUtcIso = toUtcIso;
340
328
 
341
329
  module.exports = handleListEvents;
342
330
  module.exports.buildListEventsFilter = buildListEventsFilter;