@littlebearapps/outlook-assistant 3.7.2 → 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.
@@ -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
+ };