This commit is contained in:
2026-07-06 10:39:24 +03:00
parent f7619a31ec
commit 5ec3cb4ab4
13 changed files with 4425 additions and 4426 deletions
+371 -367
View File
@@ -16,9 +16,9 @@ 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)
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
# ------------------------------------------------------------------
@@ -27,12 +27,12 @@ def _get_client(ctx: Context) -> OWAClient:
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)
if resolutions:
mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "")
return "", ""
"""Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name)
if resolutions:
mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "")
return "", ""
# ------------------------------------------------------------------
@@ -41,105 +41,105 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
def _get_availability_events(
client: OWAClient,
emails: list[str],
start: date,
end: date,
batch_size: int = 5,
chunk_days: int = 14,
client: OWAClient,
emails: list[str],
start: date,
end: date,
batch_size: int = 5,
chunk_days: int = 14,
) -> dict[str, list[dict]]:
"""Query GetUserAvailability for multiple people across a date range.
"""Query GetUserAvailability for multiple people across a date range.
Returns dict mapping email -> list of calendar event dicts with
keys: subject, start_date, busy_type.
"""
results: dict[str, list[dict]] = {email: [] for email in emails}
Returns dict mapping email -> list of calendar event dicts with
keys: subject, start_date, busy_type.
"""
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)
]
# Batch people
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)
for batch in email_batches:
current = start
while current < end:
chunk_end = min(current + timedelta(days=chunk_days), end)
mailbox_data = [
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",
},
},
},
"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",
},
},
}
try:
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", {})
if isinstance(cal_events, dict):
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(
{
"__type": "MailboxData:#Exchange",
"Email": {
"__type": "EmailAddress:#Exchange",
"Address": email,
},
"AttendeeType": "Required",
"subject": subject,
"start_date": s[:10],
"busy_type": bt,
}
for email in batch
]
)
except Exception:
pass
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"{current}T00:00:00",
"EndTime": f"{chunk_end}T00:00:00",
},
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
},
},
}
current = chunk_end
try:
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", {})
if isinstance(cal_events, dict):
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,
}
)
except Exception:
pass
current = chunk_end
return results
return results
# ------------------------------------------------------------------
@@ -149,105 +149,107 @@ def _get_availability_events(
@mcp.tool()
def get_meeting_stats(
people: str,
start_date: str,
end_date: str,
ctx: Context = None,
people: str,
start_date: str,
end_date: str,
ctx: Context = None,
) -> str:
"""Get meeting count statistics for one or more people over a date range.
"""Get meeting count statistics for one or more people over a date range.
Uses GetUserAvailability to count calendar events (including
expanded recurring instances) for each person.
Uses GetUserAvailability to count calendar events (including
expanded recurring instances) for each person.
Args:
people: Comma-separated names or email addresses to analyze.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Args:
people: Comma-separated names or email addresses to analyze.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Returns:
JSON object with per-person stats sorted by meeting count:
total_meetings, meetings_per_workday, days_with_meetings.
"""
client = _get_client(ctx)
Returns:
JSON object with per-person stats sorted by meeting count:
total_meetings, meetings_per_workday, days_with_meetings.
"""
client = _get_client(ctx)
try:
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}"})
try:
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()]
if not name_list:
return json.dumps({"error": "No people specified."})
# Resolve names
name_list = [n.strip() for n in people.split(",") if n.strip()]
if not name_list:
return json.dumps({"error": "No people specified."})
resolved = [] # (display_name, email)
for name in name_list:
display, email = _resolve_to_email(client, name)
if email:
resolved.append((display, email))
else:
resolved.append((name, ""))
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps({"error": "Could not resolve any names to email addresses."})
# Query availability
avail = _get_availability_events(client, emails, sd, ed)
# Count working days
workdays = 0
d = sd
while d < ed:
if d.weekday() < 5:
workdays += 1
d += timedelta(days=1)
workdays = max(workdays, 1)
# Build 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,
}
)
continue
events = avail[email]
total = len(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,
}
)
# Sort by total descending
stats.sort(key=lambda x: x["total_meetings"], reverse=True)
resolved = [] # (display_name, email)
for name in name_list:
display, email = _resolve_to_email(client, name)
if email:
resolved.append((display, email))
else:
resolved.append((name, ""))
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps(
{
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
},
ensure_ascii=False,
{"error": "Could not resolve any names to email addresses."}
)
# Query availability
avail = _get_availability_events(client, emails, sd, ed)
# Count working days
workdays = 0
d = sd
while d < ed:
if d.weekday() < 5:
workdays += 1
d += timedelta(days=1)
workdays = max(workdays, 1)
# Build 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,
}
)
continue
events = avail[email]
total = len(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,
}
)
# 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,
)
# ------------------------------------------------------------------
# Tool: get_meeting_contacts
@@ -256,194 +258,196 @@ def get_meeting_stats(
@mcp.tool()
def get_meeting_contacts(
start_date: str,
end_date: str,
top_n: int = 30,
ctx: Context = None,
start_date: str,
end_date: str,
top_n: int = 30,
ctx: Context = None,
) -> str:
"""Build a connection matrix: who you meet with most often.
"""Build a connection matrix: who you meet with most often.
Analyzes your calendar over a date range, accounting for recurring
meetings. For each contact found in your meetings, returns the
weighted count of shared meetings.
Analyzes your calendar over a date range, accounting for recurring
meetings. For each contact found in your meetings, returns the
weighted count of shared meetings.
Args:
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
top_n: Number of top contacts to return. Default 30.
Args:
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
top_n: Number of top contacts to return. Default 30.
Returns:
JSON object with total_meetings, unique_contacts, and a ranked
contacts array of {name, email, meetings} objects.
"""
client = _get_client(ctx)
Returns:
JSON object with total_meetings, unique_contacts, and a ranked
contacts array of {name, email, meetings} objects.
"""
client = _get_client(ctx)
try:
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}"})
# Step 1: Get own expanded events via GetUserAvailability
# 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."}
)
folder_id = client.get_folder_id("calendar")
if not folder_id:
return json.dumps({"error": "Could not find calendar folder."})
# Get expanded event subjects with occurrence counts
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)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
# Scan entire calendar (descending) to match subjects
subject_to_id: dict[str, str] = {}
offset = 0
while len(subject_to_id) < len(subject_counts):
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "Default",
},
"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",
},
}
],
},
}
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)
)
if not folder_items:
break
for item in folder_items:
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", "")
if is_last:
break
offset += 200
if offset > 3000:
break
# Step 3: GetItem for each master to get attendees
unique_ids = list(set(subject_to_id.values()))
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi : bi + 10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": item_id_list,
},
}
try:
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}"})
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
# Step 1: Get own expanded events via GetUserAvailability
# 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."}
)
folder_id = client.get_folder_id("calendar")
if not folder_id:
return json.dumps({"error": "Could not find calendar folder."})
# Get expanded event subjects with occurrence counts
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)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
# Scan entire calendar (descending) to match subjects
subject_to_id: dict[str, str] = {}
offset = 0
while len(subject_to_id) < len(subject_counts):
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "Default",
},
"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",
},
}
],
},
}
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)
if not folder_items:
break
for item in folder_items:
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", "")
if is_last:
break
offset += 200
if offset > 3000:
break
# Step 3: GetItem for each master to get attendees
unique_ids = list(set(subject_to_id.values()))
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi : bi + 10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": item_id_list,
},
}
try:
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
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", "")
addr = mailbox.get("EmailAddress", "")
if not name or not addr or addr.startswith("/O="):
continue
attendees.add((name, addr.lower()))
organizer = item.get("Organizer", {}).get("Mailbox", {})
if organizer:
org_addr = organizer.get("EmailAddress", "")
org_name = organizer.get("Name", "")
if org_addr and not org_addr.startswith("/O="):
attendees.add((org_name, org_addr.lower()))
id_to_attendees[item_id] = attendees
except Exception:
pass
# Step 4: Build weighted contacts, excluding self
contacts = Counter()
for subject, item_id in subject_to_id.items():
weight = subject_counts.get(subject, 1)
attendees = id_to_attendees.get(item_id, set())
for name, email in attendees:
if own_email and email == own_email:
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", "")
addr = mailbox.get("EmailAddress", "")
if not name or not addr or addr.startswith("/O="):
continue
contacts[(name, email)] += weight
attendees.add((name, addr.lower()))
result_contacts = []
for (name, email), count in contacts.most_common(top_n):
result_contacts.append(
{
"name": name,
"email": email,
"meetings": count,
}
)
organizer = item.get("Organizer", {}).get("Mailbox", {})
if organizer:
org_addr = organizer.get("EmailAddress", "")
org_name = organizer.get("Name", "")
if org_addr and not org_addr.startswith("/O="):
attendees.add((org_name, org_addr.lower()))
return json.dumps(
{
"period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded,
"unique_contacts": len(contacts),
"contacts": result_contacts,
},
ensure_ascii=False,
id_to_attendees[item_id] = attendees
except Exception:
pass
# Step 4: Build weighted contacts, excluding self
contacts = Counter()
for subject, item_id in subject_to_id.items():
weight = subject_counts.get(subject, 1)
attendees = id_to_attendees.get(item_id, set())
for name, email in attendees:
if own_email and email == own_email:
continue
contacts[(name, email)] += weight
result_contacts = []
for (name, email), count in contacts.most_common(top_n):
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,
)