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.
@@ -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)