matimo-microsoft 0.1.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.
Files changed (24) hide show
  1. matimo_microsoft/__init__.py +17 -0
  2. matimo_microsoft/graph_client.py +236 -0
  3. matimo_microsoft/tools/ms_create_calendar_event/definition.yaml +100 -0
  4. matimo_microsoft/tools/ms_create_calendar_event/ms_create_calendar_event.py +81 -0
  5. matimo_microsoft/tools/ms_create_document/definition.yaml +103 -0
  6. matimo_microsoft/tools/ms_create_document/ms_create_document.py +106 -0
  7. matimo_microsoft/tools/ms_get_email/definition.yaml +88 -0
  8. matimo_microsoft/tools/ms_get_email/ms_get_email.py +94 -0
  9. matimo_microsoft/tools/ms_list_files/definition.yaml +81 -0
  10. matimo_microsoft/tools/ms_list_files/ms_list_files.py +72 -0
  11. matimo_microsoft/tools/ms_publish_to_sharepoint/definition.yaml +92 -0
  12. matimo_microsoft/tools/ms_publish_to_sharepoint/ms_publish_to_sharepoint.py +122 -0
  13. matimo_microsoft/tools/ms_read_file/definition.yaml +74 -0
  14. matimo_microsoft/tools/ms_read_file/ms_read_file.py +96 -0
  15. matimo_microsoft/tools/ms_search_knowledge/definition.yaml +99 -0
  16. matimo_microsoft/tools/ms_search_knowledge/ms_search_knowledge.py +109 -0
  17. matimo_microsoft/tools/ms_send_email/definition.yaml +94 -0
  18. matimo_microsoft/tools/ms_send_email/ms_send_email.py +99 -0
  19. matimo_microsoft/tools/ms_send_teams_message/definition.yaml +87 -0
  20. matimo_microsoft/tools/ms_send_teams_message/ms_send_teams_message.py +59 -0
  21. matimo_microsoft-0.1.0.dist-info/METADATA +119 -0
  22. matimo_microsoft-0.1.0.dist-info/RECORD +24 -0
  23. matimo_microsoft-0.1.0.dist-info/WHEEL +4 -0
  24. matimo_microsoft-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,17 @@
1
+ """Matimo microsoft provider — exposes the path to YAML tool definitions."""
2
+ from __future__ import annotations
3
+
4
+ import importlib.resources
5
+ from pathlib import Path
6
+
7
+
8
+ def get_tools_path() -> str:
9
+ """Return the absolute path to the bundled microsoft tool definitions."""
10
+ try:
11
+ ref = importlib.resources.files("matimo_microsoft") / "tools"
12
+ return str(ref)
13
+ except Exception:
14
+ return str(Path(__file__).parent / "tools")
15
+
16
+
17
+ __all__ = ["get_tools_path"]
@@ -0,0 +1,236 @@
1
+ """
2
+ Shared Microsoft Graph helpers for all 'type: function' tools in this package.
3
+ Mirrors: typescript/packages/microsoft/tools/graph-client.ts
4
+
5
+ Conventions (mirrors matimo-slack and matimo-gmail):
6
+ - Tools NEVER perform OAuth token exchange. A delegated Graph access token is
7
+ injected at execution time via params['MICROSOFT_GRAPH_ACCESS_TOKEN'] (merged
8
+ from credentials by the function executor) or the MICROSOFT_GRAPH_ACCESS_TOKEN
9
+ environment variable as a fallback.
10
+ - Every Graph error is normalized into a MatimoError with the closest matching
11
+ ErrorCode (Matimo has no per-provider error classes — see matimo.errors).
12
+ - Unlike the TypeScript executor, Python's MatimoInstance.execute() re-raises
13
+ MatimoError rather than converting it to {success: False} — every failure path
14
+ here raises directly.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ from typing import Any, Literal
21
+
22
+ import httpx
23
+
24
+ from matimo.errors import ErrorCode, MatimoError
25
+
26
+ GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
27
+
28
+ _RETRYABLE_STATUS_CODES = {429, 500, 503}
29
+ _MAX_RETRIES = 3
30
+ _INITIAL_BACKOFF_S = 0.5
31
+
32
+ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
33
+ ResponseType = Literal["json", "bytes"]
34
+
35
+
36
+ def get_access_token(params: dict[str, Any]) -> str:
37
+ """
38
+ Resolve the delegated Graph access token. Matimo never exchanges OAuth codes —
39
+ the token must already be present in the merged params (credentials) or the
40
+ environment.
41
+ """
42
+ token = params.get("MICROSOFT_GRAPH_ACCESS_TOKEN") or os.environ.get(
43
+ "MICROSOFT_GRAPH_ACCESS_TOKEN"
44
+ )
45
+
46
+ if not token:
47
+ raise MatimoError(
48
+ "Microsoft Graph access token is missing. Provide it via "
49
+ "credentials.MICROSOFT_GRAPH_ACCESS_TOKEN or the MICROSOFT_GRAPH_ACCESS_TOKEN "
50
+ "environment variable. Matimo never performs the OAuth exchange itself — "
51
+ "connect Microsoft in Nova first.",
52
+ ErrorCode.AUTH_FAILED,
53
+ {"provider": "microsoft", "placeholder": "MICROSOFT_GRAPH_ACCESS_TOKEN"},
54
+ )
55
+
56
+ return str(token)
57
+
58
+
59
+ def require_params(params: dict[str, Any], required: list[str], tool_name: str) -> None:
60
+ """
61
+ Validate required parameters BEFORE any network call, mirroring the
62
+ "ValidationError before any API call" requirement. Raises VALIDATION_FAILED.
63
+ """
64
+ missing = [name for name in required if params.get(name) in (None, "")]
65
+
66
+ if missing:
67
+ raise MatimoError(
68
+ f"{tool_name}: missing required parameter(s): {', '.join(missing)}",
69
+ ErrorCode.VALIDATION_FAILED,
70
+ {"toolName": tool_name, "missingParams": missing},
71
+ )
72
+
73
+
74
+ def _parse_retry_after_seconds(retry_after_header: str | None) -> float | None:
75
+ """
76
+ Parse a `Retry-After` header into delta-seconds, returning None when it's
77
+ absent or not a numeric delay. Per RFC 9110 §10.2.3, `Retry-After` MAY be
78
+ an HTTP-date instead of delta-seconds — float() raises ValueError on that
79
+ (unlike JS's Number(), which yields NaN), so the conversion must be guarded
80
+ to avoid crashing error mapping for 429 responses.
81
+ """
82
+ if retry_after_header is None:
83
+ return None
84
+ try:
85
+ return float(retry_after_header)
86
+ except (TypeError, ValueError):
87
+ return None
88
+
89
+
90
+ def map_graph_error(
91
+ status: int,
92
+ data: Any,
93
+ headers: httpx.Headers | dict[str, str] | None,
94
+ resource_type: str,
95
+ ) -> MatimoError:
96
+ """
97
+ Map a Microsoft Graph HTTP error response onto a MatimoError using the closest
98
+ matching ErrorCode (Matimo has no CredentialError/NotFoundError/ProviderError
99
+ classes — see matimo.errors):
100
+ 401/403 -> AUTH_FAILED ("Microsoft Graph access denied. Check connection status in Nova.")
101
+ 404 -> FILE_NOT_FOUND (details.resourceType identifies what was missing)
102
+ 429 -> RATE_LIMIT_EXCEEDED (details.retryAfterSeconds carries Retry-After)
103
+ 500/503 -> EXECUTION_FAILED (retryable)
104
+ other -> EXECUTION_FAILED
105
+ """
106
+ graph_error = data.get("error") if isinstance(data, dict) else None
107
+ details: dict[str, Any] = {"statusCode": status, "graphError": graph_error, "resourceType": resource_type}
108
+
109
+ if status in (401, 403):
110
+ return MatimoError(
111
+ "Microsoft Graph access denied. Check connection status in Nova.",
112
+ ErrorCode.AUTH_FAILED,
113
+ details,
114
+ )
115
+
116
+ if status == 404:
117
+ return MatimoError(f"{resource_type} not found.", ErrorCode.FILE_NOT_FOUND, details)
118
+
119
+ if status == 429:
120
+ retry_after_header = None
121
+ if headers is not None:
122
+ retry_after_header = headers.get("retry-after") or headers.get("Retry-After")
123
+ retry_after_seconds = _parse_retry_after_seconds(retry_after_header)
124
+ return MatimoError(
125
+ "Microsoft Graph rate limit exceeded. Respect Retry-After before retrying.",
126
+ ErrorCode.RATE_LIMIT_EXCEEDED,
127
+ {**details, "retryAfterSeconds": retry_after_seconds},
128
+ )
129
+
130
+ if status in (500, 503):
131
+ return MatimoError(
132
+ "Microsoft Graph service is temporarily unavailable. Please retry shortly.",
133
+ ErrorCode.EXECUTION_FAILED,
134
+ details,
135
+ )
136
+
137
+ return MatimoError(
138
+ f"Microsoft Graph request failed with status {status}.",
139
+ ErrorCode.EXECUTION_FAILED,
140
+ details,
141
+ )
142
+
143
+
144
+ async def graph_request(
145
+ *,
146
+ method: HttpMethod,
147
+ path: str,
148
+ token: str,
149
+ query: dict[str, Any] | None = None,
150
+ body: Any = None,
151
+ headers: dict[str, str] | None = None,
152
+ resource_type: str = "Resource",
153
+ response_type: ResponseType = "json",
154
+ allow_empty_response: bool = False,
155
+ ) -> Any:
156
+ """
157
+ Perform an authenticated Microsoft Graph request with retry-on-429/5xx
158
+ (respecting Retry-After, exponential backoff, max 3 retries) and normalized
159
+ MatimoError mapping for every other failure.
160
+
161
+ Args:
162
+ path: Path relative to https://graph.microsoft.com/v1.0, e.g. '/me/messages'
163
+ response_type: 'bytes' for binary downloads (e.g. file content)
164
+ allow_empty_response: Treat a 204/empty body as success and return None
165
+ (e.g. publish, sendMail)
166
+ """
167
+ url = f"{GRAPH_BASE_URL}{path}"
168
+
169
+ is_json_body = body is not None and not isinstance(body, (bytes, bytearray))
170
+ request_headers: dict[str, str] = {
171
+ "Authorization": f"Bearer {token}",
172
+ **({"Accept": "application/json"} if response_type == "json" else {}),
173
+ **({"Content-Type": "application/json"} if is_json_body else {}),
174
+ **(headers or {}),
175
+ }
176
+
177
+ request_kwargs: dict[str, Any] = {}
178
+ if isinstance(body, (bytes, bytearray)):
179
+ request_kwargs["content"] = bytes(body)
180
+ elif body is not None:
181
+ request_kwargs["json"] = body
182
+
183
+ filtered_query = {
184
+ key: value
185
+ for key, value in (query or {}).items()
186
+ if value is not None and value != ""
187
+ }
188
+
189
+ attempt = 0
190
+ async with httpx.AsyncClient(timeout=30.0) as client:
191
+ while True:
192
+ try:
193
+ response = await client.request(
194
+ method,
195
+ url,
196
+ params=filtered_query or None,
197
+ headers=request_headers,
198
+ **request_kwargs,
199
+ )
200
+ except httpx.HTTPError as exc:
201
+ raise MatimoError(
202
+ "Microsoft Graph request failed before a response was received (network error).",
203
+ ErrorCode.NETWORK_ERROR,
204
+ {"path": path, "originalError": str(exc)},
205
+ cause=exc,
206
+ ) from exc
207
+
208
+ if 200 <= response.status_code < 300:
209
+ if allow_empty_response and (response.status_code == 204 or not response.content):
210
+ return None
211
+ if response_type == "bytes":
212
+ return response.content
213
+ if not response.content:
214
+ return None
215
+ return response.json()
216
+
217
+ try:
218
+ error_body: Any = response.json()
219
+ except ValueError:
220
+ error_body = None
221
+
222
+ error = map_graph_error(response.status_code, error_body, response.headers, resource_type)
223
+
224
+ is_retryable = response.status_code in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES
225
+ if not is_retryable:
226
+ raise error
227
+
228
+ retry_after_seconds = error.details.get("retryAfterSeconds") if error.details else None
229
+ delay_s = (
230
+ retry_after_seconds
231
+ if isinstance(retry_after_seconds, (int, float))
232
+ else _INITIAL_BACKOFF_S * (2**attempt)
233
+ )
234
+
235
+ attempt += 1
236
+ await asyncio.sleep(delay_s)
@@ -0,0 +1,100 @@
1
+ name: ms_create_calendar_event
2
+ description: |
3
+ Create an event on the signed-in user's default calendar (POST /me/events).
4
+ Supports attendees, location, a single IANA/Windows time zone for both start and
5
+ end, and Microsoft Teams online meetings (isOnlineMeeting), returning the meeting's
6
+ join URL when one is created.
7
+ version: '1.0.0'
8
+ status: approved
9
+ risk: medium
10
+
11
+ parameters:
12
+ subject:
13
+ type: string
14
+ description: Title of the event
15
+ required: true
16
+
17
+ body:
18
+ type: string
19
+ description: Description / agenda for the event (plain text)
20
+ required: false
21
+
22
+ start:
23
+ type: string
24
+ description: 'Start date and time, e.g. "2026-06-15T09:00:00"'
25
+ required: true
26
+
27
+ end:
28
+ type: string
29
+ description: 'End date and time, e.g. "2026-06-15T09:30:00"'
30
+ required: true
31
+
32
+ timezone:
33
+ type: string
34
+ description: 'IANA or Windows time zone applied to both `start` and `end`, e.g. "America/Los_Angeles"'
35
+ required: false
36
+ default: UTC
37
+
38
+ attendees:
39
+ type: array
40
+ description: Email addresses of attendees to invite
41
+ required: false
42
+
43
+ location:
44
+ type: string
45
+ description: Free-text location / room name for the event
46
+ required: false
47
+
48
+ is_online_meeting:
49
+ type: boolean
50
+ description: When true, Microsoft Teams creates an online meeting for this event
51
+ required: false
52
+ default: false
53
+
54
+ execution:
55
+ type: function
56
+ code: ./ms_create_calendar_event.py
57
+ timeout: 25000
58
+
59
+ authentication:
60
+ type: oauth2
61
+ provider: microsoft
62
+ scopes:
63
+ - https://graph.microsoft.com/Calendars.ReadWrite
64
+
65
+ output_schema:
66
+ type: object
67
+ properties:
68
+ success:
69
+ type: boolean
70
+ event_id:
71
+ type: string
72
+ web_link:
73
+ type: string
74
+ join_url:
75
+ type: string
76
+ description: Microsoft Teams meeting join URL — present only when is_online_meeting is true
77
+
78
+ error_handling:
79
+ retry: 1
80
+ backoff_type: exponential
81
+ initial_delay_ms: 1000
82
+
83
+ tags: [microsoft, graph, calendar, outlook, teams, write]
84
+
85
+ examples:
86
+ - name: Schedule a 30-minute internal sync
87
+ params:
88
+ subject: 'Sprint planning'
89
+ start: '2026-06-15T09:00:00'
90
+ end: '2026-06-15T09:30:00'
91
+ timezone: 'America/Los_Angeles'
92
+ attendees: ['alice@contoso.com', 'bob@contoso.com']
93
+ - name: Schedule an online Teams meeting with a location fallback
94
+ params:
95
+ subject: 'All-hands'
96
+ start: '2026-06-20T17:00:00'
97
+ end: '2026-06-20T18:00:00'
98
+ timezone: UTC
99
+ location: 'Building 4, Room 200'
100
+ is_online_meeting: true
@@ -0,0 +1,81 @@
1
+ """
2
+ ms_create_calendar_event — POST /me/events
3
+ https://learn.microsoft.com/en-us/graph/api/user-post-events
4
+ Mirrors: typescript/packages/microsoft/tools/ms_create_calendar_event/ms_create_calendar_event.ts
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from matimo.errors import ErrorCode, MatimoError
11
+ from matimo_microsoft.graph_client import get_access_token, graph_request, require_params
12
+
13
+ _DEFAULT_TIMEZONE = "UTC"
14
+
15
+
16
+ def _to_attendee_list(value: Any) -> list[dict[str, Any]]:
17
+ if value is None:
18
+ return []
19
+ if not isinstance(value, list) or any(not isinstance(entry, str) or not entry for entry in value):
20
+ raise MatimoError(
21
+ "ms_create_calendar_event: 'attendees' must be an array of email address strings",
22
+ ErrorCode.VALIDATION_FAILED,
23
+ {"attendees": value},
24
+ )
25
+ return [{"emailAddress": {"address": address}, "type": "required"} for address in value]
26
+
27
+
28
+ async def run(params: dict[str, Any]) -> dict[str, Any]:
29
+ require_params(params, ["subject", "start", "end"], "ms_create_calendar_event")
30
+
31
+ subject = str(params["subject"])
32
+ start = str(params["start"])
33
+ end = str(params["end"])
34
+ timezone_param = params.get("timezone")
35
+ timezone = timezone_param if isinstance(timezone_param, str) and timezone_param else _DEFAULT_TIMEZONE
36
+
37
+ attendees = _to_attendee_list(params.get("attendees"))
38
+ is_online_meeting = params.get("is_online_meeting") is True
39
+
40
+ token = get_access_token(params)
41
+
42
+ body_param = params.get("body")
43
+ location_param = params.get("location")
44
+
45
+ event_body: dict[str, Any] = {
46
+ "subject": subject,
47
+ **(
48
+ {"body": {"contentType": "Text", "content": body_param}}
49
+ if isinstance(body_param, str) and body_param
50
+ else {}
51
+ ),
52
+ "start": {"dateTime": start, "timeZone": timezone},
53
+ "end": {"dateTime": end, "timeZone": timezone},
54
+ **({"attendees": attendees} if attendees else {}),
55
+ **(
56
+ {"location": {"displayName": location_param}}
57
+ if isinstance(location_param, str) and location_param
58
+ else {}
59
+ ),
60
+ "isOnlineMeeting": is_online_meeting,
61
+ **({"onlineMeetingProvider": "teamsForBusiness"} if is_online_meeting else {}),
62
+ }
63
+
64
+ event = await graph_request(
65
+ method="POST",
66
+ path="/me/events",
67
+ token=token,
68
+ resource_type="Calendar",
69
+ body=event_body,
70
+ )
71
+ event = event if isinstance(event, dict) else {}
72
+
73
+ online_meeting = event.get("onlineMeeting") or {}
74
+ join_url = online_meeting.get("joinUrl")
75
+
76
+ return {
77
+ "success": True,
78
+ "event_id": event.get("id") or "",
79
+ "web_link": event.get("webLink") or "",
80
+ **({"join_url": join_url} if join_url else {}),
81
+ }
@@ -0,0 +1,103 @@
1
+ name: ms_create_document
2
+ description: |
3
+ Create (or overwrite) a small file in OneDrive or a SharePoint document library by
4
+ uploading content directly
5
+ (PUT /drives/{drive_id}/items/{parent_item_id}:/{filename}:/content). Intended for
6
+ small text-based documents — Microsoft Graph requires resumable upload sessions for
7
+ files larger than 4 MB, which this tool does not implement.
8
+ version: '1.0.0'
9
+ status: approved
10
+ risk: medium
11
+
12
+ parameters:
13
+ drive_id:
14
+ type: string
15
+ description: ID of the destination drive (OneDrive or SharePoint document library)
16
+ required: true
17
+
18
+ parent_item_id:
19
+ type: string
20
+ description: ID of the destination folder item. Defaults to the drive's root folder.
21
+ required: false
22
+ default: root
23
+
24
+ filename:
25
+ type: string
26
+ description: 'Name to give the new file, including extension, e.g. "report.md"'
27
+ required: true
28
+
29
+ content:
30
+ type: string
31
+ description: File content. Provide as plain text, or as base64 when content_encoding is "base64"
32
+ required: true
33
+
34
+ content_encoding:
35
+ type: string
36
+ description: How `content` is encoded
37
+ required: false
38
+ default: text
39
+ enum: [text, base64]
40
+
41
+ conflict_behaviour:
42
+ type: string
43
+ description: >-
44
+ How to handle a name collision with an existing item. Passed as the
45
+ @microsoft.graph.conflictBehavior query hint on a best-effort basis — Graph's
46
+ simple-upload (PUT .../content) endpoint documents this parameter primarily for
47
+ resumable upload sessions, so behaviour may vary by tenant/library configuration.
48
+ required: false
49
+ default: replace
50
+ enum: [replace, rename, fail]
51
+
52
+ execution:
53
+ type: function
54
+ code: ./ms_create_document.py
55
+ timeout: 30000
56
+
57
+ authentication:
58
+ type: oauth2
59
+ provider: microsoft
60
+ scopes:
61
+ - https://graph.microsoft.com/Files.ReadWrite
62
+
63
+ output_schema:
64
+ type: object
65
+ properties:
66
+ success:
67
+ type: boolean
68
+ item_id:
69
+ type: string
70
+ name:
71
+ type: string
72
+ web_url:
73
+ type: string
74
+ size_bytes:
75
+ type: number
76
+
77
+ error_handling:
78
+ retry: 1
79
+ backoff_type: exponential
80
+ initial_delay_ms: 1000
81
+
82
+ tags: [microsoft, graph, files, onedrive, sharepoint, write]
83
+
84
+ examples:
85
+ - name: Create a Markdown summary document at the drive root
86
+ params:
87
+ drive_id: 'b!xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
88
+ filename: 'meeting-notes-2026-06-07.md'
89
+ content: '# Meeting notes\n\n- Decided to ship v2.4.0 next week'
90
+ - name: Upload a base64-encoded file into a specific folder
91
+ params:
92
+ drive_id: 'b!xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
93
+ parent_item_id: '01ABCXYZ7654321'
94
+ filename: 'export.csv'
95
+ content: 'bmFtZSxlbWFpbAphbGljZSxhbGljZUBjb250b3NvLmNvbQ=='
96
+ content_encoding: base64
97
+ conflict_behaviour: rename
98
+
99
+ notes:
100
+ caution: >-
101
+ Uses the simple-upload endpoint, which Microsoft Graph limits to files up to
102
+ 4 MB. Larger files require a resumable upload session
103
+ (createUploadSession), which this tool does not implement.
@@ -0,0 +1,106 @@
1
+ """
2
+ ms_create_document — PUT /drives/{drive-id}/items/{parent-item-id}:/{filename}:/content
3
+ https://learn.microsoft.com/en-us/graph/api/driveitem-put-content
4
+ Mirrors: typescript/packages/microsoft/tools/ms_create_document/ms_create_document.ts
5
+
6
+ Uses the "simple upload" by-path addressing syntax. Graph caps this endpoint at
7
+ 4 MB; larger files require a resumable upload session, which is out of scope here
8
+ and is rejected with a clear validation error rather than silently truncating.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import binascii
14
+ from typing import Any
15
+ from urllib.parse import quote
16
+
17
+ from matimo.errors import ErrorCode, MatimoError
18
+ from matimo_microsoft.graph_client import get_access_token, graph_request, require_params
19
+
20
+ _VALID_ENCODINGS = ["text", "base64"]
21
+ _VALID_CONFLICT_BEHAVIOURS = ["replace", "rename", "fail"]
22
+ _DEFAULT_PARENT_ITEM_ID = "root"
23
+ _MAX_UPLOAD_BYTES = 4 * 1024 * 1024
24
+
25
+
26
+ async def run(params: dict[str, Any]) -> dict[str, Any]:
27
+ require_params(params, ["drive_id", "filename", "content"], "ms_create_document")
28
+
29
+ drive_id = str(params["drive_id"])
30
+ parent_item_id_param = params.get("parent_item_id")
31
+ parent_item_id = (
32
+ parent_item_id_param
33
+ if isinstance(parent_item_id_param, str) and parent_item_id_param
34
+ else _DEFAULT_PARENT_ITEM_ID
35
+ )
36
+ filename = str(params["filename"])
37
+
38
+ encoding_param = params.get("content_encoding")
39
+ encoding = "text" if encoding_param is None else str(encoding_param)
40
+ if encoding not in _VALID_ENCODINGS:
41
+ raise MatimoError(
42
+ f"ms_create_document: 'content_encoding' must be one of {', '.join(_VALID_ENCODINGS)} "
43
+ f"(received '{encoding}')",
44
+ ErrorCode.VALIDATION_FAILED,
45
+ {"content_encoding": encoding_param},
46
+ )
47
+
48
+ conflict_param = params.get("conflict_behaviour")
49
+ conflict_behaviour = "replace" if conflict_param is None else str(conflict_param)
50
+ if conflict_behaviour not in _VALID_CONFLICT_BEHAVIOURS:
51
+ raise MatimoError(
52
+ f"ms_create_document: 'conflict_behaviour' must be one of {', '.join(_VALID_CONFLICT_BEHAVIOURS)} "
53
+ f"(received '{conflict_behaviour}')",
54
+ ErrorCode.VALIDATION_FAILED,
55
+ {"conflict_behaviour": conflict_param},
56
+ )
57
+
58
+ raw_content = str(params["content"])
59
+ if encoding == "base64":
60
+ # Mirror Node's lenient Buffer.from(str, 'base64'), which decodes whatever
61
+ # it can rather than throwing on malformed input — pad out to a multiple
62
+ # of 4 and ignore decode errors from stray characters.
63
+ padded = raw_content + "=" * (-len(raw_content) % 4)
64
+ try:
65
+ buffer = base64.b64decode(padded, validate=False)
66
+ except (binascii.Error, ValueError):
67
+ buffer = b""
68
+ else:
69
+ buffer = raw_content.encode("utf-8")
70
+
71
+ if len(buffer) > _MAX_UPLOAD_BYTES:
72
+ raise MatimoError(
73
+ f"ms_create_document: content is {len(buffer)} bytes, exceeding the "
74
+ f"{_MAX_UPLOAD_BYTES}-byte limit of the simple-upload endpoint. Files this large "
75
+ "require a resumable upload session, which this tool does not implement.",
76
+ ErrorCode.VALIDATION_FAILED,
77
+ {"sizeBytes": len(buffer), "maxBytes": _MAX_UPLOAD_BYTES},
78
+ )
79
+
80
+ token = get_access_token(params)
81
+
82
+ # By-path addressing uses literal colons as delimiters — only the path SEGMENTS
83
+ # (drive id, parent item id, filename) are percent-encoded, not the colons.
84
+ path = (
85
+ f"/drives/{quote(drive_id, safe='')}/items/{quote(parent_item_id, safe='')}"
86
+ f":/{quote(filename, safe='')}:/content"
87
+ )
88
+
89
+ item = await graph_request(
90
+ method="PUT",
91
+ path=path,
92
+ token=token,
93
+ resource_type="Drive folder",
94
+ query={"@microsoft.graph.conflictBehavior": conflict_behaviour},
95
+ body=buffer,
96
+ headers={"Content-Type": "application/octet-stream"},
97
+ )
98
+ item = item if isinstance(item, dict) else {}
99
+
100
+ return {
101
+ "success": True,
102
+ "item_id": item.get("id") or "",
103
+ "name": item.get("name") or filename,
104
+ "web_url": item.get("webUrl") or "",
105
+ "size_bytes": item.get("size") or len(buffer),
106
+ }