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.
Files changed (74) hide show
  1. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.github/workflows/ci.yml +3 -3
  2. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.github/workflows/release.yml +2 -2
  3. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/PKG-INFO +1 -1
  4. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/pyproject.toml +1 -1
  5. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/server.json +2 -2
  6. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/token.py +7 -2
  7. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/batch.py +14 -3
  8. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/client.py +3 -2
  9. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/errors.py +9 -2
  10. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_read.py +15 -1
  11. msgraph_mcp_server-0.5.2/tests/sdk_graph.py +38 -0
  12. msgraph_mcp_server-0.5.2/tests/test_tool_errors.py +131 -0
  13. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_read.py +92 -4
  14. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/uv.lock +1 -1
  15. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.env.example +0 -0
  16. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.github/dependabot.yml +0 -0
  17. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/.gitignore +0 -0
  18. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/CONTRIBUTING.md +0 -0
  19. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/LICENSE +0 -0
  20. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/README.md +0 -0
  21. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/__init__.py +0 -0
  22. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/__init__.py +0 -0
  23. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/cli.py +0 -0
  24. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/auth/msal_app.py +0 -0
  25. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/config.py +0 -0
  26. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/__init__.py +0 -0
  27. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/auth_provider.py +0 -0
  28. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/pagination.py +0 -0
  29. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/serialize.py +0 -0
  30. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/graph/trimming.py +0 -0
  31. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/server.py +0 -0
  32. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/__init__.py +0 -0
  33. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/_binary.py +0 -0
  34. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/calendar.py +0 -0
  35. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_actions.py +0 -0
  36. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_batch.py +0 -0
  37. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_folders.py +0 -0
  38. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_rules.py +0 -0
  39. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/mail_write.py +0 -0
  40. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_channels.py +0 -0
  41. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_chats.py +0 -0
  42. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/teams_content.py +0 -0
  43. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/src/msgraph_mcp/tools/util.py +0 -0
  44. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/__init__.py +0 -0
  45. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/conftest.py +0 -0
  46. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/fixtures/__init__.py +0 -0
  47. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/fixtures/event_minimal.json +0 -0
  48. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/fixtures/message_minimal.json +0 -0
  49. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/integration/__init__.py +0 -0
  50. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/integration/test_live_smoke.py +0 -0
  51. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/integration/test_rules_and_folders_live.py +0 -0
  52. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_auth_cli.py +0 -0
  53. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_auth_msal_app.py +0 -0
  54. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_auth_token.py +0 -0
  55. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_config.py +0 -0
  56. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_auth_provider.py +0 -0
  57. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_batch.py +0 -0
  58. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_client.py +0 -0
  59. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_errors.py +0 -0
  60. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_me_url.py +0 -0
  61. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_pagination.py +0 -0
  62. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_serialize.py +0 -0
  63. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_graph_trimming.py +0 -0
  64. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_server.py +0 -0
  65. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_calendar.py +0 -0
  66. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_actions.py +0 -0
  67. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_batch.py +0 -0
  68. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_folders.py +0 -0
  69. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_rules.py +0 -0
  70. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_mail_write.py +0 -0
  71. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_teams_channels.py +0 -0
  72. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_teams_chats.py +0 -0
  73. {msgraph_mcp_server-0.5.1 → msgraph_mcp_server-0.5.2}/tests/test_tools_teams_content.py +0 -0
  74. {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.1
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.1
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.1
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.1
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.1
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.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "msgraph-mcp-server"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "MCP server for Microsoft Graph: Outlook mail and calendar, plus read-only Microsoft Teams"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/timfurlong/msgraph-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.5.1",
10
+ "version": "0.5.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "msgraph-mcp-server",
15
- "version": "0.5.1",
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
- raw = await self._graph.raw.request_adapter.send_primitive_async(
214
- ri, "bytes", {}
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
- self._sdk = GraphServiceClient(
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 _captured_filter(folder) == "(from/emailAddress/address eq 'a@x.com')"
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
- _captured_filter(folder) == "isRead eq false and (contains(subject,'report'))"
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()
@@ -971,7 +971,7 @@ wheels = [
971
971
 
972
972
  [[package]]
973
973
  name = "msgraph-mcp-server"
974
- version = "0.5.1"
974
+ version = "0.5.2"
975
975
  source = { editable = "." }
976
976
  dependencies = [
977
977
  { name = "mcp" },