@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/email/index.js CHANGED
@@ -137,6 +137,7 @@ const emailTools = [
137
137
  'Include email headers for each message (conversationId only)',
138
138
  },
139
139
  },
140
+ additionalProperties: false,
140
141
  required: [],
141
142
  },
142
143
  handler: async (args) => {
@@ -223,6 +224,7 @@ const emailTools = [
223
224
  'Return raw JSON instead of Markdown (headersMode only, default: false)',
224
225
  },
225
226
  },
227
+ additionalProperties: false,
226
228
  required: ['id'],
227
229
  },
228
230
  handler: async (args) => {
@@ -286,6 +288,7 @@ const emailTools = [
286
288
  'Check recipients for out-of-office, mailbox full, delivery restrictions before sending (default: false). Combine with dryRun=true for pre-send review.',
287
289
  },
288
290
  },
291
+ additionalProperties: false,
289
292
  required: ['to', 'subject', 'body'],
290
293
  },
291
294
  handler: handleSendEmail,
@@ -364,6 +367,7 @@ const emailTools = [
364
367
  'Check recipients for out-of-office, delivery restrictions before saving (action=create, default: false)',
365
368
  },
366
369
  },
370
+ additionalProperties: false,
367
371
  required: ['action'],
368
372
  },
369
373
  handler: handleDraft,
@@ -408,6 +412,7 @@ const emailTools = [
408
412
  description: 'Start date/time for follow-up, ISO 8601 (action=flag)',
409
413
  },
410
414
  },
415
+ additionalProperties: false,
411
416
  required: ['action'],
412
417
  },
413
418
  handler: async (args) => {
@@ -473,12 +478,18 @@ const emailTools = [
473
478
  type: 'string',
474
479
  description: 'Attachment ID (action=view/download, required)',
475
480
  },
481
+ outputDir: {
482
+ type: 'string',
483
+ description:
484
+ 'Directory to save file (action=download, default: system tmpdir). Auto-created if missing.',
485
+ },
476
486
  savePath: {
477
487
  type: 'string',
478
488
  description:
479
- 'Directory to save file (action=download, default: current directory)',
489
+ 'DEPRECATED alias for `outputDir`. Will be removed in v3.8.0.',
480
490
  },
481
491
  },
492
+ additionalProperties: false,
482
493
  required: ['messageId'],
483
494
  },
484
495
  handler: async (args) => {
@@ -489,8 +500,16 @@ const emailTools = [
489
500
  case 'download':
490
501
  return handleDownloadAttachment(args);
491
502
  case 'list':
492
- default:
493
503
  return handleListAttachments(args);
504
+ default:
505
+ return {
506
+ content: [
507
+ {
508
+ type: 'text',
509
+ text: `Unknown action '${action}'. Valid actions: list, view, download.`,
510
+ },
511
+ ],
512
+ };
494
513
  }
495
514
  },
496
515
  },
@@ -521,7 +540,7 @@ const emailTools = [
521
540
  type: 'string',
522
541
  enum: ['mime', 'eml', 'markdown', 'json', 'mbox', 'html', 'csv'],
523
542
  description:
524
- 'Export format (target=message: mime/eml/markdown/json/csv, target=conversation: eml/mbox/markdown/json/html/csv)',
543
+ 'Export format. Valid values vary by target: target=message accepts mime/eml/markdown/json/csv (mbox and html are conversation-only). target=conversation accepts eml/mbox/markdown/json/html/csv. target=messages (batch) accepts markdown/json/csv. mime is an alias for eml (same RFC822 bytes, .eml extension on disk).',
525
544
  },
526
545
  savePath: {
527
546
  type: 'string',
@@ -551,6 +570,11 @@ const emailTools = [
551
570
  description:
552
571
  'Search query to find emails (target=messages, alternative to emailIds)',
553
572
  },
573
+ query: {
574
+ type: 'string',
575
+ description:
576
+ 'Free-text search shortcut (target=messages). Equivalent to passing searchQuery: { subject: <query> }. Convenience alias for callers used to search-emails.',
577
+ },
554
578
  outputDir: {
555
579
  type: 'string',
556
580
  description:
@@ -581,6 +605,7 @@ const emailTools = [
581
605
  description: 'Max content size in bytes (target=mime, default: 1MB)',
582
606
  },
583
607
  },
608
+ additionalProperties: false,
584
609
  required: [],
585
610
  },
586
611
  handler: async (args) => {
@@ -593,8 +618,16 @@ const emailTools = [
593
618
  case 'mime':
594
619
  return handleGetMimeContent(args);
595
620
  case 'message':
596
- default:
597
621
  return handleExportEmail(args);
622
+ default:
623
+ return {
624
+ content: [
625
+ {
626
+ type: 'text',
627
+ text: `Unknown export target '${target}'. Valid targets: message, messages, conversation, mime.`,
628
+ },
629
+ ],
630
+ };
598
631
  }
599
632
  },
600
633
  },
@@ -630,6 +663,7 @@ const emailTools = [
630
663
  'Comma-separated tip types to request (default: all). Options: automaticReplies, mailboxFullStatus, customMailTip, externalMemberCount, totalMemberCount, maxMessageSize, deliveryRestriction, moderationStatus, recipientScope, recipientSuggestions',
631
664
  },
632
665
  },
666
+ additionalProperties: false,
633
667
  required: ['recipients'],
634
668
  },
635
669
  handler: handleGetMailTips,
package/email/list.js CHANGED
@@ -44,7 +44,12 @@ function getFieldPresetForVerbosity(verbosity) {
44
44
  */
45
45
  async function handleListEmails(args) {
46
46
  const folder = args.folder || 'inbox';
47
- const requestedCount = args.count || DEFAULT_LIMITS.listEmails; // Default 25 (was 10)
47
+ // F-17: accept `maxResults` as an alias for `count` here too. The
48
+ // search-mode handler already does this; list-mode used `args.count`
49
+ // only, so callers passing `maxResults=5` to a non-search list call
50
+ // saw their override silently ignored.
51
+ const requestedCount =
52
+ args.count ?? args.maxResults ?? DEFAULT_LIMITS.listEmails;
48
53
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
49
54
 
50
55
  try {
@@ -203,7 +203,7 @@ async function handleGetMailTips(args) {
203
203
  content: [
204
204
  {
205
205
  type: 'text',
206
- text: 'No mail tips returned for the specified recipients.',
206
+ text: 'No mail tips returned. Mail Tips is M365-only — personal Outlook.com accounts return empty responses, so recipient validation is unavailable on this account.',
207
207
  },
208
208
  ],
209
209
  };
@@ -211,15 +211,39 @@ async function handleGetMailTips(args) {
211
211
 
212
212
  const { formatted, warningCount } = formatMailTips(mailTips);
213
213
 
214
+ // F-23: Detect a "fully empty" tips response — every recipient
215
+ // returned with no actionable fields. Personal Outlook.com
216
+ // accounts surface this as a successful empty response rather
217
+ // than a feature-unsupported error, leading to false confidence
218
+ // when callers see "No issues detected".
219
+ const allEmpty = mailTips.every((tip) => {
220
+ const hasContent =
221
+ tip.recipientNotFound ||
222
+ tip.mailboxFull ||
223
+ tip.deliveryRestricted ||
224
+ tip.isModerated ||
225
+ tip.automaticReplies?.message ||
226
+ tip.maxMessageSize ||
227
+ tip.totalMemberCount ||
228
+ tip.customMailTip;
229
+ return !hasContent;
230
+ });
231
+
214
232
  let header = `# Mail Tips\n\n`;
215
233
  header += `**Recipients checked**: ${mailTips.length}\n`;
216
- header += `**Warnings**: ${warningCount}\n\n`;
234
+ header += `**Warnings**: ${warningCount}\n`;
235
+ if (allEmpty && warningCount === 0) {
236
+ header +=
237
+ '\n**Note**: Graph returned no actionable mail tips for any recipient. This usually means Mail Tips is not supported on the connected account (M365-only feature) — "No issues detected" below means "no warnings flagged by Graph", NOT "validated as deliverable".\n';
238
+ }
239
+ header += '\n';
217
240
 
218
241
  return {
219
242
  content: [{ type: 'text', text: header + formatted }],
220
243
  _meta: {
221
244
  recipientCount: mailTips.length,
222
245
  warningCount,
246
+ allEmpty,
223
247
  },
224
248
  };
225
249
  } catch (error) {
package/email/search.js CHANGED
@@ -33,7 +33,12 @@ async function handleSearchEmails(args) {
33
33
  };
34
34
  }
35
35
 
36
- const requestedCount = args.count ?? DEFAULT_LIMITS.searchEmails; // Default 10
36
+ // F-17: accept `maxResults` as an alias for `count` in non-delta mode.
37
+ // The schema declares both, but `maxResults` was only consumed by
38
+ // the delta path, so callers passing `maxResults=5` to a normal
39
+ // search saw their override silently ignored.
40
+ const requestedCount =
41
+ args.count ?? args.maxResults ?? DEFAULT_LIMITS.searchEmails;
37
42
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
38
43
  const query = args.query || '';
39
44
  const from = args.from || '';
@@ -122,16 +127,40 @@ async function progressiveSearch(
122
127
  // Track search strategies attempted
123
128
  const searchAttempts = [];
124
129
 
125
- // 0. If raw KQL query provided, use it directly
130
+ // 0. If raw KQL query provided, use it directly. The kqlQuery branch
131
+ // *terminates* — if Graph returns 0 (or throws), we surface that
132
+ // explicitly rather than falling through to combined-search, which
133
+ // would drop the user's filter and return unrelated recent emails
134
+ // with a misleading "combined-search" strategy line. (#169)
126
135
  if (searchTerms.kqlQuery) {
127
136
  try {
128
- console.error(`Attempting raw KQL search: "${searchTerms.kqlQuery}"`);
137
+ // Pass the user's KQL through as-is. The user is responsible for
138
+ // their own phrase quoting (e.g. `subject:"foo bar"`); we do NOT
139
+ // auto-wrap, which previously produced broken nested quotes like
140
+ // `"subject:"foo bar""` on Graph $search and silently returned
141
+ // recent unfiltered messages. (#169 V37-F-1)
142
+ const trimmedKql = searchTerms.kqlQuery.trim();
143
+ const alreadyQuoted =
144
+ trimmedKql.startsWith('"') && trimmedKql.endsWith('"');
145
+ const looksLikeExpression =
146
+ trimmedKql.includes(':') || /\s/.test(trimmedKql);
147
+ // Already-quoted phrases and KQL-looking expressions (field syntax
148
+ // or multi-word) are passed through as-is; only bare single tokens
149
+ // are wrapped so Graph treats them as phrase searches.
150
+ let kqlForSearch;
151
+ if (alreadyQuoted || looksLikeExpression) {
152
+ kqlForSearch = trimmedKql;
153
+ } else {
154
+ kqlForSearch = `"${trimmedKql}"`;
155
+ }
156
+
157
+ console.error(`Attempting raw KQL search: ${kqlForSearch}`);
129
158
  searchAttempts.push('raw-kql');
130
159
 
131
160
  const kqlParams = {
132
161
  $top: Math.min(50, maxCount),
133
162
  $select: selectFields,
134
- $search: `"${searchTerms.kqlQuery}"`,
163
+ $search: kqlForSearch,
135
164
  };
136
165
 
137
166
  const response = await callGraphAPIPaginated(
@@ -141,20 +170,40 @@ async function progressiveSearch(
141
170
  kqlParams,
142
171
  maxCount
143
172
  );
144
- if (response.value && response.value.length > 0) {
145
- console.error(
146
- `Raw KQL search successful: found ${response.value.length} results`
147
- );
148
- response._searchInfo = {
173
+ console.error(
174
+ `Raw KQL search complete: ${response.value?.length || 0} results`
175
+ );
176
+ const matched = response.value?.length || 0;
177
+ response._searchInfo = {
178
+ attemptsCount: searchAttempts.length,
179
+ strategies: searchAttempts,
180
+ originalTerms: searchTerms,
181
+ filterTerms: filterTerms,
182
+ kqlApplied: kqlForSearch,
183
+ // noResults flips on the helpful "Suggestions" block in the
184
+ // formatter — without it, an empty kqlQuery result would render
185
+ // the bare "No emails found matching your search criteria" line
186
+ // with no guidance.
187
+ noResults: matched === 0,
188
+ };
189
+ // Always return — never silently fall through to a path that
190
+ // would ignore kqlQuery and return unrelated emails.
191
+ return response;
192
+ } catch (error) {
193
+ console.error(`Raw KQL search failed: ${error.message}`);
194
+ // Surface the failure rather than masking it with unrelated results.
195
+ searchAttempts.push('raw-kql-error');
196
+ return {
197
+ value: [],
198
+ _searchInfo: {
149
199
  attemptsCount: searchAttempts.length,
150
200
  strategies: searchAttempts,
151
201
  originalTerms: searchTerms,
152
202
  filterTerms: filterTerms,
153
- };
154
- return response;
155
- }
156
- } catch (error) {
157
- console.error(`Raw KQL search failed: ${error.message}`);
203
+ kqlError: error.message,
204
+ noResults: true,
205
+ },
206
+ };
158
207
  }
159
208
  }
160
209
 
@@ -566,18 +615,26 @@ function filterToClientSide(messages, toValue) {
566
615
  * @returns {Array} - Filtered messages matching the query
567
616
  */
568
617
  function filterQueryClientSide(messages, queryText) {
569
- const queryLower = queryText.toLowerCase();
618
+ // F-12: split multi-word queries on whitespace and require ALL words
619
+ // to be present (AND search). Previous behaviour was substring match
620
+ // on the literal phrase, which missed the common case where the
621
+ // user types e.g. "github token" expecting it to find a subject
622
+ // like "[GitHub] Your fine-grained personal access token".
623
+ const queryLower = queryText.toLowerCase().trim();
624
+ if (!queryLower) return messages;
625
+ const words = queryLower.split(/\s+/).filter(Boolean);
626
+
570
627
  return messages.filter((m) => {
571
- const subject = (m.subject || '').toLowerCase();
572
- const body = (m.bodyPreview || '').toLowerCase();
573
- const fromAddr = (m.from?.emailAddress?.address || '').toLowerCase();
574
- const fromName = (m.from?.emailAddress?.name || '').toLowerCase();
575
- return (
576
- subject.includes(queryLower) ||
577
- body.includes(queryLower) ||
578
- fromAddr.includes(queryLower) ||
579
- fromName.includes(queryLower)
580
- );
628
+ const haystack = [
629
+ m.subject,
630
+ m.bodyPreview,
631
+ m.from?.emailAddress?.address,
632
+ m.from?.emailAddress?.name,
633
+ ]
634
+ .filter(Boolean)
635
+ .join(' ')
636
+ .toLowerCase();
637
+ return words.every((w) => haystack.includes(w));
581
638
  });
582
639
  }
583
640
 
package/folder/create.js CHANGED
@@ -43,6 +43,9 @@ async function handleCreateFolder(args) {
43
43
  text: result.message,
44
44
  },
45
45
  ],
46
+ // F-31: surface the folder ID in _meta so callers can chain
47
+ // create→move→stats without an extra `folders list` round-trip.
48
+ ...(result.folderId && { _meta: { folderId: result.folderId } }),
46
49
  };
47
50
  } catch (error) {
48
51
  if (error.message === 'Authentication required') {
@@ -118,7 +121,9 @@ async function createMailFolder(accessToken, folderName, parentFolderName) {
118
121
 
119
122
  return {
120
123
  success: true,
121
- message: `Successfully created folder "${folderName}" ${locationInfo}.`,
124
+ // F-31: include the ID in the human-readable message too so it
125
+ // shows up for AI agents that don't read _meta.
126
+ message: `Successfully created folder "${folderName}" ${locationInfo}.\n\n**ID**: ${response.id}`,
122
127
  folderId: response.id,
123
128
  };
124
129
  } else {
package/folder/index.js CHANGED
@@ -81,6 +81,7 @@ const folderTools = [
81
81
  'Folder name to delete — resolved to ID (action=delete). Cannot delete protected folders (Inbox, Drafts, Sent, etc.)',
82
82
  },
83
83
  },
84
+ additionalProperties: false,
84
85
  required: [],
85
86
  },
86
87
  handler: async (args) => {
@@ -95,8 +96,16 @@ const folderTools = [
95
96
  case 'delete':
96
97
  return handleDeleteFolder(args);
97
98
  case 'list':
98
- default:
99
99
  return handleListFolders(args);
100
+ default:
101
+ return {
102
+ content: [
103
+ {
104
+ type: 'text',
105
+ text: `Unknown action '${action}'. Valid actions: list, create, move, stats, delete.`,
106
+ },
107
+ ],
108
+ };
100
109
  }
101
110
  },
102
111
  },
package/index.js CHANGED
@@ -10,6 +10,7 @@ const {
10
10
  StdioServerTransport,
11
11
  } = require('@modelcontextprotocol/sdk/server/stdio.js');
12
12
  const config = require('./config');
13
+ const { coerceArgsAgainstSchema } = require('./utils/schema-coerce');
13
14
 
14
15
  // Import module tools
15
16
  const { authTools, setToolCount } = require('./auth');
@@ -26,6 +27,19 @@ const { advancedTools } = require('./advanced');
26
27
  console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`);
27
28
  console.error(`Test mode is ${config.USE_TEST_MODE ? 'enabled' : 'disabled'}`);
28
29
 
30
+ // F-1 / F-48: warn at startup when safety belts are unset. Mirrors the
31
+ // warning surfaced by `auth action=about`. Visible to operators reading
32
+ // stderr; AI clients reading the JSON-RPC stream are unaffected.
33
+ if (
34
+ !process.env.OUTLOOK_MAX_EMAILS_PER_SESSION &&
35
+ !process.env.OUTLOOK_ALLOWED_RECIPIENTS &&
36
+ !config.USE_TEST_MODE
37
+ ) {
38
+ console.error(
39
+ '⚠ Safety belts not configured. Consider setting OUTLOOK_MAX_EMAILS_PER_SESSION and OUTLOOK_ALLOWED_RECIPIENTS in your .mcp.json env block for safer AI-assisted sending. See `auth action=about` for details.'
40
+ );
41
+ }
42
+
29
43
  // Combine all tools
30
44
  const TOOLS = [
31
45
  ...authTools,
@@ -110,6 +124,25 @@ server.fallbackRequestHandler = async (request) => {
110
124
  const tool = TOOLS.find((t) => t.name === name);
111
125
 
112
126
  if (tool && tool.handler) {
127
+ // Coerce + validate args against the tool's inputSchema before
128
+ // dispatching. Catches array-as-string, boolean-as-string, unknown
129
+ // params, and out-of-enum action values at the MCP boundary so
130
+ // handlers receive properly-typed JS values. (#160, #162)
131
+ if (tool.inputSchema) {
132
+ const coerced = coerceArgsAgainstSchema(args, tool.inputSchema);
133
+ if (coerced.error) {
134
+ return {
135
+ content: [
136
+ {
137
+ type: 'text',
138
+ text: `Invalid arguments for tool '${name}':\n${coerced.error}`,
139
+ },
140
+ ],
141
+ isError: true,
142
+ };
143
+ }
144
+ return await tool.handler(coerced.args);
145
+ }
113
146
  return await tool.handler(args);
114
147
  }
115
148
 
package/llms.txt CHANGED
@@ -22,7 +22,8 @@ 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
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.
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.
26
27
  - **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
27
28
  - **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
28
29
  - **Batch operations**: Flag, move, export, or categorise multiple emails in a single tool call; search-driven export for batch archiving without collecting IDs
@@ -72,7 +73,12 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
72
73
 
73
74
  - [README](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/README.md): Full documentation including setup, Azure configuration, and usage
74
75
  - [Tools Reference](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/quickrefs/tools-reference.md): All 22 tools with parameters and safety annotations
76
+ - [Connect Outlook to Claude](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/connect-outlook-to-claude.md): Step-by-step setup guide for Claude Desktop / Claude Code
77
+ - [Verify Your Connection](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/verify-your-connection.md): Test and troubleshoot the connection after installation
78
+ - [Azure Setup](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/guides/azure-setup.md): Azure app registration and API permissions walkthrough
79
+ - [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/index.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
75
80
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
76
81
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
77
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history
82
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.7.4 — patch release closing two regressions surfaced by an independent v3.7.3 E2E re-verification: F-24 chokepoint now catches JSON-stringified arrays from MCP transport (#168) and search-emails kqlQuery no longer silently drops on Step 0 fall-through (#169))
83
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.7.5 polish, v3.8.0 task integration & auth, v3.9.0 new Graph APIs)
78
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.7.1",
3
+ "version": "3.7.4",
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",
package/rules/create.js CHANGED
@@ -150,7 +150,10 @@ async function handleCreateRule(args) {
150
150
  );
151
151
 
152
152
  if (response && response.id) {
153
- let text = `Successfully created rule "${name}" with sequence ${ruleSequence}.`;
153
+ // F-43: include the rule ID. update/delete accept ruleName so this
154
+ // is workable, but ID is more reliable when names contain unicode
155
+ // or duplicates exist.
156
+ let text = `Successfully created rule "${name}" with sequence ${ruleSequence}.\n\n**ID**: ${response.id}`;
154
157
  if (allWarnings.length > 0) {
155
158
  text += `\n\nNotes:\n${allWarnings.map((w) => `- ${w}`).join('\n')}`;
156
159
  }
@@ -160,6 +163,7 @@ async function handleCreateRule(args) {
160
163
  }
161
164
  return {
162
165
  content: [{ type: 'text', text }],
166
+ _meta: { ruleId: response.id },
163
167
  };
164
168
  }
165
169
 
package/rules/index.js CHANGED
@@ -209,6 +209,11 @@ const rulesTools = [
209
209
  description:
210
210
  'Rule name (action=create required, action=update to rename)',
211
211
  },
212
+ displayName: {
213
+ type: 'string',
214
+ description:
215
+ "Alias for `name` (matches Graph's own `displayName` field).",
216
+ },
212
217
  dryRun: {
213
218
  type: 'boolean',
214
219
  description:
@@ -378,10 +383,15 @@ const rulesTools = [
378
383
  description: 'ID of existing rule (action=update/delete)',
379
384
  },
380
385
  },
386
+ additionalProperties: false,
381
387
  required: [],
382
388
  },
383
389
  handler: async (args) => {
384
390
  const action = args.action || 'list';
391
+ // F-41: accept Graph's own `displayName` as alias for `name`.
392
+ if (args.displayName && !args.name) {
393
+ args = { ...args, name: args.displayName };
394
+ }
385
395
  switch (action) {
386
396
  case 'create':
387
397
  return handleCreateRule(args);
@@ -392,8 +402,16 @@ const rulesTools = [
392
402
  case 'delete':
393
403
  return handleDeleteRule(args);
394
404
  case 'list':
395
- default:
396
405
  return handleListRules(args);
406
+ default:
407
+ return {
408
+ content: [
409
+ {
410
+ type: 'text',
411
+ text: `Unknown action '${action}'. Valid actions: list, create, update, reorder, delete.`,
412
+ },
413
+ ],
414
+ };
397
415
  }
398
416
  },
399
417
  },
package/rules/update.js CHANGED
@@ -163,11 +163,19 @@ async function handleUpdateRule(args) {
163
163
 
164
164
  const changedFields = Object.keys(patch)
165
165
  .map((k) => {
166
- if (k === 'displayName') return `name → "${patch.displayName}"`;
166
+ if (k === 'displayName') {
167
+ return `name: "${currentRule.displayName}" → "${patch.displayName}"`;
168
+ }
167
169
  if (k === 'isEnabled') {
168
- return patch.isEnabled ? 'enabled' : 'disabled';
170
+ // Show explicit before/after to remove the F-45 ambiguity:
171
+ // previously rendered as bare "enabled"/"disabled" with no
172
+ // indication of direction, so callers couldn't tell whether
173
+ // the rule was enabled or whether the action just succeeded.
174
+ return `isEnabled: ${currentRule.isEnabled} → ${patch.isEnabled}`;
175
+ }
176
+ if (k === 'sequence') {
177
+ return `sequence: ${currentRule.sequence} → ${patch.sequence}`;
169
178
  }
170
- if (k === 'sequence') return `sequence → ${patch.sequence}`;
171
179
  if (k === 'conditions') return 'conditions updated';
172
180
  if (k === 'actions') return 'actions updated';
173
181
  if (k === 'exceptions') return 'exceptions updated';