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.
- matimo_microsoft/__init__.py +17 -0
- matimo_microsoft/graph_client.py +236 -0
- matimo_microsoft/tools/ms_create_calendar_event/definition.yaml +100 -0
- matimo_microsoft/tools/ms_create_calendar_event/ms_create_calendar_event.py +81 -0
- matimo_microsoft/tools/ms_create_document/definition.yaml +103 -0
- matimo_microsoft/tools/ms_create_document/ms_create_document.py +106 -0
- matimo_microsoft/tools/ms_get_email/definition.yaml +88 -0
- matimo_microsoft/tools/ms_get_email/ms_get_email.py +94 -0
- matimo_microsoft/tools/ms_list_files/definition.yaml +81 -0
- matimo_microsoft/tools/ms_list_files/ms_list_files.py +72 -0
- matimo_microsoft/tools/ms_publish_to_sharepoint/definition.yaml +92 -0
- matimo_microsoft/tools/ms_publish_to_sharepoint/ms_publish_to_sharepoint.py +122 -0
- matimo_microsoft/tools/ms_read_file/definition.yaml +74 -0
- matimo_microsoft/tools/ms_read_file/ms_read_file.py +96 -0
- matimo_microsoft/tools/ms_search_knowledge/definition.yaml +99 -0
- matimo_microsoft/tools/ms_search_knowledge/ms_search_knowledge.py +109 -0
- matimo_microsoft/tools/ms_send_email/definition.yaml +94 -0
- matimo_microsoft/tools/ms_send_email/ms_send_email.py +99 -0
- matimo_microsoft/tools/ms_send_teams_message/definition.yaml +87 -0
- matimo_microsoft/tools/ms_send_teams_message/ms_send_teams_message.py +59 -0
- matimo_microsoft-0.1.0.dist-info/METADATA +119 -0
- matimo_microsoft-0.1.0.dist-info/RECORD +24 -0
- matimo_microsoft-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
}
|