@chrischall/office-outlook-mcp 0.3.4 → 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.4"
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.4",
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.4",
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
 
@@ -148,7 +156,8 @@ a fresh preview.
148
156
 
149
157
  | variable | default | |
150
158
  |---|---|---|
151
- | `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt. An unrecognised value is treated as `refuse`. |
159
+ | `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt, unless `MCP_CONFIRM_ELICITATION=off`. An unrecognised value is treated as `refuse`. |
160
+ | `MCP_CONFIRM_ELICITATION` | `on` | `off` never shows a confirmation prompt, so every client gets the `MCP_CONFIRM_MODE` behaviour. Set it for a client that says it can show prompts but never does (the write hangs — opencode 2.0.x). Any other value is treated as `on`, with a warning on stderr. |
152
161
  | `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
153
162
  | `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. On mcp-host the host supplies a stable per-child key (`MCP_HOST_CONFIRM_SECRET`) and spent tokens are recorded under `MCP_DATA_DIR`, so an approval survives an idle restart. |
154
163
 
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,
@@ -35663,6 +35663,15 @@ ${JSON.stringify(preview, null, 2)}`,
35663
35663
  };
35664
35664
  const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
35665
35665
  const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
35666
+ if (options.elicitation === false) {
35667
+ return textResult({
35668
+ confirmed: false,
35669
+ dispatched: false,
35670
+ action: options.action,
35671
+ reason: "confirmation-unsupported",
35672
+ note: options.unsupportedNote ? `${DISABLED_NOTE}. ${options.unsupportedNote}` : `${DISABLED_NOTE}.`
35673
+ });
35674
+ }
35666
35675
  if (response.kind === "missing") {
35667
35676
  if (callerAcceptsFormElicitation(ctx) === false) {
35668
35677
  return textResult({
@@ -35693,7 +35702,7 @@ ${JSON.stringify(preview, null, 2)}`,
35693
35702
  note: "Nothing was changed because the confirmation was declined, cancelled, or left unchecked."
35694
35703
  });
35695
35704
  }
35696
- var DEFAULT_REQUEST_KEY, DEFAULT_CONFIRMATION_LABEL, UNSUPPORTED_NOTE, STATE_PREFIX;
35705
+ var DEFAULT_REQUEST_KEY, DEFAULT_CONFIRMATION_LABEL, UNSUPPORTED_NOTE, DISABLED_NOTE, STATE_PREFIX;
35697
35706
  var init_confirmation = __esm({
35698
35707
  "node_modules/@chrischall/mcp-utils/dist/server/confirmation.js"() {
35699
35708
  init_dist();
@@ -35704,6 +35713,7 @@ var init_confirmation = __esm({
35704
35713
  DEFAULT_REQUEST_KEY = "confirmation";
35705
35714
  DEFAULT_CONFIRMATION_LABEL = "Confirm this action should proceed.";
35706
35715
  UNSUPPORTED_NOTE = "Nothing was done because this client cannot show a confirmation prompt (it declares no MCP elicitation capability), and this action is never taken without one";
35716
+ DISABLED_NOTE = "Nothing was done because confirmation prompts are turned off for this server, and this action is never taken without one";
35707
35717
  STATE_PREFIX = "mcpu.confirm.v1.";
35708
35718
  }
35709
35719
  });
@@ -35857,7 +35867,7 @@ async function tokenConfirmation(action, fb) {
35857
35867
  }
35858
35868
  async function requireConfirmationWithFallback(ctx, options) {
35859
35869
  const { tokenFallback, ...confirmation } = options;
35860
- if (tokenFallback && callerAcceptsFormElicitation(ctx) === false) {
35870
+ if (tokenFallback && (options.elicitation === false || callerAcceptsFormElicitation(ctx) === false)) {
35861
35871
  return tokenConfirmation(options.action, tokenFallback);
35862
35872
  }
35863
35873
  return requireConfirmation(ctx, confirmation);
@@ -36068,6 +36078,20 @@ function readConfirmMode(env = process.env) {
36068
36078
  }
36069
36079
  return "refuse";
36070
36080
  }
36081
+ function readConfirmElicitation(env = process.env) {
36082
+ const raw = readEnvVar("MCP_CONFIRM_ELICITATION", { env })?.trim().toLowerCase();
36083
+ if (!raw || raw === "on")
36084
+ return "on";
36085
+ if (raw === "off")
36086
+ return "off";
36087
+ const key = `elicitation:${raw}`;
36088
+ if (!warned.has(key)) {
36089
+ warned.add(key);
36090
+ process.stderr.write(`MCP_CONFIRM_ELICITATION="${raw}" is not one of on, off; treating it as on.
36091
+ `);
36092
+ }
36093
+ return "on";
36094
+ }
36071
36095
  function readConfirmTtl(env) {
36072
36096
  const raw = readEnvVar("MCP_CONFIRM_TTL_SECONDS", { env })?.trim();
36073
36097
  if (!raw)
@@ -36120,10 +36144,13 @@ function confirmationFromEnv(options) {
36120
36144
  const spent = callerSpent ?? spentTokenStoreFromEnv(env);
36121
36145
  const mode = readConfirmMode(env);
36122
36146
  const ttlSeconds = readConfirmTtl(env);
36147
+ const elicitationOff = readConfirmElicitation(env) === "off";
36123
36148
  const bound = args === void 0 ? void 0 : boundArgs(args);
36124
- const confirmation = bound === void 0 || rest.binding ? rest : { ...rest, binding: { key: confirmKeyFromEnv(env), args: bound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 } };
36149
+ const bindingAdded = bound === void 0 || rest.binding ? rest : { ...rest, binding: { key: confirmKeyFromEnv(env), args: bound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 } };
36150
+ const confirmation = elicitationOff ? { ...bindingAdded, elicitation: false } : bindingAdded;
36125
36151
  if (mode === "refuse" || ttlSeconds === void 0) {
36126
- const hint = mode === "refuse" ? REFUSE_HINT : BAD_TTL_HINT;
36152
+ const reason = mode === "refuse" ? REFUSE_HINT : BAD_TTL_HINT;
36153
+ const hint = elicitationOff ? `${ELICITATION_OFF_HINT} ${reason}` : reason;
36127
36154
  return {
36128
36155
  ...confirmation,
36129
36156
  unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${hint}` : hint
@@ -36149,7 +36176,7 @@ function confirmationFromEnv(options) {
36149
36176
  }
36150
36177
  };
36151
36178
  }
36152
- var MODES, DEFAULT_TTL_SECONDS2, warned, processKey, fileStores, CONFIRM_TOKEN_AUTO_INSTRUCTION, REFUSE_HINT, BAD_TTL_HINT;
36179
+ var MODES, DEFAULT_TTL_SECONDS2, warned, processKey, fileStores, CONFIRM_TOKEN_AUTO_INSTRUCTION, REFUSE_HINT, ELICITATION_OFF_HINT, BAD_TTL_HINT;
36153
36180
  var init_confirm_env = __esm({
36154
36181
  "node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js"() {
36155
36182
  init_config();
@@ -36161,6 +36188,7 @@ var init_confirm_env = __esm({
36161
36188
  fileStores = /* @__PURE__ */ new Map();
36162
36189
  CONFIRM_TOKEN_AUTO_INSTRUCTION = "Nothing has been done yet. Review this preview; if it is what was intended, call again with the same arguments plus confirmToken. (This server runs with MCP_CONFIRM_MODE=auto, so the user's approval in chat is not required.)";
36163
36190
  REFUSE_HINT = "Set MCP_CONFIRM_MODE=ask-user on the server to allow two-step confirmation instead.";
36191
+ ELICITATION_OFF_HINT = "MCP_CONFIRM_ELICITATION=off on the server turns confirmation prompts off.";
36164
36192
  BAD_TTL_HINT = "MCP_CONFIRM_TTL_SECONDS on the server is not a positive whole number of seconds; fix it to allow two-step confirmation.";
36165
36193
  }
36166
36194
  });
@@ -37120,7 +37148,7 @@ var init_version = __esm({
37120
37148
  "src/version.ts"() {
37121
37149
  "use strict";
37122
37150
  PACKAGE_NAME = "office-outlook-mcp";
37123
- VERSION = "0.3.4";
37151
+ VERSION = "0.4.0";
37124
37152
  }
37125
37153
  });
37126
37154
 
@@ -37372,7 +37400,7 @@ async function transcriptHash(mcpNonce, extNonce, mcpSessionPub, extSessionPub)
37372
37400
  function frameAad(mcpId, seq, direction) {
37373
37401
  return enc.encode(`fetchproxy/4/frame\0${mcpId}\0${seq}\0${direction}`);
37374
37402
  }
37375
- var PROTOCOL_VERSION, HKDF_SESSION_INFO, enc, ANSWERS_NO_EXT_SESSION, KNOWN_CAPABILITIES, ACCOUNT_KEY_FRAME, ACCOUNT_ATTEST_FRAME;
37403
+ var PROTOCOL_VERSION, HKDF_SESSION_INFO, enc, ANSWERS_NO_EXT_SESSION, KNOWN_CAPABILITIES, ACCOUNT_KEY_FRAME, ACCOUNT_ATTEST_FRAME, BRIDGE_ROLE_FRAME, BRIDGE_SERVE_FRAME, ROOM_PING_FRAME, ROOM_PONG_FRAME, BRIDGE_ROLE_LABEL_MAX, ROOM_FRAME_ACCEPTS;
37376
37404
  var init_frames = __esm({
37377
37405
  "node_modules/@fetchproxy/protocol/dist/frames.js"() {
37378
37406
  init_crypto();
@@ -37398,6 +37426,17 @@ var init_frames = __esm({
37398
37426
  ]);
37399
37427
  ACCOUNT_KEY_FRAME = "account-key";
37400
37428
  ACCOUNT_ATTEST_FRAME = "account-attest";
37429
+ BRIDGE_ROLE_FRAME = "bridge-role";
37430
+ BRIDGE_SERVE_FRAME = "bridge-serve";
37431
+ ROOM_PING_FRAME = "room-ping";
37432
+ ROOM_PONG_FRAME = "room-pong";
37433
+ BRIDGE_ROLE_LABEL_MAX = 64;
37434
+ ROOM_FRAME_ACCEPTS = Object.freeze({
37435
+ [BRIDGE_ROLE_FRAME]: BRIDGE_ROLE_FRAME,
37436
+ [BRIDGE_SERVE_FRAME]: BRIDGE_SERVE_FRAME,
37437
+ [ROOM_PING_FRAME]: ROOM_PING_FRAME,
37438
+ [ROOM_PONG_FRAME]: ROOM_PING_FRAME
37439
+ });
37401
37440
  }
37402
37441
  });
37403
37442
 
@@ -38081,6 +38120,12 @@ function validateFrame(raw) {
38081
38120
  return validateAccountKey(raw);
38082
38121
  if (t === ACCOUNT_ATTEST_FRAME)
38083
38122
  return validateAccountAttest(raw);
38123
+ if (t === BRIDGE_ROLE_FRAME)
38124
+ return validateBridgeRole(raw);
38125
+ if (t === BRIDGE_SERVE_FRAME || t === ROOM_PING_FRAME || t === ROOM_PONG_FRAME) {
38126
+ assertExactFields(raw, TYPE_ONLY_FIELDS, t);
38127
+ return { type: t };
38128
+ }
38084
38129
  throw new ProtocolError3(`unknown frame type: ${String(t)}`);
38085
38130
  }
38086
38131
  function validateHello(raw) {
@@ -38376,6 +38421,40 @@ function validateAccountAttest(raw) {
38376
38421
  sig: raw.sig
38377
38422
  };
38378
38423
  }
38424
+ function validateBridgeRole(raw) {
38425
+ const L = BRIDGE_ROLE_FRAME;
38426
+ const role = raw.role;
38427
+ if (role !== "serving" && role !== "standby") {
38428
+ throw new ProtocolError3(`${L}.role: must be one of serving, standby`);
38429
+ }
38430
+ const canServe = raw.canServe;
38431
+ assertBoolean(canServe, `${L}.canServe`);
38432
+ assertExactFields(raw, BRIDGE_ROLE_FIELDS, L);
38433
+ const serving = raw.serving;
38434
+ if (role === "serving") {
38435
+ if (serving !== void 0) {
38436
+ throw new ProtocolError3(`${L}.serving: must be absent when role is serving`);
38437
+ }
38438
+ return { type: BRIDGE_ROLE_FRAME, role, canServe };
38439
+ }
38440
+ if (serving === void 0) {
38441
+ throw new ProtocolError3(`${L}.serving: required when role is standby`);
38442
+ }
38443
+ assertObject(serving, `${L}.serving`);
38444
+ const label = serving.label;
38445
+ assertDisplayString(label, `${L}.serving.label`, 1, BRIDGE_ROLE_LABEL_MAX);
38446
+ const since = serving.since;
38447
+ if (typeof since !== "number" || !Number.isSafeInteger(since) || since < 0) {
38448
+ throw new ProtocolError3(`${L}.serving.since: expected non-negative safe integer (Unix ms)`);
38449
+ }
38450
+ assertExactFields(serving, BRIDGE_ROLE_SERVING_FIELDS, `${L}.serving`);
38451
+ return {
38452
+ type: BRIDGE_ROLE_FRAME,
38453
+ role,
38454
+ canServe,
38455
+ serving: { label, since }
38456
+ };
38457
+ }
38379
38458
  function validateInnerFrame(raw) {
38380
38459
  assertObject(raw, "inner");
38381
38460
  const t = raw.type;
@@ -38837,7 +38916,7 @@ function validateInnerResponse(raw) {
38837
38916
  }
38838
38917
  throw new ProtocolError3(`inner.ok: must be boolean, got ${typeof raw.ok}`);
38839
38918
  }
38840
- var ProtocolError3, FORBIDDEN_KEYS, BASE64_RE, SCOPE_KEY_RE, SCOPE_KEY_GLOB_RE, HEADER_NAME_RE, HOSTNAME_RE, CAPTURE_PATH_RE, DOM_SELECTOR_RE, DOM_ATTRIBUTE_RE, GRAPHQL_OP_NAME_RE, PAIR_CODE_RE, DISPLAY_FORBIDDEN_RE, ACCOUNT_KEY_FIELDS, ACCOUNT_ATTEST_FIELDS, KNOWN_RESPONSE_OPS;
38919
+ var ProtocolError3, FORBIDDEN_KEYS, BASE64_RE, SCOPE_KEY_RE, SCOPE_KEY_GLOB_RE, HEADER_NAME_RE, HOSTNAME_RE, CAPTURE_PATH_RE, DOM_SELECTOR_RE, DOM_ATTRIBUTE_RE, GRAPHQL_OP_NAME_RE, PAIR_CODE_RE, DISPLAY_FORBIDDEN_RE, ACCOUNT_KEY_FIELDS, ACCOUNT_ATTEST_FIELDS, TYPE_ONLY_FIELDS, BRIDGE_ROLE_FIELDS, BRIDGE_ROLE_SERVING_FIELDS, KNOWN_RESPONSE_OPS;
38841
38920
  var init_validate = __esm({
38842
38921
  "node_modules/@fetchproxy/protocol/dist/validate.js"() {
38843
38922
  init_frames();
@@ -38892,6 +38971,9 @@ var init_validate = __esm({
38892
38971
  "notAfter",
38893
38972
  "sig"
38894
38973
  ];
38974
+ TYPE_ONLY_FIELDS = ["type"];
38975
+ BRIDGE_ROLE_FIELDS = ["type", "role", "canServe", "serving"];
38976
+ BRIDGE_ROLE_SERVING_FIELDS = ["label", "since"];
38895
38977
  KNOWN_RESPONSE_OPS = /* @__PURE__ */ new Set([
38896
38978
  ...KNOWN_CAPABILITIES,
38897
38979
  "graphql_query"
@@ -38899,6 +38981,14 @@ var init_validate = __esm({
38899
38981
  }
38900
38982
  });
38901
38983
 
38984
+ // node_modules/@fetchproxy/protocol/dist/room-frames.js
38985
+ var init_room_frames = __esm({
38986
+ "node_modules/@fetchproxy/protocol/dist/room-frames.js"() {
38987
+ init_frames();
38988
+ init_validate();
38989
+ }
38990
+ });
38991
+
38902
38992
  // node_modules/@fetchproxy/protocol/dist/pair-code.js
38903
38993
  async function pairTranscript(mcpIdentityPub, extIdentityPub, mcpNonce, extNonce, mcpSessionPub) {
38904
38994
  const label = enc3.encode(PAIR_LABEL);
@@ -39007,6 +39097,7 @@ var init_dist3 = __esm({
39007
39097
  init_awaiting_approval();
39008
39098
  init_capability_availability();
39009
39099
  init_validate();
39100
+ init_room_frames();
39010
39101
  init_crypto();
39011
39102
  init_mcp_id();
39012
39103
  init_pair_code();
@@ -41405,11 +41496,23 @@ var require_websocket = __commonJS({
41405
41496
  this._isServer = false;
41406
41497
  this._redirects = 0;
41407
41498
  if (protocols === void 0) {
41408
- protocols = [];
41499
+ if (!options || options.protocols === void 0) {
41500
+ protocols = [];
41501
+ } else if (Array.isArray(options.protocols)) {
41502
+ protocols = options.protocols;
41503
+ } else {
41504
+ protocols = [options.protocols];
41505
+ }
41409
41506
  } else if (!Array.isArray(protocols)) {
41410
41507
  if (typeof protocols === "object" && protocols !== null) {
41411
41508
  options = protocols;
41412
- protocols = [];
41509
+ if (options.protocols === void 0) {
41510
+ protocols = [];
41511
+ } else if (Array.isArray(options.protocols)) {
41512
+ protocols = options.protocols;
41513
+ } else {
41514
+ protocols = [options.protocols];
41515
+ }
41413
41516
  } else {
41414
41517
  protocols = [protocols];
41415
41518
  }
@@ -41606,7 +41709,6 @@ var require_websocket = __commonJS({
41606
41709
  }
41607
41710
  return;
41608
41711
  }
41609
- this._readyState = _WebSocket.CLOSING;
41610
41712
  this._sender.close(code2, data, !this._isServer, (err) => {
41611
41713
  if (err) return;
41612
41714
  this._closeFrameSent = true;
@@ -41614,6 +41716,7 @@ var require_websocket = __commonJS({
41614
41716
  this._socket.end();
41615
41717
  }
41616
41718
  });
41719
+ this._readyState = _WebSocket.CLOSING;
41617
41720
  setCloseTimer(this);
41618
41721
  }
41619
41722
  /**
@@ -41838,6 +41941,7 @@ var require_websocket = __commonJS({
41838
41941
  socketPath: void 0,
41839
41942
  hostname: void 0,
41840
41943
  protocol: void 0,
41944
+ protocols: void 0,
41841
41945
  timeout: void 0,
41842
41946
  method: "GET",
41843
41947
  host: void 0,
@@ -48283,6 +48387,19 @@ var OutlookClient = class {
48283
48387
  }
48284
48388
  return this.get(target.pathname.slice(prefix.length) + target.search, opts);
48285
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
+ }
48286
48403
  /**
48287
48404
  * The single write path. Everything mutating goes through here so auth and
48288
48405
  * error shaping stay in one place.
@@ -48383,7 +48500,11 @@ function fullEvent(e) {
48383
48500
  Attendees: e.Attendees?.map(
48384
48501
  (a) => pruned({ Who: addr(a), Response: a.Status?.Response })
48385
48502
  ),
48386
- 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,
48387
48508
  Preview: e.BodyPreview?.trim() || void 0,
48388
48509
  WebLink: e.WebLink
48389
48510
  });
@@ -48599,7 +48720,7 @@ async function mailboxTimeZone(client2) {
48599
48720
  }
48600
48721
 
48601
48722
  // src/tools/calendar.ts
48602
- 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";
48603
48724
  function qs2(params) {
48604
48725
  const parts = [];
48605
48726
  for (const [k, v] of Object.entries(params)) {
@@ -48608,6 +48729,28 @@ function qs2(params) {
48608
48729
  }
48609
48730
  return parts.length ? `?${parts.join("&")}` : "";
48610
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}`);
48611
48754
  function registerCalendarTools(server, client2) {
48612
48755
  server.registerTool(
48613
48756
  "outlook_list_events",
@@ -48684,6 +48827,119 @@ function registerCalendarTools(server, client2) {
48684
48827
  return minifiedResult(plainCollection(data));
48685
48828
  }
48686
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
+ );
48687
48943
  }
48688
48944
 
48689
48945
  // src/tools/directory.ts
@@ -48808,6 +49064,31 @@ function messageBody(subject, body, html) {
48808
49064
  Body: { ContentType: html === true ? "HTML" : "Text", Content: body }
48809
49065
  };
48810
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+$/, "");
48811
49092
  function registerWriteTools(server, client2) {
48812
49093
  server.registerTool(
48813
49094
  "outlook_send_mail",
@@ -48967,7 +49248,7 @@ function registerWriteTools(server, client2) {
48967
49248
  server.registerTool(
48968
49249
  "outlook_create_event",
48969
49250
  {
48970
- 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,
48971
49252
  annotations: { readOnlyHint: false, destructiveHint: false },
48972
49253
  inputSchema: external_exports.object({
48973
49254
  subject: external_exports.string().describe("Event title"),
@@ -48976,11 +49257,18 @@ function registerWriteTools(server, client2) {
48976
49257
  timeZone: external_exports.string().optional().describe("Windows time-zone name. Defaults to the MAILBOX time zone."),
48977
49258
  location: external_exports.string().optional().describe("Location display name"),
48978
49259
  body: external_exports.string().optional().describe("Event description"),
48979
- attendees: recipientList,
49260
+ attendees: recipientList.describe("Required attendees' email addresses"),
49261
+ optionalAttendees: recipientList.describe("Optional attendees' email addresses"),
49262
+ teamsMeeting: teamsMeetingParam,
48980
49263
  confirmToken: confirmTokenParam
48981
49264
  })
48982
49265
  },
48983
- 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
+ ];
48984
49272
  const tz = timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
48985
49273
  const payload = {
48986
49274
  Subject: subject,
@@ -48988,18 +49276,14 @@ function registerWriteTools(server, client2) {
48988
49276
  End: { DateTime: end, TimeZone: tz },
48989
49277
  ...location ? { Location: { DisplayName: location } } : {},
48990
49278
  ...body ? { Body: { ContentType: "Text", Content: body } } : {},
48991
- ...attendees?.length ? {
48992
- Attendees: attendees.map((Address) => ({
48993
- EmailAddress: { Address },
48994
- Type: "Required"
48995
- }))
48996
- } : {}
49279
+ ...allAttendees.length ? { Attendees: allAttendees } : {},
49280
+ ...wantTeams ? TEAMS : {}
48997
49281
  };
48998
49282
  const gate = await confirmWrite(ctx, {
48999
49283
  tool: "outlook_create_event",
49000
49284
  action: "calendar.create_event",
49001
49285
  message: "Review and confirm this event (attendees are emailed an invitation):",
49002
- summary: `Create event "${subject}" ${start} to ${end} (${tz})`,
49286
+ summary: `Create ${wantTeams ? "Teams meeting" : "event"} "${subject}" ${start} to ${end} (${tz})`,
49003
49287
  // One signed-in mailbox per server process.
49004
49288
  account: void 0,
49005
49289
  request: { method: "POST", path: "/me/events", body: payload },
@@ -49011,7 +49295,136 @@ function registerWriteTools(server, client2) {
49011
49295
  "/me/events",
49012
49296
  payload
49013
49297
  );
49014
- 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
+ });
49015
49428
  }
49016
49429
  );
49017
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.4'; // 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.4",
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>",
@@ -45,8 +45,8 @@
45
45
  "typecheck": "tsc -p tsconfig.check.json"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^2.14.0",
49
- "@fetchproxy/server": "^3.4.1",
48
+ "@chrischall/mcp-utils": "^2.15.0",
49
+ "@fetchproxy/server": "^3.6.0",
50
50
  "@modelcontextprotocol/server": "^2.3.0",
51
51
  "dotenv": "^18.0.1",
52
52
  "zod": "^4.6.5"
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.4",
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.4",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },