iris-mcp 0.1.0__tar.gz

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,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ .token_cache.json
4
+ audit.log
5
+ DISABLED
6
+
7
+ # transitional scaffolding, superseded by git history
8
+ patch_iris.py
9
+ server.py.orig
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-08-21
4
+
5
+ First published release.
6
+
7
+ - Packaged for PyPI as `iris-mcp` with an `iris-mcp` console script, and
8
+ registered with the MCP registry as `io.github.SuperAngryMonkey/iris`.
9
+ - Default draft folder is now `AI Drafts`. It was previously a name meaningful
10
+ only to the author's own setup, which would have created an oddly-named
11
+ folder in a stranger's mailbox.
12
+ - README rewritten around the actual design claim: the token carries
13
+ `Mail.ReadWrite` and never `Mail.Send`, so sending is absent rather than
14
+ merely disallowed. Setup now leads with registering your own Entra app,
15
+ because there is no shared app registration.
16
+
17
+ ### Earlier, unreleased
18
+
19
+ - Split sign-in into `iris_login` / `iris_login_finish` so the device-code flow
20
+ returns its URL immediately instead of blocking.
21
+ - `iris_auth_status` reads identity from the MSAL account and probes Graph
22
+ in-scope, rather than calling `/me`.
23
+ - Fixed bare strings passed as `to`/`cc`/`bcc` being iterated character by
24
+ character. MCP clients were unaffected — the schema forces arrays — but
25
+ direct callers were not.
26
+ - `iris_update_draft` and `iris_delete_draft` exercised against Graph for the
27
+ first time; full create/update/list/delete cycle verified.
@@ -0,0 +1,179 @@
1
+ # iris — Handoff
2
+
3
+ > **Read this first.** Single pickup point for the draft-only M365 mail MCP.
4
+ > Just continue the work — no re-introduction needed.
5
+
6
+ ---
7
+
8
+ ## TL;DR — state in five lines
9
+ 1. iris is **built, registered, signed in, and working end to end.**
10
+ 2. It composes into the **Cyrano** mail folder. It **cannot send** — no Mail.Send scope.
11
+ 3. A real draft to dean@iothings.ai was created and read back on 2026-07-23. Ghost sent it.
12
+ 4. **Two bugs are live in the public repo.** `iris_login()` can never succeed.
13
+ 5. Fix those first. Everything else is polish.
14
+
15
+ ---
16
+
17
+ ## What / where
18
+ - **iris = draft-only Microsoft 365 mail MCP.** Writes into Outlook, stops there.
19
+ A human reads the draft and presses Send.
20
+ - Local: `~/Projects/iris` on Mac-studio. Public: github.com/SuperAngryMonkey/iris (MIT).
21
+ - Registered in Claude Desktop as `iris`, alongside christian / ferryman / tupperware.
22
+ - Named for Iris, the other messenger of the gods — sibling to [hermes], ferryman, obol, minos.
23
+
24
+ ## Status: WORKING
25
+ Verified on 2026-07-23 by an actual draft appearing in the mailbox, not by the
26
+ server starting cleanly:
27
+ - `iris_create_draft(...)` -> created in **Cyrano** (folder auto-created on first use)
28
+ - `iris_list_drafts()` -> read it back
29
+ - Token carries `Mail.ReadWrite openid profile email`. **No Mail.Send.**
30
+
31
+ ## Entra registration — DONE, do not redo
32
+ | item | value |
33
+ |------|-------|
34
+ | App name | `iris`, single tenant, 800 Pound Gorilla Inc. |
35
+ | Client ID | `cf1473d7-9c86-4833-9d88-d9f91c120546` |
36
+ | Tenant ID | `cc06d355-c099-4a61-8aae-61973e4eb27e` |
37
+ | Client secret | **none, deliberately** — public client, device code |
38
+ | Allow public client flows | Enabled |
39
+ | Graph permissions | `Mail.ReadWrite` (Delegated) + default `User.Read` |
40
+
41
+ Neither ID is a secret. The absence of a secret, and of `Mail.Send`, is the design.
42
+
43
+ ## Claude Desktop config — DONE
44
+ Already patched into `claude_desktop_config.json` (backup at
45
+ `claude_desktop_config.json.bak-iris`):
46
+
47
+ "iris": {
48
+ "command": "/Users/<you>/Projects/iris/.venv/bin/python",
49
+ "args": ["/Users/<you>/Projects/iris/server.py"],
50
+ "env": {
51
+ "IRIS_CLIENT_ID": "cf1473d7-9c86-4833-9d88-d9f91c120546",
52
+ "IRIS_TENANT_ID": "cc06d355-c099-4a61-8aae-61973e4eb27e",
53
+ "IRIS_DRAFT_FOLDER": "Cyrano"
54
+ }
55
+ }
56
+
57
+ Token cache lives at `.token_cache.json` (mode 600, gitignored) and renews
58
+ silently until it lapses — roughly 90 days idle, or on a password change or
59
+ Conditional Access shift.
60
+
61
+ ---
62
+
63
+ ## KNOWN BUGS — start here
64
+
65
+ ### 1. `iris_login()` cannot work
66
+ It calls `initiate_device_flow()` and then immediately
67
+ `acquire_token_by_device_flow()`, which **blocks until the user authenticates**.
68
+ The `user_code` is captured but never returned until after that call finishes —
69
+ so the human never sees the code they are supposed to enter, and the flow times
70
+ out after ~15 minutes.
71
+
72
+ This is public. The README tells people to call `iris_login()` as setup step 4.
73
+ Anyone who clones the repo hits a dead end at the first step.
74
+
75
+ **Fix:** split into two tools sharing a module-level flow variable —
76
+ `iris_login_start()` initiates and returns the verification URL + code
77
+ immediately; `iris_login_finish()` performs the blocking exchange and writes
78
+ the cache.
79
+
80
+ **Workaround used to sign in the first time:** a two-step script run through
81
+ christian that writes to the same `.token_cache.json` the server reads. See
82
+ `docs/AS-BUILT.md`.
83
+
84
+ ### 2. `iris_auth_status()` returns 403
85
+ It calls Graph `/me`, which requires `User.Read`. The token only requests
86
+ `Mail.ReadWrite`, so Graph replies `Authorization_RequestDenied`. Sign-in is
87
+ fine; the status tool is wrong.
88
+
89
+ **Fix:** drop the `/me` call and read the username from the MSAL account object
90
+ (`app.get_accounts()[0]["username"]`). Preferable to adding `User.Read` to
91
+ SCOPES — keeping the grant minimal is the entire point of this project.
92
+
93
+ Until fixed, use `iris_list_drafts()` as the health check.
94
+
95
+ ---
96
+
97
+ ## Tools
98
+ | tool | what it does |
99
+ |------|--------------|
100
+ | `iris_login()` | **BROKEN** — see above |
101
+ | `iris_auth_status()` | **BROKEN (403)** — see above |
102
+ | `iris_create_draft(to, subject, body, cc, bcc, html, reply_to_message_id)` | works; writes to Cyrano, does not send |
103
+ | `iris_list_drafts(limit)` | works |
104
+ | `iris_update_draft(draft_id, ...)` | untested against Graph |
105
+ | `iris_delete_draft(draft_id, confirm)` | untested; needs confirm=true |
106
+
107
+ ## Where drafts land
108
+ Top-level folder named by `IRIS_DRAFT_FOLDER` (currently `Cyrano`), created on
109
+ first use. Empty string falls back to Drafts.
110
+
111
+ They are genuine drafts and Outlook sends them normally, but they **do not
112
+ appear in the Drafts view** — look in the folder.
113
+
114
+ Replies are special: Graph `createReply` always creates in Drafts, so iris moves
115
+ the message afterwards, and **a move assigns a new message id**. The returned id
116
+ will not match the one createReply produced.
117
+
118
+ ## Containment
119
+ - **No send capability** — `Mail.ReadWrite` only. Structural, not a rule.
120
+ - `recipients.allow` — address/domain allowlist; empty or absent permits all.
121
+ - `audit.log` — every draft, update, delete, login.
122
+ - Kill switch — `touch DISABLED`, or `IRIS_DISABLED=1`.
123
+ - Confirm gate — deletion requires `confirm=true`, set only on explicit human ok.
124
+
125
+ ## Next actions, in order
126
+ 1. **Fix `iris_login()`** (split start/finish). Public repo, first-step blocker.
127
+ 2. **Fix `iris_auth_status()`** (MSAL account, not Graph `/me`).
128
+ 3. Update README with a Known Issues section — currently it documents a broken
129
+ setup path with no warning.
130
+ 4. Test `iris_update_draft` and `iris_delete_draft` against Graph. Never run.
131
+ 5. Decide the LICENSE copyright holder: it currently reads James B Smith III
132
+ personally, but the Entra app sits under 800 Pound Gorilla Inc.
133
+ 6. Optional: attachments, shared/delegated mailboxes, folder nesting via
134
+ `parentFolderId`.
135
+
136
+ ## Repo map
137
+ HANDOFF.md <- you are here
138
+ README.md public-facing; needs a Known Issues section
139
+ LICENSE MIT, 2026 James B Smith III
140
+ server.py the whole server, ~441 lines
141
+ requirements.txt mcp, msal, requests
142
+ recipients.allow allowlist, currently permissive
143
+ docs/AS-BUILT.md what actually ran, and the traps hit on the way
144
+ patch_iris.py one-shot folder migration, applied, gitignored
145
+ server.py.orig pre-folder snapshot, gitignored
146
+
147
+ ## Gotchas
148
+ - macOS system `python3` is **3.9.6** and cannot install `mcp` (needs 3.10+).
149
+ Use `/opt/homebrew/bin/python3.14`, same interpreter christian runs on.
150
+ Rebuild with `python3.14 -m venv --clear .venv` — `--clear` wipes in place and
151
+ avoids a recursive force-delete, which christian's gate refuses without
152
+ explicit human approval.
153
+ - christian matches dangerous-command patterns against the **whole command
154
+ string, heredoc body included** — a document that merely quotes a destructive
155
+ command trips the gate even though nothing executes.
156
+ - Entra's new permission picker: clicking a permission group's expand chevron
157
+ silently closes the whole panel and loses the selection. Filter, click
158
+ "expand all", then tick the exact child row.
159
+ - Verify by checking a **draft actually arrived**, never by checking the server
160
+ started. "It loaded" is not "it works" — that is how both bugs above survived
161
+ to first real use.
162
+
163
+ ## 2026-08-14 — both launch bugs fixed, full lifecycle verified
164
+ - Bug 1 FIXED: login split into `iris_login()` (starts device flow, stashes it
165
+ to `.pending_flow.json` mode 600, returns URL+code immediately) and
166
+ `iris_login_finish()` (bounded ~60s poll, safe to call repeatedly; stash
167
+ keeps original expiry). Not yet exercised against a real sign-in — current
168
+ token is healthy; first real test comes at next token lapse.
169
+ - Bug 2 FIXED: `iris_auth_status()` no longer calls /me. Identity read from the
170
+ MSAL account object; Graph health probed with an in-scope call
171
+ (GET /me/mailFolders?$top=1). Returns graph_ok true/false.
172
+ - Bug 3 (new, found during testing) FIXED: a bare string passed as to/cc/bcc
173
+ was iterated character-by-character. `_flatten` and `_recips` now wrap
174
+ strings. MCP clients were never affected (schema forces arrays); direct
175
+ callers were.
176
+ - update/delete now TESTED against Graph: create → update → list → delete
177
+ cycle ran clean in the Cyrano folder (self-deleting test draft).
178
+ - Still open: LICENSE holder decision (James B Smith III vs 800 Pound Gorilla
179
+ Inc.) — Ghost's call.
iris_mcp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 800 Pound Gorilla Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.5
2
+ Name: iris-mcp
3
+ Version: 0.1.0
4
+ Summary: A Microsoft 365 mail MCP server that can draft but cannot send. The token has no Mail.Send scope.
5
+ Project-URL: Homepage, https://github.com/SuperAngryMonkey/iris
6
+ Project-URL: Source, https://github.com/SuperAngryMonkey/iris
7
+ Project-URL: Issues, https://github.com/SuperAngryMonkey/iris/issues
8
+ Author-email: "800 Pound Gorilla Inc." <james@bigassmonkey.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: email,graph,mcp,microsoft-365,model-context-protocol,msal,outlook
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications :: Email
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: mcp<2,>=1.2.0
22
+ Requires-Dist: msal>=1.28.0
23
+ Requires-Dist: requests>=2.31.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # iris
27
+
28
+ **A Microsoft 365 mail server for AI agents that cannot send email.**
29
+
30
+ Not "will not". Cannot. iris requests the delegated Graph scope `Mail.ReadWrite`
31
+ and never `Mail.Send`. The access token it holds has no capability to transmit a
32
+ message, so no prompt, no jailbreak and no bug in this code can make one go out.
33
+ It writes drafts into a folder in your mailbox. You open Outlook and press Send.
34
+
35
+ That is the whole design. Everything else is detail.
36
+
37
+ ---
38
+
39
+ ## Why this shape
40
+
41
+ The usual worry about giving an agent your mailbox is that it will send
42
+ something you did not sanction — to the wrong person, with the wrong tone, or
43
+ because someone talked it into doing so. The common answer is a confirmation
44
+ prompt, which is a guardrail: code that asks permission, and code can be
45
+ bypassed.
46
+
47
+ iris removes the capability instead. Microsoft Graph will reject a send attempt
48
+ made with this token, because the consent screen you approved never included
49
+ that permission. The security boundary is Microsoft's, not this program's, and
50
+ it holds even if this program is wrong.
51
+
52
+ The trade is real: a human is in the loop on every message, by construction. If
53
+ you want autonomous sending, iris is the wrong tool.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ uvx iris-mcp # run without installing
59
+ pip install iris-mcp
60
+ ```
61
+
62
+ Python 3.10+.
63
+
64
+ ## Setup
65
+
66
+ **You must register your own Entra application.** There is no shared app
67
+ registration and no hosted service — iris talks directly from your machine to
68
+ your tenant. This is deliberate: a shared app would mean trusting someone
69
+ else's client ID with access to your mail.
70
+
71
+ 1. Entra admin centre → **App registrations** → **New registration**. Single
72
+ tenant is fine. No redirect URI needed.
73
+ 2. **Authentication** → Settings → enable **Allow public client flows**. Device
74
+ code sign-in needs this. No client secret is used anywhere.
75
+ 3. **API permissions** → Microsoft Graph → **Delegated** → add
76
+ **`Mail.ReadWrite`**. Add nothing else. Do not add `Mail.Send` — if it is
77
+ present, the guarantee above is void.
78
+ 4. Copy the **Application (client) ID** and **Directory (tenant) ID**. Neither
79
+ is a secret.
80
+
81
+ Then add iris to your MCP client:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "iris": {
87
+ "command": "uvx",
88
+ "args": ["iris-mcp"],
89
+ "env": {
90
+ "IRIS_CLIENT_ID": "<application client id>",
91
+ "IRIS_TENANT_ID": "<directory tenant id>"
92
+ }
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Sign in once: call `iris_login`, open the URL, enter the code, then call
99
+ `iris_login_finish`. The token cache is written next to the server, mode 600.
100
+
101
+ ## Tools
102
+
103
+ | Tool | What it does |
104
+ |---|---|
105
+ | `iris_login` | Starts device-code sign-in, returns a URL and a code |
106
+ | `iris_login_finish` | Completes sign-in; safe to call repeatedly while you type the code |
107
+ | `iris_auth_status` | Who is signed in, which scopes, and whether Graph is reachable |
108
+ | `iris_create_draft` | Writes a draft (to/cc/bcc, subject, body or HTML, optional reply-to) |
109
+ | `iris_list_drafts` | Lists what is waiting in the draft folder |
110
+ | `iris_update_draft` | Revises a draft in place |
111
+ | `iris_delete_draft` | Deletes a draft; requires `confirm=true` |
112
+
113
+ ## Where drafts go
114
+
115
+ Into a dedicated top-level mail folder, `AI Drafts` by default
116
+ (`IRIS_DRAFT_FOLDER`). It is created on first use. Set the variable to an empty
117
+ string to use the normal Drafts folder instead.
118
+
119
+ These are real drafts and Outlook sends them normally — but because they live in
120
+ their own folder, they do **not** appear in the Drafts view. That is the point:
121
+ agent-written mail sits somewhere you have to go and look, rather than mixed in
122
+ with your own half-finished messages.
123
+
124
+ One wrinkle worth knowing: Graph's `createReply` always lands a reply in Drafts
125
+ first, so iris moves it afterwards, and a move assigns a new message id.
126
+
127
+ ## Other controls
128
+
129
+ - **Recipient allowlist** — `recipients.allow`, one address or domain per line.
130
+ Absent or empty means all recipients are permitted. Point `IRIS_ALLOWLIST`
131
+ elsewhere if you prefer.
132
+ - **Kill switch** — create a `DISABLED` file beside the server, or set
133
+ `IRIS_DISABLED=1`, and every tool refuses.
134
+ - **Audit log** — every call is appended to `audit.log` (`IRIS_AUDIT_LOG`).
135
+
136
+ ## Limits
137
+
138
+ No attachments. No shared or delegated mailboxes — `/me` only. No folder nesting
139
+ via `parentFolderId`. Sign-in is delegated device-code as a public client, so
140
+ the blast radius is exactly one mailbox: yours.
141
+
142
+ ## License
143
+
144
+ MIT — see [LICENSE](LICENSE).
145
+
146
+ <!-- mcp-name: io.github.SuperAngryMonkey/iris -->
@@ -0,0 +1,121 @@
1
+ # iris
2
+
3
+ **A Microsoft 365 mail server for AI agents that cannot send email.**
4
+
5
+ Not "will not". Cannot. iris requests the delegated Graph scope `Mail.ReadWrite`
6
+ and never `Mail.Send`. The access token it holds has no capability to transmit a
7
+ message, so no prompt, no jailbreak and no bug in this code can make one go out.
8
+ It writes drafts into a folder in your mailbox. You open Outlook and press Send.
9
+
10
+ That is the whole design. Everything else is detail.
11
+
12
+ ---
13
+
14
+ ## Why this shape
15
+
16
+ The usual worry about giving an agent your mailbox is that it will send
17
+ something you did not sanction — to the wrong person, with the wrong tone, or
18
+ because someone talked it into doing so. The common answer is a confirmation
19
+ prompt, which is a guardrail: code that asks permission, and code can be
20
+ bypassed.
21
+
22
+ iris removes the capability instead. Microsoft Graph will reject a send attempt
23
+ made with this token, because the consent screen you approved never included
24
+ that permission. The security boundary is Microsoft's, not this program's, and
25
+ it holds even if this program is wrong.
26
+
27
+ The trade is real: a human is in the loop on every message, by construction. If
28
+ you want autonomous sending, iris is the wrong tool.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ uvx iris-mcp # run without installing
34
+ pip install iris-mcp
35
+ ```
36
+
37
+ Python 3.10+.
38
+
39
+ ## Setup
40
+
41
+ **You must register your own Entra application.** There is no shared app
42
+ registration and no hosted service — iris talks directly from your machine to
43
+ your tenant. This is deliberate: a shared app would mean trusting someone
44
+ else's client ID with access to your mail.
45
+
46
+ 1. Entra admin centre → **App registrations** → **New registration**. Single
47
+ tenant is fine. No redirect URI needed.
48
+ 2. **Authentication** → Settings → enable **Allow public client flows**. Device
49
+ code sign-in needs this. No client secret is used anywhere.
50
+ 3. **API permissions** → Microsoft Graph → **Delegated** → add
51
+ **`Mail.ReadWrite`**. Add nothing else. Do not add `Mail.Send` — if it is
52
+ present, the guarantee above is void.
53
+ 4. Copy the **Application (client) ID** and **Directory (tenant) ID**. Neither
54
+ is a secret.
55
+
56
+ Then add iris to your MCP client:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "iris": {
62
+ "command": "uvx",
63
+ "args": ["iris-mcp"],
64
+ "env": {
65
+ "IRIS_CLIENT_ID": "<application client id>",
66
+ "IRIS_TENANT_ID": "<directory tenant id>"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Sign in once: call `iris_login`, open the URL, enter the code, then call
74
+ `iris_login_finish`. The token cache is written next to the server, mode 600.
75
+
76
+ ## Tools
77
+
78
+ | Tool | What it does |
79
+ |---|---|
80
+ | `iris_login` | Starts device-code sign-in, returns a URL and a code |
81
+ | `iris_login_finish` | Completes sign-in; safe to call repeatedly while you type the code |
82
+ | `iris_auth_status` | Who is signed in, which scopes, and whether Graph is reachable |
83
+ | `iris_create_draft` | Writes a draft (to/cc/bcc, subject, body or HTML, optional reply-to) |
84
+ | `iris_list_drafts` | Lists what is waiting in the draft folder |
85
+ | `iris_update_draft` | Revises a draft in place |
86
+ | `iris_delete_draft` | Deletes a draft; requires `confirm=true` |
87
+
88
+ ## Where drafts go
89
+
90
+ Into a dedicated top-level mail folder, `AI Drafts` by default
91
+ (`IRIS_DRAFT_FOLDER`). It is created on first use. Set the variable to an empty
92
+ string to use the normal Drafts folder instead.
93
+
94
+ These are real drafts and Outlook sends them normally — but because they live in
95
+ their own folder, they do **not** appear in the Drafts view. That is the point:
96
+ agent-written mail sits somewhere you have to go and look, rather than mixed in
97
+ with your own half-finished messages.
98
+
99
+ One wrinkle worth knowing: Graph's `createReply` always lands a reply in Drafts
100
+ first, so iris moves it afterwards, and a move assigns a new message id.
101
+
102
+ ## Other controls
103
+
104
+ - **Recipient allowlist** — `recipients.allow`, one address or domain per line.
105
+ Absent or empty means all recipients are permitted. Point `IRIS_ALLOWLIST`
106
+ elsewhere if you prefer.
107
+ - **Kill switch** — create a `DISABLED` file beside the server, or set
108
+ `IRIS_DISABLED=1`, and every tool refuses.
109
+ - **Audit log** — every call is appended to `audit.log` (`IRIS_AUDIT_LOG`).
110
+
111
+ ## Limits
112
+
113
+ No attachments. No shared or delegated mailboxes — `/me` only. No folder nesting
114
+ via `parentFolderId`. Sign-in is delegated device-code as a public client, so
115
+ the blast radius is exactly one mailbox: yours.
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE).
120
+
121
+ <!-- mcp-name: io.github.SuperAngryMonkey/iris -->
@@ -0,0 +1,75 @@
1
+ # iris — as-built
2
+
3
+ What actually ran on 2026-07-23, in order, including what went wrong.
4
+
5
+ ## 1. Scaffold
6
+ Authored `server.py` locally, syntax-checked, shipped to `~/Projects/iris` via a
7
+ christian heredoc, verified by sha256 match on both sides (393 lines at that
8
+ point). Modeled on christian's skeleton: single server.py, requirements.txt,
9
+ allowlist file, audit log, DISABLED kill switch, confirm gate.
10
+
11
+ ## 2. Venv — first attempt failed
12
+ `python3 -m venv` picked up macOS system Python **3.9.6**. `pip install mcp`
13
+ failed with "Could not find a version that satisfies the requirement mcp>=1.2.0
14
+ (from versions: none)" — mcp needs 3.10+. Rebuilt against
15
+ `/opt/homebrew/bin/python3.14`. Result: mcp 1.28.1, msal 1.37.0, requests 2.34.2.
16
+
17
+ Note: the rebuild initially used a recursive force-delete, which christian's
18
+ dangerous-pattern gate correctly refused. Used `venv --clear` instead — same
19
+ effect, no gate, no human approval needed for a routine operation.
20
+
21
+ ## 3. Drafts folder
22
+ Ghost asked for drafts to land in a dedicated folder. Applied as an
23
+ all-or-nothing patch script (10 anchored replacements, tested against a local
24
+ byte-identical copy first, then applied on the Mac and confirmed identical by
25
+ sha256). Folder name went in as "Cirano", corrected to **Cyrano** before any
26
+ Graph call — so no stray folder was ever created in the mailbox.
27
+
28
+ ## 4. Publish
29
+ git init, MIT license, README, pushed to github.com/SuperAngryMonkey/iris.
30
+ Pre-publish audit: scanned the committed tree for emails, IPs, tailnet names and
31
+ local usernames. Found and genericized `/Users/jamessmith/...` paths in
32
+ HANDOFF.md, and replaced real domains in `recipients.allow` with example ones —
33
+ one of them was a third party's and had no business being in a public repo.
34
+
35
+ ## 5. Entra registration
36
+ Done through the browser. App `iris`, single tenant. Allow public client flows
37
+ -> Enabled (this lives under **Authentication (Preview) -> Settings** in the new
38
+ portal, not the classic Add-a-platform flow). Delegated `Mail.ReadWrite` added.
39
+
40
+ **Trap:** the first permission attempt was lost because clicking the Mail
41
+ group's expand chevron silently closed the entire Request-API-permissions panel.
42
+ The working path is: filter -> "expand all" -> tick the exact child row.
43
+ `Mail.ReadWrite` is "Read and write access to user mail";
44
+ `Mail.ReadWrite.Shared` is "user and shared mail" and is the wrong one.
45
+
46
+ ## 6. Desktop config
47
+ Patched `claude_desktop_config.json` by loading and re-dumping JSON rather than
48
+ editing text, so malforming it was not possible. Backup at
49
+ `claude_desktop_config.json.bak-iris`. Re-parsed after writing to prove validity.
50
+ mcpServers went from [christian, ferryman, tupperware] to
51
+ [christian, ferryman, iris, tupperware].
52
+
53
+ ## 7. Sign-in — revealed bug #1
54
+ `iris_login()` blocks before surfacing the device code, so it can never be
55
+ completed. Worked around with two christian-run scripts against the same
56
+ `.token_cache.json` the server reads:
57
+
58
+ 1. `initiate_device_flow()`, persist the flow to `.device_flow.json`, print the
59
+ verification URL and user code, return immediately.
60
+ 2. Human approves in a browser. Then `acquire_token_by_device_flow(flow)`,
61
+ write the cache at mode 600, delete the stashed flow.
62
+
63
+ Result: signed in as james@bigassmonkey.com, scopes
64
+ `Mail.ReadWrite openid profile email`. No Mail.Send.
65
+
66
+ ## 8. First real use — revealed bug #2 and proved the thing works
67
+ `iris_auth_status()` returned Graph 403 `Authorization_RequestDenied` — it calls
68
+ `/me`, which needs `User.Read`, and the token only carries `Mail.ReadWrite`.
69
+
70
+ `iris_create_draft(...)` then succeeded: draft to dean@iothings.ai created in a
71
+ newly auto-created **Cyrano** folder, and `iris_list_drafts()` read it back
72
+ (created 2026-07-23T18:00:29Z). Ghost opened it in Outlook and sent it.
73
+
74
+ That is the first and only meaningful verification: a draft that arrived, not a
75
+ server that started.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "iris-mcp"
7
+ version = "0.1.0"
8
+ description = "A Microsoft 365 mail MCP server that can draft but cannot send. The token has no Mail.Send scope."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "800 Pound Gorilla Inc.", email = "james@bigassmonkey.com" }]
13
+ keywords = ["mcp", "model-context-protocol", "microsoft-365", "outlook", "email", "msal", "graph"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Communications :: Email",
23
+ ]
24
+ dependencies = ["mcp>=1.2.0,<2", "msal>=1.28.0", "requests>=2.31.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/SuperAngryMonkey/iris"
28
+ Source = "https://github.com/SuperAngryMonkey/iris"
29
+ Issues = "https://github.com/SuperAngryMonkey/iris/issues"
30
+
31
+ [project.scripts]
32
+ iris-mcp = "server:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ only-include = ["server.py"]
36
+ sources = ["."]
@@ -0,0 +1,5 @@
1
+ # iris recipient allowlist - one address or domain per line.
2
+ # If this file is empty or absent, ALL recipients are permitted.
3
+ # Uncomment and edit to restrict.
4
+ #example.com
5
+ #someone@example.org
@@ -0,0 +1,3 @@
1
+ mcp>=1.2.0,<2
2
+ msal>=1.28.0
3
+ requests>=2.31.0
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
+ "name": "io.github.SuperAngryMonkey/iris",
4
+ "description": "Microsoft 365 mail MCP that can draft but cannot send: the token carries Mail.ReadWrite and never Mail.Send.",
5
+ "status": "active",
6
+ "repository": {
7
+ "url": "https://github.com/SuperAngryMonkey/iris",
8
+ "source": "github"
9
+ },
10
+ "version": "0.1.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "registryBaseUrl": "https://pypi.org",
15
+ "identifier": "iris-mcp",
16
+ "version": "0.1.0",
17
+ "transport": { "type": "stdio" },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "IRIS_CLIENT_ID",
21
+ "description": "Application (client) ID of YOUR OWN Entra app registration. There is no shared app; you register one.",
22
+ "isRequired": true,
23
+ "isSecret": false
24
+ },
25
+ {
26
+ "name": "IRIS_TENANT_ID",
27
+ "description": "Directory (tenant) ID. Defaults to 'organizations'.",
28
+ "isRequired": false,
29
+ "isSecret": false
30
+ },
31
+ {
32
+ "name": "IRIS_DRAFT_FOLDER",
33
+ "description": "Mail folder drafts are written to, created on first use. Defaults to 'AI Drafts'. Empty string uses Drafts.",
34
+ "isRequired": false,
35
+ "isSecret": false
36
+ },
37
+ {
38
+ "name": "IRIS_ALLOWLIST",
39
+ "description": "Path to a recipient allowlist file. If absent or empty, all recipients are permitted.",
40
+ "isRequired": false,
41
+ "isSecret": false
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ iris — a draft-only Microsoft 365 mail MCP.
4
+
5
+ The other messenger of the gods. Composes mail into your Outlook Drafts
6
+ folder and stops there. A human opens Outlook, reads it, and presses Send.
7
+
8
+ Containment (heimdall doctrine applied to mail):
9
+ - NO SEND CAPABILITY the app requests Mail.ReadWrite ONLY, never Mail.Send.
10
+ This is structural: the token cannot send mail, so no
11
+ bug, loop, or bad instruction can put mail in flight.
12
+ - DELEGATED AUTH public client + device code. No client secret on disk,
13
+ no admin consent, blast radius = this mailbox only.
14
+ - RECIPIENT ALLOWLIST if recipients.allow is present and non-empty, drafts to
15
+ anything outside it are refused.
16
+ - AUDIT LOG every draft written appended to audit.log.
17
+ - KILL SWITCH a DISABLED file (or IRIS_DISABLED=1) blocks everything.
18
+ - CONFIRM GATE deleting a draft requires confirm=true, which the
19
+ assistant must only set after explicit human ok.
20
+
21
+ Setup (one time, in Entra ID):
22
+ 1. Register an application. Single tenant is fine.
23
+ 2. Authentication -> Add platform -> Mobile and desktop -> check the
24
+ "https://login.microsoftonline.com/common/oauth2/nativeclient" redirect,
25
+ and set "Allow public client flows" = Yes.
26
+ 3. API permissions -> Microsoft Graph -> Delegated -> Mail.ReadWrite.
27
+ Do NOT add Mail.Send. That omission is the safety property.
28
+ 4. Export IRIS_CLIENT_ID and IRIS_TENANT_ID, then call iris_login().
29
+ """
30
+ import json
31
+ import os
32
+ import re
33
+ import time
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+
37
+ import msal
38
+ import requests
39
+ from mcp.server.fastmcp import FastMCP
40
+
41
+ HERE = Path(__file__).resolve().parent
42
+ ALLOWLIST_FILE = Path(os.environ.get("IRIS_ALLOWLIST", HERE / "recipients.allow"))
43
+ AUDIT_LOG = Path(os.environ.get("IRIS_AUDIT_LOG", HERE / "audit.log"))
44
+ CACHE_FILE = Path(os.environ.get("IRIS_TOKEN_CACHE", HERE / ".token_cache.json"))
45
+ FLOW_FILE = Path(os.environ.get("IRIS_FLOW_FILE", HERE / ".pending_flow.json"))
46
+ DISABLED_FILE = HERE / "DISABLED"
47
+ # Drafts are staged here instead of the Drafts folder. Blank = use Drafts.
48
+ DRAFT_FOLDER = os.environ.get("IRIS_DRAFT_FOLDER", "AI Drafts")
49
+
50
+ CLIENT_ID = os.environ.get("IRIS_CLIENT_ID", "")
51
+ TENANT_ID = os.environ.get("IRIS_TENANT_ID", "organizations")
52
+ AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
53
+
54
+ # Mail.ReadWrite ONLY. Adding Mail.Send here would defeat the entire design.
55
+ SCOPES = ["Mail.ReadWrite"]
56
+
57
+ GRAPH = "https://graph.microsoft.com/v1.0"
58
+ HTTP_TIMEOUT = 30
59
+ MAX_BODY = 500_000
60
+
61
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
62
+
63
+ mcp = FastMCP("iris")
64
+
65
+
66
+ # ----------------------------------------------------------------- plumbing
67
+
68
+ def _now() -> str:
69
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
70
+
71
+
72
+ def _disabled() -> bool:
73
+ return DISABLED_FILE.exists() or os.environ.get("IRIS_DISABLED") == "1"
74
+
75
+
76
+ def _audit(action: str, detail: str) -> None:
77
+ try:
78
+ with AUDIT_LOG.open("a", encoding="utf-8") as fh:
79
+ fh.write(f"{_now()}\t{action}\t{detail}\n")
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def _load_allowlist() -> list[str]:
85
+ if not ALLOWLIST_FILE.exists():
86
+ return []
87
+ entries = []
88
+ for line in ALLOWLIST_FILE.read_text(encoding="utf-8").splitlines():
89
+ line = line.split("#", 1)[0].strip().lower()
90
+ if line:
91
+ entries.append(line)
92
+ return entries
93
+
94
+
95
+ def _check_recipients(addrs: list[str]) -> str | None:
96
+ """Return an error string if any address is outside the allowlist."""
97
+ allow = _load_allowlist()
98
+ if not allow:
99
+ return None
100
+ bad = []
101
+ for a in addrs:
102
+ a = a.strip().lower()
103
+ domain = a.rsplit("@", 1)[-1]
104
+ if a not in allow and domain not in allow and f"@{domain}" not in allow:
105
+ bad.append(a)
106
+ if bad:
107
+ return (
108
+ f"recipients not in {ALLOWLIST_FILE.name}: {', '.join(bad)}. "
109
+ "Add them to the allowlist or clear the file to allow all."
110
+ )
111
+ return None
112
+
113
+
114
+ def _cache() -> msal.SerializableTokenCache:
115
+ cache = msal.SerializableTokenCache()
116
+ if CACHE_FILE.exists():
117
+ cache.deserialize(CACHE_FILE.read_text(encoding="utf-8"))
118
+ return cache
119
+
120
+
121
+ def _save_cache(cache: msal.SerializableTokenCache) -> None:
122
+ if cache.has_state_changed:
123
+ CACHE_FILE.write_text(cache.serialize(), encoding="utf-8")
124
+ try:
125
+ CACHE_FILE.chmod(0o600)
126
+ except OSError:
127
+ pass
128
+
129
+
130
+ def _app(cache: msal.SerializableTokenCache) -> msal.PublicClientApplication:
131
+ return msal.PublicClientApplication(
132
+ CLIENT_ID, authority=AUTHORITY, token_cache=cache
133
+ )
134
+
135
+
136
+ def _token() -> tuple[str | None, str | None]:
137
+ """Return (access_token, error)."""
138
+ if not CLIENT_ID:
139
+ return None, "IRIS_CLIENT_ID is not set. See the setup notes in server.py."
140
+ cache = _cache()
141
+ app = _app(cache)
142
+ accounts = app.get_accounts()
143
+ if not accounts:
144
+ return None, "not signed in — run iris_login() first"
145
+ result = app.acquire_token_silent(SCOPES, account=accounts[0])
146
+ _save_cache(cache)
147
+ if not result or "access_token" not in result:
148
+ return None, "token expired or revoked — run iris_login() again"
149
+ return result["access_token"], None
150
+
151
+
152
+ def _graph(method: str, path: str, token: str, **kw) -> tuple[dict, int]:
153
+ url = path if path.startswith("http") else f"{GRAPH}{path}"
154
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
155
+ headers.update(kw.pop("headers", {}))
156
+ resp = requests.request(method, url, headers=headers, timeout=HTTP_TIMEOUT, **kw)
157
+ try:
158
+ body = resp.json() if resp.content else {}
159
+ except ValueError:
160
+ body = {"raw": resp.text[:2000]}
161
+ return body, resp.status_code
162
+
163
+
164
+ def _recips(addrs: list[str] | str | None) -> list[dict]:
165
+ if isinstance(addrs, str): # tolerate a bare address where a list is expected
166
+ addrs = [addrs]
167
+ return [{"emailAddress": {"address": a.strip()}} for a in (addrs or []) if a.strip()]
168
+
169
+
170
+ def _flatten(*groups) -> list[str]:
171
+ out = []
172
+ for g in groups:
173
+ if isinstance(g, str): # tolerate a bare address where a list is expected
174
+ g = [g]
175
+ for a in (g or []):
176
+ if a and a.strip():
177
+ out.append(a.strip())
178
+ return out
179
+
180
+
181
+ def _ensure_folder(token: str) -> tuple[str | None, str | None]:
182
+ """Find or create the staging mail folder. Returns (folder_id, error)."""
183
+ if not DRAFT_FOLDER:
184
+ return "drafts", None
185
+ body, code = _graph(
186
+ "GET", "/me/mailFolders?$top=100&$select=id,displayName", token
187
+ )
188
+ if code != 200:
189
+ return None, f"folder lookup failed {code}: {json.dumps(body)[:300]}"
190
+ want = DRAFT_FOLDER.strip().lower()
191
+ for f in body.get("value", []):
192
+ if (f.get("displayName") or "").strip().lower() == want:
193
+ return f.get("id"), None
194
+ made, code = _graph(
195
+ "POST", "/me/mailFolders", token, json={"displayName": DRAFT_FOLDER}
196
+ )
197
+ if code not in (200, 201):
198
+ return None, f"folder creation failed {code}: {json.dumps(made)[:300]}"
199
+ _audit("folder", f"created {DRAFT_FOLDER} id={made.get('id')}")
200
+ return made.get("id"), None
201
+
202
+
203
+ # -------------------------------------------------------------------- tools
204
+
205
+ @mcp.tool()
206
+ def iris_login() -> str:
207
+ """Start a device-code sign-in for the mailbox. Returns a URL and a code
208
+ for the human to enter in a browser; then call iris_login_finish() to
209
+ complete. Only needed once, or after the refresh token lapses."""
210
+ if _disabled():
211
+ return "iris is DISABLED (kill switch engaged)"
212
+ if not CLIENT_ID:
213
+ return "IRIS_CLIENT_ID is not set. See the setup notes in server.py."
214
+ cache = _cache()
215
+ app = _app(cache)
216
+ flow = app.initiate_device_flow(scopes=SCOPES)
217
+ if "user_code" not in flow:
218
+ return f"failed to start device flow: {json.dumps(flow)[:500]}"
219
+ FLOW_FILE.write_text(json.dumps(flow))
220
+ FLOW_FILE.chmod(0o600)
221
+ msg = flow.get("message", "")
222
+ return f"{msg}\n\nAfter entering the code, call iris_login_finish() to complete sign-in."
223
+
224
+
225
+ @mcp.tool()
226
+ def iris_login_finish() -> str:
227
+ """Complete a device-code sign-in started with iris_login(). Call after
228
+ entering the code in the browser. Waits up to ~60s; if the code has not
229
+ been entered yet, it says so and can simply be called again."""
230
+ if _disabled():
231
+ return "iris is DISABLED (kill switch engaged)"
232
+ if not CLIENT_ID:
233
+ return "IRIS_CLIENT_ID is not set. See the setup notes in server.py."
234
+ if not FLOW_FILE.exists():
235
+ return "no pending sign-in — call iris_login() first"
236
+ try:
237
+ flow = json.loads(FLOW_FILE.read_text())
238
+ except ValueError:
239
+ FLOW_FILE.unlink(missing_ok=True)
240
+ return "pending sign-in state was unreadable — call iris_login() again"
241
+ if time.time() > flow.get("expires_at", 0):
242
+ FLOW_FILE.unlink(missing_ok=True)
243
+ return "the device code expired — call iris_login() to get a new one"
244
+ # Bound the blocking poll to ~60s per call; the stashed flow keeps its
245
+ # original expiry, so calling again later still works.
246
+ flow["expires_at"] = min(flow.get("expires_at", 0), int(time.time()) + 60)
247
+ cache = _cache()
248
+ app = _app(cache)
249
+ result = app.acquire_token_by_device_flow(flow)
250
+ _save_cache(cache)
251
+ if "access_token" in result:
252
+ FLOW_FILE.unlink(missing_ok=True)
253
+ who = result.get("id_token_claims", {}).get("preferred_username", "unknown")
254
+ _audit("login", who)
255
+ return f"signed in as {who} (scopes: {' '.join(SCOPES)} — no send capability)"
256
+ if result.get("error") == "authorization_pending":
257
+ return "code not entered yet — finish it in the browser, then call iris_login_finish() again"
258
+ FLOW_FILE.unlink(missing_ok=True)
259
+ return f"sign-in failed: {result.get('error_description', json.dumps(result))[:500]}"
260
+
261
+
262
+ @mcp.tool()
263
+ def iris_auth_status() -> str:
264
+ """Report whether iris is signed in, as whom, and with what scopes."""
265
+ if _disabled():
266
+ return "iris is DISABLED (kill switch engaged)"
267
+ if not CLIENT_ID:
268
+ return "IRIS_CLIENT_ID is not set. See the setup notes in server.py."
269
+ cache = _cache()
270
+ app = _app(cache)
271
+ accounts = app.get_accounts()
272
+ if not accounts:
273
+ return "not signed in — run iris_login() first"
274
+ result = app.acquire_token_silent(SCOPES, account=accounts[0])
275
+ _save_cache(cache)
276
+ if not result or "access_token" not in result:
277
+ return "token expired or revoked — run iris_login() again"
278
+ # Cheap Graph probe within the granted scope. Deliberately NOT /me:
279
+ # reading the profile needs User.Read, which iris does not request.
280
+ _, code = _graph("GET", "/me/mailFolders?$top=1&$select=id", result["access_token"])
281
+ allow = _load_allowlist()
282
+ return json.dumps({
283
+ "signed_in_as": accounts[0].get("username"),
284
+ "scopes": SCOPES,
285
+ "graph_ok": code == 200,
286
+ "can_send": False,
287
+ "draft_folder": DRAFT_FOLDER or "Drafts",
288
+ "recipient_allowlist": allow or "(empty — all recipients permitted)",
289
+ }, indent=2)
290
+
291
+
292
+ @mcp.tool()
293
+ def iris_create_draft(
294
+ to: list[str],
295
+ subject: str,
296
+ body: str,
297
+ cc: list[str] | None = None,
298
+ bcc: list[str] | None = None,
299
+ html: bool = False,
300
+ reply_to_message_id: str | None = None,
301
+ ) -> str:
302
+ """Compose a message into the staging mail folder (IRIS_DRAFT_FOLDER,
303
+ default "AI Drafts"; created on first use). It is NOT sent — a human opens
304
+ Outlook and presses Send. Set reply_to_message_id to draft a threaded
305
+ reply to an existing message."""
306
+ if _disabled():
307
+ return "iris is DISABLED (kill switch engaged)"
308
+ if not to:
309
+ return "at least one 'to' recipient is required"
310
+ if len(body) > MAX_BODY:
311
+ return f"body too large ({len(body)} chars, max {MAX_BODY})"
312
+
313
+ everyone = _flatten(to, cc, bcc)
314
+ malformed = [a for a in everyone if not EMAIL_RE.match(a)]
315
+ if malformed:
316
+ return f"malformed addresses: {', '.join(malformed)}"
317
+ problem = _check_recipients(everyone)
318
+ if problem:
319
+ return problem
320
+
321
+ token, err = _token()
322
+ if err:
323
+ return err
324
+
325
+ content_type = "HTML" if html else "Text"
326
+
327
+ if reply_to_message_id:
328
+ draft, code = _graph("POST", f"/me/messages/{reply_to_message_id}/createReply", token)
329
+ if code not in (200, 201):
330
+ return f"createReply failed {code}: {json.dumps(draft)[:400]}"
331
+ draft_id = draft.get("id")
332
+ patch = {
333
+ "body": {"contentType": content_type, "content": body},
334
+ "toRecipients": _recips(to),
335
+ }
336
+ if subject:
337
+ patch["subject"] = subject
338
+ if cc:
339
+ patch["ccRecipients"] = _recips(cc)
340
+ if bcc:
341
+ patch["bccRecipients"] = _recips(bcc)
342
+ out, code = _graph("PATCH", f"/me/messages/{draft_id}", token, json=patch)
343
+ if code != 200:
344
+ return f"draft created but patch failed {code}: {json.dumps(out)[:400]}"
345
+ # createReply lands it in Drafts; relocate to the staging folder.
346
+ # NOTE: a move returns a NEW message id, so rebind out.
347
+ folder_id, ferr = _ensure_folder(token)
348
+ if ferr:
349
+ return ferr
350
+ if folder_id != "drafts":
351
+ moved, code = _graph(
352
+ "POST", f"/me/messages/{draft_id}/move", token,
353
+ json={"destinationId": folder_id},
354
+ )
355
+ if code not in (200, 201):
356
+ return f"drafted but move failed {code}: {json.dumps(moved)[:400]}"
357
+ out = moved
358
+ else:
359
+ payload = {
360
+ "subject": subject,
361
+ "body": {"contentType": content_type, "content": body},
362
+ "toRecipients": _recips(to),
363
+ }
364
+ if cc:
365
+ payload["ccRecipients"] = _recips(cc)
366
+ if bcc:
367
+ payload["bccRecipients"] = _recips(bcc)
368
+ folder_id, ferr = _ensure_folder(token)
369
+ if ferr:
370
+ return ferr
371
+ out, code = _graph(
372
+ "POST", f"/me/mailFolders/{folder_id}/messages", token, json=payload
373
+ )
374
+ if code not in (200, 201):
375
+ return f"draft creation failed {code}: {json.dumps(out)[:400]}"
376
+
377
+ _audit("draft", f"to={','.join(to)} subject={subject!r} id={out.get('id')}")
378
+ return json.dumps({
379
+ "status": "draft created — NOT sent",
380
+ "folder": DRAFT_FOLDER or "Drafts",
381
+ "id": out.get("id"),
382
+ "subject": out.get("subject"),
383
+ "to": [r["emailAddress"]["address"] for r in out.get("toRecipients", [])],
384
+ "webLink": out.get("webLink"),
385
+ "next": f"open the {DRAFT_FOLDER or 'Drafts'} folder in Outlook, read it, press Send",
386
+ }, indent=2)
387
+
388
+
389
+ @mcp.tool()
390
+ def iris_list_drafts(limit: int = 10) -> str:
391
+ """List recent messages sitting in the staging mail folder."""
392
+ if _disabled():
393
+ return "iris is DISABLED (kill switch engaged)"
394
+ limit = max(1, min(int(limit), 50))
395
+ token, err = _token()
396
+ if err:
397
+ return err
398
+ folder_id, ferr = _ensure_folder(token)
399
+ if ferr:
400
+ return ferr
401
+ q = (f"/me/mailFolders/{folder_id}/messages?$top={limit}"
402
+ "&$select=id,subject,toRecipients,createdDateTime,webLink"
403
+ "&$orderby=createdDateTime desc")
404
+ body, code = _graph("GET", q, token)
405
+ if code != 200:
406
+ return f"graph error {code}: {json.dumps(body)[:400]}"
407
+ items = [{
408
+ "id": m.get("id"),
409
+ "subject": m.get("subject"),
410
+ "to": [r["emailAddress"]["address"] for r in m.get("toRecipients", [])],
411
+ "created": m.get("createdDateTime"),
412
+ "webLink": m.get("webLink"),
413
+ } for m in body.get("value", [])]
414
+ return json.dumps(items, indent=2)
415
+
416
+
417
+ @mcp.tool()
418
+ def iris_update_draft(
419
+ draft_id: str,
420
+ subject: str | None = None,
421
+ body: str | None = None,
422
+ to: list[str] | None = None,
423
+ cc: list[str] | None = None,
424
+ bcc: list[str] | None = None,
425
+ html: bool = False,
426
+ ) -> str:
427
+ """Revise an existing draft in place. Only the fields you pass are changed."""
428
+ if _disabled():
429
+ return "iris is DISABLED (kill switch engaged)"
430
+ patch: dict = {}
431
+ if subject is not None:
432
+ patch["subject"] = subject
433
+ if body is not None:
434
+ if len(body) > MAX_BODY:
435
+ return f"body too large ({len(body)} chars, max {MAX_BODY})"
436
+ patch["body"] = {"contentType": "HTML" if html else "Text", "content": body}
437
+ for field, val in (("toRecipients", to), ("ccRecipients", cc), ("bccRecipients", bcc)):
438
+ if val is not None:
439
+ patch[field] = _recips(val)
440
+ if not patch:
441
+ return "nothing to update — pass at least one field"
442
+
443
+ everyone = _flatten(to, cc, bcc)
444
+ if everyone:
445
+ malformed = [a for a in everyone if not EMAIL_RE.match(a)]
446
+ if malformed:
447
+ return f"malformed addresses: {', '.join(malformed)}"
448
+ problem = _check_recipients(everyone)
449
+ if problem:
450
+ return problem
451
+
452
+ token, err = _token()
453
+ if err:
454
+ return err
455
+ out, code = _graph("PATCH", f"/me/messages/{draft_id}", token, json=patch)
456
+ if code != 200:
457
+ return f"update failed {code}: {json.dumps(out)[:400]}"
458
+ _audit("update", f"id={draft_id} fields={','.join(patch)}")
459
+ return json.dumps({
460
+ "status": "draft updated — still NOT sent",
461
+ "id": out.get("id"),
462
+ "subject": out.get("subject"),
463
+ "webLink": out.get("webLink"),
464
+ }, indent=2)
465
+
466
+
467
+ @mcp.tool()
468
+ def iris_delete_draft(draft_id: str, confirm: bool = False) -> str:
469
+ """Delete a draft. Destructive, so confirm=true is required — set it only
470
+ after the human has explicitly approved deleting this specific draft."""
471
+ if _disabled():
472
+ return "iris is DISABLED (kill switch engaged)"
473
+ if not confirm:
474
+ return ("refusing to delete without confirm=true. Ask the human first, "
475
+ "then retry with confirm=true.")
476
+ token, err = _token()
477
+ if err:
478
+ return err
479
+ out, code = _graph("DELETE", f"/me/messages/{draft_id}", token)
480
+ if code not in (200, 204):
481
+ return f"delete failed {code}: {json.dumps(out)[:400]}"
482
+ _audit("delete", f"id={draft_id}")
483
+ return f"draft {draft_id} deleted"
484
+
485
+
486
+ def main() -> None:
487
+ """Console-script entry point."""
488
+ mcp.run()
489
+
490
+
491
+ if __name__ == "__main__":
492
+ main()