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
+1 -4
View File
@@ -3,11 +3,8 @@
"includes": ["**/*.{md,json,py}"],
"indentWidth": 2,
"lineWidth": 80,
"json": {},
"markdown": {},
"ruff": {
"lineLength": 88,
"targetVersion": "py310"
"lineLength": 80,
},
"plugins": [
"https://plugins.dprint.dev/json-0.22.0.wasm",
+81 -81
View File
@@ -8,109 +8,109 @@ from typing import TypedDict
class Email(TypedDict, total=False):
"""An email message."""
"""An email message."""
subject: str
sender: str
sender_name: str
date: str
is_read: bool
has_attachments: bool
has_links: bool
item_id: str
size: int
is_meeting: bool
item_type: str
to: list[str]
cc: list[str]
body: str
body_type: str
attachments: list[dict]
# Meeting-specific fields
location: str
start: str
end: str
attendees_required: list[str]
attendees_optional: list[str]
subject: str
sender: str
sender_name: str
date: str
is_read: bool
has_attachments: bool
has_links: bool
item_id: str
size: int
is_meeting: bool
item_type: str
to: list[str]
cc: list[str]
body: str
body_type: str
attachments: list[dict]
# Meeting-specific fields
location: str
start: str
end: str
attendees_required: list[str]
attendees_optional: list[str]
class CalendarEvent(TypedDict, total=False):
"""A calendar event / meeting."""
"""A calendar event / meeting."""
subject: str
start: str
end: str
location: str
is_all_day: bool
is_cancelled: bool
is_meeting: bool
is_recurring: bool
organizer: str
organizer_email: str
my_response: str
item_id: str
body: str
attendees_required: list[str]
attendees_optional: list[str]
subject: str
start: str
end: str
location: str
is_all_day: bool
is_cancelled: bool
is_meeting: bool
is_recurring: bool
organizer: str
organizer_email: str
my_response: str
item_id: str
body: str
attendees_required: list[str]
attendees_optional: list[str]
class Person(TypedDict, total=False):
"""A person from the Exchange directory."""
"""A person from the Exchange directory."""
name: str
email: str
mailbox_type: str
first_name: str
last_name: str
job_title: str
department: str
company: str
office: str
alias: str
manager: str
manager_email: str
phones: dict[str, str]
address: str
direct_reports: list[dict[str, str]]
name: str
email: str
mailbox_type: str
first_name: str
last_name: str
job_title: str
department: str
company: str
office: str
alias: str
manager: str
manager_email: str
phones: dict[str, str]
address: str
direct_reports: list[dict[str, str]]
class Folder(TypedDict, total=False):
"""A mail folder."""
"""A mail folder."""
name: str
id: str
total_count: int
unread_count: int
child_folder_count: int
name: str
id: str
total_count: int
unread_count: int
child_folder_count: int
class FreeSlot(TypedDict):
"""A free time slot."""
"""A free time slot."""
date: str
start: str
end: str
duration_minutes: int
date: str
start: str
end: str
duration_minutes: int
class Availability(TypedDict, total=False):
"""Availability data for a single attendee."""
"""Availability data for a single attendee."""
email: str
busy_slots: int
free_slots: int
merged_freebusy: str
email: str
busy_slots: int
free_slots: int
merged_freebusy: str
class MeetingResult(TypedDict, total=False):
"""Result of creating/updating a meeting."""
"""Result of creating/updating a meeting."""
success: bool
subject: str
date: str
start_time: str
end_time: str
location: str
required_attendees: list[str]
optional_attendees: list[str]
error: str
success: bool
subject: str
date: str
start_time: str
end_time: str
location: str
required_attendees: list[str]
optional_attendees: list[str]
error: str
+397 -402
View File
@@ -17,480 +17,475 @@ import requests
class SessionExpiredError(Exception):
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
pass
pass
# Map common folder names (English + Russian) to OWA distinguished folder IDs
DISTINGUISHED_FOLDERS = {
"inbox": "inbox",
"входящие": "inbox",
"sent": "sentitems",
"отправленные": "sentitems",
"drafts": "drafts",
"черновики": "drafts",
"deleted": "deleteditems",
"удаленные": "deleteditems",
"junk": "junkemail",
"нежелательная почта": "junkemail",
"outbox": "outbox",
"исходящие": "outbox",
"calendar": "calendar",
"календарь": "calendar",
"inbox": "inbox",
"входящие": "inbox",
"sent": "sentitems",
"отправленные": "sentitems",
"drafts": "drafts",
"черновики": "drafts",
"deleted": "deleteditems",
"удаленные": "deleteditems",
"junk": "junkemail",
"нежелательная почта": "junkemail",
"outbox": "outbox",
"исходящие": "outbox",
"calendar": "calendar",
"календарь": "calendar",
}
class OWAClient:
"""HTTP client for OWA (Outlook Web Access) JSON API.
"""HTTP client for OWA (Outlook Web Access) JSON API.
Handles cookie loading, CSRF token extraction, request construction,
session expiry detection, and automatic cookie reload on first failure.
Handles cookie loading, CSRF token extraction, request construction,
session expiry detection, and automatic cookie reload on first failure.
"""
def __init__(
self,
cookie_file: str | None = None,
owa_url: str | None = None,
):
self.cookie_file = Path(
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=…")
self._session = requests.Session()
self._loaded = False
self.user_email: str = ""
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _action_name(action: str) -> str:
"""Convert e.g. 'GetFolder''GetFolderAction'."""
return action + "Action"
def _fresh_headers(self, action: str) -> dict:
"""Build the full set of OWA headers required by Exchange 2013.
Mirrors the headers the OWA web frontend sends for every POST.
"""
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}"
)
return {
# Basic
"accept": "*/*",
"accept-language": "en-US,en;q=0.9,ru;q=0.8",
"cache-control": "no-cache",
"client-request-id": client_req_id,
"content-length": "0",
"content-type": "application/json; charset=UTF-8",
"origin": self.owa_url,
"pragma": "no-cache",
"priority": "u=1, i",
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
),
# OWA-specific
"action": action,
"sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not)A;Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-owa-actionid": "-1",
"x-owa-actionname": self._action_name(action),
"x-owa-attempt": "1",
"x-owa-clientbegin": now_str,
"x-owa-clientbuildversion": "15.2.1748.39",
"x-owa-correlationid": client_req_id,
"x-requested-with": "XMLHttpRequest",
}
def __init__(
self,
cookie_file: str | None = None,
owa_url: str | None = None,
):
self.cookie_file = Path(
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=…")
self._session = requests.Session()
self._loaded = False
self.user_email: str = ""
def _add_canary_header(self, headers: dict) -> dict:
"""Extract X-OWA-CANARY from session cookies and add to headers."""
canary = next(
(c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), ""
)
if canary:
headers["x-owa-canary"] = canary
return headers
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@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"), ""
)
return cid if cid else "00000000000000000000000000000000"
@staticmethod
def _action_name(action: str) -> str:
"""Convert e.g. 'GetFolder''GetFolderAction'."""
return action + "Action"
# ------------------------------------------------------------------
# Cookie / session helpers
# ------------------------------------------------------------------
def _fresh_headers(self, action: str) -> dict:
"""Build the full set of OWA headers required by Exchange 2013.
def _load_cookies(self) -> None:
"""Read session-cookies.txt (name=value per line) and load into session."""
if not self.cookie_file.exists():
raise SessionExpiredError(
f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate."
)
Mirrors the headers the OWA web frontend sends for every POST.
"""
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}"
)
return {
# Basic
"accept": "*/*",
"accept-language": "en-US,en;q=0.9,ru;q=0.8",
"cache-control": "no-cache",
"client-request-id": client_req_id,
"content-length": "0",
"content-type": "application/json; charset=UTF-8",
"origin": self.owa_url,
"pragma": "no-cache",
"priority": "u=1, i",
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
# OWA-specific
"action": action,
"sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not)A;Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-owa-actionid": "-1",
"x-owa-actionname": self._action_name(action),
"x-owa-attempt": "1",
"x-owa-clientbegin": now_str,
"x-owa-clientbuildversion": "15.2.1748.39",
"x-owa-correlationid": client_req_id,
"x-requested-with": "XMLHttpRequest",
}
raw = self.cookie_file.read_text().strip()
cookies: dict[str, str] = {}
for line in raw.split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
def _add_canary_header(self, headers: dict) -> dict:
"""Extract X-OWA-CANARY from session cookies and add to headers."""
canary = next(
(c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), ""
)
if canary:
headers["x-owa-canary"] = canary
return headers
if not cookies:
raise SessionExpiredError(
"Cookie file is empty. Use the login MCP tool to authenticate."
)
@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"), "")
return cid if cid else "00000000000000000000000000000000"
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
# ------------------------------------------------------------------
# Cookie / session helpers
# ------------------------------------------------------------------
def _save_cookies(self) -> None:
"""Write the current session cookie jar to the cookie file.
def _load_cookies(self) -> None:
"""Read session-cookies.txt (name=value per line) and load into session."""
if not self.cookie_file.exists():
raise SessionExpiredError(
f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate."
)
Called automatically after every successful request so that the
on-disk cookies always reflect the freshest ``Set-Cookie`` values
returned by the server.
"""
lines = [f"{c.name}={c.value}" for c in self._session.cookies]
self.cookie_file.write_text("\n".join(lines))
self._loaded = True
raw = self.cookie_file.read_text().strip()
cookies: dict[str, str] = {}
for line in raw.split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
def _ensure_loaded(self) -> None:
"""Load cookies on first use and refresh session from the main page."""
if not self._loaded:
self._load_cookies()
self._refresh_session()
if not cookies:
raise SessionExpiredError(
"Cookie file is empty. Use the login MCP tool to authenticate."
)
def load_cookies_from_string(self, cookies_str: str) -> None:
"""Load cookies from a name=value string (one per line) into memory."""
cookies: dict[str, str] = {}
for line in cookies_str.strip().split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
if not cookies:
raise SessionExpiredError("No cookies in provided data.")
def _save_cookies(self) -> None:
"""Write the current session cookie jar to the cookie file.
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
Called automatically after every successful request so that the
on-disk cookies always reflect the freshest ``Set-Cookie`` values
returned by the server.
"""
lines = [f"{c.name}={c.value}" for c in self._session.cookies]
self.cookie_file.write_text("\n".join(lines))
self._loaded = True
# ------------------------------------------------------------------
# Core request method
# ------------------------------------------------------------------
def _ensure_loaded(self) -> None:
"""Load cookies on first use and refresh session from the main page."""
if not self._loaded:
self._load_cookies()
self._refresh_session()
def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1.
def load_cookies_from_string(self, cookies_str: str) -> None:
"""Load cookies from a name=value string (one per line) into memory."""
cookies: dict[str, str] = {}
for line in cookies_str.strip().split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
On session expiry (401, 440, or text/html response), reloads cookies
once and retries. If that also fails, raises SessionExpiredError.
if not cookies:
raise SessionExpiredError("No cookies in provided data.")
Returns the parsed JSON response dict.
"""
self._ensure_loaded()
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
try:
data = self._do_request(action, payload, timeout=timeout)
return data
except SessionExpiredError:
raise
# ------------------------------------------------------------------
# Core request method
# ------------------------------------------------------------------
def _refresh_session(self) -> None:
"""Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY)."""
try:
resp = self._session.get(
f"{self.owa_url}/owa/",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
},
timeout=10,
)
# Extract X-OWA-CANARY from Set-Cookie if present
set_cookie = resp.headers.get("Set-Cookie", "")
if "X-OWA-CANARY=" in set_cookie:
import re
def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1.
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)
# Persist any cookie changes
self._save_cookies()
except Exception:
pass # non-critical
On session expiry (401, 440, or text/html response), reloads cookies
once and retries. If that also fails, raises SessionExpiredError.
def _do_request(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""Execute a single OWA API request.
Returns the parsed JSON response dict.
"""
self._ensure_loaded()
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an
empty POST body, plus a full set of OWA-specific headers extracted
from the browser's actual network traffic.
"""
from urllib.parse import urlparse
try:
data = self._do_request(action, payload, timeout=timeout)
return data
except SessionExpiredError:
raise
url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1"
def _refresh_session(self) -> None:
"""Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY)."""
try:
resp = self._session.get(
f"{self.owa_url}/owa/",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
},
timeout=10,
)
# Extract X-OWA-CANARY from Set-Cookie if present
set_cookie = resp.headers.get("Set-Cookie", "")
if "X-OWA-CANARY=" in set_cookie:
import re
# URL-encode the JSON payload for the x-owa-urlpostdata header
url_post_data = quote(json.dumps(payload, separators=(",", ":")))
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)
# Persist any cookie changes
self._save_cookies()
except Exception:
pass # non-critical
# Build headers — start with OWA-required headers
headers = self._fresh_headers(action)
headers["x-owa-urlpostdata"] = url_post_data
# Add canary from cookies
headers = self._add_canary_header(headers)
def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""Execute a single OWA API request.
# Add extra cookies that the OWA frontend always sends
host = urlparse(self.owa_url).hostname or "owa.b1.ru"
for name, val in [("PrivateComputer", "true"), ("PBack", "0")]:
self._session.cookies.set(name, val, domain=host)
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an
empty POST body, plus a full set of OWA-specific headers extracted
from the browser's actual network traffic.
"""
from urllib.parse import urlparse
try:
resp = self._session.post(url, data="", headers=headers, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Request failed: {exc}") from exc
url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1"
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
# URL-encode the JSON payload for the x-owa-urlpostdata header
url_post_data = quote(json.dumps(payload, separators=(",", ":")))
if "text/html" in resp.headers.get("Content-Type", ""):
body_snippet = resp.text[:300] if resp.text else ""
raise SessionExpiredError(
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). Snippet: {body_snippet}"
)
# Build headers — start with OWA-required headers
headers = self._fresh_headers(action)
headers["x-owa-urlpostdata"] = url_post_data
# Add canary from cookies
headers = self._add_canary_header(headers)
# Parse JSON
try:
data = resp.json()
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
raise SessionExpiredError(
f"Unexpected response (HTTP {resp.status_code}). Session may have expired."
) from exc
# Add extra cookies that the OWA frontend always sends
host = urlparse(self.owa_url).hostname or "owa.b1.ru"
for name, val in [("PrivateComputer", "true"), ("PBack", "0")]:
self._session.cookies.set(name, val, domain=host)
# Persist any Set-Cookie updates back to disk so the next request
# (or a server restart) picks up the freshest session tokens.
self._save_cookies()
return data
try:
resp = self._session.post(url, data="", headers=headers, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Request failed: {exc}") from exc
def request_header_payload(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""POST with payload in x-owa-urlpostdata header (empty body).
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError(
f"Session expired (HTTP {resp.status_code})."
)
Alias for :meth:`request` — both methods now use the same
``x-owa-urlpostdata`` format that mirrors the OWA web frontend.
"""
return self.request(action, payload, timeout=timeout)
if "text/html" in resp.headers.get("Content-Type", ""):
body_snippet = resp.text[:300] if resp.text else ""
raise SessionExpiredError(
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
f"Snippet: {body_snippet}"
)
# ------------------------------------------------------------------
# File download (attachments)
# ------------------------------------------------------------------
# Parse JSON
try:
data = resp.json()
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
raise SessionExpiredError(
f"Unexpected response (HTTP {resp.status_code}). "
"Session may have expired."
) from exc
def download_file(
self, attachment_id: str, *, timeout: int = 60
) -> tuple[bytes, str, str]:
"""Download a file attachment by its AttachmentId.
# Persist any Set-Cookie updates back to disk so the next request
# (or a server restart) picks up the freshest session tokens.
self._save_cookies()
return data
Uses the OWA GetFileAttachment endpoint (direct GET).
def request_header_payload(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""POST with payload in x-owa-urlpostdata header (empty body).
Returns:
(content_bytes, filename, content_type)
"""
self._ensure_loaded()
Alias for :meth:`request` — both methods now use the same
``x-owa-urlpostdata`` format that mirrors the OWA web frontend.
"""
return self.request(action, payload, timeout=timeout)
from urllib.parse import quote
# ------------------------------------------------------------------
# File download (attachments)
# ------------------------------------------------------------------
url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
def download_file(
self, attachment_id: str, *, timeout: int = 60
) -> tuple[bytes, str, str]:
"""Download a file attachment by its AttachmentId.
try:
resp = self._session.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Download failed: {exc}") from exc
Uses the OWA GetFileAttachment endpoint (direct GET).
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
Returns:
(content_bytes, filename, content_type)
"""
self._ensure_loaded()
if "text/html" in resp.headers.get("Content-Type", ""):
raise SessionExpiredError(
"Session expired (HTML response on attachment download)."
)
from urllib.parse import quote
# Persist fresh cookies from the download response
self._save_cookies()
url = (
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
f"?id={quote(attachment_id)}"
)
# Parse filename from Content-Disposition header
filename = "attachment"
cd = resp.headers.get("Content-Disposition", "")
if cd:
import re as _re
from urllib.parse import unquote
try:
resp = self._session.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Download failed: {exc}") from exc
# Try filename*= (RFC 5987) first, then filename=
match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd)
if match:
filename = unquote(match.group(1).strip())
else:
match = _re.search(r'filename="?([^";]+)"?', cd)
if match:
filename = unquote(match.group(1).strip())
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
if "text/html" in resp.headers.get("Content-Type", ""):
raise SessionExpiredError(
"Session expired (HTML response on attachment download)."
)
return resp.content, filename, content_type
# Persist fresh cookies from the download response
self._save_cookies()
# ------------------------------------------------------------------
# Convenience: extract response items
# ------------------------------------------------------------------
# Parse filename from Content-Disposition header
filename = "attachment"
cd = resp.headers.get("Content-Disposition", "")
if cd:
import re as _re
from urllib.parse import unquote
@staticmethod
def extract_items(data: dict) -> list[dict]:
"""Extract Items from standard OWA response envelope.
# Try filename*= (RFC 5987) first, then filename=
match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd)
if match:
filename = unquote(match.group(1).strip())
else:
match = _re.search(r'filename="?([^";]+)"?', cd)
if match:
filename = unquote(match.group(1).strip())
Response shape: data["Body"]["ResponseMessages"]["Items"]
"""
try:
return data["Body"]["ResponseMessages"]["Items"]
except (KeyError, TypeError):
return []
content_type = resp.headers.get("Content-Type", "application/octet-stream")
# ------------------------------------------------------------------
# Folder helpers
# ------------------------------------------------------------------
return resp.content, filename, content_type
def get_folder_id(self, folder_name: str) -> str | None:
"""Resolve a folder name to its Exchange folder ID.
# ------------------------------------------------------------------
# Convenience: extract response items
# ------------------------------------------------------------------
Supports distinguished folder names (inbox, sentitems, drafts, etc.)
in both English and Russian, plus custom folder names looked up
via FindFolder on msgfolderroot.
"""
folder_lower = folder_name.lower()
@staticmethod
def extract_items(data: dict) -> list[dict]:
"""Extract Items from standard OWA response envelope.
Response shape: data["Body"]["ResponseMessages"]["Items"]
"""
try:
return data["Body"]["ResponseMessages"]["Items"]
except (KeyError, TypeError):
return []
# ------------------------------------------------------------------
# Folder helpers
# ------------------------------------------------------------------
def get_folder_id(self, folder_name: str) -> str | None:
"""Resolve a folder name to its Exchange folder ID.
Supports distinguished folder names (inbox, sentitems, drafts, etc.)
in both English and Russian, plus custom folder names looked up
via FindFolder on msgfolderroot.
"""
folder_lower = folder_name.lower()
# Check distinguished folders first
distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower)
if distinguished_id:
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": distinguished_id,
}
],
},
# Check distinguished folders first
distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower)
if distinguished_id:
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": distinguished_id,
}
],
},
}
data = self.request("GetFolder", payload)
for msg in self.extract_items(data):
if "Folders" in msg:
for f in msg["Folders"]:
fid = f.get("FolderId", {}).get("Id")
if fid:
return fid
data = self.request("GetFolder", payload)
for msg in self.extract_items(data):
if "Folders" in msg:
for f in msg["Folders"]:
fid = f.get("FolderId", {}).get("Id")
if fid:
return fid
# Fall back to searching custom folders by name
payload = {
"__type": "FindFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "msgfolderroot",
}
],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": 0,
"MaxEntriesReturned": 200,
},
},
}
# Fall back to searching custom folders by name
payload = {
"__type": "FindFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "msgfolderroot",
}
],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": 0,
"MaxEntriesReturned": 200,
},
},
}
data = self.request("FindFolder", payload)
for msg in self.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]:
if f.get("DisplayName", "").lower() == folder_lower:
return f.get("FolderId", {}).get("Id")
data = self.request("FindFolder", payload)
for msg in self.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]:
if f.get("DisplayName", "").lower() == folder_lower:
return f.get("FolderId", {}).get("Id")
return None
return None
# ------------------------------------------------------------------
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]:
"""Call ResolveNames to search the directory.
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
Mailbox and optionally Contact data.
"""
payload = {
"__type": "ResolveNamesJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "ResolveNamesRequest:#Exchange",
"UnresolvedEntry": query,
"ReturnFullContactData": full_contact,
"SearchScope": "ActiveDirectoryContacts",
"ContactDataShape": "AllProperties" if full_contact else "Default",
},
}
Returns the list of Resolution dicts from the API, each containing
Mailbox and optionally Contact data.
"""
payload = {
"__type": "ResolveNamesJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "ResolveNamesRequest:#Exchange",
"UnresolvedEntry": query,
"ReturnFullContactData": full_contact,
"SearchScope": "ActiveDirectoryContacts",
"ContactDataShape": "AllProperties" if full_contact else "Default",
},
}
data = self.request("ResolveNames", payload)
for msg in self.extract_items(data):
if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]:
return msg["ResolutionSet"]["Resolutions"]
data = self.request("ResolveNames", payload)
for msg in self.extract_items(data):
if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]:
return msg["ResolutionSet"]["Resolutions"]
return []
return []
+140 -141
View File
@@ -29,10 +29,10 @@ from mcp.server.fastmcp import FastMCP
from owa_mcp.owa_client import OWAClient
from owa_mcp.server_lifecycle import (
HashTokenVerifier,
SessionKeepalive,
apply_tool_filters,
create_owa_client,
HashTokenVerifier,
SessionKeepalive,
apply_tool_filters,
create_owa_client,
)
logger = logging.getLogger(__name__)
@@ -40,32 +40,32 @@ logger = logging.getLogger(__name__)
@dataclass
class AppContext:
"""Shared application state available to all tools via lifespan context."""
"""Shared application state available to all tools via lifespan context."""
client: OWAClient | None = None
client: OWAClient | None = None
@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Initialise the OWA client on first connection, then keep the session alive.
"""Initialise the OWA client on first connection, then keep the session alive.
A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts
immediately (pings inbox, extends the session, persists fresh cookies) and
repeats every 5 minutes until the server shuts down.
"""
client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file)
try:
client._ensure_loaded()
except Exception:
pass # no cookies yet — login tool will handle this
A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts
immediately (pings inbox, extends the session, persists fresh cookies) and
repeats every 5 minutes until the server shuts down.
"""
client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file)
try:
client._ensure_loaded()
except Exception:
pass # no cookies yet — login tool will handle this
keepalive = SessionKeepalive(client)
await keepalive.start()
keepalive = SessionKeepalive(client)
await keepalive.start()
try:
yield AppContext(client=client)
finally:
await keepalive.stop()
try:
yield AppContext(client=client)
finally:
await keepalive.stop()
# ── Temporary storage for lifespan args (set by main() before mcp.run()) ──
@@ -78,144 +78,143 @@ mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan)
def _parse_args(argv=None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="OWA MCP Server — OWA/Exchange integration via MCP",
)
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default="stdio",
help="Transport protocol (default: stdio)",
)
parser.add_argument(
"--owa-url",
required=True,
help="Base URL of the OWA instance (e.g. https://owa.example.com)",
)
parser.add_argument(
"--cookie-file",
default=None,
help="Path to session cookies file (default: session-cookies.txt)",
)
parser.add_argument(
"--token-hash",
default=None,
help=(
"SHA-256 hash of the bearer token for HTTP transport auth. "
"Compute with: echo -n 'my-token' | sha256sum"
),
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port for HTTP/SSE transport (default: 8000)",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host for HTTP/SSE transport (default: 127.0.0.1)",
)
parser.add_argument(
"--enabled-tools",
default=None,
help="Comma-separated whitelist of tool names to expose",
)
parser.add_argument(
"--disabled-tools",
default=None,
help="Comma-separated blacklist of tool names to hide",
)
return parser.parse_args(argv)
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="OWA MCP Server — OWA/Exchange integration via MCP",
)
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default="stdio",
help="Transport protocol (default: stdio)",
)
parser.add_argument(
"--owa-url",
required=True,
help="Base URL of the OWA instance (e.g. https://owa.example.com)",
)
parser.add_argument(
"--cookie-file",
default=None,
help="Path to session cookies file (default: session-cookies.txt)",
)
parser.add_argument(
"--token-hash",
default=None,
help=(
"SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum"
),
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port for HTTP/SSE transport (default: 8000)",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host for HTTP/SSE transport (default: 127.0.0.1)",
)
parser.add_argument(
"--enabled-tools",
default=None,
help="Comma-separated whitelist of tool names to expose",
)
parser.add_argument(
"--disabled-tools",
default=None,
help="Comma-separated blacklist of tool names to hide",
)
return parser.parse_args(argv)
def _configure_auth(
mcp_server: FastMCP,
token_hash: str | None,
mcp_server: FastMCP,
token_hash: str | None,
) -> None:
"""Apply token-hash auth to *mcp_server* if *token_hash* is provided."""
if not token_hash:
return
"""Apply token-hash auth to *mcp_server* if *token_hash* is provided."""
if not token_hash:
return
token_verifier: TokenVerifier = HashTokenVerifier(token_hash)
mcp_server.settings.auth = AuthSettings( # type: ignore[misc]
issuer_url="http://localhost", # type: ignore[arg-type]
resource_server_url="http://localhost", # type: ignore[arg-type]
)
mcp_server._token_verifier = token_verifier # noqa: SLF001
token_verifier: TokenVerifier = HashTokenVerifier(token_hash)
mcp_server.settings.auth = AuthSettings( # type: ignore[misc]
issuer_url="http://localhost", # type: ignore[arg-type]
resource_server_url="http://localhost", # type: ignore[arg-type]
)
mcp_server._token_verifier = token_verifier # noqa: SLF001
def main(argv=None) -> None:
"""Entry point: parse CLI args, configure server, run transport."""
global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603
"""Entry point: parse CLI args, configure server, run transport."""
global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603
args = _parse_args(argv)
args = _parse_args(argv)
# ── Validate --owa-url ─────────────────────────────────────────────────
owa_url = args.owa_url.strip().rstrip("/")
if not owa_url:
print(
"ERROR: --owa-url must not be empty.",
file=__import__("sys").stderr,
)
raise SystemExit(1)
# ── Validate --owa-url ─────────────────────────────────────────────────
owa_url = args.owa_url.strip().rstrip("/")
if not owa_url:
print(
"ERROR: --owa-url must not be empty.",
file=__import__("sys").stderr,
)
raise SystemExit(1)
# ── Stash lifespan args (consumed by app_lifespan) ─────────────────────
_lifespan_owa_url = owa_url
_lifespan_cookie_file = args.cookie_file
# ── Stash lifespan args (consumed by app_lifespan) ─────────────────────
_lifespan_owa_url = owa_url
_lifespan_cookie_file = args.cookie_file
# ── Validate --token-hash for non-stdio transports ─────────────────────
if args.transport != "stdio" and not args.token_hash:
print(
"ERROR: --token-hash is required when using HTTP transport.",
file=__import__("sys").stderr,
)
print(
" Compute hash: echo -n 'my-token' | sha256sum",
file=__import__("sys").stderr,
)
raise SystemExit(1)
# ── Validate --token-hash for non-stdio transports ─────────────────────
if args.transport != "stdio" and not args.token_hash:
print(
"ERROR: --token-hash is required when using HTTP transport.",
file=__import__("sys").stderr,
)
print(
" Compute hash: echo -n 'my-token' | sha256sum",
file=__import__("sys").stderr,
)
raise SystemExit(1)
# ── Register tools (imports populate module-level `mcp`) ───────────────
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
# ── Register tools (imports populate module-level `mcp`) ───────────────
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)
# ── Auth for HTTP transports ───────────────────────────────────────────
_configure_auth(mcp, args.token_hash)
# ── Tool filtering ─────────────────────────────────────────────────────
enabled: list[str] | None = None
if args.enabled_tools is not None:
enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()]
# ── Tool filtering ─────────────────────────────────────────────────────
enabled: list[str] | None = None
if args.enabled_tools is not None:
enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()]
disabled: list[str] | None = None
if args.disabled_tools is not None:
disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()]
disabled: list[str] | None = None
if args.disabled_tools is not None:
disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()]
if enabled or disabled:
apply_tool_filters(mcp, enabled=enabled, disabled=disabled)
if enabled or disabled:
apply_tool_filters(mcp, enabled=enabled, disabled=disabled)
# ── Host / port (already passed to FastMCP at module level; update if
# different from defaults — useful when server is re-invoked with
# different args in tests) ─────────────────────────────────────────────
mcp.settings.host = args.host
mcp.settings.port = args.port
# ── Host / port (already passed to FastMCP at module level; update if
# different from defaults — useful when server is re-invoked with
# different args in tests) ─────────────────────────────────────────────
mcp.settings.host = args.host
mcp.settings.port = args.port
# ── Run transport ─────────────────────────────────────────────────────
match args.transport:
case "stdio":
mcp.run()
case "sse":
mcp.run(transport="sse")
case "streamable-http":
mcp.run(transport="streamable-http")
# ── Run transport ─────────────────────────────────────────────────────
match args.transport:
case "stdio":
mcp.run()
case "sse":
mcp.run(transport="sse")
case "streamable-http":
mcp.run(transport="streamable-http")
if __name__ == "__main__":
main()
main()
+184 -183
View File
@@ -22,7 +22,7 @@ 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
from mcp.server.fastmcp import FastMCP
logger = logging.getLogger(__name__)
@@ -30,87 +30,88 @@ 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`.
def create_owa_client(
owa_url: str, cookie_file: str | None = None
) -> OWAClient:
"""Create and return a fully configured :class:`OWAClient`.
Args:
owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``).
cookie_file: Path to the session-cookie file. ``None`` falls back to the
default ``session-cookies.txt`` next to the package.
Args:
owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``).
cookie_file: Path to the session-cookie file. ``None`` falls back to the
default ``session-cookies.txt`` next to the package.
Returns:
A ready-to-use :class:`OWAClient`.
Returns:
A ready-to-use :class:`OWAClient`.
Raises:
ValueError: If *owa_url* is empty.
"""
owa_url = owa_url.strip().rstrip("/")
if not owa_url:
raise ValueError("OWA URL must not be empty.")
return OWAClient(owa_url=owa_url, cookie_file=cookie_file)
Raises:
ValueError: If *owa_url* is empty.
"""
owa_url = owa_url.strip().rstrip("/")
if not owa_url:
raise ValueError("OWA URL must not be empty.")
return OWAClient(owa_url=owa_url, cookie_file=cookie_file)
# ── Token verifier ────────────────────────────────────────────────────────
class HashTokenVerifier(TokenVerifier):
"""Bearer-token verifier backed by a SHA-256 hash.
"""Bearer-token verifier backed by a SHA-256 hash.
The server never stores the plaintext token — only its hex digest.
Clients send the plaintext token as ``Authorization: Bearer <token>``,
the server hashes it and compares with the stored hash.
"""
The server never stores the plaintext token — only its hex digest.
Clients send the plaintext token as ``Authorization: Bearer <token>``,
the server hashes it and compares with the stored hash.
"""
def __init__(self, token_hash: str) -> None:
def __init__(self, token_hash: str) -> None:
self._expected_hash = token_hash.strip().lower()
self._expected_hash = token_hash.strip().lower()
async def verify_token(self, token: str) -> AccessToken | None:
import hashlib
async def verify_token(self, token: str) -> AccessToken | None:
import hashlib
if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash:
return AccessToken(
token=token,
client_id="mcp-client",
scopes=[],
)
return None
if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash:
return AccessToken(
token=token,
client_id="mcp-client",
scopes=[],
)
return None
# ── Lifespan helper ──────────────────────────────────────────────────────
class _LazyClient:
"""Thin wrapper that resolves the OWAClient on first access.
"""Thin wrapper that resolves the OWAClient on first access.
Stored as ``AppContext.client`` so that ``require_client`` can work
with both a pre-built client and a ``None`` placeholder.
"""
Stored as ``AppContext.client`` so that ``require_client`` can work
with both a pre-built client and a ``None`` placeholder.
"""
def __init__(self, client: OWAClient | None) -> None:
self._client = client
def __init__(self, client: OWAClient | None) -> None:
self._client = client
@property
def resolved(self) -> OWAClient:
if self._client is None:
raise RuntimeError(
"OWA client is not initialised. "
"Check --owa-url and --cookie-file arguments."
)
return self._client
@property
def resolved(self) -> OWAClient:
if self._client is None:
raise RuntimeError(
"OWA client is not initialised. Check --owa-url and --cookie-file arguments."
)
return self._client
def require_client(client: OWAClient | None) -> OWAClient:
"""Return *client* if it is not ``None``, otherwise raise ``RuntimeError``.
"""Return *client* if it is not ``None``, otherwise raise ``RuntimeError``.
Import this in tool modules and call it with ``ctx.client`` to get a
consistent error message when OWA is not configured.
"""
if client is None:
raise RuntimeError(
"OWA client is not initialised. Pass --owa-url when starting the server."
)
return client
Import this in tool modules and call it with ``ctx.client`` to get a
consistent error message when OWA is not configured.
"""
if client is None:
raise RuntimeError(
"OWA client is not initialised. Pass --owa-url when starting the server."
)
return client
# ── Session keepalive ──────────────────────────────────────────────────────
@@ -119,151 +120,151 @@ _DEFAULT_KEEPALIVE_INTERVAL = 300 # seconds (5 minutes)
class SessionKeepalive:
"""Background task that periodically pings the OWA server to extend the
session lifetime and persist fresh cookies to disk.
"""Background task that periodically pings the OWA server to extend the
session lifetime and persist fresh cookies to disk.
On each successful ping the OWA server may return updated ``Set-Cookie``
headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT).
The client saves those cookies to disk automatically.
On each successful ping the OWA server may return updated ``Set-Cookie``
headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT).
The client saves those cookies to disk automatically.
On failure the keepalive attempts to reload cookies from disk so that a
fresh login performed by another process (or via the ``login`` tool) is
picked up without restarting the server.
On failure the keepalive attempts to reload cookies from disk so that a
fresh login performed by another process (or via the ``login`` tool) is
picked up without restarting the server.
"""
def __init__(
self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL
) -> None:
self._client = client
self._interval = interval
self._task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the background keepalive loop.
Fires one ping immediately so the session is refreshed on server
start-up, then waits ``interval`` seconds between subsequent pings.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._run(), name="session-keepalive")
def __init__(
self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL
) -> None:
self._client = client
self._interval = interval
self._task: asyncio.Task | None = None
async def stop(self) -> None:
"""Cancel the background keepalive loop and wait for it to finish."""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def start(self) -> None:
"""Start the background keepalive loop.
async def _run(self) -> None:
"""Ping immediately, then every ``interval`` seconds."""
# First ping right away (on server start)
await asyncio.to_thread(self._ping)
while True:
await asyncio.sleep(self._interval)
await asyncio.to_thread(self._ping)
Fires one ping immediately so the session is refreshed on server
start-up, then waits ``interval`` seconds between subsequent pings.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._run(), name="session-keepalive")
def _ping(self) -> None:
"""Make a lightweight GetFolder request against the inbox.
async def stop(self) -> None:
"""Cancel the background keepalive loop and wait for it to finish."""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _run(self) -> None:
"""Ping immediately, then every ``interval`` seconds."""
# First ping right away (on server start)
await asyncio.to_thread(self._ping)
while True:
await asyncio.sleep(self._interval)
await asyncio.to_thread(self._ping)
def _ping(self) -> None:
"""Make a lightweight GetFolder request against the inbox.
On success the OWAClient saves fresh cookies to disk automatically.
On ``SessionExpiredError`` the on-disk cookies are re-loaded in
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",
},
"FolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox",
}
],
},
},
)
logger.debug("Session keepalive ping succeeded")
except SessionExpiredError:
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
)
except Exception as exc:
logger.debug("Keepalive ping failed (non-critical): %s", exc)
On success the OWAClient saves fresh cookies to disk automatically.
On ``SessionExpiredError`` the on-disk cookies are re-loaded in
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",
},
"FolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox",
}
],
},
},
)
logger.debug("Session keepalive ping succeeded")
except SessionExpiredError:
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
)
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,
disabled: list[str] | None = None,
server: FastMCP,
enabled: list[str] | None = None,
disabled: list[str] | None = None,
) -> None:
"""Apply whitelist / blacklist filtering to *server*'s registered tools.
"""Apply whitelist / blacklist filtering to *server*'s registered tools.
Args:
server: The :class:`FastMCP` instance whose tool registry is modified
in-place.
enabled: Optional list of tool names to **keep**. If ``None`` or empty
all tools are eligible. Unknown names are ignored (warning
logged).
disabled: Optional list of tool names to **remove**. Applied *after*
the whitelist. Unknown names are ignored (warning logged).
"""
canonical = {t.name for t in asyncio.run(server.list_tools())}
if not canonical:
return
Args:
server: The :class:`FastMCP` instance whose tool registry is modified
in-place.
enabled: Optional list of tool names to **keep**. If ``None`` or empty
all tools are eligible. Unknown names are ignored (warning
logged).
disabled: Optional list of tool names to **remove**. Applied *after*
the whitelist. Unknown names are ignored (warning logged).
"""
canonical = {t.name for t in asyncio.run(server.list_tools())}
if not canonical:
return
allowed: set[str] = set(canonical)
allowed: set[str] = set(canonical)
# ── Whitelist ──────────────────────────────────────────────────────────
if enabled:
enabled_set = {n.strip() for n in enabled if n.strip()}
allowed &= enabled_set
unknown = enabled_set - canonical
if unknown:
logger.warning(
"Unknown tool(s) in --enabled-tools, ignoring: %s",
", ".join(sorted(unknown)),
)
# ── Whitelist ──────────────────────────────────────────────────────────
if enabled:
enabled_set = {n.strip() for n in enabled if n.strip()}
allowed &= enabled_set
unknown = enabled_set - canonical
if unknown:
logger.warning(
"Unknown tool(s) in --enabled-tools, ignoring: %s",
", ".join(sorted(unknown)),
)
# ── Blacklist ──────────────────────────────────────────────────────────
if disabled:
disabled_set = {n.strip() for n in disabled if n.strip()}
allowed -= disabled_set
unknown = disabled_set - canonical
if unknown:
logger.warning(
"Unknown tool(s) in --disabled-tools, ignoring: %s",
", ".join(sorted(unknown)),
)
# ── Blacklist ──────────────────────────────────────────────────────────
if disabled:
disabled_set = {n.strip() for n in disabled if n.strip()}
allowed -= disabled_set
unknown = disabled_set - canonical
if unknown:
logger.warning(
"Unknown tool(s) in --disabled-tools, ignoring: %s",
", ".join(sorted(unknown)),
)
# ── Remove ─────────────────────────────────────────────────────────────
to_remove = sorted(canonical - allowed)
if to_remove:
logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove))
for name in to_remove:
server.remove_tool(name)
# ── Remove ─────────────────────────────────────────────────────────────
to_remove = sorted(canonical - allowed)
if to_remove:
logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove))
for name in to_remove:
server.remove_tool(name)
logger.info("Tools available: %s", ", ".join(sorted(allowed)))
logger.info("Tools available: %s", ", ".join(sorted(allowed)))
+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)
+92 -87
View File
@@ -10,122 +10,127 @@ from datetime import datetime
def html_to_text(html_content: str) -> str:
"""Convert HTML to plain text.
"""Convert HTML to plain text.
Strips scripts, styles, converts <br>/<p>/<div> to newlines,
removes remaining tags, and unescapes HTML entities.
"""
if not html_content:
return ""
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"<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)
text = re.sub(r"<div[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text = re.sub(r"\n\s*\n", "\n\n", text)
text = re.sub(r"[ \t]+", " ", text)
return text.strip()
Strips scripts, styles, converts <br>/<p>/<div> to newlines,
removes remaining tags, and unescapes HTML entities.
"""
if not html_content:
return ""
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"<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)
text = re.sub(r"<div[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text = re.sub(r"\n\s*\n", "\n\n", text)
text = re.sub(r"[ \t]+", " ", text)
return text.strip()
def extract_links_from_html(html_content: str) -> list[dict]:
"""Extract hyperlinks from HTML content.
"""Extract hyperlinks from HTML content.
Finds <a href="...">text</a> patterns, excludes mailto:, cid:,
javascript:, and fragment-only (#) links. Deduplicates by URL.
Finds <a href="...">text</a> patterns, excludes mailto:, cid:,
javascript:, and fragment-only (#) links. Deduplicates by URL.
Returns list of {url, text} dicts.
"""
if not html_content:
return []
Returns list of {url, text} dicts.
"""
if not html_content:
return []
# Match <a ...href="URL"...>text</a>
pattern = re.compile(
r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
re.DOTALL | re.IGNORECASE,
)
# Match <a ...href="URL"...>text</a>
pattern = re.compile(
r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
re.DOTALL | re.IGNORECASE,
)
seen: set[str] = set()
links: list[dict] = []
seen: set[str] = set()
links: list[dict] = []
for url_raw, text_raw in pattern.findall(html_content):
url = html.unescape(url_raw).strip()
for url_raw, text_raw in pattern.findall(html_content):
url = html.unescape(url_raw).strip()
# Skip non-http links
if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#":
continue
# Skip fragment-only links
if url.startswith("#"):
continue
# Skip non-http links
if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#":
continue
# Skip fragment-only links
if url.startswith("#"):
continue
if url in seen:
continue
seen.add(url)
if url in seen:
continue
seen.add(url)
# Clean link text: strip tags and whitespace
text = re.sub(r"<[^>]+>", "", text_raw)
text = html.unescape(text).strip()
# Clean link text: strip tags and whitespace
text = re.sub(r"<[^>]+>", "", text_raw)
text = html.unescape(text).strip()
links.append({"url": url, "text": text})
links.append({"url": url, "text": text})
return links
return links
def format_datetime(dt_str: str) -> str:
"""Format an ISO datetime string as 'YYYY-MM-DD HH:MM'.
"""Format an ISO datetime string as 'YYYY-MM-DD HH:MM'.
Strips timezone suffixes (Z, +offset) for cleaner display.
"""
if not dt_str:
return ""
if "T" in dt_str:
date_part, time_part = dt_str.split("T", 1)
time_part = time_part.split("Z")[0].split("+")[0]
return f"{date_part} {time_part[:5]}"
return dt_str
Strips timezone suffixes (Z, +offset) for cleaner display.
"""
if not dt_str:
return ""
if "T" in dt_str:
date_part, time_part = dt_str.split("T", 1)
time_part = time_part.split("Z")[0].split("+")[0]
return f"{date_part} {time_part[:5]}"
return dt_str
def format_date(dt_str: str) -> str:
"""Extract the date portion from an ISO datetime string."""
if not dt_str:
return ""
if "T" in dt_str:
return dt_str.split("T")[0]
return dt_str
"""Extract the date portion from an ISO datetime string."""
if not dt_str:
return ""
if "T" in dt_str:
return dt_str.split("T")[0]
return dt_str
def parse_date(date_str: str) -> datetime:
"""Parse a date string in common formats.
"""Parse a date string in common formats.
Supports: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY.
"""
formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"Could not parse date: {date_str}")
Supports: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY.
"""
formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"Could not parse date: {date_str}")
def parse_iso_datetime(dt_str: str) -> datetime:
"""Parse an ISO datetime string to a naive datetime.
"""Parse an ISO datetime string to a naive datetime.
Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats,
stripping any timezone suffix.
"""
if "T" in dt_str:
clean = dt_str.split("Z")[0].split("+")[0]
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S")
return datetime.strptime(dt_str, "%Y-%m-%d")
Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats,
stripping any timezone suffix.
"""
if "T" in dt_str:
clean = dt_str.split("Z")[0].split("+")[0]
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S")
return datetime.strptime(dt_str, "%Y-%m-%d")
def format_attendee(name: str, email: str) -> str:
"""Format an attendee as 'Name <email>' or just the email."""
if name and email and not email.startswith("/O="):
return f"{name} <{email}>"
return name or email or ""
"""Format an attendee as 'Name <email>' or just the email."""
if name and email and not email.startswith("/O="):
return f"{name} <{email}>"
return name or email or ""