@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.
- package/.env.example +30 -3
- package/README.md +67 -27
- 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 +61 -82
- 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 +461 -0
- package/calendar/update.js +55 -83
- package/categories/index.js +68 -265
- package/config.js +29 -1
- package/contacts/index.js +72 -128
- package/email/attachments.js +43 -125
- package/email/conversations.js +44 -78
- package/email/delta.js +69 -46
- package/email/draft.js +170 -103
- package/email/export.js +145 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +86 -110
- 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 +39 -51
- package/email/read.js +16 -50
- package/email/search.js +47 -87
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +19 -17
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +18 -27
- package/index.js +39 -45
- package/llms-install.md +22 -4
- package/llms.txt +20 -11
- package/outlook-auth-server.js +10 -3
- package/package.json +4 -1
- package/request-handler.js +217 -116
- package/rules/create.js +28 -71
- package/rules/index.js +52 -93
- package/rules/list.js +7 -19
- package/rules/rule-builder.js +59 -22
- package/rules/update.js +27 -61
- package/server.js +41 -0
- package/settings/index.js +162 -145
- 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 +247 -42
- package/utils/server-instructions.js +73 -0
- package/utils/tool-error.js +33 -0
package/auth/oauth-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
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,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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
`|
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
782
|
-
|
|
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
|
-
|
|
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
|
},
|
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
|
|