@littlebearapps/outlook-assistant 3.7.1 → 3.7.4

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/settings/index.js CHANGED
@@ -227,12 +227,27 @@ async function handleSetAutomaticReplies(args) {
227
227
 
228
228
  // Build the settings object
229
229
  const settings = {};
230
+ let requestedStatus = null;
230
231
 
231
232
  // Determine status
232
233
  if (enabled === false) {
233
234
  settings.status = 'disabled';
235
+ requestedStatus = 'disabled';
236
+ // F-6: Graph keeps schedule timestamps when transitioning out of
237
+ // 'scheduled' mode unless they're explicitly cleared, which leaves
238
+ // status stuck at 'scheduled'. Reset both to the unix epoch so the
239
+ // disable actually applies. Graph rejects null here.
240
+ settings.scheduledStartDateTime = {
241
+ dateTime: '1970-01-01T00:00:00.000',
242
+ timeZone: 'UTC',
243
+ };
244
+ settings.scheduledEndDateTime = {
245
+ dateTime: '1970-01-01T00:00:00.000',
246
+ timeZone: 'UTC',
247
+ };
234
248
  } else if (startDateTime && endDateTime) {
235
249
  settings.status = 'scheduled';
250
+ requestedStatus = 'scheduled';
236
251
  settings.scheduledStartDateTime = {
237
252
  dateTime: new Date(startDateTime).toISOString(),
238
253
  timeZone: 'UTC',
@@ -243,6 +258,7 @@ async function handleSetAutomaticReplies(args) {
243
258
  };
244
259
  } else if (enabled === true) {
245
260
  settings.status = 'alwaysEnabled';
261
+ requestedStatus = 'alwaysEnabled';
246
262
  }
247
263
 
248
264
  // Reply messages
@@ -269,6 +285,21 @@ async function handleSetAutomaticReplies(args) {
269
285
  settings.externalAudience = externalAudience;
270
286
  }
271
287
 
288
+ // F-4: refuse to claim "updated" when nothing meaningful changed.
289
+ // Catches the misleading-success case where the caller passed only
290
+ // externalAudience without enabled/scheduled — previously the wrapper
291
+ // announced "Automatic replies updated!" with no actual state change.
292
+ if (Object.keys(settings).length === 0) {
293
+ return {
294
+ content: [
295
+ {
296
+ type: 'text',
297
+ text: 'No automatic-reply settings were provided. To change state, pass `enabled: true|false` or `startDateTime` + `endDateTime`. To update messages or audience, pass `internalReplyMessage`, `externalReplyMessage`, or `externalAudience`.',
298
+ },
299
+ ],
300
+ };
301
+ }
302
+
272
303
  // Apply settings
273
304
  await callGraphAPI(accessToken, 'PATCH', 'me/mailboxSettings', {
274
305
  automaticRepliesSetting: settings,
@@ -282,9 +313,37 @@ async function handleSetAutomaticReplies(args) {
282
313
  );
283
314
 
284
315
  const output = [];
285
- output.push('Automatic replies updated!\n');
316
+ if (requestedStatus) {
317
+ output.push('Automatic replies updated!\n');
318
+ } else {
319
+ // F-4: no status-changing param was provided. Spell out exactly
320
+ // which fields the PATCH carried so the caller doesn't think
321
+ // the status flipped silently.
322
+ const otherFields = Object.keys(settings).join(', ');
323
+ output.push(
324
+ `Updated automatic-reply settings (${otherFields}). No status change applied — pass \`enabled\` or \`startDateTime\`+\`endDateTime\` to change state.\n`
325
+ );
326
+ }
286
327
  output.push(formatAutomaticReplies(updated));
287
328
 
329
+ // F-7: Graph silently coerces alwaysEnabled → disabled on personal
330
+ // Outlook.com accounts (no error returned). Detect divergence
331
+ // between requested and post-PATCH state and surface it to the
332
+ // caller so they can correct the call.
333
+ if (requestedStatus && updated.status !== requestedStatus) {
334
+ let hint = '';
335
+ if (
336
+ requestedStatus === 'alwaysEnabled' &&
337
+ updated.status === 'disabled'
338
+ ) {
339
+ hint =
340
+ ' Personal Outlook.com accounts only support `scheduled` mode — provide `startDateTime` + `endDateTime` instead of `enabled: true` alone.';
341
+ }
342
+ output.push(
343
+ `\n**⚠ Warning**: Requested status \`${requestedStatus}\` but Graph applied \`${updated.status}\`.${hint}`
344
+ );
345
+ }
346
+
288
347
  // Warn if enabling without messages
289
348
  if (
290
349
  updated.status !== 'disabled' &&
@@ -641,6 +700,7 @@ const settingsTools = [
641
700
  "Time zone name, e.g. 'Australia/Melbourne' (action=set-working-hours)",
642
701
  },
643
702
  },
703
+ additionalProperties: false,
644
704
  required: [],
645
705
  },
646
706
  handler: async (args) => {
@@ -651,8 +711,16 @@ const settingsTools = [
651
711
  case 'set-working-hours':
652
712
  return handleSetWorkingHours(args);
653
713
  case 'get':
654
- default:
655
714
  return handleGetMailboxSettings(args);
715
+ default:
716
+ return {
717
+ content: [
718
+ {
719
+ type: 'text',
720
+ text: `Unknown action '${action}'. Valid actions: get, set-auto-replies, set-working-hours.`,
721
+ },
722
+ ],
723
+ };
656
724
  }
657
725
  },
658
726
  },
@@ -261,6 +261,11 @@ function formatEmailContent(
261
261
  body = email.bodyPreview || 'No content';
262
262
  }
263
263
 
264
+ // F-16: strip tracking-pixel zero-width chars before returning. These
265
+ // serve no purpose for AI consumption and can run into the hundreds
266
+ // per message, bloating token usage.
267
+ body = stripZeroWidth(body);
268
+
264
269
  // Truncate if needed (unless full verbosity requested)
265
270
  if (verbosity !== VERBOSITY.FULL) {
266
271
  const truncated = truncateWithMeta(body, DEFAULT_LIMITS.maxBodyTruncation);
@@ -480,6 +485,27 @@ function stripHtml(html) {
480
485
  .trim();
481
486
  }
482
487
 
488
+ /**
489
+ * Strip zero-width characters and their HTML entity equivalents
490
+ * (F-16). Mailers inject hundreds of these to defeat Gmail clipping
491
+ * and threading; they bloat token usage and confuse AI consumers
492
+ * without adding any signal. Removes:
493
+ *
494
+ * - U+200B..U+200F (zero-width space, joiner, non-joiner, RTL/LTR
495
+ * marks)
496
+ * - U+FEFF (BOM)
497
+ * - U+2060 (word joiner)
498
+ * - HTML decimal entities: ​..‏, ⁠, 
499
+ * - HTML named entities: ‍, ‌, ‎, ‏
500
+ */
501
+ function stripZeroWidth(text) {
502
+ if (!text) return text;
503
+ return text
504
+ .replace(/[\u200B-\u200F\u2060\uFEFF]+/g, '')
505
+ .replace(/&#(8203|8204|8205|8206|8207|8288|65279);/g, '')
506
+ .replace(/&(zwj|zwnj|lrm|rlm);/g, '');
507
+ }
508
+
483
509
  function escapeCSV(value) {
484
510
  if (value === null || value === undefined) return '';
485
511
  const str = String(value);
@@ -520,5 +546,6 @@ module.exports = {
520
546
  formatRecipients,
521
547
  truncateText,
522
548
  stripHtml,
549
+ stripZeroWidth,
523
550
  escapeCSV,
524
551
  };
@@ -0,0 +1,248 @@
1
+ /**
2
+ * MCP boundary schema coercion + validation.
3
+ *
4
+ * Some MCP clients deliver array/boolean/number params as strings (the
5
+ * JSON-RPC marshalling differs across clients). Handlers were written
6
+ * assuming JS-typed values, so these arrive broken — arrays iterate
7
+ * character-by-character, `=== true` fails on `'true'`, etc.
8
+ *
9
+ * This module walks each tool's `inputSchema.properties` once at the
10
+ * MCP entry point and coerces incoming values into the declared types.
11
+ * It also enforces `additionalProperties: false`, top-level enum
12
+ * constraints, and `required`. Anything that fails coercion or
13
+ * validation gets surfaced as an MCP error response before the
14
+ * handler is invoked.
15
+ *
16
+ * Tracks GH #160 (param-shape mismatches) and #162 (unknown-action
17
+ * fallthrough — enums caught here instead of in switch defaults).
18
+ */
19
+
20
+ class CoercionError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'CoercionError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Coerce a single value against a JSON schema fragment. Mutates nothing.
29
+ * Throws CoercionError on type mismatch that can't be resolved.
30
+ */
31
+ function coerceValue(value, schema, path) {
32
+ if (value === undefined || value === null) return value;
33
+ if (!schema || !schema.type) return value;
34
+
35
+ const type = schema.type;
36
+
37
+ if (type === 'array') {
38
+ let arr = value;
39
+ if (typeof arr === 'string') {
40
+ try {
41
+ arr = JSON.parse(arr);
42
+ } catch (_e) {
43
+ throw new CoercionError(
44
+ `${path}: expected array, got non-JSON string "${truncate(value)}"`
45
+ );
46
+ }
47
+ }
48
+ if (!Array.isArray(arr)) {
49
+ throw new CoercionError(
50
+ `${path}: expected array, got ${describeType(arr)}`
51
+ );
52
+ }
53
+ if (schema.items) {
54
+ return arr.map((item, i) =>
55
+ coerceValue(item, schema.items, `${path}[${i}]`)
56
+ );
57
+ }
58
+ return arr;
59
+ }
60
+
61
+ if (type === 'boolean') {
62
+ if (typeof value === 'boolean') return value;
63
+ if (value === 'true' || value === 1 || value === '1') return true;
64
+ if (value === 'false' || value === 0 || value === '0') return false;
65
+ throw new CoercionError(
66
+ `${path}: expected boolean, got ${describeType(value)} (${truncate(JSON.stringify(value))})`
67
+ );
68
+ }
69
+
70
+ if (type === 'integer') {
71
+ if (typeof value === 'number' && Number.isInteger(value)) return value;
72
+ if (typeof value === 'string' && value.trim() !== '') {
73
+ const n = Number(value);
74
+ if (Number.isInteger(n)) return n;
75
+ }
76
+ throw new CoercionError(
77
+ `${path}: expected integer, got ${describeType(value)} (${truncate(JSON.stringify(value))})`
78
+ );
79
+ }
80
+
81
+ if (type === 'number') {
82
+ if (typeof value === 'number') return value;
83
+ if (typeof value === 'string' && value.trim() !== '') {
84
+ const n = Number(value);
85
+ if (!Number.isNaN(n)) return n;
86
+ }
87
+ throw new CoercionError(
88
+ `${path}: expected number, got ${describeType(value)} (${truncate(JSON.stringify(value))})`
89
+ );
90
+ }
91
+
92
+ if (type === 'object') {
93
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
94
+ throw new CoercionError(
95
+ `${path}: expected object, got ${describeType(value)}`
96
+ );
97
+ }
98
+ if (!schema.properties) return value;
99
+ const result = { ...value };
100
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
101
+ if (key in value) {
102
+ result[key] = coerceValue(value[key], propSchema, `${path}.${key}`);
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+
108
+ if (type === 'string') {
109
+ // F-24: reject arrays passed to string-typed params with a clear
110
+ // hint. The chokepoint pattern coerces arrays *into* arrays
111
+ // (F-25/F-33/F-36) but quietly let arrays slip through to string
112
+ // params, where they got JSON-stringified and rejected by Graph
113
+ // with a confusing 400. Tools whose schema declares a comma-
114
+ // separated string for `to`/`cc`/etc. now surface a friendly
115
+ // MCP-layer error before the call ever leaves the process.
116
+ if (Array.isArray(value)) {
117
+ throw new CoercionError(
118
+ `${path}: expected comma-separated string, got array — pass "a@example.com,b@example.com" instead of ["a@example.com","b@example.com"]`
119
+ );
120
+ }
121
+ // F-24 part 2 (#168): some MCP clients JSON-stringify array literals
122
+ // before transmission when the schema declares type:string, so the
123
+ // array arrives here as the literal string '["a@x.com","b@x.com"]'
124
+ // (brackets and quotes intact). Array.isArray returns false; the
125
+ // value would otherwise pass through and Graph would reject the
126
+ // literal-bracket address with a confusing 400.
127
+ if (typeof value === 'string') {
128
+ const trimmed = value.trim();
129
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(trimmed);
133
+ } catch (_e) {
134
+ parsed = undefined;
135
+ }
136
+ if (Array.isArray(parsed)) {
137
+ const hint = parsed.every((p) => typeof p === 'string')
138
+ ? `"${parsed.join(',')}"`
139
+ : 'a comma-separated string';
140
+ throw new CoercionError(
141
+ `${path}: expected comma-separated string, got JSON-encoded array — pass ${hint} instead of ${trimmed}`
142
+ );
143
+ }
144
+ }
145
+ }
146
+ return value;
147
+ }
148
+
149
+ // unknown type: pass through
150
+ return value;
151
+ }
152
+
153
+ /**
154
+ * Coerce + validate args against a tool's inputSchema. Returns
155
+ * { args: <coerced> } on success, or
156
+ * { error: '<message>' } on failure.
157
+ *
158
+ * Validates (in this order, all errors collected):
159
+ * 1. additionalProperties: false (rejects unknown params)
160
+ * 2. type coercion for each declared property (string→array/boolean/number)
161
+ * 3. required (rejects missing required params)
162
+ * 4. enum on top-level properties (rejects out-of-enum values)
163
+ */
164
+ function coerceArgsAgainstSchema(args, inputSchema) {
165
+ if (!inputSchema || !inputSchema.properties) return { args: args || {} };
166
+ const safeArgs = args || {};
167
+ const errors = [];
168
+
169
+ // 1. additionalProperties: false
170
+ if (inputSchema.additionalProperties === false) {
171
+ const known = new Set(Object.keys(inputSchema.properties));
172
+ const unknown = Object.keys(safeArgs).filter((k) => !known.has(k));
173
+ if (unknown.length > 0) {
174
+ const validList = [...known].sort().join(', ');
175
+ errors.push(
176
+ `Unknown parameter${unknown.length > 1 ? 's' : ''}: ${unknown
177
+ .map((k) => `'${k}'`)
178
+ .join(', ')}. Valid parameters: ${validList}.`
179
+ );
180
+ }
181
+ }
182
+
183
+ // 2. coerce each known property
184
+ const coerced = { ...safeArgs };
185
+ for (const [key, propSchema] of Object.entries(inputSchema.properties)) {
186
+ if (!(key in safeArgs)) continue;
187
+ try {
188
+ coerced[key] = coerceValue(safeArgs[key], propSchema, key);
189
+ } catch (e) {
190
+ if (e instanceof CoercionError) {
191
+ errors.push(e.message);
192
+ } else {
193
+ throw e;
194
+ }
195
+ }
196
+ }
197
+
198
+ // 3. required
199
+ if (Array.isArray(inputSchema.required)) {
200
+ for (const key of inputSchema.required) {
201
+ if (
202
+ !(key in safeArgs) ||
203
+ safeArgs[key] === undefined ||
204
+ safeArgs[key] === null ||
205
+ safeArgs[key] === ''
206
+ ) {
207
+ errors.push(`Required parameter '${key}' is missing.`);
208
+ }
209
+ }
210
+ }
211
+
212
+ // 4. top-level enum constraints
213
+ for (const [key, propSchema] of Object.entries(inputSchema.properties)) {
214
+ if (!(key in coerced)) continue;
215
+ if (!Array.isArray(propSchema.enum)) continue;
216
+ const v = coerced[key];
217
+ if (v === undefined || v === null) continue;
218
+ if (!propSchema.enum.includes(v)) {
219
+ errors.push(
220
+ `Parameter '${key}': value '${v}' not in allowed values [${propSchema.enum
221
+ .map((x) => `'${x}'`)
222
+ .join(', ')}].`
223
+ );
224
+ }
225
+ }
226
+
227
+ if (errors.length > 0) {
228
+ return { error: errors.join('\n') };
229
+ }
230
+ return { args: coerced };
231
+ }
232
+
233
+ function describeType(value) {
234
+ if (value === null) return 'null';
235
+ if (Array.isArray(value)) return 'array';
236
+ return typeof value;
237
+ }
238
+
239
+ function truncate(s, n = 60) {
240
+ if (typeof s !== 'string') s = String(s);
241
+ return s.length > n ? `${s.slice(0, n)}…` : s;
242
+ }
243
+
244
+ module.exports = {
245
+ coerceArgsAgainstSchema,
246
+ coerceValue,
247
+ CoercionError,
248
+ };