msgraph-mcp-server 0.5.1__tar.gz → 0.5.2__tar.gz
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_server-0.5.1 → msgraph_mcp_server-0.5.2}/.github/workflows/ci.yml +3 -3
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.github/workflows/release.yml +2 -2
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/PKG-INFO +1 -1
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/pyproject.toml +1 -1
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/server.json +2 -2
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/token.py +7 -2
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/batch.py +14 -3
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/client.py +3 -2
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/errors.py +9 -2
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_read.py +15 -1
- msgraph_mcp_server-0.5.2/tests/sdk_graph.py +38 -0
- msgraph_mcp_server-0.5.2/tests/test_tool_errors.py +131 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_read.py +92 -4
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/uv.lock +1 -1
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.env.example +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.github/dependabot.yml +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.gitignore +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/CONTRIBUTING.md +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/LICENSE +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/README.md +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/cli.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/msal_app.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/config.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/auth_provider.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/pagination.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/serialize.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/trimming.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/server.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/_binary.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/calendar.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_actions.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_batch.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_folders.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_rules.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_write.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_channels.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_chats.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_content.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/util.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/conftest.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/fixtures/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/fixtures/event_minimal.json +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/fixtures/message_minimal.json +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/integration/__init__.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/integration/test_live_smoke.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/integration/test_rules_and_folders_live.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_auth_cli.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_auth_msal_app.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_auth_token.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_config.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_auth_provider.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_batch.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_client.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_errors.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_me_url.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_pagination.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_serialize.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_trimming.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_server.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_calendar.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_actions.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_batch.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_folders.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_rules.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_write.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_teams_channels.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_teams_chats.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_teams_content.py +0 -0
- {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_util.py +0 -0
|
@@ -19,7 +19,7 @@ jobs:
|
|
|
19
19
|
steps:
|
|
20
20
|
- uses: actions/checkout@v7
|
|
21
21
|
|
|
22
|
-
- uses: astral-sh/setup-uv@v10.0
|
|
22
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
23
23
|
with:
|
|
24
24
|
enable-cache: true
|
|
25
25
|
|
|
@@ -31,7 +31,7 @@ jobs:
|
|
|
31
31
|
steps:
|
|
32
32
|
- uses: actions/checkout@v7
|
|
33
33
|
|
|
34
|
-
- uses: astral-sh/setup-uv@v10.0
|
|
34
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
35
35
|
with:
|
|
36
36
|
enable-cache: true
|
|
37
37
|
|
|
@@ -46,7 +46,7 @@ jobs:
|
|
|
46
46
|
steps:
|
|
47
47
|
- uses: actions/checkout@v7
|
|
48
48
|
|
|
49
|
-
- uses: astral-sh/setup-uv@v10.0
|
|
49
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
50
50
|
with:
|
|
51
51
|
enable-cache: true
|
|
52
52
|
|
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
steps:
|
|
18
18
|
- uses: actions/checkout@v7
|
|
19
19
|
|
|
20
|
-
- uses: astral-sh/setup-uv@v10.0
|
|
20
|
+
- uses: astral-sh/setup-uv@v10.1.0
|
|
21
21
|
with:
|
|
22
22
|
enable-cache: true
|
|
23
23
|
|
|
@@ -40,7 +40,7 @@ jobs:
|
|
|
40
40
|
- uses: actions/checkout@v7
|
|
41
41
|
|
|
42
42
|
- name: Install uv
|
|
43
|
-
uses: astral-sh/setup-uv@v10.0
|
|
43
|
+
uses: astral-sh/setup-uv@v10.1.0
|
|
44
44
|
|
|
45
45
|
- name: Build sdist and wheel
|
|
46
46
|
run: uv build
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: msgraph-mcp-server
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: MCP server for Microsoft Graph: Outlook mail and calendar, plus read-only Microsoft Teams
|
|
5
5
|
Project-URL: Homepage, https://github.com/timfurlong/msgraph-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/timfurlong/msgraph-mcp
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/timfurlong/msgraph-mcp",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.5.
|
|
10
|
+
"version": "0.5.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "msgraph-mcp-server",
|
|
15
|
-
"version": "0.5.
|
|
15
|
+
"version": "0.5.2",
|
|
16
16
|
"runtimeHint": "uvx",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
@@ -6,12 +6,17 @@ get_access_token raises NotAuthenticatedError pointing at the login CLI.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
10
|
+
|
|
9
11
|
from msgraph_mcp import config
|
|
10
12
|
from msgraph_mcp.auth.msal_app import build_app, persist_cache
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
class NotAuthenticatedError(RuntimeError):
|
|
14
|
-
"""Raised when no valid token is available and silent refresh failed.
|
|
15
|
+
class NotAuthenticatedError(ToolError, RuntimeError):
|
|
16
|
+
"""Raised when no valid token is available and silent refresh failed.
|
|
17
|
+
|
|
18
|
+
A ToolError so MCPServer passes the login hint through to the caller.
|
|
19
|
+
"""
|
|
15
20
|
|
|
16
21
|
|
|
17
22
|
_LOGIN_HINT = "Not authenticated. Run `msgraph-mcp-login` in a terminal to sign in."
|
|
@@ -203,6 +203,10 @@ class GraphBatchTransport:
|
|
|
203
203
|
from kiota_abstractions.request_information import (
|
|
204
204
|
RequestInformation, # type: ignore[import-untyped]
|
|
205
205
|
)
|
|
206
|
+
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
|
|
207
|
+
|
|
208
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
209
|
+
from msgraph_mcp.graph.errors import map_kiota_error
|
|
206
210
|
|
|
207
211
|
ri = RequestInformation()
|
|
208
212
|
ri.url = self.GRAPH_BATCH_URL
|
|
@@ -210,7 +214,14 @@ class GraphBatchTransport:
|
|
|
210
214
|
ri.headers.try_add("Content-Type", "application/json")
|
|
211
215
|
ri.content = json.dumps(payload).encode("utf-8")
|
|
212
216
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
217
|
+
# The error map lets kiota parse Graph's error body (code and message)
|
|
218
|
+
# when the $batch request as a whole fails.
|
|
219
|
+
try:
|
|
220
|
+
raw = await self._graph.raw.request_adapter.send_primitive_async(
|
|
221
|
+
ri, "bytes", {"XXX": ODataError}
|
|
222
|
+
)
|
|
223
|
+
except NotAuthenticatedError:
|
|
224
|
+
raise
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
raise map_kiota_error(exc) from exc
|
|
216
227
|
return json.loads(raw)
|
|
@@ -15,8 +15,9 @@ from msgraph_mcp.graph.auth_provider import MsalTokenCredential
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class GraphClient:
|
|
18
|
-
def __init__(self) -> None:
|
|
19
|
-
|
|
18
|
+
def __init__(self, sdk: GraphServiceClient | None = None) -> None:
|
|
19
|
+
# `sdk` lets tests supply a client wired to a mock transport.
|
|
20
|
+
self._sdk = sdk or GraphServiceClient(
|
|
20
21
|
credentials=MsalTokenCredential(),
|
|
21
22
|
scopes=config.SCOPES,
|
|
22
23
|
)
|
|
@@ -3,18 +3,25 @@
|
|
|
3
3
|
Kiota / msgraph-sdk raises a variety of exception shapes for HTTP errors.
|
|
4
4
|
We normalize them into a single GraphAPIError class that the tool layer
|
|
5
5
|
can catch and turn into a clean MCP tool error.
|
|
6
|
+
|
|
7
|
+
Both error classes subclass MCPServer's ToolError. MCPServer reports any other
|
|
8
|
+
exception as a bare "Error executing tool <name>" and keeps its text on the
|
|
9
|
+
server, so without that the caller would never see the Graph status, code and
|
|
10
|
+
message.
|
|
6
11
|
"""
|
|
7
12
|
|
|
8
13
|
from __future__ import annotations
|
|
9
14
|
|
|
10
15
|
from typing import Any
|
|
11
16
|
|
|
17
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
18
|
+
|
|
12
19
|
|
|
13
|
-
class GraphValidationError(RuntimeError):
|
|
20
|
+
class GraphValidationError(ToolError, RuntimeError):
|
|
14
21
|
"""Raised when tool params fail validation before the Graph call."""
|
|
15
22
|
|
|
16
23
|
|
|
17
|
-
class GraphAPIError(RuntimeError):
|
|
24
|
+
class GraphAPIError(ToolError, RuntimeError):
|
|
18
25
|
"""Raised when Graph returns a non-success response."""
|
|
19
26
|
|
|
20
27
|
def __init__(self, *, status: int, code: str, message: str) -> None:
|
|
@@ -42,13 +42,21 @@ def _list_messages_query(*, limit: int, filter_expr: str | None = None):
|
|
|
42
42
|
](query_parameters=qp)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# Graph refuses a $filter combined with $orderby unless the filter names the
|
|
46
|
+
# sort property first (400 InefficientFilter, "The restriction or sort order is
|
|
47
|
+
# too complex for this operation"). Leading every filter with this always-true
|
|
48
|
+
# clause satisfies that rule, so filtered results keep the server-side
|
|
49
|
+
# newest-first order and stay newest first across pages.
|
|
50
|
+
_SORT_CLAUSE = "receivedDateTime ge 1900-01-01T00:00:00Z"
|
|
51
|
+
|
|
52
|
+
|
|
45
53
|
def _build_list_filter(*, unread_only: bool, filter: str | None) -> str | None:
|
|
46
54
|
parts: list[str] = []
|
|
47
55
|
if unread_only:
|
|
48
56
|
parts.append("isRead eq false")
|
|
49
57
|
if filter:
|
|
50
58
|
parts.append(f"({filter})")
|
|
51
|
-
return " and ".join(parts) if parts else None
|
|
59
|
+
return " and ".join([_SORT_CLAUSE, *parts]) if parts else None
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
def _search_query(*, limit: int, query: str):
|
|
@@ -74,6 +82,12 @@ async def list_messages(
|
|
|
74
82
|
) -> dict:
|
|
75
83
|
"""List messages from a mail folder, newest first.
|
|
76
84
|
|
|
85
|
+
Filtered results are newest first too. When `filter` or `unread_only` is
|
|
86
|
+
given, the server-side $filter starts with
|
|
87
|
+
`receivedDateTime ge 1900-01-01T00:00:00Z and`, because Graph only sorts a
|
|
88
|
+
filtered query by receivedDateTime when the filter names it first. That
|
|
89
|
+
clause matches every message, so it doesn't narrow the results.
|
|
90
|
+
|
|
77
91
|
Args:
|
|
78
92
|
folder_id: Folder id, or a well-known name (inbox, sentitems, drafts,
|
|
79
93
|
deleteditems, archive, junkemail). Default: inbox.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""A GraphClient backed by the real msgraph SDK over a mock HTTP transport.
|
|
2
|
+
|
|
3
|
+
Tools built on it send real requests through the SDK's middleware, so tests
|
|
4
|
+
can check the exact URL a tool sends and answer with real Graph response
|
|
5
|
+
bodies, including error bodies. No auth is involved.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from kiota_abstractions.authentication import AnonymousAuthenticationProvider
|
|
14
|
+
from msgraph.graph_request_adapter import GraphRequestAdapter, options
|
|
15
|
+
from msgraph.graph_service_client import GraphServiceClient
|
|
16
|
+
from msgraph_core import GraphClientFactory
|
|
17
|
+
|
|
18
|
+
from msgraph_mcp.graph.client import GraphClient
|
|
19
|
+
|
|
20
|
+
Handler = Callable[[httpx.Request], httpx.Response]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sdk_graph(handler: Handler) -> GraphClient:
|
|
24
|
+
http = GraphClientFactory.create_with_default_middleware(
|
|
25
|
+
options=options,
|
|
26
|
+
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
27
|
+
)
|
|
28
|
+
sdk = GraphServiceClient(
|
|
29
|
+
request_adapter=GraphRequestAdapter(
|
|
30
|
+
AnonymousAuthenticationProvider(), client=http
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return GraphClient(sdk=sdk)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def graph_error(status: int, code: str, message: str) -> httpx.Response:
|
|
37
|
+
"""A Graph error response, shaped the way Graph returns one."""
|
|
38
|
+
return httpx.Response(status, json={"error": {"code": code, "message": message}})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tool failures reach the MCP caller with their details.
|
|
2
|
+
|
|
3
|
+
MCPServer reports any exception that isn't a ToolError as a bare
|
|
4
|
+
"Error executing tool <name>" and keeps its text on the server. These tests
|
|
5
|
+
call tools through a real in-process MCP client, so they check exactly what
|
|
6
|
+
the caller sees.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
from mcp import Client
|
|
14
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
15
|
+
from mcp.types import CallToolResult, TextContent
|
|
16
|
+
|
|
17
|
+
from msgraph_mcp import server
|
|
18
|
+
from msgraph_mcp.auth.token import NotAuthenticatedError
|
|
19
|
+
from msgraph_mcp.graph.batch import GraphBatchTransport
|
|
20
|
+
from msgraph_mcp.graph.errors import GraphAPIError, GraphValidationError
|
|
21
|
+
from tests.sdk_graph import graph_error, sdk_graph
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def fake_env(monkeypatch):
|
|
26
|
+
monkeypatch.setenv("MSGRAPH_MCP_CLIENT_ID", "fake-client-id")
|
|
27
|
+
monkeypatch.setenv("MSGRAPH_MCP_TENANT_ID", "fake-tenant-id")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _call(graph, tool: str, arguments: dict) -> CallToolResult:
|
|
31
|
+
with patch("msgraph_mcp.server.GraphClient", return_value=graph):
|
|
32
|
+
mcp = server.build_server()
|
|
33
|
+
async with Client(mcp) as client:
|
|
34
|
+
return await client.call_tool(tool, arguments)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _text(result: CallToolResult) -> str:
|
|
38
|
+
return " ".join(c.text for c in result.content if isinstance(c, TextContent))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
"error_class", [GraphAPIError, GraphValidationError, NotAuthenticatedError]
|
|
43
|
+
)
|
|
44
|
+
def test_anticipated_errors_are_tool_errors(error_class):
|
|
45
|
+
assert issubclass(error_class, ToolError)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
("tool", "arguments"),
|
|
50
|
+
[
|
|
51
|
+
(
|
|
52
|
+
"list_messages",
|
|
53
|
+
{"folder_id": "sentitems", "filter": "contains(subject,'x')"},
|
|
54
|
+
),
|
|
55
|
+
("search_messages", {"query": "report"}),
|
|
56
|
+
("get_message", {"message_id": "m1"}),
|
|
57
|
+
("list_folders", {}),
|
|
58
|
+
("list_rules", {}),
|
|
59
|
+
(
|
|
60
|
+
"list_events",
|
|
61
|
+
{
|
|
62
|
+
"calendar_id": "c1",
|
|
63
|
+
"start_datetime": "2026-11-01T00:00:00Z",
|
|
64
|
+
"end_datetime": "2026-11-08T00:00:00Z",
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
("list_chats", {}),
|
|
68
|
+
("whoami", {}),
|
|
69
|
+
("batch_mark_read", {"message_ids": ["m1", "m2"]}),
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
async def test_graph_error_reaches_caller(fake_env, tool, arguments):
|
|
73
|
+
graph = sdk_graph(
|
|
74
|
+
lambda request: graph_error(
|
|
75
|
+
400,
|
|
76
|
+
"InefficientFilter",
|
|
77
|
+
"The restriction or sort order is too complex for this operation.",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
result = await _call(graph, tool, arguments)
|
|
82
|
+
|
|
83
|
+
assert result.is_error
|
|
84
|
+
text = _text(result)
|
|
85
|
+
assert "400" in text
|
|
86
|
+
assert "InefficientFilter" in text
|
|
87
|
+
assert "The restriction or sort order is too complex" in text
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_validation_error_reaches_caller(fake_env):
|
|
91
|
+
result = await _call(MagicMock(), "list_messages", {"limit": 200})
|
|
92
|
+
|
|
93
|
+
assert result.is_error
|
|
94
|
+
assert "limit must be an integer in 1..100" in _text(result)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_login_hint_reaches_caller(fake_env):
|
|
98
|
+
graph = MagicMock()
|
|
99
|
+
graph.mailbox.return_value.get.side_effect = NotAuthenticatedError(
|
|
100
|
+
"Not authenticated. Run `msgraph-mcp-login` in a terminal to sign in."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
result = await _call(graph, "whoami", {})
|
|
104
|
+
|
|
105
|
+
assert result.is_error
|
|
106
|
+
assert "msgraph-mcp-login" in _text(result)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_batch_transport_maps_graph_errors():
|
|
110
|
+
graph = sdk_graph(
|
|
111
|
+
lambda request: graph_error(
|
|
112
|
+
401, "InvalidAuthenticationToken", "Access token has expired."
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with pytest.raises(GraphAPIError) as excinfo:
|
|
117
|
+
await GraphBatchTransport(graph).post_batch({"requests": []})
|
|
118
|
+
|
|
119
|
+
assert excinfo.value.status == 401
|
|
120
|
+
assert excinfo.value.code == "InvalidAuthenticationToken"
|
|
121
|
+
assert excinfo.value.message == "Access token has expired."
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def test_batch_transport_passes_through_success():
|
|
125
|
+
graph = sdk_graph(
|
|
126
|
+
lambda request: httpx.Response(200, json={"responses": [{"id": "1"}]})
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
out = await GraphBatchTransport(graph).post_batch({"requests": []})
|
|
130
|
+
|
|
131
|
+
assert out == {"responses": [{"id": "1"}]}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
from types import SimpleNamespace
|
|
2
2
|
from unittest.mock import AsyncMock, MagicMock
|
|
3
3
|
|
|
4
|
+
import httpx
|
|
4
5
|
import pytest
|
|
5
6
|
|
|
6
7
|
from msgraph_mcp.graph.errors import GraphValidationError
|
|
7
8
|
from msgraph_mcp.tools import mail_read
|
|
9
|
+
from tests.sdk_graph import graph_error, sdk_graph
|
|
10
|
+
|
|
11
|
+
# Graph only sorts a filtered message query by receivedDateTime when the
|
|
12
|
+
# filter names receivedDateTime first, so list_messages leads every filter
|
|
13
|
+
# with this always-true clause.
|
|
14
|
+
SORT_CLAUSE = "receivedDateTime ge 1900-01-01T00:00:00Z"
|
|
8
15
|
|
|
9
16
|
|
|
10
17
|
def _fake_message(*, id_="m1", subject="Hi"):
|
|
@@ -110,7 +117,7 @@ async def test_list_messages_default_passes_no_filter():
|
|
|
110
117
|
async def test_list_messages_unread_only_sets_isread_filter():
|
|
111
118
|
graph, folder = _list_messages_mock_setup()
|
|
112
119
|
await mail_read.list_messages(graph=graph, unread_only=True)
|
|
113
|
-
assert _captured_filter(folder) == "isRead eq false"
|
|
120
|
+
assert _captured_filter(folder) == f"{SORT_CLAUSE} and isRead eq false"
|
|
114
121
|
|
|
115
122
|
|
|
116
123
|
@pytest.mark.asyncio
|
|
@@ -119,7 +126,10 @@ async def test_list_messages_raw_filter_passes_through():
|
|
|
119
126
|
await mail_read.list_messages(
|
|
120
127
|
graph=graph, filter="from/emailAddress/address eq 'a@x.com'"
|
|
121
128
|
)
|
|
122
|
-
assert
|
|
129
|
+
assert (
|
|
130
|
+
_captured_filter(folder)
|
|
131
|
+
== f"{SORT_CLAUSE} and (from/emailAddress/address eq 'a@x.com')"
|
|
132
|
+
)
|
|
123
133
|
|
|
124
134
|
|
|
125
135
|
@pytest.mark.asyncio
|
|
@@ -128,11 +138,89 @@ async def test_list_messages_combines_unread_and_filter_with_and():
|
|
|
128
138
|
await mail_read.list_messages(
|
|
129
139
|
graph=graph, unread_only=True, filter="contains(subject,'report')"
|
|
130
140
|
)
|
|
131
|
-
assert (
|
|
132
|
-
|
|
141
|
+
assert _captured_filter(folder) == (
|
|
142
|
+
f"{SORT_CLAUSE} and isRead eq false and (contains(subject,'report'))"
|
|
133
143
|
)
|
|
134
144
|
|
|
135
145
|
|
|
146
|
+
def _graph_with_sort_rule(seen: list[httpx.URL]):
|
|
147
|
+
"""Real SDK over a transport that applies Graph's $filter/$orderby rule.
|
|
148
|
+
|
|
149
|
+
Graph answers 400 InefficientFilter when a $filter is combined with an
|
|
150
|
+
$orderby whose property the filter doesn't name first.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
154
|
+
seen.append(request.url)
|
|
155
|
+
filter_expr = request.url.params.get("$filter")
|
|
156
|
+
orderby = request.url.params.get("$orderby")
|
|
157
|
+
if filter_expr and orderby:
|
|
158
|
+
sort_property = orderby.split()[0]
|
|
159
|
+
if not filter_expr.lstrip("(").startswith(sort_property):
|
|
160
|
+
return graph_error(
|
|
161
|
+
400,
|
|
162
|
+
"InefficientFilter",
|
|
163
|
+
"The restriction or sort order is too complex for this operation.",
|
|
164
|
+
)
|
|
165
|
+
return httpx.Response(200, json={"value": [{"id": "m1", "subject": "Hi"}]})
|
|
166
|
+
|
|
167
|
+
return sdk_graph(handler)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def test_list_messages_filtered_request_sorts_newest_first():
|
|
171
|
+
seen: list[httpx.URL] = []
|
|
172
|
+
graph = _graph_with_sort_rule(seen)
|
|
173
|
+
|
|
174
|
+
result = await mail_read.list_messages(
|
|
175
|
+
graph=graph,
|
|
176
|
+
folder_id="sentitems",
|
|
177
|
+
filter="contains(subject,'Quarterly report')",
|
|
178
|
+
limit=5,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
assert [item["id"] for item in result["items"]] == ["m1"]
|
|
182
|
+
(url,) = seen
|
|
183
|
+
assert url.path == "/v1.0/me/mailFolders/sentitems/messages"
|
|
184
|
+
assert url.params["$filter"] == (
|
|
185
|
+
f"{SORT_CLAUSE} and (contains(subject,'Quarterly report'))"
|
|
186
|
+
)
|
|
187
|
+
assert url.params["$orderby"] == "receivedDateTime DESC"
|
|
188
|
+
assert url.params["$top"] == "5"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.parametrize(
|
|
192
|
+
("unread_only", "filter_expr"),
|
|
193
|
+
[
|
|
194
|
+
(False, "contains(subject,'report')"),
|
|
195
|
+
(False, "hasAttachments eq false"),
|
|
196
|
+
(False, "from/emailAddress/address eq 'a@example.com'"),
|
|
197
|
+
(False, "receivedDateTime ge 2026-05-01T00:00:00Z"),
|
|
198
|
+
(True, None),
|
|
199
|
+
(True, "contains(body/content,'invoice')"),
|
|
200
|
+
],
|
|
201
|
+
)
|
|
202
|
+
async def test_list_messages_filters_satisfy_graph_sort_rule(unread_only, filter_expr):
|
|
203
|
+
seen: list[httpx.URL] = []
|
|
204
|
+
graph = _graph_with_sort_rule(seen)
|
|
205
|
+
|
|
206
|
+
result = await mail_read.list_messages(
|
|
207
|
+
graph=graph, unread_only=unread_only, filter=filter_expr
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
assert [item["id"] for item in result["items"]] == ["m1"]
|
|
211
|
+
assert seen[0].params["$orderby"] == "receivedDateTime DESC"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def test_list_messages_unfiltered_request_has_no_filter():
|
|
215
|
+
seen: list[httpx.URL] = []
|
|
216
|
+
graph = _graph_with_sort_rule(seen)
|
|
217
|
+
|
|
218
|
+
await mail_read.list_messages(graph=graph)
|
|
219
|
+
|
|
220
|
+
assert "$filter" not in seen[0].params
|
|
221
|
+
assert seen[0].params["$orderby"] == "receivedDateTime DESC"
|
|
222
|
+
|
|
223
|
+
|
|
136
224
|
@pytest.mark.asyncio
|
|
137
225
|
async def test_search_messages_calls_root_messages_with_search_param():
|
|
138
226
|
graph = MagicMock()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/auth_provider.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_channels.py
RENAMED
|
File without changes
|
|
File without changes
|
{msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_content.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|