This commit is contained in:
2026-07-06 10:39:24 +03:00
parent f7619a31ec
commit 5ec3cb4ab4
13 changed files with 4425 additions and 4426 deletions
+371 -367
View File
@@ -16,9 +16,9 @@ from owa_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
# ------------------------------------------------------------------
@@ -27,12 +27,12 @@ def _get_client(ctx: Context) -> OWAClient:
def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
"""Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name)
if resolutions:
mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "")
return "", ""
"""Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name)
if resolutions:
mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "")
return "", ""
# ------------------------------------------------------------------
@@ -41,105 +41,105 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
def _get_availability_events(
client: OWAClient,
emails: list[str],
start: date,
end: date,
batch_size: int = 5,
chunk_days: int = 14,
client: OWAClient,
emails: list[str],
start: date,
end: date,
batch_size: int = 5,
chunk_days: int = 14,
) -> dict[str, list[dict]]:
"""Query GetUserAvailability for multiple people across a date range.
"""Query GetUserAvailability for multiple people across a date range.
Returns dict mapping email -> list of calendar event dicts with
keys: subject, start_date, busy_type.
"""
results: dict[str, list[dict]] = {email: [] for email in emails}
Returns dict mapping email -> list of calendar event dicts with
keys: subject, start_date, busy_type.
"""
results: dict[str, list[dict]] = {email: [] for email in emails}
# Batch people
email_batches = [
emails[i : i + batch_size] for i in range(0, len(emails), batch_size)
]
# Batch people
email_batches = [
emails[i : i + batch_size] for i in range(0, len(emails), batch_size)
]
for batch in email_batches:
current = start
while current < end:
chunk_end = min(current + timedelta(days=chunk_days), end)
for batch in email_batches:
current = start
while current < end:
chunk_end = min(current + timedelta(days=chunk_days), end)
mailbox_data = [
mailbox_data = [
{
"__type": "MailboxData:#Exchange",
"Email": {
"__type": "EmailAddress:#Exchange",
"Address": email,
},
"AttendeeType": "Required",
}
for email in batch
]
payload = {
"__type": "GetUserAvailabilityJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
"TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time",
},
},
},
"Body": {
"__type": "GetUserAvailabilityRequest:#Exchange",
"MailboxDataArray": mailbox_data,
"FreeBusyViewOptions": {
"__type": "FreeBusyViewOptions:#Exchange",
"TimeWindow": {
"__type": "Duration:#Exchange",
"StartTime": f"{current}T00:00:00",
"EndTime": f"{chunk_end}T00:00:00",
},
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
},
},
}
try:
data = client.request("GetUserAvailability", payload)
body = data.get("Body", {})
for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])):
if i >= len(batch):
break
email = batch[i]
fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get("CalendarEventArray", {})
if isinstance(cal_events, dict):
items = cal_events.get("Items", [])
elif isinstance(cal_events, list):
items = cal_events
else:
items = []
for event in items:
s = event.get("StartTime", "")
bt = event.get("BusyType", "")
if s and bt != "Free":
details = event.get("CalendarEventDetails", {})
subject = details.get("Subject", "") if details else ""
results[email].append(
{
"__type": "MailboxData:#Exchange",
"Email": {
"__type": "EmailAddress:#Exchange",
"Address": email,
},
"AttendeeType": "Required",
"subject": subject,
"start_date": s[:10],
"busy_type": bt,
}
for email in batch
]
)
except Exception:
pass
payload = {
"__type": "GetUserAvailabilityJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
"TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time",
},
},
},
"Body": {
"__type": "GetUserAvailabilityRequest:#Exchange",
"MailboxDataArray": mailbox_data,
"FreeBusyViewOptions": {
"__type": "FreeBusyViewOptions:#Exchange",
"TimeWindow": {
"__type": "Duration:#Exchange",
"StartTime": f"{current}T00:00:00",
"EndTime": f"{chunk_end}T00:00:00",
},
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
},
},
}
current = chunk_end
try:
data = client.request("GetUserAvailability", payload)
body = data.get("Body", {})
for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])):
if i >= len(batch):
break
email = batch[i]
fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get("CalendarEventArray", {})
if isinstance(cal_events, dict):
items = cal_events.get("Items", [])
elif isinstance(cal_events, list):
items = cal_events
else:
items = []
for event in items:
s = event.get("StartTime", "")
bt = event.get("BusyType", "")
if s and bt != "Free":
details = event.get("CalendarEventDetails", {})
subject = details.get("Subject", "") if details else ""
results[email].append(
{
"subject": subject,
"start_date": s[:10],
"busy_type": bt,
}
)
except Exception:
pass
current = chunk_end
return results
return results
# ------------------------------------------------------------------
@@ -149,105 +149,107 @@ def _get_availability_events(
@mcp.tool()
def get_meeting_stats(
people: str,
start_date: str,
end_date: str,
ctx: Context = None,
people: str,
start_date: str,
end_date: str,
ctx: Context = None,
) -> str:
"""Get meeting count statistics for one or more people over a date range.
"""Get meeting count statistics for one or more people over a date range.
Uses GetUserAvailability to count calendar events (including
expanded recurring instances) for each person.
Uses GetUserAvailability to count calendar events (including
expanded recurring instances) for each person.
Args:
people: Comma-separated names or email addresses to analyze.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Args:
people: Comma-separated names or email addresses to analyze.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Returns:
JSON object with per-person stats sorted by meeting count:
total_meetings, meetings_per_workday, days_with_meetings.
"""
client = _get_client(ctx)
Returns:
JSON object with per-person stats sorted by meeting count:
total_meetings, meetings_per_workday, days_with_meetings.
"""
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
# Resolve names
name_list = [n.strip() for n in people.split(",") if n.strip()]
if not name_list:
return json.dumps({"error": "No people specified."})
# Resolve names
name_list = [n.strip() for n in people.split(",") if n.strip()]
if not name_list:
return json.dumps({"error": "No people specified."})
resolved = [] # (display_name, email)
for name in name_list:
display, email = _resolve_to_email(client, name)
if email:
resolved.append((display, email))
else:
resolved.append((name, ""))
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps({"error": "Could not resolve any names to email addresses."})
# Query availability
avail = _get_availability_events(client, emails, sd, ed)
# Count working days
workdays = 0
d = sd
while d < ed:
if d.weekday() < 5:
workdays += 1
d += timedelta(days=1)
workdays = max(workdays, 1)
# Build stats
stats = []
for display, email in resolved:
if not email or email not in avail:
stats.append(
{
"name": display,
"email": email or "not_found",
"total_meetings": 0,
"meetings_per_workday": 0,
"days_with_meetings": 0,
"workdays": workdays,
}
)
continue
events = avail[email]
total = len(events)
unique_days = len(set(ev["start_date"] for ev in events))
avg = round(total / workdays, 1)
stats.append(
{
"name": display,
"email": email,
"total_meetings": total,
"meetings_per_workday": avg,
"days_with_meetings": unique_days,
"workdays": workdays,
}
)
# Sort by total descending
stats.sort(key=lambda x: x["total_meetings"], reverse=True)
resolved = [] # (display_name, email)
for name in name_list:
display, email = _resolve_to_email(client, name)
if email:
resolved.append((display, email))
else:
resolved.append((name, ""))
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps(
{
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
},
ensure_ascii=False,
{"error": "Could not resolve any names to email addresses."}
)
# Query availability
avail = _get_availability_events(client, emails, sd, ed)
# Count working days
workdays = 0
d = sd
while d < ed:
if d.weekday() < 5:
workdays += 1
d += timedelta(days=1)
workdays = max(workdays, 1)
# Build stats
stats = []
for display, email in resolved:
if not email or email not in avail:
stats.append(
{
"name": display,
"email": email or "not_found",
"total_meetings": 0,
"meetings_per_workday": 0,
"days_with_meetings": 0,
"workdays": workdays,
}
)
continue
events = avail[email]
total = len(events)
unique_days = len(set(ev["start_date"] for ev in events))
avg = round(total / workdays, 1)
stats.append(
{
"name": display,
"email": email,
"total_meetings": total,
"meetings_per_workday": avg,
"days_with_meetings": unique_days,
"workdays": workdays,
}
)
# Sort by total descending
stats.sort(key=lambda x: x["total_meetings"], reverse=True)
return json.dumps(
{
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
},
ensure_ascii=False,
)
# ------------------------------------------------------------------
# Tool: get_meeting_contacts
@@ -256,194 +258,196 @@ def get_meeting_stats(
@mcp.tool()
def get_meeting_contacts(
start_date: str,
end_date: str,
top_n: int = 30,
ctx: Context = None,
start_date: str,
end_date: str,
top_n: int = 30,
ctx: Context = None,
) -> str:
"""Build a connection matrix: who you meet with most often.
"""Build a connection matrix: who you meet with most often.
Analyzes your calendar over a date range, accounting for recurring
meetings. For each contact found in your meetings, returns the
weighted count of shared meetings.
Analyzes your calendar over a date range, accounting for recurring
meetings. For each contact found in your meetings, returns the
weighted count of shared meetings.
Args:
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
top_n: Number of top contacts to return. Default 30.
Args:
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
top_n: Number of top contacts to return. Default 30.
Returns:
JSON object with total_meetings, unique_contacts, and a ranked
contacts array of {name, email, meetings} objects.
"""
client = _get_client(ctx)
Returns:
JSON object with total_meetings, unique_contacts, and a ranked
contacts array of {name, email, meetings} objects.
"""
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
# Step 1: Get own expanded events via GetUserAvailability
# to count subject occurrences (handles recurring meetings)
own_email = client.user_email.lower() if client.user_email else ""
if not own_email:
return json.dumps(
{"error": "User email not available. Call the login tool first."}
)
folder_id = client.get_folder_id("calendar")
if not folder_id:
return json.dumps({"error": "Could not find calendar folder."})
# Get expanded event subjects with occurrence counts
avail_result = _get_availability_events(client, [client.user_email], sd, ed)
expanded_events = avail_result.get(client.user_email, [])
subject_counts = Counter(ev["subject"] for ev in expanded_events)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
# Scan entire calendar (descending) to match subjects
subject_to_id: dict[str, str] = {}
offset = 0
while len(subject_to_id) < len(subject_counts):
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": 200,
},
"SortOrder": [
{
"__type": "SortResults:#Exchange",
"Order": "Descending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "Start",
},
}
],
},
}
data = client.request("FindItem", payload)
items = client.extract_items(data)
if not items:
break
folder_items = items[0].get("RootFolder", {}).get("Items", [])
is_last = (
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
)
if not folder_items:
break
for item in folder_items:
subj = item.get("Subject", "")
if subj and subj in subject_counts and subj not in subject_to_id:
subject_to_id[subj] = item.get("ItemId", {}).get("Id", "")
if is_last:
break
offset += 200
if offset > 3000:
break
# Step 3: GetItem for each master to get attendees
unique_ids = list(set(subject_to_id.values()))
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi : bi + 10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": item_id_list,
},
}
try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
# Step 1: Get own expanded events via GetUserAvailability
# to count subject occurrences (handles recurring meetings)
own_email = client.user_email.lower() if client.user_email else ""
if not own_email:
return json.dumps(
{"error": "User email not available. Call the login tool first."}
)
folder_id = client.get_folder_id("calendar")
if not folder_id:
return json.dumps({"error": "Could not find calendar folder."})
# Get expanded event subjects with occurrence counts
avail_result = _get_availability_events(client, [client.user_email], sd, ed)
expanded_events = avail_result.get(client.user_email, [])
subject_counts = Counter(ev["subject"] for ev in expanded_events)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
# Scan entire calendar (descending) to match subjects
subject_to_id: dict[str, str] = {}
offset = 0
while len(subject_to_id) < len(subject_counts):
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": 200,
},
"SortOrder": [
{
"__type": "SortResults:#Exchange",
"Order": "Descending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "Start",
},
}
],
},
}
data = client.request("FindItem", payload)
items = client.extract_items(data)
if not items:
break
folder_items = items[0].get("RootFolder", {}).get("Items", [])
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if not folder_items:
break
for item in folder_items:
subj = item.get("Subject", "")
if subj and subj in subject_counts and subj not in subject_to_id:
subject_to_id[subj] = item.get("ItemId", {}).get("Id", "")
if is_last:
break
offset += 200
if offset > 3000:
break
# Step 3: GetItem for each master to get attendees
unique_ids = list(set(subject_to_id.values()))
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi : bi + 10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": item_id_list,
},
}
try:
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
for att_list in [
item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or [],
]:
for a in att_list:
mailbox = a.get("Mailbox", {})
name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
if not name or not addr or addr.startswith("/O="):
continue
attendees.add((name, addr.lower()))
organizer = item.get("Organizer", {}).get("Mailbox", {})
if organizer:
org_addr = organizer.get("EmailAddress", "")
org_name = organizer.get("Name", "")
if org_addr and not org_addr.startswith("/O="):
attendees.add((org_name, org_addr.lower()))
id_to_attendees[item_id] = attendees
except Exception:
pass
# Step 4: Build weighted contacts, excluding self
contacts = Counter()
for subject, item_id in subject_to_id.items():
weight = subject_counts.get(subject, 1)
attendees = id_to_attendees.get(item_id, set())
for name, email in attendees:
if own_email and email == own_email:
for att_list in [
item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or [],
]:
for a in att_list:
mailbox = a.get("Mailbox", {})
name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
if not name or not addr or addr.startswith("/O="):
continue
contacts[(name, email)] += weight
attendees.add((name, addr.lower()))
result_contacts = []
for (name, email), count in contacts.most_common(top_n):
result_contacts.append(
{
"name": name,
"email": email,
"meetings": count,
}
)
organizer = item.get("Organizer", {}).get("Mailbox", {})
if organizer:
org_addr = organizer.get("EmailAddress", "")
org_name = organizer.get("Name", "")
if org_addr and not org_addr.startswith("/O="):
attendees.add((org_name, org_addr.lower()))
return json.dumps(
{
"period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded,
"unique_contacts": len(contacts),
"contacts": result_contacts,
},
ensure_ascii=False,
id_to_attendees[item_id] = attendees
except Exception:
pass
# Step 4: Build weighted contacts, excluding self
contacts = Counter()
for subject, item_id in subject_to_id.items():
weight = subject_counts.get(subject, 1)
attendees = id_to_attendees.get(item_id, set())
for name, email in attendees:
if own_email and email == own_email:
continue
contacts[(name, email)] += weight
result_contacts = []
for (name, email), count in contacts.most_common(top_n):
result_contacts.append(
{
"name": name,
"email": email,
"meetings": count,
}
)
return json.dumps(
{
"period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded,
"unique_contacts": len(contacts),
"contacts": result_contacts,
},
ensure_ascii=False,
)
+85 -85
View File
@@ -18,108 +18,108 @@ 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
"""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)
"""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
"""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,
cookies: str,
ctx: Context = None,
) -> str:
"""Authenticate to Exchange OWA using session cookies from the browser.
"""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.
Paste the cookies you copied from your browser (DevTools → Application →
Cookies) as a ``name=value`` string — one cookie per line.
Example cookies string::
Example cookies string::
X-OWA-CANARY=abc123
CookieAuth1=def456
...
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.
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)
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.",
}
)
# 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}"}
)
# 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)})
# 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.",
}
)
# 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.",
}
)
File diff suppressed because it is too large Load Diff
+1148 -1157
View File
File diff suppressed because it is too large Load Diff
+998 -997
View File
File diff suppressed because it is too large Load Diff
+343 -341
View File
@@ -12,435 +12,437 @@ 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 = {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
"TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time",
},
"__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)
"""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}
"""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.
"""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.
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)
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),
}
)
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": "Unexpected response",
"cookie_file": str(client.cookie_file),
}
{
"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,
parent_folder_id: str = "msgfolderroot",
recursive: bool = False,
ctx: Context = None,
) -> str:
"""List mail folders from the Exchange mailbox.
"""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).
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)
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"
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)
# 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,
},
},
}
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)})
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),
}
)
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)
return json.dumps(folders, ensure_ascii=False)
@mcp.tool()
def create_folder(
name: str,
parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
name: str,
parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
) -> str:
"""Create a new mail folder.
"""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.
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)
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",
}
],
},
}
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)})
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", ""),
}
)
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)})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def rename_folder(
folder_id: str,
new_name: str,
ctx: Context = None,
folder_id: str,
new_name: str,
ctx: Context = None,
) -> str:
"""Rename an existing mail folder.
"""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.
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)
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,
},
}
],
}
],
},
}
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)})
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", ""),
}
)
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)})
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,
folder_id: str,
delete_sub_folders: bool = False,
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Empty all items from a mail folder.
"""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.
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)
Returns:
JSON object with success status.
"""
client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
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,
},
}
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)})
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})
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)})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def delete_folder(
folder_id: str,
permanent: bool = False,
ctx: Context = None,
folder_id: str,
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Delete a mail folder.
"""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.
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)
Returns:
JSON object with success status.
"""
client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
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,
},
}
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)})
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})
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)})
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,
folder_id: str,
target_parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
) -> str:
"""Move a mail folder to a different parent folder.
"""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.
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)
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),
},
},
}
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)})
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", ""),
}
)
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)})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
+86 -86
View File
@@ -13,109 +13,109 @@ from owa_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
def _parse_person(resolution: dict) -> dict:
"""Parse person data from a ResolveNames resolution entry.
"""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", {})
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", ""),
}
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
# 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),
}
# 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", "")
# 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", ""),
}
)
# Direct reports
for report in contact.get("DirectReports", []):
person["direct_reports"].append(
{
"name": report.get("Name", ""),
"email": report.get("EmailAddress", ""),
}
)
return person
return person
@mcp.tool()
def find_person(query: str, ctx: Context) -> str:
"""Search for people in the corporate directory.
"""Search for people in the corporate directory.
Looks up employees by name, email, department, or keyword using the
Exchange ResolveNames API against Active 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.
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)
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)})
try:
resolutions = client.resolve_names(query)
except Exception as e:
return json.dumps({"error": str(e)})
if not resolutions:
return json.dumps([])
if not resolutions:
return json.dumps([])
people = [_parse_person(r) for r in resolutions]
return json.dumps(people, ensure_ascii=False)
people = [_parse_person(r) for r in resolutions]
return json.dumps(people, ensure_ascii=False)