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,140 @@
|
|
|
1
|
+
"""Teams chat read tools: list the user's chats and their message history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
|
6
|
+
from msgraph.generated.chats.chats_request_builder import ChatsRequestBuilder
|
|
7
|
+
from msgraph.generated.chats.item.messages.messages_request_builder import (
|
|
8
|
+
MessagesRequestBuilder as ChatMessagesRequestBuilder,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
12
|
+
from msgraph_mcp.graph.errors import map_kiota_error
|
|
13
|
+
from msgraph_mcp.graph.pagination import decode_page_token, encode_next_link, validate_limit
|
|
14
|
+
from msgraph_mcp.graph.serialize import chat_message_to_dict, chat_to_dict
|
|
15
|
+
from msgraph_mcp.graph.trimming import trim_chat, trim_chat_message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _list_chats_query(*, limit: int):
|
|
19
|
+
qp = ChatsRequestBuilder.ChatsRequestBuilderGetQueryParameters(
|
|
20
|
+
top=limit,
|
|
21
|
+
expand=["members", "lastMessagePreview"],
|
|
22
|
+
)
|
|
23
|
+
return RequestConfiguration[ChatsRequestBuilder.ChatsRequestBuilderGetQueryParameters](
|
|
24
|
+
query_parameters=qp
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _list_chat_messages_query(*, limit: int):
|
|
29
|
+
qp = ChatMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=limit)
|
|
30
|
+
return RequestConfiguration[
|
|
31
|
+
ChatMessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters
|
|
32
|
+
](query_parameters=qp)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def list_chats(
|
|
36
|
+
*,
|
|
37
|
+
graph,
|
|
38
|
+
limit: int = 25,
|
|
39
|
+
page_token: str | None = None,
|
|
40
|
+
include_raw: bool = False,
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""List the signed-in user's Teams chats (1:1, group, meeting).
|
|
43
|
+
|
|
44
|
+
Members are expanded so 1:1 chats (which have no topic) are identifiable
|
|
45
|
+
by participant name. Results come back in Graph's default order (most
|
|
46
|
+
recent message activity first).
|
|
47
|
+
|
|
48
|
+
Each item carries two timestamps; use the right one:
|
|
49
|
+
- last_message_time: created time of the most recent message (from the
|
|
50
|
+
expanded lastMessagePreview). This is the true "last activity" time.
|
|
51
|
+
- metadata_updated: Graph's lastUpdatedDateTime, which only changes on
|
|
52
|
+
rename/membership events (e.g. someone joining a meeting call), NOT on
|
|
53
|
+
new messages. Do not use it to judge recency -- a busy 1:1 can show a
|
|
54
|
+
months-old metadata_updated.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
limit: 1-100. Default 25.
|
|
58
|
+
page_token: Pass next_page_token from a previous result to continue.
|
|
59
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
{"items": [trimmed_chat, ...], "next_page_token": str | None}
|
|
63
|
+
"""
|
|
64
|
+
limit = validate_limit(limit)
|
|
65
|
+
builder = graph.raw.me.chats
|
|
66
|
+
try:
|
|
67
|
+
if page_token is not None:
|
|
68
|
+
url = decode_page_token(page_token)
|
|
69
|
+
collection = await builder.with_url(url).get()
|
|
70
|
+
else:
|
|
71
|
+
collection = await builder.get(request_configuration=_list_chats_query(limit=limit))
|
|
72
|
+
except NotAuthenticatedError:
|
|
73
|
+
raise
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
raise map_kiota_error(exc) from exc
|
|
76
|
+
|
|
77
|
+
items = [trim_chat(chat_to_dict(c), include_raw=include_raw) for c in (collection.value or [])]
|
|
78
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def list_chat_messages(
|
|
82
|
+
*,
|
|
83
|
+
graph,
|
|
84
|
+
chat_id: str,
|
|
85
|
+
limit: int = 25,
|
|
86
|
+
page_token: str | None = None,
|
|
87
|
+
include_body: bool = False,
|
|
88
|
+
include_raw: bool = False,
|
|
89
|
+
) -> dict:
|
|
90
|
+
"""List messages in a chat, newest first.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
chat_id: Graph chat id (from list_chats).
|
|
94
|
+
limit: 1-100. Default 25.
|
|
95
|
+
page_token: Continuation token from a previous result.
|
|
96
|
+
include_body: When True, include each message's full body and any
|
|
97
|
+
attachment card payloads (e.g. Adaptive Card JSON for bot posts).
|
|
98
|
+
Default False (snippet only; card text still feeds the snippet).
|
|
99
|
+
include_raw: Include the raw Graph payload under "raw" on each item.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
{"items": [trimmed_chat_message, ...], "next_page_token": str | None}
|
|
103
|
+
"""
|
|
104
|
+
limit = validate_limit(limit)
|
|
105
|
+
builder = graph.raw.chats.by_chat_id(chat_id).messages
|
|
106
|
+
try:
|
|
107
|
+
if page_token is not None:
|
|
108
|
+
url = decode_page_token(page_token)
|
|
109
|
+
collection = await builder.with_url(url).get()
|
|
110
|
+
else:
|
|
111
|
+
collection = await builder.get(request_configuration=_list_chat_messages_query(limit=limit))
|
|
112
|
+
except NotAuthenticatedError:
|
|
113
|
+
raise
|
|
114
|
+
except Exception as exc: # noqa: BLE001
|
|
115
|
+
raise map_kiota_error(exc) from exc
|
|
116
|
+
|
|
117
|
+
items = [
|
|
118
|
+
trim_chat_message(chat_message_to_dict(m), include_body=include_body, include_raw=include_raw)
|
|
119
|
+
for m in (collection.value or [])
|
|
120
|
+
]
|
|
121
|
+
return {"items": items, "next_page_token": encode_next_link(getattr(collection, "odata_next_link", None))}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def register(mcp, *, graph) -> None:
|
|
125
|
+
@mcp.tool(name="list_chats", description=list_chats.__doc__ or "")
|
|
126
|
+
async def _list_chats(limit: int = 25, page_token: str | None = None, include_raw: bool = False):
|
|
127
|
+
return await list_chats(graph=graph, limit=limit, page_token=page_token, include_raw=include_raw)
|
|
128
|
+
|
|
129
|
+
@mcp.tool(name="list_chat_messages", description=list_chat_messages.__doc__ or "")
|
|
130
|
+
async def _list_chat_messages(
|
|
131
|
+
chat_id: str,
|
|
132
|
+
limit: int = 25,
|
|
133
|
+
page_token: str | None = None,
|
|
134
|
+
include_body: bool = False,
|
|
135
|
+
include_raw: bool = False,
|
|
136
|
+
):
|
|
137
|
+
return await list_chat_messages(
|
|
138
|
+
graph=graph, chat_id=chat_id, limit=limit,
|
|
139
|
+
page_token=page_token, include_body=include_body, include_raw=include_raw,
|
|
140
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Teams inline hosted-content download.
|
|
2
|
+
|
|
3
|
+
Inline images and other hosted content live behind an authenticated Graph
|
|
4
|
+
endpoint (.../hostedContents/{id}/$value) that requires the server's
|
|
5
|
+
delegated token, so an agent cannot fetch them directly. This tool proxies
|
|
6
|
+
that authenticated fetch and returns base64, parallel to download_attachment
|
|
7
|
+
for mail. SharePoint/OneDrive-backed file attachments are a different Graph
|
|
8
|
+
surface and are out of scope (see README).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
|
|
16
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
17
|
+
from msgraph_mcp.graph.errors import GraphValidationError, map_kiota_error
|
|
18
|
+
from msgraph_mcp.graph.trimming import trim_hosted_content_download
|
|
19
|
+
from msgraph_mcp.tools import _binary
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sniff_content_type(data: bytes) -> str | None:
|
|
23
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
24
|
+
return "image/png"
|
|
25
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
26
|
+
return "image/jpeg"
|
|
27
|
+
if data[:6] in (b"GIF87a", b"GIF89a"):
|
|
28
|
+
return "image/gif"
|
|
29
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
30
|
+
return "image/webp"
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hosted_content_builder(graph, *, chat_id, team_id, channel_id, message_id, hosted_content_id):
|
|
35
|
+
if chat_id is not None:
|
|
36
|
+
message = graph.raw.chats.by_chat_id(chat_id).messages.by_chat_message_id(message_id)
|
|
37
|
+
else:
|
|
38
|
+
message = (
|
|
39
|
+
graph.raw.teams.by_team_id(team_id)
|
|
40
|
+
.channels.by_channel_id(channel_id)
|
|
41
|
+
.messages.by_chat_message_id(message_id)
|
|
42
|
+
)
|
|
43
|
+
return message.hosted_contents.by_chat_message_hosted_content_id(hosted_content_id).content
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def download_hosted_content(
|
|
47
|
+
*,
|
|
48
|
+
graph,
|
|
49
|
+
message_id: str,
|
|
50
|
+
hosted_content_id: str,
|
|
51
|
+
chat_id: str | None = None,
|
|
52
|
+
team_id: str | None = None,
|
|
53
|
+
channel_id: str | None = None,
|
|
54
|
+
save_path: str | None = None,
|
|
55
|
+
include_raw: bool = False,
|
|
56
|
+
) -> dict | list:
|
|
57
|
+
"""Download one inline hosted-content item (usually an image).
|
|
58
|
+
|
|
59
|
+
Specify the message location with exactly one of:
|
|
60
|
+
- chat_id (for a chat message), or
|
|
61
|
+
- team_id AND channel_id (for a channel message).
|
|
62
|
+
|
|
63
|
+
Get message_id and hosted_content_id from a message's hosted_content_refs
|
|
64
|
+
(returned by list_chat_messages / list_channel_messages / list_message_replies).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
message_id: Graph id of the message the content is attached to.
|
|
68
|
+
hosted_content_id: Graph hosted-content id (from hosted_content_refs).
|
|
69
|
+
chat_id: Chat id, if the message is in a chat.
|
|
70
|
+
team_id: Team id, if the message is in a channel.
|
|
71
|
+
channel_id: Channel id, if the message is in a channel.
|
|
72
|
+
save_path: Write the bytes to this file path (or into this existing
|
|
73
|
+
directory) instead of returning content. Returns
|
|
74
|
+
{"path", "content_type", "size_bytes"} with no content payload.
|
|
75
|
+
include_raw: Include the raw payload under "raw" (ignored when the
|
|
76
|
+
result is an image block or a saved file).
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
- Image content (PNG/JPEG/GIF/WebP, no save_path): metadata plus the
|
|
80
|
+
image itself as a native MCP image block, viewable directly.
|
|
81
|
+
- With save_path: {"path": str, "content_type": str | None, "size_bytes": int}.
|
|
82
|
+
- Otherwise: {"content_type": str | None, "size_bytes": int,
|
|
83
|
+
"content_base64": str | None}. content_type is sniffed from the
|
|
84
|
+
bytes (Graph does not return it on the $value endpoint); it may be
|
|
85
|
+
None for unrecognized formats.
|
|
86
|
+
"""
|
|
87
|
+
has_chat = chat_id is not None
|
|
88
|
+
has_team = team_id is not None
|
|
89
|
+
has_channel = channel_id is not None
|
|
90
|
+
if has_chat and (has_team or has_channel):
|
|
91
|
+
raise GraphValidationError("Provide either chat_id or (team_id and channel_id), not both.")
|
|
92
|
+
if not has_chat and not (has_team and has_channel):
|
|
93
|
+
raise GraphValidationError(
|
|
94
|
+
"Provide a message location: chat_id, or both team_id and channel_id."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
builder = _hosted_content_builder(
|
|
98
|
+
graph, chat_id=chat_id, team_id=team_id, channel_id=channel_id,
|
|
99
|
+
message_id=message_id, hosted_content_id=hosted_content_id,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
data = await builder.get()
|
|
103
|
+
except NotAuthenticatedError:
|
|
104
|
+
raise
|
|
105
|
+
except Exception as exc: # noqa: BLE001
|
|
106
|
+
raise map_kiota_error(exc) from exc
|
|
107
|
+
|
|
108
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
109
|
+
raw = {"contentType": None, "size": 0, "contentBytes": None}
|
|
110
|
+
return trim_hosted_content_download(raw, include_raw=include_raw)
|
|
111
|
+
|
|
112
|
+
data = bytes(data)
|
|
113
|
+
content_type = _sniff_content_type(data)
|
|
114
|
+
|
|
115
|
+
if save_path is not None:
|
|
116
|
+
# hosted_content_id is opaque base64 (unsafe as a filename); hash it.
|
|
117
|
+
digest = hashlib.sha256(hosted_content_id.encode()).hexdigest()[:8]
|
|
118
|
+
ext = _binary.ext_for(content_type)
|
|
119
|
+
default_name = f"hosted-content-{message_id}-{digest}{ext}"
|
|
120
|
+
path = _binary.write_bytes(save_path, data, default_name=default_name)
|
|
121
|
+
return {"path": path, "content_type": content_type, "size_bytes": len(data)}
|
|
122
|
+
|
|
123
|
+
if _binary.is_image(content_type):
|
|
124
|
+
meta = {"content_type": content_type, "size_bytes": len(data)}
|
|
125
|
+
return _binary.image_result(meta, data, content_type)
|
|
126
|
+
|
|
127
|
+
raw = {
|
|
128
|
+
"contentType": content_type,
|
|
129
|
+
"size": len(data),
|
|
130
|
+
"contentBytes": base64.b64encode(data).decode("ascii"),
|
|
131
|
+
}
|
|
132
|
+
return trim_hosted_content_download(raw, include_raw=include_raw)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def register(mcp, *, graph) -> None:
|
|
136
|
+
@mcp.tool(name="download_hosted_content", description=download_hosted_content.__doc__ or "")
|
|
137
|
+
async def _download_hosted_content(
|
|
138
|
+
message_id: str,
|
|
139
|
+
hosted_content_id: str,
|
|
140
|
+
chat_id: str | None = None,
|
|
141
|
+
team_id: str | None = None,
|
|
142
|
+
channel_id: str | None = None,
|
|
143
|
+
save_path: str | None = None,
|
|
144
|
+
include_raw: bool = False,
|
|
145
|
+
):
|
|
146
|
+
return await download_hosted_content(
|
|
147
|
+
graph=graph, message_id=message_id, hosted_content_id=hosted_content_id,
|
|
148
|
+
chat_id=chat_id, team_id=team_id, channel_id=channel_id,
|
|
149
|
+
save_path=save_path, include_raw=include_raw,
|
|
150
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Utility tools (whoami)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
6
|
+
from msgraph_mcp.graph.errors import map_kiota_error
|
|
7
|
+
from msgraph_mcp.graph.serialize import user_to_dict
|
|
8
|
+
from msgraph_mcp.graph.trimming import trim_user
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def whoami(*, graph, include_raw: bool = False) -> dict:
|
|
12
|
+
"""Return the signed-in user's profile.
|
|
13
|
+
|
|
14
|
+
Also a smoke test that authentication is working.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
include_raw: when True, include the raw Graph payload under "raw".
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Trimmed user object: {id, display_name, user_principal_name, mail, job_title}
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
user = await graph.mailbox(None).get()
|
|
24
|
+
except NotAuthenticatedError:
|
|
25
|
+
# Surface the login-required hint directly; don't wrap as a Graph error.
|
|
26
|
+
raise
|
|
27
|
+
except Exception as exc: # noqa: BLE001
|
|
28
|
+
raise map_kiota_error(exc) from exc
|
|
29
|
+
return trim_user(user_to_dict(user), include_raw=include_raw)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register(mcp, *, graph) -> None:
|
|
33
|
+
@mcp.tool(name="whoami", description=whoami.__doc__ or "")
|
|
34
|
+
async def _whoami(include_raw: bool = False) -> dict:
|
|
35
|
+
return await whoami(graph=graph, include_raw=include_raw)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: msgraph-mcp-server
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: MCP server for Microsoft Graph: Outlook mail and calendar, plus read-only Microsoft Teams
|
|
5
|
+
Project-URL: Homepage, https://github.com/timfurlong/msgraph-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/timfurlong/msgraph-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/timfurlong/msgraph-mcp/issues
|
|
8
|
+
Author: Tim Furlong
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: calendar,email,mcp,microsoft-graph,model-context-protocol,outlook,teams
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
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
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: mcp>=1.0
|
|
23
|
+
Requires-Dist: msal>=1.31
|
|
24
|
+
Requires-Dist: msgraph-sdk>=1.0
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# MSGraph MCP
|
|
29
|
+
|
|
30
|
+
<!-- mcp-name: io.github.timfurlong/msgraph-mcp -->
|
|
31
|
+
|
|
32
|
+
A Model Context Protocol (MCP) server for **Microsoft Graph**. It exposes Microsoft Outlook **mail** and **calendar**, plus read-only Microsoft **Teams** message history, to AI agents via the Microsoft Graph SDK. Acts as the signed-in user (delegated permissions, MSAL device code flow).
|
|
33
|
+
|
|
34
|
+
> Formerly published as `outlook-mcp`. Renamed because the scope grew beyond Outlook (Teams today, potentially other Graph surfaces later). Outlook mail and calendar remain first-class capabilities. See [Migrating from outlook-mcp](#migrating-from-outlook-mcp).
|
|
35
|
+
|
|
36
|
+
## What it does
|
|
37
|
+
|
|
38
|
+
Workflow-oriented tools covering common mail, calendar, and read-only Teams operations:
|
|
39
|
+
|
|
40
|
+
| Group | Tools |
|
|
41
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
42
|
+
| Util | `whoami` |
|
|
43
|
+
| Mail — read | `list_messages`, `search_messages`, `get_message`, `list_attachments`, `download_attachment` |
|
|
44
|
+
| Mail — write | `send_message`, `create_draft`, `reply_message`, `reply_all_message`, `forward_message`, `update_message`, `delete_message` |
|
|
45
|
+
| Mail — folders | `list_folders`, `create_folder`, `update_folder`, `delete_folder`, `move_message` |
|
|
46
|
+
| Mail — actions | `archive_message`, `mark_read`, `mark_unread`, `flag_message`, `unflag_message` |
|
|
47
|
+
| Mail — rules | `list_rules`, `get_rule`, `create_rule`, `update_rule`, `delete_rule` |
|
|
48
|
+
| Calendar | `list_calendars`, `list_events`, `get_event`, `create_event`, `update_event`, `delete_event`, `cancel_event`, `respond_to_event`, `find_meeting_times` |
|
|
49
|
+
| Teams (read) | `list_chats`, `list_chat_messages`, `list_joined_teams`, `list_channels`, `list_channel_messages`, `list_message_replies`, `download_hosted_content` |
|
|
50
|
+
|
|
51
|
+
Every tool that touches a mailbox or calendar accepts an optional `mailbox` argument (email or user ID) to target shared mailboxes/calendars. Omit it to use the signed-in user's own mailbox.
|
|
52
|
+
|
|
53
|
+
Every tool that returns objects accepts `include_raw=true` to also include the full Graph payload.
|
|
54
|
+
|
|
55
|
+
List/search tools support pagination via `limit` (1-100, default 25) and `page_token`.
|
|
56
|
+
|
|
57
|
+
## Prerequisites
|
|
58
|
+
|
|
59
|
+
- Python ≥ 3.11
|
|
60
|
+
- `uv`: https://docs.astral.sh/uv/
|
|
61
|
+
- An Entra (Azure AD) app registration with the right permissions (see "Entra setup" below)
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
From PyPI (package `msgraph-mcp-server`; the commands it installs are `msgraph-mcp` and `msgraph-mcp-login`):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv tool install msgraph-mcp-server # or: pip install msgraph-mcp-server
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then use `msgraph-mcp-login` / `msgraph-mcp` directly wherever the quickstart below says `uv run ...`, and wire the host with `claude mcp add msgraph -- msgraph-mcp`.
|
|
72
|
+
|
|
73
|
+
## Quickstart (from source)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# 1. Install dependencies
|
|
77
|
+
uv sync
|
|
78
|
+
|
|
79
|
+
# 2. Configure environment
|
|
80
|
+
cp .env.example .env
|
|
81
|
+
# Fill in MSGRAPH_MCP_CLIENT_ID and MSGRAPH_MCP_TENANT_ID
|
|
82
|
+
|
|
83
|
+
# 3. One-time sign-in (device code flow)
|
|
84
|
+
uv run msgraph-mcp-login
|
|
85
|
+
# Follow the prompt: visit the URL, enter the code, complete sign-in.
|
|
86
|
+
# A token cache is written to ~/.msgraph-mcp/token_cache.bin (mode 0600).
|
|
87
|
+
|
|
88
|
+
# 4. Wire the MCP into your host
|
|
89
|
+
# - Claude Code:
|
|
90
|
+
claude mcp add msgraph -- uv --directory "$(pwd)" run msgraph-mcp
|
|
91
|
+
|
|
92
|
+
# - Anything else: configure the host to launch `uv run msgraph-mcp` (stdio).
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Entra setup
|
|
96
|
+
|
|
97
|
+
The app registration (e.g. "MSGraph MCP") requires:
|
|
98
|
+
|
|
99
|
+
- **Account type:** single tenant
|
|
100
|
+
- **Redirect URI (public client):** `https://login.microsoftonline.com/common/oauth2/nativeclient`
|
|
101
|
+
- **Delegated permissions** (Microsoft Graph):
|
|
102
|
+
- `Mail.ReadWrite`
|
|
103
|
+
- `Mail.ReadWrite.Shared`
|
|
104
|
+
- `Mail.Send`
|
|
105
|
+
- `MailboxSettings.ReadWrite`
|
|
106
|
+
- `Calendars.ReadWrite`
|
|
107
|
+
- `Calendars.ReadWrite.Shared`
|
|
108
|
+
- `User.Read`
|
|
109
|
+
- `Chat.Read` (Teams)
|
|
110
|
+
- `Team.ReadBasic.All` (Teams)
|
|
111
|
+
- `Channel.ReadBasic.All` (Teams)
|
|
112
|
+
- `ChannelMessage.Read.All` (Teams)
|
|
113
|
+
- Admin consent: required for `ChannelMessage.Read.All` (always), plus the `*.Shared` permissions if your tenant requires it.
|
|
114
|
+
|
|
115
|
+
> If you signed in before any of these scopes were added to the app (for example `MailboxSettings.ReadWrite`, or the Teams scopes), re-run `uv run msgraph-mcp-login` so the cached token picks up the new scopes. Without them, calls needing the missing scope fail with a consent error.
|
|
116
|
+
|
|
117
|
+
The CLI uses public-client device code flow — **no client secret** is needed or stored.
|
|
118
|
+
|
|
119
|
+
## Environment variables
|
|
120
|
+
|
|
121
|
+
| Var | Required | Default | Purpose |
|
|
122
|
+
| ------------------------------ | -------- | -------------------------------- | ----------------------------------- |
|
|
123
|
+
| `MSGRAPH_MCP_CLIENT_ID` | yes | — | Entra (Azure AD) app client ID |
|
|
124
|
+
| `MSGRAPH_MCP_TENANT_ID` | yes | — | Tenant ID (single-tenant authority) |
|
|
125
|
+
| `MSGRAPH_MCP_TOKEN_CACHE_PATH` | no | `~/.msgraph-mcp/token_cache.bin` | Override token cache file location |
|
|
126
|
+
|
|
127
|
+
Process env wins; `.env` at the repo root is loaded as a dev fallback.
|
|
128
|
+
|
|
129
|
+
Legacy `OUTLOOK_MCP_*` names are honored as a fallback for each variable (the `MSGRAPH_MCP_*` name wins when both are set).
|
|
130
|
+
|
|
131
|
+
## Migrating from outlook-mcp
|
|
132
|
+
|
|
133
|
+
This project was named `outlook-mcp` through v0.2.0. What changed in the rename:
|
|
134
|
+
|
|
135
|
+
| Old | New |
|
|
136
|
+
| ---------------------------------- | ---------------------------------- |
|
|
137
|
+
| package `outlook-mcp` | package `msgraph-mcp` |
|
|
138
|
+
| module `outlook_mcp` | module `msgraph_mcp` |
|
|
139
|
+
| `uv run outlook-mcp` | `uv run msgraph-mcp` |
|
|
140
|
+
| `uv run outlook-mcp-login` | `uv run msgraph-mcp-login` |
|
|
141
|
+
| `OUTLOOK_MCP_*` env vars | `MSGRAPH_MCP_*` env vars |
|
|
142
|
+
| `~/.outlook-mcp/token_cache.bin` | `~/.msgraph-mcp/token_cache.bin` |
|
|
143
|
+
|
|
144
|
+
Backward compatibility, so an existing setup keeps working without re-authenticating:
|
|
145
|
+
|
|
146
|
+
- `OUTLOOK_MCP_*` env vars are still read as a fallback.
|
|
147
|
+
- If `~/.msgraph-mcp/token_cache.bin` does not exist but `~/.outlook-mcp/token_cache.bin` does, the legacy cache is used. To move to the new location: `mv ~/.outlook-mcp ~/.msgraph-mcp`.
|
|
148
|
+
|
|
149
|
+
You do need to update anything that launches the server by script name (MCP host configs): `outlook-mcp` → `msgraph-mcp`.
|
|
150
|
+
|
|
151
|
+
## Security
|
|
152
|
+
|
|
153
|
+
- The token cache contains your **refresh token**, which can mint access tokens for your mail, calendar, and Teams data. Treat it like a credential.
|
|
154
|
+
- Default location: `~/.msgraph-mcp/token_cache.bin`, mode `0600`, parent dir mode `0700`.
|
|
155
|
+
- To **revoke** access: sign in to https://account.microsoft.com or your org's identity portal, revoke the app, then `rm ~/.msgraph-mcp/token_cache.bin`.
|
|
156
|
+
- To **switch accounts**: `rm ~/.msgraph-mcp/token_cache.bin` and re-run `msgraph-mcp-login`.
|
|
157
|
+
|
|
158
|
+
## Recipes
|
|
159
|
+
|
|
160
|
+
### Route a sender into a new folder
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
# 1. Make a folder for the notifications.
|
|
164
|
+
create_folder(display_name="Notifications")
|
|
165
|
+
# -> {"id": "AAMkFolderId", "display_name": "Notifications", ...}
|
|
166
|
+
|
|
167
|
+
# 2. Create an inbox rule that moves matching senders into it.
|
|
168
|
+
create_rule(
|
|
169
|
+
display_name="Notifications",
|
|
170
|
+
sender_contains=["example.com"],
|
|
171
|
+
move_to_folder="AAMkFolderId",
|
|
172
|
+
stop_processing_rules=True,
|
|
173
|
+
)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Conditions inside one rule are AND-ed by Outlook. Pass a list to a single condition (e.g. `sender_contains=["example.com", "monitor.io"]`) for OR within that condition. Rules only run against the inbox — Graph's `messageRules` endpoint is hardcoded there and does not support per-folder rules.
|
|
177
|
+
|
|
178
|
+
`create_rule` requires at least one condition and one action. `update_rule` patches a rule in place but **replaces** the `conditions` or `actions` block whenever you pass any condition/action arg — call `get_rule` first if you need to preserve existing values.
|
|
179
|
+
|
|
180
|
+
## Microsoft Teams (read-only)
|
|
181
|
+
|
|
182
|
+
Read Teams message history as the signed-in user:
|
|
183
|
+
|
|
184
|
+
- `list_chats`, `list_chat_messages`: your 1:1 and group chats.
|
|
185
|
+
- `list_joined_teams`, `list_channels`, `list_channel_messages`, `list_message_replies`: team channels and their threads.
|
|
186
|
+
- `download_hosted_content`: download an inline image referenced by a message (`hosted_content_refs`). Images come back as a native MCP image block the agent can view directly; pass `save_path` (file or existing directory) to write the bytes to disk and get back a path instead.
|
|
187
|
+
|
|
188
|
+
### Permissions and consent
|
|
189
|
+
|
|
190
|
+
These delegated scopes are required (already listed in `SCOPES`):
|
|
191
|
+
|
|
192
|
+
- `Chat.Read`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`: user-consentable.
|
|
193
|
+
- `ChannelMessage.Read.All`: requires tenant administrator consent.
|
|
194
|
+
|
|
195
|
+
Setup:
|
|
196
|
+
|
|
197
|
+
1. Add the four delegated permissions to the app registration.
|
|
198
|
+
2. Grant tenant admin consent for `ChannelMessage.Read.All`.
|
|
199
|
+
3. Because the scope set changed, re-run the device-code login so the cached token carries the new scopes.
|
|
200
|
+
|
|
201
|
+
### Notes and limits
|
|
202
|
+
|
|
203
|
+
- Reading is delegated-only: you can read your own chats, not other users' chats.
|
|
204
|
+
- Channel message and reply pages are capped at 50 by Graph.
|
|
205
|
+
- SharePoint/OneDrive-backed file attachments are not downloadable here. In Teams, shared files are attachments whose `contentUrl` points into SharePoint, which is a different Graph surface (needs `Files.Read.All` / `Sites.Read.All` and the driveItem APIs). `download_hosted_content` covers inline hosted content (images), not shared files. This is deferred.
|
|
206
|
+
|
|
207
|
+
## Future work
|
|
208
|
+
|
|
209
|
+
Not implemented; reasonable additions:
|
|
210
|
+
|
|
211
|
+
- **Graph `$batch` requests.** Performance optimization that bundles multiple Graph calls into one HTTP round-trip; would speed up multi-step workflows but adds complexity. Defer until profiling proves the win.
|
|
212
|
+
|
|
213
|
+
Other known gaps (intentionally out of scope): chunked attachment upload (>3 MB), category master-list management, mail signatures, contacts/To-Do/OneNote, multi-account switching, change-notification subscriptions, force-delete of non-empty folders.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
# Run unit tests
|
|
219
|
+
uv run pytest
|
|
220
|
+
|
|
221
|
+
# Run unit + live integration smoke (requires a valid token cache)
|
|
222
|
+
MSGRAPH_MCP_INTEGRATION=1 uv run pytest
|
|
223
|
+
|
|
224
|
+
# Type check
|
|
225
|
+
uv run pyright src tests
|
|
226
|
+
|
|
227
|
+
# Lint
|
|
228
|
+
uv run ruff check .
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Troubleshooting
|
|
232
|
+
|
|
233
|
+
| Symptom | Fix |
|
|
234
|
+
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
235
|
+
| `NotAuthenticatedError: Not authenticated. Run \`msgraph-mcp-login\`...` | Run `uv run msgraph-mcp-login`. |
|
|
236
|
+
| `ConfigError: Missing required env var: MSGRAPH_MCP_CLIENT_ID` | Set the var in `.env` or in your MCP host's env config. |
|
|
237
|
+
| `Graph API 403: ErrorAccessDenied — ...` | Permission mismatch on the Entra app. Verify the delegated permissions list above and re-consent. |
|
|
238
|
+
| `Graph API 400: BadRequest — Syntax error: character ... is not valid at position N` from `search_messages` | The query is passed to Graph's `$search` as-is. Wrap literal/multi-character tokens in double quotes (e.g. `"weekly report"`), or use KQL fielded forms (e.g. `from:alice subject:"report"`). Bare alphanumeric strings with embedded digits are invalid KQL. |
|
|
239
|
+
| Server boots but tools 404 in the host | Confirm the host is launching `uv run msgraph-mcp` with the right working directory. |
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
Spec: `docs/specs/2026-05-19-outlook-mcp-design.md` (gitignored — local working doc).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
msgraph_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
msgraph_mcp/config.py,sha256=VVw3qHv2Q16VjNQssa56zlcVNHMCPedakKHMe5yXRzE,2175
|
|
3
|
+
msgraph_mcp/server.py,sha256=oiUxFJ_vMcqumqmonXN5meGZN6_5k3nk00e60z4A9Pk,2234
|
|
4
|
+
msgraph_mcp/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
msgraph_mcp/auth/cli.py,sha256=46izPWtNAaddzDeLgeM3luHD4rerRFVApYJ2x_01OA0,1270
|
|
6
|
+
msgraph_mcp/auth/msal_app.py,sha256=LTiUEt5srNIQTFTtbPE6O6RhU_dLxFwKbfwI3PmC8BY,1652
|
|
7
|
+
msgraph_mcp/auth/token.py,sha256=WJ438Avi7LDc5TZeXYNO7VLRraM0vlZPXQ1EX5rZVo4,986
|
|
8
|
+
msgraph_mcp/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
msgraph_mcp/graph/auth_provider.py,sha256=kaB3UYSeS0hDu3xGdJn6bm39HS0iIhut8dvLozvRwVs,1065
|
|
10
|
+
msgraph_mcp/graph/batch.py,sha256=4bZedIQJBT5MaGz54r1L1afXsstx_4ZBEffPtqf9sqk,7412
|
|
11
|
+
msgraph_mcp/graph/client.py,sha256=ZzaHNecrrS79gDDbGddgyuDoocd0hqB4Cq8tf_GX5yM,1108
|
|
12
|
+
msgraph_mcp/graph/errors.py,sha256=93weFyp_QlVxoKVLMULGexTXUgpdx0Qm8F_5nQkpTiM,1525
|
|
13
|
+
msgraph_mcp/graph/pagination.py,sha256=ITj63_VTMRmOdVvRLiaEQc7ucnubewiDtaOPiId-27g,1438
|
|
14
|
+
msgraph_mcp/graph/serialize.py,sha256=8LakliwK7ldYL_y-x5atW2GDTKVPe0QBU6a5MhOIqKY,14288
|
|
15
|
+
msgraph_mcp/graph/trimming.py,sha256=3e57kBe_1sXmo4zNeu3p0uCixmr19zXvQWXkra79JoM,13049
|
|
16
|
+
msgraph_mcp/tools/__init__.py,sha256=qJbwUSS3BZyu-NFp20RWNPYZfJGO7XLt2N263rHS1N8,944
|
|
17
|
+
msgraph_mcp/tools/_binary.py,sha256=7Gz4nZHwzPHi6-0UlkWC-ERx949s7DWcUB_j-yMmqJw,1582
|
|
18
|
+
msgraph_mcp/tools/calendar.py,sha256=MMdTiBx1e4LC7rjULpdIXzftBiS57U65B3i5-rLPGtw,21208
|
|
19
|
+
msgraph_mcp/tools/mail_actions.py,sha256=nib4cmd4HIgXGkIIqhfdnKBhcSS9UTjD_eA8G5ajzso,3464
|
|
20
|
+
msgraph_mcp/tools/mail_batch.py,sha256=HJt4QLS-NjE5uJLsg6LUmeuAvckifPnDg5hh-kdIDsA,7457
|
|
21
|
+
msgraph_mcp/tools/mail_folders.py,sha256=-i717AC9_f6j1P5MaLxQLta78kMgOpASSj459jE9wH4,9403
|
|
22
|
+
msgraph_mcp/tools/mail_read.py,sha256=YpbsB9QM4PntT9iJ2arCzlEU65UKIVQXNUaIr0PLxIY,12027
|
|
23
|
+
msgraph_mcp/tools/mail_rules.py,sha256=Gj5kbHYpNZ2Wdyiz8tFGfwY_zfScqT4JlufJWcAExPQ,17665
|
|
24
|
+
msgraph_mcp/tools/mail_write.py,sha256=UoyD3IP90vPaTWGdU8kq7neDrNEQA4bQRk5jC_f5mx0,13979
|
|
25
|
+
msgraph_mcp/tools/teams_channels.py,sha256=DzSwDhPwMedQqP31eKIwWQjJwwdzjwuylnGcWnUYL9U,9367
|
|
26
|
+
msgraph_mcp/tools/teams_chats.py,sha256=eVm0-hkGkkWamm6rJojAZjIkH_uXv_wD1O3MdzPN_bs,5556
|
|
27
|
+
msgraph_mcp/tools/teams_content.py,sha256=XpNnAIaaxux36wuP7kAnJcmoslAgMtQB9YVXVhhf6aA,6123
|
|
28
|
+
msgraph_mcp/tools/util.py,sha256=fCAIZCxph47vA_APrIWD-Eb3-mHHg-Gsjw4Ff6_7_u4,1203
|
|
29
|
+
msgraph_mcp_server-0.3.0.dist-info/METADATA,sha256=5Y4gBKk-Iuogwy4wunq5hynEySWefkrqWhwQGbj-6l4,14650
|
|
30
|
+
msgraph_mcp_server-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
31
|
+
msgraph_mcp_server-0.3.0.dist-info/entry_points.txt,sha256=PMHqZ65ru-Znc4qmLqiCNBIpEN6OL6mRcK8RkDem2Nw,102
|
|
32
|
+
msgraph_mcp_server-0.3.0.dist-info/licenses/LICENSE,sha256=0eHAEVNIZbTK0y1z0-MUmfjxhtN7bsJnJNCTieeHWgQ,1068
|
|
33
|
+
msgraph_mcp_server-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Furlong
|
|
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.
|