iris-mcp 0.1.0__py3-none-any.whl
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,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,6 @@
|
|
|
1
|
+
server.py,sha256=DhMni7DnJwLRuCZTWNgEByOeUd1tuzDOX-jFylVdjhM,18489
|
|
2
|
+
iris_mcp-0.1.0.dist-info/METADATA,sha256=sH3_Igoy6It0wUQqp714YjfRgR1qZDpsrGl-z3w1sYs,5599
|
|
3
|
+
iris_mcp-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
4
|
+
iris_mcp-0.1.0.dist-info/entry_points.txt,sha256=SR7mKBte3eR0B-GmMPCqm6YQJq3g1TR0G0eRLq25NHY,41
|
|
5
|
+
iris_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=EtJkg0PFkR4cVf5VTOf5D2T7b_FCErVOciGa6HmNqDg,1079
|
|
6
|
+
iris_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
server.py
ADDED
|
@@ -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()
|