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
msgraph_mcp/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
msgraph_mcp/auth/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""msgraph-mcp-login entry point: runs the device code flow interactively."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from msgraph_mcp import config
|
|
8
|
+
from msgraph_mcp.auth.msal_app import build_app, persist_cache
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_login() -> int:
|
|
12
|
+
app, cache = build_app()
|
|
13
|
+
flow = app.initiate_device_flow(scopes=config.SCOPES)
|
|
14
|
+
if "user_code" not in flow:
|
|
15
|
+
print(
|
|
16
|
+
f"Failed to start device flow: {flow.get('error', flow)}",
|
|
17
|
+
file=sys.stderr,
|
|
18
|
+
)
|
|
19
|
+
return 1
|
|
20
|
+
|
|
21
|
+
# Message contains the verification URL and the code; print to stdout
|
|
22
|
+
# so the user sees it in their terminal.
|
|
23
|
+
print(flow["message"], flush=True)
|
|
24
|
+
|
|
25
|
+
result = app.acquire_token_by_device_flow(flow) # blocks until user completes flow
|
|
26
|
+
if "error" in result:
|
|
27
|
+
print(
|
|
28
|
+
f"Login failed: {result['error']} — {result.get('error_description', '')}",
|
|
29
|
+
file=sys.stderr,
|
|
30
|
+
)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
persist_cache(cache)
|
|
34
|
+
|
|
35
|
+
username = (
|
|
36
|
+
result.get("id_token_claims", {}).get("preferred_username")
|
|
37
|
+
or "<unknown>"
|
|
38
|
+
)
|
|
39
|
+
print(f"Logged in as {username}", flush=True)
|
|
40
|
+
print(f"Token cache saved to {config.token_cache_path()}", flush=True)
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> None:
|
|
45
|
+
sys.exit(run_login())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MSAL PublicClientApplication builder + token cache persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import msal
|
|
8
|
+
|
|
9
|
+
from msgraph_mcp import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_app() -> tuple[msal.PublicClientApplication, msal.SerializableTokenCache]:
|
|
13
|
+
"""Build the MSAL PublicClientApplication and bind a SerializableTokenCache.
|
|
14
|
+
|
|
15
|
+
Returns the (app, cache) tuple. The cache is loaded from disk if a cache
|
|
16
|
+
file exists at the configured path; otherwise it starts empty.
|
|
17
|
+
|
|
18
|
+
Call persist_cache(cache) after any operation that may have mutated it
|
|
19
|
+
(acquire_token_silent rotates refresh tokens; the cache reports
|
|
20
|
+
has_state_changed=True in that case).
|
|
21
|
+
"""
|
|
22
|
+
cache = msal.SerializableTokenCache()
|
|
23
|
+
cache_path = config.token_cache_path()
|
|
24
|
+
if cache_path.exists():
|
|
25
|
+
cache.deserialize(cache_path.read_text())
|
|
26
|
+
app = msal.PublicClientApplication(
|
|
27
|
+
client_id=config.client_id(),
|
|
28
|
+
authority=config.authority(),
|
|
29
|
+
token_cache=cache,
|
|
30
|
+
)
|
|
31
|
+
return app, cache
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def persist_cache(cache: msal.SerializableTokenCache) -> None:
|
|
35
|
+
"""Write the cache to disk with 0600 permissions, creating parent dirs at 0700.
|
|
36
|
+
|
|
37
|
+
Safe to call even if has_state_changed is False; we always write to keep
|
|
38
|
+
behavior simple and predictable. Caller may guard with `if cache.has_state_changed`
|
|
39
|
+
if write frequency matters.
|
|
40
|
+
"""
|
|
41
|
+
cache_path = config.token_cache_path()
|
|
42
|
+
parent = cache_path.parent
|
|
43
|
+
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
44
|
+
# Ensure parent dir perms even if it already existed
|
|
45
|
+
os.chmod(parent, 0o700)
|
|
46
|
+
cache_path.write_text(cache.serialize())
|
|
47
|
+
os.chmod(cache_path, 0o600)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Silent token acquisition for the running MCP server.
|
|
2
|
+
|
|
3
|
+
The server NEVER runs device code flow. If silent acquisition fails,
|
|
4
|
+
get_access_token raises NotAuthenticatedError pointing at the login CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from msgraph_mcp import config
|
|
10
|
+
from msgraph_mcp.auth.msal_app import build_app, persist_cache
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotAuthenticatedError(RuntimeError):
|
|
14
|
+
"""Raised when no valid token is available and silent refresh failed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_LOGIN_HINT = (
|
|
18
|
+
"Not authenticated. Run `msgraph-mcp-login` in a terminal to sign in."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_access_token() -> str:
|
|
23
|
+
app, cache = build_app()
|
|
24
|
+
accounts = app.get_accounts()
|
|
25
|
+
if not accounts:
|
|
26
|
+
raise NotAuthenticatedError(_LOGIN_HINT)
|
|
27
|
+
result = app.acquire_token_silent(config.SCOPES, account=accounts[0])
|
|
28
|
+
if not result or "access_token" not in result:
|
|
29
|
+
raise NotAuthenticatedError(_LOGIN_HINT)
|
|
30
|
+
if cache.has_state_changed:
|
|
31
|
+
persist_cache(cache)
|
|
32
|
+
return result["access_token"]
|
msgraph_mcp/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Configuration loading for msgraph_mcp.
|
|
2
|
+
|
|
3
|
+
Env vars are read once at import-side via require_env. Values are sourced from
|
|
4
|
+
the process environment with .env as a fallback (process wins via override=False).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Load .env once at module import; process env takes precedence.
|
|
16
|
+
load_dotenv(override=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SCOPES: list[str] = [
|
|
20
|
+
"Mail.ReadWrite",
|
|
21
|
+
"Mail.ReadWrite.Shared",
|
|
22
|
+
"Mail.Send",
|
|
23
|
+
"Calendars.ReadWrite",
|
|
24
|
+
"Calendars.ReadWrite.Shared",
|
|
25
|
+
"MailboxSettings.ReadWrite",
|
|
26
|
+
"User.Read",
|
|
27
|
+
# Teams read-history (ChannelMessage.Read.All requires tenant admin consent)
|
|
28
|
+
"Chat.Read",
|
|
29
|
+
"Team.ReadBasic.All",
|
|
30
|
+
"Channel.ReadBasic.All",
|
|
31
|
+
"ChannelMessage.Read.All",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_CACHE_PATH = Path.home() / ".msgraph-mcp" / "token_cache.bin"
|
|
36
|
+
|
|
37
|
+
# Pre-rename (outlook-mcp) locations, honored so existing setups keep working.
|
|
38
|
+
LEGACY_CACHE_PATH = Path.home() / ".outlook-mcp" / "token_cache.bin"
|
|
39
|
+
_LEGACY_ENV_PREFIX = "OUTLOOK_MCP_"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigError(RuntimeError):
|
|
43
|
+
"""Raised when required configuration is missing or invalid."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _env(name: str) -> str | None:
|
|
47
|
+
"""Read an MSGRAPH_MCP_* env var, falling back to its legacy OUTLOOK_MCP_* name."""
|
|
48
|
+
value = os.environ.get(name)
|
|
49
|
+
if value:
|
|
50
|
+
return value
|
|
51
|
+
return os.environ.get(name.replace("MSGRAPH_MCP_", _LEGACY_ENV_PREFIX, 1))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_env(name: str) -> str:
|
|
55
|
+
value = _env(name)
|
|
56
|
+
if not value:
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
f"Missing required env var: {name}. "
|
|
59
|
+
f"Set it in the process env or in a .env file at the repo root."
|
|
60
|
+
)
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def token_cache_path() -> Path:
|
|
65
|
+
override = _env("MSGRAPH_MCP_TOKEN_CACHE_PATH")
|
|
66
|
+
if override:
|
|
67
|
+
return Path(override).expanduser()
|
|
68
|
+
if not DEFAULT_CACHE_PATH.exists() and LEGACY_CACHE_PATH.exists():
|
|
69
|
+
return LEGACY_CACHE_PATH
|
|
70
|
+
return DEFAULT_CACHE_PATH
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def authority() -> str:
|
|
74
|
+
tenant = require_env("MSGRAPH_MCP_TENANT_ID")
|
|
75
|
+
return f"https://login.microsoftonline.com/{tenant}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def client_id() -> str:
|
|
79
|
+
return require_env("MSGRAPH_MCP_CLIENT_ID")
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Bridge our MSAL silent-acquire flow into msgraph-sdk via TokenCredential.
|
|
2
|
+
|
|
3
|
+
msgraph-sdk's GraphServiceClient takes an azure-core TokenCredential and
|
|
4
|
+
calls .get_token(*scopes) on every authenticated request. We ignore the
|
|
5
|
+
scopes argument — our MSAL PublicClientApplication is pre-configured with
|
|
6
|
+
the right delegated scopes during login, and the cached token already has
|
|
7
|
+
the correct audience.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from azure.core.credentials import AccessToken, TokenCredential
|
|
15
|
+
|
|
16
|
+
from msgraph_mcp.auth.token import get_access_token
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Short expiry: MSAL's cache handles real refresh. Each get_token call goes
|
|
20
|
+
# through get_access_token() which calls acquire_token_silent (cheap, cached).
|
|
21
|
+
_BUFFER_SECONDS = 300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MsalTokenCredential(TokenCredential):
|
|
25
|
+
"""TokenCredential that delegates to our MSAL silent-acquire pipeline."""
|
|
26
|
+
|
|
27
|
+
def get_token(self, *scopes: str, **kwargs) -> AccessToken: # type: ignore[override]
|
|
28
|
+
token = get_access_token()
|
|
29
|
+
return AccessToken(token, int(time.time() + _BUFFER_SECONDS))
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Graph $batch executor.
|
|
2
|
+
|
|
3
|
+
Provides a generic chunked batch executor against Microsoft Graph's /$batch
|
|
4
|
+
endpoint, decoupled from the SDK via the BatchTransport protocol so it can
|
|
5
|
+
be unit-tested with a fake transport.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BatchRequest:
|
|
18
|
+
"""A single sub-request to include in a Graph $batch call."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
method: str
|
|
22
|
+
url: str
|
|
23
|
+
body: dict[str, Any] | None = None
|
|
24
|
+
headers: dict[str, str] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BatchResult:
|
|
29
|
+
"""Per-sub-request outcome, in the same order as the caller's input."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
ok: bool
|
|
33
|
+
status: int = 0
|
|
34
|
+
error: str | None = None
|
|
35
|
+
body: dict[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class BatchTransport(Protocol):
|
|
40
|
+
"""Minimal HTTP transport for Graph $batch.
|
|
41
|
+
|
|
42
|
+
Implementations POST the payload to https://graph.microsoft.com/v1.0/$batch
|
|
43
|
+
and return the parsed JSON body.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
async def post_batch(self, payload: dict[str, Any]) -> dict[str, Any]: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def execute_batch(
|
|
50
|
+
transport: BatchTransport,
|
|
51
|
+
requests: list[BatchRequest],
|
|
52
|
+
*,
|
|
53
|
+
chunk_size: int = 20,
|
|
54
|
+
) -> list[BatchResult]:
|
|
55
|
+
"""Execute Graph $batch in chunks. Preserves input order in results.
|
|
56
|
+
|
|
57
|
+
Sub-requests within a single $batch are numbered "1", "2", ... per chunk;
|
|
58
|
+
that numbering is internal to the executor and never exposed to callers.
|
|
59
|
+
"""
|
|
60
|
+
if not requests:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
results_by_id: dict[str, BatchResult] = {}
|
|
64
|
+
|
|
65
|
+
for chunk in _chunked(requests, chunk_size):
|
|
66
|
+
subid_to_caller: dict[str, str] = {}
|
|
67
|
+
payload_requests: list[dict[str, Any]] = []
|
|
68
|
+
for i, req in enumerate(chunk, start=1):
|
|
69
|
+
sub_id = str(i)
|
|
70
|
+
subid_to_caller[sub_id] = req.id
|
|
71
|
+
entry: dict[str, Any] = {
|
|
72
|
+
"id": sub_id,
|
|
73
|
+
"method": req.method,
|
|
74
|
+
"url": req.url,
|
|
75
|
+
}
|
|
76
|
+
if req.body is not None:
|
|
77
|
+
entry["body"] = req.body
|
|
78
|
+
entry["headers"] = {"Content-Type": "application/json"}
|
|
79
|
+
if req.headers:
|
|
80
|
+
merged = dict(entry.get("headers", {}))
|
|
81
|
+
merged.update(req.headers)
|
|
82
|
+
entry["headers"] = merged
|
|
83
|
+
payload_requests.append(entry)
|
|
84
|
+
|
|
85
|
+
response = await transport.post_batch({"requests": payload_requests})
|
|
86
|
+
|
|
87
|
+
throttled = _throttled_entries(response, payload_requests)
|
|
88
|
+
if throttled:
|
|
89
|
+
wait = _max_retry_after(response.get("responses", []))
|
|
90
|
+
await asyncio.sleep(wait or 0)
|
|
91
|
+
# Renumber the retry chunk starting at 1 again.
|
|
92
|
+
retry_payload_requests: list[dict[str, Any]] = []
|
|
93
|
+
retry_subid_to_caller: dict[str, str] = {}
|
|
94
|
+
for j, orig in enumerate(throttled, start=1):
|
|
95
|
+
new_sub_id = str(j)
|
|
96
|
+
retry_subid_to_caller[new_sub_id] = subid_to_caller[orig["id"]]
|
|
97
|
+
retry_payload_requests.append({**orig, "id": new_sub_id})
|
|
98
|
+
retry_response = await transport.post_batch({"requests": retry_payload_requests})
|
|
99
|
+
|
|
100
|
+
# Merge non-throttled originals with retry results.
|
|
101
|
+
keep = [r for r in response.get("responses", []) if r.get("status") != 429]
|
|
102
|
+
for sub in keep:
|
|
103
|
+
caller_id = subid_to_caller.get(str(sub.get("id")))
|
|
104
|
+
if caller_id is not None:
|
|
105
|
+
results_by_id[caller_id] = _sub_to_result(caller_id, sub)
|
|
106
|
+
for sub in retry_response.get("responses", []):
|
|
107
|
+
caller_id = retry_subid_to_caller.get(str(sub.get("id")))
|
|
108
|
+
if caller_id is not None:
|
|
109
|
+
results_by_id[caller_id] = _sub_to_result(caller_id, sub)
|
|
110
|
+
else:
|
|
111
|
+
for sub in response.get("responses", []):
|
|
112
|
+
caller_id = subid_to_caller.get(str(sub.get("id")))
|
|
113
|
+
if caller_id is not None:
|
|
114
|
+
results_by_id[caller_id] = _sub_to_result(caller_id, sub)
|
|
115
|
+
|
|
116
|
+
# Preserve input order; fill in any missing as failures.
|
|
117
|
+
out: list[BatchResult] = []
|
|
118
|
+
for req in requests:
|
|
119
|
+
out.append(
|
|
120
|
+
results_by_id.get(req.id)
|
|
121
|
+
or BatchResult(id=req.id, ok=False, status=0, error="No response from $batch")
|
|
122
|
+
)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _chunked(xs: list[BatchRequest], n: int) -> Iterator[list[BatchRequest]]:
|
|
127
|
+
for i in range(0, len(xs), n):
|
|
128
|
+
yield xs[i : i + n]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _sub_to_result(caller_id: str, sub: dict[str, Any]) -> BatchResult:
|
|
132
|
+
status = int(sub.get("status") or 0)
|
|
133
|
+
ok = 200 <= status < 300
|
|
134
|
+
body = sub.get("body") if isinstance(sub.get("body"), dict) else None
|
|
135
|
+
error = None if ok else _format_error(status, body)
|
|
136
|
+
return BatchResult(id=caller_id, ok=ok, status=status, error=error, body=body)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _format_error(status: int, body: dict[str, Any] | None) -> str:
|
|
140
|
+
if isinstance(body, dict):
|
|
141
|
+
err = body.get("error", {}) if isinstance(body.get("error"), dict) else {}
|
|
142
|
+
code = err.get("code", "")
|
|
143
|
+
message = err.get("message", "")
|
|
144
|
+
prefix = " ".join(filter(None, [str(status), code]))
|
|
145
|
+
return f"{prefix}: {message}" if message else prefix
|
|
146
|
+
return str(status)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _throttled_entries(response: dict[str, Any], payload_requests: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
150
|
+
"""Return the original payload entries whose responses came back 429."""
|
|
151
|
+
throttled_ids = {
|
|
152
|
+
str(r.get("id"))
|
|
153
|
+
for r in response.get("responses", [])
|
|
154
|
+
if r.get("status") == 429
|
|
155
|
+
}
|
|
156
|
+
return [p for p in payload_requests if p["id"] in throttled_ids]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _max_retry_after(responses: list[dict[str, Any]]) -> float | None:
|
|
160
|
+
max_ra: float | None = None
|
|
161
|
+
for r in responses:
|
|
162
|
+
if r.get("status") != 429:
|
|
163
|
+
continue
|
|
164
|
+
ra = (r.get("headers") or {}).get("Retry-After")
|
|
165
|
+
if ra is None:
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
v = float(ra)
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
continue
|
|
171
|
+
if max_ra is None or v > max_ra:
|
|
172
|
+
max_ra = v
|
|
173
|
+
return max_ra
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class GraphBatchTransport:
|
|
177
|
+
"""Default BatchTransport using msgraph-sdk's request adapter.
|
|
178
|
+
|
|
179
|
+
Posts to https://graph.microsoft.com/v1.0/$batch and returns the parsed
|
|
180
|
+
JSON. Reuses the existing GraphClient's auth and retry policy.
|
|
181
|
+
|
|
182
|
+
Implementation note: if msgraph-sdk grows first-class batch primitives
|
|
183
|
+
(BatchRequestContentCollection / BatchResponseContent) that fit the
|
|
184
|
+
BatchTransport contract more cleanly, this class is the only place that
|
|
185
|
+
needs to change.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
GRAPH_BATCH_URL = "https://graph.microsoft.com/v1.0/$batch"
|
|
189
|
+
|
|
190
|
+
def __init__(self, graph) -> None:
|
|
191
|
+
self._graph = graph
|
|
192
|
+
|
|
193
|
+
async def post_batch(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
194
|
+
import json
|
|
195
|
+
|
|
196
|
+
from kiota_abstractions.method import Method # type: ignore[import-untyped]
|
|
197
|
+
from kiota_abstractions.request_information import RequestInformation # type: ignore[import-untyped]
|
|
198
|
+
|
|
199
|
+
ri = RequestInformation()
|
|
200
|
+
ri.url = self.GRAPH_BATCH_URL
|
|
201
|
+
ri.http_method = Method.POST
|
|
202
|
+
ri.headers.try_add("Content-Type", "application/json")
|
|
203
|
+
ri.content = json.dumps(payload).encode("utf-8")
|
|
204
|
+
|
|
205
|
+
raw = await self._graph.raw.request_adapter.send_primitive_async(ri, "bytes", {})
|
|
206
|
+
return json.loads(raw)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Small wrapper around msgraph-sdk's GraphServiceClient.
|
|
2
|
+
|
|
3
|
+
Centralizes:
|
|
4
|
+
- GraphServiceClient construction with our MsalTokenCredential
|
|
5
|
+
- /me vs /users/{id} routing for shared mailboxes/calendars
|
|
6
|
+
- A handle to the raw SDK client for cases that need it directly
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from msgraph.graph_service_client import GraphServiceClient
|
|
12
|
+
|
|
13
|
+
from msgraph_mcp import config
|
|
14
|
+
from msgraph_mcp.graph.auth_provider import MsalTokenCredential
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphClient:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._sdk = GraphServiceClient(
|
|
20
|
+
credentials=MsalTokenCredential(),
|
|
21
|
+
scopes=config.SCOPES,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def raw(self) -> GraphServiceClient:
|
|
26
|
+
return self._sdk
|
|
27
|
+
|
|
28
|
+
def mailbox(self, mailbox: str | None):
|
|
29
|
+
"""Return the builder rooted at /me or /users/{mailbox}.
|
|
30
|
+
|
|
31
|
+
Use it like:
|
|
32
|
+
await graph.mailbox(mailbox).messages.get(...)
|
|
33
|
+
await graph.mailbox(mailbox).calendar.events.get(...)
|
|
34
|
+
"""
|
|
35
|
+
if mailbox is None:
|
|
36
|
+
return self._sdk.me
|
|
37
|
+
return self._sdk.users.by_user_id(mailbox)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Graph error mapping.
|
|
2
|
+
|
|
3
|
+
Kiota / msgraph-sdk raises a variety of exception shapes for HTTP errors.
|
|
4
|
+
We normalize them into a single GraphAPIError class that the tool layer
|
|
5
|
+
can catch and turn into a clean MCP tool error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GraphValidationError(RuntimeError):
|
|
14
|
+
"""Raised when tool params fail validation before the Graph call."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphAPIError(RuntimeError):
|
|
18
|
+
"""Raised when Graph returns a non-success response."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *, status: int, code: str, message: str) -> None:
|
|
21
|
+
self.status = status
|
|
22
|
+
self.code = code
|
|
23
|
+
self.message = message
|
|
24
|
+
super().__init__(f"Graph API {status}: {code} — {message}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def map_kiota_error(exc: Any) -> GraphAPIError:
|
|
28
|
+
"""Map an arbitrary kiota/msgraph exception into a GraphAPIError.
|
|
29
|
+
|
|
30
|
+
Kiota's ODataError exposes:
|
|
31
|
+
- response_status_code: int
|
|
32
|
+
- error: object with .code (str) and .message (str)
|
|
33
|
+
Some lower-level errors only expose a status and a string. For anything
|
|
34
|
+
we can't introspect, fall back to status=0 and stringify the exception.
|
|
35
|
+
"""
|
|
36
|
+
status = int(getattr(exc, "response_status_code", 0) or 0)
|
|
37
|
+
inner = getattr(exc, "error", None)
|
|
38
|
+
code = "Unknown"
|
|
39
|
+
message = str(exc) if status == 0 else f"HTTP {status}"
|
|
40
|
+
if inner is not None:
|
|
41
|
+
code = getattr(inner, "code", code) or code
|
|
42
|
+
message = getattr(inner, "message", message) or message
|
|
43
|
+
return GraphAPIError(status=status, code=code, message=message)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Opaque pagination tokens and limit validation.
|
|
2
|
+
|
|
3
|
+
The agent never sees raw @odata.nextLink URLs. We base64-encode the URL
|
|
4
|
+
and validate on decode that it points at graph.microsoft.com.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from msgraph_mcp.graph.errors import GraphValidationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_ALLOWED_HOSTS = {"graph.microsoft.com"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encode_next_link(url: str | None) -> str | None:
|
|
20
|
+
if not url:
|
|
21
|
+
return None
|
|
22
|
+
return base64.urlsafe_b64encode(url.encode("utf-8")).decode("ascii").rstrip("=")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_page_token(token: str) -> str:
|
|
26
|
+
try:
|
|
27
|
+
# Re-pad
|
|
28
|
+
padded = token + "=" * (-len(token) % 4)
|
|
29
|
+
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
30
|
+
url = raw.decode("utf-8")
|
|
31
|
+
except (binascii.Error, UnicodeDecodeError) as exc:
|
|
32
|
+
raise GraphValidationError(f"Invalid page_token: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
parsed = urlparse(url)
|
|
35
|
+
if parsed.scheme != "https" or parsed.hostname not in _ALLOWED_HOSTS:
|
|
36
|
+
raise GraphValidationError(
|
|
37
|
+
f"Invalid page_token: must point at graph.microsoft.com (got {parsed.hostname!r})"
|
|
38
|
+
)
|
|
39
|
+
return url
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_limit(limit: int, *, maximum: int = 100) -> int:
|
|
43
|
+
if not isinstance(limit, int) or limit < 1 or limit > maximum:
|
|
44
|
+
raise GraphValidationError(
|
|
45
|
+
f"limit must be an integer in 1..{maximum} (got {limit!r})"
|
|
46
|
+
)
|
|
47
|
+
return limit
|