This commit is contained in:
2026-07-06 01:58:46 +03:00
parent a9497625b1
commit 40dea9a2c2
18 changed files with 69 additions and 334 deletions
+1
View File
@@ -0,0 +1 @@
"""MCP tool modules for OWA."""
+422
View File
@@ -0,0 +1,422 @@
"""Analytics tools for the OWA MCP server.
Provides meeting statistics and connection matrix analysis
using GetUserAvailability and GetItem APIs.
"""
import json
from collections import Counter, defaultdict
from datetime import datetime, timedelta, date
from mcp.server.fastmcp import Context
from owa_mcp.server import mcp, AppContext
from owa_mcp.owa_client import OWAClient
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)
# ------------------------------------------------------------------
# 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)
if resolutions:
mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "")
return "", ""
# ------------------------------------------------------------------
# Internal: query GetUserAvailability in chunks
# ------------------------------------------------------------------
def _get_availability_events(
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.
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)]
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]
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({
'subject': subject,
'start_date': s[:10],
'busy_type': bt,
})
except Exception:
pass
current = chunk_end
return results
# ------------------------------------------------------------------
# Tool: get_meeting_stats
# ------------------------------------------------------------------
@mcp.tool()
def get_meeting_stats(
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.
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.
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}"})
# 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)
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,
end_date: str,
top_n: int = 30,
ctx: Context = None,
) -> str:
"""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.
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)
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:
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:
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)
+117
View File
@@ -0,0 +1,117 @@
"""Authentication tool for the OWA MCP server.
Provides a `login` tool that accepts session cookies from the user,
saves them to disk, loads them into the OWA client, and verifies the session.
The user obtains cookies from their browser (DevTools → Application → Cookies),
pastes them as a ``name=value`` string (one cookie per line), and passes them
to this tool.
"""
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_lifecycle import require_client
def _get_app_ctx(ctx: Context) -> AppContext:
"""Extract the AppContext from the MCP lifespan context."""
return ctx.request_context.lifespan_context
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
return require_client(_get_app_ctx(ctx).client)
def _session_is_valid(client: OWAClient) -> bool:
"""Quick check: can we reach the inbox?"""
try:
client._ensure_loaded()
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": "inbox",
}
],
},
}
data = client.request("GetFolder", payload)
items = client.extract_items(data)
return bool(items)
except Exception:
return False
@mcp.tool()
async def login(
cookies: str,
ctx: Context = None,
) -> str:
"""Authenticate to Exchange OWA using session cookies from the browser.
Paste the cookies you copied from your browser (DevTools → Application →
Cookies) as a ``name=value`` string — one cookie per line.
Example cookies string::
X-OWA-CANARY=abc123
CookieAuth1=def456
...
Args:
cookies: Session cookies in ``name=value`` format, one per line.
Obtain from browser DevTools → Application → Cookies → copy all
cookies as ``name=value`` pairs.
Returns:
JSON result with success status and message.
"""
client = _get_client(ctx)
# 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.",
})
# 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}"})
# Load into memory
try:
client.load_cookies_from_string(cookies_str)
except SessionExpiredError as e:
return json.dumps({"success": False, "error": str(e)})
# Verify session
if _session_is_valid(client):
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.",
})
+594
View File
@@ -0,0 +1,594 @@
"""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.server import mcp, AppContext
from owa_mcp.owa_client import OWAClient
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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
"""Folder tools for the OWA MCP server.
Ports the get-folders.py logic into an MCP tool using OWAClient.
"""
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_lifecycle import require_client
_DISTINGUISHED_NAMES = {
"msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems",
"junkemail", "outbox", "calendar", "contacts", "tasks", "notes",
"journal", "searchfolders",
}
_HEADER_TZ = {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
"TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time",
},
},
}
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)
def _folder_id_dict(folder_id: str) -> dict:
"""Build a typed FolderId or DistinguishedFolderId dict."""
if folder_id.lower() in _DISTINGUISHED_NAMES:
return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id}
return {"__type": "FolderId:#Exchange", "Id": folder_id}
@mcp.tool()
def check_session(ctx: Context = None) -> str:
"""Check whether the current OWA session is authenticated.
Makes a lightweight GetFolder call on the inbox. Returns session
status, the mailbox display name, and cookie file path.
Returns:
JSON object with authenticated (bool), mailbox name, and details.
"""
client = _get_client(ctx)
payload = {
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"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),
})
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": False,
"error": "Unexpected response",
"cookie_file": str(client.cookie_file),
})
@mcp.tool()
def get_folders(
parent_folder_id: str = "msgfolderroot",
recursive: bool = False,
ctx: Context = None,
) -> str:
"""List mail folders from the Exchange mailbox.
Args:
parent_folder_id: Parent folder to list children of.
Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID.
recursive: If True, traverse all subfolders recursively (Deep).
If False, only list immediate children (Shallow).
Returns:
JSON array of folder objects with: name, id, total_count,
unread_count, child_folder_count.
"""
client = _get_client(ctx)
traversal = "Deep" if recursive else "Shallow"
# Determine parent folder id type
# Distinguished folder names are short lowercase strings
parent_folder = _folder_id_dict(parent_folder_id)
payload = {
"__type": "FindFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [parent_folder],
"Traversal": traversal,
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": 0,
"MaxEntriesReturned": 200,
},
},
}
try:
data = client.request("FindFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
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),
})
return json.dumps(folders, ensure_ascii=False)
@mcp.tool()
def create_folder(
name: str,
parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
) -> str:
"""Create a new mail folder.
Args:
name: Display name for the new folder.
parent_folder_id: Parent folder to create under.
Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID.
Returns:
JSON object with the created folder's id and name.
"""
client = _get_client(ctx)
payload = {
"__type": "CreateFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "CreateFolderRequest:#Exchange",
"ParentFolderId": {
"__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(parent_folder_id),
},
"Folders": [
{
"__type": "Folder:#Exchange",
"DisplayName": name,
"FolderClass": "IPF.Note",
}
],
},
}
try:
data = client.request_header_payload("CreateFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
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({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def rename_folder(
folder_id: str,
new_name: str,
ctx: Context = None,
) -> str:
"""Rename an existing mail folder.
Args:
folder_id: The Exchange folder ID to rename (from get_folders).
new_name: New display name for the folder.
Returns:
JSON object with success status and new folder id.
"""
client = _get_client(ctx)
payload = {
"__type": "UpdateFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "UpdateFolderRequest:#Exchange",
"FolderChanges": [
{
"__type": "FolderChange:#Exchange",
"FolderId": {
"__type": "FolderId:#Exchange",
"Id": folder_id,
},
"Updates": [
{
"__type": "SetFolderField:#Exchange",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "FolderDisplayName",
},
"Folder": {
"__type": "Folder:#Exchange",
"DisplayName": new_name,
},
}
],
}
],
},
}
try:
data = client.request_header_payload("UpdateFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
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({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def empty_folder(
folder_id: str,
delete_sub_folders: bool = False,
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Empty all items from a mail folder.
Args:
folder_id: The Exchange folder ID to empty (from get_folders).
delete_sub_folders: If True, also delete sub-folders. Default False.
permanent: If True, permanently delete items (HardDelete).
Otherwise move to Deleted Items. Default False.
Returns:
JSON object with success status.
"""
client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
payload = {
"__type": "EmptyFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "EmptyFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"DeleteType": delete_type,
"DeleteSubFolders": delete_sub_folders,
"SuppressReadReceipt": True,
},
}
try:
data = client.request_header_payload("EmptyFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "folder_id": folder_id})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def delete_folder(
folder_id: str,
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Delete a mail folder.
Args:
folder_id: The Exchange folder ID to delete (from get_folders).
permanent: If True, permanently delete (HardDelete).
Otherwise move to Deleted Items. Default False.
Returns:
JSON object with success status.
"""
client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
payload = {
"__type": "DeleteFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "DeleteFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"DeleteType": delete_type,
},
}
try:
data = client.request_header_payload("DeleteFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "folder_id": folder_id})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def move_folder(
folder_id: str,
target_parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
) -> str:
"""Move a mail folder to a different parent folder.
Args:
folder_id: The Exchange folder ID to move (from get_folders).
target_parent_folder_id: Destination parent folder.
Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID.
Returns:
JSON object with success status and new folder ID.
"""
client = _get_client(ctx)
payload = {
"__type": "MoveFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "MoveFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"ToFolderId": {
"__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(target_parent_folder_id),
},
},
}
try:
data = client.request_header_payload("MoveFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
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({"error": "Unexpected response", "raw": str(data)})
+119
View File
@@ -0,0 +1,119 @@
"""People / directory search tools for the OWA MCP server.
Ports the find-person.py logic into an MCP tool using OWAClient.
"""
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_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)
def _parse_person(resolution: dict) -> dict:
"""Parse person data from a ResolveNames resolution entry.
Preserves the exact logic from find-person.py parse_person().
"""
mailbox = resolution.get("Mailbox", {})
contact = resolution.get("Contact", {})
person = {
"name": mailbox.get("Name", contact.get("DisplayName", "")),
"email": mailbox.get("EmailAddress", ""),
"type": mailbox.get("MailboxType", ""),
"first_name": contact.get("GivenName", ""),
"last_name": contact.get("Surname", ""),
"job_title": contact.get("JobTitle", ""),
"department": contact.get("Department", ""),
"company": contact.get("CompanyName", ""),
"office": contact.get("OfficeLocation", ""),
"manager": "",
"manager_email": "",
"phones": {},
"address": {},
"direct_reports": [],
"alias": contact.get("Alias", ""),
}
# Phone numbers
for phone in contact.get("PhoneNumbers", []):
key = phone.get("Key", "")
number = phone.get("PhoneNumber", "")
if number:
person["phones"][key] = number
# Physical address
for addr in contact.get("PhysicalAddresses", []):
if addr.get("Key") == "Business":
parts = []
if addr.get("Street"):
parts.append(addr["Street"])
if addr.get("City"):
parts.append(addr["City"])
if addr.get("PostalCode"):
parts.append(addr["PostalCode"])
if addr.get("CountryOrRegion"):
parts.append(addr["CountryOrRegion"])
if parts:
person["address"] = {
"street": addr.get("Street", ""),
"city": addr.get("City", ""),
"postal_code": addr.get("PostalCode", ""),
"country": addr.get("CountryOrRegion", ""),
"full": ", ".join(parts),
}
# Manager
manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {})
if manager_data:
person["manager"] = manager_data.get("Name", "")
person["manager_email"] = manager_data.get("EmailAddress", "")
elif contact.get("Manager"):
person["manager"] = contact.get("Manager", "")
# Direct reports
for report in contact.get("DirectReports", []):
person["direct_reports"].append({
"name": report.get("Name", ""),
"email": report.get("EmailAddress", ""),
})
return person
@mcp.tool()
def find_person(query: str, ctx: Context) -> str:
"""Search for people in the corporate directory.
Looks up employees by name, email, department, or keyword using the
Exchange ResolveNames API against Active Directory.
Args:
query: Name, email address, or keyword to search for.
Returns:
JSON array of matching people with contact details (name, email,
job_title, department, company, office, phones, address, manager,
direct_reports, alias).
"""
client = _get_client(ctx)
try:
resolutions = client.resolve_names(query)
except Exception as e:
return json.dumps({"error": str(e)})
if not resolutions:
return json.dumps([])
people = [_parse_person(r) for r in resolutions]
return json.dumps(people, ensure_ascii=False)