@littlebearapps/outlook-assistant 3.13.0 → 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.
- package/.env.example +27 -3
- package/README.md +66 -26
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/oauth-server.js +7 -1
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +28 -30
- package/auth/tools.js +45 -76
- package/calendar/attendees.js +36 -0
- package/calendar/cancel.js +9 -25
- package/calendar/create.js +42 -48
- package/calendar/decline.js +10 -25
- package/calendar/delete.js +10 -25
- package/calendar/index.js +20 -37
- package/calendar/list.js +4 -16
- package/calendar/preview.js +335 -0
- package/calendar/update.js +42 -86
- package/categories/index.js +59 -264
- package/config.js +29 -1
- package/contacts/index.js +72 -128
- package/email/attachments.js +42 -124
- package/email/conversations.js +44 -78
- package/email/delta.js +10 -34
- package/email/draft.js +140 -96
- package/email/export.js +141 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +85 -109
- package/email/list.js +4 -17
- package/email/mail-tips.js +86 -57
- package/email/mark-as-read.js +13 -49
- package/email/mime.js +14 -49
- package/email/read.js +16 -50
- package/email/search.js +46 -86
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +17 -16
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +6 -20
- package/index.js +19 -43
- package/llms-install.md +22 -4
- package/llms.txt +17 -8
- package/outlook-auth-server.js +10 -3
- package/package.json +4 -1
- package/request-handler.js +217 -116
- package/rules/create.js +27 -70
- package/rules/index.js +30 -92
- package/rules/list.js +5 -17
- package/rules/rule-builder.js +57 -20
- package/rules/update.js +26 -60
- package/server.js +37 -0
- package/settings/index.js +142 -143
- package/tools.js +30 -0
- package/utils/field-presets.js +4 -2
- package/utils/graph-api.js +65 -22
- package/utils/logger.js +251 -0
- package/utils/mock-data.js +91 -2
- package/utils/read-only.js +59 -0
- package/utils/response-formatter.js +54 -15
- package/utils/risk-classes.js +324 -0
- package/utils/safe-write.js +372 -6
- package/utils/safety.js +109 -25
- package/utils/server-instructions.js +62 -0
- package/utils/tool-error.js +33 -0
package/auth/token-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
log.note('auth', 'token-cache-save-failed');
|
|
71
|
+
log.debug('Error saving token cache:', error);
|
|
68
72
|
return false;
|
|
69
73
|
}
|
|
70
74
|
}
|
package/auth/token-storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
log.debug('No access token available.');
|
|
165
168
|
return null;
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
if (this.isTokenExpired()) {
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
+
log.debug('Tokens exchanged and saved successfully.');
|
|
370
371
|
resolve(this.tokens);
|
|
371
372
|
} catch (saveError) {
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
+
log.debug('Token file deleted successfully.');
|
|
427
425
|
} catch (error) {
|
|
428
426
|
if (error.code === 'ENOENT') {
|
|
429
|
-
|
|
427
|
+
log.debug('Token file not found, nothing to delete.');
|
|
430
428
|
} else {
|
|
431
|
-
|
|
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,9 @@ 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 { log } = require('../utils/logger');
|
|
22
25
|
|
|
23
26
|
// Path for persisting device code state across MCP server restarts
|
|
24
27
|
const DEVICE_CODE_STATE_PATH = path.join(
|
|
@@ -242,6 +245,7 @@ async function handleAbout() {
|
|
|
242
245
|
`| Test Mode | ${testMode} |`,
|
|
243
246
|
`| Rate Limit | ${rateLimit} |`,
|
|
244
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)'} |`,
|
|
245
249
|
`| Scopes | ${scopes.length} configured |`,
|
|
246
250
|
`| Shared mailboxes | ${describeSharedMailboxStatus(granted)} |`,
|
|
247
251
|
``,
|
|
@@ -370,7 +374,7 @@ function saveDeviceCodeState(state) {
|
|
|
370
374
|
fs.unlinkSync(DEVICE_CODE_STATE_PATH);
|
|
371
375
|
}
|
|
372
376
|
} catch (error) {
|
|
373
|
-
|
|
377
|
+
log.debug(
|
|
374
378
|
`[AUTH] Failed to ${state ? 'save' : 'clean up'} device code state: ${error.message}`
|
|
375
379
|
);
|
|
376
380
|
}
|
|
@@ -388,13 +392,13 @@ function loadDeviceCodeState() {
|
|
|
388
392
|
}
|
|
389
393
|
const state = JSON.parse(fs.readFileSync(DEVICE_CODE_STATE_PATH, 'utf8'));
|
|
390
394
|
if (Date.now() > state.expiresAt) {
|
|
391
|
-
|
|
395
|
+
log.debug('[AUTH] Persisted device code has expired, cleaning up');
|
|
392
396
|
saveDeviceCodeState(null);
|
|
393
397
|
return null;
|
|
394
398
|
}
|
|
395
399
|
return state;
|
|
396
400
|
} catch (error) {
|
|
397
|
-
|
|
401
|
+
log.debug(`[AUTH] Failed to load device code state: ${error.message}`);
|
|
398
402
|
return null;
|
|
399
403
|
}
|
|
400
404
|
}
|
|
@@ -412,7 +416,7 @@ async function handleDeviceCodeAuth(prefix) {
|
|
|
412
416
|
return buildMissingClientIdResponse();
|
|
413
417
|
}
|
|
414
418
|
|
|
415
|
-
|
|
419
|
+
log.debug('[AUTH] Starting device code flow...');
|
|
416
420
|
// Attempt the configured scope set (base, plus `.Shared` when
|
|
417
421
|
// OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
|
|
418
422
|
// `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
|
|
@@ -449,7 +453,7 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
|
|
|
449
453
|
!isConsentRequiredError(error) &&
|
|
450
454
|
isScopeConsentError(error)
|
|
451
455
|
) {
|
|
452
|
-
|
|
456
|
+
log.debug(
|
|
453
457
|
'[AUTH] Shared-mailbox scopes rejected at device-code request; retrying with base scopes.'
|
|
454
458
|
);
|
|
455
459
|
return initiateDeviceCode(
|
|
@@ -473,8 +477,9 @@ async function initiateDeviceCode(scopes, scopesUsed, prefix) {
|
|
|
473
477
|
};
|
|
474
478
|
saveDeviceCodeState(pendingDeviceCode);
|
|
475
479
|
|
|
476
|
-
|
|
477
|
-
|
|
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`
|
|
478
483
|
);
|
|
479
484
|
|
|
480
485
|
const lines = [];
|
|
@@ -532,7 +537,8 @@ function buildDeviceCodeErrorResponse(error) {
|
|
|
532
537
|
lines.push('', 'Suggested fixes:', ...hints.map((h) => `- ${h}`));
|
|
533
538
|
}
|
|
534
539
|
|
|
535
|
-
|
|
540
|
+
log.note('auth', authErrorLogLabel('device-code-failed', error));
|
|
541
|
+
log.debug(`[AUTH] Device code initiation failed: ${msg}`);
|
|
536
542
|
|
|
537
543
|
return {
|
|
538
544
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
@@ -552,27 +558,17 @@ async function handleDeviceCodeComplete() {
|
|
|
552
558
|
}
|
|
553
559
|
|
|
554
560
|
if (!pendingDeviceCode) {
|
|
555
|
-
return {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
type: 'text',
|
|
559
|
-
text: 'No pending device code flow. Call authenticate with method=device-code first.',
|
|
560
|
-
},
|
|
561
|
-
],
|
|
562
|
-
};
|
|
561
|
+
return toolError('No pending device code flow.', {
|
|
562
|
+
nextStep: 'Start one with the `auth` tool with action=authenticate.',
|
|
563
|
+
});
|
|
563
564
|
}
|
|
564
565
|
|
|
565
566
|
if (Date.now() > pendingDeviceCode.expiresAt) {
|
|
566
567
|
pendingDeviceCode = null;
|
|
567
568
|
saveDeviceCodeState(null);
|
|
568
|
-
return
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
type: 'text',
|
|
572
|
-
text: 'Device code has expired. Please start a new authentication with action=authenticate.',
|
|
573
|
-
},
|
|
574
|
-
],
|
|
575
|
-
};
|
|
569
|
+
return toolError(
|
|
570
|
+
'Device code has expired. Please start a new authentication with action=authenticate.'
|
|
571
|
+
);
|
|
576
572
|
}
|
|
577
573
|
|
|
578
574
|
// Poll with the client ID the code was issued to (older state files don't
|
|
@@ -593,7 +589,7 @@ async function handleDeviceCodeComplete() {
|
|
|
593
589
|
: config.AUTH_CONFIG.scopes;
|
|
594
590
|
|
|
595
591
|
try {
|
|
596
|
-
|
|
592
|
+
log.debug('[AUTH] Polling for device code completion...');
|
|
597
593
|
const tokenResponse = await pollForToken(
|
|
598
594
|
clientId,
|
|
599
595
|
pendingDeviceCode.deviceCode,
|
|
@@ -633,7 +629,7 @@ async function handleDeviceCodeComplete() {
|
|
|
633
629
|
};
|
|
634
630
|
await tokenStorage._saveTokensToFile();
|
|
635
631
|
|
|
636
|
-
|
|
632
|
+
log.debug('[AUTH] Device code flow completed successfully.');
|
|
637
633
|
|
|
638
634
|
return {
|
|
639
635
|
content: [
|
|
@@ -656,7 +652,7 @@ async function handleDeviceCodeComplete() {
|
|
|
656
652
|
!isConsentRequiredError(error) &&
|
|
657
653
|
isScopeConsentError(error)
|
|
658
654
|
) {
|
|
659
|
-
|
|
655
|
+
log.debug(
|
|
660
656
|
'[AUTH] Shared-mailbox scopes rejected; falling back to base scopes.'
|
|
661
657
|
);
|
|
662
658
|
// Do NOT clear pendingDeviceCode — initiateDeviceCode replaces it.
|
|
@@ -677,19 +673,14 @@ async function handleDeviceCodeComplete() {
|
|
|
677
673
|
} catch (reissueError) {
|
|
678
674
|
pendingDeviceCode = null;
|
|
679
675
|
saveDeviceCodeState(null);
|
|
680
|
-
return {
|
|
681
|
-
content: [
|
|
682
|
-
{
|
|
683
|
-
type: 'text',
|
|
684
|
-
text: `Authentication failed: ${reissueError.message}`,
|
|
685
|
-
},
|
|
686
|
-
],
|
|
687
|
-
};
|
|
676
|
+
return toolError(`Authentication failed: ${reissueError.message}`);
|
|
688
677
|
}
|
|
689
678
|
}
|
|
690
679
|
|
|
691
680
|
pendingDeviceCode = null;
|
|
692
681
|
saveDeviceCodeState(null);
|
|
682
|
+
log.note('auth', authErrorLogLabel('device-code-complete-failed', error));
|
|
683
|
+
log.debug(`[AUTH] Device code completion failed: ${error.message}`);
|
|
693
684
|
|
|
694
685
|
// Consent required (AADSTS65001) — remediable, so surface it instead of
|
|
695
686
|
// silently downgrading to base scopes (which would strip shared-mailbox
|
|
@@ -697,30 +688,18 @@ async function handleDeviceCodeComplete() {
|
|
|
697
688
|
// Only when the shared scopes were requested — otherwise the generic
|
|
698
689
|
// path below (with its AADSTS hint table) is unchanged.
|
|
699
690
|
if (config.SHARED_SCOPES.length > 0 && isConsentRequiredError(error)) {
|
|
700
|
-
return
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
'No scopes were changed — your configured capability is unchanged.',
|
|
710
|
-
].join('\n'),
|
|
711
|
-
},
|
|
712
|
-
],
|
|
713
|
-
};
|
|
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
|
+
);
|
|
714
700
|
}
|
|
715
701
|
|
|
716
|
-
return {
|
|
717
|
-
content: [
|
|
718
|
-
{
|
|
719
|
-
type: 'text',
|
|
720
|
-
text: `Authentication failed: ${error.message}`,
|
|
721
|
-
},
|
|
722
|
-
],
|
|
723
|
-
};
|
|
702
|
+
return toolError(`Authentication failed: ${error.message}`);
|
|
724
703
|
}
|
|
725
704
|
}
|
|
726
705
|
|
|
@@ -729,7 +708,7 @@ async function handleDeviceCodeComplete() {
|
|
|
729
708
|
* @returns {object} - MCP response
|
|
730
709
|
*/
|
|
731
710
|
async function handleCheckAuthStatus() {
|
|
732
|
-
|
|
711
|
+
log.debug('[CHECK-AUTH-STATUS] Starting authentication status check');
|
|
733
712
|
|
|
734
713
|
// Use TokenStorage for accurate status (includes refresh attempt)
|
|
735
714
|
const TokenStorage = require('./token-storage');
|
|
@@ -744,7 +723,7 @@ async function handleCheckAuthStatus() {
|
|
|
744
723
|
const accessToken = await tokenStorage.getValidAccessToken();
|
|
745
724
|
|
|
746
725
|
if (!accessToken) {
|
|
747
|
-
|
|
726
|
+
log.debug('[CHECK-AUTH-STATUS] No valid access token');
|
|
748
727
|
const text =
|
|
749
728
|
getClientIdSource() === 'none'
|
|
750
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}`
|
|
@@ -759,7 +738,7 @@ async function handleCheckAuthStatus() {
|
|
|
759
738
|
? Math.round((expiresAt - Date.now()) / 60000)
|
|
760
739
|
: 'unknown';
|
|
761
740
|
|
|
762
|
-
|
|
741
|
+
log.debug(
|
|
763
742
|
`[CHECK-AUTH-STATUS] Authenticated, token expires in ~${expiresIn} min`
|
|
764
743
|
);
|
|
765
744
|
|
|
@@ -778,13 +757,8 @@ const authTools = [
|
|
|
778
757
|
{
|
|
779
758
|
name: 'auth',
|
|
780
759
|
description:
|
|
781
|
-
'Manage authentication with the Microsoft Graph API. action=`status` (default) returns the current auth state
|
|
782
|
-
|
|
783
|
-
title: 'Authentication',
|
|
784
|
-
readOnlyHint: false,
|
|
785
|
-
destructiveHint: false,
|
|
786
|
-
openWorldHint: false,
|
|
787
|
-
},
|
|
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'),
|
|
788
762
|
inputSchema: {
|
|
789
763
|
type: 'object',
|
|
790
764
|
properties: {
|
|
@@ -825,14 +799,9 @@ const authTools = [
|
|
|
825
799
|
case 'status':
|
|
826
800
|
return handleCheckAuthStatus();
|
|
827
801
|
default:
|
|
828
|
-
return
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
type: 'text',
|
|
832
|
-
text: `Unknown action '${action}'. Valid actions: status, authenticate, device-code-complete, about.`,
|
|
833
|
-
},
|
|
834
|
-
],
|
|
835
|
-
};
|
|
802
|
+
return toolError(
|
|
803
|
+
`Unknown action '${action}'. Valid actions: status, authenticate, device-code-complete, about.`
|
|
804
|
+
);
|
|
836
805
|
}
|
|
837
806
|
},
|
|
838
807
|
},
|
package/calendar/attendees.js
CHANGED
|
@@ -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,
|
package/calendar/cancel.js
CHANGED
|
@@ -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
|
|