635 lines
21 KiB
Python
635 lines
21 KiB
Python
"""Availability / free-time tools for the OWA MCP server.
|
|
|
|
Ports find-free-time.py and find-meeting-time.py logic into MCP tools
|
|
using OWAClient.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
from mcp.server.fastmcp import Context
|
|
|
|
from owa_mcp.owa_client import OWAClient
|
|
from owa_mcp.server import AppContext, mcp
|
|
from owa_mcp.server_lifecycle import require_client
|
|
|
|
|
|
def _get_client(ctx: Context) -> OWAClient:
|
|
"""Extract the OWAClient from the MCP lifespan context."""
|
|
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
return require_client(app_ctx.client)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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]:
|
|
"""Parse the MergedFreeBusy string.
|
|
|
|
Each character represents a time slot:
|
|
0 = Free, 1 = Tentative, 2 = Busy, 3 = Out of Office, 4 = Working Elsewhere
|
|
"""
|
|
busy_periods = []
|
|
current_time = start_time
|
|
|
|
for char in freebusy_str:
|
|
next_time = current_time + timedelta(minutes=interval_minutes)
|
|
if char in ["1", "2", "3", "4"]: # Not free
|
|
busy_periods.append((current_time, next_time, char))
|
|
current_time = next_time
|
|
|
|
return busy_periods
|
|
|
|
|
|
def _merge_busy_periods(all_busy: list) -> list[tuple]:
|
|
"""Merge overlapping busy periods."""
|
|
if not all_busy:
|
|
return []
|
|
|
|
# Sort by start time
|
|
sorted_busy = sorted(all_busy, key=lambda x: x[0])
|
|
merged = [(sorted_busy[0][0], sorted_busy[0][1])]
|
|
|
|
for start, end, *_ in sorted_busy[1:]:
|
|
if start <= merged[-1][1]:
|
|
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
else:
|
|
merged.append((start, end))
|
|
|
|
return merged
|
|
|
|
|
|
def _find_free_slots(
|
|
busy_periods: list, date, start_hour: int, end_hour: int, duration_minutes: int
|
|
) -> list[tuple]:
|
|
"""Find free slots on a given date within working hours."""
|
|
day_start = datetime.combine(date, datetime.min.time().replace(hour=start_hour))
|
|
day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour))
|
|
|
|
# Filter busy periods to this day
|
|
day_busy = []
|
|
for period in busy_periods:
|
|
start, end = period[0], period[1]
|
|
if end.date() < date or start.date() > date:
|
|
continue
|
|
start = max(start, day_start)
|
|
end = min(end, day_end)
|
|
if start < end:
|
|
day_busy.append((start, end))
|
|
|
|
# Merge overlapping
|
|
merged = _merge_busy_periods([(s, e) for s, e in day_busy])
|
|
|
|
# Find gaps
|
|
free_slots = []
|
|
current = day_start
|
|
|
|
for busy_start, busy_end in merged:
|
|
if current < busy_start:
|
|
gap_duration = (busy_start - current).total_seconds() / 60
|
|
if gap_duration >= duration_minutes:
|
|
free_slots.append((current, busy_start))
|
|
current = max(current, busy_end)
|
|
|
|
# Check for time after last meeting
|
|
if current < day_end:
|
|
gap_duration = (day_end - current).total_seconds() / 60
|
|
if gap_duration >= duration_minutes:
|
|
free_slots.append((current, day_end))
|
|
|
|
return free_slots
|
|
|
|
|
|
def _format_time(dt: datetime) -> str:
|
|
"""Format datetime as HH:MM."""
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
"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",
|
|
},
|
|
},
|
|
}
|
|
|
|
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", {})
|
|
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"):
|
|
continue
|
|
|
|
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})
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
|
|
return 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",
|
|
},
|
|
"Body": {
|
|
"__type": "GetFolderRequest:#Exchange",
|
|
"FolderShape": {
|
|
"__type": "FolderResponseShape:#Exchange",
|
|
"BaseShape": "IdOnly",
|
|
},
|
|
"FolderIds": [
|
|
{"__type": "DistinguishedFolderId:#Exchange", "Id": "calendar"}
|
|
],
|
|
},
|
|
}
|
|
|
|
data = client.request("GetFolder", payload)
|
|
for msg in client.extract_items(data):
|
|
if "Folders" in msg:
|
|
for f in msg["Folders"]:
|
|
fid = f.get("FolderId", {}).get("Id")
|
|
if fid:
|
|
return fid
|
|
return 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]:
|
|
"""Get calendar events within a date range. Returns list of busy period dicts."""
|
|
events = []
|
|
offset = 0
|
|
batch_size = 100
|
|
|
|
while True:
|
|
payload = {
|
|
"__type": "FindItemJsonRequest:#Exchange",
|
|
"Header": {
|
|
"__type": "JsonRequestHeaders:#Exchange",
|
|
"RequestServerVersion": "Exchange2013",
|
|
},
|
|
"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,
|
|
},
|
|
"SortOrder": [
|
|
{
|
|
"__type": "SortResults:#Exchange",
|
|
"Order": "Ascending",
|
|
"Path": {
|
|
"__type": "PropertyUri:#Exchange",
|
|
"FieldURI": "Start",
|
|
},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
data = client.request("FindItem", payload)
|
|
items = client.extract_items(data)
|
|
|
|
if not items:
|
|
break
|
|
|
|
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"):
|
|
continue
|
|
|
|
# Skip free/tentative slots
|
|
fbt = item.get("FreeBusyType", "Busy")
|
|
if fbt in ["Free", "NoData"]:
|
|
continue
|
|
|
|
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"))
|
|
|
|
# Convert to naive datetime for comparison
|
|
start = start.replace(tzinfo=None)
|
|
end = end.replace(tzinfo=None)
|
|
|
|
# Filter by date range
|
|
if end.date() < start_date or start.date() > end_date:
|
|
continue
|
|
|
|
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)
|
|
if is_last:
|
|
break
|
|
|
|
offset += batch_size
|
|
|
|
return events
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tool: find_free_time
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def find_free_time(
|
|
start_date: str,
|
|
end_date: str = "",
|
|
duration_minutes: int = 30,
|
|
start_hour: int = 9,
|
|
end_hour: int = 18,
|
|
ctx: Context = None,
|
|
) -> str:
|
|
"""Find free time slots in your own calendar.
|
|
|
|
Analyzes your calendar events and returns available time slots
|
|
within working hours for each weekday in the range.
|
|
|
|
Args:
|
|
start_date: Start date in YYYY-MM-DD format.
|
|
end_date: End date in YYYY-MM-DD format. Defaults to start_date
|
|
if not provided (single-day search).
|
|
duration_minutes: Minimum slot duration in minutes. Default 30.
|
|
start_hour: Working day start hour (0-23). Default 9.
|
|
end_hour: Working day end hour (0-23). Default 18.
|
|
|
|
Returns:
|
|
JSON object with free_slots keyed by date, each containing an
|
|
array of {start, end, duration_minutes} objects.
|
|
"""
|
|
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
|
|
except ValueError as e:
|
|
return json.dumps({"error": f"Invalid date format: {e}"})
|
|
|
|
try:
|
|
# Use GetUserAvailability for accurate recurring event expansion
|
|
if client.user_email:
|
|
all_busy = _get_availability_events(client, client.user_email, sd, ed)
|
|
else:
|
|
# 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."
|
|
}
|
|
)
|
|
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]
|
|
|
|
result = {}
|
|
current_date = sd
|
|
while current_date <= ed:
|
|
# Skip weekends
|
|
if current_date.weekday() < 5:
|
|
free = _find_free_slots(
|
|
busy_periods,
|
|
current_date,
|
|
start_hour,
|
|
end_hour,
|
|
duration_minutes,
|
|
)
|
|
if free:
|
|
result[str(current_date)] = [
|
|
{
|
|
"start": _format_time(s),
|
|
"end": _format_time(e),
|
|
"duration_minutes": int((e - s).total_seconds() / 60),
|
|
}
|
|
for s, e in free
|
|
]
|
|
current_date += timedelta(days=1)
|
|
|
|
return json.dumps({"free_slots": result}, ensure_ascii=False)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tool: find_meeting_time
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def find_meeting_time(
|
|
emails: str,
|
|
start_date: str,
|
|
end_date: str = "",
|
|
duration_minutes: int = 30,
|
|
start_hour: int = 9,
|
|
end_hour: int = 18,
|
|
ctx: Context = None,
|
|
) -> str:
|
|
"""Find meeting times that work for multiple people.
|
|
|
|
Uses the OWA GetUserAvailability API to check cross-mailbox
|
|
availability and find common free slots for all attendees.
|
|
Supports multi-day ranges — searches each weekday in the range.
|
|
|
|
Args:
|
|
emails: Comma-separated email addresses or names of attendees.
|
|
start_date: Start date in YYYY-MM-DD format.
|
|
end_date: End date in YYYY-MM-DD format. Defaults to start_date
|
|
if not provided (single-day search).
|
|
duration_minutes: Minimum slot duration in minutes. Default 30.
|
|
start_hour: Working day start hour (0-23). Default 9.
|
|
end_hour: Working day end hour (0-23). Default 18.
|
|
|
|
Returns:
|
|
JSON object with attendee info and free_slots keyed by date,
|
|
each containing an array of {start, end, duration_minutes}.
|
|
"""
|
|
client = _get_client(ctx)
|
|
|
|
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
|
|
except ValueError as e:
|
|
return json.dumps({"error": f"Invalid date format: {e}"})
|
|
|
|
# Resolve names to email addresses via ResolveNames
|
|
email_list = []
|
|
resolve_errors = []
|
|
for entry in raw_list:
|
|
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", "")
|
|
if addr:
|
|
email_list.append(addr)
|
|
else:
|
|
resolve_errors.append(entry)
|
|
else:
|
|
resolve_errors.append(entry)
|
|
|
|
if not email_list:
|
|
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",
|
|
}
|
|
)
|
|
|
|
# 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",
|
|
},
|
|
},
|
|
},
|
|
"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",
|
|
},
|
|
},
|
|
}
|
|
|
|
try:
|
|
data = client.request("GetUserAvailability", payload)
|
|
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")})
|
|
|
|
# Parse availability responses
|
|
all_busy = []
|
|
attendee_info = []
|
|
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}"
|
|
|
|
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,
|
|
}
|
|
)
|
|
|
|
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 [])
|
|
)
|
|
if 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", "")
|
|
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"))
|
|
all_busy.append((start, end))
|
|
except Exception:
|
|
pass
|
|
else:
|
|
attendee_info.append(
|
|
{
|
|
"email": email,
|
|
"status": "no_data",
|
|
}
|
|
)
|
|
|
|
merged_busy = _merge_busy_periods(all_busy)
|
|
|
|
# Find free slots for each weekday in range
|
|
free_by_date = {}
|
|
current_date = sd
|
|
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,
|
|
)
|
|
if free:
|
|
free_by_date[str(current_date)] = [
|
|
{
|
|
"start": _format_time(s),
|
|
"end": _format_time(e),
|
|
"duration_minutes": int((e - s).total_seconds() / 60),
|
|
}
|
|
for s, e in free
|
|
]
|
|
current_date += timedelta(days=1)
|
|
|
|
result = {
|
|
"period": {"start": str(sd), "end": str(ed)},
|
|
"attendees": attendee_info,
|
|
"free_slots": free_by_date,
|
|
}
|
|
|
|
if resolve_errors:
|
|
result["unresolved"] = resolve_errors
|
|
|
|
return json.dumps(result, ensure_ascii=False)
|