msgraph-mcp-server 0.3.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.
- msgraph_mcp/__init__.py +0 -0
- msgraph_mcp/auth/__init__.py +0 -0
- msgraph_mcp/auth/cli.py +45 -0
- msgraph_mcp/auth/msal_app.py +47 -0
- msgraph_mcp/auth/token.py +32 -0
- msgraph_mcp/config.py +79 -0
- msgraph_mcp/graph/__init__.py +0 -0
- msgraph_mcp/graph/auth_provider.py +29 -0
- msgraph_mcp/graph/batch.py +206 -0
- msgraph_mcp/graph/client.py +37 -0
- msgraph_mcp/graph/errors.py +43 -0
- msgraph_mcp/graph/pagination.py +47 -0
- msgraph_mcp/graph/serialize.py +351 -0
- msgraph_mcp/graph/trimming.py +359 -0
- msgraph_mcp/server.py +61 -0
- msgraph_mcp/tools/__init__.py +35 -0
- msgraph_mcp/tools/_binary.py +48 -0
- msgraph_mcp/tools/calendar.py +585 -0
- msgraph_mcp/tools/mail_actions.py +83 -0
- msgraph_mcp/tools/mail_batch.py +220 -0
- msgraph_mcp/tools/mail_folders.py +263 -0
- msgraph_mcp/tools/mail_read.py +333 -0
- msgraph_mcp/tools/mail_rules.py +468 -0
- msgraph_mcp/tools/mail_write.py +403 -0
- msgraph_mcp/tools/teams_channels.py +223 -0
- msgraph_mcp/tools/teams_chats.py +140 -0
- msgraph_mcp/tools/teams_content.py +150 -0
- msgraph_mcp/tools/util.py +35 -0
- msgraph_mcp_server-0.3.0.dist-info/METADATA +243 -0
- msgraph_mcp_server-0.3.0.dist-info/RECORD +33 -0
- msgraph_mcp_server-0.3.0.dist-info/WHEEL +4 -0
- msgraph_mcp_server-0.3.0.dist-info/entry_points.txt +3 -0
- msgraph_mcp_server-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Trim raw Graph dicts to small agent-friendly shapes.
|
|
2
|
+
|
|
3
|
+
All trimmers accept dict-shaped input (raw Graph JSON), not msgraph-sdk
|
|
4
|
+
model objects. Tool implementations are responsible for calling
|
|
5
|
+
.serialize() or otherwise producing a plain dict before trimming.
|
|
6
|
+
|
|
7
|
+
include_raw=True returns {**trimmed, "raw": raw}.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html as _htmllib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
18
|
+
_WS_RE = re.compile(r"\s+")
|
|
19
|
+
_HOSTED_RE = re.compile(r"hostedContents/([^/\"'\s]+)/\$value")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _attach_raw(trimmed: dict, raw: dict, include_raw: bool) -> dict:
|
|
23
|
+
if include_raw:
|
|
24
|
+
return {**trimmed, "raw": raw}
|
|
25
|
+
return trimmed
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _email(rec: dict | None) -> dict | None:
|
|
29
|
+
if not rec:
|
|
30
|
+
return None
|
|
31
|
+
addr = rec.get("emailAddress") or {}
|
|
32
|
+
return {"name": addr.get("name"), "address": addr.get("address")}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _emails(recs: list[dict] | None) -> list[dict]:
|
|
36
|
+
return [e for e in (_email(r) for r in (recs or [])) if e is not None]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def trim_message(raw: dict, *, include_body: bool, include_raw: bool) -> dict:
|
|
40
|
+
body = raw.get("body") or {}
|
|
41
|
+
flag = raw.get("flag") or {}
|
|
42
|
+
trimmed: dict[str, Any] = {
|
|
43
|
+
"id": raw.get("id"),
|
|
44
|
+
"subject": raw.get("subject"),
|
|
45
|
+
"from": _email(raw.get("from")),
|
|
46
|
+
"to": _emails(raw.get("toRecipients")),
|
|
47
|
+
"cc": _emails(raw.get("ccRecipients")),
|
|
48
|
+
"received": raw.get("receivedDateTime"),
|
|
49
|
+
"sent": raw.get("sentDateTime"),
|
|
50
|
+
"snippet": raw.get("bodyPreview"),
|
|
51
|
+
"body_type": body.get("contentType") or "text",
|
|
52
|
+
"is_read": bool(raw.get("isRead")),
|
|
53
|
+
"is_draft": bool(raw.get("isDraft")),
|
|
54
|
+
"has_attachments": bool(raw.get("hasAttachments")),
|
|
55
|
+
"importance": raw.get("importance") or "normal",
|
|
56
|
+
"flag": flag.get("flagStatus") or "notFlagged",
|
|
57
|
+
"categories": list(raw.get("categories") or []),
|
|
58
|
+
"conversation_id": raw.get("conversationId"),
|
|
59
|
+
"web_link": raw.get("webLink"),
|
|
60
|
+
"parent_folder_id": raw.get("parentFolderId"),
|
|
61
|
+
"inference_classification": raw.get("inferenceClassification"),
|
|
62
|
+
}
|
|
63
|
+
if include_body:
|
|
64
|
+
trimmed["body"] = body.get("content")
|
|
65
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def trim_event(raw: dict, *, include_body: bool, include_raw: bool) -> dict:
|
|
69
|
+
start = raw.get("start") or {}
|
|
70
|
+
end = raw.get("end") or {}
|
|
71
|
+
location = raw.get("location") or {}
|
|
72
|
+
online = raw.get("onlineMeeting") or {}
|
|
73
|
+
body = raw.get("body") or {}
|
|
74
|
+
attendees_raw = raw.get("attendees") or []
|
|
75
|
+
attendees = []
|
|
76
|
+
for a in attendees_raw:
|
|
77
|
+
email = _email(a)
|
|
78
|
+
status = (a.get("status") or {}).get("response")
|
|
79
|
+
attendees.append(
|
|
80
|
+
{
|
|
81
|
+
"name": (email or {}).get("name"),
|
|
82
|
+
"address": (email or {}).get("address"),
|
|
83
|
+
"type": a.get("type"),
|
|
84
|
+
"response": status,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
trimmed: dict[str, Any] = {
|
|
88
|
+
"id": raw.get("id"),
|
|
89
|
+
"subject": raw.get("subject"),
|
|
90
|
+
"organizer": _email(raw.get("organizer")),
|
|
91
|
+
"start": {"date_time": start.get("dateTime"), "time_zone": start.get("timeZone")},
|
|
92
|
+
"end": {"date_time": end.get("dateTime"), "time_zone": end.get("timeZone")},
|
|
93
|
+
"location": location.get("displayName"),
|
|
94
|
+
"is_all_day": bool(raw.get("isAllDay")),
|
|
95
|
+
"is_cancelled": bool(raw.get("isCancelled")),
|
|
96
|
+
"is_online_meeting": bool(raw.get("isOnlineMeeting")),
|
|
97
|
+
"attendees": attendees,
|
|
98
|
+
"body_preview": raw.get("bodyPreview"),
|
|
99
|
+
"show_as": raw.get("showAs"),
|
|
100
|
+
"sensitivity": raw.get("sensitivity"),
|
|
101
|
+
"recurrence": raw.get("recurrence"),
|
|
102
|
+
"web_link": raw.get("webLink"),
|
|
103
|
+
}
|
|
104
|
+
if raw.get("isOnlineMeeting"):
|
|
105
|
+
trimmed["online_meeting_url"] = online.get("joinUrl")
|
|
106
|
+
if include_body:
|
|
107
|
+
trimmed["body"] = body.get("content")
|
|
108
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def trim_folder(raw: dict, *, include_raw: bool) -> dict:
|
|
112
|
+
trimmed = {
|
|
113
|
+
"id": raw.get("id"),
|
|
114
|
+
"display_name": raw.get("displayName"),
|
|
115
|
+
"parent_folder_id": raw.get("parentFolderId"),
|
|
116
|
+
"total_item_count": raw.get("totalItemCount"),
|
|
117
|
+
"unread_item_count": raw.get("unreadItemCount"),
|
|
118
|
+
"child_folder_count": raw.get("childFolderCount"),
|
|
119
|
+
}
|
|
120
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def trim_attachment_list(raw: dict, *, include_raw: bool) -> dict:
|
|
124
|
+
trimmed = {
|
|
125
|
+
"id": raw.get("id"),
|
|
126
|
+
"name": raw.get("name"),
|
|
127
|
+
"content_type": raw.get("contentType"),
|
|
128
|
+
"size_bytes": raw.get("size"),
|
|
129
|
+
"is_inline": bool(raw.get("isInline")),
|
|
130
|
+
}
|
|
131
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def trim_attachment_download(raw: dict, *, include_raw: bool) -> dict:
|
|
135
|
+
trimmed = {
|
|
136
|
+
"name": raw.get("name"),
|
|
137
|
+
"content_type": raw.get("contentType"),
|
|
138
|
+
"size_bytes": raw.get("size"),
|
|
139
|
+
"content_base64": raw.get("contentBytes"),
|
|
140
|
+
}
|
|
141
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def trim_user(raw: dict, *, include_raw: bool) -> dict:
|
|
145
|
+
trimmed = {
|
|
146
|
+
"id": raw.get("id"),
|
|
147
|
+
"display_name": raw.get("displayName"),
|
|
148
|
+
"user_principal_name": raw.get("userPrincipalName"),
|
|
149
|
+
"mail": raw.get("mail"),
|
|
150
|
+
"job_title": raw.get("jobTitle"),
|
|
151
|
+
}
|
|
152
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def trim_calendar(raw: dict, *, include_raw: bool) -> dict:
|
|
156
|
+
owner = raw.get("owner") or {}
|
|
157
|
+
trimmed = {
|
|
158
|
+
"id": raw.get("id"),
|
|
159
|
+
"name": raw.get("name"),
|
|
160
|
+
"owner": {"name": owner.get("name"), "address": owner.get("address")} if owner else None,
|
|
161
|
+
"can_edit": bool(raw.get("canEdit")),
|
|
162
|
+
"is_default_calendar": bool(raw.get("isDefaultCalendar")),
|
|
163
|
+
}
|
|
164
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _trim_rule_conditions(raw: dict | None) -> dict | None:
|
|
168
|
+
if not raw:
|
|
169
|
+
return None
|
|
170
|
+
return {
|
|
171
|
+
"sender_contains": raw.get("senderContains"),
|
|
172
|
+
"subject_contains": raw.get("subjectContains"),
|
|
173
|
+
"body_contains": raw.get("bodyContains"),
|
|
174
|
+
"body_or_subject_contains": raw.get("bodyOrSubjectContains"),
|
|
175
|
+
"from_addresses": _emails(raw.get("fromAddresses")),
|
|
176
|
+
"has_attachments": raw.get("hasAttachments"),
|
|
177
|
+
"categories": raw.get("categories"),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _trim_rule_actions(raw: dict | None) -> dict | None:
|
|
182
|
+
if not raw:
|
|
183
|
+
return None
|
|
184
|
+
return {
|
|
185
|
+
"move_to_folder": raw.get("moveToFolder"),
|
|
186
|
+
"copy_to_folder": raw.get("copyToFolder"),
|
|
187
|
+
"delete": raw.get("delete"),
|
|
188
|
+
"mark_as_read": raw.get("markAsRead"),
|
|
189
|
+
"mark_importance": raw.get("markImportance"),
|
|
190
|
+
"assign_categories": raw.get("assignCategories"),
|
|
191
|
+
"forward_to": _emails(raw.get("forwardTo")),
|
|
192
|
+
"stop_processing_rules": raw.get("stopProcessingRules"),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def trim_message_rule(raw: dict, *, include_raw: bool) -> dict:
|
|
197
|
+
trimmed = {
|
|
198
|
+
"id": raw.get("id"),
|
|
199
|
+
"display_name": raw.get("displayName"),
|
|
200
|
+
"sequence": raw.get("sequence"),
|
|
201
|
+
"is_enabled": bool(raw.get("isEnabled")),
|
|
202
|
+
"has_error": bool(raw.get("hasError")),
|
|
203
|
+
"is_read_only": bool(raw.get("isReadOnly")),
|
|
204
|
+
"conditions": _trim_rule_conditions(raw.get("conditions")),
|
|
205
|
+
"actions": _trim_rule_actions(raw.get("actions")),
|
|
206
|
+
}
|
|
207
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _html_to_snippet(content: str | None, content_type: str | None, *, limit: int = 280) -> str | None:
|
|
211
|
+
if not content:
|
|
212
|
+
return None
|
|
213
|
+
text = content
|
|
214
|
+
if (content_type or "").lower() == "html":
|
|
215
|
+
text = _TAG_RE.sub(" ", text)
|
|
216
|
+
text = _htmllib.unescape(text)
|
|
217
|
+
text = _WS_RE.sub(" ", text).strip()
|
|
218
|
+
if len(text) > limit:
|
|
219
|
+
text = text[:limit].rstrip() + "..."
|
|
220
|
+
return text or None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _hosted_content_refs(content: str | None) -> list[dict]:
|
|
224
|
+
if not content:
|
|
225
|
+
return []
|
|
226
|
+
# dict.fromkeys preserves first-seen order and de-dupes repeated ids.
|
|
227
|
+
return [{"hosted_content_id": hid} for hid in dict.fromkeys(_HOSTED_RE.findall(content))]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _reaction_counts(reactions: list[dict] | None) -> dict[str, int]:
|
|
231
|
+
counts: dict[str, int] = {}
|
|
232
|
+
for r in reactions or []:
|
|
233
|
+
rt = r.get("reactionType")
|
|
234
|
+
if rt:
|
|
235
|
+
counts[rt] = counts.get(rt, 0) + 1
|
|
236
|
+
return counts
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _card_texts(node: Any) -> list[str]:
|
|
240
|
+
"""Collect every "text" string in an Adaptive Card JSON tree, in order."""
|
|
241
|
+
texts: list[str] = []
|
|
242
|
+
if isinstance(node, dict):
|
|
243
|
+
for key, value in node.items():
|
|
244
|
+
if key == "text" and isinstance(value, str):
|
|
245
|
+
texts.append(value)
|
|
246
|
+
else:
|
|
247
|
+
texts.extend(_card_texts(value))
|
|
248
|
+
elif isinstance(node, list):
|
|
249
|
+
for value in node:
|
|
250
|
+
texts.extend(_card_texts(value))
|
|
251
|
+
return texts
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _card_snippet(raw_attachments: list[dict], *, limit: int = 280) -> str | None:
|
|
255
|
+
"""Build a snippet from card attachment text (app/bot posts have no body text)."""
|
|
256
|
+
texts: list[str] = []
|
|
257
|
+
for a in raw_attachments:
|
|
258
|
+
content = a.get("content")
|
|
259
|
+
if not isinstance(content, str) or "card" not in (a.get("contentType") or ""):
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
card = json.loads(content)
|
|
263
|
+
except ValueError:
|
|
264
|
+
continue
|
|
265
|
+
texts.extend(_card_texts(card))
|
|
266
|
+
joined = _WS_RE.sub(" ", " ".join(t for t in texts if t.strip())).strip()
|
|
267
|
+
if len(joined) > limit:
|
|
268
|
+
joined = joined[:limit].rstrip() + "..."
|
|
269
|
+
return joined or None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def trim_chat_message(raw: dict, *, include_body: bool, include_raw: bool) -> dict:
|
|
273
|
+
body = raw.get("body") or {}
|
|
274
|
+
from_user = raw.get("from") or {}
|
|
275
|
+
content = body.get("content")
|
|
276
|
+
content_type = body.get("contentType")
|
|
277
|
+
raw_attachments = list(raw.get("attachments") or [])
|
|
278
|
+
attachments = []
|
|
279
|
+
for a in raw_attachments:
|
|
280
|
+
entry = {
|
|
281
|
+
"id": a.get("id"),
|
|
282
|
+
"name": a.get("name"),
|
|
283
|
+
"content_type": a.get("contentType"),
|
|
284
|
+
"content_url": a.get("contentUrl"),
|
|
285
|
+
}
|
|
286
|
+
if include_body:
|
|
287
|
+
entry["content"] = a.get("content")
|
|
288
|
+
attachments.append(entry)
|
|
289
|
+
mentions = [m.get("mentionText") for m in (raw.get("mentions") or []) if m.get("mentionText")]
|
|
290
|
+
trimmed: dict[str, Any] = {
|
|
291
|
+
"id": raw.get("id"),
|
|
292
|
+
"message_type": raw.get("messageType") or "message",
|
|
293
|
+
"from": from_user.get("displayName"),
|
|
294
|
+
"from_id": from_user.get("id"),
|
|
295
|
+
"created": raw.get("createdDateTime"),
|
|
296
|
+
"last_modified": raw.get("lastModifiedDateTime"),
|
|
297
|
+
"deleted": raw.get("deletedDateTime") is not None,
|
|
298
|
+
"importance": raw.get("importance") or "normal",
|
|
299
|
+
"subject": raw.get("subject"),
|
|
300
|
+
# Bot/app posts often have a body that is just an <attachment> tag;
|
|
301
|
+
# fall back to the card attachments' text so the snippet stays useful.
|
|
302
|
+
"snippet": _html_to_snippet(content, content_type) or _card_snippet(raw_attachments),
|
|
303
|
+
"body_type": content_type or "text",
|
|
304
|
+
"attachments": attachments,
|
|
305
|
+
"mentions": mentions,
|
|
306
|
+
"hosted_content_refs": _hosted_content_refs(content),
|
|
307
|
+
"reactions": _reaction_counts(raw.get("reactions")),
|
|
308
|
+
"web_url": raw.get("webUrl"),
|
|
309
|
+
}
|
|
310
|
+
if include_body:
|
|
311
|
+
trimmed["body"] = content
|
|
312
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def trim_chat(raw: dict, *, include_raw: bool) -> dict:
|
|
316
|
+
preview = raw.get("lastMessagePreview") or {}
|
|
317
|
+
trimmed = {
|
|
318
|
+
"id": raw.get("id"),
|
|
319
|
+
"chat_type": raw.get("chatType"),
|
|
320
|
+
"topic": raw.get("topic"),
|
|
321
|
+
"members": list(raw.get("members") or []),
|
|
322
|
+
# True last-activity time (created time of the most recent message).
|
|
323
|
+
"last_message_time": preview.get("createdDateTime"),
|
|
324
|
+
# Graph's lastUpdatedDateTime: only bumped on rename/membership
|
|
325
|
+
# changes, NOT on new messages. Kept for reference, but do not treat
|
|
326
|
+
# it as last activity -- use last_message_time for that.
|
|
327
|
+
"metadata_updated": raw.get("lastUpdatedDateTime"),
|
|
328
|
+
"web_url": raw.get("webUrl"),
|
|
329
|
+
}
|
|
330
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def trim_team(raw: dict, *, include_raw: bool) -> dict:
|
|
334
|
+
trimmed = {
|
|
335
|
+
"id": raw.get("id"),
|
|
336
|
+
"display_name": raw.get("displayName"),
|
|
337
|
+
"description": raw.get("description"),
|
|
338
|
+
}
|
|
339
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def trim_channel(raw: dict, *, include_raw: bool) -> dict:
|
|
343
|
+
trimmed = {
|
|
344
|
+
"id": raw.get("id"),
|
|
345
|
+
"display_name": raw.get("displayName"),
|
|
346
|
+
"description": raw.get("description"),
|
|
347
|
+
"membership_type": raw.get("membershipType"),
|
|
348
|
+
"web_url": raw.get("webUrl"),
|
|
349
|
+
}
|
|
350
|
+
return _attach_raw(trimmed, raw, include_raw)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def trim_hosted_content_download(raw: dict, *, include_raw: bool) -> dict:
|
|
354
|
+
trimmed = {
|
|
355
|
+
"content_type": raw.get("contentType"),
|
|
356
|
+
"size_bytes": raw.get("size"),
|
|
357
|
+
"content_base64": raw.get("contentBytes"),
|
|
358
|
+
}
|
|
359
|
+
return _attach_raw(trimmed, raw, include_raw)
|
msgraph_mcp/server.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""FastMCP server entry point.
|
|
2
|
+
|
|
3
|
+
Boot order:
|
|
4
|
+
1. Validate config (fail fast if env is missing).
|
|
5
|
+
2. Build the GraphClient (lazy token acquisition — no Graph calls yet).
|
|
6
|
+
3. Build a FastMCP app, register all tools, and run on stdio.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from msgraph_mcp import config
|
|
14
|
+
from msgraph_mcp.graph.client import GraphClient
|
|
15
|
+
from msgraph_mcp.tools import register_all
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_INSTRUCTIONS = """
|
|
19
|
+
Microsoft Outlook (mail + calendar) and Microsoft Teams (read-only) via Microsoft Graph.
|
|
20
|
+
|
|
21
|
+
This server acts as the signed-in user (delegated auth). All tools that
|
|
22
|
+
touch a mailbox or calendar accept an optional `mailbox` argument (email
|
|
23
|
+
or user ID) to target shared mailboxes/calendars; omit it to use the
|
|
24
|
+
signed-in user's own mailbox.
|
|
25
|
+
|
|
26
|
+
Teams tools are read-only and act as the signed-in user: list_chats,
|
|
27
|
+
list_chat_messages, list_joined_teams, list_channels, list_channel_messages,
|
|
28
|
+
list_message_replies, and download_hosted_content. They take no `mailbox`
|
|
29
|
+
argument (delegated auth reads only your own chats; channel messages are
|
|
30
|
+
team-scoped).
|
|
31
|
+
|
|
32
|
+
Downloads: download_attachment and download_hosted_content return
|
|
33
|
+
PNG/JPEG/GIF/WebP content as a native MCP image block (viewable directly,
|
|
34
|
+
no base64 decoding needed). Pass `save_path` (a file path, or an existing
|
|
35
|
+
directory) to write the bytes to disk and get back {path, content_type,
|
|
36
|
+
size_bytes} instead — use that for non-image content or when you want a
|
|
37
|
+
file on disk. Other content without `save_path` still returns base64.
|
|
38
|
+
|
|
39
|
+
Pagination: list/search tools accept `limit` (1-100, default 25) and
|
|
40
|
+
`page_token`. Pass the returned `next_page_token` back as `page_token`
|
|
41
|
+
to fetch the next page.
|
|
42
|
+
|
|
43
|
+
Responses are trimmed by default. Pass `include_raw=true` to any tool
|
|
44
|
+
that returns objects to also get the full Graph payload.
|
|
45
|
+
""".strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_server() -> FastMCP:
|
|
49
|
+
# Fail fast on missing env (raises ConfigError before we hit the event loop)
|
|
50
|
+
config.client_id()
|
|
51
|
+
config.authority()
|
|
52
|
+
|
|
53
|
+
mcp = FastMCP(name="msgraph-mcp", instructions=_INSTRUCTIONS)
|
|
54
|
+
graph = GraphClient()
|
|
55
|
+
register_all(mcp, graph=graph)
|
|
56
|
+
return mcp
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
mcp = build_server()
|
|
61
|
+
mcp.run() # stdio transport by default
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tool registration entry point.
|
|
2
|
+
|
|
3
|
+
register_all(mcp) wires every tool into the FastMCP app. Each tool module
|
|
4
|
+
exposes a register(mcp, *, graph) function so we can keep modules small.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from msgraph_mcp.tools import (
|
|
10
|
+
calendar,
|
|
11
|
+
mail_actions,
|
|
12
|
+
mail_batch,
|
|
13
|
+
mail_folders,
|
|
14
|
+
mail_read,
|
|
15
|
+
mail_rules,
|
|
16
|
+
mail_write,
|
|
17
|
+
teams_chats,
|
|
18
|
+
teams_channels,
|
|
19
|
+
teams_content,
|
|
20
|
+
util,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_all(mcp, *, graph) -> None:
|
|
25
|
+
util.register(mcp, graph=graph)
|
|
26
|
+
mail_read.register(mcp, graph=graph)
|
|
27
|
+
mail_write.register(mcp, graph=graph)
|
|
28
|
+
mail_folders.register(mcp, graph=graph)
|
|
29
|
+
mail_actions.register(mcp, graph=graph)
|
|
30
|
+
mail_batch.register(mcp, graph=graph)
|
|
31
|
+
mail_rules.register(mcp, graph=graph)
|
|
32
|
+
calendar.register(mcp, graph=graph)
|
|
33
|
+
teams_chats.register(mcp, graph=graph)
|
|
34
|
+
teams_channels.register(mcp, graph=graph)
|
|
35
|
+
teams_content.register(mcp, graph=graph)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared helpers for tools that return downloaded binary content.
|
|
2
|
+
|
|
3
|
+
Image bytes are returned to the client as a native MCP image block
|
|
4
|
+
(mcp.server.fastmcp.utilities.types.Image) so agents see the image directly
|
|
5
|
+
instead of a base64 text payload; save_path writes bytes to disk instead and
|
|
6
|
+
returns only metadata. Non-image content without save_path falls back to the
|
|
7
|
+
legacy base64 dict.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp.utilities.types import Image
|
|
15
|
+
|
|
16
|
+
_EXT_BY_CONTENT_TYPE = {
|
|
17
|
+
"image/png": ".png",
|
|
18
|
+
"image/jpeg": ".jpg",
|
|
19
|
+
"image/gif": ".gif",
|
|
20
|
+
"image/webp": ".webp",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_image(content_type: str | None) -> bool:
|
|
25
|
+
return bool(content_type) and content_type in _EXT_BY_CONTENT_TYPE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ext_for(content_type: str | None) -> str:
|
|
29
|
+
return _EXT_BY_CONTENT_TYPE.get(content_type or "", ".bin")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def image_result(meta: dict, data: bytes, content_type: str) -> list:
|
|
33
|
+
"""Metadata dict + native MCP image block (FastMCP renders both)."""
|
|
34
|
+
return [meta, Image(data=data, format=content_type.removeprefix("image/"))]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_bytes(save_path: str, data: bytes, *, default_name: str) -> str:
|
|
38
|
+
"""Write data to save_path and return the resolved path.
|
|
39
|
+
|
|
40
|
+
save_path may be a file path, or an existing directory (default_name is
|
|
41
|
+
appended). Parent directories are created as needed.
|
|
42
|
+
"""
|
|
43
|
+
target = pathlib.Path(save_path).expanduser()
|
|
44
|
+
if target.is_dir():
|
|
45
|
+
target = target / default_name
|
|
46
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
target.write_bytes(data)
|
|
48
|
+
return str(target)
|