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
+148 -121
View File
@@ -5,13 +5,13 @@ using GetUserAvailability and GetItem APIs.
"""
import json
from collections import Counter, defaultdict
from datetime import datetime, timedelta, date
from collections import Counter
from datetime import date, 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
@@ -25,6 +25,7 @@ def _get_client(ctx: Context) -> OWAClient:
# Internal: resolve names to emails
# ------------------------------------------------------------------
def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
"""Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name)
@@ -38,6 +39,7 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
# Internal: query GetUserAvailability in chunks
# ------------------------------------------------------------------
def _get_availability_events(
client: OWAClient,
emails: list[str],
@@ -54,77 +56,84 @@ def _get_availability_events(
results: dict[str, list[dict]] = {email: [] for email in emails}
# Batch people
email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)]
email_batches = [
emails[i : i + batch_size] for i in range(0, len(emails), batch_size)
]
for batch in email_batches:
current = start
while current < end:
chunk_end = min(current + timedelta(days=chunk_days), end)
mailbox_data = [{
'__type': 'MailboxData:#Exchange',
'Email': {
'__type': 'EmailAddress:#Exchange',
'Address': email,
},
'AttendeeType': 'Required',
} for email in batch]
mailbox_data = [
{
"__type": "MailboxData:#Exchange",
"Email": {
"__type": "EmailAddress:#Exchange",
"Address": email,
},
"AttendeeType": "Required",
}
for email in batch
]
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': mailbox_data,
'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": mailbox_data,
"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 i, fb_resp in enumerate(body.get('FreeBusyResponseArray', [])):
data = client.request("GetUserAvailability", payload)
body = data.get("Body", {})
for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])):
if i >= len(batch):
break
email = batch[i]
fb_view = fb_resp.get('FreeBusyView', {})
cal_events = fb_view.get('CalendarEventArray', {})
fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get("CalendarEventArray", {})
if isinstance(cal_events, dict):
items = cal_events.get('Items', [])
items = cal_events.get("Items", [])
elif isinstance(cal_events, list):
items = cal_events
else:
items = []
for event in items:
s = event.get('StartTime', '')
bt = event.get('BusyType', '')
if s and bt != 'Free':
details = event.get('CalendarEventDetails', {})
subject = details.get('Subject', '') if details else ''
results[email].append({
'subject': subject,
'start_date': s[:10],
'busy_type': bt,
})
s = event.get("StartTime", "")
bt = event.get("BusyType", "")
if s and bt != "Free":
details = event.get("CalendarEventDetails", {})
subject = details.get("Subject", "") if details else ""
results[email].append(
{
"subject": subject,
"start_date": s[:10],
"busy_type": bt,
}
)
except Exception:
pass
@@ -137,6 +146,7 @@ def _get_availability_events(
# Tool: get_meeting_stats
# ------------------------------------------------------------------
@mcp.tool()
def get_meeting_stats(
people: str,
@@ -161,13 +171,13 @@ def get_meeting_stats(
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date()
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
# Resolve names
name_list = [n.strip() for n in people.split(',') if n.strip()]
name_list = [n.strip() for n in people.split(",") if n.strip()]
if not name_list:
return json.dumps({"error": "No people specified."})
@@ -199,43 +209,51 @@ def get_meeting_stats(
stats = []
for display, email in resolved:
if not email or email not in avail:
stats.append({
"name": display,
"email": email or "not_found",
"total_meetings": 0,
"meetings_per_workday": 0,
"days_with_meetings": 0,
"workdays": workdays,
})
stats.append(
{
"name": display,
"email": email or "not_found",
"total_meetings": 0,
"meetings_per_workday": 0,
"days_with_meetings": 0,
"workdays": workdays,
}
)
continue
events = avail[email]
total = len(events)
unique_days = len(set(ev['start_date'] for ev in events))
unique_days = len(set(ev["start_date"] for ev in events))
avg = round(total / workdays, 1)
stats.append({
"name": display,
"email": email,
"total_meetings": total,
"meetings_per_workday": avg,
"days_with_meetings": unique_days,
"workdays": workdays,
})
stats.append(
{
"name": display,
"email": email,
"total_meetings": total,
"meetings_per_workday": avg,
"days_with_meetings": unique_days,
"workdays": workdays,
}
)
# Sort by total descending
stats.sort(key=lambda x: x["total_meetings"], reverse=True)
return json.dumps({
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
}, ensure_ascii=False)
return json.dumps(
{
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
},
ensure_ascii=False,
)
# ------------------------------------------------------------------
# Tool: get_meeting_contacts
# ------------------------------------------------------------------
@mcp.tool()
def get_meeting_contacts(
start_date: str,
@@ -261,8 +279,8 @@ def get_meeting_contacts(
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date()
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
@@ -270,7 +288,9 @@ def get_meeting_contacts(
# to count subject occurrences (handles recurring meetings)
own_email = client.user_email.lower() if client.user_email else ""
if not own_email:
return json.dumps({"error": "User email not available. Call the login tool first."})
return json.dumps(
{"error": "User email not available. Call the login tool first."}
)
folder_id = client.get_folder_id("calendar")
if not folder_id:
@@ -280,7 +300,7 @@ def get_meeting_contacts(
avail_result = _get_availability_events(client, [client.user_email], sd, ed)
expanded_events = avail_result.get(client.user_email, [])
subject_counts = Counter(ev['subject'] for ev in expanded_events)
subject_counts = Counter(ev["subject"] for ev in expanded_events)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
@@ -290,51 +310,51 @@ def get_meeting_contacts(
while len(subject_to_id) < len(subject_counts):
payload = {
'__type': 'FindItemJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
'Body': {
'__type': 'FindItemRequest:#Exchange',
'ItemShape': {
'__type': 'ItemResponseShape:#Exchange',
'BaseShape': 'Default',
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "Default",
},
'ParentFolderIds': [
{'__type': 'FolderId:#Exchange', 'Id': folder_id}
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": 200,
},
"SortOrder": [
{
"__type": "SortResults:#Exchange",
"Order": "Descending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "Start",
},
}
],
'Traversal': 'Shallow',
'Paging': {
'__type': 'IndexedPageView:#Exchange',
'BasePoint': 'Beginning',
'Offset': offset,
'MaxEntriesReturned': 200,
},
'SortOrder': [{
'__type': 'SortResults:#Exchange',
'Order': 'Descending',
'Path': {
'__type': 'PropertyUri:#Exchange',
'FieldURI': 'Start',
},
}],
},
}
data = client.request('FindItem', payload)
data = client.request("FindItem", payload)
items = client.extract_items(data)
if not items:
break
folder_items = items[0].get('RootFolder', {}).get('Items', [])
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True)
folder_items = items[0].get("RootFolder", {}).get("Items", [])
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if not folder_items:
break
for item in folder_items:
subj = item.get('Subject', '')
subj = item.get("Subject", "")
if subj and subj in subject_counts and subj not in subject_to_id:
subject_to_id[subj] = item.get('ItemId', {}).get('Id', '')
subject_to_id[subj] = item.get("ItemId", {}).get("Id", "")
if is_last:
break
@@ -347,7 +367,7 @@ def get_meeting_contacts(
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi:bi+10]
batch = unique_ids[bi : bi + 10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
@@ -375,8 +395,10 @@ def get_meeting_contacts(
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
for att_list in [item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or []]:
for att_list in [
item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or [],
]:
for a in att_list:
mailbox = a.get("Mailbox", {})
name = mailbox.get("Name", "")
@@ -408,15 +430,20 @@ def get_meeting_contacts(
result_contacts = []
for (name, email), count in contacts.most_common(top_n):
result_contacts.append({
"name": name,
"email": email,
"meetings": count,
})
result_contacts.append(
{
"name": name,
"email": email,
"meetings": count,
}
)
return json.dumps({
"period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded,
"unique_contacts": len(contacts),
"contacts": result_contacts,
}, ensure_ascii=False)
return json.dumps(
{
"period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded,
"unique_contacts": len(contacts),
"contacts": result_contacts,
},
ensure_ascii=False,
)
+22 -14
View File
@@ -12,8 +12,8 @@ import json
from mcp.server.fastmcp import Context
from owa_mcp.server import mcp, AppContext
from owa_mcp.owa_client import OWAClient, SessionExpiredError
from owa_mcp.server import AppContext, mcp
from owa_mcp.server_lifecycle import require_client
@@ -87,16 +87,20 @@ async def login(
# Parse and validate cookies
cookies_str = cookies.strip()
if not cookies_str:
return json.dumps({
"success": False,
"error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.",
})
return json.dumps(
{
"success": False,
"error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.",
}
)
# Save to disk
try:
client.cookie_file.write_text(cookies_str + "\n")
except Exception as e:
return json.dumps({"success": False, "error": f"Failed to write cookie file: {e}"})
return json.dumps(
{"success": False, "error": f"Failed to write cookie file: {e}"}
)
# Load into memory
try:
@@ -106,12 +110,16 @@ async def login(
# Verify session
if _session_is_valid(client):
return json.dumps({
"success": True,
"message": f"Logged in. Session cookies saved to {client.cookie_file}.",
})
return json.dumps(
{
"success": True,
"message": f"Logged in. Session cookies saved to {client.cookie_file}.",
}
)
else:
return json.dumps({
"success": False,
"error": "Cookies loaded but session verification failed. The cookies may have expired.",
})
return json.dumps(
{
"success": False,
"error": "Cookies loaded but session verification failed. The cookies may have expired.",
}
)
+200 -160
View File
@@ -9,8 +9,8 @@ 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
@@ -24,6 +24,7 @@ def _get_client(ctx: Context) -> OWAClient:
# Pure helper functions (preserved from find-meeting-time.py)
# ------------------------------------------------------------------
def _parse_freebusy_string(
freebusy_str: str, start_time: datetime, interval_minutes: int = 30
) -> list[tuple]:
@@ -37,7 +38,7 @@ def _parse_freebusy_string(
for char in freebusy_str:
next_time = current_time + timedelta(minutes=interval_minutes)
if char in ['1', '2', '3', '4']: # Not free
if char in ["1", "2", "3", "4"]: # Not free
busy_periods.append((current_time, next_time, char))
current_time = next_time
@@ -105,75 +106,82 @@ def _find_free_slots(
def _format_time(dt: datetime) -> str:
"""Format datetime as HH:MM."""
return dt.strftime('%H:%M')
return dt.strftime("%H:%M")
# ------------------------------------------------------------------
# Helper: get busy events via GetUserAvailability (includes recurring)
# ------------------------------------------------------------------
def _get_availability_events(
client: OWAClient, email: str, start_date, end_date
) -> list[dict]:
"""Get busy events via GetUserAvailability (expands recurring events)."""
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': email},
'AttendeeType': 'Required',
}],
'FreeBusyViewOptions': {
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{start_date}T00:00:00',
'EndTime': f'{end_date + timedelta(days=1)}T00:00:00',
"Body": {
"__type": "GetUserAvailabilityRequest:#Exchange",
"MailboxDataArray": [
{
"__type": "MailboxData:#Exchange",
"Email": {"__type": "EmailAddress:#Exchange", "Address": email},
"AttendeeType": "Required",
}
],
"FreeBusyViewOptions": {
"__type": "FreeBusyViewOptions:#Exchange",
"TimeWindow": {
"__type": "Duration:#Exchange",
"StartTime": f"{start_date}T00:00:00",
"EndTime": f"{end_date + timedelta(days=1)}T00:00:00",
},
'MergedFreeBusyIntervalInMinutes': 30,
'RequestedView': 'DetailedMerged',
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
},
},
}
data = client.request('GetUserAvailability', payload)
body = data.get('Body', {})
data = client.request("GetUserAvailability", payload)
body = data.get("Body", {})
events = []
for fb_resp in body.get('FreeBusyResponseArray', []):
fb_view = fb_resp.get('FreeBusyView', {})
cal_events = fb_view.get('CalendarEventArray', {})
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', '')
if bt in ('Free', 'NoData'):
bt = event.get("BusyType", "")
if bt in ("Free", "NoData"):
continue
start_str = event.get('StartTime', '')
end_str = event.get('EndTime', '')
start_str = event.get("StartTime", "")
end_str = event.get("EndTime", "")
if not start_str or not end_str:
continue
try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None)
end = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None)
events.append({'start': start, 'end': end, 'status': bt})
start = datetime.fromisoformat(
start_str.replace("Z", "+00:00")
).replace(tzinfo=None)
end = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
events.append({"start": start, "end": end, "status": bt})
except (ValueError, AttributeError):
continue
@@ -184,22 +192,23 @@ def _get_availability_events(
# Helper: get calendar folder ID
# ------------------------------------------------------------------
def _get_calendar_folder_id(client: OWAClient) -> str | None:
"""Get the calendar folder ID via GetFolder."""
payload = {
'__type': 'GetFolderJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
'Body': {
'__type': 'GetFolderRequest:#Exchange',
'FolderShape': {
'__type': 'FolderResponseShape:#Exchange',
'BaseShape': 'IdOnly',
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly",
},
'FolderIds': [
{'__type': 'DistinguishedFolderId:#Exchange', 'Id': 'calendar'}
"FolderIds": [
{"__type": "DistinguishedFolderId:#Exchange", "Id": "calendar"}
],
},
}
@@ -218,6 +227,7 @@ def _get_calendar_folder_id(client: OWAClient) -> str | None:
# Helper: get own calendar events (from find-free-time.py)
# ------------------------------------------------------------------
def _get_calendar_events(
client: OWAClient, folder_id: str, start_date, end_date
) -> list[dict]:
@@ -228,34 +238,32 @@ def _get_calendar_events(
while True:
payload = {
'__type': 'FindItemJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
'Body': {
'__type': 'FindItemRequest:#Exchange',
'ItemShape': {
'__type': 'ItemResponseShape:#Exchange',
'BaseShape': 'AllProperties',
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
'ParentFolderIds': [
{'__type': 'FolderId:#Exchange', 'Id': folder_id}
],
'Traversal': 'Shallow',
'Paging': {
'__type': 'IndexedPageView:#Exchange',
'BasePoint': 'Beginning',
'Offset': offset,
'MaxEntriesReturned': batch_size,
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": batch_size,
},
'SortOrder': [
"SortOrder": [
{
'__type': 'SortResults:#Exchange',
'Order': 'Ascending',
'Path': {
'__type': 'PropertyUri:#Exchange',
'FieldURI': 'Start',
"__type": "SortResults:#Exchange",
"Order": "Ascending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "Start",
},
}
],
@@ -268,29 +276,29 @@ def _get_calendar_events(
if not items:
break
folder_items = items[0].get('RootFolder', {}).get('Items', [])
folder_items = items[0].get("RootFolder", {}).get("Items", [])
if not folder_items:
break
for item in folder_items:
# Skip cancelled events
if item.get('IsCancelled'):
if item.get("IsCancelled"):
continue
# Skip free/tentative slots
fbt = item.get('FreeBusyType', 'Busy')
if fbt in ['Free', 'NoData']:
fbt = item.get("FreeBusyType", "Busy")
if fbt in ["Free", "NoData"]:
continue
start_str = item.get('Start', '')
end_str = item.get('End', '')
start_str = item.get("Start", "")
end_str = item.get("End", "")
if not start_str or not end_str:
continue
try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
# Convert to naive datetime for comparison
start = start.replace(tzinfo=None)
@@ -300,17 +308,19 @@ def _get_calendar_events(
if end.date() < start_date or start.date() > end_date:
continue
events.append({
'start': start,
'end': end,
'subject': item.get('Subject', ''),
'status': fbt,
})
events.append(
{
"start": start,
"end": end,
"subject": item.get("Subject", ""),
"status": fbt,
}
)
except (ValueError, AttributeError):
continue
# Check if there are more items
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True)
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if is_last:
break
@@ -323,6 +333,7 @@ def _get_calendar_events(
# Tool: find_free_time
# ------------------------------------------------------------------
@mcp.tool()
def find_free_time(
start_date: str,
@@ -352,8 +363,8 @@ def find_free_time(
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
@@ -365,13 +376,17 @@ def find_free_time(
# Fallback to FindItem (misses recurring event occurrences)
folder_id = _get_calendar_folder_id(client)
if not folder_id:
return json.dumps({"error": "Could not find calendar folder. Session may have expired."})
return json.dumps(
{
"error": "Could not find calendar folder. Session may have expired."
}
)
all_busy = _get_calendar_events(client, folder_id, sd, ed)
except Exception as e:
return json.dumps({"error": str(e)})
# Convert event dicts to (start, end) tuples for _find_free_slots
busy_periods = [(ev['start'], ev['end']) for ev in all_busy]
busy_periods = [(ev["start"], ev["end"]) for ev in all_busy]
result = {}
current_date = sd
@@ -379,8 +394,11 @@ def find_free_time(
# Skip weekends
if current_date.weekday() < 5:
free = _find_free_slots(
busy_periods, current_date,
start_hour, end_hour, duration_minutes,
busy_periods,
current_date,
start_hour,
end_hour,
duration_minutes,
)
if free:
result[str(current_date)] = [
@@ -400,6 +418,7 @@ def find_free_time(
# Tool: find_meeting_time
# ------------------------------------------------------------------
@mcp.tool()
def find_meeting_time(
emails: str,
@@ -431,13 +450,13 @@ def find_meeting_time(
"""
client = _get_client(ctx)
raw_list = [e.strip() for e in emails.split(',') if e.strip()]
raw_list = [e.strip() for e in emails.split(",") if e.strip()]
if not raw_list:
return json.dumps({"error": "No email addresses provided."})
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
@@ -445,12 +464,12 @@ def find_meeting_time(
email_list = []
resolve_errors = []
for entry in raw_list:
if '@' in entry:
if "@" in entry:
email_list.append(entry)
else:
resolutions = client.resolve_names(entry, full_contact=False)
if resolutions:
addr = resolutions[0].get('Mailbox', {}).get('EmailAddress', '')
addr = resolutions[0].get("Mailbox", {}).get("EmailAddress", "")
if addr:
email_list.append(addr)
else:
@@ -459,46 +478,52 @@ def find_meeting_time(
resolve_errors.append(entry)
if not email_list:
return json.dumps({"error": f"Could not resolve any names to email addresses: {resolve_errors}"})
return json.dumps(
{
"error": f"Could not resolve any names to email addresses: {resolve_errors}"
}
)
# Build mailbox data (reused for each day chunk)
mailbox_data = []
for email in email_list:
mailbox_data.append({
'__type': 'MailboxData:#Exchange',
'Email': {
'__type': 'EmailAddress:#Exchange',
'Address': email,
},
'AttendeeType': 'Required',
})
mailbox_data.append(
{
"__type": "MailboxData:#Exchange",
"Email": {
"__type": "EmailAddress:#Exchange",
"Address": email,
},
"AttendeeType": "Required",
}
)
# Query the full date range at once (API handles multi-day windows)
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': mailbox_data,
'FreeBusyViewOptions': {
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{sd}T00:00:00',
'EndTime': f'{ed + timedelta(days=1)}T00:00:00',
"Body": {
"__type": "GetUserAvailabilityRequest:#Exchange",
"MailboxDataArray": mailbox_data,
"FreeBusyViewOptions": {
"__type": "FreeBusyViewOptions:#Exchange",
"TimeWindow": {
"__type": "Duration:#Exchange",
"StartTime": f"{sd}T00:00:00",
"EndTime": f"{ed + timedelta(days=1)}T00:00:00",
},
'MergedFreeBusyIntervalInMinutes': 30,
'RequestedView': 'DetailedMerged',
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
},
},
}
@@ -508,57 +533,69 @@ def find_meeting_time(
except Exception as e:
return json.dumps({"error": str(e)})
body = data.get('Body', {})
if 'ErrorCode' in body:
return json.dumps({"error": body.get('FaultMessage', 'Unknown error')})
body = data.get("Body", {})
if "ErrorCode" in body:
return json.dumps({"error": body.get("FaultMessage", "Unknown error")})
# Parse availability responses
all_busy = []
attendee_info = []
freebusy_responses = body.get('FreeBusyResponseArray', [])
freebusy_responses = body.get("FreeBusyResponseArray", [])
for i, fb_resp in enumerate(freebusy_responses):
fb_view = fb_resp.get('FreeBusyView', {})
merged_fb = fb_view.get('MergedFreeBusy', '')
email = email_list[i] if i < len(email_list) else f"Person {i+1}"
fb_view = fb_resp.get("FreeBusyView", {})
merged_fb = fb_view.get("MergedFreeBusy", "")
email = email_list[i] if i < len(email_list) else f"Person {i + 1}"
if merged_fb:
start_time = datetime.combine(sd, datetime.min.time())
busy_periods = _parse_freebusy_string(merged_fb, start_time)
busy_count = sum(1 for c in merged_fb if c != '0')
free_count = sum(1 for c in merged_fb if c == '0')
attendee_info.append({
"email": email,
"busy_slots": busy_count,
"free_slots": free_count,
})
busy_count = sum(1 for c in merged_fb if c != "0")
free_count = sum(1 for c in merged_fb if c == "0")
attendee_info.append(
{
"email": email,
"busy_slots": busy_count,
"free_slots": free_count,
}
)
all_busy.extend(busy_periods)
else:
# Fallback: parse CalendarEventArray
cal_events_raw = fb_view.get('CalendarEventArray', {})
cal_events = cal_events_raw.get('Items', []) if isinstance(cal_events_raw, dict) else (cal_events_raw if isinstance(cal_events_raw, list) else [])
cal_events_raw = fb_view.get("CalendarEventArray", {})
cal_events = (
cal_events_raw.get("Items", [])
if isinstance(cal_events_raw, dict)
else (cal_events_raw if isinstance(cal_events_raw, list) else [])
)
if cal_events:
attendee_info.append({
"email": email,
"calendar_events": len(cal_events),
})
attendee_info.append(
{
"email": email,
"calendar_events": len(cal_events),
}
)
for event in cal_events:
start_str = event.get('StartTime', '')
end_str = event.get('EndTime', '')
start_str = event.get("StartTime", "")
end_str = event.get("EndTime", "")
if start_str and end_str:
try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
start = datetime.fromisoformat(
start_str.replace("Z", "+00:00")
)
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
all_busy.append((start, end))
except Exception:
pass
else:
attendee_info.append({
"email": email,
"status": "no_data",
})
attendee_info.append(
{
"email": email,
"status": "no_data",
}
)
merged_busy = _merge_busy_periods(all_busy)
@@ -568,8 +605,11 @@ def find_meeting_time(
while current_date <= ed:
if current_date.weekday() < 5: # Skip weekends
free = _find_free_slots(
merged_busy, current_date,
start_hour, end_hour, duration_minutes,
merged_busy,
current_date,
start_hour,
end_hour,
duration_minutes,
)
if free:
free_by_date[str(current_date)] = [
+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}"})
+79 -50
View File
@@ -8,10 +8,10 @@ import json
from mcp.server.fastmcp import Context
from owa_mcp.server import mcp, AppContext
from owa_mcp.owa_client import OWAClient, SessionExpiredError
from owa_mcp.server import AppContext, mcp
from owa_mcp.server_lifecycle import require_client
from owa_mcp.utils import html_to_text, extract_links_from_html
from owa_mcp.utils import extract_links_from_html, html_to_text
def _get_client(ctx: Context) -> OWAClient:
@@ -215,7 +215,12 @@ def _get_item_details(client: OWAClient, item_id: str) -> dict:
item_type = item.get("__type", "")
if any(
t in item_type
for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation", "CalendarItem")
for t in (
"MeetingRequest",
"MeetingResponse",
"MeetingCancellation",
"CalendarItem",
)
):
result["location"] = item.get(
"Location",
@@ -331,9 +336,7 @@ def get_emails(
find_body = {
"__type": "FindItemRequest:#Exchange",
"ItemShape": item_shape,
"ParentFolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
@@ -392,18 +395,19 @@ def get_emails(
if not items:
return json.dumps(
{"item_ids": [], "count": 0} if ids_only
else {"emails": [], "count": 0}
{"item_ids": [], "count": 0} if ids_only else {"emails": [], "count": 0}
)
if ids_only:
result = []
for item in items:
result.append({
"item_id": item.get("ItemId", {}).get("Id", ""),
"date": item.get("DateTimeReceived", ""),
"subject": item.get("Subject", ""),
})
result.append(
{
"item_id": item.get("ItemId", {}).get("Id", ""),
"date": item.get("DateTimeReceived", ""),
"subject": item.get("Subject", ""),
}
)
return json.dumps({"item_ids": result, "count": len(result)})
emails = []
@@ -763,9 +767,7 @@ def move_email(
if not folder_id:
return json.dumps({"error": f"Folder '{target_folder}' not found."})
items = [
{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids
]
items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids]
payload = {
"__type": "MoveItemJsonRequest:#Exchange",
@@ -824,9 +826,7 @@ def delete_email(
try:
client = _get_client(ctx)
items = [
{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids
]
items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids]
payload = {
"__type": "DeleteItemJsonRequest:#Exchange",
@@ -887,18 +887,31 @@ def download_attachments(
attachments = details.get("attachments", [])
if not attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0,
"message": "No attachments found."})
return json.dumps(
{
"success": True,
"downloaded": [],
"count": 0,
"message": "No attachments found.",
}
)
# Filter to non-inline file attachments with IDs
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)
@@ -938,17 +951,21 @@ def download_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,
@@ -1021,12 +1038,14 @@ def get_email_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 SessionExpiredError as e:
return json.dumps({"error": str(e)})
@@ -1107,16 +1126,22 @@ async def search_emails(
max_results = max(1, min(max_results, 100))
if search_scope not in ("all", "subject", "body", "from"):
return json.dumps({"error": f"invalid search_scope: {search_scope}", "results": []})
return json.dumps(
{"error": f"invalid search_scope: {search_scope}", "results": []}
)
restriction = _build_search_restriction(query.strip(), search_scope)
if restriction is None:
return json.dumps({"error": f"unsupported search_scope: {search_scope}", "results": []})
return json.dumps(
{"error": f"unsupported search_scope: {search_scope}", "results": []}
)
if folder_id:
parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}]
else:
parent_folder = [{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}]
parent_folder = [
{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}
]
payload = {
"__type": "FindItemJsonRequest:#Exchange",
@@ -1153,6 +1178,7 @@ async def search_emails(
body_type = item.get("Body", {}).get("BodyType", "HTML")
if body_type == "HTML" and body_html:
import re
body_preview = re.sub(r"<[^>]+>", " ", body_html)
body_preview = " ".join(body_preview.split())[:200]
else:
@@ -1163,13 +1189,16 @@ async def search_emails(
if len(results) >= max_results:
break
return json.dumps({
"query": query.strip(),
"search_scope": search_scope,
"folder_id": folder_id or "all",
"total_results": len(results),
"results": results,
}, ensure_ascii=False)
return json.dumps(
{
"query": query.strip(),
"search_scope": search_scope,
"folder_id": folder_id or "all",
"total_results": len(results),
"results": results,
},
ensure_ascii=False,
)
except SessionExpiredError as e:
return json.dumps({"error": str(e)})
except Exception as e:
+69 -54
View File
@@ -7,15 +7,24 @@ import json
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
_DISTINGUISHED_NAMES = {
"msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems",
"junkemail", "outbox", "calendar", "contacts", "tasks", "notes",
"journal", "searchfolders",
"msgfolderroot",
"inbox",
"sentitems",
"drafts",
"deleteditems",
"junkemail",
"outbox",
"calendar",
"contacts",
"tasks",
"notes",
"journal",
"searchfolders",
}
_HEADER_TZ = {
@@ -68,36 +77,40 @@ def check_session(ctx: Context = None) -> str:
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"FolderIds": [
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
],
"FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}],
},
}
try:
data = client.request("GetFolder", payload)
except Exception as e:
return json.dumps({
"authenticated": False,
"error": str(e),
"cookie_file": str(client.cookie_file),
})
return json.dumps(
{
"authenticated": False,
"error": str(e),
"cookie_file": str(client.cookie_file),
}
)
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"authenticated": True,
"mailbox": folder.get("DisplayName", ""),
"unread": folder.get("UnreadCount", 0),
"cookie_file": str(client.cookie_file),
})
return json.dumps(
{
"authenticated": True,
"mailbox": folder.get("DisplayName", ""),
"unread": folder.get("UnreadCount", 0),
"cookie_file": str(client.cookie_file),
}
)
return json.dumps({
"authenticated": False,
"error": "Unexpected response",
"cookie_file": str(client.cookie_file),
})
return json.dumps(
{
"authenticated": False,
"error": "Unexpected response",
"cookie_file": str(client.cookie_file),
}
)
@mcp.tool()
@@ -159,13 +172,15 @@ def get_folders(
for msg in client.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]:
folders.append({
"name": f.get("DisplayName", "Unknown"),
"id": f.get("FolderId", {}).get("Id", ""),
"total_count": f.get("TotalCount", 0),
"unread_count": f.get("UnreadCount", 0),
"child_folder_count": f.get("ChildFolderCount", 0),
})
folders.append(
{
"name": f.get("DisplayName", "Unknown"),
"id": f.get("FolderId", {}).get("Id", ""),
"total_count": f.get("TotalCount", 0),
"unread_count": f.get("UnreadCount", 0),
"child_folder_count": f.get("ChildFolderCount", 0),
}
)
return json.dumps(folders, ensure_ascii=False)
@@ -216,11 +231,13 @@ def create_folder(
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"success": True,
"name": name,
"id": folder.get("FolderId", {}).get("Id", ""),
})
return json.dumps(
{
"success": True,
"name": name,
"id": folder.get("FolderId", {}).get("Id", ""),
}
)
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@@ -280,11 +297,13 @@ def rename_folder(
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"success": True,
"new_name": new_name,
"id": folder.get("FolderId", {}).get("Id", ""),
})
return json.dumps(
{
"success": True,
"new_name": new_name,
"id": folder.get("FolderId", {}).get("Id", ""),
}
)
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@@ -316,9 +335,7 @@ def empty_folder(
"Header": _HEADER_TZ,
"Body": {
"__type": "EmptyFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"DeleteType": delete_type,
"DeleteSubFolders": delete_sub_folders,
"SuppressReadReceipt": True,
@@ -362,9 +379,7 @@ def delete_folder(
"Header": _HEADER_TZ,
"Body": {
"__type": "DeleteFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"DeleteType": delete_type,
},
}
@@ -405,9 +420,7 @@ def move_folder(
"Header": _HEADER_TZ,
"Body": {
"__type": "MoveFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"ToFolderId": {
"__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(target_parent_folder_id),
@@ -423,9 +436,11 @@ def move_folder(
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"success": True,
"folder_id": folder.get("FolderId", {}).get("Id", ""),
})
return json.dumps(
{
"success": True,
"folder_id": folder.get("FolderId", {}).get("Id", ""),
}
)
return json.dumps({"error": "Unexpected response", "raw": str(data)})
+7 -5
View File
@@ -7,8 +7,8 @@ import json
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
@@ -82,10 +82,12 @@ def _parse_person(resolution: dict) -> dict:
# Direct reports
for report in contact.get("DirectReports", []):
person["direct_reports"].append({
"name": report.get("Name", ""),
"email": report.get("EmailAddress", ""),
})
person["direct_reports"].append(
{
"name": report.get("Name", ""),
"email": report.get("EmailAddress", ""),
}
)
return person