@chrischall/office-outlook-mcp 0.9.0 → 0.10.0

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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Outlook / Microsoft 365 — mail, calendar, contacts and tasks via natural language",
10
- "version": "0.9.0"
10
+ "version": "0.10.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Outlook (Microsoft 365)",
16
16
  "source": "./",
17
17
  "description": "MCP server for Outlook / Microsoft 365 — triage unread mail, reply, answer meeting invites and schedule meetings, plus folders, calendar, contacts and tasks, with confirmation before anything is sent",
18
- "version": "0.9.0",
18
+ "version": "0.10.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "office-outlook-mcp",
3
3
  "displayName": "Outlook (Microsoft 365)",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "description": "Outlook / Microsoft 365 for Claude — triage unread mail, reply, answer meeting invites and schedule meetings, plus folders, calendar, contacts and tasks, with confirmation before anything is sent, using your signed-in browser session",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -39293,7 +39293,7 @@ var init_version = __esm({
39293
39293
  "src/version.ts"() {
39294
39294
  "use strict";
39295
39295
  PACKAGE_NAME = "office-outlook-mcp";
39296
- VERSION = "0.9.0";
39296
+ VERSION = "0.10.0";
39297
39297
  }
39298
39298
  });
39299
39299
 
@@ -68470,13 +68470,17 @@ function compactEvent(e) {
68470
68470
  ShowAs: e.ShowAs
68471
68471
  });
68472
68472
  }
68473
+ function eventAttendees(e) {
68474
+ return e.Attendees?.map((a) => pruned({ Who: addr(a), Response: a.Status?.Response }));
68475
+ }
68476
+ function detailedEvent(e) {
68477
+ return pruned({ ...compactEvent(e), Attendees: eventAttendees(e) });
68478
+ }
68473
68479
  function fullEvent(e) {
68474
68480
  return pruned({
68475
68481
  ...compactEvent(e),
68476
68482
  SeriesMasterId: e.SeriesMasterId,
68477
- Attendees: e.Attendees?.map(
68478
- (a) => pruned({ Who: addr(a), Response: a.Status?.Response })
68479
- ),
68483
+ Attendees: eventAttendees(e),
68480
68484
  // The Teams link lives under OnlineMeeting; OnlineMeetingUrl is empty on
68481
68485
  // every live Teams meeting checked.
68482
68486
  JoinUrl: e.OnlineMeeting?.JoinUrl || e.OnlineMeetingUrl || void 0,
@@ -69199,7 +69203,7 @@ function registerCalendarTools(server, client2) {
69199
69203
  server.registerTool(
69200
69204
  "outlook_get_event",
69201
69205
  {
69202
- description: `Get one calendar event in full, including attendees and their responses. Attendees appear in view:"full", every one of them \u2014 there is no 20-address cap as on a message's To/Cc; view:"raw" returns Outlook's untouched record, uncapped, Attendees included. ` + UNTRUSTED_DESCRIPTION_SUFFIX,
69206
+ description: 'Get one calendar event with its attendees and their responses. Attendees come back in EVERY view, including the default compact one \u2014 all of them, uncapped (no 20-address cap as on a message\'s To/Cc). The event\'s time is labelled with its zone in the `TimeZone` field (the mailbox zone unless you pass `timeZone`); the same instant read in another zone shows a different clock time, so read `TimeZone`, not just the clock. view:"full" adds the Teams join link, body preview and web link; view:"raw" returns Outlook\'s untouched record. ' + UNTRUSTED_DESCRIPTION_SUFFIX,
69203
69207
  annotations: { readOnlyHint: true, openWorldHint: true },
69204
69208
  _meta: workMeta("lookup"),
69205
69209
  inputSchema: external_exports.object({
@@ -69216,7 +69220,7 @@ function registerCalendarTools(server, client2) {
69216
69220
  );
69217
69221
  const v = resolveView(view, VIEWS2);
69218
69222
  if (v === "raw") return mailboxUntrusted(data);
69219
- return mailboxUntrusted(v === "compact" ? compactEvent(data) : fullEvent(data));
69223
+ return mailboxUntrusted(v === "full" ? fullEvent(data) : detailedEvent(data));
69220
69224
  }
69221
69225
  );
69222
69226
  server.registerTool(
@@ -71631,7 +71635,7 @@ function decodeJwt(token) {
71631
71635
  return { oid: claims.oid, tid: claims.tid };
71632
71636
  }
71633
71637
  function cleanAnswer(text) {
71634
- return text.replace(/[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu, "").replace(/cite(?:turn\d+[a-z]+\d+)+/gi, "").replace(/【[^】]{1,60}】/g, "").replace(/[ \t]+\n/g, "\n").replace(/[ \t]{2,}/g, " ").trim();
71638
+ return text.replace(/[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu, "").replace(/cite(?:turn\d+[a-z]+\d+)+/gi, "").replace(/【[^】]{1,60}】/g, "").replace(/[ \t]+\n/g, "\n").replace(/(\S)[ \t]{2,}/g, "$1 ").trim();
71635
71639
  }
71636
71640
  var DisengagedError = class extends Error {
71637
71641
  disengaged = true;
@@ -71898,6 +71902,12 @@ function abortError() {
71898
71902
  }
71899
71903
 
71900
71904
  // src/tools/copilot.ts
71905
+ async function timeContext(outlook) {
71906
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
71907
+ const zone = await mailboxTimeZone(outlook);
71908
+ return zone ? `(Context: today is ${today}. The user's mailbox time zone is ${zone}, and mailbox times are stored in UTC. When you state any date or time, convert it to ${zone} and label the zone.)` : `(Context: today is ${today}. Mailbox times are stored in UTC \u2014 when you state a time, label its time zone.)`;
71909
+ }
71910
+ var VERBATIM_DIRECTIVE = "(Reproduce any content I ask you to quote or return IN FULL and verbatim \u2014 do not summarise, paraphrase, shorten, or omit any part of it, even if it is long.)";
71901
71911
  var TURN_TIMEOUT_MS = 12e4;
71902
71912
  function turnSignal(hostSignal) {
71903
71913
  const controller = new AbortController();
@@ -71916,23 +71926,27 @@ function turnSignal(hostSignal) {
71916
71926
  }
71917
71927
  };
71918
71928
  }
71919
- function registerCopilotTools(server, copilot2) {
71929
+ function registerCopilotTools(server, copilot2, outlook) {
71920
71930
  server.registerTool(
71921
71931
  "outlook_ask_copilot",
71922
71932
  {
71923
- description: 'Ask Microsoft 365 Copilot a question answered from the signed-in mailbox (Copilot searches your own mail, calendar and files server-side and writes a reply). Use it for synthesis across many messages \u2014 "what needs my attention first", "summarise the thread with X", "did anyone ask me for anything this week". Follow-up calls continue the same conversation; pass newConversation:true to start fresh. The prompt is sent to Microsoft\'s Copilot service on your tenant; put your own question in it, not text copied from an email. Each call uses one of a per-conversation quota, reported back. First use needs a one-time sign-in: run `opencode-m365 login` (from the opencode-m365-copilot package) so an MSAL cache exists.' + UNTRUSTED_DESCRIPTION_SUFFIX,
71933
+ description: 'Ask Microsoft 365 Copilot a question answered from the signed-in mailbox (Copilot searches your own mail, calendar and files server-side and writes a reply). Use it for synthesis across many messages \u2014 "what needs my attention first", "summarise the thread with X", "did anyone ask me for anything this week". Follow-up calls continue the same conversation; pass newConversation:true to start fresh. Pass verbatim:true when you need content quoted back in full (Copilot otherwise answers concisely and summarises long material; for a single item a focused "quote X in full" also works). Times are converted to the mailbox time zone and labelled. The prompt is sent to Microsoft\'s Copilot service on your tenant; put your own question in it, not text copied from an email. Each call uses one of a per-conversation quota, reported back. First use needs a one-time sign-in: run `opencode-m365 login` (from the opencode-m365-copilot package) so an MSAL cache exists.' + UNTRUSTED_DESCRIPTION_SUFFIX,
71924
71934
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
71925
71935
  _meta: workMeta("lookup", { aliases: ["copilot", "ask copilot", "m365 copilot"] }),
71926
71936
  inputSchema: external_exports.object({
71927
71937
  prompt: external_exports.string().min(1).describe("The question for Copilot, in your own words"),
71928
- newConversation: external_exports.boolean().optional().describe("Start a fresh conversation with no prior context (default false: continue the current one)")
71938
+ newConversation: external_exports.boolean().optional().describe("Start a fresh conversation with no prior context (default false: continue the current one)"),
71939
+ verbatim: external_exports.boolean().optional().describe("Ask Copilot to quote requested content in full rather than summarising (default false). Use for retrieving a saved note, a full message, etc.")
71929
71940
  })
71930
71941
  },
71931
- async ({ prompt, newConversation }, ctx) => {
71942
+ async ({ prompt, newConversation, verbatim }, ctx) => {
71932
71943
  if (newConversation) copilot2.reset();
71933
71944
  const { signal, dispose } = turnSignal(ctx?.signal);
71934
71945
  try {
71935
- const result = await copilot2.ask(prompt, { signal });
71946
+ const preamble = [await timeContext(outlook), verbatim ? VERBATIM_DIRECTIVE : ""].filter(Boolean).join(" ");
71947
+ const result = await copilot2.ask(`${preamble}
71948
+
71949
+ ${prompt}`, { signal });
71936
71950
  return mailboxUntrusted({
71937
71951
  answer: result.text,
71938
71952
  conversationId: copilot2.conversationId,
@@ -72090,7 +72104,7 @@ await runMcp({
72090
72104
  registerWriteTools,
72091
72105
  registerExportTools,
72092
72106
  registerRuleTools,
72093
- (server) => registerCopilotTools(server, copilot),
72107
+ (server) => registerCopilotTools(server, copilot, client),
72094
72108
  registerHealthcheckTool
72095
72109
  ]
72096
72110
  });
@@ -98,7 +98,10 @@ export function cleanAnswer(text) {
98
98
  .replace(/cite(?:turn\d+[a-z]+\d+)+/gi, '')
99
99
  .replace(/【[^】]{1,60}】/g, '')
100
100
  .replace(/[ \t]+\n/g, '\n')
101
- .replace(/[ \t]{2,}/g, ' ')
101
+ // Collapse only runs that FOLLOW a non-space character (the double spaces a
102
+ // removed token leaves mid-line), never leading indentation — a Copilot
103
+ // answer can quote an indented code block from a mail or note.
104
+ .replace(/(\S)[ \t]{2,}/g, '$1 ')
102
105
  .trim();
103
106
  }
104
107
  /** M365 refused to engage with the prompt. Not a rate limit — do not retry into it. */
package/dist/index.js CHANGED
@@ -45,7 +45,7 @@ await runMcp({
45
45
  registerWriteTools,
46
46
  registerExportTools,
47
47
  registerRuleTools,
48
- (server) => registerCopilotTools(server, copilot),
48
+ (server) => registerCopilotTools(server, copilot, client),
49
49
  registerHealthcheckTool,
50
50
  ],
51
51
  });
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { McpToolError, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
3
3
  import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js';
4
4
  import { briefDate, briefLine, briefResult, briefViewParam, spaced, workMeta } from './_hints.js';
5
- import { compactEvent, fullEvent, projectCollection, VIEWS, } from '../view.js';
5
+ import { compactEvent, detailedEvent, fullEvent, projectCollection, VIEWS, } from '../view.js';
6
6
  import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
7
7
  import { mailboxTimeZone } from '../timezone.js';
8
8
  const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,OnlineMeeting,BodyPreview,' +
@@ -89,7 +89,7 @@ export function registerCalendarTools(server, client) {
89
89
  return mailboxUntrusted(projectCollection(data, v === 'full' ? fullEvent : compactEvent, 'event'));
90
90
  });
91
91
  server.registerTool('outlook_get_event', {
92
- description: 'Get one calendar event in full, including attendees and their responses. Attendees appear in view:"full", every one of them — there is no 20-address cap as on a message\'s To/Cc; view:"raw" returns Outlook\'s untouched record, uncapped, Attendees included.' +
92
+ description: 'Get one calendar event with its attendees and their responses. Attendees come back in EVERY view, including the default compact one — all of them, uncapped (no 20-address cap as on a message\'s To/Cc). The event\'s time is labelled with its zone in the `TimeZone` field (the mailbox zone unless you pass `timeZone`); the same instant read in another zone shows a different clock time, so read `TimeZone`, not just the clock. view:"full" adds the Teams join link, body preview and web link; view:"raw" returns Outlook\'s untouched record.' +
93
93
  ' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
94
94
  annotations: { readOnlyHint: true, openWorldHint: true },
95
95
  _meta: workMeta('lookup'),
@@ -104,7 +104,7 @@ export function registerCalendarTools(server, client) {
104
104
  const v = resolveView(view, VIEWS);
105
105
  if (v === 'raw')
106
106
  return mailboxUntrusted(data);
107
- return mailboxUntrusted(v === 'compact' ? compactEvent(data) : fullEvent(data));
107
+ return mailboxUntrusted(v === 'full' ? fullEvent(data) : detailedEvent(data));
108
108
  });
109
109
  server.registerTool('outlook_list_calendars', {
110
110
  description: 'List the calendars in the mailbox, for picking one to read or write against.',
@@ -14,8 +14,28 @@
14
14
  import { z } from 'zod';
15
15
  import { minifiedResult } from '@chrischall/mcp-utils';
16
16
  import { DisengagedError } from '../copilot/session.js';
17
+ import { mailboxTimeZone } from '../timezone.js';
17
18
  import { workMeta } from './_hints.js';
18
19
  import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js';
20
+ /**
21
+ * A short context preamble prepended to every Copilot prompt.
22
+ *
23
+ * Copilot renders a stored UTC time as a bare clock with no zone — a 2026-11-09
24
+ * UTC time came back as "11:41 AM", which reads as local but is not. Telling it
25
+ * today's date and the mailbox zone, and to convert and label, fixes the
26
+ * labelling at the source. The mailbox zone is read once (cached per client); if
27
+ * it cannot be read — a server set up for Copilot but not the Outlook mail
28
+ * token — the instruction degrades to "label the zone" with no date-math hint.
29
+ */
30
+ async function timeContext(outlook) {
31
+ const today = new Date().toISOString().slice(0, 10);
32
+ const zone = await mailboxTimeZone(outlook);
33
+ return zone
34
+ ? `(Context: today is ${today}. The user's mailbox time zone is ${zone}, and mailbox times are stored in UTC. When you state any date or time, convert it to ${zone} and label the zone.)`
35
+ : `(Context: today is ${today}. Mailbox times are stored in UTC — when you state a time, label its time zone.)`;
36
+ }
37
+ /** The extra instruction a verbatim request prepends. */
38
+ const VERBATIM_DIRECTIVE = "(Reproduce any content I ask you to quote or return IN FULL and verbatim — do not summarise, paraphrase, shorten, or omit any part of it, even if it is long.)";
19
39
  /** Stop a turn that outlives this, so a wedged connection cannot hang the call. */
20
40
  const TURN_TIMEOUT_MS = 120_000;
21
41
  /**
@@ -41,9 +61,9 @@ function turnSignal(hostSignal) {
41
61
  },
42
62
  };
43
63
  }
44
- export function registerCopilotTools(server, copilot) {
64
+ export function registerCopilotTools(server, copilot, outlook) {
45
65
  server.registerTool('outlook_ask_copilot', {
46
- description: 'Ask Microsoft 365 Copilot a question answered from the signed-in mailbox (Copilot searches your own mail, calendar and files server-side and writes a reply). Use it for synthesis across many messages — "what needs my attention first", "summarise the thread with X", "did anyone ask me for anything this week". Follow-up calls continue the same conversation; pass newConversation:true to start fresh. The prompt is sent to Microsoft\'s Copilot service on your tenant; put your own question in it, not text copied from an email. Each call uses one of a per-conversation quota, reported back. First use needs a one-time sign-in: run `opencode-m365 login` (from the opencode-m365-copilot package) so an MSAL cache exists.' +
66
+ description: 'Ask Microsoft 365 Copilot a question answered from the signed-in mailbox (Copilot searches your own mail, calendar and files server-side and writes a reply). Use it for synthesis across many messages — "what needs my attention first", "summarise the thread with X", "did anyone ask me for anything this week". Follow-up calls continue the same conversation; pass newConversation:true to start fresh. Pass verbatim:true when you need content quoted back in full (Copilot otherwise answers concisely and summarises long material; for a single item a focused "quote X in full" also works). Times are converted to the mailbox time zone and labelled. The prompt is sent to Microsoft\'s Copilot service on your tenant; put your own question in it, not text copied from an email. Each call uses one of a per-conversation quota, reported back. First use needs a one-time sign-in: run `opencode-m365 login` (from the opencode-m365-copilot package) so an MSAL cache exists.' +
47
67
  UNTRUSTED_DESCRIPTION_SUFFIX,
48
68
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
49
69
  _meta: workMeta('lookup', { aliases: ['copilot', 'ask copilot', 'm365 copilot'] }),
@@ -53,13 +73,20 @@ export function registerCopilotTools(server, copilot) {
53
73
  .boolean()
54
74
  .optional()
55
75
  .describe('Start a fresh conversation with no prior context (default false: continue the current one)'),
76
+ verbatim: z
77
+ .boolean()
78
+ .optional()
79
+ .describe('Ask Copilot to quote requested content in full rather than summarising (default false). Use for retrieving a saved note, a full message, etc.'),
56
80
  }),
57
- }, async ({ prompt, newConversation }, ctx) => {
81
+ }, async ({ prompt, newConversation, verbatim }, ctx) => {
58
82
  if (newConversation)
59
83
  copilot.reset();
60
84
  const { signal, dispose } = turnSignal(ctx?.signal);
61
85
  try {
62
- const result = await copilot.ask(prompt, { signal });
86
+ const preamble = [await timeContext(outlook), verbatim ? VERBATIM_DIRECTIVE : '']
87
+ .filter(Boolean)
88
+ .join(' ');
89
+ const result = await copilot.ask(`${preamble}\n\n${prompt}`, { signal });
63
90
  return mailboxUntrusted({
64
91
  answer: result.text,
65
92
  conversationId: copilot.conversationId,
package/dist/version.js CHANGED
@@ -10,4 +10,4 @@
10
10
  * line below and nowhere else — not even in prose.
11
11
  */
12
12
  export const PACKAGE_NAME = 'office-outlook-mcp';
13
- export const VERSION = '0.9.0'; // x-release-please-version
13
+ export const VERSION = '0.10.0'; // x-release-please-version
package/dist/view.js CHANGED
@@ -100,11 +100,28 @@ export function compactEvent(e) {
100
100
  ShowAs: e.ShowAs,
101
101
  });
102
102
  }
103
+ /** Attendees projected as `{ Who, Response }`, or undefined when there are none. */
104
+ export function eventAttendees(e) {
105
+ return e.Attendees?.map((a) => pruned({ Who: addr(a), Response: a.Status?.Response }));
106
+ }
107
+ /**
108
+ * A single event's compact view WITH its attendees — what outlook_get_event
109
+ * returns by default.
110
+ *
111
+ * A single event is not a list, so the per-item cap that keeps attendees off
112
+ * list_events (one `calendarview` page can hold dozens of events) does not
113
+ * apply, and the attendees are the point of looking one event up. list_events
114
+ * still uses the attendee-free {@link compactEvent}; only get_event carries them
115
+ * in its compact view, as its description promises.
116
+ */
117
+ export function detailedEvent(e) {
118
+ return pruned({ ...compactEvent(e), Attendees: eventAttendees(e) });
119
+ }
103
120
  export function fullEvent(e) {
104
121
  return pruned({
105
122
  ...compactEvent(e),
106
123
  SeriesMasterId: e.SeriesMasterId,
107
- Attendees: e.Attendees?.map((a) => pruned({ Who: addr(a), Response: a.Status?.Response })),
124
+ Attendees: eventAttendees(e),
108
125
  // The Teams link lives under OnlineMeeting; OnlineMeetingUrl is empty on
109
126
  // every live Teams meeting checked.
110
127
  JoinUrl: e.OnlineMeeting?.JoinUrl || e.OnlineMeetingUrl || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "mcpName": "io.github.chrischall/office-outlook-mcp",
5
5
  "description": "Outlook / Microsoft 365 MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/office-outlook-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.9.0",
9
+ "version": "0.10.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.9.0",
14
+ "version": "0.10.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },