w
This commit is contained in:
+148
-121
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user