feat: basics #1

Merged
albnnc merged 8 commits from itchy-gayal into main 2026-07-06 07:55:40 +00:00
14 changed files with 823 additions and 607 deletions
Showing only changes of commit 4207b38b16 - Show all commits
+21
View File
@@ -0,0 +1,21 @@
{
"excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"],
"includes": ["**/*.{md,json,py}"],
"indentWidth": 2,
"json": {
"deno": true
},
"lineWidth": 80,
"markdown": {
"deno": true
},
"plugins": [
"https://plugins.dprint.dev/json-0.22.0.wasm",
"https://plugins.dprint.dev/markdown-0.22.1.wasm",
"https://plugins.dprint.dev/ruff-0.7.20.wasm"
],
"ruff": {
"lineLength": 88,
"targetVersion": "py310"
}
}
+7
View File
@@ -9,6 +9,7 @@ from typing import TypedDict
class Email(TypedDict, total=False):
"""An email message."""
subject: str
sender: str
sender_name: str
@@ -35,6 +36,7 @@ class Email(TypedDict, total=False):
class CalendarEvent(TypedDict, total=False):
"""A calendar event / meeting."""
subject: str
start: str
end: str
@@ -54,6 +56,7 @@ class CalendarEvent(TypedDict, total=False):
class Person(TypedDict, total=False):
"""A person from the Exchange directory."""
name: str
email: str
mailbox_type: str
@@ -73,6 +76,7 @@ class Person(TypedDict, total=False):
class Folder(TypedDict, total=False):
"""A mail folder."""
name: str
id: str
total_count: int
@@ -82,6 +86,7 @@ class Folder(TypedDict, total=False):
class FreeSlot(TypedDict):
"""A free time slot."""
date: str
start: str
end: str
@@ -90,6 +95,7 @@ class FreeSlot(TypedDict):
class Availability(TypedDict, total=False):
"""Availability data for a single attendee."""
email: str
busy_slots: int
free_slots: int
@@ -98,6 +104,7 @@ class Availability(TypedDict, total=False):
class MeetingResult(TypedDict, total=False):
"""Result of creating/updating a meeting."""
success: bool
subject: str
date: str
+20 -24
View File
@@ -10,14 +10,15 @@ mirroring what the OWA web frontend sends.
import json
import time
from urllib.parse import quote, urlparse
from pathlib import Path
from urllib.parse import quote, urlparse
import requests
class SessionExpiredError(Exception):
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
pass
@@ -53,14 +54,11 @@ class OWAClient:
owa_url: str | None = None,
):
self.cookie_file = Path(
cookie_file
or str(Path(__file__).parent.parent / "session-cookies.txt")
cookie_file or str(Path(__file__).parent.parent / "session-cookies.txt")
)
self.owa_url = (owa_url or "").rstrip("/")
if not self.owa_url:
raise ValueError(
"OWA URL must be provided via --owa-url or owa_url=…"
)
raise ValueError("OWA URL must be provided via --owa-url or owa_url=…")
self._session = requests.Session()
self._loaded = False
self.user_email: str = ""
@@ -81,9 +79,10 @@ class OWAClient:
"""
now_ms = int(time.time() * 1000)
client_req_id = f"{self._client_id}_{now_ms}"
now_str = time.strftime(
"%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)
) + f"{now_ms % 1000:03d}"
now_str = (
time.strftime("%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000))
+ f"{now_ms % 1000:03d}"
)
return {
# Basic
"accept": "*/*",
@@ -129,9 +128,7 @@ class OWAClient:
@property
def _client_id(self) -> str:
"""Extract ClientId from session cookies."""
cid = next(
(c.value for c in self._session.cookies if c.name == "ClientId"), ""
)
cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "")
return cid if cid else "00000000000000000000000000000000"
# ------------------------------------------------------------------
@@ -153,7 +150,9 @@ class OWAClient:
cookies[name] = value
if not cookies:
raise SessionExpiredError("Cookie file is empty. Use the login MCP tool to authenticate.")
raise SessionExpiredError(
"Cookie file is empty. Use the login MCP tool to authenticate."
)
self._session = requests.Session()
self._session.cookies.update(cookies)
@@ -229,12 +228,11 @@ class OWAClient:
set_cookie = resp.headers.get("Set-Cookie", "")
if "X-OWA-CANARY=" in set_cookie:
import re
m = re.search(r'X-OWA-CANARY=([^;]+)', set_cookie)
m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie)
if m:
host = urlparse(self.owa_url).hostname or "owa.b1.ru"
self._session.cookies.set(
"X-OWA-CANARY", m.group(1), domain=host
)
self._session.cookies.set("X-OWA-CANARY", m.group(1), domain=host)
# Persist any cookie changes
self._save_cookies()
except Exception:
@@ -273,7 +271,9 @@ class OWAClient:
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError("Session expired (HTTP {}).".format(resp.status_code))
raise SessionExpiredError(
f"Session expired (HTTP {resp.status_code})."
)
if "text/html" in resp.headers.get("Content-Type", ""):
body_snippet = resp.text[:300] if resp.text else ""
@@ -335,9 +335,7 @@ class OWAClient:
raise SessionExpiredError(f"Download failed: {exc}") from exc
if resp.status_code in (401, 440):
raise SessionExpiredError(
f"Session expired (HTTP {resp.status_code})."
)
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
if "text/html" in resp.headers.get("Content-Type", ""):
raise SessionExpiredError(
@@ -469,9 +467,7 @@ class OWAClient:
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
def resolve_names(
self, query: str, *, full_contact: bool = True
) -> list[dict]:
def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]:
"""Call ResolveNames to search the directory.
Returns the list of Resolution dicts from the API, each containing
+9 -8
View File
@@ -23,7 +23,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.provider import TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
@@ -41,6 +41,7 @@ logger = logging.getLogger(__name__)
@dataclass
class AppContext:
"""Shared application state available to all tools via lifespan context."""
client: OWAClient | None = None
@@ -177,13 +178,13 @@ def main(argv=None) -> None:
raise SystemExit(1)
# ── Register tools (imports populate module-level `mcp`) ───────────────
import owa_mcp.tools.email # noqa: E402, F401
import owa_mcp.tools.calendar # noqa: E402, F401
import owa_mcp.tools.people # noqa: E402, F401
import owa_mcp.tools.folders # noqa: E402, F401
import owa_mcp.tools.availability # noqa: E402, F401
import owa_mcp.tools.analytics # noqa: E402, F401
import owa_mcp.tools.auth # noqa: E402, F401
import owa_mcp.tools.analytics # noqa: E402, F401
import owa_mcp.tools.auth # noqa: E402, F401
import owa_mcp.tools.availability # noqa: E402, F401
import owa_mcp.tools.calendar # noqa: E402, F401
import owa_mcp.tools.email # noqa: E402, F401
import owa_mcp.tools.folders # noqa: E402, F401
import owa_mcp.tools.people # noqa: E402, F401
# ── Auth for HTTP transports ───────────────────────────────────────────
_configure_auth(mcp, args.token_hash)
+39 -23
View File
@@ -17,9 +17,10 @@ import asyncio
import logging
from typing import TYPE_CHECKING
from owa_mcp.owa_client import OWAClient, SessionExpiredError
from mcp.server.auth.provider import AccessToken, TokenVerifier
from owa_mcp.owa_client import OWAClient, SessionExpiredError
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
@@ -28,6 +29,7 @@ logger = logging.getLogger(__name__)
# ── Client factory ───────────────────────────────────────────────────────
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
"""Create and return a fully configured :class:`OWAClient`.
@@ -50,6 +52,7 @@ def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient
# ── Token verifier ────────────────────────────────────────────────────────
class HashTokenVerifier(TokenVerifier):
"""Bearer-token verifier backed by a SHA-256 hash.
@@ -59,11 +62,12 @@ class HashTokenVerifier(TokenVerifier):
"""
def __init__(self, token_hash: str) -> None:
import hashlib
self._expected_hash = token_hash.strip().lower()
async def verify_token(self, token: str) -> AccessToken | None:
import hashlib
if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash:
return AccessToken(
token=token,
@@ -75,6 +79,7 @@ class HashTokenVerifier(TokenVerifier):
# ── Lifespan helper ──────────────────────────────────────────────────────
class _LazyClient:
"""Thin wrapper that resolves the OWAClient on first access.
@@ -103,8 +108,7 @@ def require_client(client: OWAClient | None) -> OWAClient:
"""
if client is None:
raise RuntimeError(
"OWA client is not initialised. "
"Pass --owa-url when starting the server."
"OWA client is not initialised. Pass --owa-url when starting the server."
)
return client
@@ -127,7 +131,9 @@ class SessionKeepalive:
picked up without restarting the server.
"""
def __init__(self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL) -> None:
def __init__(
self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL
) -> None:
self._client = client
self._interval = interval
self._task: asyncio.Task | None = None
@@ -168,37 +174,47 @@ class SessionKeepalive:
case the user re-authenticated while the server was running.
"""
try:
self._client.request("GetFolder", {
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly",
self._client.request(
"GetFolder",
{
"__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",
}
],
},
"FolderIds": [{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox",
}],
},
})
)
logger.debug("Session keepalive ping succeeded")
except SessionExpiredError:
logger.warning("Session expired during keepalive — reloading cookies from disk")
logger.warning(
"Session expired during keepalive — reloading cookies from disk"
)
try:
self._client._ensure_loaded()
except Exception as exc:
logger.warning("Failed to reload cookies after keepalive failure: %s", exc)
logger.warning(
"Failed to reload cookies after keepalive failure: %s", exc
)
except Exception as exc:
logger.debug("Keepalive ping failed (non-critical): %s", exc)
# ── Tool filtering ───────────────────────────────────────────────────────
def apply_tool_filters(
server: FastMCP,
enabled: list[str] | None = None,
+148 -121
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -5
View File
@@ -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
+1 -3
View File
@@ -20,9 +20,7 @@ def html_to_text(html_content: str) -> str:
text = re.sub(
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
+8
View File
@@ -32,3 +32,11 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["owa_mcp*"]
[tool.ruff]
line-length = 88
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
ignore = ["E501"]