@littlebearapps/outlook-assistant 3.11.1 → 3.12.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 +12 -0
- package/README.md +46 -28
- package/advanced/index.js +239 -10
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/index.js +19 -1
- package/calendar/list.js +154 -2
- package/categories/index.js +17 -3
- package/config.js +73 -17
- package/email/attachments.js +79 -5
- package/email/conversations.js +29 -15
- package/email/delta.js +94 -4
- package/email/export.js +164 -54
- package/email/folder-utils.js +29 -6
- package/email/headers.js +5 -1
- package/email/index.js +68 -13
- package/email/list.js +8 -1
- package/email/mark-as-read.js +3 -1
- package/email/mime.js +4 -1
- package/email/read.js +5 -1
- package/email/search.js +14 -5
- package/folder/create.js +11 -4
- package/folder/delete.js +9 -1
- package/folder/index.js +11 -1
- package/folder/list.js +61 -27
- package/folder/move.js +32 -7
- package/folder/resolve.js +62 -23
- package/folder/stats.js +11 -5
- package/llms-install.md +28 -9
- package/llms.txt +12 -8
- package/package.json +5 -5
- package/utils/graph-api.js +109 -3
- package/utils/mailbox.js +77 -0
- package/utils/response-formatter.js +44 -10
package/auth/device-code.js
CHANGED
|
@@ -73,10 +73,19 @@ async function initiateDeviceCodeFlow(clientId, scopes) {
|
|
|
73
73
|
const { statusCode, body } = await postRequest(endpoint, postData);
|
|
74
74
|
|
|
75
75
|
if (statusCode < 200 || statusCode >= 300) {
|
|
76
|
-
|
|
76
|
+
const error = new Error(
|
|
77
77
|
body.error_description ||
|
|
78
78
|
`Device code request failed with status ${statusCode}`
|
|
79
79
|
);
|
|
80
|
+
// Same classification payload as pollForToken, so a scope rejection at
|
|
81
|
+
// initiation can be recognised by isScopeConsentError.
|
|
82
|
+
error.oauth = {
|
|
83
|
+
error: body.error,
|
|
84
|
+
error_codes: body.error_codes,
|
|
85
|
+
suberror: body.suberror,
|
|
86
|
+
error_description: body.error_description,
|
|
87
|
+
};
|
|
88
|
+
throw error;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
return {
|
|
@@ -133,11 +142,22 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
|
|
|
133
142
|
throw new Error(
|
|
134
143
|
'Device code expired. Please restart the authentication process.'
|
|
135
144
|
);
|
|
136
|
-
default:
|
|
137
|
-
|
|
145
|
+
default: {
|
|
146
|
+
// Attach the raw OAuth payload so callers (e.g. handleDeviceCodeComplete)
|
|
147
|
+
// can classify the failure — notably scope-consent rejections that should
|
|
148
|
+
// trigger a base-scopes fallback. Keep the existing message text.
|
|
149
|
+
const e = new Error(
|
|
138
150
|
body.error_description ||
|
|
139
151
|
`Token polling failed: ${body.error || `status ${statusCode}`}`
|
|
140
152
|
);
|
|
153
|
+
e.oauth = {
|
|
154
|
+
error: body.error,
|
|
155
|
+
error_codes: body.error_codes,
|
|
156
|
+
suberror: body.suberror,
|
|
157
|
+
error_description: body.error_description,
|
|
158
|
+
};
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
141
161
|
}
|
|
142
162
|
}
|
|
143
163
|
|
|
@@ -146,7 +166,84 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
|
|
|
146
166
|
);
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
// AADSTS codes meaning "this scope value isn't supported for this account" —
|
|
170
|
+
// the only signals that justify a SILENT, DURABLE downgrade to base scopes:
|
|
171
|
+
// 650053 — "The application asked for scope '<x>' that doesn't exist on the
|
|
172
|
+
// resource" (the personal-account `.Shared` rejection)
|
|
173
|
+
// 70011 — invalid scope value
|
|
174
|
+
// Deliberately NOT here:
|
|
175
|
+
// 65001 — consent required (remediable: user/admin consent) → see
|
|
176
|
+
// isConsentRequiredError; must not silently strip capability
|
|
177
|
+
// 28000 — generic invalid request, not scope-specific
|
|
178
|
+
// invalid_grant (bare) — MFA/conditional access, revoked grant, tenant policy
|
|
179
|
+
const SCOPE_UNSUPPORTED_AADSTS_CODES = ['650053', '70011'];
|
|
180
|
+
const CONSENT_REQUIRED_AADSTS_CODES = ['65001'];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Does `err` carry one of `codes` in `oauth.error_codes` (array) or as an
|
|
184
|
+
* `AADSTS<code>` substring in `oauth.error_description` / `err.message`?
|
|
185
|
+
* @param {Error & {oauth?: object}} err
|
|
186
|
+
* @param {string[]} codes
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
// The OAuth error payload is attacker-influencable HTTP data — fields may
|
|
190
|
+
// arrive as arrays or objects instead of strings. Coerce before substring
|
|
191
|
+
// checks so `includes` is always String.prototype.includes.
|
|
192
|
+
function asString(value) {
|
|
193
|
+
return typeof value === 'string' ? value : '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hasAadstsCode(err, codes) {
|
|
197
|
+
const oauth = err.oauth || {};
|
|
198
|
+
if (Array.isArray(oauth.error_codes)) {
|
|
199
|
+
const found = oauth.error_codes.map(String);
|
|
200
|
+
if (found.some((c) => codes.includes(c))) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const haystack = `${asString(oauth.error_description)} ${asString(err.message)}`;
|
|
205
|
+
// Whole-code match: `AADSTS70011` must not match `AADSTS700110`.
|
|
206
|
+
return codes.some((code) =>
|
|
207
|
+
new RegExp(`AADSTS${code}(?!\\d)`).test(haystack)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Predicate: is this a "requested scope isn't supported for this account"
|
|
213
|
+
* rejection that warrants falling back to base scopes? Deliberately narrow —
|
|
214
|
+
* a false positive silently and permanently strips shared-mailbox access.
|
|
215
|
+
* @param {Error & {oauth?: object}} err
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
function isScopeConsentError(err) {
|
|
219
|
+
if (!err) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
// Consent-required takes precedence: it is remediable, so it must surface
|
|
223
|
+
// rather than silently downgrade the scope set.
|
|
224
|
+
if (isConsentRequiredError(err)) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const oauth = err.oauth || {};
|
|
228
|
+
return (
|
|
229
|
+
oauth.error === 'invalid_scope' ||
|
|
230
|
+
hasAadstsCode(err, SCOPE_UNSUPPORTED_AADSTS_CODES)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Predicate: consent required (AADSTS65001). Remediable via user/admin consent
|
|
236
|
+
* — surface it, never downgrade the scope set.
|
|
237
|
+
* @param {Error & {oauth?: object}} err
|
|
238
|
+
* @returns {boolean}
|
|
239
|
+
*/
|
|
240
|
+
function isConsentRequiredError(err) {
|
|
241
|
+
return Boolean(err) && hasAadstsCode(err, CONSENT_REQUIRED_AADSTS_CODES);
|
|
242
|
+
}
|
|
243
|
+
|
|
149
244
|
module.exports = {
|
|
150
245
|
initiateDeviceCodeFlow,
|
|
151
246
|
pollForToken,
|
|
247
|
+
isScopeConsentError,
|
|
248
|
+
isConsentRequiredError,
|
|
152
249
|
};
|
package/auth/token-storage.js
CHANGED
|
@@ -5,6 +5,36 @@ const https = require('https');
|
|
|
5
5
|
const querystring = require('querystring');
|
|
6
6
|
const { describeAuthError } = require('./auth-errors');
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Decide which scopes a refresh request should use. Prefer the scopes that were
|
|
10
|
+
* actually GRANTED (so a base-only fallback never re-requests `.Shared` on
|
|
11
|
+
* refresh and gets logged out ~1h later). Falls back to the parsed `scope`
|
|
12
|
+
* string, then to the configured scopes for back-compat with token files
|
|
13
|
+
* written before granted_scopes existed.
|
|
14
|
+
*
|
|
15
|
+
* `offline_access` is always included. Microsoft's token responses list only
|
|
16
|
+
* the scopes the access token is valid for — `offline_access` is not among
|
|
17
|
+
* them — and the token endpoint issues a new refresh_token only when
|
|
18
|
+
* `offline_access` is requested. Refreshing with the bare granted list would
|
|
19
|
+
* stop refresh-token rotation and eventually log the user out.
|
|
20
|
+
* @param {object|null} tokens - Stored token object
|
|
21
|
+
* @param {string[]} configScopes - Configured scope set (back-compat fallback)
|
|
22
|
+
* @returns {string[]} - Scopes to send in the refresh request
|
|
23
|
+
*/
|
|
24
|
+
function resolveRefreshScopes(tokens, configScopes) {
|
|
25
|
+
let scopes = configScopes;
|
|
26
|
+
if (tokens) {
|
|
27
|
+
if (Array.isArray(tokens.granted_scopes) && tokens.granted_scopes.length) {
|
|
28
|
+
scopes = tokens.granted_scopes;
|
|
29
|
+
} else if (typeof tokens.scope === 'string' && tokens.scope.trim()) {
|
|
30
|
+
scopes = tokens.scope.split(' ').filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return scopes.includes('offline_access')
|
|
34
|
+
? scopes
|
|
35
|
+
: [...scopes, 'offline_access'];
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
class TokenStorage {
|
|
9
39
|
constructor(config) {
|
|
10
40
|
this.config = {
|
|
@@ -179,7 +209,9 @@ class TokenStorage {
|
|
|
179
209
|
client_id: this.config.clientId,
|
|
180
210
|
grant_type: 'refresh_token',
|
|
181
211
|
refresh_token: this.tokens.refresh_token,
|
|
182
|
-
|
|
212
|
+
// Use the GRANTED scopes, not the full configured set. After a base-only
|
|
213
|
+
// fallback, re-requesting `.Shared` here would fail and log the user out.
|
|
214
|
+
scope: resolveRefreshScopes(this.tokens, this.config.scopes).join(' '),
|
|
183
215
|
};
|
|
184
216
|
if (!isDeviceCode) {
|
|
185
217
|
refreshParams.client_secret = this.config.clientSecret;
|
|
@@ -272,13 +304,14 @@ class TokenStorage {
|
|
|
272
304
|
);
|
|
273
305
|
}
|
|
274
306
|
console.log('Exchanging authorization code for tokens...');
|
|
307
|
+
const requestedScopes = this.config.scopes;
|
|
275
308
|
const postData = querystring.stringify({
|
|
276
309
|
client_id: this.config.clientId,
|
|
277
310
|
client_secret: this.config.clientSecret,
|
|
278
311
|
grant_type: 'authorization_code',
|
|
279
312
|
code: authCode,
|
|
280
313
|
redirect_uri: this.config.redirectUri,
|
|
281
|
-
scope:
|
|
314
|
+
scope: requestedScopes.join(' '),
|
|
282
315
|
});
|
|
283
316
|
|
|
284
317
|
const requestOptions = {
|
|
@@ -306,6 +339,14 @@ class TokenStorage {
|
|
|
306
339
|
expires_in: responseBody.expires_in,
|
|
307
340
|
expires_at: Date.now() + responseBody.expires_in * 1000,
|
|
308
341
|
scope: responseBody.scope,
|
|
342
|
+
// Persist granted scopes so refresh re-requests exactly what
|
|
343
|
+
// was granted (mirrors the device-code path). If the token
|
|
344
|
+
// response omits `scope`, fall back to what we requested.
|
|
345
|
+
granted_scopes:
|
|
346
|
+
typeof responseBody.scope === 'string' &&
|
|
347
|
+
responseBody.scope.trim()
|
|
348
|
+
? responseBody.scope.split(' ').filter(Boolean)
|
|
349
|
+
: requestedScopes,
|
|
309
350
|
token_type: responseBody.token_type,
|
|
310
351
|
};
|
|
311
352
|
try {
|
|
@@ -379,4 +420,5 @@ class TokenStorage {
|
|
|
379
420
|
}
|
|
380
421
|
|
|
381
422
|
module.exports = TokenStorage;
|
|
423
|
+
module.exports.resolveRefreshScopes = resolveRefreshScopes;
|
|
382
424
|
// Adding a newline at the end of the file as requested by Gemini Code Assist
|
package/auth/tools.js
CHANGED
|
@@ -6,7 +6,12 @@ const { getAuthErrorHints } = require('./auth-errors');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const tokenManager = require('./token-manager');
|
|
9
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
initiateDeviceCodeFlow,
|
|
11
|
+
pollForToken,
|
|
12
|
+
isScopeConsentError,
|
|
13
|
+
isConsentRequiredError,
|
|
14
|
+
} = require('./device-code');
|
|
10
15
|
|
|
11
16
|
// Path for persisting device code state across MCP server restarts
|
|
12
17
|
const DEVICE_CODE_STATE_PATH = path.join(
|
|
@@ -20,6 +25,49 @@ function setToolCount(count) {
|
|
|
20
25
|
_toolCount = count;
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Scopes recorded as granted in a stored token object. Prefers the
|
|
30
|
+
* `granted_scopes` array; falls back to the token response's `scope` string
|
|
31
|
+
* (token files written before granted_scopes existed). Full-URI forms such as
|
|
32
|
+
* `https://graph.microsoft.com/Mail.Read` are reduced to the bare scope name.
|
|
33
|
+
* @param {object|null} tokens
|
|
34
|
+
* @returns {string[]|null} - null when no token is stored
|
|
35
|
+
*/
|
|
36
|
+
function grantedScopesOf(tokens) {
|
|
37
|
+
if (!tokens) return null;
|
|
38
|
+
let raw = [];
|
|
39
|
+
if (Array.isArray(tokens.granted_scopes) && tokens.granted_scopes.length) {
|
|
40
|
+
raw = tokens.granted_scopes;
|
|
41
|
+
} else if (typeof tokens.scope === 'string') {
|
|
42
|
+
raw = tokens.scope.split(' ');
|
|
43
|
+
}
|
|
44
|
+
return raw.map((scope) => String(scope).split('/').pop()).filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Human-readable shared-mailbox status for `auth about`.
|
|
49
|
+
* @param {string[]|null} granted - Granted scopes, or null when signed out
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function describeSharedMailboxStatus(granted) {
|
|
53
|
+
if (config.SHARED_MAILBOX_MODE === 'off' || !config.SHARED_SCOPES.length) {
|
|
54
|
+
return 'Disabled (opt-in, work/school only: set `OUTLOOK_SHARED_MAILBOX=read` or `=true`, restart, then `auth action=authenticate force=true`)';
|
|
55
|
+
}
|
|
56
|
+
const lowerGranted = (granted || []).map((s) => s.toLowerCase());
|
|
57
|
+
const parts = config.SHARED_SCOPES.map(
|
|
58
|
+
(scope) =>
|
|
59
|
+
`${scope} ${lowerGranted.includes(scope.toLowerCase()) ? 'granted' : 'not granted'}`
|
|
60
|
+
);
|
|
61
|
+
let status = `Enabled (${config.SHARED_MAILBOX_MODE}): ${parts.join(', ')}`;
|
|
62
|
+
if (!granted) {
|
|
63
|
+
status += ' (not signed in)';
|
|
64
|
+
} else if (parts.some((p) => p.endsWith('not granted'))) {
|
|
65
|
+
status +=
|
|
66
|
+
' (re-authenticate with `auth action=authenticate force=true`; personal accounts cannot be granted these)';
|
|
67
|
+
}
|
|
68
|
+
return status;
|
|
69
|
+
}
|
|
70
|
+
|
|
23
71
|
/**
|
|
24
72
|
* About tool handler
|
|
25
73
|
* @returns {object} - MCP response
|
|
@@ -43,6 +91,17 @@ async function handleAbout() {
|
|
|
43
91
|
// GET /me round-trip when a valid token is available; degrades
|
|
44
92
|
// gracefully when not authenticated.
|
|
45
93
|
let identity = 'Not authenticated (run `auth action=authenticate`)';
|
|
94
|
+
// Granted scopes come from the stored token file (scope names only — the
|
|
95
|
+
// tokens themselves are never surfaced).
|
|
96
|
+
let granted = null;
|
|
97
|
+
try {
|
|
98
|
+
const { tokenStorage } = require('./index');
|
|
99
|
+
if (tokenStorage && typeof tokenStorage.getTokens === 'function') {
|
|
100
|
+
granted = grantedScopesOf(await tokenStorage.getTokens());
|
|
101
|
+
}
|
|
102
|
+
} catch (_e) {
|
|
103
|
+
// Leave granted as unknown
|
|
104
|
+
}
|
|
46
105
|
try {
|
|
47
106
|
const { ensureAuthenticated } = require('./index');
|
|
48
107
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
@@ -70,8 +129,10 @@ async function handleAbout() {
|
|
|
70
129
|
`| Rate Limit | ${rateLimit} |`,
|
|
71
130
|
`| Recipient Allowlist | ${allowlist} |`,
|
|
72
131
|
`| Scopes | ${scopes.length} configured |`,
|
|
132
|
+
`| Shared mailboxes | ${describeSharedMailboxStatus(granted)} |`,
|
|
73
133
|
``,
|
|
74
|
-
`**
|
|
134
|
+
`**Configured scopes**: ${scopes.join(', ')}`,
|
|
135
|
+
`**Granted scopes**: ${granted ? granted.filter((s) => s !== 'offline_access').join(', ') || 'none recorded' : 'not signed in'}`,
|
|
75
136
|
];
|
|
76
137
|
|
|
77
138
|
// F-1 / F-48: warn when no safety belts are wired up. AI-assisted
|
|
@@ -209,6 +270,23 @@ async function handleDeviceCodeAuth() {
|
|
|
209
270
|
}
|
|
210
271
|
|
|
211
272
|
console.error('[AUTH] Starting device code flow...');
|
|
273
|
+
// Attempt the configured scope set (base, plus `.Shared` when
|
|
274
|
+
// OUTLOOK_SHARED_MAILBOX opts in). If the account can't consent to
|
|
275
|
+
// `.Shared`, handleDeviceCodeComplete re-issues with base scopes.
|
|
276
|
+
return initiateDeviceCode(config.AUTH_CONFIG.scopes, 'full');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Shared helper: request a device code for a given scope set, persist the
|
|
281
|
+
* pending state (tagging which scope set was used so the completion step can
|
|
282
|
+
* decide whether a fallback is still available), and build the MCP response.
|
|
283
|
+
* @param {string[]} scopes - OAuth scopes to request
|
|
284
|
+
* @param {'full'|'base'} scopesUsed - Label recording which scope set was used
|
|
285
|
+
* @param {string} [prefix] - Optional leading line (used for the fallback case)
|
|
286
|
+
* @returns {Promise<object>} - MCP response
|
|
287
|
+
*/
|
|
288
|
+
async function initiateDeviceCode(scopes, scopesUsed, prefix) {
|
|
289
|
+
const clientId = config.AUTH_CONFIG.clientId;
|
|
212
290
|
|
|
213
291
|
// #213 — initiation can throw (blocked network egress in a sandboxed
|
|
214
292
|
// connector, AADSTS9002331 audience mismatch, invalid_client, non-JSON
|
|
@@ -218,11 +296,25 @@ async function handleDeviceCodeAuth() {
|
|
|
218
296
|
// (handleDeviceCodeComplete) already has, and surface actionable hints.
|
|
219
297
|
let response;
|
|
220
298
|
try {
|
|
221
|
-
response = await initiateDeviceCodeFlow(
|
|
222
|
-
clientId,
|
|
223
|
-
config.AUTH_CONFIG.scopes
|
|
224
|
-
);
|
|
299
|
+
response = await initiateDeviceCodeFlow(clientId, scopes);
|
|
225
300
|
} catch (error) {
|
|
301
|
+
// The `.Shared` scopes can also be rejected when the code is requested
|
|
302
|
+
// (before sign-in). Same single fallback as at completion.
|
|
303
|
+
if (
|
|
304
|
+
config.SHARED_SCOPES.length > 0 &&
|
|
305
|
+
scopesUsed === 'full' &&
|
|
306
|
+
!isConsentRequiredError(error) &&
|
|
307
|
+
isScopeConsentError(error)
|
|
308
|
+
) {
|
|
309
|
+
console.error(
|
|
310
|
+
'[AUTH] Shared-mailbox scopes rejected at device-code request; retrying with base scopes.'
|
|
311
|
+
);
|
|
312
|
+
return initiateDeviceCode(
|
|
313
|
+
config.AUTH_CONFIG.fallbackScopes,
|
|
314
|
+
'base',
|
|
315
|
+
"Your account doesn't support shared-mailbox access; signing in with the standard scopes instead."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
226
318
|
return buildDeviceCodeErrorResponse(error);
|
|
227
319
|
}
|
|
228
320
|
|
|
@@ -232,24 +324,31 @@ async function handleDeviceCodeAuth() {
|
|
|
232
324
|
interval: response.interval,
|
|
233
325
|
expiresIn: response.expiresIn,
|
|
234
326
|
expiresAt: Date.now() + response.expiresIn * 1000,
|
|
327
|
+
scopesUsed,
|
|
235
328
|
};
|
|
236
329
|
saveDeviceCodeState(pendingDeviceCode);
|
|
237
330
|
|
|
238
331
|
console.error(
|
|
239
|
-
`[AUTH] Device code: ${response.userCode}, expires in ${response.expiresIn}s`
|
|
332
|
+
`[AUTH] Device code (${scopesUsed} scopes): ${response.userCode}, expires in ${response.expiresIn}s`
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const lines = [];
|
|
336
|
+
if (prefix) {
|
|
337
|
+
lines.push(prefix, '');
|
|
338
|
+
}
|
|
339
|
+
lines.push(
|
|
340
|
+
`## Device Code Authentication\n`,
|
|
341
|
+
`Visit: **${response.verificationUri}**`,
|
|
342
|
+
`Enter code: **${response.userCode}**\n`,
|
|
343
|
+
`The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
|
|
344
|
+
`After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`
|
|
240
345
|
);
|
|
241
346
|
|
|
242
347
|
return {
|
|
243
348
|
content: [
|
|
244
349
|
{
|
|
245
350
|
type: 'text',
|
|
246
|
-
text:
|
|
247
|
-
`## Device Code Authentication\n`,
|
|
248
|
-
`Visit: **${response.verificationUri}**`,
|
|
249
|
-
`Enter code: **${response.userCode}**\n`,
|
|
250
|
-
`The code expires in ${Math.floor(response.expiresIn / 60)} minutes.\n`,
|
|
251
|
-
`After entering the code and signing in, call this tool again with \`action=device-code-complete\` to finish authentication.`,
|
|
252
|
-
].join('\n'),
|
|
351
|
+
text: lines.join('\n'),
|
|
253
352
|
},
|
|
254
353
|
],
|
|
255
354
|
};
|
|
@@ -332,6 +431,14 @@ async function handleDeviceCodeComplete() {
|
|
|
332
431
|
}
|
|
333
432
|
|
|
334
433
|
const clientId = config.AUTH_CONFIG.clientId;
|
|
434
|
+
// Capture which scope set this pending flow attempted, before any mutation.
|
|
435
|
+
const scopesUsed = pendingDeviceCode.scopesUsed || 'full';
|
|
436
|
+
// The scopes we attempted — used as the granted_scopes fallback when the
|
|
437
|
+
// token response omits `scope`.
|
|
438
|
+
const attemptedScopes =
|
|
439
|
+
scopesUsed === 'base'
|
|
440
|
+
? config.AUTH_CONFIG.fallbackScopes
|
|
441
|
+
: config.AUTH_CONFIG.scopes;
|
|
335
442
|
|
|
336
443
|
try {
|
|
337
444
|
console.error('[AUTH] Polling for device code completion...');
|
|
@@ -355,12 +462,20 @@ async function handleDeviceCodeComplete() {
|
|
|
355
462
|
tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint,
|
|
356
463
|
});
|
|
357
464
|
|
|
465
|
+
// Persist the GRANTED scopes so token refresh re-requests exactly what was
|
|
466
|
+
// granted (not the full configured set) — otherwise a base-only fallback
|
|
467
|
+
// would re-request `.Shared` on refresh ~1h later and log the user out.
|
|
468
|
+
const grantedScopes = tokenResponse.scope
|
|
469
|
+
? tokenResponse.scope.split(' ').filter(Boolean)
|
|
470
|
+
: attemptedScopes;
|
|
471
|
+
|
|
358
472
|
tokenStorage.tokens = {
|
|
359
473
|
access_token: tokenResponse.access_token,
|
|
360
474
|
refresh_token: tokenResponse.refresh_token,
|
|
361
475
|
expires_in: tokenResponse.expires_in,
|
|
362
476
|
expires_at: Date.now() + tokenResponse.expires_in * 1000,
|
|
363
477
|
scope: tokenResponse.scope,
|
|
478
|
+
granted_scopes: grantedScopes,
|
|
364
479
|
token_type: tokenResponse.token_type,
|
|
365
480
|
auth_method: 'device-code',
|
|
366
481
|
};
|
|
@@ -377,8 +492,75 @@ async function handleDeviceCodeComplete() {
|
|
|
377
492
|
],
|
|
378
493
|
};
|
|
379
494
|
} catch (error) {
|
|
495
|
+
// Scope-consent rejection while attempting the FULL set → re-issue with
|
|
496
|
+
// base scopes. This is the personal-account path: one extra device code.
|
|
497
|
+
// Only meaningful when shared-mailbox support is on: with the flag off the
|
|
498
|
+
// attempted set already IS the base set, so there is nothing to drop.
|
|
499
|
+
// Consent-required (AADSTS65001) is checked first: it is remediable and
|
|
500
|
+
// must surface below, never trigger a silent downgrade.
|
|
501
|
+
if (
|
|
502
|
+
config.SHARED_SCOPES.length > 0 &&
|
|
503
|
+
scopesUsed === 'full' &&
|
|
504
|
+
!isConsentRequiredError(error) &&
|
|
505
|
+
isScopeConsentError(error)
|
|
506
|
+
) {
|
|
507
|
+
console.error(
|
|
508
|
+
'[AUTH] Shared-mailbox scopes rejected; falling back to base scopes.'
|
|
509
|
+
);
|
|
510
|
+
// Do NOT clear pendingDeviceCode — initiateDeviceCode replaces it.
|
|
511
|
+
try {
|
|
512
|
+
const fallbackResponse = await initiateDeviceCode(
|
|
513
|
+
config.AUTH_CONFIG.fallbackScopes,
|
|
514
|
+
'base',
|
|
515
|
+
"Your account doesn't support shared-mailbox access; enter this new code to finish signing in."
|
|
516
|
+
);
|
|
517
|
+
// initiateDeviceCode reports its own failures as { isError: true }
|
|
518
|
+
// instead of throwing. Clear the rejected full-scope pending state so
|
|
519
|
+
// a later completion attempt doesn't retry it.
|
|
520
|
+
if (fallbackResponse && fallbackResponse.isError) {
|
|
521
|
+
pendingDeviceCode = null;
|
|
522
|
+
saveDeviceCodeState(null);
|
|
523
|
+
}
|
|
524
|
+
return fallbackResponse;
|
|
525
|
+
} catch (reissueError) {
|
|
526
|
+
pendingDeviceCode = null;
|
|
527
|
+
saveDeviceCodeState(null);
|
|
528
|
+
return {
|
|
529
|
+
content: [
|
|
530
|
+
{
|
|
531
|
+
type: 'text',
|
|
532
|
+
text: `Authentication failed: ${reissueError.message}`,
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
380
539
|
pendingDeviceCode = null;
|
|
381
540
|
saveDeviceCodeState(null);
|
|
541
|
+
|
|
542
|
+
// Consent required (AADSTS65001) — remediable, so surface it instead of
|
|
543
|
+
// silently downgrading to base scopes (which would strip shared-mailbox
|
|
544
|
+
// access for every future refresh).
|
|
545
|
+
// Only when the shared scopes were requested — otherwise the generic
|
|
546
|
+
// path below (with its AADSTS hint table) is unchanged.
|
|
547
|
+
if (config.SHARED_SCOPES.length > 0 && isConsentRequiredError(error)) {
|
|
548
|
+
return {
|
|
549
|
+
content: [
|
|
550
|
+
{
|
|
551
|
+
type: 'text',
|
|
552
|
+
text: [
|
|
553
|
+
'Authentication failed: consent was not granted (AADSTS65001).',
|
|
554
|
+
'',
|
|
555
|
+
`An administrator may need to grant consent for the shared-mailbox scopes (${config.SHARED_SCOPES.join(', ')}), or re-run \`auth action=authenticate\` and approve every requested permission.`,
|
|
556
|
+
'If your organisation will not consent to them, unset OUTLOOK_SHARED_MAILBOX and restart the server to sign in with the standard scopes.',
|
|
557
|
+
'No scopes were changed — your configured capability is unchanged.',
|
|
558
|
+
].join('\n'),
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
382
564
|
return {
|
|
383
565
|
content: [
|
|
384
566
|
{
|
package/calendar/index.js
CHANGED
|
@@ -13,7 +13,7 @@ const calendarTools = [
|
|
|
13
13
|
{
|
|
14
14
|
name: 'list-events',
|
|
15
15
|
description:
|
|
16
|
-
'List
|
|
16
|
+
'List calendar events for the signed-in user (read-only). By default returns upcoming events (start ≥ now). Optional `startAfter`, `startBefore` and `subject` filters find past, current or specifically-named events; supplying any of them replaces the default "now" lower bound and the filters are AND-ed together. Results are oldest first, except when the search only looks backwards (`startBefore` without `startAfter`, or `subject` alone), where they are newest first. Returns an array of events with id, subject, start/end, attendees, location, organiser, and webLink. Use `count` (default 10, max 50) to control page size. Each start/end is returned as a canonical UTC ISO-8601 instant (e.g. `2026-04-02T22:00:00.000Z`) followed by a labelled local rendering in the configured display timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`) — the UTC value is authoritative, so consumers never have to guess the zone.',
|
|
17
17
|
annotations: {
|
|
18
18
|
title: 'List Calendar Events',
|
|
19
19
|
readOnlyHint: true,
|
|
@@ -26,6 +26,24 @@ const calendarTools = [
|
|
|
26
26
|
type: 'number',
|
|
27
27
|
description: 'Number of events to retrieve (default: 10, max: 50)',
|
|
28
28
|
},
|
|
29
|
+
startAfter: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
format: 'date-time',
|
|
32
|
+
description:
|
|
33
|
+
'Optional ISO 8601 datetime with `Z` or a ±hh:mm offset (required). Only return events whose start is on or after this time. Replaces the default "now" lower bound when supplied. Example: "2026-01-01T00:00:00Z" or "2026-01-01T09:00:00+10:00".',
|
|
34
|
+
},
|
|
35
|
+
startBefore: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
format: 'date-time',
|
|
38
|
+
description:
|
|
39
|
+
'Optional ISO 8601 datetime with `Z` or a ±hh:mm offset (required). Only return events whose start is strictly before this time. Combine with `startAfter` to bound a window; on its own, results are newest first. Example: "2026-02-01T00:00:00Z".',
|
|
40
|
+
},
|
|
41
|
+
subject: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description:
|
|
44
|
+
'Optional substring (max 255 characters) to match against the event subject, case-insensitive (Graph `contains()`). Useful for finding past or current events by name; on its own, results are newest first.',
|
|
45
|
+
maxLength: 255,
|
|
46
|
+
},
|
|
29
47
|
},
|
|
30
48
|
additionalProperties: false,
|
|
31
49
|
required: [],
|