@littlebearapps/outlook-assistant 3.5.1 → 3.5.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/README.md CHANGED
@@ -149,10 +149,11 @@ npx @littlebearapps/outlook-assistant
149
149
  You need a Microsoft Azure app registration to authenticate. See the **[Azure Setup Guide](docs/guides/azure-setup.md)** for a detailed walkthrough (including first-time Azure account creation), or if you've done this before:
150
150
 
151
151
  1. Create a new app registration at [portal.azure.com](https://portal.azure.com/)
152
- 2. Set redirect URI to `http://localhost:3333/auth/callback`
153
- 3. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
154
- 4. Create a client secret and copy the **Value** (not the Secret ID)
155
- 5. Enable **"Allow public client flows"** in Authentication > Advanced settings (for device code flow)
152
+ 2. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
153
+ 3. Create a client secret and copy the **Value** (not the Secret ID)
154
+ 4. Under Authentication > **Add a platform** > **Mobile and desktop applications** — check `nativeclient` URI
155
+ 5. Enable **"Allow public client flows"** in Authentication > Advanced settings
156
+ 6. _(Optional)_ Set redirect URI to `http://localhost:3333/auth/callback` — only needed for browser auth flow
156
157
 
157
158
  ### 3. Configure Your MCP Client
158
159
 
package/auth/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const tokenManager = require('./token-manager');
5
5
  const TokenStorage = require('./token-storage');
6
6
  const config = require('../config');
7
- const { authTools } = require('./tools');
7
+ const { authTools, setToolCount } = require('./tools');
8
8
 
9
9
  // Singleton TokenStorage instance with auto-refresh support
10
10
  const tokenStorage = new TokenStorage({
@@ -39,5 +39,6 @@ module.exports = {
39
39
  tokenManager, // deprecated: use tokenStorage
40
40
  tokenStorage,
41
41
  authTools,
42
+ setToolCount,
42
43
  ensureAuthenticated,
43
44
  };
package/auth/tools.js CHANGED
@@ -5,6 +5,12 @@ const config = require('../config');
5
5
  const tokenManager = require('./token-manager');
6
6
  const { initiateDeviceCodeFlow, pollForToken } = require('./device-code');
7
7
 
8
+ // Dynamic tool count — set by index.js after TOOLS array is built
9
+ let _toolCount = 0;
10
+ function setToolCount(count) {
11
+ _toolCount = count;
12
+ }
13
+
8
14
  /**
9
15
  * About tool handler
10
16
  * @returns {object} - MCP response
@@ -25,7 +31,7 @@ async function handleAbout() {
25
31
  `## Diagnostics\n`,
26
32
  `| Setting | Value |`,
27
33
  `|---------|-------|`,
28
- `| Tools | 20 across 9 modules |`,
34
+ `| Tools | ${_toolCount} across 9 modules |`,
29
35
  `| Modules | auth, email, calendar, folder, rules, contacts, categories, settings, advanced |`,
30
36
  `| Timezone | ${config.DEFAULT_TIMEZONE} |`,
31
37
  `| Test Mode | ${testMode} |`,
@@ -320,6 +326,7 @@ const authTools = [
320
326
 
321
327
  module.exports = {
322
328
  authTools,
329
+ setToolCount,
323
330
  handleAbout,
324
331
  handleAuthenticate,
325
332
  handleDeviceCodeAuth,
@@ -305,17 +305,26 @@ async function handleUpdateCategory(args) {
305
305
  updateData
306
306
  );
307
307
 
308
- const colorName = COLOR_NAMES[response.color] || response.color;
308
+ // Prefer input values over response (PATCH may return partial data)
309
+ const updatedName = displayName || response.displayName;
310
+ const updatedColor = color || response.color;
311
+ const colorName = COLOR_NAMES[updatedColor] || updatedColor;
312
+
313
+ const updatedCategory = {
314
+ ...response,
315
+ displayName: updatedName,
316
+ color: updatedColor,
317
+ };
309
318
 
310
319
  return {
311
320
  content: [
312
321
  {
313
322
  type: 'text',
314
- text: `Category updated!\n\n**Name**: ${response.displayName}\n**Color**: ${colorName} (${response.color})\n**ID**: ${response.id}`,
323
+ text: `Category updated!\n\n**Name**: ${updatedName}\n**Color**: ${colorName} (${updatedColor})\n**ID**: ${response.id || id}`,
315
324
  },
316
325
  ],
317
326
  _meta: {
318
- category: formatCategory(response),
327
+ category: formatCategory(updatedCategory),
319
328
  },
320
329
  };
321
330
  } catch (error) {
@@ -428,7 +437,9 @@ async function handleApplyCategory(args) {
428
437
  };
429
438
  }
430
439
 
431
- if (!categories || !Array.isArray(categories) || categories.length === 0) {
440
+ const applyAction = action || 'set'; // 'set', 'add', 'remove'
441
+
442
+ if (!categories || !Array.isArray(categories)) {
432
443
  return {
433
444
  content: [
434
445
  {
@@ -439,7 +450,17 @@ async function handleApplyCategory(args) {
439
450
  };
440
451
  }
441
452
 
442
- const applyAction = action || 'set'; // 'set', 'add', 'remove'
453
+ // Empty array is only valid for action=set (clears all categories)
454
+ if (categories.length === 0 && applyAction !== 'set') {
455
+ return {
456
+ content: [
457
+ {
458
+ type: 'text',
459
+ text: 'Categories array cannot be empty for add/remove. Use action=set with an empty array to clear all categories.',
460
+ },
461
+ ],
462
+ };
463
+ }
443
464
 
444
465
  try {
445
466
  const accessToken = await ensureAuthenticated();
@@ -17,6 +17,8 @@ const {
17
17
  formatEmailsAsCSV,
18
18
  VERBOSITY,
19
19
  } = require('../utils/response-formatter');
20
+ // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
21
+ // InefficientFilter on personal accounts with $orderby. Client-side filtering used instead.
20
22
 
21
23
  /**
22
24
  * Format a date for filenames
@@ -65,10 +67,12 @@ async function handleListConversations(args) {
65
67
  'id',
66
68
  'subject',
67
69
  'from',
70
+ 'toRecipients',
68
71
  'receivedDateTime',
69
72
  'conversationId',
70
73
  'conversationIndex',
71
74
  'isRead',
75
+ 'hasAttachments',
72
76
  'bodyPreview',
73
77
  ].join(',');
74
78
 
@@ -79,6 +83,34 @@ async function handleListConversations(args) {
79
83
  $top: 200, // Get more to group
80
84
  };
81
85
 
86
+ // Apply simple $filter conditions that Graph API supports on personal accounts
87
+ // Complex filters (contains on subject, endswith on email) cause InefficientFilter
88
+ // errors, so those are handled client-side after fetching.
89
+ const serverFilterConditions = [];
90
+ if (args.hasAttachments === true) {
91
+ serverFilterConditions.push('hasAttachments eq true');
92
+ }
93
+ if (args.receivedAfter) {
94
+ try {
95
+ const afterDate = new Date(args.receivedAfter).toISOString();
96
+ serverFilterConditions.push(`receivedDateTime ge ${afterDate}`);
97
+ } catch (_e) {
98
+ /* ignore invalid date */
99
+ }
100
+ }
101
+ if (args.receivedBefore) {
102
+ try {
103
+ const beforeDate = new Date(args.receivedBefore).toISOString();
104
+ serverFilterConditions.push(`receivedDateTime le ${beforeDate}`);
105
+ } catch (_e) {
106
+ /* ignore invalid date */
107
+ }
108
+ }
109
+
110
+ if (serverFilterConditions.length > 0) {
111
+ queryParams.$filter = serverFilterConditions.join(' and ');
112
+ }
113
+
82
114
  const response = await callGraphAPI(
83
115
  accessToken,
84
116
  'GET',
@@ -86,7 +118,33 @@ async function handleListConversations(args) {
86
118
  null,
87
119
  queryParams
88
120
  );
89
- const messages = response.value || [];
121
+ let messages = response.value || [];
122
+
123
+ // Client-side filtering for conditions that cause InefficientFilter on personal accounts
124
+ if (args.subject) {
125
+ const subjectLower = args.subject.toLowerCase();
126
+ messages = messages.filter((m) =>
127
+ (m.subject || '').toLowerCase().includes(subjectLower)
128
+ );
129
+ }
130
+ if (args.from) {
131
+ const fromLower = args.from.toLowerCase();
132
+ messages = messages.filter((m) => {
133
+ const addr = (m.from?.emailAddress?.address || '').toLowerCase();
134
+ const name = (m.from?.emailAddress?.name || '').toLowerCase();
135
+ return addr.includes(fromLower) || name.includes(fromLower);
136
+ });
137
+ }
138
+ if (args.to) {
139
+ const toLower = args.to.toLowerCase();
140
+ messages = messages.filter((m) =>
141
+ (m.toRecipients || []).some((r) => {
142
+ const addr = (r.emailAddress?.address || '').toLowerCase();
143
+ const name = (r.emailAddress?.name || '').toLowerCase();
144
+ return addr.includes(toLower) || name.includes(toLower);
145
+ })
146
+ );
147
+ }
90
148
 
91
149
  // Group by conversationId
92
150
  const conversations = new Map();
@@ -146,13 +146,46 @@ async function handleGetMailTips(args) {
146
146
  };
147
147
  }
148
148
 
149
+ // Normalise recipients to a clean array of email strings
150
+ let recipientList;
151
+ if (Array.isArray(recipients)) {
152
+ recipientList = recipients.map((e) => String(e).trim());
153
+ } else if (typeof recipients === 'string') {
154
+ // Handle JSON array strings like '["a@b.com","c@d.com"]' from MCP clients
155
+ const trimmed = recipients.trim();
156
+ if (trimmed.startsWith('[')) {
157
+ try {
158
+ recipientList = JSON.parse(trimmed).map((e) => String(e).trim());
159
+ } catch (_e) {
160
+ // Fall through to comma-split
161
+ }
162
+ }
163
+ if (!recipientList) {
164
+ recipientList = trimmed.split(',').map((e) => e.trim());
165
+ }
166
+ } else {
167
+ recipientList = [String(recipients).trim()];
168
+ }
169
+
170
+ // Filter out empty strings
171
+ recipientList = recipientList.filter((e) => e.length > 0);
172
+
173
+ if (recipientList.length === 0) {
174
+ return {
175
+ content: [
176
+ {
177
+ type: 'text',
178
+ text: 'At least one valid recipient email address is required.',
179
+ },
180
+ ],
181
+ };
182
+ }
183
+
149
184
  try {
150
185
  const accessToken = await ensureAuthenticated();
151
186
 
152
187
  const requestBody = {
153
- EmailAddresses: Array.isArray(recipients)
154
- ? recipients
155
- : recipients.split(',').map((e) => e.trim()),
188
+ EmailAddresses: recipientList,
156
189
  MailTipsOptions: tipTypes || MAIL_TIP_TYPES.join(','),
157
190
  };
158
191
 
package/email/search.js CHANGED
@@ -152,33 +152,47 @@ async function progressiveSearch(
152
152
  }
153
153
  }
154
154
 
155
- // 1. Try combined search (most specific)
156
- try {
157
- const params = buildSearchParams(
158
- searchTerms,
159
- filterTerms,
160
- Math.min(50, maxCount),
161
- selectFields
162
- );
163
- console.error('Attempting combined search with params:', params);
164
- searchAttempts.push('combined-search');
155
+ // Check if we have any actual search terms (not just boolean filters)
156
+ const hasSearchTerms =
157
+ searchTerms.query ||
158
+ searchTerms.from ||
159
+ searchTerms.to ||
160
+ searchTerms.subject;
161
+
162
+ // 1. Try combined search (most specific) — skip if only boolean filters
163
+ if (
164
+ !hasSearchTerms &&
165
+ (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true)
166
+ ) {
167
+ // Skip directly to boolean-only filter (step 3) — combined search is redundant
168
+ console.error('Only boolean filters provided, skipping combined search');
169
+ } else
170
+ try {
171
+ const params = buildSearchParams(
172
+ searchTerms,
173
+ filterTerms,
174
+ Math.min(50, maxCount),
175
+ selectFields
176
+ );
177
+ console.error('Attempting combined search with params:', params);
178
+ searchAttempts.push('combined-search');
165
179
 
166
- const response = await callGraphAPIPaginated(
167
- accessToken,
168
- 'GET',
169
- endpoint,
170
- params,
171
- maxCount
172
- );
173
- if (response.value && response.value.length > 0) {
174
- console.error(
175
- `Combined search successful: found ${response.value.length} results`
180
+ const response = await callGraphAPIPaginated(
181
+ accessToken,
182
+ 'GET',
183
+ endpoint,
184
+ params,
185
+ maxCount
176
186
  );
177
- return response;
187
+ if (response.value && response.value.length > 0) {
188
+ console.error(
189
+ `Combined search successful: found ${response.value.length} results`
190
+ );
191
+ return response;
192
+ }
193
+ } catch (error) {
194
+ console.error(`Combined search failed: ${error.message}`);
178
195
  }
179
- } catch (error) {
180
- console.error(`Combined search failed: ${error.message}`);
181
- }
182
196
 
183
197
  // 2. Try each search term individually, starting with most specific
184
198
  const searchPriority = ['from', 'to', 'subject', 'query'];
@@ -200,23 +214,16 @@ async function progressiveSearch(
200
214
  // $search for free-text query only.
201
215
  // NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
202
216
  if (term === 'from') {
203
- if (searchTerms[term].includes('@')) {
204
- simplifiedParams.$filter = `from/emailAddress/address eq '${searchTerms[term]}'`;
205
- } else {
206
- simplifiedParams.$filter = `contains(from/emailAddress/name, '${searchTerms[term]}')`;
207
- }
217
+ simplifiedParams.$filter = buildFromFilter(searchTerms[term]);
208
218
  } else if (term === 'to') {
209
- if (searchTerms[term].includes('@')) {
210
- simplifiedParams.$filter = `toRecipients/any(r: r/emailAddress/address eq '${searchTerms[term]}')`;
211
- } else {
212
- simplifiedParams.$filter = `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms[term]}'))`;
213
- }
219
+ simplifiedParams.$filter = buildToFilter(searchTerms[term]);
214
220
  } else if (term === 'subject') {
215
221
  // Use $filter with contains() — $search silently fails on personal MS accounts
216
222
  simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
217
223
  } else if (term === 'query') {
218
- simplifiedParams.$orderby = 'receivedDateTime desc';
219
- simplifiedParams.$search = `"${searchTerms[term]}"`;
224
+ // On personal accounts, $search fails with 503. Use $filter with
225
+ // contains(subject) as a best-effort fallback for free-text queries.
226
+ simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
220
227
  }
221
228
 
222
229
  // Add boolean filters if applicable
@@ -241,21 +248,29 @@ async function progressiveSearch(
241
248
  }
242
249
  }
243
250
 
244
- // 3. Try with only boolean filters
245
- if (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true) {
251
+ // 3. Try with only boolean filters (also date range filters)
252
+ const hasBooleanFilters =
253
+ filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
254
+ const hasDateFilters =
255
+ filterTerms.receivedAfter || filterTerms.receivedBefore;
256
+ if (hasBooleanFilters || hasDateFilters) {
246
257
  try {
247
- console.error('Attempting search with only boolean filters');
258
+ console.error('Attempting search with only boolean/date filters');
248
259
  searchAttempts.push('boolean-filters-only');
249
260
 
250
261
  const filterOnlyParams = {
251
262
  $top: Math.min(50, maxCount),
252
263
  $select: selectFields,
253
- $orderby: 'receivedDateTime desc',
254
264
  };
255
265
 
256
- // Add the boolean filters
266
+ // Add the boolean + date filters
257
267
  addBooleanFilters(filterOnlyParams, filterTerms);
258
268
 
269
+ // Only add $orderby if no $filter (they can conflict on personal accounts)
270
+ if (!filterOnlyParams.$filter) {
271
+ filterOnlyParams.$orderby = 'receivedDateTime desc';
272
+ }
273
+
259
274
  const response = await callGraphAPIPaginated(
260
275
  accessToken,
261
276
  'GET',
@@ -269,6 +284,29 @@ async function progressiveSearch(
269
284
  return response;
270
285
  } catch (error) {
271
286
  console.error(`Boolean filter search failed: ${error.message}`);
287
+ // Retry without $orderby if it was the issue
288
+ if (error.message && error.message.includes('InefficientFilter')) {
289
+ try {
290
+ console.error('Retrying boolean filters without $orderby');
291
+ const retryParams = {
292
+ $top: Math.min(50, maxCount),
293
+ $select: selectFields,
294
+ };
295
+ addBooleanFilters(retryParams, filterTerms);
296
+ const response = await callGraphAPIPaginated(
297
+ accessToken,
298
+ 'GET',
299
+ endpoint,
300
+ retryParams,
301
+ maxCount
302
+ );
303
+ return response;
304
+ } catch (retryError) {
305
+ console.error(
306
+ `Boolean filter retry also failed: ${retryError.message}`
307
+ );
308
+ }
309
+ }
272
310
  }
273
311
  }
274
312
 
@@ -304,6 +342,54 @@ async function progressiveSearch(
304
342
  return response;
305
343
  }
306
344
 
345
+ /**
346
+ * Detect if a value is a domain-only filter (e.g. "@souliv.com.au" or "souliv.com.au")
347
+ * vs a full email address (e.g. "user@souliv.com.au") vs a display name (e.g. "Billie")
348
+ * @param {string} val - The from/to filter value
349
+ * @returns {'domain'|'email'|'name'} - The type of filter
350
+ */
351
+ function classifyEmailFilter(val) {
352
+ if (val.startsWith('@')) return 'domain';
353
+ // Has dots but no @ — likely a domain like "souliv.com.au"
354
+ if (!val.includes('@') && val.includes('.')) return 'domain';
355
+ if (val.includes('@')) return 'email';
356
+ return 'name';
357
+ }
358
+
359
+ /**
360
+ * Build a $filter condition for a from field value
361
+ * @param {string} val - The from filter value
362
+ * @returns {string} - OData $filter condition
363
+ */
364
+ function buildFromFilter(val) {
365
+ const type = classifyEmailFilter(val);
366
+ if (type === 'domain') {
367
+ // Use contains() — endswith() not supported on personal accounts
368
+ const domain = val.startsWith('@') ? val : `@${val}`;
369
+ return `contains(from/emailAddress/address, '${domain.substring(1)}')`;
370
+ } else if (type === 'email') {
371
+ return `from/emailAddress/address eq '${val}'`;
372
+ }
373
+ return `contains(from/emailAddress/name, '${val}')`;
374
+ }
375
+
376
+ /**
377
+ * Build a $filter condition for a to field value
378
+ * @param {string} val - The to filter value
379
+ * @returns {string} - OData $filter condition
380
+ */
381
+ function buildToFilter(val) {
382
+ const type = classifyEmailFilter(val);
383
+ if (type === 'domain') {
384
+ const domain = val.startsWith('@') ? val : `@${val}`;
385
+ // Use contains() — endswith() not supported on personal accounts
386
+ return `toRecipients/any(r: contains(r/emailAddress/address, '${domain.substring(1)}'))`;
387
+ } else if (type === 'email') {
388
+ return `toRecipients/any(r: r/emailAddress/address eq '${val}')`;
389
+ }
390
+ return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
391
+ }
392
+
307
393
  /**
308
394
  * Build search parameters from search terms and filter terms
309
395
  * Uses $filter for email addresses (more reliable than $search)
@@ -342,28 +428,12 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
342
428
  // NOTE: $filter on email addresses is incompatible with $orderby - Graph API limitation
343
429
  if (searchTerms.from) {
344
430
  usesEmailFilter = true;
345
- if (searchTerms.from.includes('@')) {
346
- filterConditions.push(
347
- `from/emailAddress/address eq '${searchTerms.from}'`
348
- );
349
- } else {
350
- filterConditions.push(
351
- `contains(from/emailAddress/name, '${searchTerms.from}')`
352
- );
353
- }
431
+ filterConditions.push(buildFromFilter(searchTerms.from));
354
432
  }
355
433
 
356
434
  if (searchTerms.to) {
357
435
  usesEmailFilter = true;
358
- if (searchTerms.to.includes('@')) {
359
- filterConditions.push(
360
- `toRecipients/any(r: r/emailAddress/address eq '${searchTerms.to}')`
361
- );
362
- } else {
363
- filterConditions.push(
364
- `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms.to}'))`
365
- );
366
- }
436
+ filterConditions.push(buildToFilter(searchTerms.to));
367
437
  }
368
438
 
369
439
  // Add boolean filters (these ARE compatible with $orderby)
@@ -625,4 +695,7 @@ async function handleSearchByMessageId(args) {
625
695
  module.exports = {
626
696
  handleSearchEmails,
627
697
  handleSearchByMessageId,
698
+ buildFromFilter,
699
+ buildToFilter,
700
+ classifyEmailFilter,
628
701
  };
package/email/send.js CHANGED
@@ -101,49 +101,7 @@ async function handleSendEmail(args) {
101
101
  const allowlistError = checkRecipientAllowlist(allRecipients);
102
102
  if (allowlistError) return allowlistError;
103
103
 
104
- // Pre-send mail tips check
105
- if (checkRecipients) {
106
- const allAddresses = allRecipients.map((r) => r.emailAddress.address);
107
- const tipsResult = await handleGetMailTips({
108
- recipients: allAddresses,
109
- });
110
-
111
- // If there are warnings, prepend them to the response
112
- if (tipsResult._meta?.warningCount > 0 && dryRun) {
113
- // In dry-run mode, include mail tips in the preview
114
- const tipsText = tipsResult.content[0]?.text || '';
115
- const preview = formatDryRunPreview({
116
- message: {
117
- subject,
118
- body: {
119
- contentType:
120
- /<(html|div|p|h[1-6]|br|table|ul|ol|li|span|a\s|img|strong|em|b|i)\b/i.test(
121
- body
122
- )
123
- ? 'html'
124
- : 'text',
125
- content: body,
126
- },
127
- toRecipients,
128
- ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
129
- bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
130
- importance,
131
- },
132
- saveToSentItems,
133
- });
134
- return {
135
- content: [
136
- {
137
- type: 'text',
138
- text: tipsText + '\n\n---\n\n' + preview.content[0].text,
139
- },
140
- ],
141
- _meta: { mailTips: tipsResult._meta },
142
- };
143
- }
144
- }
145
-
146
- // Prepare email object
104
+ // Prepare email object (needed by both dryRun and actual send)
147
105
  const emailObject = {
148
106
  message: {
149
107
  subject,
@@ -164,6 +122,37 @@ async function handleSendEmail(args) {
164
122
  saveToSentItems,
165
123
  };
166
124
 
125
+ // Pre-send mail tips check
126
+ if (checkRecipients) {
127
+ const allAddresses = allRecipients.map((r) => r.emailAddress.address);
128
+ const tipsResult = await handleGetMailTips({
129
+ recipients: allAddresses,
130
+ });
131
+
132
+ const tipsText = tipsResult.content[0]?.text || '';
133
+
134
+ // In dry-run mode, always include mail tips in the preview
135
+ if (dryRun) {
136
+ const preview = formatDryRunPreview(emailObject);
137
+ return {
138
+ content: [
139
+ {
140
+ type: 'text',
141
+ text: tipsText + '\n\n---\n\n' + preview.content[0].text,
142
+ },
143
+ ],
144
+ _meta: { mailTips: tipsResult._meta },
145
+ };
146
+ }
147
+
148
+ // In send mode, warn if there are issues but proceed
149
+ if (tipsResult._meta?.warningCount > 0) {
150
+ // Store tips to prepend to send response
151
+ emailObject._mailTipsText = tipsText;
152
+ emailObject._mailTipsMeta = tipsResult._meta;
153
+ }
154
+ }
155
+
167
156
  // Dry-run mode: return preview without sending
168
157
  if (dryRun) {
169
158
  return formatDryRunPreview(emailObject);
package/index.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  const config = require('./config');
13
13
 
14
14
  // Import module tools
15
- const { authTools } = require('./auth');
15
+ const { authTools, setToolCount } = require('./auth');
16
16
  const { calendarTools } = require('./calendar');
17
17
  const { emailTools } = require('./email');
18
18
  const { folderTools } = require('./folder');
@@ -39,6 +39,9 @@ const TOOLS = [
39
39
  ...advancedTools,
40
40
  ];
41
41
 
42
+ // Set dynamic tool count for auth about handler
43
+ setToolCount(TOOLS.length);
44
+
42
45
  // Create server with tools capabilities
43
46
  const server = new Server(
44
47
  { name: config.SERVER_NAME, version: config.SERVER_VERSION },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
5
  "description": "Outlook Assistant — MCP server with 21 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",