@littlebearapps/outlook-assistant 3.12.0 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/README.md +8 -3
- package/advanced/index.js +80 -36
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +33 -10
- package/calendar/list.js +10 -19
- package/calendar/update.js +65 -33
- package/config.js +30 -0
- package/contacts/index.js +2 -1
- package/email/attachments.js +7 -35
- package/email/conversations.js +155 -88
- package/email/delta.js +29 -9
- package/email/draft.js +66 -9
- package/email/export.js +5 -79
- package/email/folder-utils.js +0 -123
- package/email/index.js +12 -10
- package/email/search.js +9 -4
- package/folder/index.js +1 -1
- package/folder/resolve.js +3 -2
- package/index.js +9 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +324 -218
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- package/calendar/accept.js +0 -72
package/.env.example
CHANGED
|
@@ -31,6 +31,14 @@ USE_TEST_MODE=false
|
|
|
31
31
|
# receivedBefore rather than raising this if you can.
|
|
32
32
|
# OUTLOOK_SEARCH_SCAN_LIMIT=500
|
|
33
33
|
|
|
34
|
+
# Inactivity timeout for each Graph request attempt (milliseconds): an attempt
|
|
35
|
+
# that receives no data for this long is abandoned with a timeout error. It is
|
|
36
|
+
# not an overall deadline — a slow response that keeps arriving isn't cut off.
|
|
37
|
+
# Throttled (429) and busy (503/504) responses are retried automatically (up to
|
|
38
|
+
# 3 times, honouring Retry-After); POST requests such as sending mail are only
|
|
39
|
+
# retried on a 429 asking for a short wait (10 s or less, 20 s in total).
|
|
40
|
+
# OUTLOOK_REQUEST_TIMEOUT_MS=60000
|
|
41
|
+
|
|
34
42
|
# Optional: Default authentication method (device-code or browser)
|
|
35
43
|
# device-code: No auth server needed, works remotely/headless
|
|
36
44
|
# browser: Traditional OAuth redirect via localhost:3333
|
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ Outlook Assistant is designed with safety-first principles for AI-driven email a
|
|
|
143
143
|
|
|
144
144
|
**Input and file hardening** — IDs containing `.` or `..` path segments are refused before any request is made, continuation links (`deltaToken`) must point at `graph.microsoft.com`, and attachment downloads and exports write sanitised filenames inside the output directory without overwriting existing files or following symlinks.
|
|
145
145
|
|
|
146
|
-
**Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway.
|
|
146
|
+
**Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway. `update`, `send` and `delete` refuse any ID that is not an unsent draft, so a received or sent message is never edited, deleted or re-sent.
|
|
147
147
|
|
|
148
148
|
**Token-optimised architecture** — Tools are consolidated using the STRAP (Single Tool, Resource, Action Pattern) approach. 22 tools instead of 55 reduces per-turn overhead by ~11,000 tokens (~64%), keeping more of the AI's context window available for your actual conversation. Fewer tools also means the AI selects the right tool more accurately — research shows tool selection degrades beyond ~40 tools.
|
|
149
149
|
|
|
@@ -166,7 +166,7 @@ npx @littlebearapps/outlook-assistant
|
|
|
166
166
|
To check which version you have, or to see the available options:
|
|
167
167
|
|
|
168
168
|
```bash
|
|
169
|
-
outlook-assistant --version # prints e.g. 3.12.
|
|
169
|
+
outlook-assistant --version # prints e.g. 3.12.1
|
|
170
170
|
outlook-assistant --help # usage, options and key environment variables
|
|
171
171
|
```
|
|
172
172
|
|
|
@@ -374,6 +374,7 @@ USE_TEST_MODE=false
|
|
|
374
374
|
| `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, and rule forwards. | unrestricted |
|
|
375
375
|
| `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support (work/school only). `read` requests `Mail.Read.Shared`; `true` (or `readwrite`/`1`) also requests `Mail.ReadWrite.Shared`. Unset leaves sign-in unchanged. After enabling, restart and run `auth action=authenticate force=true`. | unset (off) |
|
|
376
376
|
| `OUTLOOK_SEARCH_SCAN_LIMIT` | How many recent messages the client-side search fallback scans. Personal accounts match `to` locally within this window, so the default caps how far back a `to` search reaches. Max 5000. | `500` |
|
|
377
|
+
| `OUTLOOK_REQUEST_TIMEOUT_MS` | Inactivity timeout for each Graph request attempt, in milliseconds: an attempt that receives no data for this long is abandoned with a timeout error. It isn't an overall deadline, so a slow response that keeps arriving isn't cut off. Throttled (`429`) and busy (`503`/`504`) responses are retried automatically, honouring `Retry-After`. | `60000` |
|
|
377
378
|
|
|
378
379
|
### MCP Client Configuration
|
|
379
380
|
|
|
@@ -451,7 +452,9 @@ outlook-assistant/
|
|
|
451
452
|
│ ├── conversations.js # Thread listing/export
|
|
452
453
|
│ ├── attachments.js # Attachment operations
|
|
453
454
|
│ └── ...
|
|
454
|
-
├── calendar/ # Calendar module (3 tools
|
|
455
|
+
├── calendar/ # Calendar module (3 tools)
|
|
456
|
+
│ ├── attendees.js # Attendee builder (email or {email, type})
|
|
457
|
+
│ └── list.js # list-events filters
|
|
455
458
|
├── contacts/ # Contacts module (2 tools)
|
|
456
459
|
├── categories/ # Categories module (3 tools)
|
|
457
460
|
├── settings/ # Settings module (1 tool)
|
|
@@ -462,6 +465,8 @@ outlook-assistant/
|
|
|
462
465
|
├── graph-api.js # Microsoft Graph API client (includes $batch, path guards)
|
|
463
466
|
├── mailbox.js # me vs users/{sharedMailbox} prefix, shared-mailbox opt-in
|
|
464
467
|
├── safety.js # Rate limiting, recipient allowlist, dry-run
|
|
468
|
+
├── safe-write.js # Exclusive, outputDir-confined file writes
|
|
469
|
+
├── datetime.js # ISO 8601 parsing and timezone conversion
|
|
465
470
|
├── odata-helpers.js # OData query building
|
|
466
471
|
├── field-presets.js # Token-efficient field selections
|
|
467
472
|
├── response-formatter.js # Verbosity levels
|
package/advanced/index.js
CHANGED
|
@@ -20,6 +20,12 @@ const {
|
|
|
20
20
|
} = require('../utils/mailbox');
|
|
21
21
|
const { resolveFolder } = require('../folder/resolve');
|
|
22
22
|
const { getAllFoldersHierarchy } = require('../folder/list');
|
|
23
|
+
const {
|
|
24
|
+
InvalidDateTimeError,
|
|
25
|
+
toGraphDateTimeTimeZone,
|
|
26
|
+
zonedParts,
|
|
27
|
+
zonedWallTimeToUtcMs,
|
|
28
|
+
} = require('../utils/datetime');
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Format an email for display (simplified)
|
|
@@ -408,6 +414,49 @@ async function handleListSharedMailboxFolders(sharedMailbox, args) {
|
|
|
408
414
|
}
|
|
409
415
|
}
|
|
410
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Default follow-up start for a flag with only a due date: 09:00 in
|
|
419
|
+
* DEFAULT_TIMEZONE on the due's local date, or the due itself when the due is
|
|
420
|
+
* earlier than that (a start after the due would be nonsense).
|
|
421
|
+
*/
|
|
422
|
+
function deriveFlagStart(due) {
|
|
423
|
+
let wall;
|
|
424
|
+
if (due.timeZone === DEFAULT_TIMEZONE) {
|
|
425
|
+
wall = due.dateTime;
|
|
426
|
+
} else {
|
|
427
|
+
const parts = zonedParts(Date.parse(`${due.dateTime}Z`), DEFAULT_TIMEZONE);
|
|
428
|
+
if (!parts) return { ...due };
|
|
429
|
+
wall = `${parts.date}T${parts.time}`;
|
|
430
|
+
}
|
|
431
|
+
const nineAm = `${wall.slice(0, 10)}T09:00:00`;
|
|
432
|
+
// Same zone and fixed-width prefix, so string order is time order.
|
|
433
|
+
return wall < nineAm
|
|
434
|
+
? { ...due }
|
|
435
|
+
: { dateTime: nineAm, timeZone: DEFAULT_TIMEZONE };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Describe a flag dateTimeTimeZone envelope as UTC plus DEFAULT_TIMEZONE,
|
|
440
|
+
* e.g. "2026-03-01 09:00 UTC (2026-03-01 20:00 Australia/Melbourne)".
|
|
441
|
+
*/
|
|
442
|
+
function describeFlagTime(envelope) {
|
|
443
|
+
const ms =
|
|
444
|
+
envelope.timeZone === 'UTC'
|
|
445
|
+
? Date.parse(`${envelope.dateTime}Z`)
|
|
446
|
+
: zonedWallTimeToUtcMs(envelope.dateTime, envelope.timeZone);
|
|
447
|
+
const local = (date, time) =>
|
|
448
|
+
`${date} ${time.slice(0, 5)} ${DEFAULT_TIMEZONE}`;
|
|
449
|
+
if (Number.isNaN(ms)) {
|
|
450
|
+
// DEFAULT_TIMEZONE isn't an IANA zone Intl knows; show it as given.
|
|
451
|
+
return local(envelope.dateTime.slice(0, 10), envelope.dateTime.slice(11));
|
|
452
|
+
}
|
|
453
|
+
const iso = new Date(ms).toISOString();
|
|
454
|
+
const utc = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
455
|
+
const parts =
|
|
456
|
+
DEFAULT_TIMEZONE === 'UTC' ? null : zonedParts(ms, DEFAULT_TIMEZONE);
|
|
457
|
+
return parts ? `${utc} (${local(parts.date, parts.time)})` : utc;
|
|
458
|
+
}
|
|
459
|
+
|
|
411
460
|
/**
|
|
412
461
|
* Set message flag handler
|
|
413
462
|
*/
|
|
@@ -435,43 +484,38 @@ async function handleSetMessageFlag(args) {
|
|
|
435
484
|
};
|
|
436
485
|
}
|
|
437
486
|
|
|
487
|
+
// Build flag object. Zoned values (Z/offset) are sent as the same instant in
|
|
488
|
+
// UTC; zone-less values are read in DEFAULT_TIMEZONE. Bad dates are refused
|
|
489
|
+
// here, before authenticating or calling Graph.
|
|
490
|
+
const flag = {
|
|
491
|
+
flagStatus: 'flagged',
|
|
492
|
+
};
|
|
438
493
|
try {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
|
|
494
|
+
if (startDateTime) {
|
|
495
|
+
flag.startDateTime = toGraphDateTimeTimeZone(
|
|
496
|
+
startDateTime,
|
|
497
|
+
'startDateTime'
|
|
498
|
+
);
|
|
499
|
+
}
|
|
446
500
|
if (dueDateTime) {
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
dateTime: dueDt,
|
|
452
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
// Graph API requires startDateTime when dueDateTime is set
|
|
456
|
-
// Default to start of the same day if not explicitly provided
|
|
457
|
-
if (startDateTime) {
|
|
458
|
-
flag.startDateTime = {
|
|
459
|
-
dateTime: startDateTime.replace(/Z$/i, ''),
|
|
460
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
461
|
-
};
|
|
462
|
-
} else {
|
|
463
|
-
const startOfDay = `${dueDt.split('T')[0]}T09:00:00`;
|
|
464
|
-
flag.startDateTime = {
|
|
465
|
-
dateTime: startOfDay,
|
|
466
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
467
|
-
};
|
|
501
|
+
flag.dueDateTime = toGraphDateTimeTimeZone(dueDateTime, 'dueDateTime');
|
|
502
|
+
// Graph requires startDateTime when dueDateTime is set.
|
|
503
|
+
if (!flag.startDateTime) {
|
|
504
|
+
flag.startDateTime = deriveFlagStart(flag.dueDateTime);
|
|
468
505
|
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
506
|
+
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error instanceof InvalidDateTimeError) {
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: 'text', text: error.message }],
|
|
511
|
+
isError: true,
|
|
473
512
|
};
|
|
474
513
|
}
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const accessToken = await ensureAuthenticated();
|
|
475
519
|
|
|
476
520
|
// Process all messages
|
|
477
521
|
const results = [];
|
|
@@ -493,11 +537,11 @@ async function handleSetMessageFlag(args) {
|
|
|
493
537
|
if (results.length > 0) {
|
|
494
538
|
output.push(`Flagged ${results.length} message(s) for follow-up`);
|
|
495
539
|
|
|
496
|
-
if (dueDateTime) {
|
|
497
|
-
output.push(`**Due**: ${
|
|
540
|
+
if (flag.dueDateTime) {
|
|
541
|
+
output.push(`**Due**: ${describeFlagTime(flag.dueDateTime)}`);
|
|
498
542
|
}
|
|
499
|
-
if (startDateTime) {
|
|
500
|
-
output.push(`**Start**: ${
|
|
543
|
+
if (flag.startDateTime) {
|
|
544
|
+
output.push(`**Start**: ${describeFlagTime(flag.startDateTime)}`);
|
|
501
545
|
}
|
|
502
546
|
}
|
|
503
547
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared attendee builder for create-event and manage-event update (#249).
|
|
3
|
+
*
|
|
4
|
+
* Callers pass each attendee as a plain email string or as
|
|
5
|
+
* `{ email, type }` where type is `required`, `optional` or `resource`.
|
|
6
|
+
* Graph's PATCH on `attendees` replaces the whole list, so an entry
|
|
7
|
+
* without a type takes the type that address already has on the event
|
|
8
|
+
* (matched case-insensitively) and only falls back to `required` for a
|
|
9
|
+
* new address. An explicit type always wins.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ATTENDEE_TYPES = ['required', 'optional', 'resource'];
|
|
13
|
+
const ATTENDEE_FIELDS = new Set(['email', 'type']);
|
|
14
|
+
|
|
15
|
+
function invalid(index, reason) {
|
|
16
|
+
return new Error(`Invalid attendee at position ${index + 1}: ${reason}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate one attendee entry.
|
|
21
|
+
* @param {string|object} entry - Email string or {email, type}
|
|
22
|
+
* @param {number} index - Position in the list (for error messages)
|
|
23
|
+
* @returns {{email: string, type: (string|undefined)}}
|
|
24
|
+
* @throws {Error} 'Invalid attendee at position N: …'
|
|
25
|
+
*/
|
|
26
|
+
function normaliseAttendeeInput(entry, index) {
|
|
27
|
+
if (typeof entry === 'string') {
|
|
28
|
+
const email = entry.trim();
|
|
29
|
+
if (!email) throw invalid(index, 'email address is empty.');
|
|
30
|
+
return { email, type: undefined };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
34
|
+
throw invalid(
|
|
35
|
+
index,
|
|
36
|
+
`expected an email address or an {email, type} object, got ${JSON.stringify(entry)}.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const unknown = Object.keys(entry).find((k) => !ATTENDEE_FIELDS.has(k));
|
|
41
|
+
if (unknown) {
|
|
42
|
+
throw invalid(
|
|
43
|
+
index,
|
|
44
|
+
`unknown attendee field '${unknown}'. Use {email, type}.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (typeof entry.email !== 'string' || !entry.email.trim()) {
|
|
48
|
+
throw invalid(index, 'email address is missing or empty.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const type = entry.type ?? undefined;
|
|
52
|
+
if (type !== undefined && !ATTENDEE_TYPES.includes(type)) {
|
|
53
|
+
throw invalid(
|
|
54
|
+
index,
|
|
55
|
+
`type '${type}' must be one of: ${ATTENDEE_TYPES.join(', ')}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return { email: entry.email.trim(), type };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a whole attendee list.
|
|
63
|
+
* @param {Array} list
|
|
64
|
+
* @returns {Array<{email: string, type: (string|undefined)}>}
|
|
65
|
+
*/
|
|
66
|
+
function normaliseAttendees(list) {
|
|
67
|
+
if (!Array.isArray(list)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'Invalid attendees: expected a list of email addresses or {email, type} objects.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return list.map((entry, index) => normaliseAttendeeInput(entry, index));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the Graph `attendees` array.
|
|
77
|
+
* @param {Array} list - Email strings and/or {email, type} objects
|
|
78
|
+
* @param {Array} current - The event's current Graph attendees (update only)
|
|
79
|
+
* @returns {Array<{emailAddress: {address: string}, type: string}>}
|
|
80
|
+
*/
|
|
81
|
+
function buildAttendees(list, current = []) {
|
|
82
|
+
const currentTypes = new Map();
|
|
83
|
+
for (const attendee of current || []) {
|
|
84
|
+
const address = attendee?.emailAddress?.address;
|
|
85
|
+
if (typeof address === 'string' && ATTENDEE_TYPES.includes(attendee.type)) {
|
|
86
|
+
currentTypes.set(address.toLowerCase(), attendee.type);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return normaliseAttendees(list).map(({ email, type }) => ({
|
|
91
|
+
emailAddress: { address: email },
|
|
92
|
+
type: type || currentTypes.get(email.toLowerCase()) || 'required',
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
ATTENDEE_TYPES,
|
|
98
|
+
normaliseAttendeeInput,
|
|
99
|
+
normaliseAttendees,
|
|
100
|
+
buildAttendees,
|
|
101
|
+
};
|
package/calendar/cancel.js
CHANGED
|
@@ -30,10 +30,11 @@ async function handleCancelEvent(args) {
|
|
|
30
30
|
// Build API endpoint
|
|
31
31
|
const endpoint = `me/events/${eventId}/cancel`;
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
const body = {
|
|
35
|
-
|
|
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);
|
package/calendar/create.js
CHANGED
|
@@ -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:
|
|
46
|
-
emailAddress: { address: email },
|
|
47
|
-
type: 'required',
|
|
48
|
-
})),
|
|
59
|
+
attendees: graphAttendees,
|
|
49
60
|
body: { contentType: 'HTML', content: body || '' },
|
|
50
61
|
};
|
|
51
62
|
|
package/calendar/decline.js
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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`
|
|
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:
|
|
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:
|
|
187
|
+
items: ATTENDEE_ITEM_SCHEMA,
|
|
165
188
|
description:
|
|
166
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
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).
|