w
This commit is contained in:
+193
-145
@@ -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}"})
|
||||
|
||||
Reference in New Issue
Block a user