@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/.env.example +8 -0
- package/README.md +58 -18
- package/advanced/index.js +80 -36
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +5 -1
- package/auth/token-storage.js +29 -14
- package/auth/tools.js +179 -18
- 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 +37 -1
- 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 +13 -3
- package/llms-install.md +10 -4
- package/llms.txt +7 -7
- package/package.json +3 -2
- 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/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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|
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).
|