@littlebearapps/outlook-assistant 3.7.0 → 3.7.2
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/auth/token-storage.js +18 -6
- package/auth/tools.js +67 -3
- package/email/search.js +278 -23
- package/package.json +1 -1
package/auth/token-storage.js
CHANGED
|
@@ -49,11 +49,13 @@ class TokenStorage {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
if (!this.config.clientId
|
|
52
|
+
if (!this.config.clientId) {
|
|
53
53
|
console.warn(
|
|
54
|
-
'TokenStorage: OUTLOOK_CLIENT_ID
|
|
54
|
+
'TokenStorage: OUTLOOK_CLIENT_ID is not configured. Token operations will fail.'
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
|
+
// client_secret is only required for browser flow (confidential client).
|
|
58
|
+
// Device code flow (public client) does not use client_secret.
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
async _loadTokensFromFile() {
|
|
@@ -158,14 +160,24 @@ class TokenStorage {
|
|
|
158
160
|
return this._refreshPromise.then((tokens) => tokens.access_token);
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
// Device code flow is a public client flow — Microsoft rejects client_secret
|
|
164
|
+
// in refresh requests for tokens obtained via device code.
|
|
165
|
+
// Browser flow (confidential client) requires client_secret.
|
|
166
|
+
const isDeviceCode = this.tokens.auth_method === 'device-code';
|
|
167
|
+
console.log(
|
|
168
|
+
`Attempting to refresh access token (auth_method: ${this.tokens.auth_method || 'browser'})...`
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const refreshParams = {
|
|
163
172
|
client_id: this.config.clientId,
|
|
164
|
-
client_secret: this.config.clientSecret,
|
|
165
173
|
grant_type: 'refresh_token',
|
|
166
174
|
refresh_token: this.tokens.refresh_token,
|
|
167
175
|
scope: this.config.scopes.join(' '),
|
|
168
|
-
}
|
|
176
|
+
};
|
|
177
|
+
if (!isDeviceCode) {
|
|
178
|
+
refreshParams.client_secret = this.config.clientSecret;
|
|
179
|
+
}
|
|
180
|
+
const postData = querystring.stringify(refreshParams);
|
|
169
181
|
|
|
170
182
|
const requestOptions = {
|
|
171
183
|
method: 'POST',
|
package/auth/tools.js
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
* Authentication-related tools for the Outlook Assistant server
|
|
3
3
|
*/
|
|
4
4
|
const config = require('../config');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
5
7
|
const tokenManager = require('./token-manager');
|
|
6
8
|
const { initiateDeviceCodeFlow, pollForToken } = require('./device-code');
|
|
7
9
|
|
|
10
|
+
// Path for persisting device code state across MCP server restarts
|
|
11
|
+
const DEVICE_CODE_STATE_PATH = path.join(
|
|
12
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
13
|
+
'.outlook-assistant-pending-auth.json'
|
|
14
|
+
);
|
|
15
|
+
|
|
8
16
|
// Dynamic tool count — set by index.js after TOOLS array is built
|
|
9
17
|
let _toolCount = 0;
|
|
10
18
|
function setToolCount(count) {
|
|
@@ -89,12 +97,57 @@ async function handleAuthenticate(args) {
|
|
|
89
97
|
};
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
//
|
|
100
|
+
// In-memory state for pending device code flow (also persisted to disk)
|
|
93
101
|
let pendingDeviceCode = null;
|
|
94
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Save device code state to disk so it survives MCP server restarts.
|
|
105
|
+
* Uses mode 0o600 (owner-only) — same as token file.
|
|
106
|
+
* @param {object|null} state - Device code state or null to delete
|
|
107
|
+
*/
|
|
108
|
+
function saveDeviceCodeState(state) {
|
|
109
|
+
try {
|
|
110
|
+
if (state) {
|
|
111
|
+
fs.writeFileSync(DEVICE_CODE_STATE_PATH, JSON.stringify(state), {
|
|
112
|
+
mode: 0o600,
|
|
113
|
+
});
|
|
114
|
+
} else if (fs.existsSync(DEVICE_CODE_STATE_PATH)) {
|
|
115
|
+
fs.unlinkSync(DEVICE_CODE_STATE_PATH);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(
|
|
119
|
+
`[AUTH] Failed to ${state ? 'save' : 'clean up'} device code state: ${error.message}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load device code state from disk (fallback when in-memory state is lost).
|
|
126
|
+
* Returns null if no state exists or if the state has expired.
|
|
127
|
+
* @returns {object|null}
|
|
128
|
+
*/
|
|
129
|
+
function loadDeviceCodeState() {
|
|
130
|
+
try {
|
|
131
|
+
if (!fs.existsSync(DEVICE_CODE_STATE_PATH)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const state = JSON.parse(fs.readFileSync(DEVICE_CODE_STATE_PATH, 'utf8'));
|
|
135
|
+
if (Date.now() > state.expiresAt) {
|
|
136
|
+
console.error('[AUTH] Persisted device code has expired, cleaning up');
|
|
137
|
+
saveDeviceCodeState(null);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return state;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(`[AUTH] Failed to load device code state: ${error.message}`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
95
147
|
/**
|
|
96
148
|
* Device code flow step 1 — request a code for the user to enter.
|
|
97
149
|
* Returns the code + URL immediately. Call device-code-complete to finish.
|
|
150
|
+
* State is persisted to disk so it survives MCP server restarts.
|
|
98
151
|
* @returns {object} - MCP response
|
|
99
152
|
*/
|
|
100
153
|
async function handleDeviceCodeAuth() {
|
|
@@ -116,13 +169,14 @@ async function handleDeviceCodeAuth() {
|
|
|
116
169
|
config.AUTH_CONFIG.scopes
|
|
117
170
|
);
|
|
118
171
|
|
|
119
|
-
// Store
|
|
172
|
+
// Store in memory and persist to disk
|
|
120
173
|
pendingDeviceCode = {
|
|
121
174
|
deviceCode: response.deviceCode,
|
|
122
175
|
interval: response.interval,
|
|
123
176
|
expiresIn: response.expiresIn,
|
|
124
177
|
expiresAt: Date.now() + response.expiresIn * 1000,
|
|
125
178
|
};
|
|
179
|
+
saveDeviceCodeState(pendingDeviceCode);
|
|
126
180
|
|
|
127
181
|
console.error(
|
|
128
182
|
`[AUTH] Device code: ${response.userCode}, expires in ${response.expiresIn}s`
|
|
@@ -146,9 +200,15 @@ async function handleDeviceCodeAuth() {
|
|
|
146
200
|
|
|
147
201
|
/**
|
|
148
202
|
* Device code flow step 2 — poll until the user completes authentication.
|
|
203
|
+
* Checks in-memory state first, falls back to disk-persisted state.
|
|
149
204
|
* @returns {object} - MCP response
|
|
150
205
|
*/
|
|
151
206
|
async function handleDeviceCodeComplete() {
|
|
207
|
+
// Try in-memory first, fall back to disk (survives server restarts)
|
|
208
|
+
if (!pendingDeviceCode) {
|
|
209
|
+
pendingDeviceCode = loadDeviceCodeState();
|
|
210
|
+
}
|
|
211
|
+
|
|
152
212
|
if (!pendingDeviceCode) {
|
|
153
213
|
return {
|
|
154
214
|
content: [
|
|
@@ -162,6 +222,7 @@ async function handleDeviceCodeComplete() {
|
|
|
162
222
|
|
|
163
223
|
if (Date.now() > pendingDeviceCode.expiresAt) {
|
|
164
224
|
pendingDeviceCode = null;
|
|
225
|
+
saveDeviceCodeState(null);
|
|
165
226
|
return {
|
|
166
227
|
content: [
|
|
167
228
|
{
|
|
@@ -184,8 +245,9 @@ async function handleDeviceCodeComplete() {
|
|
|
184
245
|
);
|
|
185
246
|
|
|
186
247
|
pendingDeviceCode = null;
|
|
248
|
+
saveDeviceCodeState(null);
|
|
187
249
|
|
|
188
|
-
// Save tokens using TokenStorage
|
|
250
|
+
// Save tokens using TokenStorage — mark as device-code auth
|
|
189
251
|
const TokenStorage = require('./token-storage');
|
|
190
252
|
const tokenStorage = new TokenStorage({
|
|
191
253
|
clientId: config.AUTH_CONFIG.clientId,
|
|
@@ -202,6 +264,7 @@ async function handleDeviceCodeComplete() {
|
|
|
202
264
|
expires_at: Date.now() + tokenResponse.expires_in * 1000,
|
|
203
265
|
scope: tokenResponse.scope,
|
|
204
266
|
token_type: tokenResponse.token_type,
|
|
267
|
+
auth_method: 'device-code',
|
|
205
268
|
};
|
|
206
269
|
await tokenStorage._saveTokensToFile();
|
|
207
270
|
|
|
@@ -217,6 +280,7 @@ async function handleDeviceCodeComplete() {
|
|
|
217
280
|
};
|
|
218
281
|
} catch (error) {
|
|
219
282
|
pendingDeviceCode = null;
|
|
283
|
+
saveDeviceCodeState(null);
|
|
220
284
|
return {
|
|
221
285
|
content: [
|
|
222
286
|
{
|
package/email/search.js
CHANGED
|
@@ -145,6 +145,12 @@ async function progressiveSearch(
|
|
|
145
145
|
console.error(
|
|
146
146
|
`Raw KQL search successful: found ${response.value.length} results`
|
|
147
147
|
);
|
|
148
|
+
response._searchInfo = {
|
|
149
|
+
attemptsCount: searchAttempts.length,
|
|
150
|
+
strategies: searchAttempts,
|
|
151
|
+
originalTerms: searchTerms,
|
|
152
|
+
filterTerms: filterTerms,
|
|
153
|
+
};
|
|
148
154
|
return response;
|
|
149
155
|
}
|
|
150
156
|
} catch (error) {
|
|
@@ -188,6 +194,12 @@ async function progressiveSearch(
|
|
|
188
194
|
console.error(
|
|
189
195
|
`Combined search successful: found ${response.value.length} results`
|
|
190
196
|
);
|
|
197
|
+
response._searchInfo = {
|
|
198
|
+
attemptsCount: searchAttempts.length,
|
|
199
|
+
strategies: searchAttempts,
|
|
200
|
+
originalTerms: searchTerms,
|
|
201
|
+
filterTerms: filterTerms,
|
|
202
|
+
};
|
|
191
203
|
return response;
|
|
192
204
|
}
|
|
193
205
|
} catch (error) {
|
|
@@ -241,10 +253,109 @@ async function progressiveSearch(
|
|
|
241
253
|
console.error(
|
|
242
254
|
`Search with ${term} successful: found ${response.value.length} results`
|
|
243
255
|
);
|
|
256
|
+
response._searchInfo = {
|
|
257
|
+
attemptsCount: searchAttempts.length,
|
|
258
|
+
strategies: searchAttempts,
|
|
259
|
+
originalTerms: searchTerms,
|
|
260
|
+
filterTerms: filterTerms,
|
|
261
|
+
};
|
|
244
262
|
return response;
|
|
245
263
|
}
|
|
264
|
+
|
|
265
|
+
// Client-side fallback for 'to' filter — toRecipients/any() lambda
|
|
266
|
+
// returns 0 results on personal accounts even when emails exist
|
|
267
|
+
if (term === 'to') {
|
|
268
|
+
console.error(
|
|
269
|
+
'to filter returned 0 results, trying client-side filtering'
|
|
270
|
+
);
|
|
271
|
+
searchAttempts.push('client-side-to');
|
|
272
|
+
const messages = await fetchForClientSideFilter(
|
|
273
|
+
accessToken,
|
|
274
|
+
endpoint,
|
|
275
|
+
maxCount
|
|
276
|
+
);
|
|
277
|
+
const matched = filterToClientSide(messages, searchTerms[term]);
|
|
278
|
+
if (matched.length > 0) {
|
|
279
|
+
console.error(
|
|
280
|
+
`Client-side to filter matched ${matched.length} of ${messages.length} messages`
|
|
281
|
+
);
|
|
282
|
+
return { value: matched.slice(0, maxCount) };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Client-side fallback for 'query' — search bodyPreview, subject, from
|
|
287
|
+
if (term === 'query') {
|
|
288
|
+
console.error(
|
|
289
|
+
'query contains(subject) returned 0 results, trying client-side body search'
|
|
290
|
+
);
|
|
291
|
+
searchAttempts.push('client-side-query');
|
|
292
|
+
const messages = await fetchForClientSideFilter(
|
|
293
|
+
accessToken,
|
|
294
|
+
endpoint,
|
|
295
|
+
maxCount
|
|
296
|
+
);
|
|
297
|
+
const matched = filterQueryClientSide(messages, searchTerms[term]);
|
|
298
|
+
if (matched.length > 0) {
|
|
299
|
+
console.error(
|
|
300
|
+
`Client-side query matched ${matched.length} of ${messages.length} messages`
|
|
301
|
+
);
|
|
302
|
+
return { value: matched.slice(0, maxCount) };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
246
305
|
} catch (error) {
|
|
247
306
|
console.error(`Search with ${term} failed: ${error.message}`);
|
|
307
|
+
|
|
308
|
+
// Client-side fallback for 'to' when API throws (e.g. InefficientFilter)
|
|
309
|
+
if (term === 'to') {
|
|
310
|
+
try {
|
|
311
|
+
console.error(
|
|
312
|
+
'to filter threw error, trying client-side filtering'
|
|
313
|
+
);
|
|
314
|
+
searchAttempts.push('client-side-to');
|
|
315
|
+
const messages = await fetchForClientSideFilter(
|
|
316
|
+
accessToken,
|
|
317
|
+
endpoint,
|
|
318
|
+
maxCount
|
|
319
|
+
);
|
|
320
|
+
const matched = filterToClientSide(messages, searchTerms[term]);
|
|
321
|
+
if (matched.length > 0) {
|
|
322
|
+
console.error(
|
|
323
|
+
`Client-side to filter matched ${matched.length} of ${messages.length} messages`
|
|
324
|
+
);
|
|
325
|
+
return { value: matched.slice(0, maxCount) };
|
|
326
|
+
}
|
|
327
|
+
} catch (fallbackError) {
|
|
328
|
+
console.error(
|
|
329
|
+
`Client-side to fallback also failed: ${fallbackError.message}`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Client-side fallback for 'query' when API throws
|
|
335
|
+
if (term === 'query') {
|
|
336
|
+
try {
|
|
337
|
+
console.error(
|
|
338
|
+
'query filter threw error, trying client-side body search'
|
|
339
|
+
);
|
|
340
|
+
searchAttempts.push('client-side-query');
|
|
341
|
+
const messages = await fetchForClientSideFilter(
|
|
342
|
+
accessToken,
|
|
343
|
+
endpoint,
|
|
344
|
+
maxCount
|
|
345
|
+
);
|
|
346
|
+
const matched = filterQueryClientSide(messages, searchTerms[term]);
|
|
347
|
+
if (matched.length > 0) {
|
|
348
|
+
console.error(
|
|
349
|
+
`Client-side query matched ${matched.length} of ${messages.length} messages`
|
|
350
|
+
);
|
|
351
|
+
return { value: matched.slice(0, maxCount) };
|
|
352
|
+
}
|
|
353
|
+
} catch (fallbackError) {
|
|
354
|
+
console.error(
|
|
355
|
+
`Client-side query fallback also failed: ${fallbackError.message}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
248
359
|
}
|
|
249
360
|
}
|
|
250
361
|
}
|
|
@@ -282,6 +393,12 @@ async function progressiveSearch(
|
|
|
282
393
|
console.error(
|
|
283
394
|
`Boolean filter search found ${response.value?.length || 0} results`
|
|
284
395
|
);
|
|
396
|
+
response._searchInfo = {
|
|
397
|
+
attemptsCount: searchAttempts.length,
|
|
398
|
+
strategies: searchAttempts,
|
|
399
|
+
originalTerms: searchTerms,
|
|
400
|
+
filterTerms: filterTerms,
|
|
401
|
+
};
|
|
285
402
|
return response;
|
|
286
403
|
} catch (error) {
|
|
287
404
|
console.error(`Boolean filter search failed: ${error.message}`);
|
|
@@ -301,6 +418,12 @@ async function progressiveSearch(
|
|
|
301
418
|
retryParams,
|
|
302
419
|
maxCount
|
|
303
420
|
);
|
|
421
|
+
response._searchInfo = {
|
|
422
|
+
attemptsCount: searchAttempts.length,
|
|
423
|
+
strategies: searchAttempts,
|
|
424
|
+
originalTerms: searchTerms,
|
|
425
|
+
filterTerms: filterTerms,
|
|
426
|
+
};
|
|
304
427
|
return response;
|
|
305
428
|
} catch (retryError) {
|
|
306
429
|
console.error(
|
|
@@ -311,8 +434,35 @@ async function progressiveSearch(
|
|
|
311
434
|
}
|
|
312
435
|
}
|
|
313
436
|
|
|
314
|
-
// 4. Final fallback
|
|
315
|
-
|
|
437
|
+
// 4. Final fallback
|
|
438
|
+
// If the user specified search filters, return 0 results with guidance
|
|
439
|
+
// instead of silently returning unfiltered recent emails.
|
|
440
|
+
const hasAnyFilters =
|
|
441
|
+
searchTerms.query ||
|
|
442
|
+
searchTerms.from ||
|
|
443
|
+
searchTerms.to ||
|
|
444
|
+
searchTerms.subject ||
|
|
445
|
+
searchTerms.kqlQuery;
|
|
446
|
+
|
|
447
|
+
if (hasAnyFilters) {
|
|
448
|
+
console.error(
|
|
449
|
+
'All search strategies exhausted with filters active — returning 0 results'
|
|
450
|
+
);
|
|
451
|
+
searchAttempts.push('no-results');
|
|
452
|
+
return {
|
|
453
|
+
value: [],
|
|
454
|
+
_searchInfo: {
|
|
455
|
+
attemptsCount: searchAttempts.length,
|
|
456
|
+
strategies: searchAttempts,
|
|
457
|
+
originalTerms: searchTerms,
|
|
458
|
+
filterTerms: filterTerms,
|
|
459
|
+
noResults: true,
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// No search filters specified — return recent emails (list mode)
|
|
465
|
+
console.error('No search filters specified, returning recent emails');
|
|
316
466
|
searchAttempts.push('recent-emails');
|
|
317
467
|
|
|
318
468
|
const basicParams = {
|
|
@@ -328,11 +478,8 @@ async function progressiveSearch(
|
|
|
328
478
|
basicParams,
|
|
329
479
|
maxCount
|
|
330
480
|
);
|
|
331
|
-
console.error(
|
|
332
|
-
`Fallback to recent emails found ${response.value?.length || 0} results`
|
|
333
|
-
);
|
|
481
|
+
console.error(`Recent emails: found ${response.value?.length || 0} results`);
|
|
334
482
|
|
|
335
|
-
// Add a note to the response about the search attempts
|
|
336
483
|
response._searchInfo = {
|
|
337
484
|
attemptsCount: searchAttempts.length,
|
|
338
485
|
strategies: searchAttempts,
|
|
@@ -391,6 +538,74 @@ function buildToFilter(val) {
|
|
|
391
538
|
return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
|
|
392
539
|
}
|
|
393
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Client-side filter for toRecipients — used when the OData toRecipients/any()
|
|
543
|
+
* lambda expression fails on personal accounts (InefficientFilter).
|
|
544
|
+
* Mirrors the pattern from conversations.js lines 138-147.
|
|
545
|
+
* @param {Array} messages - Array of message objects with toRecipients
|
|
546
|
+
* @param {string} toValue - The to filter value (email, domain, or name)
|
|
547
|
+
* @returns {Array} - Filtered messages where at least one recipient matches
|
|
548
|
+
*/
|
|
549
|
+
function filterToClientSide(messages, toValue) {
|
|
550
|
+
const toLower = toValue.toLowerCase();
|
|
551
|
+
return messages.filter((m) =>
|
|
552
|
+
(m.toRecipients || []).some((r) => {
|
|
553
|
+
const addr = (r.emailAddress?.address || '').toLowerCase();
|
|
554
|
+
const name = (r.emailAddress?.name || '').toLowerCase();
|
|
555
|
+
return addr.includes(toLower) || name.includes(toLower);
|
|
556
|
+
})
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Client-side filter for free-text query — used when $search and
|
|
562
|
+
* contains(subject) both fail on personal accounts.
|
|
563
|
+
* Searches subject, bodyPreview, from address, and from name.
|
|
564
|
+
* @param {Array} messages - Array of message objects
|
|
565
|
+
* @param {string} queryText - The query text to search for
|
|
566
|
+
* @returns {Array} - Filtered messages matching the query
|
|
567
|
+
*/
|
|
568
|
+
function filterQueryClientSide(messages, queryText) {
|
|
569
|
+
const queryLower = queryText.toLowerCase();
|
|
570
|
+
return messages.filter((m) => {
|
|
571
|
+
const subject = (m.subject || '').toLowerCase();
|
|
572
|
+
const body = (m.bodyPreview || '').toLowerCase();
|
|
573
|
+
const fromAddr = (m.from?.emailAddress?.address || '').toLowerCase();
|
|
574
|
+
const fromName = (m.from?.emailAddress?.name || '').toLowerCase();
|
|
575
|
+
return (
|
|
576
|
+
subject.includes(queryLower) ||
|
|
577
|
+
body.includes(queryLower) ||
|
|
578
|
+
fromAddr.includes(queryLower) ||
|
|
579
|
+
fromName.includes(queryLower)
|
|
580
|
+
);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Fetch recent messages for client-side filtering fallback.
|
|
586
|
+
* Uses the 'search' field preset which includes toRecipients and bodyPreview.
|
|
587
|
+
* @param {string} accessToken - Access token
|
|
588
|
+
* @param {string} endpoint - API endpoint
|
|
589
|
+
* @param {number} maxCount - Maximum results to fetch
|
|
590
|
+
* @returns {Promise<Array>} - Array of message objects
|
|
591
|
+
*/
|
|
592
|
+
async function fetchForClientSideFilter(accessToken, endpoint, maxCount) {
|
|
593
|
+
const searchFields = getEmailFields('search');
|
|
594
|
+
const params = {
|
|
595
|
+
$top: Math.min(200, maxCount * 5),
|
|
596
|
+
$select: searchFields,
|
|
597
|
+
$orderby: 'receivedDateTime desc',
|
|
598
|
+
};
|
|
599
|
+
const response = await callGraphAPIPaginated(
|
|
600
|
+
accessToken,
|
|
601
|
+
'GET',
|
|
602
|
+
endpoint,
|
|
603
|
+
params,
|
|
604
|
+
Math.min(200, maxCount * 5)
|
|
605
|
+
);
|
|
606
|
+
return response.value || [];
|
|
607
|
+
}
|
|
608
|
+
|
|
394
609
|
/**
|
|
395
610
|
* Build search parameters from search terms and filter terms
|
|
396
611
|
* Uses $filter for email addresses (more reliable than $search)
|
|
@@ -531,37 +746,75 @@ function addBooleanFilters(params, filterTerms) {
|
|
|
531
746
|
* @returns {object} - MCP response object
|
|
532
747
|
*/
|
|
533
748
|
function formatSearchResults(response, folder, verbosity) {
|
|
749
|
+
// Build metadata
|
|
750
|
+
const meta = {
|
|
751
|
+
returned: (response.value || []).length,
|
|
752
|
+
totalAvailable: response['@odata.count'] || null,
|
|
753
|
+
hasMore: Boolean(response['@odata.nextLink']),
|
|
754
|
+
verbosity: verbosity,
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Add searchMetadata to _meta when available (for programmatic fallback detection)
|
|
758
|
+
if (response._searchInfo) {
|
|
759
|
+
const finalStrategy =
|
|
760
|
+
response._searchInfo.strategies[
|
|
761
|
+
response._searchInfo.strategies.length - 1
|
|
762
|
+
];
|
|
763
|
+
meta.searchMetadata = {
|
|
764
|
+
strategiesAttempted: response._searchInfo.strategies,
|
|
765
|
+
finalStrategy: finalStrategy,
|
|
766
|
+
filterApplied: !response._searchInfo.noResults,
|
|
767
|
+
originalFilters: response._searchInfo.originalTerms,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Handle 0 results
|
|
534
772
|
if (!response.value || response.value.length === 0) {
|
|
773
|
+
// Actionable guidance when filters were specified but matched nothing
|
|
774
|
+
if (response._searchInfo?.noResults) {
|
|
775
|
+
const filters = response._searchInfo.originalTerms || {};
|
|
776
|
+
const activeFilters = Object.entries(filters)
|
|
777
|
+
.filter(([, v]) => v)
|
|
778
|
+
.map(([k]) => k);
|
|
779
|
+
const filterDesc =
|
|
780
|
+
activeFilters.length > 0
|
|
781
|
+
? ` (filters: ${activeFilters.join(', ')})`
|
|
782
|
+
: '';
|
|
783
|
+
|
|
784
|
+
const text =
|
|
785
|
+
`No emails found matching your filters in "${folder}"${filterDesc}.\n\n` +
|
|
786
|
+
'**Suggestions:**\n' +
|
|
787
|
+
'- Try `searchAllFolders: true` to search across all folders including Archive\n' +
|
|
788
|
+
'- Specify the correct folder if emails have been moved (use `folders` tool to list folders)\n' +
|
|
789
|
+
'- Use `from` filter instead of `to` (more reliable on personal accounts)\n' +
|
|
790
|
+
'- Use `kqlQuery` with `searchAllFolders: true` for cross-folder search';
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
content: [{ type: 'text', text }],
|
|
794
|
+
_meta: meta,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
535
798
|
return {
|
|
536
799
|
content: [
|
|
537
800
|
{
|
|
538
801
|
type: 'text',
|
|
539
|
-
text:
|
|
802
|
+
text: 'No emails found matching your search criteria.',
|
|
540
803
|
},
|
|
541
804
|
],
|
|
805
|
+
_meta: meta,
|
|
542
806
|
};
|
|
543
807
|
}
|
|
544
808
|
|
|
545
|
-
//
|
|
546
|
-
const meta = {
|
|
547
|
-
returned: response.value.length,
|
|
548
|
-
totalAvailable: response['@odata.count'] || null,
|
|
549
|
-
hasMore: Boolean(response['@odata.nextLink']),
|
|
550
|
-
verbosity: verbosity,
|
|
551
|
-
};
|
|
552
|
-
|
|
553
|
-
// Add search strategy info if available (for debugging)
|
|
809
|
+
// Add search strategy note for transparency
|
|
554
810
|
let searchNote = '';
|
|
555
811
|
if (response._searchInfo) {
|
|
556
|
-
const strategy =
|
|
557
|
-
response._searchInfo.strategies[
|
|
558
|
-
response._searchInfo.strategies.length - 1
|
|
559
|
-
];
|
|
812
|
+
const strategy = meta.searchMetadata.finalStrategy;
|
|
560
813
|
if (strategy === 'recent-emails') {
|
|
561
814
|
searchNote =
|
|
562
|
-
'\n\n**Note**:
|
|
563
|
-
|
|
564
|
-
|
|
815
|
+
'\n\n**Note**: No search filters were applied — showing recent emails.';
|
|
816
|
+
} else if (strategy.startsWith('client-side-')) {
|
|
817
|
+
searchNote = `\n\n_Search strategy: ${strategy} (filtered locally due to personal account API limitations)_`;
|
|
565
818
|
} else {
|
|
566
819
|
searchNote = `\n\n_Search strategy: ${strategy}_`;
|
|
567
820
|
}
|
|
@@ -699,4 +952,6 @@ module.exports = {
|
|
|
699
952
|
buildFromFilter,
|
|
700
953
|
buildToFilter,
|
|
701
954
|
classifyEmailFilter,
|
|
955
|
+
filterToClientSide,
|
|
956
|
+
filterQueryClientSide,
|
|
702
957
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebearapps/outlook-assistant",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.2",
|
|
4
4
|
"mcpName": "io.github.littlebearapps/outlook-assistant",
|
|
5
5
|
"description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
|
|
6
6
|
"main": "index.js",
|