@littlebearapps/outlook-assistant 3.13.0 → 3.14.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.
Files changed (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
@@ -5,6 +5,8 @@ const _fs = require('fs'); // Reserved for future HTTPS support
5
5
  const crypto = require('crypto'); // Added for generating random string
6
6
  const TokenStorage = require('./token-storage'); // Assuming TokenStorage is in the same directory
7
7
  const { loadSavedClientId } = require('./client-config');
8
+ const { authErrorLogLabel } = require('./auth-errors');
9
+ const { log } = require('../utils/logger');
8
10
 
9
11
  // HTML templates
10
12
  function escapeHtml(unsafe) {
@@ -197,7 +199,11 @@ function setupOAuthRoutes(
197
199
  await tokenStorage.exchangeCodeForTokens(code);
198
200
  res.send(templates.authSuccess);
199
201
  } catch (exchangeError) {
200
- console.error('Token exchange error:', exchangeError);
202
+ log.note(
203
+ 'auth',
204
+ authErrorLogLabel('token-exchange-failed', exchangeError)
205
+ );
206
+ log.debug('Token exchange error:', exchangeError);
201
207
  res.status(500).send(templates.tokenExchangeError(exchangeError));
202
208
  }
203
209
  });
@@ -3,6 +3,7 @@
3
3
  */
4
4
  const fs = require('fs');
5
5
  const config = require('../config');
6
+ const { log } = require('../utils/logger');
6
7
 
7
8
  // Global variable to store tokens
8
9
  let cachedTokens = null;
@@ -38,11 +39,13 @@ function loadTokenCache() {
38
39
  cachedTokens = tokens;
39
40
  return tokens;
40
41
  } catch (parseError) {
41
- console.error('Error parsing token file:', parseError.message);
42
+ log.note('auth', 'token-cache-unreadable');
43
+ log.debug('Error parsing token file:', parseError.message);
42
44
  return null;
43
45
  }
44
46
  } catch (error) {
45
- console.error('Error loading token cache:', error.message);
47
+ log.note('auth', 'token-cache-unreadable');
48
+ log.debug('Error loading token cache:', error.message);
46
49
  return null;
47
50
  }
48
51
  }
@@ -64,7 +67,8 @@ function saveTokenCache(tokens) {
64
67
  cachedTokens = tokens;
65
68
  return true;
66
69
  } catch (error) {
67
- console.error('Error saving token cache:', error);
70
+ log.note('auth', 'token-cache-save-failed');
71
+ log.debug('Error saving token cache:', error);
68
72
  return false;
69
73
  }
70
74
  }
@@ -3,8 +3,9 @@ const fsSync = require('fs');
3
3
  const path = require('path');
4
4
  const https = require('https');
5
5
  const querystring = require('querystring');
6
- const { describeAuthError } = require('./auth-errors');
6
+ const { describeAuthError, authErrorLogLabel } = require('./auth-errors');
7
7
  const { resolveClientId } = require('./client-config');
8
+ const { log } = require('../utils/logger');
8
9
 
9
10
  /**
10
11
  * Decide which scopes a refresh request should use. Prefer the scopes that were
@@ -108,7 +109,8 @@ class TokenStorage {
108
109
  return this.tokens;
109
110
  } catch (error) {
110
111
  if (error.code !== 'ENOENT') {
111
- console.error('Error loading token cache:', error.message);
112
+ log.note('auth', 'token-cache-unreadable');
113
+ log.debug('Error loading token cache:', error.message);
112
114
  }
113
115
  this.tokens = null;
114
116
  return null;
@@ -126,7 +128,8 @@ class TokenStorage {
126
128
  { mode: 0o600 }
127
129
  );
128
130
  } catch (error) {
129
- console.error('Error saving token cache:', error.message);
131
+ log.note('auth', 'token-cache-save-failed');
132
+ log.debug('Error saving token cache:', error.message);
130
133
  throw error;
131
134
  }
132
135
  }
@@ -161,19 +164,20 @@ class TokenStorage {
161
164
  await this.getTokens(); // Ensure tokens are loaded
162
165
 
163
166
  if (!this.tokens || !this.tokens.access_token) {
164
- console.error('No access token available.');
167
+ log.debug('No access token available.');
165
168
  return null;
166
169
  }
167
170
 
168
171
  if (this.isTokenExpired()) {
169
- console.error(
172
+ log.debug(
170
173
  'Access token expired or nearing expiration. Attempting refresh.'
171
174
  );
172
175
  if (this.tokens.refresh_token) {
173
176
  try {
174
177
  return await this.refreshAccessToken();
175
178
  } catch (refreshError) {
176
- console.error('Failed to refresh access token:', refreshError);
179
+ log.note('auth', authErrorLogLabel('refresh-failed', refreshError));
180
+ log.debug('Failed to refresh access token:', refreshError);
177
181
  // Drop the in-memory tokens so callers re-authenticate. The save
178
182
  // below is intentionally a no-op — `_saveTokensToFile` returns early
179
183
  // when `tokens` is null — which is the behaviour we want: a transient
@@ -184,9 +188,8 @@ class TokenStorage {
184
188
  return null;
185
189
  }
186
190
  } else {
187
- console.warn(
188
- 'No refresh token available. Cannot refresh access token.'
189
- );
191
+ log.note('auth', 'no-refresh-token');
192
+ log.debug('No refresh token available. Cannot refresh access token.');
190
193
  // Same as above: clears memory, leaves the file alone. (#72)
191
194
  this.tokens = null;
192
195
  await this._saveTokensToFile();
@@ -205,7 +208,7 @@ class TokenStorage {
205
208
 
206
209
  // Prevent multiple concurrent refresh attempts
207
210
  if (this._refreshPromise) {
208
- console.error('Refresh already in progress, returning existing promise.');
211
+ log.debug('Refresh already in progress, returning existing promise.');
209
212
  return this._refreshPromise.then((tokens) => tokens.access_token);
210
213
  }
211
214
 
@@ -213,7 +216,7 @@ class TokenStorage {
213
216
  // in refresh requests for tokens obtained via device code.
214
217
  // Browser flow (confidential client) requires client_secret.
215
218
  const isDeviceCode = this.tokens.auth_method === 'device-code';
216
- console.error(
219
+ log.debug(
217
220
  `Attempting to refresh access token (auth_method: ${this.tokens.auth_method || 'browser'})...`
218
221
  );
219
222
 
@@ -259,12 +262,10 @@ class TokenStorage {
259
262
  Date.now() + responseBody.expires_in * 1000;
260
263
  try {
261
264
  await this._saveTokensToFile();
262
- console.error(
263
- 'Access token refreshed and saved successfully.'
264
- );
265
+ log.debug('Access token refreshed and saved successfully.');
265
266
  resolve(this.tokens);
266
267
  } catch (saveError) {
267
- console.error('Failed to save refreshed tokens:', saveError);
268
+ log.debug('Failed to save refreshed tokens:', saveError);
268
269
  // Even if save fails, tokens are updated in memory.
269
270
  // Depending on desired strictness, could reject here.
270
271
  // For now, resolve with in-memory tokens but log critical error.
@@ -276,7 +277,7 @@ class TokenStorage {
276
277
  );
277
278
  }
278
279
  } else {
279
- console.error('Error refreshing token:', responseBody);
280
+ log.debug('Error refreshing token:', responseBody);
280
281
  reject(
281
282
  new Error(
282
283
  describeAuthError(
@@ -288,7 +289,7 @@ class TokenStorage {
288
289
  }
289
290
  } catch (e) {
290
291
  // Catch any error during parsing or saving
291
- console.error(
292
+ log.debug(
292
293
  'Error processing refresh token response or saving tokens:',
293
294
  e
294
295
  );
@@ -300,7 +301,7 @@ class TokenStorage {
300
301
  }
301
302
  );
302
303
  req.on('error', (error) => {
303
- console.error('HTTP error during token refresh:', error);
304
+ log.debug('HTTP error during token refresh:', error);
304
305
  reject(error);
305
306
  this._refreshPromise = null; // Clear promise on error
306
307
  });
@@ -318,7 +319,7 @@ class TokenStorage {
318
319
  'Client ID or Client Secret is not configured. Cannot exchange code for tokens.'
319
320
  );
320
321
  }
321
- console.error('Exchanging authorization code for tokens...');
322
+ log.debug('Exchanging authorization code for tokens...');
322
323
  const requestedScopes = this.config.scopes;
323
324
  const postData = querystring.stringify({
324
325
  client_id: clientId,
@@ -366,10 +367,10 @@ class TokenStorage {
366
367
  };
367
368
  try {
368
369
  await this._saveTokensToFile();
369
- console.error('Tokens exchanged and saved successfully.');
370
+ log.debug('Tokens exchanged and saved successfully.');
370
371
  resolve(this.tokens);
371
372
  } catch (saveError) {
372
- console.error('Failed to save exchanged tokens:', saveError);
373
+ log.debug('Failed to save exchanged tokens:', saveError);
373
374
  // Similar to refresh, tokens are in memory but not persisted.
374
375
  // Rejecting to indicate the operation wasn't fully successful.
375
376
  reject(
@@ -379,10 +380,7 @@ class TokenStorage {
379
380
  );
380
381
  }
381
382
  } else {
382
- console.error(
383
- 'Error exchanging code for tokens:',
384
- responseBody
385
- );
383
+ log.debug('Error exchanging code for tokens:', responseBody);
386
384
  reject(
387
385
  new Error(
388
386
  describeAuthError(
@@ -394,7 +392,7 @@ class TokenStorage {
394
392
  }
395
393
  } catch (e) {
396
394
  // Catch any error during parsing or saving
397
- console.error(
395
+ log.debug(
398
396
  'Error processing token exchange response or saving tokens:',
399
397
  e,
400
398
  'Raw data:',
@@ -410,7 +408,7 @@ class TokenStorage {
410
408
  }
411
409
  );
412
410
  req.on('error', (error) => {
413
- console.error('HTTP error during code exchange:', error);
411
+ log.debug('HTTP error during code exchange:', error);
414
412
  reject(error);
415
413
  });
416
414
  req.write(postData);
@@ -423,12 +421,12 @@ class TokenStorage {
423
421
  this.tokens = null;
424
422
  try {
425
423
  await fs.unlink(this.config.tokenStorePath);
426
- console.error('Token file deleted successfully.');
424
+ log.debug('Token file deleted successfully.');
427
425
  } catch (error) {
428
426
  if (error.code === 'ENOENT') {
429
- console.error('Token file not found, nothing to delete.');
427
+ log.debug('Token file not found, nothing to delete.');
430
428
  } else {
431
- console.error('Error deleting token file:', error);
429
+ log.debug('Error deleting token file:', error);
432
430
  }
433
431
  }
434
432
  }
package/auth/tools.js CHANGED
@@ -2,7 +2,7 @@
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');
@@ -19,6 +19,14 @@ const {
19
19
  isScopeConsentError,
20
20
  isConsentRequiredError,
21
21
  } = require('./device-code');
22
+ const { toolMetadata } = require('../utils/risk-classes');
23
+ const { toolError } = require('../utils/tool-error');
24
+ const {
25
+ describeSessionLimits,
26
+ resolveSessionLimit,
27
+ RATE_LIMITED_TOOLS,
28
+ } = require('../utils/safety');
29
+ const { log } = require('../utils/logger');
22
30
 
23
31
  // Path for persisting device code state across MCP server restarts
24
32
  const DEVICE_CODE_STATE_PATH = path.join(
@@ -190,12 +198,15 @@ async function handleAbout() {
190
198
  (s) => s !== 'offline_access'
191
199
  );
192
200
  const testMode = config.USE_TEST_MODE ? 'Enabled' : 'Disabled';
193
- const rateLimitConfigured = Boolean(
194
- process.env.OUTLOOK_MAX_EMAILS_PER_SESSION
201
+ // A cap on any rate-limited tool counts as configured (#302).
202
+ const sessionLimits = describeSessionLimits();
203
+ const rateLimitConfigured = Object.keys(RATE_LIMITED_TOOLS).some(
204
+ (tool) => resolveSessionLimit(tool).limit !== null
195
205
  );
196
206
  const allowlistConfigured = Boolean(process.env.OUTLOOK_ALLOWED_RECIPIENTS);
197
- const rateLimit =
198
- process.env.OUTLOOK_MAX_EMAILS_PER_SESSION || 'Unlimited (no limit set)';
207
+ const rateLimit = rateLimitConfigured
208
+ ? sessionLimits.join('; ')
209
+ : 'Unlimited (no limit set; 0 would block)';
199
210
  const allowlist =
200
211
  process.env.OUTLOOK_ALLOWED_RECIPIENTS || 'None (all recipients allowed)';
201
212
 
@@ -240,8 +251,9 @@ async function handleAbout() {
240
251
  `| Modules | auth, email, calendar, folder, rules, contacts, categories, settings, advanced |`,
241
252
  `| Timezone | ${config.DEFAULT_TIMEZONE} |`,
242
253
  `| Test Mode | ${testMode} |`,
243
- `| Rate Limit | ${rateLimit} |`,
254
+ `| Session limits | ${rateLimit} |`,
244
255
  `| Recipient Allowlist | ${allowlist} |`,
256
+ `| 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)'} |`,
245
257
  `| Scopes | ${scopes.length} configured |`,
246
258
  `| Shared mailboxes | ${describeSharedMailboxStatus(granted)} |`,
247
259
  ``,
@@ -260,7 +272,9 @@ async function handleAbout() {
260
272
  );
261
273
  lines.push('```');
262
274
  if (!rateLimitConfigured) {
263
- lines.push('OUTLOOK_MAX_EMAILS_PER_SESSION=10');
275
+ lines.push(
276
+ 'OUTLOOK_MAX_EMAILS_PER_SESSION=10 # 0 blocks sending; unset = no limit'
277
+ );
264
278
  }
265
279
  if (!allowlistConfigured) {
266
280
  lines.push(
@@ -370,7 +384,7 @@ function saveDeviceCodeState(state) {
370
384
  fs.unlinkSync(DEVICE_CODE_STATE_PATH);
371
385
  }
372
386
  } catch (error) {
373
- console.error(
387
+ log.debug(
374
388
  `[AUTH] Failed to ${state ? 'save' : 'clean up'} device code state: ${error.message}`
375
389
  );
376
390
  }
@@ -388,13 +402,13 @@ function loadDeviceCodeState() {
388
402
  }
389
403
  const state = JSON.parse(fs.readFileSync(DEVICE_CODE_STATE_PATH, 'utf8'));
390
404
  if (Date.now() > state.expiresAt) {
391
- console.error('[AUTH] Persisted device code has expired, cleaning up');
405
+ log.debug('[AUTH] Persisted device code has expired, cleaning up');
392
406
  saveDeviceCodeState(null);
393
407
  return null;
394
408
  }
395
409
  return state;
396
410
  } catch (error) {
397
- console.error(`[AUTH] Failed to load device code state: ${error.message}`);
411
+ log.debug(`[AUTH] Failed to load device code state: ${error.message}`);
398
412
  return null;
399
413
  }
400
414
  }
@@ -412,7 +426,7 @@ async function handleDeviceCodeAuth(prefix) {
412
426
  return buildMissingClientIdResponse();
413
427
  }
414
428
 
415
- console.error('[AUTH] Starting device code flow...');
429
+ log.debug('[AUTH] Starting device code flow...');
416
430
  // Attempt the configured scope set (base, plus `.Shared` when
417
431
  // OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
418
432
  // `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
@@ -449,7 +463,7 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
449
463
  !isConsentRequiredError(error) &&
450
464
  isScopeConsentError(error)
451
465
  ) {
452
- console.error(
466
+ log.debug(
453
467
  '[AUTH] Shared-mailbox scopes rejected at device-code request; retrying with base scopes.'
454
468
  );
455
469
  return initiateDeviceCode(
@@ -473,8 +487,9 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
473
487
  };
474
488
  saveDeviceCodeState(pendingDeviceCode);
475
489
 
476
- console.error(
477
- `[AUTH] Device code (${scopesUsed} scopes): ${response.userCode}, expires in ${response.expiresIn}s`
490
+ // Never log the user code: it is for the user (via the tool result) only.
491
+ log.debug(
492
+ `[AUTH] Device code issued (${scopesUsed} scopes), expires in ${response.expiresIn}s`
478
493
  );
479
494
 
480
495
  const lines = [];
@@ -532,7 +547,8 @@ function buildDeviceCodeErrorResponse(error) {
532
547
  lines.push('', 'Suggested fixes:', ...hints.map((h) => `- ${h}`));
533
548
  }
534
549
 
535
- console.error(`[AUTH] Device code initiation failed: ${msg}`);
550
+ log.note('auth', authErrorLogLabel('device-code-failed', error));
551
+ log.debug(`[AUTH] Device code initiation failed: ${msg}`);
536
552
 
537
553
  return {
538
554
  content: [{ type: 'text', text: lines.join('\n') }],
@@ -552,27 +568,17 @@ async function handleDeviceCodeComplete() {
552
568
  }
553
569
 
554
570
  if (!pendingDeviceCode) {
555
- return {
556
- content: [
557
- {
558
- type: 'text',
559
- text: 'No pending device code flow. Call authenticate with method=device-code first.',
560
- },
561
- ],
562
- };
571
+ return toolError('No pending device code flow.', {
572
+ nextStep: 'Start one with the `auth` tool with action=authenticate.',
573
+ });
563
574
  }
564
575
 
565
576
  if (Date.now() > pendingDeviceCode.expiresAt) {
566
577
  pendingDeviceCode = null;
567
578
  saveDeviceCodeState(null);
568
- return {
569
- content: [
570
- {
571
- type: 'text',
572
- text: 'Device code has expired. Please start a new authentication with action=authenticate.',
573
- },
574
- ],
575
- };
579
+ return toolError(
580
+ 'Device code has expired. Please start a new authentication with action=authenticate.'
581
+ );
576
582
  }
577
583
 
578
584
  // Poll with the client ID the code was issued to (older state files don't
@@ -593,7 +599,7 @@ async function handleDeviceCodeComplete() {
593
599
  : config.AUTH_CONFIG.scopes;
594
600
 
595
601
  try {
596
- console.error('[AUTH] Polling for device code completion...');
602
+ log.debug('[AUTH] Polling for device code completion...');
597
603
  const tokenResponse = await pollForToken(
598
604
  clientId,
599
605
  pendingDeviceCode.deviceCode,
@@ -633,7 +639,7 @@ async function handleDeviceCodeComplete() {
633
639
  };
634
640
  await tokenStorage._saveTokensToFile();
635
641
 
636
- console.error('[AUTH] Device code flow completed successfully.');
642
+ log.debug('[AUTH] Device code flow completed successfully.');
637
643
 
638
644
  return {
639
645
  content: [
@@ -656,7 +662,7 @@ async function handleDeviceCodeComplete() {
656
662
  !isConsentRequiredError(error) &&
657
663
  isScopeConsentError(error)
658
664
  ) {
659
- console.error(
665
+ log.debug(
660
666
  '[AUTH] Shared-mailbox scopes rejected; falling back to base scopes.'
661
667
  );
662
668
  // Do NOT clear pendingDeviceCode — initiateDeviceCode replaces it.
@@ -677,19 +683,14 @@ async function handleDeviceCodeComplete() {
677
683
  } catch (reissueError) {
678
684
  pendingDeviceCode = null;
679
685
  saveDeviceCodeState(null);
680
- return {
681
- content: [
682
- {
683
- type: 'text',
684
- text: `Authentication failed: ${reissueError.message}`,
685
- },
686
- ],
687
- };
686
+ return toolError(`Authentication failed: ${reissueError.message}`);
688
687
  }
689
688
  }
690
689
 
691
690
  pendingDeviceCode = null;
692
691
  saveDeviceCodeState(null);
692
+ log.note('auth', authErrorLogLabel('device-code-complete-failed', error));
693
+ log.debug(`[AUTH] Device code completion failed: ${error.message}`);
693
694
 
694
695
  // Consent required (AADSTS65001) — remediable, so surface it instead of
695
696
  // silently downgrading to base scopes (which would strip shared-mailbox
@@ -697,30 +698,18 @@ async function handleDeviceCodeComplete() {
697
698
  // Only when the shared scopes were requested — otherwise the generic
698
699
  // path below (with its AADSTS hint table) is unchanged.
699
700
  if (config.SHARED_SCOPES.length > 0 && isConsentRequiredError(error)) {
700
- return {
701
- content: [
702
- {
703
- type: 'text',
704
- text: [
705
- 'Authentication failed: consent was not granted (AADSTS65001).',
706
- '',
707
- `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.`,
708
- 'If your organisation will not consent to them, unset OUTLOOK_SHARED_MAILBOX and restart the server to sign in with the standard scopes.',
709
- 'No scopes were changed — your configured capability is unchanged.',
710
- ].join('\n'),
711
- },
712
- ],
713
- };
701
+ return toolError(
702
+ [
703
+ 'Authentication failed: consent was not granted (AADSTS65001).',
704
+ '',
705
+ `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.`,
706
+ 'If your organisation will not consent to them, unset OUTLOOK_SHARED_MAILBOX and restart the server to sign in with the standard scopes.',
707
+ 'No scopes were changed — your configured capability is unchanged.',
708
+ ].join('\n')
709
+ );
714
710
  }
715
711
 
716
- return {
717
- content: [
718
- {
719
- type: 'text',
720
- text: `Authentication failed: ${error.message}`,
721
- },
722
- ],
723
- };
712
+ return toolError(`Authentication failed: ${error.message}`);
724
713
  }
725
714
  }
726
715
 
@@ -729,7 +718,7 @@ async function handleDeviceCodeComplete() {
729
718
  * @returns {object} - MCP response
730
719
  */
731
720
  async function handleCheckAuthStatus() {
732
- console.error('[CHECK-AUTH-STATUS] Starting authentication status check');
721
+ log.debug('[CHECK-AUTH-STATUS] Starting authentication status check');
733
722
 
734
723
  // Use TokenStorage for accurate status (includes refresh attempt)
735
724
  const TokenStorage = require('./token-storage');
@@ -744,7 +733,7 @@ async function handleCheckAuthStatus() {
744
733
  const accessToken = await tokenStorage.getValidAccessToken();
745
734
 
746
735
  if (!accessToken) {
747
- console.error('[CHECK-AUTH-STATUS] No valid access token');
736
+ log.debug('[CHECK-AUTH-STATUS] No valid access token');
748
737
  const text =
749
738
  getClientIdSource() === 'none'
750
739
  ? `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}`
@@ -759,7 +748,7 @@ async function handleCheckAuthStatus() {
759
748
  ? Math.round((expiresAt - Date.now()) / 60000)
760
749
  : 'unknown';
761
750
 
762
- console.error(
751
+ log.debug(
763
752
  `[CHECK-AUTH-STATUS] Authenticated, token expires in ~${expiresIn} min`
764
753
  );
765
754
 
@@ -778,13 +767,8 @@ const authTools = [
778
767
  {
779
768
  name: 'auth',
780
769
  description:
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.',
782
- annotations: {
783
- title: 'Authentication',
784
- readOnlyHint: false,
785
- destructiveHint: false,
786
- openWorldHint: false,
787
- },
770
+ '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.',
771
+ ...toolMetadata('auth', 'Authentication'),
788
772
  inputSchema: {
789
773
  type: 'object',
790
774
  properties: {
@@ -825,14 +809,9 @@ const authTools = [
825
809
  case 'status':
826
810
  return handleCheckAuthStatus();
827
811
  default:
828
- return {
829
- content: [
830
- {
831
- type: 'text',
832
- text: `Unknown action '${action}'. Valid actions: status, authenticate, device-code-complete, about.`,
833
- },
834
- ],
835
- };
812
+ return toolError(
813
+ `Unknown action '${action}'. Valid actions: status, authenticate, device-code-complete, about.`
814
+ );
836
815
  }
837
816
  },
838
817
  },
@@ -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