@littlebearapps/outlook-assistant 3.13.0 → 3.14.1

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.
Files changed (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
package/settings/index.js CHANGED
@@ -6,6 +6,29 @@
6
6
  */
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
+ const { toolMetadata } = require('../utils/risk-classes');
10
+ const { toolError, authRequiredError } = require('../utils/tool-error');
11
+ const { dryRunResult, dryRunUnsupported } = require('../utils/safety');
12
+ const { DEFAULT_TIMEZONE } = require('../config');
13
+ const { toUtcIso, formatLocal } = require('../calendar/list');
14
+
15
+ /**
16
+ * A Graph dateTimeTimeZone as the UTC instant plus a labelled local time in
17
+ * the display timezone, e.g. "2026-10-05T01:45:00.000Z (5 Oct 2026, 12:45 pm
18
+ * GMT+11:00)" (#304). A zone Graph returns that can't be converted is shown
19
+ * as given, with its zone.
20
+ * @param {{dateTime: string, timeZone?: string}} dtz
21
+ * @returns {string}
22
+ */
23
+ function formatScheduleTime(dtz) {
24
+ try {
25
+ const utc = toUtcIso(dtz);
26
+ const local = formatLocal(utc, DEFAULT_TIMEZONE);
27
+ return local ? `${utc} (${local})` : utc;
28
+ } catch (_error) {
29
+ return `${dtz?.dateTime} (${dtz?.timeZone || 'UTC'})`;
30
+ }
31
+ }
9
32
 
10
33
  // Days of the week for working hours
11
34
  const DAYS_OF_WEEK = [
@@ -62,12 +85,12 @@ function formatAutomaticReplies(settings) {
62
85
  if (settings.status !== 'disabled') {
63
86
  if (settings.scheduledStartDateTime) {
64
87
  lines.push(
65
- `**Scheduled Start**: ${new Date(settings.scheduledStartDateTime.dateTime).toLocaleString()}`
88
+ `**Scheduled Start**: ${formatScheduleTime(settings.scheduledStartDateTime)}`
66
89
  );
67
90
  }
68
91
  if (settings.scheduledEndDateTime) {
69
92
  lines.push(
70
- `**Scheduled End**: ${new Date(settings.scheduledEndDateTime.dateTime).toLocaleString()}`
93
+ `**Scheduled End**: ${formatScheduleTime(settings.scheduledEndDateTime)}`
71
94
  );
72
95
  }
73
96
 
@@ -91,6 +114,86 @@ function formatAutomaticReplies(settings) {
91
114
  return lines.join('\n');
92
115
  }
93
116
 
117
+ /** Who gets the external reply, for each externalAudience value. */
118
+ const EXTERNAL_AUDIENCE_LABELS = {
119
+ none: 'nobody',
120
+ contactsOnly: 'only senders in your contacts',
121
+ all: 'all external senders',
122
+ };
123
+
124
+ /** Longest stretch of a reply message quoted in a preview. */
125
+ const REPLY_PREVIEW_CHARS = 100;
126
+
127
+ /** "get a 25-character reply: "…"" (or no message) for a preview. */
128
+ function describeReply(message, unchanged) {
129
+ const tag = unchanged ? ' (unchanged)' : '';
130
+ if (!message) return `— no reply message is set${tag}`;
131
+ const quoted =
132
+ message.length > REPLY_PREVIEW_CHARS
133
+ ? `${message.substring(0, REPLY_PREVIEW_CHARS)}…`
134
+ : message;
135
+ return `get a ${message.length}-character reply${tag}: "${quoted}"`;
136
+ }
137
+
138
+ /** "scheduled, from A to B (UTC)" */
139
+ function describeSchedule(start, end) {
140
+ if (!start?.dateTime || !end?.dateTime) return 'scheduled';
141
+ return `scheduled, from ${formatScheduleTime(start)} to ${formatScheduleTime(end)}`;
142
+ }
143
+
144
+ /**
145
+ * dryRun preview for set-auto-replies (#274): who would get an automatic
146
+ * reply, when, and how long each message is. Fields the call doesn't set
147
+ * keep their current values, so those are read first and marked unchanged.
148
+ * @param {object} changes - The automaticRepliesSetting the call would PATCH
149
+ * @param {object} current - The current automaticRepliesSetting
150
+ */
151
+ function previewAutomaticReplies(changes, current) {
152
+ const effective = { ...current, ...changes };
153
+ const unchanged = (field) => !(field in changes);
154
+ const lines = [];
155
+
156
+ const status = effective.status;
157
+ const statusTag = unchanged('status') ? ' (unchanged)' : '';
158
+ if (status === 'disabled') {
159
+ lines.push(
160
+ `Status: off (disabled)${statusTag}. Nobody gets an automatic reply.`
161
+ );
162
+ return dryRunResult(lines, { settings: effective });
163
+ }
164
+ if (status === 'alwaysEnabled') {
165
+ lines.push(`Status: on now, with no end date (alwaysEnabled)${statusTag}.`);
166
+ } else {
167
+ lines.push(
168
+ `Status: ${describeSchedule(effective.scheduledStartDateTime, effective.scheduledEndDateTime)}${statusTag}.`
169
+ );
170
+ }
171
+
172
+ lines.push(
173
+ `Internal senders (your organisation): ${describeReply(effective.internalReplyMessage, unchanged('internalReplyMessage'))}`
174
+ );
175
+
176
+ const audience = effective.externalAudience;
177
+ const audienceTag = `externalAudience=${audience || 'unknown'}${unchanged('externalAudience') ? '; unchanged' : ''}`;
178
+ if (audience === 'none') {
179
+ lines.push(`External senders: nobody (${audienceTag}).`);
180
+ } else {
181
+ const who = EXTERNAL_AUDIENCE_LABELS[audience] || 'unknown audience';
182
+ lines.push(
183
+ `External senders: ${who} (${audienceTag}) ${describeReply(effective.externalReplyMessage, unchanged('externalReplyMessage'))}`
184
+ );
185
+ }
186
+
187
+ if (changes.status === 'alwaysEnabled') {
188
+ lines.push(
189
+ '',
190
+ 'Note: Personal Outlook.com accounts only support scheduled replies, and Graph may leave them off. Pass `startDateTime` + `endDateTime` there instead.'
191
+ );
192
+ }
193
+
194
+ return dryRunResult(lines, { settings: effective });
195
+ }
196
+
94
197
  /**
95
198
  * Get mailbox settings handler
96
199
  */
@@ -189,23 +292,9 @@ async function handleGetMailboxSettings(args) {
189
292
  };
190
293
  } catch (error) {
191
294
  if (error.message === 'Authentication required') {
192
- return {
193
- content: [
194
- {
195
- type: 'text',
196
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
197
- },
198
- ],
199
- };
295
+ return authRequiredError();
200
296
  }
201
- return {
202
- content: [
203
- {
204
- type: 'text',
205
- text: `Error getting mailbox settings: ${error.message}`,
206
- },
207
- ],
208
- };
297
+ return toolError(`Error getting mailbox settings: ${error.message}`);
209
298
  }
210
299
  }
211
300
 
@@ -220,6 +309,7 @@ async function handleSetAutomaticReplies(args) {
220
309
  internalReplyMessage,
221
310
  externalReplyMessage,
222
311
  externalAudience,
312
+ dryRun = false,
223
313
  } = args;
224
314
 
225
315
  try {
@@ -273,14 +363,9 @@ async function handleSetAutomaticReplies(args) {
273
363
  // External audience
274
364
  if (externalAudience) {
275
365
  if (!['none', 'contactsOnly', 'all'].includes(externalAudience)) {
276
- return {
277
- content: [
278
- {
279
- type: 'text',
280
- text: "externalAudience must be 'none', 'contactsOnly', or 'all'.",
281
- },
282
- ],
283
- };
366
+ return toolError(
367
+ "externalAudience must be 'none', 'contactsOnly', or 'all'."
368
+ );
284
369
  }
285
370
  settings.externalAudience = externalAudience;
286
371
  }
@@ -290,14 +375,20 @@ async function handleSetAutomaticReplies(args) {
290
375
  // externalAudience without enabled/scheduled — previously the wrapper
291
376
  // announced "Automatic replies updated!" with no actual state change.
292
377
  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
- };
378
+ return toolError(
379
+ '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`.'
380
+ );
381
+ }
382
+
383
+ // dryRun: read the current setting to fill in what this call leaves
384
+ // alone, and say who would get a reply; change nothing.
385
+ if (dryRun) {
386
+ const current = await callGraphAPI(
387
+ accessToken,
388
+ 'GET',
389
+ 'me/mailboxSettings/automaticRepliesSetting'
390
+ );
391
+ return previewAutomaticReplies(settings, current || {});
301
392
  }
302
393
 
303
394
  // Apply settings
@@ -377,23 +468,9 @@ async function handleSetAutomaticReplies(args) {
377
468
  };
378
469
  } catch (error) {
379
470
  if (error.message === 'Authentication required') {
380
- return {
381
- content: [
382
- {
383
- type: 'text',
384
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
385
- },
386
- ],
387
- };
471
+ return authRequiredError();
388
472
  }
389
- return {
390
- content: [
391
- {
392
- type: 'text',
393
- text: `Error setting automatic replies: ${error.message}`,
394
- },
395
- ],
396
- };
473
+ return toolError(`Error setting automatic replies: ${error.message}`);
397
474
  }
398
475
  }
399
476
 
@@ -405,37 +482,22 @@ async function handleSetWorkingHours(args) {
405
482
 
406
483
  // Validate inputs
407
484
  if (!startTime && !endTime && !daysOfWeek && !timeZone) {
408
- return {
409
- content: [
410
- {
411
- type: 'text',
412
- text: 'At least one of startTime, endTime, daysOfWeek, or timeZone is required.',
413
- },
414
- ],
415
- };
485
+ return toolError(
486
+ 'At least one of startTime, endTime, daysOfWeek, or timeZone is required.'
487
+ );
416
488
  }
417
489
 
418
490
  // Validate time format (HH:MM or HH:MM:SS)
419
491
  const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
420
492
  if (startTime && !timeRegex.test(startTime)) {
421
- return {
422
- content: [
423
- {
424
- type: 'text',
425
- text: "startTime must be in HH:MM or HH:MM:SS format (e.g., '09:00' or '09:00:00').",
426
- },
427
- ],
428
- };
493
+ return toolError(
494
+ "startTime must be in HH:MM or HH:MM:SS format (e.g., '09:00' or '09:00:00')."
495
+ );
429
496
  }
430
497
  if (endTime && !timeRegex.test(endTime)) {
431
- return {
432
- content: [
433
- {
434
- type: 'text',
435
- text: "endTime must be in HH:MM or HH:MM:SS format (e.g., '17:00' or '17:00:00').",
436
- },
437
- ],
438
- };
498
+ return toolError(
499
+ "endTime must be in HH:MM or HH:MM:SS format (e.g., '17:00' or '17:00:00')."
500
+ );
439
501
  }
440
502
 
441
503
  // Validate days of week
@@ -444,14 +506,9 @@ async function handleSetWorkingHours(args) {
444
506
  (d) => !DAYS_OF_WEEK.includes(d.toLowerCase())
445
507
  );
446
508
  if (invalidDays.length > 0) {
447
- return {
448
- content: [
449
- {
450
- type: 'text',
451
- text: `Invalid days: ${invalidDays.join(', ')}. Valid days: ${DAYS_OF_WEEK.join(', ')}`,
452
- },
453
- ],
454
- };
509
+ return toolError(
510
+ `Invalid days: ${invalidDays.join(', ')}. Valid days: ${DAYS_OF_WEEK.join(', ')}`
511
+ );
455
512
  }
456
513
  }
457
514
 
@@ -506,23 +563,9 @@ async function handleSetWorkingHours(args) {
506
563
  };
507
564
  } catch (error) {
508
565
  if (error.message === 'Authentication required') {
509
- return {
510
- content: [
511
- {
512
- type: 'text',
513
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
514
- },
515
- ],
516
- };
566
+ return authRequiredError();
517
567
  }
518
- return {
519
- content: [
520
- {
521
- type: 'text',
522
- text: `Error setting working hours: ${error.message}`,
523
- },
524
- ],
525
- };
568
+ return toolError(`Error setting working hours: ${error.message}`);
526
569
  }
527
570
  }
528
571
 
@@ -548,23 +591,9 @@ async function handleGetAutomaticReplies() {
548
591
  };
549
592
  } catch (error) {
550
593
  if (error.message === 'Authentication required') {
551
- return {
552
- content: [
553
- {
554
- type: 'text',
555
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
556
- },
557
- ],
558
- };
594
+ return authRequiredError();
559
595
  }
560
- return {
561
- content: [
562
- {
563
- type: 'text',
564
- text: `Error getting automatic replies: ${error.message}`,
565
- },
566
- ],
567
- };
596
+ return toolError(`Error getting automatic replies: ${error.message}`);
568
597
  }
569
598
  }
570
599
 
@@ -590,23 +619,9 @@ async function handleGetWorkingHours() {
590
619
  };
591
620
  } catch (error) {
592
621
  if (error.message === 'Authentication required') {
593
- return {
594
- content: [
595
- {
596
- type: 'text',
597
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
598
- },
599
- ],
600
- };
622
+ return authRequiredError();
601
623
  }
602
- return {
603
- content: [
604
- {
605
- type: 'text',
606
- text: `Error getting working hours: ${error.message}`,
607
- },
608
- ],
609
- };
624
+ return toolError(`Error getting working hours: ${error.message}`);
610
625
  }
611
626
  }
612
627
 
@@ -615,13 +630,8 @@ const settingsTools = [
615
630
  {
616
631
  name: 'mailbox-settings',
617
632
  description:
618
- 'Read or update mailbox-level settings (idempotent — safe to retry; sets are PATCH-style and merge with existing state). action=`get` (default) returns settings — use `section` to filter (`language`, `timeZone`, `workingHours`, `automaticRepliesSetting`, or `all`). action=`set-auto-replies` configures out-of-office: `enabled` true/false, optional `startDateTime`/`endDateTime` (ISO 8601) for scheduled mode, `internalReplyMessage` and (optionally) `externalReplyMessage`. action=`set-working-hours` updates the schedule: `startTime`/`endTime` (HH:MM) and `daysOfWeek` (array of `monday`..`sunday`). Returns the updated settings object on set actions.',
619
- annotations: {
620
- title: 'Mailbox Settings',
621
- readOnlyHint: false,
622
- destructiveHint: false,
623
- idempotentHint: true,
624
- },
633
+ 'Read or update mailbox-level settings (idempotent — safe to retry; sets are PATCH-style and merge with existing state). action=`get` (default) returns settings — use `section` to filter (`language`, `timeZone`, `workingHours`, `automaticRepliesSetting`, or `all`). action=`set-auto-replies` configures out-of-office: `enabled` true/false, optional `startDateTime`/`endDateTime` (ISO 8601) for scheduled mode, `internalReplyMessage` and (optionally) `externalReplyMessage` and `externalAudience` (none/contactsOnly/all); pass `dryRun: true` to preview who would get replies without changing anything. action=`set-working-hours` updates the schedule: `startTime`/`endTime` (HH:MM) and `daysOfWeek` (array of `monday`..`sunday`). Returns the updated settings object on set actions.',
634
+ ...toolMetadata('mailbox-settings', 'Mailbox Settings'),
625
635
  inputSchema: {
626
636
  type: 'object',
627
637
  properties: {
@@ -674,6 +684,11 @@ const settingsTools = [
674
684
  enum: ['none', 'contactsOnly', 'all'],
675
685
  description: 'Who receives external reply (action=set-auto-replies)',
676
686
  },
687
+ dryRun: {
688
+ type: 'boolean',
689
+ description:
690
+ 'Preview only (action=set-auto-replies): nothing is changed. Shows who would get automatic replies, the schedule and each message length. Other actions refuse dryRun and change nothing. Default false.',
691
+ },
677
692
  // set-working-hours params
678
693
  startTime: {
679
694
  type: 'string',
@@ -705,6 +720,13 @@ const settingsTools = [
705
720
  },
706
721
  handler: async (args) => {
707
722
  const action = args.action || 'get';
723
+ if (args.dryRun && action !== 'set-auto-replies') {
724
+ return dryRunUnsupported(
725
+ 'mailbox-settings',
726
+ action,
727
+ 'set-auto-replies'
728
+ );
729
+ }
708
730
  switch (action) {
709
731
  case 'set-auto-replies':
710
732
  return handleSetAutomaticReplies(args);
@@ -713,14 +735,9 @@ const settingsTools = [
713
735
  case 'get':
714
736
  return handleGetMailboxSettings(args);
715
737
  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
- };
738
+ return toolError(
739
+ `Unknown action '${action}'. Valid actions: get, set-auto-replies, set-working-hours.`
740
+ );
724
741
  }
725
742
  },
726
743
  },
package/tools.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tool registry: every tool the server exposes, in listing order.
3
+ *
4
+ * Kept separate from index.js so tests can load the real tool list without
5
+ * starting the stdio transport. Add a new module's tools here (and classify
6
+ * them in utils/risk-classes.js).
7
+ */
8
+ const { authTools } = require('./auth');
9
+ const { calendarTools } = require('./calendar');
10
+ const { emailTools } = require('./email');
11
+ const { folderTools } = require('./folder');
12
+ const { rulesTools } = require('./rules');
13
+ const { contactsTools } = require('./contacts');
14
+ const { categoriesTools } = require('./categories');
15
+ const { settingsTools } = require('./settings');
16
+ const { advancedTools } = require('./advanced');
17
+
18
+ const TOOLS = [
19
+ ...authTools,
20
+ ...calendarTools,
21
+ ...emailTools,
22
+ ...folderTools,
23
+ ...rulesTools,
24
+ ...contactsTools,
25
+ ...categoriesTools,
26
+ ...settingsTools,
27
+ ...advancedTools,
28
+ ];
29
+
30
+ module.exports = { TOOLS };
@@ -5,6 +5,8 @@
5
5
  * and reduce response size/token usage.
6
6
  */
7
7
 
8
+ const { log } = require('./logger');
9
+
8
10
  /**
9
11
  * Field presets for different use cases
10
12
  */
@@ -255,7 +257,7 @@ const FOLDER_FIELDS = {
255
257
  function getEmailFields(preset = 'list') {
256
258
  const fields = FIELD_PRESETS[preset];
257
259
  if (!fields) {
258
- console.error(`Unknown preset: ${preset}, falling back to 'list'`);
260
+ log.debug(`Unknown preset: ${preset}, falling back to 'list'`);
259
261
  return FIELD_PRESETS.list.join(',');
260
262
  }
261
263
  return fields.join(',');
@@ -269,7 +271,7 @@ function getEmailFields(preset = 'list') {
269
271
  function getFolderFields(preset = 'basic') {
270
272
  const fields = FOLDER_FIELDS[preset];
271
273
  if (!fields) {
272
- console.error(`Unknown folder preset: ${preset}, falling back to 'basic'`);
274
+ log.debug(`Unknown folder preset: ${preset}, falling back to 'basic'`);
273
275
  return FOLDER_FIELDS.basic.join(',');
274
276
  }
275
277
  return fields.join(',');
@@ -4,6 +4,7 @@
4
4
  const https = require('https');
5
5
  const config = require('../config');
6
6
  const mockData = require('./mock-data');
7
+ const { log, graphPathShape } = require('./logger');
7
8
 
8
9
  /**
9
10
  * Guard for caller-supplied full URLs (nextLink/deltaLink continuations).
@@ -270,7 +271,7 @@ function sleep(ms) {
270
271
  * final response (2xx, or the last non-retried error status)
271
272
  * @throws {Error} The network error of the final attempt
272
273
  */
273
- async function requestWithRetry(request) {
274
+ async function sendWithRetry(request) {
274
275
  const method = String(request.method).toUpperCase();
275
276
  const timeoutMs = request.timeoutMs || config.REQUEST_TIMEOUT_MS;
276
277
  let networkRetryUsed = false;
@@ -316,7 +317,8 @@ async function requestWithRetry(request) {
316
317
  }
317
318
  }
318
319
 
319
- console.error(
320
+ log.increment('graphRetries');
321
+ log.debug(
320
322
  `[GRAPH-API] ${method} ${networkError ? networkError.code : response.status}; ` +
321
323
  `retry ${retry + 1}/${MAX_RETRIES} in ${delayMs} ms`
322
324
  );
@@ -325,6 +327,36 @@ async function requestWithRetry(request) {
325
327
  }
326
328
  }
327
329
 
330
+ /**
331
+ * sendWithRetry, noting a final failure on the current tool call's log line
332
+ * as status (or network error code), method and a PII-free path shape (#278).
333
+ * @param {object} request - See sendWithRetry
334
+ * @returns {Promise<{status: number, headers: object, text: string}>}
335
+ */
336
+ async function requestWithRetry(request) {
337
+ const method = String(request.method).toUpperCase();
338
+ try {
339
+ const response = await sendWithRetry(request);
340
+ if (response.status >= 400) {
341
+ log.note(
342
+ 'graph',
343
+ `${response.status} ${method} ${graphPathShape(request.url)}`
344
+ );
345
+ log.debug(
346
+ `[GRAPH-API] ${method} ${request.url} failed with ${response.status}: ${response.text}`
347
+ );
348
+ }
349
+ return response;
350
+ } catch (error) {
351
+ log.note(
352
+ 'graph',
353
+ `${error.code || 'network-error'} ${method} ${graphPathShape(request.url)}`
354
+ );
355
+ log.debug(`[GRAPH-API] ${method} ${request.url} failed:`, error);
356
+ throw error;
357
+ }
358
+ }
359
+
328
360
  /**
329
361
  * Makes a request to the Microsoft Graph API
330
362
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
@@ -357,7 +389,7 @@ async function callGraphAPI(
357
389
  try {
358
390
  finalUrl = buildGraphUrl(path, queryParams);
359
391
  } catch (error) {
360
- console.error('Error calling Graph API:', error);
392
+ log.debug('Error calling Graph API:', error);
361
393
  throw error;
362
394
  }
363
395
 
@@ -422,12 +454,18 @@ async function callGraphAPI(
422
454
 
423
455
  /**
424
456
  * Calls Graph API with pagination support to retrieve all results up to maxCount
457
+ *
458
+ * Reports truncation (#279): `hasMore` is true when it stopped with more
459
+ * available (at maxCount with items trimmed or a further page, or because
460
+ * Graph repeated a nextLink). `@odata.count` is set only when every item was
461
+ * returned, so a page size is never presented as a total. `@odata.nextLink`
462
+ * is kept only when it continues exactly after the returned items.
425
463
  * @param {string} accessToken - The access token for authentication
426
464
  * @param {string} method - HTTP method (GET only for pagination)
427
465
  * @param {string} path - API endpoint path
428
466
  * @param {object} queryParams - Initial query parameters
429
467
  * @param {number} maxCount - Maximum number of items to retrieve (0 = all)
430
- * @returns {Promise<object>} - Combined API response with all items
468
+ * @returns {Promise<{value: Array<object>, hasMore: boolean, '@odata.count'?: number, '@odata.nextLink'?: string}>}
431
469
  * @throws {Error} If method is not 'GET'
432
470
  * @throws {Error} If any page request fails for any other reason
433
471
  */
@@ -443,13 +481,14 @@ async function callGraphAPIPaginated(
443
481
  }
444
482
 
445
483
  const allItems = [];
446
- let nextLink;
484
+ const seenLinks = new Set();
447
485
  let currentUrl = path;
448
486
  let currentParams = { ...queryParams };
487
+ let nextLink;
488
+ let hasMore = false;
449
489
 
450
490
  try {
451
- do {
452
- // Make API call
491
+ for (;;) {
453
492
  const response = await callGraphAPI(
454
493
  accessToken,
455
494
  method,
@@ -458,35 +497,39 @@ async function callGraphAPIPaginated(
458
497
  currentParams
459
498
  );
460
499
 
461
- // Add items from this page
462
500
  if (response.value && Array.isArray(response.value)) {
463
501
  allItems.push(...response.value);
464
502
  }
503
+ nextLink = response['@odata.nextLink'];
465
504
 
466
- // Check if we've reached the desired count
505
+ // Stop at the desired count; more remain if items were trimmed or
506
+ // there is another page.
467
507
  if (maxCount > 0 && allItems.length >= maxCount) {
508
+ hasMore = allItems.length > maxCount || Boolean(nextLink);
509
+ if (allItems.length > maxCount) nextLink = undefined;
468
510
  break;
469
511
  }
470
-
471
- // Get next page URL
472
- nextLink = response['@odata.nextLink'];
473
-
474
- if (nextLink) {
475
- // Pass the full nextLink URL directly to callGraphAPI
476
- currentUrl = nextLink;
477
- currentParams = {}; // nextLink already contains all params
512
+ if (!nextLink) break;
513
+ // A repeated link would loop forever; stop and say there is more.
514
+ if (seenLinks.has(nextLink)) {
515
+ hasMore = true;
516
+ nextLink = undefined;
517
+ break;
478
518
  }
479
- } while (nextLink);
519
+ seenLinks.add(nextLink);
520
+ currentUrl = nextLink; // the full nextLink URL carries every param
521
+ currentParams = {};
522
+ }
480
523
 
481
- // Trim to exact count if needed
482
524
  const finalItems = maxCount > 0 ? allItems.slice(0, maxCount) : allItems;
483
-
484
525
  return {
485
526
  value: finalItems,
486
- '@odata.count': finalItems.length,
527
+ hasMore,
528
+ ...(!hasMore && { '@odata.count': finalItems.length }),
529
+ ...(hasMore && nextLink && { '@odata.nextLink': nextLink }),
487
530
  };
488
531
  } catch (error) {
489
- console.error('Error during pagination:', error);
532
+ log.debug('Error during pagination:', error);
490
533
  throw error;
491
534
  }
492
535
  }