@chrischall/office-outlook-mcp 0.0.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.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: outlook-fpx
3
+ description: Read and act on the user's Outlook / Microsoft 365 mailbox — mail, folders, calendar, contacts, tasks — via the Outlook REST API, using a bearer token lifted from their signed-in browser tab. Use when asked about their Outlook email, calendar or meetings.
4
+ ---
5
+
6
+ # Outlook (Microsoft 365) access
7
+
8
+ Talks to `https://outlook.office.com/api/v2.0` with a bearer token captured
9
+ from the signed-in Outlook Web tab. **The browser is needed only to mint the
10
+ token** (~25h lifetime) — every actual request is a plain server-side `curl`.
11
+
12
+ ## Setup (once)
13
+
14
+ ```sh
15
+ npm i -g @fetchproxy/cli # provides `fpx`
16
+ fpx profile add outlook --domain cloud.microsoft --domain office.com \
17
+ --domain microsoft.com --domain office365.com
18
+ fpx profile declare outlook \
19
+ --capture-header authorization@outlook.cloud.microsoft \
20
+ --capture-header authorization@outlook.office.com \
21
+ --allow-download
22
+ ```
23
+
24
+ Declare **all** scopes before the first pairing — widening them later forces the
25
+ user to re-approve. The first `fpx` call prints a 6-digit pair code to approve in
26
+ the Transporter extension popup; the grant then persists.
27
+
28
+ Requires the **Transporter** Chrome extension, and the extension and CLI must be
29
+ on the **same major version** — a 3.x extension against a 2.x CLI fails by
30
+ binding the port and silently never connecting.
31
+
32
+ ## Use
33
+
34
+ ```sh
35
+ source references/outlook-env.sh
36
+ outlook_token_refresh # once per ~25h; needs an open, signed-in Outlook tab
37
+ outlook_get '/me' | jq .
38
+ ```
39
+
40
+ - `outlook_get <path> [curl args]` — GET, body on stdout
41
+ - `outlook_get_text <path>` — same plus `Prefer: outlook.body-content-type="text"`; **use this for anything you will read** (measured 9.4× smaller than HTML)
42
+ - `outlook_post <path> <json>` / `outlook_patch <path> <json>`
43
+ - `outlook_token_expiry` — prints time remaining, never the token
44
+
45
+ Paths are relative to `/api/v2.0`. Exit code **4** means non-2xx; a **401**
46
+ prints `run: outlook_token_refresh`.
47
+
48
+ Common starting points — full verified set, including calendar, contacts, tasks
49
+ and write payloads, in **`references/api-recipes.md`**:
50
+
51
+ ```sh
52
+ outlook_get '/me/mailfolders/inbox?$select=UnreadItemCount,TotalItemCount'
53
+ outlook_get '/me/mailfolders/inbox/messages?$top=20&$orderby=ReceivedDateTime%20desc&$select=Id,Subject,From,ReceivedDateTime,IsRead'
54
+ outlook_get '/me/calendarview?startDateTime=2026-09-20T00:00:00Z&endDateTime=2026-09-27T00:00:00Z&$select=Subject,Start,End,Location'
55
+ ```
56
+
57
+ ## Rules that bite
58
+
59
+ - **Fields are PascalCase** (`ReceivedDateTime`, `IsRead`). This is the Outlook
60
+ REST API, *not* Graph — a camelCase Graph snippet silently returns nothing.
61
+ - **Always pass `$select`.** The default projection is ~40 fields per message
62
+ including the full body.
63
+ - **`$search` and `$filter` cannot be combined.**
64
+ - **`/me/events` does not expand recurring series** — use `/me/calendarview`
65
+ with a start/end window for "what's on my schedule".
66
+ - **`Prefer: outlook.timezone` takes Windows zone names** (`Eastern Standard
67
+ Time`), not IANA (`America/New_York`).
68
+ - **`@odata.nextLink` is an absolute URL** — pass it to `curl -K ~/.outlook-fpx/curlrc`
69
+ directly, not to `outlook_get`, which would prefix the base again.
70
+ - **`/owa/0/service.svc` is not this API** and returns 500. Use `/api/v2.0`.
71
+ - **A 2xx is not proof a write persisted** — re-read the resource to confirm.
72
+ - Never name a shell local `path` or `status`: in zsh `path` is tied to `PATH`
73
+ (assigning it wipes `PATH` mid-function) and `status` is read-only. Both are
74
+ harmless in bash, so this only breaks for zsh users.
75
+
76
+ ## Privacy
77
+
78
+ This reads the user's real mailbox. Surface only what was asked for; do not dump
79
+ message bodies, recipients or attachments into the transcript incidentally. The
80
+ token lives in `~/.outlook-fpx/curlrc` (0600, its own directory — deliberately
81
+ not shared with any MCP's state) and is passed to `curl` via `-K` so it never
82
+ appears in `argv` or `ps` output.
@@ -0,0 +1,163 @@
1
+ # Outlook REST v2.0 — verified request shapes
2
+
3
+ Every endpoint and header below was executed live against a real TransUnion
4
+ M365 mailbox on 2026-09-20 and returned the status shown. Nothing here is
5
+ inferred from documentation.
6
+
7
+ Base: `https://outlook.office.com/api/v2.0`
8
+ Auth: `Authorization: Bearer <token>` (see SKILL.md for capture)
9
+
10
+ > **Fields are PascalCase.** This is the Outlook REST API, not Microsoft Graph.
11
+ > `ReceivedDateTime`, `IsRead`, `BodyPreview` — not `receivedDateTime`. Copying a
12
+ > Graph snippet here silently returns nothing useful.
13
+
14
+ ---
15
+
16
+ ## Identity
17
+
18
+ ```sh
19
+ outlook_get '/me' | jq '{EmailAddress, DisplayName, Alias, MailboxGuid}'
20
+ ```
21
+ Returns `Id, EmailAddress, DisplayName, Alias, MailboxGuid`. Cheapest liveness check.
22
+
23
+ ---
24
+
25
+ ## Mail
26
+
27
+ ### Folders
28
+
29
+ ```sh
30
+ outlook_get '/me/mailfolders?$top=50' | jq -r '.value[] | "\(.UnreadItemCount)/\(.TotalItemCount)\t\(.DisplayName)"'
31
+ ```
32
+
33
+ Item keys: `Id, DisplayName, ParentFolderId, ChildFolderCount, UnreadItemCount,
34
+ TotalItemCount, SizeInBytes, IsHidden`.
35
+
36
+ Well-known names usable in place of an id — all verified present:
37
+ `inbox`, `drafts`, `sentitems`, `deleteditems`, `archive`, `junkemail`,
38
+ `outbox`, `clutter`.
39
+
40
+ ```sh
41
+ outlook_get '/me/mailfolders/inbox?$select=UnreadItemCount,TotalItemCount' | jq .
42
+ ```
43
+
44
+ ### Listing messages
45
+
46
+ Always pass `$select` — the default projection returns ~40 fields per message
47
+ including full `Body`, which is enormous on a 13k-message folder.
48
+
49
+ ```sh
50
+ outlook_get '/me/mailfolders/inbox/messages?$top=20&$orderby=ReceivedDateTime%20desc&$select=Id,Subject,From,ReceivedDateTime,IsRead,HasAttachments' \
51
+ | jq -r '.value[] | "\(.ReceivedDateTime) \(if .IsRead then " " else "•" end) \(.From.EmailAddress.Name // "?")\t\(.Subject)"'
52
+ ```
53
+
54
+ Unread only:
55
+ ```sh
56
+ outlook_get '/me/mailfolders/inbox/messages?$filter=IsRead%20eq%20false&$top=25&$select=Id,Subject,From,ReceivedDateTime'
57
+ ```
58
+
59
+ Full-text search (`$search` and `$filter` cannot be combined):
60
+ ```sh
61
+ outlook_get '/me/messages?$search=%22quarterly%20report%22&$top=10&$select=Id,Subject,From,ReceivedDateTime'
62
+ ```
63
+
64
+ Verified query options: `$top`, `$skip`, `$select`, `$filter`, `$orderby`,
65
+ `$search`, `$count`. Paging: follow `@odata.nextLink` (absolute URL — pass it to
66
+ `curl` directly, not through `outlook_get`, which prefixes the base).
67
+
68
+ ### Reading one message
69
+
70
+ ```sh
71
+ outlook_get_text "/me/messages/$ID" | jq '{Subject, From, ReceivedDateTime, Body: .Body.Content}'
72
+ ```
73
+
74
+ `outlook_get_text` sends `Prefer: outlook.body-content-type="text"`. Measured on
75
+ a real message: **7,299 bytes HTML → 775 bytes text (9.4×)**; across a 5-message
76
+ collection 37,638 → 12,828 bytes. Use it for anything an agent will read.
77
+
78
+ Without the header the API returns whatever the message was authored in, so a
79
+ collection comes back with a *mix* of `HTML` and `Text` — the `Prefer` header
80
+ normalises all of them to `Text`.
81
+
82
+ ### Attachments
83
+
84
+ ```sh
85
+ outlook_get "/me/messages/$ID/attachments?\$select=Id,Name,Size,ContentType" \
86
+ | jq -r '.value[] | "\(.Size)\t\(.ContentType)\t\(.Name)"'
87
+ ```
88
+ Item keys: `@odata.type, @odata.mediaContentType, Id, Name, ContentType, Size`.
89
+ Content bytes come back base64 in `ContentBytes` when `$select` does not exclude
90
+ it; omit `$select` to get it, and expect the response to be ~1.37× the file size.
91
+
92
+ ---
93
+
94
+ ## Calendar
95
+
96
+ ```sh
97
+ # events in a window — expands recurrences, which /me/events does NOT
98
+ outlook_get '/me/calendarview?startDateTime=2026-09-20T00:00:00Z&endDateTime=2026-09-27T00:00:00Z&$select=Subject,Start,End,Location,Organizer&$orderby=Start/DateTime' \
99
+ -H 'Prefer: outlook.timezone="Eastern Standard Time"' \
100
+ | jq -r '.value[] | "\(.Start.DateTime) \(.Subject)"'
101
+ ```
102
+
103
+ `Prefer: outlook.timezone="<Windows tz name>"` is verified — `Start.TimeZone`
104
+ comes back as the requested zone instead of UTC. Note it takes **Windows**
105
+ zone names (`Eastern Standard Time`), not IANA (`America/New_York`).
106
+
107
+ `/me/calendars` lists calendars; `/me/events` lists events **without** expanding
108
+ recurring series, so prefer `/me/calendarview` for "what's on my schedule".
109
+
110
+ ---
111
+
112
+ ## Contacts, tasks, people
113
+
114
+ ```sh
115
+ outlook_get '/me/contacts?$top=20&$select=DisplayName,EmailAddresses,CompanyName'
116
+ outlook_get '/me/tasks?$top=20&$select=Subject,Status,DueDateTime'
117
+ outlook_get '/me/people?$top=20&$select=DisplayName,ScoredEmailAddresses' # relevance-ranked
118
+ outlook_get '/me/MailboxSettings' # tz, working hours, auto-reply
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Writes
124
+
125
+ All four routes were confirmed to exist and be authorised by POSTing a
126
+ deliberately invalid body and receiving **400** (`UnableToDeserializePostBody` /
127
+ `RequestBodyRead`) rather than 401/403/404. **The payloads below are shapes, not
128
+ executed calls** — no mail was sent and nothing was mutated during the build.
129
+ Verify the body of any write by re-reading the resource afterwards; a 2xx is not
130
+ proof it persisted.
131
+
132
+ ```sh
133
+ # send mail
134
+ outlook_post '/me/sendmail' '{
135
+ "Message": {
136
+ "Subject": "…",
137
+ "Body": {"ContentType": "Text", "Content": "…"},
138
+ "ToRecipients": [{"EmailAddress": {"Address": "someone@example.com"}}]
139
+ },
140
+ "SaveToSentItems": true
141
+ }'
142
+
143
+ # mark read / move / create draft / create event
144
+ outlook_patch "/me/messages/$ID" '{"IsRead": true}'
145
+ outlook_post "/me/messages/$ID/move" '{"DestinationId": "archive"}'
146
+ outlook_post '/me/messages' '{"Subject":"…","Body":{"ContentType":"Text","Content":"…"},"ToRecipients":[…]}'
147
+ outlook_post '/me/events' '{"Subject":"…","Start":{"DateTime":"2026-09-22T15:00:00","TimeZone":"Eastern Standard Time"},"End":{…}}'
148
+ ```
149
+
150
+ Send only to `@example.com` when testing.
151
+
152
+ ---
153
+
154
+ ## Errors
155
+
156
+ | Signal | Meaning |
157
+ |---|---|
158
+ | `401`, **empty body**, `WWW-Authenticate: Bearer client_id="00000002-0000-0ff1-ce00-000000000000"` | token expired or revoked → `outlook_token_refresh` |
159
+ | `400` + `error.code` (`UnableToDeserializePostBody`, `RequestBodyRead`) | route exists, payload is wrong |
160
+ | `404` | wrong id or path — *not* an auth problem |
161
+ | `500` on `/owa/0/service.svc` | that is the OWA internal endpoint; it is **not** part of this API. Use `/api/v2.0`. |
162
+
163
+ `outlook_get` returns rc `4` for any non-2xx and prints the status to stderr.
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bash
2
+ # Outlook access helpers — source this file, then use outlook_get / outlook_post.
3
+ #
4
+ # source references/outlook-env.sh
5
+ # outlook_token_refresh # once per ~25h (browser needed only here)
6
+ # outlook_get '/me/messages?$top=5&$select=Subject,From' | jq .
7
+ #
8
+ # Works under bash and zsh. Data on stdout, diagnostics on stderr.
9
+ # The bearer token never appears in argv (curl reads it from a 0600 config file).
10
+
11
+ OUTLOOK_FPX_DIR="${OUTLOOK_FPX_DIR:-$HOME/.outlook-fpx}"
12
+ OUTLOOK_FPX_PROFILE="${OUTLOOK_FPX_PROFILE:-outlook}"
13
+ OUTLOOK_API="${OUTLOOK_API:-https://outlook.office.com/api/v2.0}"
14
+ # Host whose outgoing Authorization header we snapshot. New Outlook Web is on
15
+ # outlook.cloud.microsoft; older tenants still serve outlook.office.com.
16
+ OUTLOOK_CAPTURE_HOST="${OUTLOOK_CAPTURE_HOST:-outlook.cloud.microsoft}"
17
+
18
+ _outlook_curlrc() { printf '%s/curlrc' "$OUTLOOK_FPX_DIR"; }
19
+
20
+ # Reload an open Outlook tab so the page re-issues API calls while we capture.
21
+ # macOS/Chrome only; a no-op elsewhere (the capture still works if the tab is
22
+ # active on its own, it is just less reliable).
23
+ _outlook_poke_tab() {
24
+ command -v osascript >/dev/null 2>&1 || return 0
25
+ osascript - "$OUTLOOK_CAPTURE_HOST" <<'APPLESCRIPT' >/dev/null 2>&1 || true
26
+ on run argv
27
+ set theHost to item 1 of argv
28
+ tell application "Google Chrome"
29
+ repeat with w in windows
30
+ repeat with t in tabs of w
31
+ if (URL of t) contains theHost then
32
+ tell t to reload
33
+ return "ok"
34
+ end if
35
+ end repeat
36
+ end repeat
37
+ end tell
38
+ return "none"
39
+ end run
40
+ APPLESCRIPT
41
+ }
42
+
43
+ # Capture a fresh bearer token from the signed-in browser tab.
44
+ outlook_token_refresh() {
45
+ local timeout="${1:-55}" tmp rc token dir
46
+ dir="$OUTLOOK_FPX_DIR"
47
+ mkdir -p "$dir" || return 1
48
+ chmod 700 "$dir" 2>/dev/null
49
+
50
+ command -v fpx >/dev/null 2>&1 || {
51
+ echo "fpx not found — npm i -g @fetchproxy/cli" >&2; return 1; }
52
+
53
+ tmp="$(mktemp "${TMPDIR:-/tmp}/outlook-cap.XXXXXX")" || return 1
54
+
55
+ echo "capturing token from ${OUTLOOK_CAPTURE_HOST} (reloading your Outlook tab)…" >&2
56
+ fpx capture "authorization@${OUTLOOK_CAPTURE_HOST}" \
57
+ --capture-timeout "$timeout" -p "$OUTLOOK_FPX_PROFILE" >"$tmp" 2>"$tmp.err" &
58
+ local cappid=$!
59
+ sleep 3
60
+ _outlook_poke_tab
61
+ wait "$cappid"; rc=$?
62
+
63
+ if [ "$rc" -ne 0 ] || [ ! -s "$tmp" ]; then
64
+ echo "capture failed (exit $rc). Is an Outlook tab open and signed in?" >&2
65
+ sed -n '1,3p' "$tmp.err" >&2 2>/dev/null
66
+ rm -f "$tmp" "$tmp.err"; return 1
67
+ fi
68
+
69
+ token="$(OUTLOOK_CAP_FILE="$tmp" OUTLOOK_CAP_HOST="$OUTLOOK_CAPTURE_HOST" python3 -c '
70
+ import json, os, sys
71
+ d = json.load(open(os.environ["OUTLOOK_CAP_FILE"]))
72
+ v = d.get("authorization@" + os.environ["OUTLOOK_CAP_HOST"])
73
+ if not v:
74
+ vals = [x for x in d.values() if isinstance(x, str) and x.strip()]
75
+ v = vals[0] if vals else ""
76
+ sys.stdout.write(v.strip())
77
+ ')"
78
+ rm -f "$tmp" "$tmp.err"
79
+
80
+ case "$token" in
81
+ Bearer\ *) : ;;
82
+ "") echo "no authorization header captured — reload Outlook and retry" >&2; return 1 ;;
83
+ *) token="Bearer $token" ;;
84
+ esac
85
+
86
+ local rc_file; rc_file="$(_outlook_curlrc)"
87
+ umask 077
88
+ printf 'header = "Authorization: %s"\n' "$token" > "$rc_file" || return 1
89
+ chmod 600 "$rc_file" 2>/dev/null
90
+ echo "token stored in $rc_file ($(outlook_token_expiry))" >&2
91
+ }
92
+
93
+ # Print the token's expiry (non-secret claim) without revealing the token.
94
+ outlook_token_expiry() {
95
+ local rc_file; rc_file="$(_outlook_curlrc)"
96
+ [ -f "$rc_file" ] || { echo "no token"; return 1; }
97
+ OUTLOOK_RC="$rc_file" python3 -c '
98
+ import base64, json, os, re, sys, datetime
99
+ txt = open(os.environ["OUTLOOK_RC"]).read()
100
+ m = re.search(r"Authorization: Bearer ([A-Za-z0-9._-]+)", txt)
101
+ if not m: print("unparseable"); sys.exit(1)
102
+ p = m.group(1).split(".")
103
+ if len(p) < 2: print("not a jwt"); sys.exit(1)
104
+ s = p[1] + "=" * (-len(p[1]) % 4)
105
+ try:
106
+ exp = json.loads(base64.urlsafe_b64decode(s)).get("exp")
107
+ except Exception:
108
+ print("undecodable"); sys.exit(1)
109
+ if not exp: print("no exp"); sys.exit(1)
110
+ now = datetime.datetime.now(datetime.timezone.utc).timestamp()
111
+ left = int(exp - now)
112
+ when = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
113
+ print(f"expires {when}" + (f", {left//3600}h{(left%3600)//60}m left" if left > 0 else ", EXPIRED"))
114
+ '
115
+ }
116
+
117
+ # NOTE: never name a local `path` or `status` here. In zsh `path` is tied to the
118
+ # PATH array (assigning it wipes PATH for the rest of the function, so even
119
+ # `mktemp` stops resolving) and `status` is read-only. Both are harmless under
120
+ # bash, so this only shows up when the file is sourced from a zsh shell.
121
+ _outlook_request() {
122
+ local method="$1" req_path="$2"; shift 2
123
+ local rc_file; rc_file="$(_outlook_curlrc)"
124
+ [ -f "$rc_file" ] || { echo "no token — run: outlook_token_refresh" >&2; return 1; }
125
+
126
+ local http_status tmp
127
+ tmp="$(mktemp "${TMPDIR:-/tmp}/outlook-resp.XXXXXX")" || return 1
128
+ # Args in an array so nothing is re-split; token stays in the -K file.
129
+ local -a args
130
+ args=(-s -K "$rc_file" -X "$method" -H 'Accept: application/json'
131
+ -o "$tmp" -w '%{http_code}' --max-time 60)
132
+ http_status="$(curl "${args[@]}" "$@" "${OUTLOOK_API}${req_path}")" || {
133
+ echo "curl failed" >&2; rm -f "$tmp"; return 1; }
134
+
135
+ case "$http_status" in
136
+ 2*) cat "$tmp"; rm -f "$tmp"; return 0 ;;
137
+ 401) echo "401 — token expired or revoked. Run: outlook_token_refresh" >&2
138
+ rm -f "$tmp"; return 4 ;;
139
+ *) echo "HTTP $http_status" >&2; sed -n '1,5p' "$tmp" >&2; rm -f "$tmp"; return 4 ;;
140
+ esac
141
+ }
142
+
143
+ # outlook_get '<path>' [extra curl args...]
144
+ outlook_get() { local p="$1"; shift; _outlook_request GET "$p" "$@"; }
145
+
146
+ # outlook_post '<path>' '<json>' [extra curl args...]
147
+ outlook_post() {
148
+ local p="$1" body="$2"; shift 2
149
+ _outlook_request POST "$p" -H 'Content-Type: application/json' -d "$body" "$@"
150
+ }
151
+
152
+ # outlook_patch '<path>' '<json>' [extra curl args...]
153
+ outlook_patch() {
154
+ local p="$1" body="$2"; shift 2
155
+ _outlook_request PATCH "$p" -H 'Content-Type: application/json' -d "$body" "$@"
156
+ }
157
+
158
+ # Plain-text bodies instead of HTML — ~3-9x smaller. Use for anything you read.
159
+ outlook_get_text() { local p="$1"; shift; outlook_get "$p" -H 'Prefer: outlook.body-content-type="text"' "$@"; }