@littlebearapps/outlook-assistant 3.9.0 → 3.10.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/README.md CHANGED
@@ -100,7 +100,7 @@ Outlook Assistant works with both personal and work/school Microsoft accounts, b
100
100
  | Contacts CRUD | Full support | Full support |
101
101
  | Inbox rules | Full support | Full support |
102
102
  | Folders | Full support | Full support |
103
- | Free-text `query` search | Limited — use `subject`, `from`, `to` filters instead | Full KQL support |
103
+ | Free-text `query` search | Limited — progressive fallback; `subject`, `from`, `to` filters are more direct | Full `$search` support |
104
104
  | Categories | Full support | Full support |
105
105
  | Mailbox settings | Full support | Full support |
106
106
  | Focused Inbox | API works (overrides stored) but mail routing not affected | Full support |
@@ -111,7 +111,7 @@ Outlook Assistant works with both personal and work/school Microsoft accounts, b
111
111
 
112
112
  ### What Makes This Different
113
113
 
114
- - **Progressive search** — on accounts where Microsoft's `$search` API is limited, Outlook Assistant automatically falls back through up to 4 search strategies to find your emails. Most Graph API wrappers fail silently; this one adapts.
114
+ - **Progressive search** — on accounts where Microsoft's `$search` API is limited, Outlook Assistant automatically falls back through up to 4 search strategies to find your emails, and reports which one answered in `_meta.searchMetadata` along with any filter it could not honour (`droppedFilters`). Most Graph API wrappers fail silently; this one adapts and tells you.
115
115
  - **Email forensics** — raw header access for DKIM, SPF, DMARC, delivery chain, X-Mailer, X-Originating-IP, and spam scores. Returns the full data so you can investigate phishing, audit compliance, or trace delivery issues. (Auto-verdict is on the roadmap; today the data is surfaced and analysed in-conversation.)
116
116
  - **Delta sync** — incremental inbox monitoring returns only what changed since your last check, with tokens for continuous polling. Designed for agent workflows that need to watch a mailbox.
117
117
  - **Batch operations** — flag, move, export, or categorise multiple emails in a single call. Search-driven export lets you batch-export results without collecting IDs manually.
@@ -497,7 +497,7 @@ USE_TEST_MODE=true npm start
497
497
  | [Getting Started](docs/how-to/getting-started/connect-outlook-to-claude.md) | Install, configure, and authenticate — start here |
498
498
  | [Azure Setup Guide](docs/guides/azure-setup.md) | Azure account creation, app registration, permissions, and secrets |
499
499
  | [How-To Guides](docs/how-to/index.md) | 29 practical guides for email, calendar, contacts, and settings |
500
- | [Roadmap](ROADMAP.md) | Active milestones (v3.7.5, v3.8.x, v3.10.0+) and recent releases |
500
+ | [Roadmap](ROADMAP.md) | Active milestones (v3.7.5, v3.8.x, v3.11.0+) and recent releases |
501
501
  | [Troubleshooting & FAQ](docs/how-to/getting-started/verify-your-connection.md#common-connection-problems) | Common problems, re-authentication, and frequently asked questions |
502
502
  | [Tools Reference](docs/quickrefs/tools-reference.md) | All 22 tools with parameters |
503
503
  | [AI Agent Guide](docs/how-to/ai-agents/using-outlook-assistant-in-agents.md) | Tool selection and workflow patterns for AI agents |
@@ -506,7 +506,7 @@ Full documentation: [docs/](docs/README.md)
506
506
 
507
507
  ## Known Limitations
508
508
 
509
- - **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts — field-scoped raw `$search` (e.g. `subject:"…"`) may return nothing there. `query` mitigates this with progressive fallback (OData filters, then client-side), so for reliable personal-account search prefer structured filters (`from`, `subject`, `to`, `receivedAfter`) or `query`. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results.
509
+ - **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. `query` mitigates this with progressive fallback (OData filters, boolean filters, then a client-side scan). Field-scoped `$search` (e.g. `subject:"…"`) is rejected outright there; since v3.10.0 `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried, but boolean operators, grouping, wildcards and other field prefixes are not — those still terminate with an explicit no-results rather than a silent broader search. Structured filters (`from`, `subject`, `to`, `receivedAfter`) remain the most direct route. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results.
510
510
  - **Focused Inbox**: Only available on work/school Microsoft 365 accounts.
511
511
  - **Shared mailboxes**: Require `Mail.Read.Shared` permission and a work/school account.
512
512
  - **Meeting room search**: Requires `Place.Read.All` permission with admin consent (work/school accounts only).
package/email/index.js CHANGED
@@ -73,7 +73,7 @@ const emailTools = [
73
73
  searchExpression: {
74
74
  type: 'string',
75
75
  description:
76
- 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: on personal Outlook.com accounts field-scoped `$search` is best-effort and may return nothing — prefer `query` there (it has progressive fallback).',
76
+ 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there.',
77
77
  },
78
78
  kqlQuery: {
79
79
  type: 'string',
package/email/search.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  DEFAULT_LIMITS,
14
14
  } = require('../utils/response-formatter');
15
15
  const { getEmailFields } = require('../utils/field-presets');
16
+ const { escapeODataString } = require('../utils/odata-helpers');
16
17
 
17
18
  // Upper bound on how many recent messages the client-side fallback scans
18
19
  // before giving up. Deliberately DECOUPLED from the requested result count so
@@ -71,9 +72,16 @@ async function handleSearchEmails(args) {
71
72
  const kqlQuery =
72
73
  (args.searchExpression || '').trim() || (args.kqlQuery || '').trim();
73
74
 
74
- // Select fields based on verbosity
75
+ // Select fields based on verbosity — but never at the cost of correctness.
76
+ // When more than one search term is supplied, the ladder may satisfy one
77
+ // server-side and narrow by the rest locally (#229), and those matchers read
78
+ // `toRecipients` and `bodyPreview`, which the `list` preset omits. Without
79
+ // this, a `from`+`to` search would drop every row at default verbosity and
80
+ // return "No emails found" — making `outputVerbosity`, a presentation
81
+ // parameter, decide which messages are found.
82
+ const searchTermCount = [query, from, to, subject].filter(Boolean).length;
75
83
  const selectFields = getEmailFields(
76
- verbosity === VERBOSITY.FULL ? 'search' : 'list'
84
+ verbosity === VERBOSITY.FULL || searchTermCount > 1 ? 'search' : 'list'
77
85
  );
78
86
 
79
87
  try {
@@ -102,7 +110,12 @@ async function handleSearchEmails(args) {
102
110
 
103
111
  // Label the scope accurately — a cross-folder search is not "inbox". (#169)
104
112
  const scopeLabel = searchAllFolders ? 'all folders' : folder;
105
- return formatSearchResults(response, scopeLabel, verbosity);
113
+ return formatSearchResults(
114
+ response,
115
+ scopeLabel,
116
+ verbosity,
117
+ searchAllFolders
118
+ );
106
119
  } catch (error) {
107
120
  // Handle authentication errors
108
121
  if (error.message === 'Authentication required') {
@@ -128,6 +141,134 @@ async function handleSearchEmails(args) {
128
141
  }
129
142
  }
130
143
 
144
+ /** Field prefixes a `searchExpression` can be translated into OData filters. */
145
+ const TRANSLATABLE_KQL_FIELDS = new Set(['from', 'to', 'subject']);
146
+
147
+ /**
148
+ * Parse a `searchExpression` that consists purely of recognised field-scoped
149
+ * terms, e.g. `from:info@example.com` or `subject:"Quarterly Report"`. (#217)
150
+ *
151
+ * Unscoped `$search` works fine on personal Outlook.com accounts; only the
152
+ * field-scoped forms come back empty. Those same messages are reachable
153
+ * through the OData filters the ladder already builds, so a recognised
154
+ * expression can be translated and retried rather than reported as absent.
155
+ *
156
+ * This parser is deliberately strict, and returns null for anything it does
157
+ * not fully understand — free text, `AND`/`OR`/`NOT`, parentheses, unknown
158
+ * prefixes, a repeated field, or free text mixed in with a scoped term. That
159
+ * keeps the terminal no-fallthrough behaviour for every expression whose
160
+ * meaning we cannot reproduce exactly, which is what #169 V37-F-1 shipped to
161
+ * guarantee. Translating a half-understood expression would reintroduce it.
162
+ *
163
+ * @param {string} expression - The trimmed raw search expression
164
+ * @returns {object|null} - Search terms to retry with, or null if not translatable
165
+ */
166
+ function parseFieldScopedExpression(expression) {
167
+ const trimmed = (expression || '').trim();
168
+ if (!trimmed) return null;
169
+
170
+ const terms = {};
171
+ let i = 0;
172
+
173
+ while (i < trimmed.length) {
174
+ while (i < trimmed.length && /\s/.test(trimmed[i])) i++;
175
+ if (i >= trimmed.length) break;
176
+
177
+ // Every token must be `field:value`. A token without a colon is free
178
+ // text; a colon further along belongs to a later token, and the slice
179
+ // then carries whitespace or an operator, which fails the field check.
180
+ const colon = trimmed.indexOf(':', i);
181
+ if (colon === -1) return null;
182
+
183
+ const field = trimmed.slice(i, colon).toLowerCase();
184
+ if (!TRANSLATABLE_KQL_FIELDS.has(field)) return null;
185
+ if (terms[field]) return null; // repeated field — ambiguous, don't guess
186
+ i = colon + 1;
187
+
188
+ let value;
189
+ if (trimmed[i] === '"') {
190
+ const end = trimmed.indexOf('"', i + 1);
191
+ if (end === -1) return null; // unbalanced quote
192
+ value = trimmed.slice(i + 1, end);
193
+ i = end + 1;
194
+ // A quoted value must be followed by whitespace or end-of-string.
195
+ if (i < trimmed.length && !/\s/.test(trimmed[i])) return null;
196
+ } else {
197
+ let end = i;
198
+ while (end < trimmed.length && !/\s/.test(trimmed[end])) end++;
199
+ value = trimmed.slice(i, end);
200
+ i = end;
201
+ }
202
+
203
+ if (!value) return null;
204
+ // Wildcards and grouping mean something in KQL that an OData `eq` or
205
+ // `contains` does not reproduce. Rather than translate them literally,
206
+ // decline the whole expression.
207
+ if (/[()*]/.test(value)) return null;
208
+ terms[field] = value;
209
+ }
210
+
211
+ return Object.keys(terms).length > 0 ? terms : null;
212
+ }
213
+
214
+ /**
215
+ * Retry a field-scoped `searchExpression` as OData filters, down the normal
216
+ * ladder. Returns null when the expression is not translatable, which keeps
217
+ * the terminal no-fallthrough behaviour #169 V37-F-1 shipped. (#217)
218
+ *
219
+ * @param {object} ctx - Everything the retry needs from the raw-KQL branch
220
+ * @returns {Promise<object|null>} - A merged response, or null if untranslatable
221
+ */
222
+ async function retryFieldScopedExpression(ctx) {
223
+ const {
224
+ endpoint,
225
+ accessToken,
226
+ trimmedKql,
227
+ kqlForSearch,
228
+ searchTerms,
229
+ filterTerms,
230
+ maxCount,
231
+ selectFields,
232
+ searchAttempts,
233
+ } = ctx;
234
+
235
+ const translated = parseFieldScopedExpression(trimmedKql);
236
+ if (!translated) return null;
237
+
238
+ console.error(
239
+ `Retrying field-scoped searchExpression as OData filters: ${JSON.stringify(translated)}`
240
+ );
241
+ const retry = await progressiveSearch(
242
+ endpoint,
243
+ accessToken,
244
+ translated,
245
+ filterTerms,
246
+ maxCount,
247
+ selectFields
248
+ );
249
+ const retryCount = retry.value?.length || 0;
250
+ const strategies = [
251
+ ...searchAttempts,
252
+ ...(retry._searchInfo?.strategies || []),
253
+ // Last, so finalStrategy names the translation rather than whichever rung
254
+ // of the ladder happened to answer it.
255
+ 'raw-kql-translated',
256
+ ];
257
+ retry._searchInfo = {
258
+ ...retry._searchInfo,
259
+ attemptsCount: strategies.length,
260
+ strategies,
261
+ // Report what the caller actually asked for, not the rewrite.
262
+ originalTerms: searchTerms,
263
+ filterTerms,
264
+ kqlApplied: kqlForSearch,
265
+ kqlTranslatedTo: translated,
266
+ appliedTerms: ['kqlQuery'],
267
+ noResults: retryCount === 0,
268
+ };
269
+ return retry;
270
+ }
271
+
131
272
  /**
132
273
  * Execute a search with progressively simpler fallback strategies
133
274
  * @param {string} endpoint - API endpoint
@@ -158,27 +299,42 @@ async function progressiveSearch(
158
299
  // would drop the user's filter and return unrelated recent emails
159
300
  // with a misleading "combined-search" strategy line. (#169)
160
301
  if (searchTerms.kqlQuery) {
161
- try {
162
- // Pass the user's KQL through as-is. The user is responsible for
163
- // their own phrase quoting (e.g. `subject:"foo bar"`); we do NOT
164
- // auto-wrap, which previously produced broken nested quotes like
165
- // `"subject:"foo bar""` on Graph $search and silently returned
166
- // recent unfiltered messages. (#169 V37-F-1)
167
- const trimmedKql = searchTerms.kqlQuery.trim();
168
- const alreadyQuoted =
169
- trimmedKql.startsWith('"') && trimmedKql.endsWith('"');
170
- const looksLikeExpression =
171
- trimmedKql.includes(':') || /\s/.test(trimmedKql);
172
- // Already-quoted phrases and KQL-looking expressions (field syntax
173
- // or multi-word) are passed through as-is; only bare single tokens
174
- // are wrapped so Graph treats them as phrase searches.
175
- let kqlForSearch;
176
- if (alreadyQuoted || looksLikeExpression) {
177
- kqlForSearch = trimmedKql;
178
- } else {
179
- kqlForSearch = `"${trimmedKql}"`;
180
- }
302
+ // Pass the user's KQL through as-is. The user is responsible for
303
+ // their own phrase quoting (e.g. `subject:"foo bar"`); we do NOT
304
+ // auto-wrap, which previously produced broken nested quotes like
305
+ // `"subject:"foo bar""` on Graph $search and silently returned
306
+ // recent unfiltered messages. (#169 V37-F-1)
307
+ const trimmedKql = searchTerms.kqlQuery.trim();
308
+ const alreadyQuoted =
309
+ trimmedKql.startsWith('"') && trimmedKql.endsWith('"');
310
+ const looksLikeExpression =
311
+ trimmedKql.includes(':') || /\s/.test(trimmedKql);
312
+ // Already-quoted phrases and KQL-looking expressions (field syntax
313
+ // or multi-word) are passed through as-is; only bare single tokens
314
+ // are wrapped so Graph treats them as phrase searches.
315
+ let kqlForSearch;
316
+ if (alreadyQuoted || looksLikeExpression) {
317
+ kqlForSearch = trimmedKql;
318
+ } else {
319
+ kqlForSearch = `"${trimmedKql}"`;
320
+ }
181
321
 
322
+ // Graph rejects field-scoped expressions outright on personal accounts
323
+ // ("Syntax error: character ':' is not valid"), so the translated retry
324
+ // has to be reachable from the catch as well as the empty-result path.
325
+ const translationContext = {
326
+ endpoint,
327
+ accessToken,
328
+ trimmedKql,
329
+ kqlForSearch,
330
+ searchTerms,
331
+ filterTerms,
332
+ maxCount,
333
+ selectFields,
334
+ searchAttempts,
335
+ };
336
+
337
+ try {
182
338
  console.error(`Attempting raw KQL search: ${kqlForSearch}`);
183
339
  searchAttempts.push('raw-kql');
184
340
 
@@ -205,19 +361,49 @@ async function progressiveSearch(
205
361
  originalTerms: searchTerms,
206
362
  filterTerms: filterTerms,
207
363
  kqlApplied: kqlForSearch,
364
+ appliedTerms: ['kqlQuery'],
208
365
  // noResults flips on the helpful "Suggestions" block in the
209
366
  // formatter — without it, an empty kqlQuery result would render
210
367
  // the bare "No emails found matching your search criteria" line
211
368
  // with no guidance.
212
369
  noResults: matched === 0,
213
370
  };
214
- // Always return — never silently fall through to a path that
371
+ // Empty-but-successful is the other #217 shape: some tenants answer a
372
+ // field-scoped expression with 0 rather than a 400.
373
+ if (matched === 0) {
374
+ // Guarded like the error path below: if the translated ladder threw,
375
+ // an unguarded call here would land in the catch and run the whole
376
+ // retry a second time.
377
+ try {
378
+ const retry = await retryFieldScopedExpression(translationContext);
379
+ if (retry) return retry;
380
+ } catch (retryError) {
381
+ console.error(`Translated retry failed: ${retryError.message}`);
382
+ }
383
+ }
384
+
385
+ // Otherwise always return — never silently fall through to a path that
215
386
  // would ignore kqlQuery and return unrelated emails.
216
387
  return response;
217
388
  } catch (error) {
218
389
  console.error(`Raw KQL search failed: ${error.message}`);
219
- // Surface the failure rather than masking it with unrelated results.
220
390
  searchAttempts.push('raw-kql-error');
391
+
392
+ // A field-scoped expression is what Graph rejects here on personal
393
+ // accounts. Translating and retrying beats reporting mail that plainly
394
+ // exists as absent — the same messages come back from the equivalent
395
+ // OData filter. Anything untranslatable still surfaces the error. (#217)
396
+ try {
397
+ const retry = await retryFieldScopedExpression(translationContext);
398
+ if (retry) {
399
+ retry._searchInfo.kqlError = error.message;
400
+ return retry;
401
+ }
402
+ } catch (retryError) {
403
+ console.error(`Translated retry also failed: ${retryError.message}`);
404
+ }
405
+
406
+ // Surface the failure rather than masking it with unrelated results.
221
407
  return {
222
408
  value: [],
223
409
  _searchInfo: {
@@ -226,6 +412,7 @@ async function progressiveSearch(
226
412
  originalTerms: searchTerms,
227
413
  filterTerms: filterTerms,
228
414
  kqlError: error.message,
415
+ appliedTerms: ['kqlQuery'],
229
416
  noResults: true,
230
417
  },
231
418
  };
@@ -273,6 +460,8 @@ async function progressiveSearch(
273
460
  strategies: searchAttempts,
274
461
  originalTerms: searchTerms,
275
462
  filterTerms: filterTerms,
463
+ // The combined $filter carried every supplied term. (#229)
464
+ appliedTerms: SEARCH_TERM_KEYS.filter((t) => searchTerms[t]),
276
465
  };
277
466
  return response;
278
467
  }
@@ -311,7 +500,9 @@ async function progressiveSearch(
311
500
  simplifiedParams.$filter = buildToFilter(searchTerms[term]);
312
501
  } else if (term === 'subject') {
313
502
  // Use $filter with contains() — $search silently fails on personal MS accounts
314
- simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
503
+ simplifiedParams.$filter = `contains(subject, '${escapeODataString(
504
+ searchTerms[term]
505
+ )}')`;
315
506
  } else if (term === 'query') {
316
507
  // On personal accounts, $search fails with 503. Use $filter with
317
508
  // contains(subject) as a best-effort fallback for free-text queries.
@@ -324,7 +515,7 @@ async function progressiveSearch(
324
515
  .split(/\s+/)
325
516
  .filter(Boolean);
326
517
  simplifiedParams.$filter = queryWords
327
- .map((w) => `contains(subject, '${w.replace(/'/g, "''")}')`)
518
+ .map((w) => `contains(subject, '${escapeODataString(w)}')`)
328
519
  .join(' and ');
329
520
  }
330
521
 
@@ -339,16 +530,39 @@ async function progressiveSearch(
339
530
  maxCount
340
531
  );
341
532
  if (response.value && response.value.length > 0) {
533
+ // This $filter carried only `term`. Apply every other supplied search
534
+ // term locally before returning — otherwise we hand back the
535
+ // single-term superset while claiming the filter was applied. (#229)
536
+ const { matched, secondaryApplied } = applySecondarySearchTerms(
537
+ response.value,
538
+ searchTerms,
539
+ term
540
+ );
541
+ if (matched.length > 0) {
542
+ const narrowing =
543
+ secondaryApplied.length > 0
544
+ ? ` after local ${secondaryApplied.join(', ')} narrowing of ${response.value.length}`
545
+ : '';
546
+ console.error(
547
+ `Search with ${term} successful: found ${matched.length} results${narrowing}`
548
+ );
549
+ narrowResponse(response, matched);
550
+ response._searchInfo = {
551
+ attemptsCount: searchAttempts.length,
552
+ strategies: searchAttempts,
553
+ originalTerms: searchTerms,
554
+ filterTerms: filterTerms,
555
+ appliedTerms: [term, ...secondaryApplied],
556
+ ...(secondaryApplied.length > 0 && {
557
+ clientSideTerms: secondaryApplied,
558
+ }),
559
+ };
560
+ return response;
561
+ }
562
+ recordNarrowingMiss(scanState, response.value.length);
342
563
  console.error(
343
- `Search with ${term} successful: found ${response.value.length} results`
564
+ `Search with ${term} found ${response.value.length} results, but none also satisfied ${secondaryApplied.join(', ')} — continuing`
344
565
  );
345
- response._searchInfo = {
346
- attemptsCount: searchAttempts.length,
347
- strategies: searchAttempts,
348
- originalTerms: searchTerms,
349
- filterTerms: filterTerms,
350
- };
351
- return response;
352
566
  }
353
567
  } catch (error) {
354
568
  console.error(`Search with ${term} failed: ${error.message}`);
@@ -418,13 +632,32 @@ async function progressiveSearch(
418
632
  console.error(
419
633
  `Boolean filter search found ${response.value?.length || 0} results`
420
634
  );
421
- response._searchInfo = {
422
- attemptsCount: searchAttempts.length,
423
- strategies: searchAttempts,
424
- originalTerms: searchTerms,
425
- filterTerms: filterTerms,
426
- };
427
- return response;
635
+ // This step applied only the boolean/date filters. Narrow by the
636
+ // caller's search terms rather than returning "every unread email" as
637
+ // though it were "every unread email from X". (#229)
638
+ const { matched, secondaryApplied } = applySecondarySearchTerms(
639
+ response.value || [],
640
+ searchTerms,
641
+ null
642
+ );
643
+ if (matched.length > 0 || secondaryApplied.length === 0) {
644
+ narrowResponse(response, matched);
645
+ response._searchInfo = {
646
+ attemptsCount: searchAttempts.length,
647
+ strategies: searchAttempts,
648
+ originalTerms: searchTerms,
649
+ filterTerms: filterTerms,
650
+ appliedTerms: secondaryApplied,
651
+ ...(secondaryApplied.length > 0 && {
652
+ clientSideTerms: secondaryApplied,
653
+ }),
654
+ };
655
+ return response;
656
+ }
657
+ recordNarrowingMiss(scanState, response.value.length);
658
+ console.error(
659
+ `Boolean filter search found ${response.value.length} results, but none satisfied ${secondaryApplied.join(', ')} — continuing`
660
+ );
428
661
  } catch (error) {
429
662
  console.error(`Boolean filter search failed: ${error.message}`);
430
663
  // Retry without $orderby if it was the issue
@@ -443,13 +676,33 @@ async function progressiveSearch(
443
676
  retryParams,
444
677
  maxCount
445
678
  );
446
- response._searchInfo = {
447
- attemptsCount: searchAttempts.length,
448
- strategies: searchAttempts,
449
- originalTerms: searchTerms,
450
- filterTerms: filterTerms,
451
- };
452
- return response;
679
+ const { matched, secondaryApplied } = applySecondarySearchTerms(
680
+ response.value || [],
681
+ searchTerms,
682
+ null
683
+ );
684
+ // Same guard as the primary boolean path above: an empty set here
685
+ // means the search terms went unsatisfied, so fall through to the
686
+ // no-results rung rather than returning 0 with filterApplied true
687
+ // and no guidance.
688
+ if (matched.length > 0 || secondaryApplied.length === 0) {
689
+ narrowResponse(response, matched);
690
+ response._searchInfo = {
691
+ attemptsCount: searchAttempts.length,
692
+ strategies: searchAttempts,
693
+ originalTerms: searchTerms,
694
+ filterTerms: filterTerms,
695
+ appliedTerms: secondaryApplied,
696
+ ...(secondaryApplied.length > 0 && {
697
+ clientSideTerms: secondaryApplied,
698
+ }),
699
+ };
700
+ return response;
701
+ }
702
+ recordNarrowingMiss(scanState, response.value.length);
703
+ console.error(
704
+ `Boolean filter retry found ${response.value.length} results, but none satisfied ${secondaryApplied.join(', ')} — continuing`
705
+ );
453
706
  } catch (retryError) {
454
707
  console.error(
455
708
  `Boolean filter retry also failed: ${retryError.message}`
@@ -489,6 +742,11 @@ async function progressiveSearch(
489
742
  scanLimit: scanState.scanLimit,
490
743
  truncated: scanState.truncated,
491
744
  }),
745
+ // A local narrowing pass that matched nothing looked only at the page
746
+ // the winning filter returned, so say how much that was. (#229)
747
+ ...(scanState.narrowedCandidates !== undefined && {
748
+ narrowedCandidates: scanState.narrowedCandidates,
749
+ }),
492
750
  },
493
751
  };
494
752
  }
@@ -546,11 +804,13 @@ function buildFromFilter(val) {
546
804
  if (type === 'domain') {
547
805
  // Use contains() — endswith() not supported on personal accounts
548
806
  const domain = val.startsWith('@') ? val : `@${val}`;
549
- return `contains(from/emailAddress/address, '${domain.substring(1)}')`;
807
+ return `contains(from/emailAddress/address, '${escapeODataString(
808
+ domain.substring(1)
809
+ )}')`;
550
810
  } else if (type === 'email') {
551
- return `from/emailAddress/address eq '${val}'`;
811
+ return `from/emailAddress/address eq '${escapeODataString(val)}'`;
552
812
  }
553
- return `contains(from/emailAddress/name, '${val}')`;
813
+ return `contains(from/emailAddress/name, '${escapeODataString(val)}')`;
554
814
  }
555
815
 
556
816
  /**
@@ -563,11 +823,17 @@ function buildToFilter(val) {
563
823
  if (type === 'domain') {
564
824
  const domain = val.startsWith('@') ? val : `@${val}`;
565
825
  // Use contains() — endswith() not supported on personal accounts
566
- return `toRecipients/any(r: contains(r/emailAddress/address, '${domain.substring(1)}'))`;
826
+ return `toRecipients/any(r: contains(r/emailAddress/address, '${escapeODataString(
827
+ domain.substring(1)
828
+ )}'))`;
567
829
  } else if (type === 'email') {
568
- return `toRecipients/any(r: r/emailAddress/address eq '${val}')`;
830
+ return `toRecipients/any(r: r/emailAddress/address eq '${escapeODataString(
831
+ val
832
+ )}')`;
569
833
  }
570
- return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
834
+ return `toRecipients/any(r: contains(r/emailAddress/name, '${escapeODataString(
835
+ val
836
+ )}'))`;
571
837
  }
572
838
 
573
839
  /**
@@ -621,6 +887,150 @@ function filterQueryClientSide(messages, queryText) {
621
887
  });
622
888
  }
623
889
 
890
+ /** Relabel internal keys to the caller-facing param names. (#169) */
891
+ const FILTER_LABELS = { kqlQuery: 'searchExpression' };
892
+
893
+ /** Every filter key a caller can supply, including the terminal raw-KQL one. */
894
+ const SEARCH_FILTER_KEYS = ['from', 'to', 'subject', 'query', 'kqlQuery'];
895
+
896
+ /**
897
+ * The search terms a caller can supply, in ladder-priority order. `kqlQuery`
898
+ * is handled by the terminal raw-KQL branch and never mixes with these.
899
+ */
900
+ const SEARCH_TERM_KEYS = ['from', 'to', 'subject', 'query'];
901
+
902
+ /**
903
+ * Client-side matcher for a `from` value. Mirrors filterToClientSide, matching
904
+ * either the sender address or the display name. (#229)
905
+ * @param {Array} messages - Messages to filter
906
+ * @param {string} fromValue - The from filter value
907
+ * @returns {Array} - Matching messages
908
+ */
909
+ function filterFromClientSide(messages, fromValue) {
910
+ // Mirror buildFromFilter branch for branch. A plain substring test over both
911
+ // address and name diverges from the server in both directions: it misses
912
+ // `@example.com` against `alerts@mail.example.com` (the server strips the
913
+ // leading @ and does contains), and it wrongly keeps `bbob@x.com` for
914
+ // `bob@x.com` (the server uses eq).
915
+ const type = classifyEmailFilter(fromValue);
916
+
917
+ if (type === 'domain') {
918
+ const domain = (
919
+ fromValue.startsWith('@') ? fromValue.slice(1) : fromValue
920
+ ).toLowerCase();
921
+ return messages.filter((m) =>
922
+ (m.from?.emailAddress?.address || '').toLowerCase().includes(domain)
923
+ );
924
+ }
925
+
926
+ if (type === 'email') {
927
+ const needle = fromValue.toLowerCase();
928
+ return messages.filter(
929
+ (m) => (m.from?.emailAddress?.address || '').toLowerCase() === needle
930
+ );
931
+ }
932
+
933
+ const needle = fromValue.toLowerCase();
934
+ return messages.filter((m) =>
935
+ (m.from?.emailAddress?.name || '').toLowerCase().includes(needle)
936
+ );
937
+ }
938
+
939
+ /**
940
+ * Client-side matcher for a `subject` value — the local equivalent of the
941
+ * server-side `contains(subject, '…')`. (#229)
942
+ * @param {Array} messages - Messages to filter
943
+ * @param {string} subjectValue - The subject filter value
944
+ * @returns {Array} - Matching messages
945
+ */
946
+ function filterSubjectClientSide(messages, subjectValue) {
947
+ const needle = subjectValue.toLowerCase().trim();
948
+ if (!needle) return messages;
949
+ return messages.filter((m) =>
950
+ (m.subject || '').toLowerCase().includes(needle)
951
+ );
952
+ }
953
+
954
+ const CLIENT_SIDE_TERM_MATCHERS = {
955
+ from: filterFromClientSide,
956
+ to: filterToClientSide,
957
+ subject: filterSubjectClientSide,
958
+ query: filterQueryClientSide,
959
+ };
960
+
961
+ /**
962
+ * Narrow a result set by every supplied search term that the winning strategy
963
+ * did NOT apply server-side. (#229)
964
+ *
965
+ * Steps 2 and 3 of the ladder each satisfy at most one search term — step 2
966
+ * returns on the first single term that yields results, and step 3 applies
967
+ * only the boolean/date filters. Both used to return that set verbatim, so
968
+ * `from=X` + `subject=Y` handed back every email from X while
969
+ * `searchMetadata.filterApplied` still said `true`. For an AI caller asking
970
+ * "find the email from X about Y", a confident superset is strictly worse
971
+ * than returning nothing.
972
+ *
973
+ * Narrowing runs over the rows the winning strategy already fetched, so it can
974
+ * miss a match beyond that page; the ladder continues to the next strategy when
975
+ * it comes up empty, and `recordNarrowingMiss` records how much was examined so
976
+ * the no-results response can disclose it rather than implying the mailbox was
977
+ * exhausted.
978
+ *
979
+ * The local matchers mirror their server-side counterparts but are not
980
+ * identical to them — `filterQueryClientSide` searches the body preview, and a
981
+ * display-name match is a substring test either side — so a narrowed set is a
982
+ * close approximation of the combined filter, not a formal equivalent.
983
+ *
984
+ * @param {Array} messages - Messages returned by the winning strategy
985
+ * @param {object} searchTerms - All search terms the caller supplied
986
+ * @param {string|null} appliedTerm - The term already satisfied server-side
987
+ * @returns {{matched: Array, secondaryApplied: string[]}}
988
+ */
989
+ /**
990
+ * Replace a response's rows with the narrowed subset, keeping the reported
991
+ * totals honest. `@odata.count` was set from the pre-narrowing page, so
992
+ * leaving it makes `_meta.totalAvailable` claim matches that do not exist and
993
+ * invites a caller to paginate for them. (#229)
994
+ *
995
+ * @param {object} response - The Graph response being narrowed in place
996
+ * @param {Array} matched - The rows that survived narrowing
997
+ */
998
+ function narrowResponse(response, matched) {
999
+ const dropped = (response.value || []).length !== matched.length;
1000
+ response.value = matched;
1001
+ if (dropped) {
1002
+ delete response['@odata.count'];
1003
+ delete response['@odata.nextLink'];
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Note that a narrowing pass examined rows and matched none of them, so the
1009
+ * eventual no-results response can say how much was actually looked at rather
1010
+ * than implying the mailbox was exhausted. (#229)
1011
+ *
1012
+ * @param {object} scanState - Side-channel carried to the no-results rung
1013
+ * @param {number} examined - How many rows the narrowing pass saw
1014
+ */
1015
+ function recordNarrowingMiss(scanState, examined) {
1016
+ if (!scanState) return;
1017
+ scanState.narrowedCandidates = Math.max(
1018
+ scanState.narrowedCandidates || 0,
1019
+ examined
1020
+ );
1021
+ }
1022
+
1023
+ function applySecondarySearchTerms(messages, searchTerms, appliedTerm) {
1024
+ const secondary = SEARCH_TERM_KEYS.filter(
1025
+ (t) => t !== appliedTerm && searchTerms[t]
1026
+ );
1027
+ let matched = messages;
1028
+ for (const term of secondary) {
1029
+ matched = CLIENT_SIDE_TERM_MATCHERS[term](matched, searchTerms[term]);
1030
+ }
1031
+ return { matched, secondaryApplied: secondary };
1032
+ }
1033
+
624
1034
  /**
625
1035
  * Fetch a window of recent messages for the client-side filtering fallback.
626
1036
  * Uses the 'search' field preset (includes toRecipients and bodyPreview).
@@ -734,7 +1144,14 @@ async function runClientSideFallback(
734
1144
  kind === 'to'
735
1145
  ? filterToClientSide(messages, searchTerms.to)
736
1146
  : filterQueryClientSide(messages, searchTerms.query);
737
- const matched = applyBooleanDateFilters(termMatched, filterTerms);
1147
+ // The local matcher covers only `kind`; narrow by the caller's other search
1148
+ // terms too, or this path returns the same superset step 2 used to. (#229)
1149
+ const { matched: narrowed, secondaryApplied } = applySecondarySearchTerms(
1150
+ termMatched,
1151
+ searchTerms,
1152
+ kind
1153
+ );
1154
+ const matched = applyBooleanDateFilters(narrowed, filterTerms);
738
1155
  if (matched.length === 0) {
739
1156
  return null;
740
1157
  }
@@ -748,6 +1165,8 @@ async function runClientSideFallback(
748
1165
  strategies: searchAttempts,
749
1166
  originalTerms: searchTerms,
750
1167
  filterTerms,
1168
+ appliedTerms: [kind, ...secondaryApplied],
1169
+ ...(secondaryApplied.length > 0 && { clientSideTerms: secondaryApplied }),
751
1170
  candidatesScanned,
752
1171
  scanLimit: CLIENT_SCAN_LIMIT,
753
1172
  truncated,
@@ -785,7 +1204,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
785
1204
  // Use $filter for subject — $search silently fails on personal MS accounts
786
1205
  if (searchTerms.subject) {
787
1206
  filterConditions.push(
788
- `contains(subject, '${searchTerms.subject.replace(/'/g, "''")}')`
1207
+ `contains(subject, '${escapeODataString(searchTerms.subject)}')`
789
1208
  );
790
1209
  }
791
1210
 
@@ -887,14 +1306,103 @@ function addBooleanFilters(params, filterTerms) {
887
1306
  }
888
1307
  }
889
1308
 
1309
+ /**
1310
+ * Build no-results guidance from what the caller actually supplied and what the
1311
+ * progressive-search ladder actually attempted, rather than printing the same
1312
+ * four lines on every empty search. (#231)
1313
+ *
1314
+ * The previous fixed list included "use `from` filter instead of `to` (more
1315
+ * reliable on personal accounts)", which has been untrue since v3.7.1 added the
1316
+ * client-side `to` fallback (#139 / PR #141). The tool was teaching its callers
1317
+ * something false about itself.
1318
+ *
1319
+ * @param {object} searchInfo - The _searchInfo block from progressiveSearch
1320
+ * @param {boolean} searchAllFolders - Whether the search already spanned all folders
1321
+ * @returns {string[]} - Ordered suggestion lines, without the leading bullet
1322
+ */
1323
+ function buildNoResultsSuggestions(searchInfo, searchAllFolders) {
1324
+ const filters = searchInfo.originalTerms || {};
1325
+ const strategies = searchInfo.strategies || [];
1326
+ const suggestions = [];
1327
+
1328
+ // Scope — only worth suggesting when it isn't already what we just did.
1329
+ if (searchAllFolders) {
1330
+ suggestions.push(
1331
+ 'All folders were already searched, so no message in this mailbox matches these filters'
1332
+ );
1333
+ } else {
1334
+ suggestions.push(
1335
+ 'Try `searchAllFolders: true` to search across all folders including Archive'
1336
+ );
1337
+ suggestions.push(
1338
+ 'Specify the correct folder if emails have been moved (use the `folders` tool to list folders)'
1339
+ );
1340
+ }
1341
+
1342
+ // Report the fallback the ladder actually took, instead of guessing at one.
1343
+ const clientSide = strategies.filter((s) => s.startsWith('client-side-'));
1344
+ if (clientSide.length > 0) {
1345
+ const fields = clientSide
1346
+ .map((s) => `\`${s.slice('client-side-'.length)}\``)
1347
+ .join(', ');
1348
+ let note = `${fields} was matched locally after the server-side filter came back empty`;
1349
+ if (searchInfo.candidatesScanned) {
1350
+ note += ` — ${searchInfo.candidatesScanned} recent messages examined`;
1351
+ if (searchInfo.truncated) {
1352
+ note += `, hitting the ${searchInfo.scanLimit} scan limit, so older matches were not seen (narrow with \`receivedAfter\`)`;
1353
+ }
1354
+ }
1355
+ suggestions.push(note);
1356
+ }
1357
+
1358
+ // A narrowing pass that matched nothing saw only the page the winning
1359
+ // server-side filter returned — a much weaker basis for "none exists" than
1360
+ // the bounded client-side scan, so say so rather than implying otherwise.
1361
+ if (searchInfo.narrowedCandidates) {
1362
+ suggestions.push(
1363
+ `The other filters were applied locally to the ${searchInfo.narrowedCandidates} message${searchInfo.narrowedCandidates === 1 ? '' : 's'} the server-side filter returned, so a match beyond that page would not have been seen — raise \`count\`, or narrow with \`receivedAfter\``
1364
+ );
1365
+ }
1366
+
1367
+ if (filters.kqlQuery) {
1368
+ suggestions.push(
1369
+ 'Structured filters (`from`, `to`, `subject`) reach messages that `searchExpression` cannot on personal accounts'
1370
+ );
1371
+ }
1372
+
1373
+ if (filters.query) {
1374
+ suggestions.push(
1375
+ 'Free-text `query` falls back to a subject match on personal accounts — try `subject` directly, or fewer words'
1376
+ );
1377
+ }
1378
+
1379
+ if (filters.subject) {
1380
+ suggestions.push(
1381
+ '`subject` is a substring match — try a shorter, more distinctive fragment'
1382
+ );
1383
+ }
1384
+
1385
+ const applied = ['from', 'to', 'subject', 'query', 'kqlQuery'].filter(
1386
+ (k) => filters[k]
1387
+ );
1388
+ if (applied.length > 1) {
1389
+ suggestions.push(
1390
+ `All ${applied.length} filters must match the same message — try removing one`
1391
+ );
1392
+ }
1393
+
1394
+ return suggestions;
1395
+ }
1396
+
890
1397
  /**
891
1398
  * Format search results into Markdown using response-formatter utilities
892
1399
  * @param {object} response - The API response object
893
1400
  * @param {string} folder - Folder that was searched
894
1401
  * @param {string} verbosity - Output verbosity level
1402
+ * @param {boolean} [searchAllFolders] - Whether the search spanned all folders
895
1403
  * @returns {object} - MCP response object
896
1404
  */
897
- function formatSearchResults(response, folder, verbosity) {
1405
+ function formatSearchResults(response, folder, verbosity, searchAllFolders) {
898
1406
  // Build metadata
899
1407
  const meta = {
900
1408
  returned: (response.value || []).length,
@@ -909,11 +1417,35 @@ function formatSearchResults(response, folder, verbosity) {
909
1417
  response._searchInfo.strategies[
910
1418
  response._searchInfo.strategies.length - 1
911
1419
  ];
1420
+ // Which supplied filters actually reached the result set, and which the
1421
+ // winning strategy could not honour. `droppedFilters` is normally empty;
1422
+ // a non-empty value means the response is a superset of what was asked
1423
+ // for, and `filterApplied` must not claim otherwise. (#229)
1424
+ const suppliedTerms = SEARCH_FILTER_KEYS.filter(
1425
+ (k) => response._searchInfo.originalTerms?.[k]
1426
+ );
1427
+ const appliedTerms = response._searchInfo.appliedTerms ?? suppliedTerms;
1428
+ const droppedFilters = suppliedTerms
1429
+ .filter((k) => !appliedTerms.includes(k))
1430
+ .map((k) => FILTER_LABELS[k] || k);
1431
+
912
1432
  meta.searchMetadata = {
913
1433
  strategiesAttempted: response._searchInfo.strategies,
914
1434
  finalStrategy: finalStrategy,
915
- filterApplied: !response._searchInfo.noResults,
1435
+ filterApplied:
1436
+ !response._searchInfo.noResults && droppedFilters.length === 0,
1437
+ droppedFilters,
1438
+ ...(response._searchInfo.clientSideTerms && {
1439
+ clientSideFilters: response._searchInfo.clientSideTerms,
1440
+ }),
916
1441
  originalFilters: response._searchInfo.originalTerms,
1442
+ // A field-scoped `searchExpression` that personal accounts reject is
1443
+ // retried as OData filters, which is a rewrite of what the caller asked
1444
+ // for. Report the rewrite, so `raw-kql-translated` is inspectable rather
1445
+ // than something the caller has to take on trust. (#217)
1446
+ ...(response._searchInfo.kqlTranslatedTo && {
1447
+ kqlTranslatedTo: response._searchInfo.kqlTranslatedTo,
1448
+ }),
917
1449
  // Surface client-side scan coverage so callers can tell when a fallback
918
1450
  // result may be incomplete (older matches beyond the scan budget). (#169)
919
1451
  ...(response._searchInfo.candidatesScanned !== undefined && {
@@ -929,8 +1461,6 @@ function formatSearchResults(response, folder, verbosity) {
929
1461
  // Actionable guidance when filters were specified but matched nothing
930
1462
  if (response._searchInfo?.noResults) {
931
1463
  const filters = response._searchInfo.originalTerms || {};
932
- // Relabel internal keys to the caller-facing param names. (#169)
933
- const FILTER_LABELS = { kqlQuery: 'searchExpression' };
934
1464
  const activeFilters = Object.entries(filters)
935
1465
  .filter(([, v]) => v)
936
1466
  .map(([k]) => FILTER_LABELS[k] || k);
@@ -939,13 +1469,13 @@ function formatSearchResults(response, folder, verbosity) {
939
1469
  ? ` (filters: ${activeFilters.join(', ')})`
940
1470
  : '';
941
1471
 
942
- const text =
943
- `No emails found matching your filters in "${folder}"${filterDesc}.\n\n` +
944
- '**Suggestions:**\n' +
945
- '- Try `searchAllFolders: true` to search across all folders including Archive\n' +
946
- '- Specify the correct folder if emails have been moved (use `folders` tool to list folders)\n' +
947
- '- Use `from` filter instead of `to` (more reliable on personal accounts)\n' +
948
- '- Use `searchExpression` with `searchAllFolders: true` for cross-folder search';
1472
+ const suggestions = buildNoResultsSuggestions(
1473
+ response._searchInfo,
1474
+ searchAllFolders
1475
+ );
1476
+
1477
+ const bullets = suggestions.map((line) => `- ${line}`).join('\n');
1478
+ const text = `No emails found matching your filters in "${folder}"${filterDesc}.\n\n**Suggestions:**\n${bullets}`;
949
1479
 
950
1480
  return {
951
1481
  content: [{ type: 'text', text }],
@@ -1029,7 +1559,7 @@ async function handleSearchByMessageId(args) {
1029
1559
 
1030
1560
  // Build filter - need to escape the Message-ID properly
1031
1561
  // Graph API expects: internetMessageId eq '<value>'
1032
- const escapedMessageId = messageId.replace(/'/g, "''");
1562
+ const escapedMessageId = escapeODataString(messageId);
1033
1563
 
1034
1564
  const params = {
1035
1565
  $filter: `internetMessageId eq '${escapedMessageId}'`,
@@ -1112,4 +1642,6 @@ module.exports = {
1112
1642
  classifyEmailFilter,
1113
1643
  filterToClientSide,
1114
1644
  filterQueryClientSide,
1645
+ filterFromClientSide,
1646
+ filterSubjectClientSide,
1115
1647
  };
package/llms.txt CHANGED
@@ -22,7 +22,7 @@ Built by [Little Bear Apps](https://littlebearapps.com).
22
22
 
23
23
  ## Key Differentiators
24
24
 
25
- - **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
25
+ - **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback, and `_meta.searchMetadata` reports which strategy answered plus any filter that could not be honoured (`droppedFilters`), so a partially-applied search can never pass as a complete one. Field-scoped `$search` expressions (`from:`/`to:`/`subject:`), which personal accounts reject outright, are translated to equivalent OData filters and retried. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
26
26
  - **Remote-friendly auth**: Device code flow (default) — no auth server, no port forwarding, no SSH tunnels. State persists across MCP server restarts. Works from Untether, mosh, SSH, and headless environments.
27
27
  - **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
28
28
  - **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
79
79
  - [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
80
80
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
81
81
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
82
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.9.0 — nested folder addressing by path/ID (#216); reliable cross-folder search with `kqlQuery` renamed to `searchExpression` (#169); plus the v3.8.3 patch — security `overrides` clearing the audit gate (#215), `openWorldHint` on external-content tools (#92), canonical UTC ISO-8601 `list-events` times (#118))
83
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.7.5 polish, v3.8.x carry-over, v3.10.0+ new Graph APIs)
82
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217); two-filter searches no longer return the single-filter superset, with `searchMetadata.droppedFilters` reporting anything unhonoured (#229); `from`/`to` filter values OData-escaped (#230); no-results guidance derived from what was actually attempted (#231); production `npm audit` gate cleared plus a weekly watchdog (#215). Preceded by v3.9.1 — packaging hotfix restoring `request-handler.js` in the published tarball (#223) — and v3.9.0 — nested folder addressing by path/ID (#216) and reliable cross-folder search with `kqlQuery` renamed to `searchExpression` (#169))
83
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.7.5 polish, v3.8.x carry-over, v3.11.0+ new Graph APIs)
84
84
  - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
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",
@@ -58,6 +58,7 @@
58
58
  "files": [
59
59
  "index.js",
60
60
  "config.js",
61
+ "request-handler.js",
61
62
  "outlook-auth-server.js",
62
63
  "auth/",
63
64
  "calendar/",
@@ -97,8 +98,11 @@
97
98
  },
98
99
  "overrides": {
99
100
  "minimatch": ">=3.1.3",
100
- "hono": "^4.12.31",
101
- "fast-uri": "^3.1.4",
101
+ "hono": "^4.13.7",
102
+ "@hono/node-server": "^1.19.17",
103
+ "fast-uri": "^3.1.7",
104
+ "ip-address": "^10.7.0",
105
+ "qs": "^6.16.0",
102
106
  "body-parser": "^2.3.0"
103
107
  }
104
108
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * MCP request dispatcher for the Outlook Assistant server.
3
+ *
4
+ * Extracted from index.js so the dispatch + error-shaping logic is
5
+ * unit-testable without starting the stdio transport.
6
+ *
7
+ * IMPORTANT (#213): a `tools/call` that fails MUST return a visible MCP
8
+ * tool-error result (`{ content: [...], isError: true }`). Returning a
9
+ * content-less `{ error: {...} }` object gets coerced by the SDK into
10
+ * `{ content: [] }`, which the client renders as EMPTY OUTPUT — the exact
11
+ * symptom reported for device-code auth in a remote connector session.
12
+ */
13
+ const config = require('./config');
14
+ const { coerceArgsAgainstSchema } = require('./utils/schema-coerce');
15
+
16
+ /**
17
+ * Build the MCP fallbackRequestHandler for a given tool set.
18
+ * @param {Array<{name: string, description?: string, inputSchema?: object, annotations?: object, handler?: Function}>} TOOLS
19
+ * @returns {(request: object) => Promise<object>}
20
+ */
21
+ function createRequestHandler(TOOLS) {
22
+ return async (request) => {
23
+ try {
24
+ const { method, params, id } = request;
25
+ console.error(`REQUEST: ${method} [${id}]`);
26
+
27
+ // Initialize handler
28
+ if (method === 'initialize') {
29
+ console.error(`INITIALIZE REQUEST: ID [${id}]`);
30
+ return {
31
+ protocolVersion: '2024-11-05',
32
+ capabilities: {
33
+ tools: TOOLS.reduce((acc, tool) => {
34
+ acc[tool.name] = {};
35
+ return acc;
36
+ }, {}),
37
+ },
38
+ serverInfo: {
39
+ name: config.SERVER_NAME,
40
+ version: config.SERVER_VERSION,
41
+ },
42
+ };
43
+ }
44
+
45
+ // Tools list handler
46
+ if (method === 'tools/list') {
47
+ console.error(`TOOLS LIST REQUEST: ID [${id}]`);
48
+ console.error(`TOOLS COUNT: ${TOOLS.length}`);
49
+ console.error(`TOOLS NAMES: ${TOOLS.map((t) => t.name).join(', ')}`);
50
+
51
+ return {
52
+ tools: TOOLS.map((tool) => ({
53
+ name: tool.name,
54
+ description: tool.description,
55
+ inputSchema: tool.inputSchema,
56
+ ...(tool.annotations && { annotations: tool.annotations }),
57
+ })),
58
+ };
59
+ }
60
+
61
+ // Required empty responses for other capabilities
62
+ if (method === 'resources/list') return { resources: [] };
63
+ if (method === 'prompts/list') return { prompts: [] };
64
+
65
+ // Tool call handler
66
+ if (method === 'tools/call') {
67
+ try {
68
+ const { name, arguments: args = {} } = params || {};
69
+
70
+ console.error(`TOOL CALL: ${name}`);
71
+
72
+ // Find the tool handler
73
+ const tool = TOOLS.find((t) => t.name === name);
74
+
75
+ if (tool && tool.handler) {
76
+ // Coerce + validate args against the tool's inputSchema before
77
+ // dispatching. Catches array-as-string, boolean-as-string, unknown
78
+ // params, and out-of-enum action values at the MCP boundary so
79
+ // handlers receive properly-typed JS values. (#160, #162)
80
+ if (tool.inputSchema) {
81
+ const coerced = coerceArgsAgainstSchema(args, tool.inputSchema);
82
+ if (coerced.error) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: 'text',
87
+ text: `Invalid arguments for tool '${name}':\n${coerced.error}`,
88
+ },
89
+ ],
90
+ isError: true,
91
+ };
92
+ }
93
+ return await tool.handler(coerced.args);
94
+ }
95
+ return await tool.handler(args);
96
+ }
97
+
98
+ // Tool not found — return visible isError content, not a
99
+ // content-less { error } (which renders as empty output). (#213)
100
+ return {
101
+ content: [
102
+ {
103
+ type: 'text',
104
+ text: `Tool not found: ${name}`,
105
+ },
106
+ ],
107
+ isError: true,
108
+ };
109
+ } catch (error) {
110
+ console.error(`Error in tools/call:`, error);
111
+ // Surface the failure as visible tool-error content so it is not
112
+ // silently rendered as empty output by the client. (#213)
113
+ return {
114
+ content: [
115
+ {
116
+ type: 'text',
117
+ text: `Error processing tool call: ${error.message}`,
118
+ },
119
+ ],
120
+ isError: true,
121
+ };
122
+ }
123
+ }
124
+
125
+ // For any other method, return method not found
126
+ return {
127
+ error: {
128
+ code: -32601,
129
+ message: `Method not found: ${method}`,
130
+ },
131
+ };
132
+ } catch (error) {
133
+ console.error(`Error in fallbackRequestHandler:`, error);
134
+ return {
135
+ error: {
136
+ code: -32603,
137
+ message: `Error processing request: ${error.message}`,
138
+ },
139
+ };
140
+ }
141
+ };
142
+ }
143
+
144
+ module.exports = { createRequestHandler };