@littlebearapps/outlook-assistant 3.12.1 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.env.example +27 -3
  2. package/README.md +108 -33
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/client-config.js +142 -0
  6. package/auth/index.js +4 -2
  7. package/auth/oauth-server.js +12 -2
  8. package/auth/token-manager.js +7 -3
  9. package/auth/token-storage.js +46 -33
  10. package/auth/tools.js +223 -93
  11. package/calendar/attendees.js +36 -0
  12. package/calendar/cancel.js +9 -25
  13. package/calendar/create.js +42 -48
  14. package/calendar/decline.js +10 -25
  15. package/calendar/delete.js +10 -25
  16. package/calendar/index.js +20 -37
  17. package/calendar/list.js +4 -16
  18. package/calendar/preview.js +335 -0
  19. package/calendar/update.js +42 -86
  20. package/categories/index.js +59 -264
  21. package/config.js +36 -2
  22. package/contacts/index.js +72 -128
  23. package/email/attachments.js +42 -124
  24. package/email/conversations.js +44 -78
  25. package/email/delta.js +10 -34
  26. package/email/draft.js +140 -96
  27. package/email/export.js +141 -110
  28. package/email/folder-utils.js +3 -2
  29. package/email/headers.js +11 -49
  30. package/email/index.js +85 -109
  31. package/email/list.js +4 -17
  32. package/email/mail-tips.js +86 -57
  33. package/email/mark-as-read.js +13 -49
  34. package/email/mime.js +14 -49
  35. package/email/read.js +16 -50
  36. package/email/search.js +46 -86
  37. package/email/send.js +82 -48
  38. package/folder/create.js +6 -25
  39. package/folder/delete.js +117 -38
  40. package/folder/index.js +17 -16
  41. package/folder/list.js +5 -17
  42. package/folder/move.js +13 -42
  43. package/folder/resolve.js +11 -6
  44. package/folder/stats.js +6 -20
  45. package/index.js +23 -45
  46. package/llms-install.md +31 -7
  47. package/llms.txt +19 -10
  48. package/outlook-auth-server.js +10 -3
  49. package/package.json +6 -2
  50. package/request-handler.js +217 -116
  51. package/rules/create.js +27 -70
  52. package/rules/index.js +30 -92
  53. package/rules/list.js +5 -17
  54. package/rules/rule-builder.js +57 -20
  55. package/rules/update.js +26 -60
  56. package/server.js +37 -0
  57. package/settings/index.js +142 -143
  58. package/tools.js +30 -0
  59. package/utils/field-presets.js +4 -2
  60. package/utils/graph-api.js +65 -22
  61. package/utils/logger.js +251 -0
  62. package/utils/mock-data.js +91 -2
  63. package/utils/read-only.js +59 -0
  64. package/utils/response-formatter.js +54 -15
  65. package/utils/risk-classes.js +324 -0
  66. package/utils/safe-write.js +372 -6
  67. package/utils/safety.js +109 -25
  68. package/utils/server-instructions.js +62 -0
  69. package/utils/tool-error.js +33 -0
package/auth/tools.js CHANGED
@@ -2,16 +2,26 @@
2
2
  * Authentication-related tools for the Outlook Assistant server
3
3
  */
4
4
  const config = require('../config');
5
- const { getAuthErrorHints } = require('./auth-errors');
5
+ const { getAuthErrorHints, authErrorLogLabel } = 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,
12
19
  isScopeConsentError,
13
20
  isConsentRequiredError,
14
21
  } = require('./device-code');
22
+ const { toolMetadata } = require('../utils/risk-classes');
23
+ const { toolError } = require('../utils/tool-error');
24
+ const { log } = require('../utils/logger');
15
25
 
16
26
  // Path for persisting device code state across MCP server restarts
17
27
  const DEVICE_CODE_STATE_PATH = path.join(
@@ -19,6 +29,112 @@ const DEVICE_CODE_STATE_PATH = path.join(
19
29
  '.outlook-assistant-pending-auth.json'
20
30
  );
21
31
 
32
+ const SETUP_GUIDE_URL =
33
+ 'https://github.com/littlebearapps/outlook-assistant/blob/main/docs/how-to/getting-started/connect-outlook-to-claude.md';
34
+ const SAVED_CONFIG_DISPLAY_PATH = `~/${CONFIG_FILE_NAME}`;
35
+
36
+ /**
37
+ * Error shown when no client ID resolves (no env var, nothing saved). Written
38
+ * for the AI client: it tells it what to ask the user and which call to make.
39
+ * @returns {object} - MCP response ({ content, isError: true })
40
+ */
41
+ function buildMissingClientIdResponse() {
42
+ return {
43
+ content: [
44
+ {
45
+ type: 'text',
46
+ text: [
47
+ 'Error: OUTLOOK_CLIENT_ID is not configured, so sign-in cannot start.',
48
+ '',
49
+ '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`.',
50
+ `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.`,
51
+ '',
52
+ 'The client secret is not needed for device-code sign-in (the default); only the browser flow uses it.',
53
+ `No app registration yet? Follow the setup guide: ${SETUP_GUIDE_URL}`,
54
+ 'Alternatively, set OUTLOOK_CLIENT_ID in the MCP server environment and restart it.',
55
+ ].join('\n'),
56
+ },
57
+ ],
58
+ isError: true,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Validate and save a client ID supplied via `auth action=authenticate`.
64
+ * @param {unknown} clientId
65
+ * @returns {{error: object}|{saved: string}} - An MCP error response, or the saved ID
66
+ */
67
+ function applyClientIdArg(clientId) {
68
+ if (!isValidClientId(clientId)) {
69
+ return {
70
+ error: {
71
+ content: [
72
+ {
73
+ type: 'text',
74
+ text: [
75
+ 'Error: `clientId` is not a valid Azure Application (client) ID.',
76
+ '',
77
+ '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.',
78
+ `Setup guide: ${SETUP_GUIDE_URL}`,
79
+ ].join('\n'),
80
+ },
81
+ ],
82
+ isError: true,
83
+ },
84
+ };
85
+ }
86
+
87
+ const env = getEnvClientId();
88
+ if (env && env.value.trim().toLowerCase() !== clientId.trim().toLowerCase()) {
89
+ return {
90
+ error: {
91
+ content: [
92
+ {
93
+ type: 'text',
94
+ text: [
95
+ `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.`,
96
+ '',
97
+ `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.`,
98
+ ].join('\n'),
99
+ },
100
+ ],
101
+ isError: true,
102
+ },
103
+ };
104
+ }
105
+
106
+ try {
107
+ return { saved: saveClientId(clientId) };
108
+ } catch (error) {
109
+ return {
110
+ error: {
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: `Error: could not save the client ID to ${SAVED_CONFIG_DISPLAY_PATH}: ${error.message}`,
115
+ },
116
+ ],
117
+ isError: true,
118
+ },
119
+ };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Client ID row for `auth about`. The ID itself is never shown.
125
+ * @returns {string}
126
+ */
127
+ function describeClientIdStatus() {
128
+ const source = getClientIdSource();
129
+ if (source === 'env') {
130
+ return `Configured (environment: ${getEnvClientId().name})`;
131
+ }
132
+ if (source === 'saved') {
133
+ return `Configured (saved in ${SAVED_CONFIG_DISPLAY_PATH})`;
134
+ }
135
+ return 'Not set (run `auth action=authenticate clientId=<Application (client) ID>`)';
136
+ }
137
+
22
138
  // Dynamic tool count — set by index.js after TOOLS array is built
23
139
  let _toolCount = 0;
24
140
  function setToolCount(count) {
@@ -122,12 +238,14 @@ async function handleAbout() {
122
238
  `| Setting | Value |`,
123
239
  `|---------|-------|`,
124
240
  `| Mailbox | ${identity} |`,
241
+ `| Client ID | ${describeClientIdStatus()} |`,
125
242
  `| Tools | ${_toolCount} across 9 modules |`,
126
243
  `| Modules | auth, email, calendar, folder, rules, contacts, categories, settings, advanced |`,
127
244
  `| Timezone | ${config.DEFAULT_TIMEZONE} |`,
128
245
  `| Test Mode | ${testMode} |`,
129
246
  `| Rate Limit | ${rateLimit} |`,
130
247
  `| Recipient Allowlist | ${allowlist} |`,
248
+ `| Read-only mode | ${config.READ_ONLY ? 'On (OUTLOOK_READ_ONLY): only read tools and actions run' : 'Off (set OUTLOOK_READ_ONLY=true and restart to refuse every change)'} |`,
131
249
  `| Scopes | ${scopes.length} configured |`,
132
250
  `| Shared mailboxes | ${describeSharedMailboxStatus(granted)} |`,
133
251
  ``,
@@ -185,19 +303,54 @@ async function handleAuthenticate(args) {
185
303
  };
186
304
  }
187
305
 
306
+ // Optional runtime client ID (for clients that can't set env vars, e.g.
307
+ // plugin marketplaces): validate, refuse if an env var would override it,
308
+ // save, then carry on with the normal flow.
309
+ // null / blank counts as not supplied: some clients send empty optionals.
310
+ let savedPrefix;
311
+ const suppliedClientId = args?.clientId;
312
+ if (
313
+ suppliedClientId !== undefined &&
314
+ suppliedClientId !== null &&
315
+ String(suppliedClientId).trim() !== ''
316
+ ) {
317
+ const result = applyClientIdArg(suppliedClientId);
318
+ if (result.error) {
319
+ return result.error;
320
+ }
321
+ savedPrefix = `Saved your Azure Application (client) ID to \`${SAVED_CONFIG_DISPLAY_PATH}\`.`;
322
+ }
323
+
188
324
  const method = args?.method || config.AUTH_CONFIG.defaultAuthMethod;
189
325
 
190
326
  if (method === 'device-code') {
191
- return handleDeviceCodeAuth();
327
+ return handleDeviceCodeAuth(savedPrefix);
192
328
  }
193
329
 
194
330
  // Browser redirect flow (existing behaviour)
195
- const authUrl = `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${config.AUTH_CONFIG.clientId}`;
331
+ const clientId = config.AUTH_CONFIG.clientId;
332
+ if (!clientId) {
333
+ return buildMissingClientIdResponse();
334
+ }
335
+ const authUrl = `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${encodeURIComponent(clientId)}`;
336
+ const lines = [];
337
+ if (savedPrefix) {
338
+ lines.push(savedPrefix, '');
339
+ }
340
+ lines.push(
341
+ `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.`
342
+ );
343
+ if (getClientIdSource() !== 'env') {
344
+ lines.push(
345
+ '',
346
+ '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.'
347
+ );
348
+ }
196
349
  return {
197
350
  content: [
198
351
  {
199
352
  type: 'text',
200
- text: `Authentication required. Please visit the following URL to authenticate with Microsoft: ${authUrl}\n\nAfter authentication, you will be redirected back to this application.\n\nNote: The auth server must be running on port 3333. If working remotely, consider using method=device-code instead.`,
353
+ text: lines.join('\n'),
201
354
  },
202
355
  ],
203
356
  };
@@ -221,7 +374,7 @@ function saveDeviceCodeState(state) {
221
374
  fs.unlinkSync(DEVICE_CODE_STATE_PATH);
222
375
  }
223
376
  } catch (error) {
224
- console.error(
377
+ log.debug(
225
378
  `[AUTH] Failed to ${state ? 'save' : 'clean up'} device code state: ${error.message}`
226
379
  );
227
380
  }
@@ -239,13 +392,13 @@ function loadDeviceCodeState() {
239
392
  }
240
393
  const state = JSON.parse(fs.readFileSync(DEVICE_CODE_STATE_PATH, 'utf8'));
241
394
  if (Date.now() > state.expiresAt) {
242
- console.error('[AUTH] Persisted device code has expired, cleaning up');
395
+ log.debug('[AUTH] Persisted device code has expired, cleaning up');
243
396
  saveDeviceCodeState(null);
244
397
  return null;
245
398
  }
246
399
  return state;
247
400
  } catch (error) {
248
- console.error(`[AUTH] Failed to load device code state: ${error.message}`);
401
+ log.debug(`[AUTH] Failed to load device code state: ${error.message}`);
249
402
  return null;
250
403
  }
251
404
  }
@@ -254,26 +407,20 @@ function loadDeviceCodeState() {
254
407
  * Device code flow step 1 — request a code for the user to enter.
255
408
  * Returns the code + URL immediately. Call device-code-complete to finish.
256
409
  * State is persisted to disk so it survives MCP server restarts.
410
+ * @param {string} [prefix] - Optional leading line (e.g. "client ID saved")
257
411
  * @returns {object} - MCP response
258
412
  */
259
- async function handleDeviceCodeAuth() {
413
+ async function handleDeviceCodeAuth(prefix) {
260
414
  const clientId = config.AUTH_CONFIG.clientId;
261
415
  if (!clientId) {
262
- return {
263
- content: [
264
- {
265
- type: 'text',
266
- text: 'Error: OUTLOOK_CLIENT_ID is not configured.',
267
- },
268
- ],
269
- };
416
+ return buildMissingClientIdResponse();
270
417
  }
271
418
 
272
- console.error('[AUTH] Starting device code flow...');
419
+ log.debug('[AUTH] Starting device code flow...');
273
420
  // Attempt the configured scope set (base, plus `.Shared` when
274
421
  // OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
275
422
  // `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
276
- return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full');
423
+ return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full', prefix);
277
424
  }
278
425
 
279
426
  /**
@@ -306,7 +453,7 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
306
453
  !isConsentRequiredError(error) &&
307
454
  isScopeConsentError(error)
308
455
  ) {
309
- console.error(
456
+ log.debug(
310
457
  '[AUTH] Shared-mailbox scopes rejected at device-code request; retrying with base scopes.'
311
458
  );
312
459
  return initiateDeviceCode(
@@ -318,18 +465,21 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
318
465
  return buildDeviceCodeErrorResponse(error);
319
466
  }
320
467
 
321
- // Store in memory and persist to disk
468
+ // Store in memory and persist to disk. The client ID is recorded because
469
+ // the device code is bound to it: completion must poll with the same one.
322
470
  pendingDeviceCode = {
323
471
  deviceCode: response.deviceCode,
324
472
  interval: response.interval,
325
473
  expiresIn: response.expiresIn,
326
474
  expiresAt: Date.now() + response.expiresIn * 1000,
327
475
  scopesUsed,
476
+ clientId,
328
477
  };
329
478
  saveDeviceCodeState(pendingDeviceCode);
330
479
 
331
- console.error(
332
- `[AUTH] Device code (${scopesUsed} scopes): ${response.userCode}, expires in ${response.expiresIn}s`
480
+ // Never log the user code: it is for the user (via the tool result) only.
481
+ log.debug(
482
+ `[AUTH] Device code issued (${scopesUsed} scopes), expires in ${response.expiresIn}s`
333
483
  );
334
484
 
335
485
  const lines = [];
@@ -387,7 +537,8 @@ function buildDeviceCodeErrorResponse(error) {
387
537
  lines.push('', 'Suggested fixes:', ...hints.map((h) => `- ${h}`));
388
538
  }
389
539
 
390
- console.error(`[AUTH] Device code initiation failed: ${msg}`);
540
+ log.note('auth', authErrorLogLabel('device-code-failed', error));
541
+ log.debug(`[AUTH] Device code initiation failed: ${msg}`);
391
542
 
392
543
  return {
393
544
  content: [{ type: 'text', text: lines.join('\n') }],
@@ -407,30 +558,27 @@ async function handleDeviceCodeComplete() {
407
558
  }
408
559
 
409
560
  if (!pendingDeviceCode) {
410
- return {
411
- content: [
412
- {
413
- type: 'text',
414
- text: 'No pending device code flow. Call authenticate with method=device-code first.',
415
- },
416
- ],
417
- };
561
+ return toolError('No pending device code flow.', {
562
+ nextStep: 'Start one with the `auth` tool with action=authenticate.',
563
+ });
418
564
  }
419
565
 
420
566
  if (Date.now() > pendingDeviceCode.expiresAt) {
421
567
  pendingDeviceCode = null;
422
568
  saveDeviceCodeState(null);
423
- return {
424
- content: [
425
- {
426
- type: 'text',
427
- text: 'Device code has expired. Please start a new authentication with action=authenticate.',
428
- },
429
- ],
430
- };
569
+ return toolError(
570
+ 'Device code has expired. Please start a new authentication with action=authenticate.'
571
+ );
431
572
  }
432
573
 
433
- const clientId = config.AUTH_CONFIG.clientId;
574
+ // Poll with the client ID the code was issued to (older state files don't
575
+ // record it, so fall back to the current one).
576
+ const clientId = pendingDeviceCode.clientId || config.AUTH_CONFIG.clientId;
577
+ if (!clientId) {
578
+ pendingDeviceCode = null;
579
+ saveDeviceCodeState(null);
580
+ return buildMissingClientIdResponse();
581
+ }
434
582
  // Capture which scope set this pending flow attempted, before any mutation.
435
583
  const scopesUsed = pendingDeviceCode.scopesUsed || 'full';
436
584
  // The scopes we attempted — used as the granted_scopes fallback when the
@@ -441,7 +589,7 @@ async function handleDeviceCodeComplete() {
441
589
  : config.AUTH_CONFIG.scopes;
442
590
 
443
591
  try {
444
- console.error('[AUTH] Polling for device code completion...');
592
+ log.debug('[AUTH] Polling for device code completion...');
445
593
  const tokenResponse = await pollForToken(
446
594
  clientId,
447
595
  pendingDeviceCode.deviceCode,
@@ -455,7 +603,7 @@ async function handleDeviceCodeComplete() {
455
603
  // Save tokens using TokenStorage — mark as device-code auth
456
604
  const TokenStorage = require('./token-storage');
457
605
  const tokenStorage = new TokenStorage({
458
- clientId: config.AUTH_CONFIG.clientId,
606
+ clientId,
459
607
  clientSecret: config.AUTH_CONFIG.clientSecret,
460
608
  tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
461
609
  scopes: config.AUTH_CONFIG.scopes,
@@ -481,7 +629,7 @@ async function handleDeviceCodeComplete() {
481
629
  };
482
630
  await tokenStorage._saveTokensToFile();
483
631
 
484
- console.error('[AUTH] Device code flow completed successfully.');
632
+ log.debug('[AUTH] Device code flow completed successfully.');
485
633
 
486
634
  return {
487
635
  content: [
@@ -504,7 +652,7 @@ async function handleDeviceCodeComplete() {
504
652
  !isConsentRequiredError(error) &&
505
653
  isScopeConsentError(error)
506
654
  ) {
507
- console.error(
655
+ log.debug(
508
656
  '[AUTH] Shared-mailbox scopes rejected; falling back to base scopes.'
509
657
  );
510
658
  // Do NOT clear pendingDeviceCode — initiateDeviceCode replaces it.
@@ -525,19 +673,14 @@ async function handleDeviceCodeComplete() {
525
673
  } catch (reissueError) {
526
674
  pendingDeviceCode = null;
527
675
  saveDeviceCodeState(null);
528
- return {
529
- content: [
530
- {
531
- type: 'text',
532
- text: `Authentication failed: ${reissueError.message}`,
533
- },
534
- ],
535
- };
676
+ return toolError(`Authentication failed: ${reissueError.message}`);
536
677
  }
537
678
  }
538
679
 
539
680
  pendingDeviceCode = null;
540
681
  saveDeviceCodeState(null);
682
+ log.note('auth', authErrorLogLabel('device-code-complete-failed', error));
683
+ log.debug(`[AUTH] Device code completion failed: ${error.message}`);
541
684
 
542
685
  // Consent required (AADSTS65001) — remediable, so surface it instead of
543
686
  // silently downgrading to base scopes (which would strip shared-mailbox
@@ -545,30 +688,18 @@ async function handleDeviceCodeComplete() {
545
688
  // Only when the shared scopes were requested — otherwise the generic
546
689
  // path below (with its AADSTS hint table) is unchanged.
547
690
  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
- };
691
+ return toolError(
692
+ [
693
+ 'Authentication failed: consent was not granted (AADSTS65001).',
694
+ '',
695
+ `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.`,
696
+ 'If your organisation will not consent to them, unset OUTLOOK_SHARED_MAILBOX and restart the server to sign in with the standard scopes.',
697
+ 'No scopes were changed — your configured capability is unchanged.',
698
+ ].join('\n')
699
+ );
562
700
  }
563
701
 
564
- return {
565
- content: [
566
- {
567
- type: 'text',
568
- text: `Authentication failed: ${error.message}`,
569
- },
570
- ],
571
- };
702
+ return toolError(`Authentication failed: ${error.message}`);
572
703
  }
573
704
  }
574
705
 
@@ -577,7 +708,7 @@ async function handleDeviceCodeComplete() {
577
708
  * @returns {object} - MCP response
578
709
  */
579
710
  async function handleCheckAuthStatus() {
580
- console.error('[CHECK-AUTH-STATUS] Starting authentication status check');
711
+ log.debug('[CHECK-AUTH-STATUS] Starting authentication status check');
581
712
 
582
713
  // Use TokenStorage for accurate status (includes refresh attempt)
583
714
  const TokenStorage = require('./token-storage');
@@ -592,9 +723,13 @@ async function handleCheckAuthStatus() {
592
723
  const accessToken = await tokenStorage.getValidAccessToken();
593
724
 
594
725
  if (!accessToken) {
595
- console.error('[CHECK-AUTH-STATUS] No valid access token');
726
+ log.debug('[CHECK-AUTH-STATUS] No valid access token');
727
+ const text =
728
+ getClientIdSource() === 'none'
729
+ ? `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}`
730
+ : 'Not authenticated';
596
731
  return {
597
- content: [{ type: 'text', text: 'Not authenticated' }],
732
+ content: [{ type: 'text', text }],
598
733
  };
599
734
  }
600
735
 
@@ -603,7 +738,7 @@ async function handleCheckAuthStatus() {
603
738
  ? Math.round((expiresAt - Date.now()) / 60000)
604
739
  : 'unknown';
605
740
 
606
- console.error(
741
+ log.debug(
607
742
  `[CHECK-AUTH-STATUS] Authenticated, token expires in ~${expiresIn} min`
608
743
  );
609
744
 
@@ -622,13 +757,8 @@ const authTools = [
622
757
  {
623
758
  name: 'auth',
624
759
  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.',
626
- annotations: {
627
- title: 'Authentication',
628
- readOnlyHint: false,
629
- destructiveHint: false,
630
- openWorldHint: false,
631
- },
760
+ 'Manage authentication with the Microsoft Graph API. action=`status` (default) returns the current auth state, refreshing an expired access token while the refresh token is still valid (~90 days); call it first to check before other tools. action=`authenticate` starts sign-in: `method: "device-code"` (default, works headlessly) returns a code + URL for the user to visit; `method: "browser"` uses the local auth server on :3333 (run `npm run auth-server` first). `force: true` re-authenticates 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 sign-in once the browser shows it succeeded. action=`about` returns server version, configured audience, scope list and other diagnostics. Tokens persist to `~/.outlook-assistant-tokens.json` and survive server restarts.',
761
+ ...toolMetadata('auth', 'Authentication'),
632
762
  inputSchema: {
633
763
  type: 'object',
634
764
  properties: {
@@ -648,6 +778,11 @@ const authTools = [
648
778
  description:
649
779
  'Force re-authentication even if already authenticated (action=authenticate only)',
650
780
  },
781
+ clientId: {
782
+ type: 'string',
783
+ description:
784
+ "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.",
785
+ },
651
786
  },
652
787
  additionalProperties: false,
653
788
  required: [],
@@ -664,14 +799,9 @@ const authTools = [
664
799
  case 'status':
665
800
  return handleCheckAuthStatus();
666
801
  default:
667
- return {
668
- content: [
669
- {
670
- type: 'text',
671
- text: `Unknown action '${action}'. Valid actions: status, authenticate, device-code-complete, about.`,
672
- },
673
- ],
674
- };
802
+ return toolError(
803
+ `Unknown action '${action}'. Valid actions: status, authenticate, device-code-complete, about.`
804
+ );
675
805
  }
676
806
  },
677
807
  },
@@ -9,6 +9,9 @@
9
9
  * new address. An explicit type always wins.
10
10
  */
11
11
 
12
+ const { findBlockedRecipients } = require('../utils/safety');
13
+ const { toolError } = require('../utils/tool-error');
14
+
12
15
  const ATTENDEE_TYPES = ['required', 'optional', 'resource'];
13
16
  const ATTENDEE_FIELDS = new Set(['email', 'type']);
14
17
 
@@ -93,8 +96,41 @@ function buildAttendees(list, current = []) {
93
96
  }));
94
97
  }
95
98
 
99
+ /**
100
+ * Refuse an attendee list that OUTLOOK_ALLOWED_RECIPIENTS doesn't fully
101
+ * allow: Graph emails every attendee (rooms and resources included) an
102
+ * invitation or update carrying the event body. The whole call is refused,
103
+ * never sent with the blocked attendees dropped, and a dry run reports the
104
+ * refusal too. Needs no Graph call.
105
+ * @param {Array<{emailAddress: {address: string}}>} attendees - Graph attendees
106
+ * @param {{operation?: 'create'|'update', dryRun?: boolean}} [options]
107
+ * @returns {object|null} - toolError naming the blocked addresses, or null
108
+ */
109
+ function checkAttendeeAllowlist(
110
+ attendees,
111
+ { operation = 'create', dryRun = false } = {}
112
+ ) {
113
+ if (!attendees || attendees.length === 0) return null;
114
+ const result = findBlockedRecipients(attendees);
115
+ if (!result) return null;
116
+
117
+ const subject = operation === 'update' ? 'Event update' : 'Event';
118
+ const lead = dryRun
119
+ ? `DRY RUN — ${subject.toLowerCase()} would be refused`
120
+ : `${subject} refused`;
121
+ const outcome = operation === 'update' ? 'changed' : 'created';
122
+ return toolError(
123
+ `${lead}: OUTLOOK_ALLOWED_RECIPIENTS does not allow attendee ${result.blocked.join(', ')} (allowed recipients/domains: ${result.allowed.join(', ')}). Graph emails every attendee, so nothing was ${outcome}; the call is refused whole rather than sent without them.`,
124
+ {
125
+ nextStep:
126
+ 'Remove those attendees and retry, or ask the user to add them to OUTLOOK_ALLOWED_RECIPIENTS in the server configuration and restart the server.',
127
+ }
128
+ );
129
+ }
130
+
96
131
  module.exports = {
97
132
  ATTENDEE_TYPES,
133
+ checkAttendeeAllowlist,
98
134
  normaliseAttendeeInput,
99
135
  normaliseAttendees,
100
136
  buildAttendees,
@@ -3,6 +3,8 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
+ const { toolError, authRequiredError } = require('../utils/tool-error');
7
+ const { previewCancelEvent } = require('./preview');
6
8
 
7
9
  /**
8
10
  * Cancel event handler
@@ -10,23 +12,19 @@ const { ensureAuthenticated } = require('../auth');
10
12
  * @returns {object} - MCP response
11
13
  */
12
14
  async function handleCancelEvent(args) {
13
- const { eventId, comment } = args;
15
+ const { eventId, comment, dryRun = false } = args;
14
16
 
15
17
  if (!eventId) {
16
- return {
17
- content: [
18
- {
19
- type: 'text',
20
- text: 'Event ID is required to cancel an event.',
21
- },
22
- ],
23
- };
18
+ return toolError('Event ID is required to cancel an event.');
24
19
  }
25
20
 
26
21
  try {
27
22
  // Get access token
28
23
  const accessToken = await ensureAuthenticated();
29
24
 
25
+ // dryRun: read the event and say who would be emailed; send nothing.
26
+ if (dryRun) return await previewCancelEvent(accessToken, args);
27
+
30
28
  // Build API endpoint
31
29
  const endpoint = `me/events/${eventId}/cancel`;
32
30
 
@@ -49,24 +47,10 @@ async function handleCancelEvent(args) {
49
47
  };
50
48
  } catch (error) {
51
49
  if (error.message === 'Authentication required') {
52
- return {
53
- content: [
54
- {
55
- type: 'text',
56
- text: "Authentication required. Please use the 'authenticate' tool first.",
57
- },
58
- ],
59
- };
50
+ return authRequiredError();
60
51
  }
61
52
 
62
- return {
63
- content: [
64
- {
65
- type: 'text',
66
- text: `Error cancelling event: ${error.message}`,
67
- },
68
- ],
69
- };
53
+ return toolError(`Error cancelling event: ${error.message}`);
70
54
  }
71
55
  }
72
56