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,403 @@
|
|
|
1
|
+
"""Mail write tools: send, draft, reply, reply_all, forward, update, delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from msgraph.generated.models.attachment import Attachment
|
|
9
|
+
from msgraph.generated.models.body_type import BodyType
|
|
10
|
+
from msgraph.generated.models.email_address import EmailAddress
|
|
11
|
+
from msgraph.generated.models.file_attachment import FileAttachment
|
|
12
|
+
from msgraph.generated.models.followup_flag import FollowupFlag
|
|
13
|
+
from msgraph.generated.models.followup_flag_status import FollowupFlagStatus
|
|
14
|
+
from msgraph.generated.models.importance import Importance
|
|
15
|
+
from msgraph.generated.models.item_body import ItemBody
|
|
16
|
+
from msgraph.generated.models.message import Message
|
|
17
|
+
from msgraph.generated.models.recipient import Recipient
|
|
18
|
+
from msgraph.generated.users.item.messages.item.forward.forward_post_request_body import (
|
|
19
|
+
ForwardPostRequestBody,
|
|
20
|
+
)
|
|
21
|
+
from msgraph.generated.users.item.messages.item.reply.reply_post_request_body import (
|
|
22
|
+
ReplyPostRequestBody,
|
|
23
|
+
)
|
|
24
|
+
from msgraph.generated.users.item.messages.item.reply_all.reply_all_post_request_body import (
|
|
25
|
+
ReplyAllPostRequestBody,
|
|
26
|
+
)
|
|
27
|
+
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import (
|
|
28
|
+
SendMailPostRequestBody,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
32
|
+
from msgraph_mcp.graph.errors import GraphValidationError, map_kiota_error
|
|
33
|
+
from msgraph_mcp.graph.serialize import message_to_dict
|
|
34
|
+
from msgraph_mcp.graph.trimming import trim_message
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MAX_INLINE_ATTACHMENT_BYTES = 3 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _recipient(addr: str) -> Recipient:
|
|
41
|
+
r = Recipient()
|
|
42
|
+
e = EmailAddress()
|
|
43
|
+
e.address = addr
|
|
44
|
+
r.email_address = e
|
|
45
|
+
return r
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _recipients(addrs: list[str] | None) -> list[Recipient]:
|
|
49
|
+
return [_recipient(a) for a in (addrs or [])]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _body(text: str, body_type: Literal["text", "html"]) -> ItemBody:
|
|
53
|
+
b = ItemBody()
|
|
54
|
+
b.content_type = BodyType.Text if body_type == "text" else BodyType.Html
|
|
55
|
+
b.content = text
|
|
56
|
+
return b
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_and_build_attachments(atts: list[dict] | None) -> list[Attachment]:
|
|
60
|
+
if not atts:
|
|
61
|
+
return []
|
|
62
|
+
total = 0
|
|
63
|
+
built: list[Attachment] = []
|
|
64
|
+
for spec in atts:
|
|
65
|
+
try:
|
|
66
|
+
raw = base64.b64decode(spec["content_b64"], validate=True)
|
|
67
|
+
except (KeyError, ValueError) as exc:
|
|
68
|
+
raise GraphValidationError(f"Invalid attachment content_b64: {exc}") from exc
|
|
69
|
+
total += len(raw)
|
|
70
|
+
if total > _MAX_INLINE_ATTACHMENT_BYTES:
|
|
71
|
+
raise GraphValidationError(
|
|
72
|
+
f"Attachment payload exceeds 3 MB raw limit (got {total} bytes). "
|
|
73
|
+
"Use a chunked upload (not supported in v1) for larger files."
|
|
74
|
+
)
|
|
75
|
+
fa = FileAttachment()
|
|
76
|
+
fa.name = spec.get("name") or "attachment"
|
|
77
|
+
fa.content_type = spec.get("content_type") or "application/octet-stream"
|
|
78
|
+
fa.content_bytes = raw
|
|
79
|
+
fa.odata_type = "#microsoft.graph.fileAttachment"
|
|
80
|
+
built.append(fa)
|
|
81
|
+
return built
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_message(
|
|
85
|
+
*,
|
|
86
|
+
to: list[str],
|
|
87
|
+
subject: str,
|
|
88
|
+
body: str,
|
|
89
|
+
body_type: Literal["text", "html"],
|
|
90
|
+
cc: list[str] | None = None,
|
|
91
|
+
bcc: list[str] | None = None,
|
|
92
|
+
attachments: list[dict] | None = None,
|
|
93
|
+
) -> Message:
|
|
94
|
+
m = Message()
|
|
95
|
+
m.subject = subject
|
|
96
|
+
m.body = _body(body, body_type)
|
|
97
|
+
m.to_recipients = _recipients(to)
|
|
98
|
+
m.cc_recipients = _recipients(cc)
|
|
99
|
+
m.bcc_recipients = _recipients(bcc)
|
|
100
|
+
atts = _validate_and_build_attachments(attachments)
|
|
101
|
+
if atts:
|
|
102
|
+
m.attachments = atts
|
|
103
|
+
return m
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def send_message(
|
|
107
|
+
*,
|
|
108
|
+
graph,
|
|
109
|
+
to: list[str],
|
|
110
|
+
subject: str,
|
|
111
|
+
body: str,
|
|
112
|
+
body_type: Literal["text", "html"] = "text",
|
|
113
|
+
cc: list[str] | None = None,
|
|
114
|
+
bcc: list[str] | None = None,
|
|
115
|
+
attachments: list[dict] | None = None,
|
|
116
|
+
save_to_sent_items: bool = True,
|
|
117
|
+
mailbox: str | None = None,
|
|
118
|
+
) -> dict:
|
|
119
|
+
"""Send a new message immediately.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
to: List of recipient email addresses (required, non-empty).
|
|
123
|
+
subject: Subject line.
|
|
124
|
+
body: Message body.
|
|
125
|
+
body_type: "text" or "html". Default "text".
|
|
126
|
+
cc: Optional CC list.
|
|
127
|
+
bcc: Optional BCC list.
|
|
128
|
+
attachments: Optional list of {name, content_b64, content_type}.
|
|
129
|
+
Total raw size must be <= 3 MB.
|
|
130
|
+
save_to_sent_items: Default True.
|
|
131
|
+
mailbox: Optional mailbox (defaults to signed-in user).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
{"status": "sent"}
|
|
135
|
+
"""
|
|
136
|
+
if not to:
|
|
137
|
+
raise GraphValidationError("`to` must be a non-empty list of email addresses")
|
|
138
|
+
msg = _build_message(
|
|
139
|
+
to=to, subject=subject, body=body, body_type=body_type,
|
|
140
|
+
cc=cc, bcc=bcc, attachments=attachments,
|
|
141
|
+
)
|
|
142
|
+
req = SendMailPostRequestBody()
|
|
143
|
+
req.message = msg
|
|
144
|
+
req.save_to_sent_items = save_to_sent_items
|
|
145
|
+
try:
|
|
146
|
+
await graph.mailbox(mailbox).send_mail.post(req)
|
|
147
|
+
except NotAuthenticatedError:
|
|
148
|
+
raise
|
|
149
|
+
except Exception as exc: # noqa: BLE001
|
|
150
|
+
raise map_kiota_error(exc) from exc
|
|
151
|
+
return {"status": "sent"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def create_draft(
|
|
155
|
+
*,
|
|
156
|
+
graph,
|
|
157
|
+
to: list[str],
|
|
158
|
+
subject: str,
|
|
159
|
+
body: str,
|
|
160
|
+
body_type: Literal["text", "html"] = "text",
|
|
161
|
+
cc: list[str] | None = None,
|
|
162
|
+
bcc: list[str] | None = None,
|
|
163
|
+
attachments: list[dict] | None = None,
|
|
164
|
+
mailbox: str | None = None,
|
|
165
|
+
include_raw: bool = False,
|
|
166
|
+
) -> dict:
|
|
167
|
+
"""Create a draft message (not sent). Returns the trimmed draft."""
|
|
168
|
+
msg = _build_message(
|
|
169
|
+
to=to, subject=subject, body=body, body_type=body_type,
|
|
170
|
+
cc=cc, bcc=bcc, attachments=attachments,
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
created = await graph.mailbox(mailbox).messages.post(msg)
|
|
174
|
+
except NotAuthenticatedError:
|
|
175
|
+
raise
|
|
176
|
+
except Exception as exc: # noqa: BLE001
|
|
177
|
+
raise map_kiota_error(exc) from exc
|
|
178
|
+
return trim_message(message_to_dict(created), include_body=False, include_raw=include_raw)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _reply_body(comment: str | None, to: list[str] | None) -> ReplyPostRequestBody:
|
|
182
|
+
body = ReplyPostRequestBody()
|
|
183
|
+
if comment is not None:
|
|
184
|
+
body.comment = comment
|
|
185
|
+
if to:
|
|
186
|
+
m = Message()
|
|
187
|
+
m.to_recipients = _recipients(to)
|
|
188
|
+
body.message = m
|
|
189
|
+
return body
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _reply_all_body(comment: str | None) -> ReplyAllPostRequestBody:
|
|
193
|
+
body = ReplyAllPostRequestBody()
|
|
194
|
+
if comment is not None:
|
|
195
|
+
body.comment = comment
|
|
196
|
+
return body
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _forward_body(comment: str | None, to: list[str]) -> ForwardPostRequestBody:
|
|
200
|
+
body = ForwardPostRequestBody()
|
|
201
|
+
if comment is not None:
|
|
202
|
+
body.comment = comment
|
|
203
|
+
body.to_recipients = _recipients(to)
|
|
204
|
+
return body
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def reply_message(
|
|
208
|
+
*,
|
|
209
|
+
graph,
|
|
210
|
+
message_id: str,
|
|
211
|
+
comment: str | None = None,
|
|
212
|
+
extra_to: list[str] | None = None,
|
|
213
|
+
mailbox: str | None = None,
|
|
214
|
+
) -> dict:
|
|
215
|
+
"""Reply to the sender of a message."""
|
|
216
|
+
try:
|
|
217
|
+
await (
|
|
218
|
+
graph.mailbox(mailbox)
|
|
219
|
+
.messages.by_message_id(message_id)
|
|
220
|
+
.reply.post(_reply_body(comment, extra_to))
|
|
221
|
+
)
|
|
222
|
+
except NotAuthenticatedError:
|
|
223
|
+
raise
|
|
224
|
+
except Exception as exc: # noqa: BLE001
|
|
225
|
+
raise map_kiota_error(exc) from exc
|
|
226
|
+
return {"status": "sent"}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def reply_all_message(
|
|
230
|
+
*,
|
|
231
|
+
graph,
|
|
232
|
+
message_id: str,
|
|
233
|
+
comment: str | None = None,
|
|
234
|
+
mailbox: str | None = None,
|
|
235
|
+
) -> dict:
|
|
236
|
+
"""Reply-all to a message (sender + everyone on the to/cc lines)."""
|
|
237
|
+
try:
|
|
238
|
+
await (
|
|
239
|
+
graph.mailbox(mailbox)
|
|
240
|
+
.messages.by_message_id(message_id)
|
|
241
|
+
.reply_all.post(_reply_all_body(comment))
|
|
242
|
+
)
|
|
243
|
+
except NotAuthenticatedError:
|
|
244
|
+
raise
|
|
245
|
+
except Exception as exc: # noqa: BLE001
|
|
246
|
+
raise map_kiota_error(exc) from exc
|
|
247
|
+
return {"status": "sent"}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def forward_message(
|
|
251
|
+
*,
|
|
252
|
+
graph,
|
|
253
|
+
message_id: str,
|
|
254
|
+
to: list[str],
|
|
255
|
+
comment: str | None = None,
|
|
256
|
+
mailbox: str | None = None,
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""Forward a message to new recipients."""
|
|
259
|
+
if not to:
|
|
260
|
+
raise GraphValidationError("`to` must be a non-empty list of email addresses")
|
|
261
|
+
try:
|
|
262
|
+
await (
|
|
263
|
+
graph.mailbox(mailbox)
|
|
264
|
+
.messages.by_message_id(message_id)
|
|
265
|
+
.forward.post(_forward_body(comment, to))
|
|
266
|
+
)
|
|
267
|
+
except NotAuthenticatedError:
|
|
268
|
+
raise
|
|
269
|
+
except Exception as exc: # noqa: BLE001
|
|
270
|
+
raise map_kiota_error(exc) from exc
|
|
271
|
+
return {"status": "sent"}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
_IMPORTANCE_MAP = {"low": Importance.Low, "normal": Importance.Normal, "high": Importance.High}
|
|
275
|
+
_FLAG_MAP = {
|
|
276
|
+
"notFlagged": FollowupFlagStatus.NotFlagged,
|
|
277
|
+
"flagged": FollowupFlagStatus.Flagged,
|
|
278
|
+
"complete": FollowupFlagStatus.Complete,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def update_message(
|
|
283
|
+
*,
|
|
284
|
+
graph,
|
|
285
|
+
message_id: str,
|
|
286
|
+
is_read: bool | None = None,
|
|
287
|
+
flag: Literal["notFlagged", "flagged", "complete"] | None = None,
|
|
288
|
+
importance: Literal["low", "normal", "high"] | None = None,
|
|
289
|
+
categories: list[str] | None = None,
|
|
290
|
+
parent_folder_id: str | None = None,
|
|
291
|
+
mailbox: str | None = None,
|
|
292
|
+
include_raw: bool = False,
|
|
293
|
+
) -> dict:
|
|
294
|
+
"""Patch a message's read state / flag / importance / categories.
|
|
295
|
+
|
|
296
|
+
For moving a message between folders, prefer `move_message` (clearer
|
|
297
|
+
intent). This tool patches in-place; setting parent_folder_id here
|
|
298
|
+
works but is not the primary path.
|
|
299
|
+
|
|
300
|
+
Returns the trimmed updated message.
|
|
301
|
+
"""
|
|
302
|
+
patch = Message()
|
|
303
|
+
if is_read is not None:
|
|
304
|
+
patch.is_read = is_read
|
|
305
|
+
if flag is not None:
|
|
306
|
+
if flag not in _FLAG_MAP:
|
|
307
|
+
raise GraphValidationError(f"flag must be one of {list(_FLAG_MAP)}")
|
|
308
|
+
ff = FollowupFlag()
|
|
309
|
+
ff.flag_status = _FLAG_MAP[flag]
|
|
310
|
+
patch.flag = ff
|
|
311
|
+
if importance is not None:
|
|
312
|
+
if importance not in _IMPORTANCE_MAP:
|
|
313
|
+
raise GraphValidationError(f"importance must be one of {list(_IMPORTANCE_MAP)}")
|
|
314
|
+
patch.importance = _IMPORTANCE_MAP[importance]
|
|
315
|
+
if categories is not None:
|
|
316
|
+
patch.categories = list(categories)
|
|
317
|
+
if parent_folder_id is not None:
|
|
318
|
+
patch.parent_folder_id = parent_folder_id
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
updated = await graph.mailbox(mailbox).messages.by_message_id(message_id).patch(patch)
|
|
322
|
+
except NotAuthenticatedError:
|
|
323
|
+
raise
|
|
324
|
+
except Exception as exc: # noqa: BLE001
|
|
325
|
+
raise map_kiota_error(exc) from exc
|
|
326
|
+
return trim_message(message_to_dict(updated), include_body=False, include_raw=include_raw)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def delete_message(
|
|
330
|
+
*, graph, message_id: str, mailbox: str | None = None
|
|
331
|
+
) -> dict:
|
|
332
|
+
"""Soft-delete a message (moves it to Deleted Items)."""
|
|
333
|
+
try:
|
|
334
|
+
await graph.mailbox(mailbox).messages.by_message_id(message_id).delete()
|
|
335
|
+
except NotAuthenticatedError:
|
|
336
|
+
raise
|
|
337
|
+
except Exception as exc: # noqa: BLE001
|
|
338
|
+
raise map_kiota_error(exc) from exc
|
|
339
|
+
return {"status": "deleted"}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def register(mcp, *, graph) -> None:
|
|
343
|
+
@mcp.tool(name="send_message", description=send_message.__doc__ or "")
|
|
344
|
+
async def _send(
|
|
345
|
+
to: list[str], subject: str, body: str,
|
|
346
|
+
body_type: Literal["text", "html"] = "text",
|
|
347
|
+
cc: list[str] | None = None,
|
|
348
|
+
bcc: list[str] | None = None, attachments: list[dict] | None = None,
|
|
349
|
+
save_to_sent_items: bool = True, mailbox: str | None = None,
|
|
350
|
+
):
|
|
351
|
+
return await send_message(
|
|
352
|
+
graph=graph, to=to, subject=subject, body=body,
|
|
353
|
+
body_type=body_type, cc=cc, bcc=bcc, attachments=attachments,
|
|
354
|
+
save_to_sent_items=save_to_sent_items, mailbox=mailbox,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
@mcp.tool(name="create_draft", description=create_draft.__doc__ or "")
|
|
358
|
+
async def _draft(
|
|
359
|
+
to: list[str], subject: str, body: str,
|
|
360
|
+
body_type: Literal["text", "html"] = "text",
|
|
361
|
+
cc: list[str] | None = None,
|
|
362
|
+
bcc: list[str] | None = None, attachments: list[dict] | None = None,
|
|
363
|
+
mailbox: str | None = None, include_raw: bool = False,
|
|
364
|
+
):
|
|
365
|
+
return await create_draft(
|
|
366
|
+
graph=graph, to=to, subject=subject, body=body,
|
|
367
|
+
body_type=body_type, cc=cc, bcc=bcc, attachments=attachments,
|
|
368
|
+
mailbox=mailbox, include_raw=include_raw,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
@mcp.tool(name="reply_message", description=reply_message.__doc__ or "")
|
|
372
|
+
async def _reply(message_id: str, comment: str | None = None, extra_to: list[str] | None = None, mailbox: str | None = None):
|
|
373
|
+
return await reply_message(graph=graph, message_id=message_id, comment=comment, extra_to=extra_to, mailbox=mailbox)
|
|
374
|
+
|
|
375
|
+
@mcp.tool(name="reply_all_message", description=reply_all_message.__doc__ or "")
|
|
376
|
+
async def _reply_all(message_id: str, comment: str | None = None, mailbox: str | None = None):
|
|
377
|
+
return await reply_all_message(graph=graph, message_id=message_id, comment=comment, mailbox=mailbox)
|
|
378
|
+
|
|
379
|
+
@mcp.tool(name="forward_message", description=forward_message.__doc__ or "")
|
|
380
|
+
async def _forward(message_id: str, to: list[str], comment: str | None = None, mailbox: str | None = None):
|
|
381
|
+
return await forward_message(graph=graph, message_id=message_id, to=to, comment=comment, mailbox=mailbox)
|
|
382
|
+
|
|
383
|
+
@mcp.tool(name="update_message", description=update_message.__doc__ or "")
|
|
384
|
+
async def _update(
|
|
385
|
+
message_id: str,
|
|
386
|
+
is_read: bool | None = None,
|
|
387
|
+
flag: Literal["notFlagged", "flagged", "complete"] | None = None,
|
|
388
|
+
importance: Literal["low", "normal", "high"] | None = None,
|
|
389
|
+
categories: list[str] | None = None,
|
|
390
|
+
parent_folder_id: str | None = None,
|
|
391
|
+
mailbox: str | None = None,
|
|
392
|
+
include_raw: bool = False,
|
|
393
|
+
):
|
|
394
|
+
return await update_message(
|
|
395
|
+
graph=graph, message_id=message_id,
|
|
396
|
+
is_read=is_read, flag=flag, importance=importance,
|
|
397
|
+
categories=categories, parent_folder_id=parent_folder_id,
|
|
398
|
+
mailbox=mailbox, include_raw=include_raw,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@mcp.tool(name="delete_message", description=delete_message.__doc__ or "")
|
|
402
|
+
async def _delete(message_id: str, mailbox: str | None = None):
|
|
403
|
+
return await delete_message(graph=graph, message_id=message_id, mailbox=mailbox)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Teams channel read tools: joined teams, channels, channel messages, replies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
|
6
|
+
from msgraph.generated.teams.item.channels.item.messages.messages_request_builder import (
|
|
7
|
+
MessagesRequestBuilder as ChannelMessagesRequestBuilder,
|
|
8
|
+
)
|
|
9
|
+
from msgraph.generated.teams.item.channels.item.messages.item.replies.replies_request_builder import (
|
|
10
|
+
RepliesRequestBuilder,
|
|
11
|
+
)
|
|
12
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
13
|
+
from msgraph_mcp.graph.errors import map_kiota_error
|
|
14
|
+
from msgraph_mcp.graph.pagination import decode_page_token, encode_next_link, validate_limit
|
|
15
|
+
from msgraph_mcp.graph.serialize import channel_to_dict, chat_message_to_dict, team_to_dict
|
|
16
|
+
from msgraph_mcp.graph.trimming import trim_channel, trim_chat_message, trim_team
|
|
17
|
+
|
|
18
|
+
_CHANNEL_MSG_MAX = 50
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _channel_messages_query(*, limit: int):
|
|
22
|
+
qp = ChannelMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=limit)
|
|
23
|
+
return RequestConfiguration[
|
|
24
|
+
ChannelMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters
|
|
25
|
+
](query_parameters=qp)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _replies_query(*, limit: int):
|
|
29
|
+
qp = RepliesRequestBuilder.RepliesRequestBuilderGetQueryParameters(top=limit)
|
|
30
|
+
return RequestConfiguration[
|
|
31
|
+
RepliesRequestBuilder.RepliesRequestBuilderGetQueryParameters
|
|
32
|
+
](query_parameters=qp)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _paged(builder, *, page_token, request_configuration):
|
|
36
|
+
if page_token is not None:
|
|
37
|
+
url = decode_page_token(page_token)
|
|
38
|
+
return await builder.with_url(url).get()
|
|
39
|
+
return await builder.get(request_configuration=request_configuration)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def list_joined_teams(
|
|
43
|
+
*, graph, limit: int = 25, page_token: str | None = None, include_raw: bool = False
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""List the teams the signed-in user is a member of.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
limit: 1-100. Default 25. Applied client-side (see below); teams
|
|
49
|
+
beyond the limit are dropped.
|
|
50
|
+
page_token: Continuation token from a previous result.
|
|
51
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
{"items": [trimmed_team, ...], "next_page_token": str | None}
|
|
55
|
+
"""
|
|
56
|
+
limit = validate_limit(limit)
|
|
57
|
+
builder = graph.raw.me.joined_teams
|
|
58
|
+
try:
|
|
59
|
+
# /me/joinedTeams rejects $top ("Query option 'Top' is not allowed"),
|
|
60
|
+
# so fetch unpaged and cap client-side.
|
|
61
|
+
collection = await _paged(builder, page_token=page_token, request_configuration=None)
|
|
62
|
+
except NotAuthenticatedError:
|
|
63
|
+
raise
|
|
64
|
+
except Exception as exc: # noqa: BLE001
|
|
65
|
+
raise map_kiota_error(exc) from exc
|
|
66
|
+
items = [trim_team(team_to_dict(t), include_raw=include_raw) for t in (collection.value or [])[:limit]]
|
|
67
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def list_channels(
|
|
71
|
+
*, graph, team_id: str, limit: int = 25, page_token: str | None = None, include_raw: bool = False
|
|
72
|
+
) -> dict:
|
|
73
|
+
"""List channels in a team.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
team_id: Graph team id (from list_joined_teams).
|
|
77
|
+
limit: 1-100. Default 25. Applied client-side (see below); channels
|
|
78
|
+
beyond the limit are dropped.
|
|
79
|
+
page_token: Continuation token from a previous result.
|
|
80
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
{"items": [trimmed_channel, ...], "next_page_token": str | None}
|
|
84
|
+
"""
|
|
85
|
+
limit = validate_limit(limit)
|
|
86
|
+
builder = graph.raw.teams.by_team_id(team_id).channels
|
|
87
|
+
try:
|
|
88
|
+
# /teams/{id}/channels rejects $top ("Query option 'Top' is not
|
|
89
|
+
# allowed"), so fetch unpaged and cap client-side.
|
|
90
|
+
collection = await _paged(builder, page_token=page_token, request_configuration=None)
|
|
91
|
+
except NotAuthenticatedError:
|
|
92
|
+
raise
|
|
93
|
+
except Exception as exc: # noqa: BLE001
|
|
94
|
+
raise map_kiota_error(exc) from exc
|
|
95
|
+
items = [trim_channel(channel_to_dict(c), include_raw=include_raw) for c in (collection.value or [])[:limit]]
|
|
96
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def list_channel_messages(
|
|
100
|
+
*,
|
|
101
|
+
graph,
|
|
102
|
+
team_id: str,
|
|
103
|
+
channel_id: str,
|
|
104
|
+
limit: int = 25,
|
|
105
|
+
page_token: str | None = None,
|
|
106
|
+
include_body: bool = False,
|
|
107
|
+
include_raw: bool = False,
|
|
108
|
+
) -> dict:
|
|
109
|
+
"""List root messages in a channel, newest first.
|
|
110
|
+
|
|
111
|
+
Replies are not included here; fetch them with list_message_replies.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
team_id: Graph team id.
|
|
115
|
+
channel_id: Graph channel id (from list_channels).
|
|
116
|
+
limit: 1-50 (Graph caps channel message pages at 50). Default 25.
|
|
117
|
+
page_token: Continuation token from a previous result.
|
|
118
|
+
include_body: When True, include each message's full body and any
|
|
119
|
+
attachment card payloads (e.g. Adaptive Card JSON for bot posts).
|
|
120
|
+
Default False (snippet only; card text still feeds the snippet).
|
|
121
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
{"items": [trimmed_chat_message, ...], "next_page_token": str | None}
|
|
125
|
+
"""
|
|
126
|
+
limit = validate_limit(limit, maximum=_CHANNEL_MSG_MAX)
|
|
127
|
+
builder = graph.raw.teams.by_team_id(team_id).channels.by_channel_id(channel_id).messages
|
|
128
|
+
try:
|
|
129
|
+
collection = await _paged(builder, page_token=page_token, request_configuration=_channel_messages_query(limit=limit))
|
|
130
|
+
except NotAuthenticatedError:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
raise map_kiota_error(exc) from exc
|
|
134
|
+
items = [
|
|
135
|
+
trim_chat_message(chat_message_to_dict(m), include_body=include_body, include_raw=include_raw)
|
|
136
|
+
for m in (collection.value or [])
|
|
137
|
+
]
|
|
138
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def list_message_replies(
|
|
142
|
+
*,
|
|
143
|
+
graph,
|
|
144
|
+
team_id: str,
|
|
145
|
+
channel_id: str,
|
|
146
|
+
message_id: str,
|
|
147
|
+
limit: int = 25,
|
|
148
|
+
page_token: str | None = None,
|
|
149
|
+
include_body: bool = False,
|
|
150
|
+
include_raw: bool = False,
|
|
151
|
+
) -> dict:
|
|
152
|
+
"""List replies to a channel message (the thread under a root post), newest first.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
team_id: Graph team id.
|
|
156
|
+
channel_id: Graph channel id.
|
|
157
|
+
message_id: Graph id of the root channel message (from list_channel_messages).
|
|
158
|
+
limit: 1-50 (Graph caps reply pages at 50). Default 25.
|
|
159
|
+
page_token: Continuation token from a previous result.
|
|
160
|
+
include_body: When True, include each reply's full body and any
|
|
161
|
+
attachment card payloads. Default False (snippet only).
|
|
162
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
{"items": [trimmed_chat_message, ...], "next_page_token": str | None}
|
|
166
|
+
"""
|
|
167
|
+
limit = validate_limit(limit, maximum=_CHANNEL_MSG_MAX)
|
|
168
|
+
builder = (
|
|
169
|
+
graph.raw.teams.by_team_id(team_id)
|
|
170
|
+
.channels.by_channel_id(channel_id)
|
|
171
|
+
.messages.by_chat_message_id(message_id)
|
|
172
|
+
.replies
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
collection = await _paged(builder, page_token=page_token, request_configuration=_replies_query(limit=limit))
|
|
176
|
+
except NotAuthenticatedError:
|
|
177
|
+
raise
|
|
178
|
+
except Exception as exc: # noqa: BLE001
|
|
179
|
+
raise map_kiota_error(exc) from exc
|
|
180
|
+
items = [
|
|
181
|
+
trim_chat_message(chat_message_to_dict(m), include_body=include_body, include_raw=include_raw)
|
|
182
|
+
for m in (collection.value or [])
|
|
183
|
+
]
|
|
184
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def register(mcp, *, graph) -> None:
|
|
188
|
+
@mcp.tool(name="list_joined_teams", description=list_joined_teams.__doc__ or "")
|
|
189
|
+
async def _list_joined_teams(limit: int = 25, page_token: str | None = None, include_raw: bool = False):
|
|
190
|
+
return await list_joined_teams(graph=graph, limit=limit, page_token=page_token, include_raw=include_raw)
|
|
191
|
+
|
|
192
|
+
@mcp.tool(name="list_channels", description=list_channels.__doc__ or "")
|
|
193
|
+
async def _list_channels(team_id: str, limit: int = 25, page_token: str | None = None, include_raw: bool = False):
|
|
194
|
+
return await list_channels(graph=graph, team_id=team_id, limit=limit, page_token=page_token, include_raw=include_raw)
|
|
195
|
+
|
|
196
|
+
@mcp.tool(name="list_channel_messages", description=list_channel_messages.__doc__ or "")
|
|
197
|
+
async def _list_channel_messages(
|
|
198
|
+
team_id: str,
|
|
199
|
+
channel_id: str,
|
|
200
|
+
limit: int = 25,
|
|
201
|
+
page_token: str | None = None,
|
|
202
|
+
include_body: bool = False,
|
|
203
|
+
include_raw: bool = False,
|
|
204
|
+
):
|
|
205
|
+
return await list_channel_messages(
|
|
206
|
+
graph=graph, team_id=team_id, channel_id=channel_id, limit=limit,
|
|
207
|
+
page_token=page_token, include_body=include_body, include_raw=include_raw,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@mcp.tool(name="list_message_replies", description=list_message_replies.__doc__ or "")
|
|
211
|
+
async def _list_message_replies(
|
|
212
|
+
team_id: str,
|
|
213
|
+
channel_id: str,
|
|
214
|
+
message_id: str,
|
|
215
|
+
limit: int = 25,
|
|
216
|
+
page_token: str | None = None,
|
|
217
|
+
include_body: bool = False,
|
|
218
|
+
include_raw: bool = False,
|
|
219
|
+
):
|
|
220
|
+
return await list_message_replies(
|
|
221
|
+
graph=graph, team_id=team_id, channel_id=channel_id, message_id=message_id,
|
|
222
|
+
limit=limit, page_token=page_token, include_body=include_body, include_raw=include_raw,
|
|
223
|
+
)
|