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.
File without changes
File without changes
@@ -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