This commit is contained in:
2026-07-06 02:05:16 +03:00
parent 40dea9a2c2
commit 4207b38b16
14 changed files with 823 additions and 607 deletions
+193 -145
View File
@@ -11,10 +11,10 @@ from datetime import datetime, timedelta
from mcp.server.fastmcp import Context
from owa_mcp.server import mcp, AppContext
from owa_mcp.owa_client import OWAClient
from owa_mcp.server import AppContext, mcp
from owa_mcp.server_lifecycle import require_client
from owa_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html
from owa_mcp.utils import extract_links_from_html, html_to_text
def _get_client(ctx: Context) -> OWAClient:
@@ -63,9 +63,7 @@ def _get_event_details(client: OWAClient, item_id: str) -> dict:
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": [
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
},
}
@@ -160,9 +158,7 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict:
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": [
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
},
}
@@ -202,13 +198,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict:
name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
if addr and not addr.startswith("/O="):
result["resolved_required"].append({
"Mailbox": {
"Name": name,
"EmailAddress": addr,
"RoutingType": "SMTP",
result["resolved_required"].append(
{
"Mailbox": {
"Name": name,
"EmailAddress": addr,
"RoutingType": "SMTP",
}
}
})
)
# Optional attendees
for a in item.get("OptionalAttendees", []) or []:
@@ -216,13 +214,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict:
name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
if addr and not addr.startswith("/O="):
result["resolved_optional"].append({
"Mailbox": {
"Name": name,
"EmailAddress": addr,
"RoutingType": "SMTP",
result["resolved_optional"].append(
{
"Mailbox": {
"Name": name,
"EmailAddress": addr,
"RoutingType": "SMTP",
}
}
})
)
return result
@@ -331,72 +331,81 @@ def _get_expanded_events(
chunk_end = min(current + timedelta(days=chunk_days), end_date)
payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
'TimeZoneContext': {
'__type': 'TimeZoneContext:#Exchange',
'TimeZoneDefinition': {
'__type': 'TimeZoneDefinitionType:#Exchange',
'Id': 'Russian Standard Time',
"__type": "GetUserAvailabilityJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
"TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time",
},
},
},
'Body': {
'__type': 'GetUserAvailabilityRequest:#Exchange',
'MailboxDataArray': [{
'__type': 'MailboxData:#Exchange',
'Email': {'__type': 'EmailAddress:#Exchange', 'Address': client.user_email},
'AttendeeType': 'Required',
}],
'FreeBusyViewOptions': {
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{current}T00:00:00',
'EndTime': f'{chunk_end}T00:00:00',
"Body": {
"__type": "GetUserAvailabilityRequest:#Exchange",
"MailboxDataArray": [
{
"__type": "MailboxData:#Exchange",
"Email": {
"__type": "EmailAddress:#Exchange",
"Address": client.user_email,
},
"AttendeeType": "Required",
}
],
"FreeBusyViewOptions": {
"__type": "FreeBusyViewOptions:#Exchange",
"TimeWindow": {
"__type": "Duration:#Exchange",
"StartTime": f"{current}T00:00:00",
"EndTime": f"{chunk_end}T00:00:00",
},
'MergedFreeBusyIntervalInMinutes': 30,
'RequestedView': 'DetailedMerged',
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
},
},
}
try:
data = client.request('GetUserAvailability', payload)
body = data.get('Body', {})
for fb_resp in body.get('FreeBusyResponseArray', []):
fb_view = fb_resp.get('FreeBusyView', {})
cal_events = fb_view.get('CalendarEventArray', {})
data = client.request("GetUserAvailability", payload)
body = data.get("Body", {})
for fb_resp in body.get("FreeBusyResponseArray", []):
fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get("CalendarEventArray", {})
items = (
cal_events.get('Items', [])
cal_events.get("Items", [])
if isinstance(cal_events, dict)
else (cal_events if isinstance(cal_events, list) else [])
)
for event in items:
bt = event.get('BusyType', '')
details = event.get('CalendarEventDetails', {})
subject = details.get('Subject', '') if details else ''
location = details.get('Location', '') if details else ''
is_meeting = details.get('IsMeeting', False) if details else False
is_recurring = details.get('IsRecurring', False) if details else False
bt = event.get("BusyType", "")
details = event.get("CalendarEventDetails", {})
subject = details.get("Subject", "") if details else ""
location = details.get("Location", "") if details else ""
is_meeting = details.get("IsMeeting", False) if details else False
is_recurring = (
details.get("IsRecurring", False) if details else False
)
expanded.append({
'subject': subject or '(No subject)',
'start': event.get('StartTime', ''),
'end': event.get('EndTime', ''),
'busy_type': bt,
'location': location,
'is_meeting': is_meeting,
'is_recurring': is_recurring,
})
expanded.append(
{
"subject": subject or "(No subject)",
"start": event.get("StartTime", ""),
"end": event.get("EndTime", ""),
"busy_type": bt,
"location": location,
"is_meeting": is_meeting,
"is_recurring": is_recurring,
}
)
except Exception:
pass
current = chunk_end
return sorted(expanded, key=lambda x: x.get('start', ''))
return sorted(expanded, key=lambda x: x.get("start", ""))
@mcp.tool()
@@ -438,15 +447,17 @@ def get_calendar_events(
)
events = []
for ev in expanded:
events.append({
"subject": ev['subject'],
"start": ev['start'],
"end": ev['end'],
"location": ev.get('location', ''),
"busy_type": ev.get('busy_type', ''),
"is_meeting": ev.get('is_meeting', False),
"is_recurring": ev.get('is_recurring', False),
})
events.append(
{
"subject": ev["subject"],
"start": ev["start"],
"end": ev["end"],
"location": ev.get("location", ""),
"busy_type": ev.get("busy_type", ""),
"is_meeting": ev.get("is_meeting", False),
"is_recurring": ev.get("is_recurring", False),
}
)
return json.dumps(events, ensure_ascii=False)
# --- Default mode ---
@@ -476,9 +487,7 @@ def get_calendar_events(
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ParentFolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"CalendarView": {
"__type": "CalendarView:#Exchange",
@@ -722,12 +731,16 @@ def create_meeting(
]
return json.dumps(result, ensure_ascii=False)
else:
return json.dumps({
"error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""),
})
return json.dumps(
{
"error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""),
}
)
return json.dumps({"success": True, "subject": subject, "note": "No confirmation details"})
return json.dumps(
{"success": True, "subject": subject, "note": "No confirmation details"}
)
# ------------------------------------------------------------------
@@ -791,8 +804,12 @@ def update_meeting(
orig_start_str = orig.get("start", "")
orig_end_str = orig.get("end", "")
try:
orig_start = datetime.fromisoformat(orig_start_str.replace("Z", "+00:00")).replace(tzinfo=None)
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(tzinfo=None)
orig_start = datetime.fromisoformat(
orig_start_str.replace("Z", "+00:00")
).replace(tzinfo=None)
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
except (ValueError, AttributeError):
orig_start = None
@@ -821,7 +838,9 @@ def update_meeting(
new_start = orig_start
new_end = orig_end
else:
return json.dumps({"error": "Cannot determine meeting time. Provide date and start_time."})
return json.dumps(
{"error": "Cannot determine meeting time. Provide date and start_time."}
)
new_location = location if location is not None else orig.get("location", "")
@@ -845,9 +864,7 @@ def update_meeting(
},
"Body": {
"__type": "DeleteItemRequest:#Exchange",
"ItemIds": [
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
"DeleteType": "MoveToDeletedItems",
"SendMeetingCancellations": "SendToAllAndSaveCopy",
"SuppressReadReceipts": True,
@@ -935,7 +952,9 @@ def update_meeting(
try:
data = client.request("CreateCalendarEvent", create_payload)
except Exception as e:
return json.dumps({"error": f"Original cancelled but failed to create new: {e}"})
return json.dumps(
{"error": f"Original cancelled but failed to create new: {e}"}
)
body = data.get("Body", {})
if "ErrorCode" in body:
@@ -959,12 +978,16 @@ def update_meeting(
return json.dumps(result, ensure_ascii=False)
if resp_items:
return json.dumps({
"error": resp_items[0].get("MessageText", "Unknown error"),
"response_code": resp_items[0].get("ResponseCode", ""),
})
return json.dumps(
{
"error": resp_items[0].get("MessageText", "Unknown error"),
"response_code": resp_items[0].get("ResponseCode", ""),
}
)
return json.dumps({"success": True, "subject": new_subject, "note": "No confirmation details"})
return json.dumps(
{"success": True, "subject": new_subject, "note": "No confirmation details"}
)
# ------------------------------------------------------------------
@@ -997,9 +1020,7 @@ def cancel_meeting(
},
"Body": {
"__type": "DeleteItemRequest:#Exchange",
"ItemIds": [
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
"DeleteType": "MoveToDeletedItems",
"SendMeetingCancellations": "SendToAllAndSaveCopy",
"SuppressReadReceipts": True,
@@ -1014,10 +1035,12 @@ def cancel_meeting(
if item.get("ResponseClass") == "Success":
return json.dumps({"success": True, "message": "Meeting cancelled"})
else:
return json.dumps({
"error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""),
})
return json.dumps(
{
"error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""),
}
)
# DeleteItem may return empty on success
body = data.get("Body", {})
@@ -1060,9 +1083,11 @@ def respond_to_meeting(
response_type = response_types.get(response)
if not response_type:
return json.dumps({
"error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative."
})
return json.dumps(
{
"error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative."
}
)
response_item = {
"__type": response_type,
@@ -1098,16 +1123,20 @@ def respond_to_meeting(
if items:
item = items[0]
if item.get("ResponseClass") == "Success":
return json.dumps({
"success": True,
"response": response,
"message": f"Meeting {response.lower()}ed",
})
return json.dumps(
{
"success": True,
"response": response,
"message": f"Meeting {response.lower()}ed",
}
)
else:
return json.dumps({
"error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""),
})
return json.dumps(
{
"error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""),
}
)
return json.dumps({"error": "No response from server"})
@@ -1147,9 +1176,7 @@ def download_event_attachments(
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": [
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
},
}
@@ -1162,28 +1189,43 @@ def download_event_attachments(
continue
for item in msg["Items"]:
for att in item.get("Attachments", []):
attachments.append({
"name": att.get("Name", ""),
"size": att.get("Size", 0),
"content_type": att.get("ContentType", ""),
"attachment_id": att.get("AttachmentId", {}).get("Id", ""),
"is_inline": att.get("IsInline", False),
})
attachments.append(
{
"name": att.get("Name", ""),
"size": att.get("Size", 0),
"content_type": att.get("ContentType", ""),
"attachment_id": att.get("AttachmentId", {}).get("Id", ""),
"is_inline": att.get("IsInline", False),
}
)
break
if not attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0,
"message": "No attachments found on this event."})
return json.dumps(
{
"success": True,
"downloaded": [],
"count": 0,
"message": "No attachments found on this event.",
}
)
# Filter to non-inline file attachments
file_attachments = [
a for a in attachments
a
for a in attachments
if a.get("attachment_id") and not a.get("is_inline", False)
]
if not file_attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0,
"message": "No downloadable file attachments."})
return json.dumps(
{
"success": True,
"downloaded": [],
"count": 0,
"message": "No downloadable file attachments.",
}
)
os.makedirs(target_folder, exist_ok=True)
@@ -1223,17 +1265,21 @@ def download_event_attachments(
with open(filepath, "wb") as f:
f.write(content)
downloaded.append({
"name": filename,
"path": filepath,
"size": len(content),
"content_type": content_type,
})
downloaded.append(
{
"name": filename,
"path": filepath,
"size": len(content),
"content_type": content_type,
}
)
except Exception as e:
errors.append({
"name": att.get("name", "unknown"),
"error": str(e),
})
errors.append(
{
"name": att.get("name", "unknown"),
"error": str(e),
}
)
result = {
"success": len(errors) == 0,
@@ -1308,12 +1354,14 @@ def get_event_links(
links = extract_links_from_html(body_val)
break
return json.dumps({
"item_id": item_id,
"subject": subject,
"links": links,
"count": len(links),
})
return json.dumps(
{
"item_id": item_id,
"subject": subject,
"links": links,
"count": len(links),
}
)
except Exception as e:
return json.dumps({"error": f"Failed to extract event links: {e}"})