@chrischall/office-outlook-mcp 0.1.1 → 0.1.3

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.1.1"
10
+ "version": "0.1.3"
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.1.1",
18
+ "version": "0.1.3",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "outlook": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/bundle.js"],
6
+ "env": {
7
+ "OUTLOOK_ACCESS_TOKEN": "${OUTLOOK_ACCESS_TOKEN}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "office-outlook-mcp",
3
3
  "displayName": "Outlook (Microsoft 365)",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
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",
@@ -19,5 +19,5 @@
19
19
  "mcp"
20
20
  ],
21
21
  "skills": "./skills/",
22
- "mcp": "./.mcp.json"
22
+ "mcp": "./mcp.json"
23
23
  }
package/.mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "outlook": {
4
4
  "command": "node",
5
- "args": ["${CLAUDE_PLUGIN_ROOT}/dist/bundle.js"],
5
+ "args": ["dist/bundle.js"],
6
6
  "env": {
7
7
  "OUTLOOK_ACCESS_TOKEN": "${OUTLOOK_ACCESS_TOKEN}"
8
8
  }
package/README.md CHANGED
@@ -37,6 +37,42 @@ npm i -g @fetchproxy/cli
37
37
  The first capture prints a 6-digit pair code to approve in the Transporter
38
38
  popup; the grant persists.
39
39
 
40
+ ### Install in opencode
41
+
42
+ opencode reads MCP servers from `opencode.json` (project) or
43
+ `~/.config/opencode/opencode.json` (global):
44
+
45
+ ```json
46
+ {
47
+ "mcp": {
48
+ "servers": {
49
+ "outlook": {
50
+ "type": "local",
51
+ "command": ["npx", "-y", "@chrischall/office-outlook-mcp"]
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ That one file serves **opencode 2 and current opencode 1** — verified live on
59
+ 2.0.11, 1.18.31 and 1.18.28, which all connect from it.
60
+
61
+ Two things worth knowing, both measured rather than assumed:
62
+
63
+ - **Never carry both config shapes in one file.** Older opencode 1 wants the
64
+ servers directly under `mcp` (`"mcp": { "outlook": { … } }`), and the
65
+ temptation is to write both so either version finds one. Do not: given a
66
+ sibling key beside `servers`, opencode 2 parses the file — it still shows up
67
+ in `opencode debug config` — and then reports *"No MCP servers configured"*.
68
+ No error, no warning, every server silently gone.
69
+ - **An opencode 1 old enough to reject `servers` says so loudly**
70
+ (`Configuration is invalid at …`), so if you see that, switch that machine to
71
+ the flat shape rather than combining them.
72
+
73
+ opencode 2 talks to a background service, so `opencode reload` before
74
+ `opencode mcp list` after editing config.
75
+
40
76
  ### Configuration
41
77
 
42
78
  Everything is optional — with nothing set, the server captures from the browser.
@@ -47,7 +83,7 @@ Everything is optional — with nothing set, the server captures from the browse
47
83
  | `OUTLOOK_DISABLE_FETCHPROXY` | `1` to disable browser capture, requiring `OUTLOOK_ACCESS_TOKEN`. |
48
84
  | `OUTLOOK_API_BASE` | Override the REST base URL. Defaults to `https://outlook.office.com/api/v2.0`. |
49
85
  | `OUTLOOK_WS_PORT` | fetchproxy concentrator port. Defaults to `37149`, the fleet-wide shared port. |
50
- | `OUTLOOK_CAPTURE_TIMEOUT` | Seconds to wait for the tab to make a readable request. Defaults to `30`. |
86
+ | `OUTLOOK_CAPTURE_TIMEOUT` | Seconds to wait for the tab to make a readable request. Bounds the WHOLE capture, both declared hosts included. Defaults to `30`. |
51
87
  | `OUTLOOK_TOKEN_CACHE` | `false` to stop caching the token between runs. |
52
88
  | `OUTLOOK_TOKEN_FILE` | Path to the cached token. Defaults to `~/.office-outlook-mcp/token.json`. |
53
89
 
@@ -66,8 +102,10 @@ Everything is optional — with nothing set, the server captures from the browse
66
102
  **Diagnostics** — `outlook_healthcheck`
67
103
 
68
104
  Every read tool takes `view: compact | full | raw`, defaulting to **compact**.
69
- Mutating tools make **no network call** without `confirm: true` — they return a
70
- dry-run preview of exactly what would be sent.
105
+ Mutating tools **write nothing** without `confirm: true` — they return a
106
+ dry-run preview of exactly what would be sent. (`outlook_create_event` first
107
+ reads the mailbox time zone, so its preview can name the zone it would book
108
+ in; that is the one read a dry run makes.)
71
109
 
72
110
  ## Things worth knowing
73
111
 
@@ -81,6 +119,11 @@ dry-run preview of exactly what would be sent.
81
119
  - `search` and `unreadOnly` cannot be combined; the API rejects `$search`
82
120
  alongside `$filter`, so the tool refuses before making a doomed request.
83
121
  - Moving a message assigns it a **new id**.
122
+ - `outlook_list_events` returns times in the **mailbox's own time zone** unless
123
+ `timeZone` overrides it. The API itself defaults to UTC, which silently reads
124
+ as a four-hour error on an Eastern mailbox.
125
+ - Contact phone fields are `MobilePhone1`, not Graph's `MobilePhone`. The v2.0
126
+ Contact type rejects the Graph name with a 400.
84
127
 
85
128
  ## The lightweight alternative
86
129
 
@@ -16,7 +16,7 @@
16
16
  * - that token authenticates plain server-side requests to
17
17
  * `outlook.office.com/api/v2.0` with no bridge involved;
18
18
  * - older tenants still serve the app from `outlook.office.com`, so both
19
- * hosts are declared and captured concurrently.
19
+ * hosts are declared and captured, under ONE overall window.
20
20
  *
21
21
  * We do NOT try to mint a token by calling an endpoint ourselves. There is no
22
22
  * such endpoint reachable from the page's origin — the token comes from MSAL's
@@ -56,6 +56,27 @@ const CAPTURE_DECL_OFFICE = {
56
56
  path: '/*',
57
57
  headerName: 'authorization',
58
58
  };
59
+ /**
60
+ * The hosts a token may be captured from — the two Outlook Web origins.
61
+ *
62
+ * Exported so the trust boundary below can be derived from them rather than
63
+ * restated.
64
+ */
65
+ export const CAPTURE_HOSTS = [CAPTURE_DECL_CLOUD.host, CAPTURE_DECL_OFFICE.host];
66
+ /**
67
+ * The extension's trust boundary: exactly the hosts we capture from.
68
+ *
69
+ * These were the apex domains `cloud.microsoft` and `office.com`, which the
70
+ * extension reads as "any subdomain of either" — the whole of Microsoft 365
71
+ * granted to read one header off two known hosts. It is also what sent the
72
+ * pairing flow to `m365.cloud.microsoft/chat`, a tab nothing here needs.
73
+ *
74
+ * Narrowing costs nothing: the capture declarations were always these two
75
+ * hosts, and every API call the server makes afterwards is a plain server-side
76
+ * `fetch` that the bridge never sees. Verified live 2026-09-20 — capture still
77
+ * succeeded, and an already-paired extension did not ask to re-pair.
78
+ */
79
+ export const TRUST_DOMAINS = [...CAPTURE_HOSTS];
59
80
  /**
60
81
  * How long to wait for the page to make a request we can read.
61
82
  *
@@ -97,6 +118,45 @@ function withFetch(opts) {
97
118
  : [...derived, 'fetch'],
98
119
  };
99
120
  }
121
+ /**
122
+ * Settle a set of per-host capture attempts under ONE overall deadline.
123
+ *
124
+ * `Promise.any` alone is not enough. The two declared hosts are documented as
125
+ * raced, but the bridge serializes them behind its single connection, so the
126
+ * wait was the sum rather than the max — measured live at 14.4s for a 5s window
127
+ * and 36s for a 20s one. At the 30s default that lands past the 60s request
128
+ * timeout an MCP client uses by default, so the host gave up before this code
129
+ * could report the real reason. The window is a promise about total wait, and
130
+ * this is what keeps it.
131
+ *
132
+ * Exported for the test that pins that bound.
133
+ */
134
+ export async function raceCaptures(attempts, windowMs) {
135
+ // A rejection that loses the race must not surface as an unhandled rejection
136
+ // once the winner has already settled the caller.
137
+ for (const a of attempts)
138
+ a.catch(() => { });
139
+ let timer;
140
+ const expiry = new Promise((_, reject) => {
141
+ timer = setTimeout(() => reject(new Error(`timed out after ${Math.round(windowMs / 1000)}s waiting for the page to ` +
142
+ 'make a request we could read')), windowMs);
143
+ });
144
+ try {
145
+ // `any` resolves on the first SUCCESS rather than the first settle, so the
146
+ // host the user is not on failing does not sink the one they are on.
147
+ return await Promise.race([
148
+ Promise.any(attempts).catch((e) => {
149
+ throw new Error(e instanceof AggregateError
150
+ ? e.errors.map((x) => x.message).join('; ')
151
+ : e.message);
152
+ }),
153
+ expiry,
154
+ ]);
155
+ }
156
+ finally {
157
+ clearTimeout(timer);
158
+ }
159
+ }
100
160
  /** Normalise a captured header to a bare token, dropping any `Bearer ` prefix. */
101
161
  export function stripBearer(raw) {
102
162
  const m = /^\s*Bearer\s+(.+)$/i.exec(raw);
@@ -105,13 +165,15 @@ export function stripBearer(raw) {
105
165
  /**
106
166
  * Capture an Outlook access token from the user's signed-in browser tab.
107
167
  *
108
- * Both hosts are raced: whichever the tab is actually on answers first, and the
109
- * other simply never resolves. Returns the bare token (no `Bearer ` prefix).
168
+ * Both hosts are attempted: whichever the tab is actually on answers first, and
169
+ * the other simply never resolves. The bridge serializes them, so the pair is
170
+ * held to one deadline by `raceCaptures` rather than to one deadline each.
171
+ * Returns the bare token (no `Bearer ` prefix).
110
172
  */
111
173
  export async function captureTokenViaFetchproxy() {
112
174
  const transport = createFetchproxyTransport({
113
175
  ...withFetch(createBootstrapOpts({
114
- domains: ['cloud.microsoft', 'office.com'],
176
+ domains: [...TRUST_DOMAINS],
115
177
  bootstrap: {
116
178
  captureHeaders: [{ ...CAPTURE_DECL_CLOUD }, { ...CAPTURE_DECL_OFFICE }],
117
179
  },
@@ -140,15 +202,11 @@ export async function captureTokenViaFetchproxy() {
140
202
  }
141
203
  return token;
142
204
  }));
143
- // `any` resolves on the first SUCCESS rather than the first settle, so the
144
- // host the user is not on failing does not sink the one they are on.
145
205
  try {
146
- return await Promise.any(attempts);
206
+ return await raceCaptures(attempts, CAPTURE_TIMEOUT_MS);
147
207
  }
148
208
  catch (e) {
149
- const detail = e instanceof AggregateError
150
- ? e.errors.map((x) => x.message).join('; ')
151
- : e.message;
209
+ const detail = e.message;
152
210
  throw new Error(`could not capture an Outlook token from the browser (${detail}). ` +
153
211
  'Open a signed-in Outlook tab (outlook.cloud.microsoft or ' +
154
212
  'outlook.office.com), leave it in the foreground for a moment so the ' +
package/dist/bundle.js CHANGED
@@ -35843,7 +35843,7 @@ var init_version = __esm({
35843
35843
  "src/version.ts"() {
35844
35844
  "use strict";
35845
35845
  PACKAGE_NAME = "office-outlook-mcp";
35846
- VERSION = "0.1.1";
35846
+ VERSION = "0.1.3";
35847
35847
  }
35848
35848
  });
35849
35849
 
@@ -44889,8 +44889,11 @@ var init_fetchproxy = __esm({
44889
44889
  // src/auth-fetchproxy.ts
44890
44890
  var auth_fetchproxy_exports = {};
44891
44891
  __export(auth_fetchproxy_exports, {
44892
+ CAPTURE_HOSTS: () => CAPTURE_HOSTS,
44893
+ TRUST_DOMAINS: () => TRUST_DOMAINS,
44892
44894
  captureTokenViaFetchproxy: () => captureTokenViaFetchproxy,
44893
44895
  getWsPort: () => getWsPort,
44896
+ raceCaptures: () => raceCaptures,
44894
44897
  stripBearer: () => stripBearer
44895
44898
  });
44896
44899
  function getWsPort() {
@@ -44903,6 +44906,33 @@ function withFetch(opts) {
44903
44906
  capabilities: derived2.includes("fetch") ? derived2 : [...derived2, "fetch"]
44904
44907
  };
44905
44908
  }
44909
+ async function raceCaptures(attempts, windowMs) {
44910
+ for (const a of attempts) a.catch(() => {
44911
+ });
44912
+ let timer;
44913
+ const expiry = new Promise((_, reject) => {
44914
+ timer = setTimeout(
44915
+ () => reject(
44916
+ new Error(
44917
+ `timed out after ${Math.round(windowMs / 1e3)}s waiting for the page to make a request we could read`
44918
+ )
44919
+ ),
44920
+ windowMs
44921
+ );
44922
+ });
44923
+ try {
44924
+ return await Promise.race([
44925
+ Promise.any(attempts).catch((e) => {
44926
+ throw new Error(
44927
+ e instanceof AggregateError ? e.errors.map((x) => x.message).join("; ") : e.message
44928
+ );
44929
+ }),
44930
+ expiry
44931
+ ]);
44932
+ } finally {
44933
+ clearTimeout(timer);
44934
+ }
44935
+ }
44906
44936
  function stripBearer(raw) {
44907
44937
  const m = /^\s*Bearer\s+(.+)$/i.exec(raw);
44908
44938
  return (m ? m[1] : raw).trim();
@@ -44911,7 +44941,7 @@ async function captureTokenViaFetchproxy() {
44911
44941
  const transport = createFetchproxyTransport({
44912
44942
  ...withFetch(
44913
44943
  createBootstrapOpts({
44914
- domains: ["cloud.microsoft", "office.com"],
44944
+ domains: [...TRUST_DOMAINS],
44915
44945
  bootstrap: {
44916
44946
  captureHeaders: [{ ...CAPTURE_DECL_CLOUD }, { ...CAPTURE_DECL_OFFICE }]
44917
44947
  }
@@ -44939,9 +44969,9 @@ async function captureTokenViaFetchproxy() {
44939
44969
  })
44940
44970
  );
44941
44971
  try {
44942
- return await Promise.any(attempts);
44972
+ return await raceCaptures(attempts, CAPTURE_TIMEOUT_MS);
44943
44973
  } catch (e) {
44944
- const detail = e instanceof AggregateError ? e.errors.map((x) => x.message).join("; ") : e.message;
44974
+ const detail = e.message;
44945
44975
  throw new Error(
44946
44976
  `could not capture an Outlook token from the browser (${detail}). Open a signed-in Outlook tab (outlook.cloud.microsoft or outlook.office.com), leave it in the foreground for a moment so the page makes a request, and retry.`
44947
44977
  );
@@ -44953,7 +44983,7 @@ async function captureTokenViaFetchproxy() {
44953
44983
  }
44954
44984
  }
44955
44985
  }
44956
- var DEFAULT_WS_PORT, CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE, CAPTURE_TIMEOUT_MS, BRIDGE_DEADLINE_MS, MIN_TOKEN_LENGTH;
44986
+ var DEFAULT_WS_PORT, CAPTURE_DECL_CLOUD, CAPTURE_DECL_OFFICE, CAPTURE_HOSTS, TRUST_DOMAINS, CAPTURE_TIMEOUT_MS, BRIDGE_DEADLINE_MS, MIN_TOKEN_LENGTH;
44957
44987
  var init_auth_fetchproxy = __esm({
44958
44988
  "src/auth-fetchproxy.ts"() {
44959
44989
  "use strict";
@@ -44971,6 +45001,8 @@ var init_auth_fetchproxy = __esm({
44971
45001
  path: "/*",
44972
45002
  headerName: "authorization"
44973
45003
  };
45004
+ CAPTURE_HOSTS = [CAPTURE_DECL_CLOUD.host, CAPTURE_DECL_OFFICE.host];
45005
+ TRUST_DOMAINS = [...CAPTURE_HOSTS];
44974
45006
  CAPTURE_TIMEOUT_MS = readTtlMsEnv("OUTLOOK_CAPTURE_TIMEOUT", 3e4);
44975
45007
  BRIDGE_DEADLINE_MS = Math.max(CAPTURE_TIMEOUT_MS + 15e3, 3e4);
44976
45008
  MIN_TOKEN_LENGTH = 100;
@@ -45625,6 +45657,13 @@ function compactFolder(f) {
45625
45657
  Total: f.TotalItemCount
45626
45658
  });
45627
45659
  }
45660
+ function stripOData(o) {
45661
+ const out = {};
45662
+ for (const [k, v] of Object.entries(o)) {
45663
+ if (!k.startsWith("@odata.")) out[k] = v;
45664
+ }
45665
+ return out;
45666
+ }
45628
45667
  function pruned(o) {
45629
45668
  const out = {};
45630
45669
  for (const [k, v] of Object.entries(o)) {
@@ -45763,7 +45802,7 @@ function registerMailTools(server, client2) {
45763
45802
  );
45764
45803
  return minifiedResult({
45765
45804
  count: data.value?.length ?? 0,
45766
- items: data.value ?? []
45805
+ items: (data.value ?? []).map(stripOData)
45767
45806
  });
45768
45807
  }
45769
45808
  );
@@ -45772,6 +45811,23 @@ function registerMailTools(server, client2) {
45772
45811
  // src/tools/calendar.ts
45773
45812
  init_zod();
45774
45813
  init_dist2();
45814
+
45815
+ // src/timezone.ts
45816
+ var zoneByClient = /* @__PURE__ */ new WeakMap();
45817
+ async function mailboxTimeZone(client2) {
45818
+ const cached2 = zoneByClient.get(client2);
45819
+ if (cached2) return cached2;
45820
+ try {
45821
+ const settings = await client2.get("/me/MailboxSettings");
45822
+ const zone = settings?.TimeZone?.trim() || void 0;
45823
+ if (zone) zoneByClient.set(client2, zone);
45824
+ return zone;
45825
+ } catch {
45826
+ return void 0;
45827
+ }
45828
+ }
45829
+
45830
+ // src/tools/calendar.ts
45775
45831
  var EVENT_SELECT = "Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview";
45776
45832
  function qs2(params) {
45777
45833
  const parts = [];
@@ -45798,6 +45854,7 @@ function registerCalendarTools(server, client2) {
45798
45854
  })
45799
45855
  },
45800
45856
  async ({ view, start, end, timeZone, limit }) => {
45857
+ const zone = timeZone ?? await mailboxTimeZone(client2);
45801
45858
  const data = await client2.get(
45802
45859
  `/me/calendarview${qs2({
45803
45860
  startDateTime: start,
@@ -45806,7 +45863,7 @@ function registerCalendarTools(server, client2) {
45806
45863
  $orderby: "Start/DateTime",
45807
45864
  $top: limit ?? 50
45808
45865
  })}`,
45809
- timeZone ? { prefer: `outlook.timezone="${timeZone}"` } : {}
45866
+ zone ? { prefer: `outlook.timezone="${zone}"` } : {}
45810
45867
  );
45811
45868
  const v = resolveView(view, VIEWS2);
45812
45869
  if (v === "raw") return minifiedResult(data);
@@ -45827,9 +45884,10 @@ function registerCalendarTools(server, client2) {
45827
45884
  })
45828
45885
  },
45829
45886
  async ({ view, id, timeZone }) => {
45887
+ const zone = timeZone ?? await mailboxTimeZone(client2);
45830
45888
  const data = await client2.get(
45831
45889
  `/me/events/${encodeURIComponent(id)}`,
45832
- timeZone ? { prefer: `outlook.timezone="${timeZone}"` } : {}
45890
+ zone ? { prefer: `outlook.timezone="${zone}"` } : {}
45833
45891
  );
45834
45892
  const v = resolveView(view, VIEWS2);
45835
45893
  if (v === "raw") return minifiedResult(data);
@@ -45850,7 +45908,10 @@ function registerCalendarTools(server, client2) {
45850
45908
  `/me/calendars${qs2({ $select: "Id,Name,Color,CanEdit,Owner" })}`
45851
45909
  );
45852
45910
  if (resolveView(view, VIEWS2) === "raw") return minifiedResult(data);
45853
- return minifiedResult({ count: data.value?.length ?? 0, items: data.value ?? [] });
45911
+ return minifiedResult({
45912
+ count: data.value?.length ?? 0,
45913
+ items: (data.value ?? []).map(stripOData)
45914
+ });
45854
45915
  }
45855
45916
  );
45856
45917
  }
@@ -45868,7 +45929,7 @@ function qs3(params) {
45868
45929
  }
45869
45930
  function collection(data, raw) {
45870
45931
  if (raw) return data;
45871
- return { count: data.value?.length ?? 0, items: data.value ?? [] };
45932
+ return { count: data.value?.length ?? 0, items: (data.value ?? []).map(stripOData) };
45872
45933
  }
45873
45934
  function registerDirectoryTools(server, client2) {
45874
45935
  server.registerTool(
@@ -45878,7 +45939,7 @@ function registerDirectoryTools(server, client2) {
45878
45939
  annotations: { readOnlyHint: true },
45879
45940
  inputSchema: external_exports.object({})
45880
45941
  },
45881
- async () => minifiedResult(await client2.get("/me"))
45942
+ async () => minifiedResult(stripOData(await client2.get("/me")))
45882
45943
  );
45883
45944
  server.registerTool(
45884
45945
  "outlook_get_mailbox_settings",
@@ -45887,7 +45948,7 @@ function registerDirectoryTools(server, client2) {
45887
45948
  annotations: { readOnlyHint: true },
45888
45949
  inputSchema: external_exports.object({})
45889
45950
  },
45890
- async () => minifiedResult(await client2.get("/me/MailboxSettings"))
45951
+ async () => minifiedResult(stripOData(await client2.get("/me/MailboxSettings")))
45891
45952
  );
45892
45953
  server.registerTool(
45893
45954
  "outlook_list_contacts",
@@ -45905,7 +45966,9 @@ function registerDirectoryTools(server, client2) {
45905
45966
  `/me/contacts${qs3({
45906
45967
  $top: limit ?? 50,
45907
45968
  $skip: skip,
45908
- $select: "Id,DisplayName,EmailAddresses,CompanyName,JobTitle,MobilePhone"
45969
+ // `MobilePhone1`, not `MobilePhone`: the latter is the Graph name and
45970
+ // the v2.0 Contact type rejects it outright with a 400.
45971
+ $select: "Id,DisplayName,EmailAddresses,CompanyName,JobTitle,MobilePhone1"
45909
45972
  })}`
45910
45973
  );
45911
45974
  return minifiedResult(collection(data, resolveView(view, VIEWS2) === "raw"));
@@ -45987,7 +46050,7 @@ function registerWriteTools(server, client2) {
45987
46050
  "outlook_send_mail",
45988
46051
  {
45989
46052
  description: "Send an email from the signed-in mailbox. Requires confirm:true \u2014 without it this makes no network call and returns a preview of exactly what would be sent.",
45990
- annotations: { readOnlyHint: false, destructiveHint: false },
46053
+ annotations: { readOnlyHint: false, destructiveHint: true },
45991
46054
  inputSchema: external_exports.object({
45992
46055
  to: recipientList,
45993
46056
  cc: recipientList,
@@ -46133,7 +46196,7 @@ function registerWriteTools(server, client2) {
46133
46196
  subject: external_exports.string().describe("Event title"),
46134
46197
  start: external_exports.string().min(1).describe("Start, ISO 8601 local time e.g. 2026-09-22T15:00:00"),
46135
46198
  end: external_exports.string().min(1).describe("End, ISO 8601 local time"),
46136
- timeZone: external_exports.string().optional().describe('Windows time-zone name (default "UTC")'),
46199
+ timeZone: external_exports.string().optional().describe("Windows time-zone name. Defaults to the MAILBOX time zone."),
46137
46200
  location: external_exports.string().optional().describe("Location display name"),
46138
46201
  body: external_exports.string().optional().describe("Event description"),
46139
46202
  attendees: recipientList,
@@ -46141,7 +46204,7 @@ function registerWriteTools(server, client2) {
46141
46204
  })
46142
46205
  },
46143
46206
  async ({ subject, start, end, timeZone, location, body, attendees, confirm }) => {
46144
- const tz = timeZone ?? "UTC";
46207
+ const tz = timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
46145
46208
  const payload = {
46146
46209
  Subject: subject,
46147
46210
  Start: { DateTime: start, TimeZone: tz },
@@ -0,0 +1,16 @@
1
+ const zoneByClient = new WeakMap();
2
+ export async function mailboxTimeZone(client) {
3
+ const cached = zoneByClient.get(client);
4
+ if (cached)
5
+ return cached;
6
+ try {
7
+ const settings = await client.get('/me/MailboxSettings');
8
+ const zone = settings?.TimeZone?.trim() || undefined;
9
+ if (zone)
10
+ zoneByClient.set(client, zone);
11
+ return zone;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
3
- import { compactEvent, fullEvent, projectCollection } from '../view.js';
3
+ import { compactEvent, fullEvent, projectCollection, stripOData, } from '../view.js';
4
4
  import { VIEWS } from './mail.js';
5
+ import { mailboxTimeZone } from '../timezone.js';
5
6
  const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview';
6
7
  function qs(params) {
7
8
  const parts = [];
@@ -30,13 +31,14 @@ export function registerCalendarTools(server, client) {
30
31
  limit: z.number().int().min(1).max(200).optional().describe('Max events (default 50)'),
31
32
  }),
32
33
  }, async ({ view, start, end, timeZone, limit }) => {
34
+ const zone = timeZone ?? (await mailboxTimeZone(client));
33
35
  const data = await client.get(`/me/calendarview${qs({
34
36
  startDateTime: start,
35
37
  endDateTime: end,
36
38
  $select: EVENT_SELECT,
37
39
  $orderby: 'Start/DateTime',
38
40
  $top: limit ?? 50,
39
- })}`, timeZone ? { prefer: `outlook.timezone="${timeZone}"` } : {});
41
+ })}`, zone ? { prefer: `outlook.timezone="${zone}"` } : {});
40
42
  const v = resolveView(view, VIEWS);
41
43
  if (v === 'raw')
42
44
  return minifiedResult(data);
@@ -51,7 +53,8 @@ export function registerCalendarTools(server, client) {
51
53
  timeZone: z.string().optional().describe('Windows time-zone name for returned times'),
52
54
  }),
53
55
  }, async ({ view, id, timeZone }) => {
54
- const data = await client.get(`/me/events/${encodeURIComponent(id)}`, timeZone ? { prefer: `outlook.timezone="${timeZone}"` } : {});
56
+ const zone = timeZone ?? (await mailboxTimeZone(client));
57
+ const data = await client.get(`/me/events/${encodeURIComponent(id)}`, zone ? { prefer: `outlook.timezone="${zone}"` } : {});
55
58
  const v = resolveView(view, VIEWS);
56
59
  if (v === 'raw')
57
60
  return minifiedResult(data);
@@ -67,6 +70,9 @@ export function registerCalendarTools(server, client) {
67
70
  const data = await client.get(`/me/calendars${qs({ $select: 'Id,Name,Color,CanEdit,Owner' })}`);
68
71
  if (resolveView(view, VIEWS) === 'raw')
69
72
  return minifiedResult(data);
70
- return minifiedResult({ count: data.value?.length ?? 0, items: data.value ?? [] });
73
+ return minifiedResult({
74
+ count: data.value?.length ?? 0,
75
+ items: (data.value ?? []).map(stripOData),
76
+ });
71
77
  });
72
78
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
3
+ import { stripOData } from '../view.js';
3
4
  import { VIEWS } from './mail.js';
4
5
  function qs(params) {
5
6
  const parts = [];
@@ -13,19 +14,19 @@ function qs(params) {
13
14
  function collection(data, raw) {
14
15
  if (raw)
15
16
  return data;
16
- return { count: data.value?.length ?? 0, items: data.value ?? [] };
17
+ return { count: data.value?.length ?? 0, items: (data.value ?? []).map(stripOData) };
17
18
  }
18
19
  export function registerDirectoryTools(server, client) {
19
20
  server.registerTool('outlook_get_profile', {
20
21
  description: "Get the signed-in mailbox's identity (email address, display name, alias). The cheapest call that proves the credential works.",
21
22
  annotations: { readOnlyHint: true },
22
23
  inputSchema: z.object({}),
23
- }, async () => minifiedResult(await client.get('/me')));
24
+ }, async () => minifiedResult(stripOData(await client.get('/me'))));
24
25
  server.registerTool('outlook_get_mailbox_settings', {
25
26
  description: 'Get mailbox settings: time zone, working hours, language, and automatic-replies (out-of-office) configuration.',
26
27
  annotations: { readOnlyHint: true },
27
28
  inputSchema: z.object({}),
28
- }, async () => minifiedResult(await client.get('/me/MailboxSettings')));
29
+ }, async () => minifiedResult(stripOData(await client.get('/me/MailboxSettings'))));
29
30
  server.registerTool('outlook_list_contacts', {
30
31
  description: 'List saved contacts from the mailbox address book.',
31
32
  annotations: { readOnlyHint: true },
@@ -38,7 +39,9 @@ export function registerDirectoryTools(server, client) {
38
39
  const data = await client.get(`/me/contacts${qs({
39
40
  $top: limit ?? 50,
40
41
  $skip: skip,
41
- $select: 'Id,DisplayName,EmailAddresses,CompanyName,JobTitle,MobilePhone',
42
+ // `MobilePhone1`, not `MobilePhone`: the latter is the Graph name and
43
+ // the v2.0 Contact type rejects it outright with a 400.
44
+ $select: 'Id,DisplayName,EmailAddresses,CompanyName,JobTitle,MobilePhone1',
42
45
  })}`);
43
46
  return minifiedResult(collection(data, resolveView(view, VIEWS) === 'raw'));
44
47
  });
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, viewParam, McpToolError } from '@chrischall/mcp-utils';
3
- import { compactFolder, compactMessage, fullMessage, projectCollection, } from '../view.js';
3
+ import { compactFolder, compactMessage, fullMessage, projectCollection, stripOData, } from '../view.js';
4
4
  export const VIEWS = ['compact', 'full', 'raw'];
5
5
  /**
6
6
  * Well-known folder names the API accepts wherever a folder id is taken.
@@ -124,7 +124,7 @@ export function registerMailTools(server, client) {
124
124
  })}`);
125
125
  return minifiedResult({
126
126
  count: data.value?.length ?? 0,
127
- items: data.value ?? [],
127
+ items: (data.value ?? []).map(stripOData),
128
128
  });
129
129
  });
130
130
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult } from '@chrischall/mcp-utils';
3
3
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
+ import { mailboxTimeZone } from '../timezone.js';
4
5
  const recipientList = z
5
6
  .array(z.string().min(3))
6
7
  .optional()
@@ -21,7 +22,7 @@ function messageBody(subject, body, html) {
21
22
  export function registerWriteTools(server, client) {
22
23
  server.registerTool('outlook_send_mail', {
23
24
  description: 'Send an email from the signed-in mailbox. Requires confirm:true — without it this makes no network call and returns a preview of exactly what would be sent.',
24
- annotations: { readOnlyHint: false, destructiveHint: false },
25
+ annotations: { readOnlyHint: false, destructiveHint: true },
25
26
  inputSchema: z.object({
26
27
  to: recipientList,
27
28
  cc: recipientList,
@@ -133,14 +134,15 @@ export function registerWriteTools(server, client) {
133
134
  timeZone: z
134
135
  .string()
135
136
  .optional()
136
- .describe('Windows time-zone name (default "UTC")'),
137
+ .describe('Windows time-zone name. Defaults to the MAILBOX time zone.'),
137
138
  location: z.string().optional().describe('Location display name'),
138
139
  body: z.string().optional().describe('Event description'),
139
140
  attendees: recipientList,
140
141
  confirm: schemaConfirm,
141
142
  }),
142
143
  }, async ({ subject, start, end, timeZone, location, body, attendees, confirm }) => {
143
- const tz = timeZone ?? 'UTC';
144
+ // Falls back to UTC only when the mailbox itself declares no zone.
145
+ const tz = timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
144
146
  const payload = {
145
147
  Subject: subject,
146
148
  Start: { DateTime: start, TimeZone: tz },
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.1.1'; // x-release-please-version
13
+ export const VERSION = '0.1.3'; // x-release-please-version
package/dist/view.js CHANGED
@@ -83,6 +83,27 @@ export function compactFolder(f) {
83
83
  Total: f.TotalItemCount,
84
84
  });
85
85
  }
86
+ /**
87
+ * Drop OData's envelope keys (`@odata.id`, `@odata.etag`, `@odata.context`, …)
88
+ * from a record.
89
+ *
90
+ * `$select` does not suppress them: a contact selected down to six fields still
91
+ * arrives with a ~200-char `@odata.id` self-URL repeating the mailbox GUID and
92
+ * the item Id, which on a 50-item listing is most of the payload. The typed
93
+ * projections never had this problem because they name their fields; the tools
94
+ * that pass records through untouched did.
95
+ *
96
+ * Everything that is not an annotation survives, so this stays a projection of
97
+ * the envelope rather than of the record.
98
+ */
99
+ export function stripOData(o) {
100
+ const out = {};
101
+ for (const [k, v] of Object.entries(o)) {
102
+ if (!k.startsWith('@odata.'))
103
+ out[k] = v;
104
+ }
105
+ return out;
106
+ }
86
107
  /** Drop `undefined` so a compact record does not carry empty keys. */
87
108
  function pruned(o) {
88
109
  const out = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/office-outlook-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1.1",
9
+ "version": "0.1.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/office-outlook-mcp",
14
- "version": "0.1.1",
14
+ "version": "0.1.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },