@chrischall/office-outlook-mcp 0.3.5 → 0.4.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.3.5"
10
+ "version": "0.4.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 — read mail, folders, calendar, contacts and tasks, and send mail with confirmation",
18
- "version": "0.3.5",
18
+ "version": "0.4.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.3.5",
4
+ "version": "0.4.0",
5
5
  "description": "Outlook / Microsoft 365 for Claude — read mail, folders, calendar, contacts and tasks, and send mail with confirmation, using your signed-in browser session",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -108,13 +108,21 @@ Everything is optional — with nothing set, the server captures from the browse
108
108
 
109
109
  **Read** — `outlook_list_folders`, `outlook_list_messages`,
110
110
  `outlook_get_message`, `outlook_list_attachments`, `outlook_list_events`,
111
- `outlook_get_event`, `outlook_list_calendars`, `outlook_get_profile`,
111
+ `outlook_get_event`, `outlook_list_calendars`, `outlook_find_meeting_times`,
112
+ `outlook_get_schedule`, `outlook_get_profile`,
112
113
  `outlook_get_mailbox_settings`, `outlook_list_contacts`, `outlook_list_people`,
113
114
  `outlook_list_tasks`
114
115
 
115
116
  **Write** (all ask you to confirm first — see [Confirmations](#confirmations)) — `outlook_send_mail`,
116
117
  `outlook_create_draft`, `outlook_mark_read`, `outlook_move_message`,
117
- `outlook_create_event`
118
+ `outlook_create_event`, `outlook_update_event`
119
+
120
+ **Meetings** — `outlook_find_meeting_times` asks Outlook's Scheduling
121
+ Assistant for slots when everyone is free, and `outlook_get_schedule` shows
122
+ each person's busy blocks. `outlook_create_event` and `outlook_update_event`
123
+ attach a Microsoft Teams meeting by default and return its join link; pass
124
+ `teamsMeeting: false` to leave it off. Only the organizer can update a
125
+ meeting.
118
126
 
119
127
  **Diagnostics** — `outlook_healthcheck`
120
128
 
package/dist/bundle.js CHANGED
@@ -23737,10 +23737,10 @@ function scanXMcpHeaderDeclarations(inputSchema) {
23737
23737
  if (!RFC9110_TOKEN.test(raw)) return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`;
23738
23738
  const type = typeof schema.type === "string" ? schema.type : void 0;
23739
23739
  if (type === void 0 || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? "<none>"}`;
23740
- const lower = raw.toLowerCase();
23741
- const prior = seenLower.get(lower);
23740
+ const lower2 = raw.toLowerCase();
23741
+ const prior = seenLower.get(lower2);
23742
23742
  if (prior !== void 0) return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`;
23743
- seenLower.set(lower, raw);
23743
+ seenLower.set(lower2, raw);
23744
23744
  declarations.push({
23745
23745
  path,
23746
23746
  headerName: raw,
@@ -37148,7 +37148,7 @@ var init_version = __esm({
37148
37148
  "src/version.ts"() {
37149
37149
  "use strict";
37150
37150
  PACKAGE_NAME = "office-outlook-mcp";
37151
- VERSION = "0.3.5";
37151
+ VERSION = "0.4.0";
37152
37152
  }
37153
37153
  });
37154
37154
 
@@ -48387,6 +48387,19 @@ var OutlookClient = class {
48387
48387
  }
48388
48388
  return this.get(target.pathname.slice(prefix.length) + target.search, opts);
48389
48389
  }
48390
+ /**
48391
+ * POST a read-only query — `findmeetingtimes`, `getschedule` — that changes
48392
+ * nothing in the mailbox. Kept apart from `write` so a reader can tell the
48393
+ * two apart at the call site, and because these take `Prefer` (notably
48394
+ * `outlook.timezone`, without which every returned slot is UTC).
48395
+ */
48396
+ async post(path, body, opts = {}) {
48397
+ this.#requireConfigured();
48398
+ return this.#client().fetchJson("POST", path, {
48399
+ body,
48400
+ headers: opts.prefer ? { Prefer: opts.prefer } : void 0
48401
+ });
48402
+ }
48390
48403
  /**
48391
48404
  * The single write path. Everything mutating goes through here so auth and
48392
48405
  * error shaping stay in one place.
@@ -48487,7 +48500,11 @@ function fullEvent(e) {
48487
48500
  Attendees: e.Attendees?.map(
48488
48501
  (a) => pruned({ Who: addr(a), Response: a.Status?.Response })
48489
48502
  ),
48490
- OnlineMeetingUrl: e.OnlineMeetingUrl,
48503
+ // The Teams link lives under OnlineMeeting; OnlineMeetingUrl is empty on
48504
+ // every live Teams meeting checked.
48505
+ JoinUrl: e.OnlineMeeting?.JoinUrl || e.OnlineMeetingUrl || void 0,
48506
+ // Kept beside JoinUrl so a consumer of the old field still finds it.
48507
+ OnlineMeetingUrl: e.OnlineMeetingUrl || void 0,
48491
48508
  Preview: e.BodyPreview?.trim() || void 0,
48492
48509
  WebLink: e.WebLink
48493
48510
  });
@@ -48703,7 +48720,7 @@ async function mailboxTimeZone(client2) {
48703
48720
  }
48704
48721
 
48705
48722
  // src/tools/calendar.ts
48706
- var EVENT_SELECT = "Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview";
48723
+ var EVENT_SELECT = "Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,OnlineMeeting,BodyPreview";
48707
48724
  function qs2(params) {
48708
48725
  const parts = [];
48709
48726
  for (const [k, v] of Object.entries(params)) {
@@ -48712,6 +48729,28 @@ function qs2(params) {
48712
48729
  }
48713
48730
  return parts.length ? `?${parts.join("&")}` : "";
48714
48731
  }
48732
+ function dateTimeTimeZone(value, zone) {
48733
+ if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
48734
+ const d = new Date(value);
48735
+ if (!Number.isNaN(d.getTime())) {
48736
+ return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ""), TimeZone: "UTC" };
48737
+ }
48738
+ }
48739
+ return { DateTime: value, TimeZone: zone };
48740
+ }
48741
+ function assertWindow(start, end) {
48742
+ const a = Date.parse(start);
48743
+ const b = Date.parse(end);
48744
+ if (!Number.isNaN(a) && !Number.isNaN(b) && b <= a) {
48745
+ throw new McpToolError(`The window ends (${end}) before it starts (${start}).`, {
48746
+ hint: "`end` must be later than `start`."
48747
+ });
48748
+ }
48749
+ }
48750
+ function trimTime(dt) {
48751
+ return dt?.replace(/\.0+$/, "");
48752
+ }
48753
+ var attendeeList = (what) => external_exports.array(external_exports.string().min(3)).describe(`Email addresses of ${what}`);
48715
48754
  function registerCalendarTools(server, client2) {
48716
48755
  server.registerTool(
48717
48756
  "outlook_list_events",
@@ -48788,6 +48827,119 @@ function registerCalendarTools(server, client2) {
48788
48827
  return minifiedResult(plainCollection(data));
48789
48828
  }
48790
48829
  );
48830
+ server.registerTool(
48831
+ "outlook_find_meeting_times",
48832
+ {
48833
+ description: "Find times when people can meet \u2014 Outlook's Scheduling Assistant. Give the attendees, a search window and a duration; Outlook checks everyone's free/busy (and the signed-in user's, as organizer) and returns ranked candidate slots with each attendee's availability. By default only working hours are searched. Use this to answer \"when can X and Y meet?\"; then book with outlook_create_event. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone), and returned slots are in that zone too. " + UNTRUSTED_DESCRIPTION_SUFFIX,
48834
+ annotations: { readOnlyHint: true },
48835
+ inputSchema: external_exports.object({
48836
+ view: viewParam(VIEWS2),
48837
+ attendees: attendeeList("required attendees").min(1),
48838
+ optionalAttendees: attendeeList("optional attendees").optional(),
48839
+ start: external_exports.string().min(1).describe("Earliest the meeting may start, e.g. 2026-10-12T09:00:00"),
48840
+ end: external_exports.string().min(1).describe("Latest the meeting may end, e.g. 2026-10-16T17:00:00"),
48841
+ durationMinutes: external_exports.number().int().min(5).max(1440).describe("Meeting length in minutes"),
48842
+ timeZone: external_exports.string().optional().describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
48843
+ maxCandidates: external_exports.number().int().min(1).max(50).optional().describe("Max suggestions (default 10)"),
48844
+ workingHoursOnly: external_exports.boolean().optional().describe("Only suggest times inside working hours (default true). false searches around the clock."),
48845
+ minimumAttendeePercentage: external_exports.number().min(0).max(100).optional().describe("Lowest confidence (0-100) a slot needs to be suggested. Lower it to see slots where not everyone is free."),
48846
+ isOrganizerOptional: external_exports.boolean().optional().describe("true when the signed-in user need not attend, so their own calendar is ignored")
48847
+ })
48848
+ },
48849
+ async (args) => {
48850
+ assertWindow(args.start, args.end);
48851
+ const zone = args.timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
48852
+ const body = {
48853
+ Attendees: [
48854
+ ...args.attendees.map((Address) => ({ Type: "Required", EmailAddress: { Address } })),
48855
+ ...(args.optionalAttendees ?? []).map((Address) => ({
48856
+ Type: "Optional",
48857
+ EmailAddress: { Address }
48858
+ }))
48859
+ ],
48860
+ TimeConstraint: {
48861
+ ActivityDomain: args.workingHoursOnly === false ? "Unrestricted" : "Work",
48862
+ Timeslots: [
48863
+ { Start: dateTimeTimeZone(args.start, zone), End: dateTimeTimeZone(args.end, zone) }
48864
+ ]
48865
+ },
48866
+ MeetingDuration: `PT${args.durationMinutes}M`,
48867
+ MaxCandidates: args.maxCandidates ?? 10,
48868
+ ReturnSuggestionReasons: true,
48869
+ ...args.minimumAttendeePercentage !== void 0 ? { MinimumAttendeePercentage: args.minimumAttendeePercentage } : {},
48870
+ ...args.isOrganizerOptional !== void 0 ? { IsOrganizerOptional: args.isOrganizerOptional } : {}
48871
+ };
48872
+ const data = await client2.post("/me/findmeetingtimes", body, { prefer: `outlook.timezone="${zone}"` });
48873
+ if (resolveView(args.view, VIEWS2) === "raw") return mailboxUntrusted(data);
48874
+ const suggestions = (data?.MeetingTimeSuggestions ?? []).map((s) => ({
48875
+ Start: trimTime(s.MeetingTimeSlot?.Start?.DateTime),
48876
+ End: trimTime(s.MeetingTimeSlot?.End?.DateTime),
48877
+ Confidence: s.Confidence,
48878
+ Organizer: s.OrganizerAvailability,
48879
+ Attendees: Object.fromEntries(
48880
+ (s.AttendeeAvailability ?? []).map((a) => [
48881
+ a.Attendee?.EmailAddress?.Address ?? a.Attendee?.EmailAddress?.Name ?? "?",
48882
+ a.Availability
48883
+ ])
48884
+ ),
48885
+ Reason: s.SuggestionReason
48886
+ }));
48887
+ return mailboxUntrusted({
48888
+ timeZone: zone,
48889
+ count: suggestions.length,
48890
+ suggestions,
48891
+ // Outlook explains an empty result ("AttendeesUnavailable", ...); an
48892
+ // empty list without it reads as "nobody is ever free".
48893
+ ...data?.EmptySuggestionsReason ? { emptySuggestionsReason: data.EmptySuggestionsReason } : {}
48894
+ });
48895
+ }
48896
+ );
48897
+ server.registerTool(
48898
+ "outlook_get_schedule",
48899
+ {
48900
+ description: "Get people's free/busy blocks for a time window \u2014 the grid view of Outlook's Scheduling Assistant. Returns each person's busy, tentative and out-of-office items (with subject and location where their calendar shares them). Use outlook_find_meeting_times to have Outlook pick slots instead. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone). " + UNTRUSTED_DESCRIPTION_SUFFIX,
48901
+ annotations: { readOnlyHint: true },
48902
+ inputSchema: external_exports.object({
48903
+ view: viewParam(VIEWS2),
48904
+ people: attendeeList("people (or rooms) to look up").min(1).max(100),
48905
+ start: external_exports.string().min(1).describe("Window start, e.g. 2026-10-12T09:00:00"),
48906
+ end: external_exports.string().min(1).describe("Window end, e.g. 2026-10-12T17:00:00"),
48907
+ timeZone: external_exports.string().optional().describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
48908
+ intervalMinutes: external_exports.number().int().min(5).max(1440).optional().describe("Slot size for the raw view's AvailabilityView string (default 30)")
48909
+ })
48910
+ },
48911
+ async ({ view, people, start, end, timeZone, intervalMinutes }) => {
48912
+ assertWindow(start, end);
48913
+ const zone = timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
48914
+ const data = await client2.post(
48915
+ "/me/calendar/getschedule",
48916
+ {
48917
+ Schedules: people,
48918
+ StartTime: dateTimeTimeZone(start, zone),
48919
+ EndTime: dateTimeTimeZone(end, zone),
48920
+ AvailabilityViewInterval: intervalMinutes ?? 30
48921
+ },
48922
+ { prefer: `outlook.timezone="${zone}"` }
48923
+ );
48924
+ if (resolveView(view, VIEWS2) === "raw") return mailboxUntrusted(data);
48925
+ const schedules = (data?.value ?? []).map(
48926
+ (p) => (
48927
+ // An unresolvable address must not read as "free all day".
48928
+ p.Error !== void 0 && p.Error !== null ? { Who: p.ScheduleId, Error: p.Error } : {
48929
+ Who: p.ScheduleId,
48930
+ Busy: (p.ScheduleItems ?? []).map((i) => ({
48931
+ Start: trimTime(i.Start?.DateTime),
48932
+ End: trimTime(i.End?.DateTime),
48933
+ Status: i.Status,
48934
+ ...i.Subject ? { Subject: i.Subject } : {},
48935
+ ...i.Location ? { Location: i.Location } : {}
48936
+ }))
48937
+ }
48938
+ )
48939
+ );
48940
+ return mailboxUntrusted({ timeZone: zone, schedules });
48941
+ }
48942
+ );
48791
48943
  }
48792
48944
 
48793
48945
  // src/tools/directory.ts
@@ -48912,6 +49064,31 @@ function messageBody(subject, body, html) {
48912
49064
  Body: { ContentType: html === true ? "HTML" : "Text", Content: body }
48913
49065
  };
48914
49066
  }
49067
+ var TEAMS = { IsOnlineMeeting: true, OnlineMeetingProvider: "TeamsForBusiness" };
49068
+ var teamsMeetingParam = external_exports.boolean().optional().describe("Attach a Microsoft Teams meeting (default true). false leaves it off.");
49069
+ var EVENT_STATE_SELECT = "Id,Subject,IsOrganizer,Start,End,Location,Attendees,IsOnlineMeeting,OnlineMeetingProvider,OnlineMeeting";
49070
+ function attendee(Address, Type) {
49071
+ return { Type, EmailAddress: { Address } };
49072
+ }
49073
+ var lower = (a) => a.EmailAddress?.Address?.toLowerCase();
49074
+ function joinUrlOf(e) {
49075
+ return e?.OnlineMeeting?.JoinUrl || void 0;
49076
+ }
49077
+ function isTeams(e) {
49078
+ return e.IsOnlineMeeting === true && e.OnlineMeetingProvider === "TeamsForBusiness";
49079
+ }
49080
+ function dateTimeTimeZone2(value, zone) {
49081
+ if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
49082
+ const d = new Date(value);
49083
+ if (!Number.isNaN(d.getTime())) {
49084
+ return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ""), TimeZone: "UTC" };
49085
+ }
49086
+ }
49087
+ return { DateTime: value, TimeZone: zone };
49088
+ }
49089
+ var naiveMs = (dt) => Date.parse(`${dt.replace(/\.\d+$/, "")}Z`);
49090
+ var fromNaiveMs = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "");
49091
+ var trimTime2 = (dt) => dt?.replace(/\.\d+$/, "");
48915
49092
  function registerWriteTools(server, client2) {
48916
49093
  server.registerTool(
48917
49094
  "outlook_send_mail",
@@ -49071,7 +49248,7 @@ function registerWriteTools(server, client2) {
49071
49248
  server.registerTool(
49072
49249
  "outlook_create_event",
49073
49250
  {
49074
- description: 'Create a calendar event. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation. ' + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
49251
+ description: 'Create a calendar event or meeting. A Microsoft Teams meeting is attached by default (`teamsMeeting: false` to skip) and its join link is returned. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation. To pick a time first, use outlook_find_meeting_times. ' + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
49075
49252
  annotations: { readOnlyHint: false, destructiveHint: false },
49076
49253
  inputSchema: external_exports.object({
49077
49254
  subject: external_exports.string().describe("Event title"),
@@ -49080,11 +49257,18 @@ function registerWriteTools(server, client2) {
49080
49257
  timeZone: external_exports.string().optional().describe("Windows time-zone name. Defaults to the MAILBOX time zone."),
49081
49258
  location: external_exports.string().optional().describe("Location display name"),
49082
49259
  body: external_exports.string().optional().describe("Event description"),
49083
- attendees: recipientList,
49260
+ attendees: recipientList.describe("Required attendees' email addresses"),
49261
+ optionalAttendees: recipientList.describe("Optional attendees' email addresses"),
49262
+ teamsMeeting: teamsMeetingParam,
49084
49263
  confirmToken: confirmTokenParam
49085
49264
  })
49086
49265
  },
49087
- async ({ subject, start, end, timeZone, location, body, attendees, confirmToken }, ctx) => {
49266
+ async ({ subject, start, end, timeZone, location, body, attendees, optionalAttendees, teamsMeeting, confirmToken }, ctx) => {
49267
+ const wantTeams = teamsMeeting !== false;
49268
+ const allAttendees = [
49269
+ ...(attendees ?? []).map((a) => attendee(a, "Required")),
49270
+ ...(optionalAttendees ?? []).map((a) => attendee(a, "Optional"))
49271
+ ];
49088
49272
  const tz = timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
49089
49273
  const payload = {
49090
49274
  Subject: subject,
@@ -49092,18 +49276,14 @@ function registerWriteTools(server, client2) {
49092
49276
  End: { DateTime: end, TimeZone: tz },
49093
49277
  ...location ? { Location: { DisplayName: location } } : {},
49094
49278
  ...body ? { Body: { ContentType: "Text", Content: body } } : {},
49095
- ...attendees?.length ? {
49096
- Attendees: attendees.map((Address) => ({
49097
- EmailAddress: { Address },
49098
- Type: "Required"
49099
- }))
49100
- } : {}
49279
+ ...allAttendees.length ? { Attendees: allAttendees } : {},
49280
+ ...wantTeams ? TEAMS : {}
49101
49281
  };
49102
49282
  const gate = await confirmWrite(ctx, {
49103
49283
  tool: "outlook_create_event",
49104
49284
  action: "calendar.create_event",
49105
49285
  message: "Review and confirm this event (attendees are emailed an invitation):",
49106
- summary: `Create event "${subject}" ${start} to ${end} (${tz})`,
49286
+ summary: `Create ${wantTeams ? "Teams meeting" : "event"} "${subject}" ${start} to ${end} (${tz})`,
49107
49287
  // One signed-in mailbox per server process.
49108
49288
  account: void 0,
49109
49289
  request: { method: "POST", path: "/me/events", body: payload },
@@ -49115,7 +49295,136 @@ function registerWriteTools(server, client2) {
49115
49295
  "/me/events",
49116
49296
  payload
49117
49297
  );
49118
- return minifiedResult({ created: true, Id: created?.Id, WebLink: created?.WebLink });
49298
+ let joinUrl = joinUrlOf(created);
49299
+ if (wantTeams && !joinUrl && created?.Id) {
49300
+ const after = await client2.get(
49301
+ `/me/events/${encodeURIComponent(created.Id)}?$select=OnlineMeeting`
49302
+ );
49303
+ joinUrl = joinUrlOf(after);
49304
+ }
49305
+ return minifiedResult({
49306
+ created: true,
49307
+ Id: created?.Id,
49308
+ WebLink: created?.WebLink,
49309
+ ...joinUrl ? { JoinUrl: joinUrl } : {},
49310
+ ...wantTeams && !joinUrl ? { warning: "The event was created, but Outlook returned no Teams join link." } : {}
49311
+ });
49312
+ }
49313
+ );
49314
+ server.registerTool(
49315
+ "outlook_update_event",
49316
+ {
49317
+ description: "Update a meeting you organize: retitle, move, relocate, edit the description, or add/remove attendees. Moving only `start` keeps the meeting's length. A Microsoft Teams meeting is added if the event lacks one (`teamsMeeting: false` to skip). Attendees are sent an updated invitation. Only the organizer can update a meeting \u2014 an attendee's edit would change their own copy alone, so it is refused. The result is verified by re-reading the event. " + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
49318
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
49319
+ inputSchema: external_exports.object({
49320
+ id: external_exports.string().min(1).describe("Event Id from outlook_list_events or outlook_get_event"),
49321
+ subject: external_exports.string().optional().describe("New title"),
49322
+ start: external_exports.string().optional().describe("New start, ISO 8601 local time e.g. 2026-09-22T15:00:00"),
49323
+ end: external_exports.string().optional().describe("New end. Omit when moving `start` to keep the current length."),
49324
+ timeZone: external_exports.string().optional().describe("Windows time-zone name for start/end. Defaults to the MAILBOX time zone."),
49325
+ location: external_exports.string().optional().describe("New location display name"),
49326
+ body: external_exports.string().optional().describe("New description (replaces the existing one)"),
49327
+ addAttendees: recipientList.describe("Required attendees to add"),
49328
+ addOptionalAttendees: recipientList.describe("Optional attendees to add"),
49329
+ removeAttendees: recipientList.describe("Attendees to remove"),
49330
+ teamsMeeting: teamsMeetingParam,
49331
+ confirmToken: confirmTokenParam
49332
+ })
49333
+ },
49334
+ async (args, ctx) => {
49335
+ const tz = args.timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
49336
+ const path = `/me/events/${encodeURIComponent(args.id)}`;
49337
+ const readEvent = (zone) => client2.get(`${path}?$select=${EVENT_STATE_SELECT}`, {
49338
+ prefer: `outlook.timezone="${zone}"`
49339
+ });
49340
+ const current = await readEvent(tz);
49341
+ if (current?.IsOrganizer === false) {
49342
+ throw new McpToolError("Only the organizer can update this meeting.", {
49343
+ hint: "You are an attendee. Changing your copy would not reach the organizer or anyone else; ask the organizer, or propose a new time from Outlook."
49344
+ });
49345
+ }
49346
+ const payload = {};
49347
+ if (args.subject !== void 0) payload.Subject = args.subject;
49348
+ if (args.location !== void 0) payload.Location = { DisplayName: args.location };
49349
+ if (args.body !== void 0) payload.Body = { ContentType: "Text", Content: args.body };
49350
+ if (args.start !== void 0 || args.end !== void 0) {
49351
+ const start = args.start !== void 0 ? dateTimeTimeZone2(args.start, tz) : void 0;
49352
+ let end = args.end !== void 0 ? dateTimeTimeZone2(args.end, tz) : void 0;
49353
+ if (start && !end && current?.Start?.DateTime && current?.End?.DateTime) {
49354
+ const length = naiveMs(current.End.DateTime) - naiveMs(current.Start.DateTime);
49355
+ end = { DateTime: fromNaiveMs(naiveMs(start.DateTime) + length), TimeZone: start.TimeZone };
49356
+ }
49357
+ if (args.start !== void 0 && args.end !== void 0 && start?.TimeZone !== end?.TimeZone) {
49358
+ throw new McpToolError("`start` and `end` are in different zones.", {
49359
+ hint: "Give both the same way: both local wall-clock times, or both with Z or an offset."
49360
+ });
49361
+ }
49362
+ const startIn = start ?? (end && end.TimeZone !== tz ? (await readEvent(end.TimeZone))?.Start : current?.Start);
49363
+ if (end && startIn?.DateTime && naiveMs(end.DateTime) <= naiveMs(startIn.DateTime)) {
49364
+ throw new McpToolError("The meeting would end before it starts.", {
49365
+ hint: "`end` must be later than `start`."
49366
+ });
49367
+ }
49368
+ if (start) payload.Start = start;
49369
+ if (end) payload.End = end;
49370
+ }
49371
+ const adding = [
49372
+ ...(args.addAttendees ?? []).map((a) => attendee(a, "Required")),
49373
+ ...(args.addOptionalAttendees ?? []).map((a) => attendee(a, "Optional"))
49374
+ ];
49375
+ const removing = new Set((args.removeAttendees ?? []).map((a) => a.toLowerCase()));
49376
+ if (adding.length || removing.size) {
49377
+ const next = (current?.Attendees ?? []).filter((a) => !removing.has(lower(a) ?? "")).map(({ Type, EmailAddress }) => ({ Type, EmailAddress }));
49378
+ for (const a of adding) {
49379
+ if (!next.some((n) => lower(n) === lower(a))) next.push(a);
49380
+ }
49381
+ payload.Attendees = next;
49382
+ }
49383
+ const addTeams = args.teamsMeeting !== false && !(current && isTeams(current));
49384
+ if (addTeams) Object.assign(payload, TEAMS);
49385
+ if (Object.keys(payload).length === 0) {
49386
+ throw new McpToolError("Nothing to update.", {
49387
+ hint: "Pass at least one of subject, start, end, location, body, or attendee changes."
49388
+ });
49389
+ }
49390
+ const gate = await confirmWrite(ctx, {
49391
+ tool: "outlook_update_event",
49392
+ action: "calendar.update_event",
49393
+ message: "Review and confirm this change (attendees are sent an updated invitation):",
49394
+ summary: `Update "${current?.Subject ?? args.id}": ${Object.keys(payload).join(", ")}`,
49395
+ // One signed-in mailbox per server process.
49396
+ account: void 0,
49397
+ target: args.id,
49398
+ request: { method: "PATCH", path, body: payload },
49399
+ confirmToken: args.confirmToken
49400
+ });
49401
+ if (gate) return gate;
49402
+ await client2.write("PATCH", path, payload);
49403
+ const writtenZone = (payload.Start ?? payload.End)?.TimeZone ?? tz;
49404
+ const after = await readEvent(writtenZone);
49405
+ const unchanged = [];
49406
+ if (payload.Subject !== void 0 && after?.Subject !== payload.Subject) unchanged.push("Subject");
49407
+ for (const key of ["Start", "End"]) {
49408
+ const want = payload[key];
49409
+ if (want && trimTime2(after?.[key]?.DateTime) !== trimTime2(want.DateTime)) unchanged.push(key);
49410
+ }
49411
+ if (payload.Location !== void 0 && after?.Location?.DisplayName !== args.location) {
49412
+ unchanged.push("Location");
49413
+ }
49414
+ if (payload.Attendees !== void 0) {
49415
+ const want = payload.Attendees.map(lower).sort().join();
49416
+ const got = (after?.Attendees ?? []).map(lower).sort().join();
49417
+ if (want !== got) unchanged.push("Attendees");
49418
+ }
49419
+ const joinUrl = joinUrlOf(after);
49420
+ if (addTeams && !joinUrl) unchanged.push("Teams meeting");
49421
+ return minifiedResult({
49422
+ updated: unchanged.length === 0,
49423
+ Id: after?.Id ?? args.id,
49424
+ changed: Object.keys(payload),
49425
+ ...joinUrl ? { JoinUrl: joinUrl } : {},
49426
+ ...unchanged.length ? { warning: `Outlook accepted the update but these did not change: ${unchanged.join(", ")}.` } : {}
49427
+ });
49119
49428
  }
49120
49429
  );
49121
49430
  }
package/dist/client.js CHANGED
@@ -228,6 +228,19 @@ export class OutlookClient {
228
228
  }
229
229
  return this.get(target.pathname.slice(prefix.length) + target.search, opts);
230
230
  }
231
+ /**
232
+ * POST a read-only query — `findmeetingtimes`, `getschedule` — that changes
233
+ * nothing in the mailbox. Kept apart from `write` so a reader can tell the
234
+ * two apart at the call site, and because these take `Prefer` (notably
235
+ * `outlook.timezone`, without which every returned slot is UTC).
236
+ */
237
+ async post(path, body, opts = {}) {
238
+ this.#requireConfigured();
239
+ return this.#client().fetchJson('POST', path, {
240
+ body,
241
+ headers: opts.prefer ? { Prefer: opts.prefer } : undefined,
242
+ });
243
+ }
231
244
  /**
232
245
  * The single write path. Everything mutating goes through here so auth and
233
246
  * error shaping stay in one place.
@@ -1,11 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
3
3
  import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js';
4
4
  import { compactEvent, fullEvent, projectCollection, } from '../view.js';
5
5
  import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
6
6
  import { VIEWS } from './mail.js';
7
7
  import { mailboxTimeZone } from '../timezone.js';
8
- const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview';
8
+ const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,OnlineMeeting,BodyPreview';
9
9
  function qs(params) {
10
10
  const parts = [];
11
11
  for (const [k, v] of Object.entries(params)) {
@@ -15,6 +15,37 @@ function qs(params) {
15
15
  }
16
16
  return parts.length ? `?${parts.join('&')}` : '';
17
17
  }
18
+ /**
19
+ * An Outlook `DateTimeTimeZone` for a caller-supplied time.
20
+ *
21
+ * A wall-clock time ("2026-10-12T09:00:00") is read in `zone`, matching
22
+ * outlook_create_event. One carrying `Z` or an offset already names its
23
+ * instant, so it is converted to UTC rather than having its offset silently
24
+ * ignored and re-read in the mailbox zone.
25
+ */
26
+ function dateTimeTimeZone(value, zone) {
27
+ if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
28
+ const d = new Date(value);
29
+ if (!Number.isNaN(d.getTime())) {
30
+ return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ''), TimeZone: 'UTC' };
31
+ }
32
+ }
33
+ return { DateTime: value, TimeZone: zone };
34
+ }
35
+ function assertWindow(start, end) {
36
+ const a = Date.parse(start);
37
+ const b = Date.parse(end);
38
+ if (!Number.isNaN(a) && !Number.isNaN(b) && b <= a) {
39
+ throw new McpToolError(`The window ends (${end}) before it starts (${start}).`, {
40
+ hint: '`end` must be later than `start`.',
41
+ });
42
+ }
43
+ }
44
+ /** Outlook pads times to 7 fractional digits; that is noise to a reader. */
45
+ function trimTime(dt) {
46
+ return dt?.replace(/\.0+$/, '');
47
+ }
48
+ const attendeeList = (what) => z.array(z.string().min(3)).describe(`Email addresses of ${what}`);
18
49
  export function registerCalendarTools(server, client) {
19
50
  server.registerTool('outlook_list_events', {
20
51
  description: 'List calendar events in a date window. This uses the calendar VIEW, which expands recurring series into their individual occurrences — the right tool for "what is on my schedule". Times are returned in `timeZone` when given.' +
@@ -78,4 +109,135 @@ export function registerCalendarTools(server, client) {
78
109
  return minifiedResult(data);
79
110
  return minifiedResult(plainCollection(data));
80
111
  });
112
+ server.registerTool('outlook_find_meeting_times', {
113
+ description: "Find times when people can meet — Outlook's Scheduling Assistant. Give the attendees, a search window and a duration; Outlook checks everyone's free/busy (and the signed-in user's, as organizer) and returns ranked candidate slots with each attendee's availability. By default only working hours are searched. Use this to answer \"when can X and Y meet?\"; then book with outlook_create_event. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone), and returned slots are in that zone too." +
114
+ ' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
115
+ annotations: { readOnlyHint: true },
116
+ inputSchema: z.object({
117
+ view: viewParam(VIEWS),
118
+ attendees: attendeeList('required attendees').min(1),
119
+ optionalAttendees: attendeeList('optional attendees').optional(),
120
+ start: z.string().min(1).describe('Earliest the meeting may start, e.g. 2026-10-12T09:00:00'),
121
+ end: z.string().min(1).describe('Latest the meeting may end, e.g. 2026-10-16T17:00:00'),
122
+ durationMinutes: z.number().int().min(5).max(1440).describe('Meeting length in minutes'),
123
+ timeZone: z
124
+ .string()
125
+ .optional()
126
+ .describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
127
+ maxCandidates: z.number().int().min(1).max(50).optional().describe('Max suggestions (default 10)'),
128
+ workingHoursOnly: z
129
+ .boolean()
130
+ .optional()
131
+ .describe("Only suggest times inside working hours (default true). false searches around the clock."),
132
+ minimumAttendeePercentage: z
133
+ .number()
134
+ .min(0)
135
+ .max(100)
136
+ .optional()
137
+ .describe('Lowest confidence (0-100) a slot needs to be suggested. Lower it to see slots where not everyone is free.'),
138
+ isOrganizerOptional: z
139
+ .boolean()
140
+ .optional()
141
+ .describe("true when the signed-in user need not attend, so their own calendar is ignored"),
142
+ }),
143
+ }, async (args) => {
144
+ assertWindow(args.start, args.end);
145
+ const zone = args.timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
146
+ const body = {
147
+ Attendees: [
148
+ ...args.attendees.map((Address) => ({ Type: 'Required', EmailAddress: { Address } })),
149
+ ...(args.optionalAttendees ?? []).map((Address) => ({
150
+ Type: 'Optional',
151
+ EmailAddress: { Address },
152
+ })),
153
+ ],
154
+ TimeConstraint: {
155
+ ActivityDomain: args.workingHoursOnly === false ? 'Unrestricted' : 'Work',
156
+ Timeslots: [
157
+ { Start: dateTimeTimeZone(args.start, zone), End: dateTimeTimeZone(args.end, zone) },
158
+ ],
159
+ },
160
+ MeetingDuration: `PT${args.durationMinutes}M`,
161
+ MaxCandidates: args.maxCandidates ?? 10,
162
+ ReturnSuggestionReasons: true,
163
+ ...(args.minimumAttendeePercentage !== undefined
164
+ ? { MinimumAttendeePercentage: args.minimumAttendeePercentage }
165
+ : {}),
166
+ ...(args.isOrganizerOptional !== undefined
167
+ ? { IsOrganizerOptional: args.isOrganizerOptional }
168
+ : {}),
169
+ };
170
+ const data = await client.post('/me/findmeetingtimes', body, { prefer: `outlook.timezone="${zone}"` });
171
+ if (resolveView(args.view, VIEWS) === 'raw')
172
+ return mailboxUntrusted(data);
173
+ const suggestions = (data?.MeetingTimeSuggestions ?? []).map((s) => ({
174
+ Start: trimTime(s.MeetingTimeSlot?.Start?.DateTime),
175
+ End: trimTime(s.MeetingTimeSlot?.End?.DateTime),
176
+ Confidence: s.Confidence,
177
+ Organizer: s.OrganizerAvailability,
178
+ Attendees: Object.fromEntries((s.AttendeeAvailability ?? []).map((a) => [
179
+ a.Attendee?.EmailAddress?.Address ?? a.Attendee?.EmailAddress?.Name ?? '?',
180
+ a.Availability,
181
+ ])),
182
+ Reason: s.SuggestionReason,
183
+ }));
184
+ return mailboxUntrusted({
185
+ timeZone: zone,
186
+ count: suggestions.length,
187
+ suggestions,
188
+ // Outlook explains an empty result ("AttendeesUnavailable", ...); an
189
+ // empty list without it reads as "nobody is ever free".
190
+ ...(data?.EmptySuggestionsReason
191
+ ? { emptySuggestionsReason: data.EmptySuggestionsReason }
192
+ : {}),
193
+ });
194
+ });
195
+ server.registerTool('outlook_get_schedule', {
196
+ description: "Get people's free/busy blocks for a time window — the grid view of Outlook's Scheduling Assistant. Returns each person's busy, tentative and out-of-office items (with subject and location where their calendar shares them). Use outlook_find_meeting_times to have Outlook pick slots instead. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone)." +
197
+ ' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
198
+ annotations: { readOnlyHint: true },
199
+ inputSchema: z.object({
200
+ view: viewParam(VIEWS),
201
+ people: attendeeList('people (or rooms) to look up').min(1).max(100),
202
+ start: z.string().min(1).describe('Window start, e.g. 2026-10-12T09:00:00'),
203
+ end: z.string().min(1).describe('Window end, e.g. 2026-10-12T17:00:00'),
204
+ timeZone: z
205
+ .string()
206
+ .optional()
207
+ .describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
208
+ intervalMinutes: z
209
+ .number()
210
+ .int()
211
+ .min(5)
212
+ .max(1440)
213
+ .optional()
214
+ .describe('Slot size for the raw view\'s AvailabilityView string (default 30)'),
215
+ }),
216
+ }, async ({ view, people, start, end, timeZone, intervalMinutes }) => {
217
+ assertWindow(start, end);
218
+ const zone = timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
219
+ const data = await client.post('/me/calendar/getschedule', {
220
+ Schedules: people,
221
+ StartTime: dateTimeTimeZone(start, zone),
222
+ EndTime: dateTimeTimeZone(end, zone),
223
+ AvailabilityViewInterval: intervalMinutes ?? 30,
224
+ }, { prefer: `outlook.timezone="${zone}"` });
225
+ if (resolveView(view, VIEWS) === 'raw')
226
+ return mailboxUntrusted(data);
227
+ const schedules = (data?.value ?? []).map((p) =>
228
+ // An unresolvable address must not read as "free all day".
229
+ p.Error !== undefined && p.Error !== null
230
+ ? { Who: p.ScheduleId, Error: p.Error }
231
+ : {
232
+ Who: p.ScheduleId,
233
+ Busy: (p.ScheduleItems ?? []).map((i) => ({
234
+ Start: trimTime(i.Start?.DateTime),
235
+ End: trimTime(i.End?.DateTime),
236
+ Status: i.Status,
237
+ ...(i.Subject ? { Subject: i.Subject } : {}),
238
+ ...(i.Location ? { Location: i.Location } : {}),
239
+ })),
240
+ });
241
+ return mailboxUntrusted({ timeZone: zone, schedules });
242
+ });
81
243
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, minifiedResult, } from '@chrischall/mcp-utils';
2
+ import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, McpToolError, minifiedResult, } from '@chrischall/mcp-utils';
3
3
  import { mailboxTimeZone } from '../timezone.js';
4
4
  const recipientList = z
5
5
  .array(z.string().min(3))
@@ -18,6 +18,44 @@ function messageBody(subject, body, html) {
18
18
  Body: { ContentType: html === true ? 'HTML' : 'Text', Content: body },
19
19
  };
20
20
  }
21
+ /**
22
+ * What makes an event a Teams meeting. Field names verified on live events
23
+ * 2026-10-08; the join link then appears at `OnlineMeeting.JoinUrl`.
24
+ */
25
+ const TEAMS = { IsOnlineMeeting: true, OnlineMeetingProvider: 'TeamsForBusiness' };
26
+ const teamsMeetingParam = z
27
+ .boolean()
28
+ .optional()
29
+ .describe('Attach a Microsoft Teams meeting (default true). false leaves it off.');
30
+ const EVENT_STATE_SELECT = 'Id,Subject,IsOrganizer,Start,End,Location,Attendees,IsOnlineMeeting,OnlineMeetingProvider,OnlineMeeting';
31
+ function attendee(Address, Type) {
32
+ return { Type, EmailAddress: { Address } };
33
+ }
34
+ const lower = (a) => a.EmailAddress?.Address?.toLowerCase();
35
+ function joinUrlOf(e) {
36
+ return e?.OnlineMeeting?.JoinUrl || undefined;
37
+ }
38
+ function isTeams(e) {
39
+ return e.IsOnlineMeeting === true && e.OnlineMeetingProvider === 'TeamsForBusiness';
40
+ }
41
+ /**
42
+ * A `DateTimeTimeZone` for a caller-supplied time: wall-clock is read in
43
+ * `zone`; a value with `Z` or an offset already names its instant and goes to
44
+ * UTC rather than having the offset ignored.
45
+ */
46
+ function dateTimeTimeZone(value, zone) {
47
+ if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
48
+ const d = new Date(value);
49
+ if (!Number.isNaN(d.getTime())) {
50
+ return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ''), TimeZone: 'UTC' };
51
+ }
52
+ }
53
+ return { DateTime: value, TimeZone: zone };
54
+ }
55
+ /** Wall-clock arithmetic: both sides are naive times in one zone. */
56
+ const naiveMs = (dt) => Date.parse(`${dt.replace(/\.\d+$/, '')}Z`);
57
+ const fromNaiveMs = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, '');
58
+ const trimTime = (dt) => dt?.replace(/\.\d+$/, '');
21
59
  export function registerWriteTools(server, client) {
22
60
  server.registerTool('outlook_send_mail', {
23
61
  description: 'Send an email from the signed-in mailbox.' +
@@ -172,7 +210,7 @@ export function registerWriteTools(server, client) {
172
210
  });
173
211
  });
174
212
  server.registerTool('outlook_create_event', {
175
- description: 'Create a calendar event. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation.' +
213
+ description: 'Create a calendar event or meeting. A Microsoft Teams meeting is attached by default (`teamsMeeting: false` to skip) and its join link is returned. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation. To pick a time first, use outlook_find_meeting_times.' +
176
214
  ' ' +
177
215
  CONFIRM_FLOW_SENTENCE +
178
216
  ' ' +
@@ -188,10 +226,17 @@ export function registerWriteTools(server, client) {
188
226
  .describe('Windows time-zone name. Defaults to the MAILBOX time zone.'),
189
227
  location: z.string().optional().describe('Location display name'),
190
228
  body: z.string().optional().describe('Event description'),
191
- attendees: recipientList,
229
+ attendees: recipientList.describe('Required attendees\' email addresses'),
230
+ optionalAttendees: recipientList.describe('Optional attendees\' email addresses'),
231
+ teamsMeeting: teamsMeetingParam,
192
232
  confirmToken: confirmTokenParam,
193
233
  }),
194
- }, async ({ subject, start, end, timeZone, location, body, attendees, confirmToken }, ctx) => {
234
+ }, async ({ subject, start, end, timeZone, location, body, attendees, optionalAttendees, teamsMeeting, confirmToken }, ctx) => {
235
+ const wantTeams = teamsMeeting !== false;
236
+ const allAttendees = [
237
+ ...(attendees ?? []).map((a) => attendee(a, 'Required')),
238
+ ...(optionalAttendees ?? []).map((a) => attendee(a, 'Optional')),
239
+ ];
195
240
  // Falls back to UTC only when the mailbox itself declares no zone.
196
241
  const tz = timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
197
242
  const payload = {
@@ -200,20 +245,14 @@ export function registerWriteTools(server, client) {
200
245
  End: { DateTime: end, TimeZone: tz },
201
246
  ...(location ? { Location: { DisplayName: location } } : {}),
202
247
  ...(body ? { Body: { ContentType: 'Text', Content: body } } : {}),
203
- ...(attendees?.length
204
- ? {
205
- Attendees: attendees.map((Address) => ({
206
- EmailAddress: { Address },
207
- Type: 'Required',
208
- })),
209
- }
210
- : {}),
248
+ ...(allAttendees.length ? { Attendees: allAttendees } : {}),
249
+ ...(wantTeams ? TEAMS : {}),
211
250
  };
212
251
  const gate = await confirmWrite(ctx, {
213
252
  tool: 'outlook_create_event',
214
253
  action: 'calendar.create_event',
215
254
  message: 'Review and confirm this event (attendees are emailed an invitation):',
216
- summary: `Create event "${subject}" ${start} to ${end} (${tz})`,
255
+ summary: `Create ${wantTeams ? 'Teams meeting' : 'event'} "${subject}" ${start} to ${end} (${tz})`,
217
256
  // One signed-in mailbox per server process.
218
257
  account: undefined,
219
258
  request: { method: 'POST', path: '/me/events', body: payload },
@@ -222,6 +261,175 @@ export function registerWriteTools(server, client) {
222
261
  if (gate)
223
262
  return gate;
224
263
  const created = await client.write('POST', '/me/events', payload);
225
- return minifiedResult({ created: true, Id: created?.Id, WebLink: created?.WebLink });
264
+ let joinUrl = joinUrlOf(created);
265
+ // A 201 is not proof the Teams meeting was provisioned — the link is.
266
+ // Re-read once in case it was filled in after the create returned.
267
+ if (wantTeams && !joinUrl && created?.Id) {
268
+ const after = await client.get(`/me/events/${encodeURIComponent(created.Id)}?$select=OnlineMeeting`);
269
+ joinUrl = joinUrlOf(after);
270
+ }
271
+ return minifiedResult({
272
+ created: true,
273
+ Id: created?.Id,
274
+ WebLink: created?.WebLink,
275
+ ...(joinUrl ? { JoinUrl: joinUrl } : {}),
276
+ ...(wantTeams && !joinUrl
277
+ ? { warning: 'The event was created, but Outlook returned no Teams join link.' }
278
+ : {}),
279
+ });
280
+ });
281
+ server.registerTool('outlook_update_event', {
282
+ description: 'Update a meeting you organize: retitle, move, relocate, edit the description, or add/remove attendees. Moving only `start` keeps the meeting\'s length. A Microsoft Teams meeting is added if the event lacks one (`teamsMeeting: false` to skip). Attendees are sent an updated invitation. Only the organizer can update a meeting — an attendee\'s edit would change their own copy alone, so it is refused. The result is verified by re-reading the event.' +
283
+ ' ' +
284
+ CONFIRM_FLOW_SENTENCE +
285
+ ' ' +
286
+ CONFIRM_INJECTION_RULE,
287
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
288
+ inputSchema: z.object({
289
+ id: z.string().min(1).describe('Event Id from outlook_list_events or outlook_get_event'),
290
+ subject: z.string().optional().describe('New title'),
291
+ start: z.string().optional().describe('New start, ISO 8601 local time e.g. 2026-09-22T15:00:00'),
292
+ end: z
293
+ .string()
294
+ .optional()
295
+ .describe('New end. Omit when moving `start` to keep the current length.'),
296
+ timeZone: z
297
+ .string()
298
+ .optional()
299
+ .describe('Windows time-zone name for start/end. Defaults to the MAILBOX time zone.'),
300
+ location: z.string().optional().describe('New location display name'),
301
+ body: z.string().optional().describe('New description (replaces the existing one)'),
302
+ addAttendees: recipientList.describe('Required attendees to add'),
303
+ addOptionalAttendees: recipientList.describe('Optional attendees to add'),
304
+ removeAttendees: recipientList.describe('Attendees to remove'),
305
+ teamsMeeting: teamsMeetingParam,
306
+ confirmToken: confirmTokenParam,
307
+ }),
308
+ }, async (args, ctx) => {
309
+ const tz = args.timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
310
+ const path = `/me/events/${encodeURIComponent(args.id)}`;
311
+ const readEvent = (zone) => client.get(`${path}?$select=${EVENT_STATE_SELECT}`, {
312
+ prefer: `outlook.timezone="${zone}"`,
313
+ });
314
+ const current = await readEvent(tz);
315
+ if (current?.IsOrganizer === false) {
316
+ throw new McpToolError('Only the organizer can update this meeting.', {
317
+ hint: "You are an attendee. Changing your copy would not reach the organizer or anyone else; ask the organizer, or propose a new time from Outlook.",
318
+ });
319
+ }
320
+ const payload = {};
321
+ if (args.subject !== undefined)
322
+ payload.Subject = args.subject;
323
+ if (args.location !== undefined)
324
+ payload.Location = { DisplayName: args.location };
325
+ if (args.body !== undefined)
326
+ payload.Body = { ContentType: 'Text', Content: args.body };
327
+ if (args.start !== undefined || args.end !== undefined) {
328
+ const start = args.start !== undefined ? dateTimeTimeZone(args.start, tz) : undefined;
329
+ let end = args.end !== undefined ? dateTimeTimeZone(args.end, tz) : undefined;
330
+ // Moving the start alone keeps the length. The current times were read
331
+ // in `tz`, so convert the duration onto whatever zone the new start uses.
332
+ if (start && !end && current?.Start?.DateTime && current?.End?.DateTime) {
333
+ const length = naiveMs(current.End.DateTime) - naiveMs(current.Start.DateTime);
334
+ end = { DateTime: fromNaiveMs(naiveMs(start.DateTime) + length), TimeZone: start.TimeZone };
335
+ }
336
+ // A wall-clock time and one with an offset cannot be ordered without
337
+ // the Windows zone's rules, which we do not have. Refuse rather than
338
+ // skip the order check and write a meeting that ends before it starts.
339
+ if (args.start !== undefined && args.end !== undefined && start?.TimeZone !== end?.TimeZone) {
340
+ throw new McpToolError('`start` and `end` are in different zones.', {
341
+ hint: 'Give both the same way: both local wall-clock times, or both with Z or an offset.',
342
+ });
343
+ }
344
+ // Order check against the start the meeting will have. When only `end`
345
+ // moves that is the current start, read in the end's zone.
346
+ const startIn = start ??
347
+ (end && end.TimeZone !== tz
348
+ ? (await readEvent(end.TimeZone))?.Start
349
+ : current?.Start);
350
+ if (end &&
351
+ startIn?.DateTime &&
352
+ naiveMs(end.DateTime) <= naiveMs(startIn.DateTime)) {
353
+ throw new McpToolError('The meeting would end before it starts.', {
354
+ hint: '`end` must be later than `start`.',
355
+ });
356
+ }
357
+ if (start)
358
+ payload.Start = start;
359
+ if (end)
360
+ payload.End = end;
361
+ }
362
+ const adding = [
363
+ ...(args.addAttendees ?? []).map((a) => attendee(a, 'Required')),
364
+ ...(args.addOptionalAttendees ?? []).map((a) => attendee(a, 'Optional')),
365
+ ];
366
+ const removing = new Set((args.removeAttendees ?? []).map((a) => a.toLowerCase()));
367
+ if (adding.length || removing.size) {
368
+ // PATCH replaces the whole list, so build it from the current one.
369
+ const next = (current?.Attendees ?? [])
370
+ .filter((a) => !removing.has(lower(a) ?? ''))
371
+ .map(({ Type, EmailAddress }) => ({ Type, EmailAddress }));
372
+ for (const a of adding) {
373
+ if (!next.some((n) => lower(n) === lower(a)))
374
+ next.push(a);
375
+ }
376
+ payload.Attendees = next;
377
+ }
378
+ const addTeams = args.teamsMeeting !== false && !(current && isTeams(current));
379
+ if (addTeams)
380
+ Object.assign(payload, TEAMS);
381
+ if (Object.keys(payload).length === 0) {
382
+ throw new McpToolError('Nothing to update.', {
383
+ hint: 'Pass at least one of subject, start, end, location, body, or attendee changes.',
384
+ });
385
+ }
386
+ const gate = await confirmWrite(ctx, {
387
+ tool: 'outlook_update_event',
388
+ action: 'calendar.update_event',
389
+ message: 'Review and confirm this change (attendees are sent an updated invitation):',
390
+ summary: `Update "${current?.Subject ?? args.id}": ${Object.keys(payload).join(', ')}`,
391
+ // One signed-in mailbox per server process.
392
+ account: undefined,
393
+ target: args.id,
394
+ request: { method: 'PATCH', path, body: payload },
395
+ confirmToken: args.confirmToken,
396
+ });
397
+ if (gate)
398
+ return gate;
399
+ await client.write('PATCH', path, payload);
400
+ // Re-read rather than trust the status, in the zone the write used so
401
+ // the times compare like for like.
402
+ // Start and End share a zone (enforced above), but either may be alone.
403
+ const writtenZone = (payload.Start ?? payload.End)?.TimeZone ?? tz;
404
+ const after = await readEvent(writtenZone);
405
+ const unchanged = [];
406
+ if (payload.Subject !== undefined && after?.Subject !== payload.Subject)
407
+ unchanged.push('Subject');
408
+ for (const key of ['Start', 'End']) {
409
+ const want = payload[key];
410
+ if (want && trimTime(after?.[key]?.DateTime) !== trimTime(want.DateTime))
411
+ unchanged.push(key);
412
+ }
413
+ if (payload.Location !== undefined && after?.Location?.DisplayName !== args.location) {
414
+ unchanged.push('Location');
415
+ }
416
+ if (payload.Attendees !== undefined) {
417
+ const want = payload.Attendees.map(lower).sort().join();
418
+ const got = (after?.Attendees ?? []).map(lower).sort().join();
419
+ if (want !== got)
420
+ unchanged.push('Attendees');
421
+ }
422
+ const joinUrl = joinUrlOf(after);
423
+ if (addTeams && !joinUrl)
424
+ unchanged.push('Teams meeting');
425
+ return minifiedResult({
426
+ updated: unchanged.length === 0,
427
+ Id: after?.Id ?? args.id,
428
+ changed: Object.keys(payload),
429
+ ...(joinUrl ? { JoinUrl: joinUrl } : {}),
430
+ ...(unchanged.length
431
+ ? { warning: `Outlook accepted the update but these did not change: ${unchanged.join(', ')}.` }
432
+ : {}),
433
+ });
226
434
  });
227
435
  }
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.3.5'; // x-release-please-version
13
+ export const VERSION = '0.4.0'; // x-release-please-version
package/dist/view.js CHANGED
@@ -70,7 +70,11 @@ export function fullEvent(e) {
70
70
  ...compactEvent(e),
71
71
  ShowAs: e.ShowAs,
72
72
  Attendees: e.Attendees?.map((a) => pruned({ Who: addr(a), Response: a.Status?.Response })),
73
- OnlineMeetingUrl: e.OnlineMeetingUrl,
73
+ // The Teams link lives under OnlineMeeting; OnlineMeetingUrl is empty on
74
+ // every live Teams meeting checked.
75
+ JoinUrl: e.OnlineMeeting?.JoinUrl || e.OnlineMeetingUrl || undefined,
76
+ // Kept beside JoinUrl so a consumer of the old field still finds it.
77
+ OnlineMeetingUrl: e.OnlineMeetingUrl || undefined,
74
78
  Preview: e.BodyPreview?.trim() || undefined,
75
79
  WebLink: e.WebLink,
76
80
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.3.5",
3
+ "version": "0.4.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.3.5",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.3.5",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },