@littlebearapps/outlook-assistant 3.11.2 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +20 -0
- package/README.md +51 -28
- package/advanced/index.js +319 -46
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- 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 +51 -10
- package/calendar/list.js +146 -3
- package/calendar/update.js +65 -33
- package/categories/index.js +17 -3
- package/config.js +103 -17
- package/contacts/index.js +2 -1
- package/email/attachments.js +19 -37
- package/email/conversations.js +180 -91
- package/email/delta.js +123 -13
- package/email/draft.js +66 -9
- package/email/export.js +113 -77
- package/email/folder-utils.js +29 -129
- package/email/headers.js +5 -1
- package/email/index.js +76 -19
- package/email/list.js +8 -1
- package/email/mark-as-read.js +3 -1
- package/email/mime.js +4 -1
- package/email/read.js +5 -1
- package/email/search.js +23 -9
- package/folder/create.js +11 -4
- package/folder/delete.js +9 -1
- package/folder/index.js +11 -1
- package/folder/list.js +61 -27
- package/folder/move.js +32 -7
- package/folder/resolve.js +65 -25
- package/folder/stats.js +11 -5
- package/index.js +9 -1
- package/llms-install.md +28 -9
- package/llms.txt +13 -9
- package/package.json +3 -3
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +390 -211
- package/utils/mailbox.js +77 -0
- 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/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 {
|
|
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
|
-
`**
|
|
134
|
+
`**Configured scopes**: ${scopes.join(', ')}`,
|
|
135
|
+
`**Granted scopes**: ${granted ? granted.filter((s) => s !== 'offline_access').join(', ') || 'none recorded' : 'not signed in'}`,
|
|
75
136
|
];
|
|
76
137
|
|
|
77
138
|
// F-1 / F-48: warn when no safety belts are wired up. AI-assisted
|
|
@@ -209,6 +270,23 @@ async function handleDeviceCodeAuth() {
|
|
|
209
270
|
}
|
|
210
271
|
|
|
211
272
|
console.error('[AUTH] Starting device code flow...');
|
|
273
|
+
// Attempt the configured scope set (base, plus `.Shared` when
|
|
274
|
+
// OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
|
|
275
|
+
// `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
|
|
276
|
+
return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Shared helper: request a device code for a given scope set, persist the
|
|
281
|
+
* pending state (tagging which scope set was used so the completion step can
|
|
282
|
+
* decide whether a fallback is still available), and build the MCP response.
|
|
283
|
+
* @param {string[]} scopes - OAuth scopes to request
|
|
284
|
+
* @param {'full'|'base'} scopesUsed - Label recording which scope set was used
|
|
285
|
+
* @param {string} [prefix] - Optional leading line (used for the fallback case)
|
|
286
|
+
* @returns {Promise<object>} - MCP response
|
|
287
|
+
*/
|
|
288
|
+
async function initiateDeviceCode(scopes, scopesUsed, prefix) {
|
|
289
|
+
const clientId = config.AUTH_CONFIG.clientId;
|
|
212
290
|
|
|
213
291
|
// #213 — initiation can throw (blocked network egress in a sandboxed
|
|
214
292
|
// connector, AADSTS9002331 audience mismatch, invalid_client, non-JSON
|
|
@@ -218,11 +296,25 @@ async function handleDeviceCodeAuth() {
|
|
|
218
296
|
// (handleDeviceCodeComplete) already has, and surface actionable hints.
|
|
219
297
|
let response;
|
|
220
298
|
try {
|
|
221
|
-
response = await initiateDeviceCodeFlow(
|
|
222
|
-
clientId,
|
|
223
|
-
config.AUTH_CONFIG.scopes
|
|
224
|
-
);
|
|
299
|
+
response = await initiateDeviceCodeFlow(clientId, scopes);
|
|
225
300
|
} catch (error) {
|
|
301
|
+
// The `.Shared` scopes can also be rejected when the code is requested
|
|
302
|
+
// (before sign-in). Same single fallback as at completion.
|
|
303
|
+
if (
|
|
304
|
+
config.SHARED_SCOPES.length > 0 &&
|
|
305
|
+
scopesUsed === 'full' &&
|
|
306
|
+
!isConsentRequiredError(error) &&
|
|
307
|
+
isScopeConsentError(error)
|
|
308
|
+
) {
|
|
309
|
+
console.error(
|
|
310
|
+
'[AUTH] Shared-mailbox scopes rejected at device-code request; retrying with base scopes.'
|
|
311
|
+
);
|
|
312
|
+
return initiateDeviceCode(
|
|
313
|
+
config.AUTH_CONFIG.fallbackScopes,
|
|
314
|
+
'base',
|
|
315
|
+
"Your account doesn't support shared-mailbox access; signing in with the standard scopes instead."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
226
318
|
return buildDeviceCodeErrorResponse(error);
|
|
227
319
|
}
|
|
228
320
|
|
|
@@ -232,24 +324,31 @@ async function handleDeviceCodeAuth() {
|
|
|
232
324
|
interval: response.interval,
|
|
233
325
|
expiresIn: response.expiresIn,
|
|
234
326
|
expiresAt: Date.now() + response.expiresIn * 1000,
|
|
327
|
+
scopesUsed,
|
|
235
328
|
};
|
|
236
329
|
saveDeviceCodeState(pendingDeviceCode);
|
|
237
330
|
|
|
238
331
|
console.error(
|
|
239
|
-
`[AUTH] Device code: ${response.userCode}, expires in ${response.expiresIn}s`
|
|
332
|
+
`[AUTH] Device code (${scopesUsed} scopes): ${response.userCode}, expires in ${response.expiresIn}s`
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const lines = [];
|
|
336
|
+
if (prefix) {
|
|
337
|
+
lines.push(prefix, '');
|
|
338
|
+
}
|
|
339
|
+
lines.push(
|
|
340
|
+
`## Device Code Authentication\n`,
|
|
341
|
+
`Visit: **${response.verificationUri}**`,
|
|
342
|
+
`Enter code: **${response.userCode}**\n`,
|
|
343
|
+
`The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
|
|
344
|
+
`After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`
|
|
240
345
|
);
|
|
241
346
|
|
|
242
347
|
return {
|
|
243
348
|
content: [
|
|
244
349
|
{
|
|
245
350
|
type: 'text',
|
|
246
|
-
text:
|
|
247
|
-
`## Device Code Authentication\n`,
|
|
248
|
-
`Visit: **${response.verificationUri}**`,
|
|
249
|
-
`Enter code: **${response.userCode}**\n`,
|
|
250
|
-
`The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
|
|
251
|
-
`After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`,
|
|
252
|
-
].join('\n'),
|
|
351
|
+
text: lines.join('\n'),
|
|
253
352
|
},
|
|
254
353
|
],
|
|
255
354
|
};
|
|
@@ -332,6 +431,14 @@ async function handleDeviceCodeComplete() {
|
|
|
332
431
|
}
|
|
333
432
|
|
|
334
433
|
const clientId = config.AUTH_CONFIG.clientId;
|
|
434
|
+
// Capture which scope set this pending flow attempted, before any mutation.
|
|
435
|
+
const scopesUsed = pendingDeviceCode.scopesUsed || 'full';
|
|
436
|
+
// The scopes we attempted — used as the granted_scopes fallback when the
|
|
437
|
+
// token response omits `scope`.
|
|
438
|
+
const attemptedScopes =
|
|
439
|
+
scopesUsed === 'base'
|
|
440
|
+
? config.AUTH_CONFIG.fallbackScopes
|
|
441
|
+
: config.AUTH_CONFIG.scopes;
|
|
335
442
|
|
|
336
443
|
try {
|
|
337
444
|
console.error('[AUTH] Polling for device code completion...');
|
|
@@ -355,12 +462,20 @@ async function handleDeviceCodeComplete() {
|
|
|
355
462
|
tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
|
|
356
463
|
});
|
|
357
464
|
|
|
465
|
+
// Persist the GRANTED scopes so token refresh re-requests exactly what was
|
|
466
|
+
// granted (not the full configured set) — otherwise a base-only fallback
|
|
467
|
+
// would re-request `.Shared` on refresh ~1h later and log the user out.
|
|
468
|
+
const grantedScopes = tokenResponse.scope
|
|
469
|
+
? tokenResponse.scope.split(' ').filter(Boolean)
|
|
470
|
+
: attemptedScopes;
|
|
471
|
+
|
|
358
472
|
tokenStorage.tokens = {
|
|
359
473
|
access_token: tokenResponse.access_token,
|
|
360
474
|
refresh_token: tokenResponse.refresh_token,
|
|
361
475
|
expires_in: tokenResponse.expires_in,
|
|
362
476
|
expires_at: Date.now() + tokenResponse.expires_in * 1000,
|
|
363
477
|
scope: tokenResponse.scope,
|
|
478
|
+
granted_scopes: grantedScopes,
|
|
364
479
|
token_type: tokenResponse.token_type,
|
|
365
480
|
auth_method: 'device-code',
|
|
366
481
|
};
|
|
@@ -377,8 +492,75 @@ async function handleDeviceCodeComplete() {
|
|
|
377
492
|
],
|
|
378
493
|
};
|
|
379
494
|
} catch (error) {
|
|
495
|
+
// Scope-consent rejection while attempting the FULL set → re-issue with
|
|
496
|
+
// base scopes. This is the personal-account path: one extra device code.
|
|
497
|
+
// Only meaningful when shared-mailbox support is on: with the flag off the
|
|
498
|
+
// attempted set already IS the base set, so there is nothing to drop.
|
|
499
|
+
// Consent-required (AADSTS65001) is checked first: it is remediable and
|
|
500
|
+
// must surface below, never trigger a silent downgrade.
|
|
501
|
+
if (
|
|
502
|
+
config.SHARED_SCOPES.length > 0 &&
|
|
503
|
+
scopesUsed === 'full' &&
|
|
504
|
+
!isConsentRequiredError(error) &&
|
|
505
|
+
isScopeConsentError(error)
|
|
506
|
+
) {
|
|
507
|
+
console.error(
|
|
508
|
+
'[AUTH] Shared-mailbox scopes rejected; falling back to base scopes.'
|
|
509
|
+
);
|
|
510
|
+
// Do NOT clear pendingDeviceCode — initiateDeviceCode replaces it.
|
|
511
|
+
try {
|
|
512
|
+
const fallbackResponse = await initiateDeviceCode(
|
|
513
|
+
config.AUTH_CONFIG.fallbackScopes,
|
|
514
|
+
'base',
|
|
515
|
+
"Your account doesn't support shared-mailbox access; enter this new code to finish signing in."
|
|
516
|
+
);
|
|
517
|
+
// initiateDeviceCode reports its own failures as { isError: true }
|
|
518
|
+
// instead of throwing. Clear the rejected full-scope pending state so
|
|
519
|
+
// a later completion attempt doesn't retry it.
|
|
520
|
+
if (fallbackResponse && fallbackResponse.isError) {
|
|
521
|
+
pendingDeviceCode = null;
|
|
522
|
+
saveDeviceCodeState(null);
|
|
523
|
+
}
|
|
524
|
+
return fallbackResponse;
|
|
525
|
+
} catch (reissueError) {
|
|
526
|
+
pendingDeviceCode = null;
|
|
527
|
+
saveDeviceCodeState(null);
|
|
528
|
+
return {
|
|
529
|
+
content: [
|
|
530
|
+
{
|
|
531
|
+
type: 'text',
|
|
532
|
+
text: `Authentication failed: ${reissueError.message}`,
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
380
539
|
pendingDeviceCode = null;
|
|
381
540
|
saveDeviceCodeState(null);
|
|
541
|
+
|
|
542
|
+
// Consent required (AADSTS65001) — remediable, so surface it instead of
|
|
543
|
+
// silently downgrading to base scopes (which would strip shared-mailbox
|
|
544
|
+
// access for every future refresh).
|
|
545
|
+
// Only when the shared scopes were requested — otherwise the generic
|
|
546
|
+
// path below (with its AADSTS hint table) is unchanged.
|
|
547
|
+
if (config.SHARED_SCOPES.length > 0 && isConsentRequiredError(error)) {
|
|
548
|
+
return {
|
|
549
|
+
content: [
|
|
550
|
+
{
|
|
551
|
+
type: 'text',
|
|
552
|
+
text: [
|
|
553
|
+
'Authentication failed: consent was not granted (AADSTS65001).',
|
|
554
|
+
'',
|
|
555
|
+
`An administrator may need to grant consent for the shared-mailbox scopes (${config.SHARED_SCOPES.join(', ')}), or re-run \`auth action=authenticate\` and approve every requested permission.`,
|
|
556
|
+
'If your organisation will not consent to them, unset OUTLOOK_SHARED_MAILBOX and restart the server to sign in with the standard scopes.',
|
|
557
|
+
'No scopes were changed — your configured capability is unchanged.',
|
|
558
|
+
].join('\n'),
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
382
564
|
return {
|
|
383
565
|
content: [
|
|
384
566
|
{
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared attendee builder for create-event and manage-event update (#249).
|
|
3
|
+
*
|
|
4
|
+
* Callers pass each attendee as a plain email string or as
|
|
5
|
+
* `{ email, type }` where type is `required`, `optional` or `resource`.
|
|
6
|
+
* Graph's PATCH on `attendees` replaces the whole list, so an entry
|
|
7
|
+
* without a type takes the type that address already has on the event
|
|
8
|
+
* (matched case-insensitively) and only falls back to `required` for a
|
|
9
|
+
* new address. An explicit type always wins.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ATTENDEE_TYPES = ['required', 'optional', 'resource'];
|
|
13
|
+
const ATTENDEE_FIELDS = new Set(['email', 'type']);
|
|
14
|
+
|
|
15
|
+
function invalid(index, reason) {
|
|
16
|
+
return new Error(`Invalid attendee at position ${index + 1}: ${reason}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate one attendee entry.
|
|
21
|
+
* @param {string|object} entry - Email string or {email, type}
|
|
22
|
+
* @param {number} index - Position in the list (for error messages)
|
|
23
|
+
* @returns {{email: string, type: (string|undefined)}}
|
|
24
|
+
* @throws {Error} 'Invalid attendee at position N: …'
|
|
25
|
+
*/
|
|
26
|
+
function normaliseAttendeeInput(entry, index) {
|
|
27
|
+
if (typeof entry === 'string') {
|
|
28
|
+
const email = entry.trim();
|
|
29
|
+
if (!email) throw invalid(index, 'email address is empty.');
|
|
30
|
+
return { email, type: undefined };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
34
|
+
throw invalid(
|
|
35
|
+
index,
|
|
36
|
+
`expected an email address or an {email, type} object, got ${JSON.stringify(entry)}.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const unknown = Object.keys(entry).find((k) => !ATTENDEE_FIELDS.has(k));
|
|
41
|
+
if (unknown) {
|
|
42
|
+
throw invalid(
|
|
43
|
+
index,
|
|
44
|
+
`unknown attendee field '${unknown}'. Use {email, type}.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (typeof entry.email !== 'string' || !entry.email.trim()) {
|
|
48
|
+
throw invalid(index, 'email address is missing or empty.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const type = entry.type ?? undefined;
|
|
52
|
+
if (type !== undefined && !ATTENDEE_TYPES.includes(type)) {
|
|
53
|
+
throw invalid(
|
|
54
|
+
index,
|
|
55
|
+
`type '${type}' must be one of: ${ATTENDEE_TYPES.join(', ')}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return { email: entry.email.trim(), type };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a whole attendee list.
|
|
63
|
+
* @param {Array} list
|
|
64
|
+
* @returns {Array<{email: string, type: (string|undefined)}>}
|
|
65
|
+
*/
|
|
66
|
+
function normaliseAttendees(list) {
|
|
67
|
+
if (!Array.isArray(list)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'Invalid attendees: expected a list of email addresses or {email, type} objects.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return list.map((entry, index) => normaliseAttendeeInput(entry, index));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the Graph `attendees` array.
|
|
77
|
+
* @param {Array} list - Email strings and/or {email, type} objects
|
|
78
|
+
* @param {Array} current - The event's current Graph attendees (update only)
|
|
79
|
+
* @returns {Array<{emailAddress: {address: string}, type: string}>}
|
|
80
|
+
*/
|
|
81
|
+
function buildAttendees(list, current = []) {
|
|
82
|
+
const currentTypes = new Map();
|
|
83
|
+
for (const attendee of current || []) {
|
|
84
|
+
const address = attendee?.emailAddress?.address;
|
|
85
|
+
if (typeof address === 'string' && ATTENDEE_TYPES.includes(attendee.type)) {
|
|
86
|
+
currentTypes.set(address.toLowerCase(), attendee.type);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return normaliseAttendees(list).map(({ email, type }) => ({
|
|
91
|
+
emailAddress: { address: email },
|
|
92
|
+
type: type || currentTypes.get(email.toLowerCase()) || 'required',
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
ATTENDEE_TYPES,
|
|
98
|
+
normaliseAttendeeInput,
|
|
99
|
+
normaliseAttendees,
|
|
100
|
+
buildAttendees,
|
|
101
|
+
};
|
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
|
|
34
|
+
'List calendar events for the signed-in user (read-only). By default returns upcoming events (start ≥ now). Optional `startAfter`, `startBefore` and `subject` filters find past, current or specifically-named events; supplying any of them replaces the default "now" lower bound and the filters are AND-ed together. Results are oldest first, except when the search only looks backwards (`startBefore` without `startAfter`, or `subject` alone), where they are newest first. Each event shows its subject, location, start/end, a body preview and its id. Use `count` (default 10, max 100) to control page size. Each start/end is returned as a canonical UTC ISO-8601 instant (e.g. `2026-04-02T22:00:00.000Z`) followed by a labelled local rendering in the configured display timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`) — the UTC value is authoritative, so consumers never have to guess the zone.',
|
|
17
35
|
annotations: {
|
|
18
36
|
title: 'List Calendar Events',
|
|
19
37
|
readOnlyHint: true,
|
|
@@ -24,7 +42,25 @@ const calendarTools = [
|
|
|
24
42
|
properties: {
|
|
25
43
|
count: {
|
|
26
44
|
type: 'number',
|
|
27
|
-
description: 'Number of events to retrieve (default: 10, max:
|
|
45
|
+
description: 'Number of events to retrieve (default: 10, max: 100)',
|
|
46
|
+
},
|
|
47
|
+
startAfter: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
format: 'date-time',
|
|
50
|
+
description:
|
|
51
|
+
'Optional ISO 8601 datetime with `Z` or a ±hh:mm offset (required). Only return events whose start is on or after this time. Replaces the default "now" lower bound when supplied. Example: "2026-01-01T00:00:00Z" or "2026-01-01T09:00:00+10:00".',
|
|
52
|
+
},
|
|
53
|
+
startBefore: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
format: 'date-time',
|
|
56
|
+
description:
|
|
57
|
+
'Optional ISO 8601 datetime with `Z` or a ±hh:mm offset (required). Only return events whose start is strictly before this time. Combine with `startAfter` to bound a window; on its own, results are newest first. Example: "2026-02-01T00:00:00Z".',
|
|
58
|
+
},
|
|
59
|
+
subject: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description:
|
|
62
|
+
'Optional substring (max 255 characters) to match against the event subject, case-insensitive (Graph `contains()`). Useful for finding past or current events by name; on its own, results are newest first.',
|
|
63
|
+
maxLength: 255,
|
|
28
64
|
},
|
|
29
65
|
},
|
|
30
66
|
additionalProperties: false,
|
|
@@ -59,10 +95,9 @@ const calendarTools = [
|
|
|
59
95
|
},
|
|
60
96
|
attendees: {
|
|
61
97
|
type: 'array',
|
|
62
|
-
items:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
description: 'List of attendee email addresses',
|
|
98
|
+
items: ATTENDEE_ITEM_SCHEMA,
|
|
99
|
+
description:
|
|
100
|
+
"Attendees: email address strings (required attendees) or {email, type} objects, where type is 'required', 'optional' or 'resource' (a room or equipment)",
|
|
66
101
|
},
|
|
67
102
|
body: {
|
|
68
103
|
type: 'string',
|
|
@@ -77,7 +112,7 @@ const calendarTools = [
|
|
|
77
112
|
{
|
|
78
113
|
name: 'manage-event',
|
|
79
114
|
description:
|
|
80
|
-
"Manage an existing calendar event (destructive: covers update/decline/cancel/delete — use dryRun where supported to preview). action=`update` edits fields in place via PATCH (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart) — only fields you pass are changed; pass `dryRun: true` to preview the PATCH payload. action=`decline` declines an invitation (optional `comment`). action=`cancel` cancels an event you organised and notifies attendees. action=`delete`
|
|
115
|
+
"Manage an existing calendar event (destructive: covers update/decline/cancel/delete — use dryRun where supported to preview). action=`update` edits fields in place via PATCH (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart) — only fields you pass are changed; pass `dryRun: true` to preview the PATCH payload. action=`decline` declines an invitation (optional `comment`; `sendResponse: false` declines without notifying the organiser). action=`cancel` cancels an event you organised and notifies attendees. action=`delete` removes the event from your calendar (Graph doesn't document a guaranteed recovery path, so don't count on restoring it); deleting a meeting you organised that has attendees still emails them a cancellation, so use `cancel` (with an optional `comment`) when you want to control that message. Returns the updated event on update; status confirmation otherwise. Note: there is no `accept` action — accept invitations in the Outlook UI (Graph's accept verb is unreliable across personal/M365).",
|
|
81
116
|
annotations: {
|
|
82
117
|
title: 'Manage Calendar Event',
|
|
83
118
|
readOnlyHint: false,
|
|
@@ -103,7 +138,13 @@ const calendarTools = [
|
|
|
103
138
|
},
|
|
104
139
|
comment: {
|
|
105
140
|
type: 'string',
|
|
106
|
-
description:
|
|
141
|
+
description:
|
|
142
|
+
'Message sent with a decline or cancel (optional; omitted if not given)',
|
|
143
|
+
},
|
|
144
|
+
sendResponse: {
|
|
145
|
+
type: 'boolean',
|
|
146
|
+
description:
|
|
147
|
+
'Send the decline to the organiser (action=decline only; default true). Pass false to decline without notifying the organiser.',
|
|
107
148
|
},
|
|
108
149
|
subject: {
|
|
109
150
|
type: 'string',
|
|
@@ -143,9 +184,9 @@ const calendarTools = [
|
|
|
143
184
|
},
|
|
144
185
|
attendees: {
|
|
145
186
|
type: 'array',
|
|
146
|
-
items:
|
|
187
|
+
items: ATTENDEE_ITEM_SCHEMA,
|
|
147
188
|
description:
|
|
148
|
-
|
|
189
|
+
"Full replacement attendee list — pass the complete desired list, or [] to clear (action=update only). Each entry is an email address string or an {email, type} object (type 'required', 'optional' or 'resource'). A string, or an object without a type, keeps the type that address already has on the event (new addresses are required); an explicit type always wins.",
|
|
149
190
|
},
|
|
150
191
|
body: {
|
|
151
192
|
type: 'string',
|