@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.
- package/.env.example +27 -3
- package/README.md +108 -33
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +12 -2
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +46 -33
- package/auth/tools.js +223 -93
- 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 +36 -2
- 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 +23 -45
- package/llms-install.md +31 -7
- package/llms.txt +19 -10
- package/outlook-auth-server.js +10 -3
- package/package.json +6 -2
- 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/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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
626
|
-
|
|
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
|
-
|
|
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
|
},
|
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
|
|