@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.
@@ -49,11 +49,13 @@ class TokenStorage {
49
49
  }
50
50
  }
51
51
 
52
- if (!this.config.clientId || !this.config.clientSecret) {
52
+ if (!this.config.clientId) {
53
53
  console.warn(
54
- 'TokenStorage: OUTLOOK_CLIENT_ID or OUTLOOK_CLIENT_SECRET is not configured. Token operations might fail.'
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
- console.log('Attempting to refresh access token...');
162
- const postData = querystring.stringify({
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
- // Module-level state for pending device code flow
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 for the completion step
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: just get recent emails with pagination
315
- console.error('All search strategies failed, falling back to recent emails');
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: `No emails found matching your search criteria.`,
802
+ text: 'No emails found matching your search criteria.',
540
803
  },
541
804
  ],
805
+ _meta: meta,
542
806
  };
543
807
  }
544
808
 
545
- // Build metadata
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**: Your search query could not be applied — showing recent emails instead. ' +
563
- 'On personal Microsoft accounts, free-text `query` search may not work. ' +
564
- 'Try using `subject`, `from`, `to`, or `kqlQuery` parameters for more reliable filtering.';
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.0",
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",