feat: basics #1

Merged
albnnc merged 8 commits from itchy-gayal into main 2026-07-06 07:55:40 +00:00
13 changed files with 4425 additions and 4426 deletions
Showing only changes of commit 5ec3cb4ab4 - Show all commits
+1 -4
View File
@@ -3,11 +3,8 @@
"includes": ["**/*.{md,json,py}"], "includes": ["**/*.{md,json,py}"],
"indentWidth": 2, "indentWidth": 2,
"lineWidth": 80, "lineWidth": 80,
"json": {},
"markdown": {},
"ruff": { "ruff": {
"lineLength": 88, "lineLength": 80,
"targetVersion": "py310"
}, },
"plugins": [ "plugins": [
"https://plugins.dprint.dev/json-0.22.0.wasm", "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): class Email(TypedDict, total=False):
"""An email message.""" """An email message."""
subject: str subject: str
sender: str sender: str
sender_name: str sender_name: str
date: str date: str
is_read: bool is_read: bool
has_attachments: bool has_attachments: bool
has_links: bool has_links: bool
item_id: str item_id: str
size: int size: int
is_meeting: bool is_meeting: bool
item_type: str item_type: str
to: list[str] to: list[str]
cc: list[str] cc: list[str]
body: str body: str
body_type: str body_type: str
attachments: list[dict] attachments: list[dict]
# Meeting-specific fields # Meeting-specific fields
location: str location: str
start: str start: str
end: str end: str
attendees_required: list[str] attendees_required: list[str]
attendees_optional: list[str] attendees_optional: list[str]
class CalendarEvent(TypedDict, total=False): class CalendarEvent(TypedDict, total=False):
"""A calendar event / meeting.""" """A calendar event / meeting."""
subject: str subject: str
start: str start: str
end: str end: str
location: str location: str
is_all_day: bool is_all_day: bool
is_cancelled: bool is_cancelled: bool
is_meeting: bool is_meeting: bool
is_recurring: bool is_recurring: bool
organizer: str organizer: str
organizer_email: str organizer_email: str
my_response: str my_response: str
item_id: str item_id: str
body: str body: str
attendees_required: list[str] attendees_required: list[str]
attendees_optional: list[str] attendees_optional: list[str]
class Person(TypedDict, total=False): class Person(TypedDict, total=False):
"""A person from the Exchange directory.""" """A person from the Exchange directory."""
name: str name: str
email: str email: str
mailbox_type: str mailbox_type: str
first_name: str first_name: str
last_name: str last_name: str
job_title: str job_title: str
department: str department: str
company: str company: str
office: str office: str
alias: str alias: str
manager: str manager: str
manager_email: str manager_email: str
phones: dict[str, str] phones: dict[str, str]
address: str address: str
direct_reports: list[dict[str, str]] direct_reports: list[dict[str, str]]
class Folder(TypedDict, total=False): class Folder(TypedDict, total=False):
"""A mail folder.""" """A mail folder."""
name: str name: str
id: str id: str
total_count: int total_count: int
unread_count: int unread_count: int
child_folder_count: int child_folder_count: int
class FreeSlot(TypedDict): class FreeSlot(TypedDict):
"""A free time slot.""" """A free time slot."""
date: str date: str
start: str start: str
end: str end: str
duration_minutes: int duration_minutes: int
class Availability(TypedDict, total=False): class Availability(TypedDict, total=False):
"""Availability data for a single attendee.""" """Availability data for a single attendee."""
email: str email: str
busy_slots: int busy_slots: int
free_slots: int free_slots: int
merged_freebusy: str merged_freebusy: str
class MeetingResult(TypedDict, total=False): class MeetingResult(TypedDict, total=False):
"""Result of creating/updating a meeting.""" """Result of creating/updating a meeting."""
success: bool success: bool
subject: str subject: str
date: str date: str
start_time: str start_time: str
end_time: str end_time: str
location: str location: str
required_attendees: list[str] required_attendees: list[str]
optional_attendees: list[str] optional_attendees: list[str]
error: str error: str
+397 -402
View File
@@ -17,480 +17,475 @@ import requests
class SessionExpiredError(Exception): 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 # Map common folder names (English + Russian) to OWA distinguished folder IDs
DISTINGUISHED_FOLDERS = { DISTINGUISHED_FOLDERS = {
"inbox": "inbox", "inbox": "inbox",
"входящие": "inbox", "входящие": "inbox",
"sent": "sentitems", "sent": "sentitems",
"отправленные": "sentitems", "отправленные": "sentitems",
"drafts": "drafts", "drafts": "drafts",
"черновики": "drafts", "черновики": "drafts",
"deleted": "deleteditems", "deleted": "deleteditems",
"удаленные": "deleteditems", "удаленные": "deleteditems",
"junk": "junkemail", "junk": "junkemail",
"нежелательная почта": "junkemail", "нежелательная почта": "junkemail",
"outbox": "outbox", "outbox": "outbox",
"исходящие": "outbox", "исходящие": "outbox",
"calendar": "calendar", "calendar": "calendar",
"календарь": "calendar", "календарь": "calendar",
} }
class OWAClient: 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, Handles cookie loading, CSRF token extraction, request construction,
session expiry detection, and automatic cookie reload on first failure. 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__( def _add_canary_header(self, headers: dict) -> dict:
self, """Extract X-OWA-CANARY from session cookies and add to headers."""
cookie_file: str | None = None, canary = next(
owa_url: str | None = None, (c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), ""
): )
self.cookie_file = Path( if canary:
cookie_file or str(Path(__file__).parent.parent / "session-cookies.txt") headers["x-owa-canary"] = canary
) return headers
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 = ""
# ------------------------------------------------------------------ @property
# Helpers 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: # Cookie / session helpers
"""Convert e.g. 'GetFolder''GetFolderAction'.""" # ------------------------------------------------------------------
return action + "Action"
def _fresh_headers(self, action: str) -> dict: def _load_cookies(self) -> None:
"""Build the full set of OWA headers required by Exchange 2013. """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. raw = self.cookie_file.read_text().strip()
""" cookies: dict[str, str] = {}
now_ms = int(time.time() * 1000) for line in raw.split("\n"):
client_req_id = f"{self._client_id}_{now_ms}" if "=" in line:
now_str = ( name, value = line.split("=", 1)
time.strftime("%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)) cookies[name] = value
+ 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 _add_canary_header(self, headers: dict) -> dict: if not cookies:
"""Extract X-OWA-CANARY from session cookies and add to headers.""" raise SessionExpiredError(
canary = next( "Cookie file is empty. Use the login MCP tool to authenticate."
(c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), "" )
)
if canary:
headers["x-owa-canary"] = canary
return headers
@property self._session = requests.Session()
def _client_id(self) -> str: self._session.cookies.update(cookies)
"""Extract ClientId from session cookies.""" self._loaded = True
cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "")
return cid if cid else "00000000000000000000000000000000"
# ------------------------------------------------------------------ def _save_cookies(self) -> None:
# Cookie / session helpers """Write the current session cookie jar to the cookie file.
# ------------------------------------------------------------------
def _load_cookies(self) -> None: Called automatically after every successful request so that the
"""Read session-cookies.txt (name=value per line) and load into session.""" on-disk cookies always reflect the freshest ``Set-Cookie`` values
if not self.cookie_file.exists(): returned by the server.
raise SessionExpiredError( """
f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate." 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() def _ensure_loaded(self) -> None:
cookies: dict[str, str] = {} """Load cookies on first use and refresh session from the main page."""
for line in raw.split("\n"): if not self._loaded:
if "=" in line: self._load_cookies()
name, value = line.split("=", 1) self._refresh_session()
cookies[name] = value
if not cookies: def load_cookies_from_string(self, cookies_str: str) -> None:
raise SessionExpiredError( """Load cookies from a name=value string (one per line) into memory."""
"Cookie file is empty. Use the login MCP tool to authenticate." 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() if not cookies:
self._session.cookies.update(cookies) raise SessionExpiredError("No cookies in provided data.")
self._loaded = True
def _save_cookies(self) -> None: self._session = requests.Session()
"""Write the current session cookie jar to the cookie file. 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 # Core request method
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
def _ensure_loaded(self) -> None: def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""Load cookies on first use and refresh session from the main page.""" """POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1.
if not self._loaded:
self._load_cookies()
self._refresh_session()
def load_cookies_from_string(self, cookies_str: str) -> None: On session expiry (401, 440, or text/html response), reloads cookies
"""Load cookies from a name=value string (one per line) into memory.""" once and retries. If that also fails, raises SessionExpiredError.
cookies: dict[str, str] = {}
for line in cookies_str.strip().split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
if not cookies: Returns the parsed JSON response dict.
raise SessionExpiredError("No cookies in provided data.") """
self._ensure_loaded()
self._session = requests.Session() try:
self._session.cookies.update(cookies) data = self._do_request(action, payload, timeout=timeout)
self._loaded = True return data
except SessionExpiredError:
raise
# ------------------------------------------------------------------ def _refresh_session(self) -> None:
# Core request method """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: m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie)
"""POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1. 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 def _do_request(
once and retries. If that also fails, raises SessionExpiredError. self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""Execute a single OWA API request.
Returns the parsed JSON response dict. Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
""" is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an
self._ensure_loaded() 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: url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1"
data = self._do_request(action, payload, timeout=timeout)
return data
except SessionExpiredError:
raise
def _refresh_session(self) -> None: # URL-encode the JSON payload for the x-owa-urlpostdata header
"""Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY).""" url_post_data = quote(json.dumps(payload, separators=(",", ":")))
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
m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie) # Build headers — start with OWA-required headers
if m: headers = self._fresh_headers(action)
host = urlparse(self.owa_url).hostname or "owa.b1.ru" headers["x-owa-urlpostdata"] = url_post_data
self._session.cookies.set("X-OWA-CANARY", m.group(1), domain=host) # Add canary from cookies
# Persist any cookie changes headers = self._add_canary_header(headers)
self._save_cookies()
except Exception:
pass # non-critical
def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: # Add extra cookies that the OWA frontend always sends
"""Execute a single OWA API request. 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 try:
is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an resp = self._session.post(url, data="", headers=headers, timeout=timeout)
empty POST body, plus a full set of OWA-specific headers extracted except requests.exceptions.RequestException as exc:
from the browser's actual network traffic. raise SessionExpiredError(f"Request failed: {exc}") from exc
"""
from urllib.parse import urlparse
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 if "text/html" in resp.headers.get("Content-Type", ""):
url_post_data = quote(json.dumps(payload, separators=(",", ":"))) 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 # Parse JSON
headers = self._fresh_headers(action) try:
headers["x-owa-urlpostdata"] = url_post_data data = resp.json()
# Add canary from cookies except (ValueError, requests.exceptions.JSONDecodeError) as exc:
headers = self._add_canary_header(headers) raise SessionExpiredError(
f"Unexpected response (HTTP {resp.status_code}). Session may have expired."
) from exc
# Add extra cookies that the OWA frontend always sends # Persist any Set-Cookie updates back to disk so the next request
host = urlparse(self.owa_url).hostname or "owa.b1.ru" # (or a server restart) picks up the freshest session tokens.
for name, val in [("PrivateComputer", "true"), ("PBack", "0")]: self._save_cookies()
self._session.cookies.set(name, val, domain=host) return data
try: def request_header_payload(
resp = self._session.post(url, data="", headers=headers, timeout=timeout) self, action: str, payload: dict, *, timeout: int = 30
except requests.exceptions.RequestException as exc: ) -> dict:
raise SessionExpiredError(f"Request failed: {exc}") from exc """POST with payload in x-owa-urlpostdata header (empty body).
# Detect session expiry Alias for :meth:`request` — both methods now use the same
if resp.status_code in (401, 440): ``x-owa-urlpostdata`` format that mirrors the OWA web frontend.
raise SessionExpiredError( """
f"Session expired (HTTP {resp.status_code})." 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 "" # File download (attachments)
raise SessionExpiredError( # ------------------------------------------------------------------
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
f"Snippet: {body_snippet}"
)
# Parse JSON def download_file(
try: self, attachment_id: str, *, timeout: int = 60
data = resp.json() ) -> tuple[bytes, str, str]:
except (ValueError, requests.exceptions.JSONDecodeError) as exc: """Download a file attachment by its AttachmentId.
raise SessionExpiredError(
f"Unexpected response (HTTP {resp.status_code}). "
"Session may have expired."
) from exc
# Persist any Set-Cookie updates back to disk so the next request Uses the OWA GetFileAttachment endpoint (direct GET).
# (or a server restart) picks up the freshest session tokens.
self._save_cookies()
return data
def request_header_payload( Returns:
self, action: str, payload: dict, *, timeout: int = 30 (content_bytes, filename, content_type)
) -> dict: """
"""POST with payload in x-owa-urlpostdata header (empty body). self._ensure_loaded()
Alias for :meth:`request` — both methods now use the same from urllib.parse import quote
``x-owa-urlpostdata`` format that mirrors the OWA web frontend.
"""
return self.request(action, payload, timeout=timeout)
# ------------------------------------------------------------------ url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
# File download (attachments)
# ------------------------------------------------------------------
def download_file( try:
self, attachment_id: str, *, timeout: int = 60 resp = self._session.get(url, timeout=timeout)
) -> tuple[bytes, str, str]: except requests.exceptions.RequestException as exc:
"""Download a file attachment by its AttachmentId. 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: if "text/html" in resp.headers.get("Content-Type", ""):
(content_bytes, filename, content_type) raise SessionExpiredError(
""" "Session expired (HTML response on attachment download)."
self._ensure_loaded() )
from urllib.parse import quote # Persist fresh cookies from the download response
self._save_cookies()
url = ( # Parse filename from Content-Disposition header
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment" filename = "attachment"
f"?id={quote(attachment_id)}" cd = resp.headers.get("Content-Disposition", "")
) if cd:
import re as _re
from urllib.parse import unquote
try: # Try filename*= (RFC 5987) first, then filename=
resp = self._session.get(url, timeout=timeout) match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd)
except requests.exceptions.RequestException as exc: if match:
raise SessionExpiredError(f"Download failed: {exc}") from exc 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): content_type = resp.headers.get("Content-Type", "application/octet-stream")
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
if "text/html" in resp.headers.get("Content-Type", ""): return resp.content, filename, content_type
raise SessionExpiredError(
"Session expired (HTML response on attachment download)."
)
# Persist fresh cookies from the download response # ------------------------------------------------------------------
self._save_cookies() # Convenience: extract response items
# ------------------------------------------------------------------
# Parse filename from Content-Disposition header @staticmethod
filename = "attachment" def extract_items(data: dict) -> list[dict]:
cd = resp.headers.get("Content-Disposition", "") """Extract Items from standard OWA response envelope.
if cd:
import re as _re
from urllib.parse import unquote
# Try filename*= (RFC 5987) first, then filename= Response shape: data["Body"]["ResponseMessages"]["Items"]
match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd) """
if match: try:
filename = unquote(match.group(1).strip()) return data["Body"]["ResponseMessages"]["Items"]
else: except (KeyError, TypeError):
match = _re.search(r'filename="?([^";]+)"?', cd) return []
if match:
filename = unquote(match.group(1).strip())
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.
# ------------------------------------------------------------------ Supports distinguished folder names (inbox, sentitems, drafts, etc.)
# Convenience: extract response items in both English and Russian, plus custom folder names looked up
# ------------------------------------------------------------------ via FindFolder on msgfolderroot.
"""
folder_lower = folder_name.lower()
@staticmethod # Check distinguished folders first
def extract_items(data: dict) -> list[dict]: distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower)
"""Extract Items from standard OWA response envelope. if distinguished_id:
payload = {
Response shape: data["Body"]["ResponseMessages"]["Items"] "__type": "GetFolderJsonRequest:#Exchange",
""" "Header": {
try: "__type": "JsonRequestHeaders:#Exchange",
return data["Body"]["ResponseMessages"]["Items"] "RequestServerVersion": "Exchange2013",
except (KeyError, TypeError): },
return [] "Body": {
"__type": "GetFolderRequest:#Exchange",
# ------------------------------------------------------------------ "FolderShape": {
# Folder helpers "__type": "FolderResponseShape:#Exchange",
# ------------------------------------------------------------------ "BaseShape": "IdOnly",
},
def get_folder_id(self, folder_name: str) -> str | None: "FolderIds": [
"""Resolve a folder name to its Exchange folder ID. {
"__type": "DistinguishedFolderId:#Exchange",
Supports distinguished folder names (inbox, sentitems, drafts, etc.) "Id": distinguished_id,
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,
}
],
},
} }
],
},
}
data = self.request("GetFolder", payload) data = self.request("GetFolder", payload)
for msg in self.extract_items(data): for msg in self.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
for f in msg["Folders"]: for f in msg["Folders"]:
fid = f.get("FolderId", {}).get("Id") fid = f.get("FolderId", {}).get("Id")
if fid: if fid:
return fid return fid
# Fall back to searching custom folders by name # Fall back to searching custom folders by name
payload = { payload = {
"__type": "FindFolderJsonRequest:#Exchange", "__type": "FindFolderJsonRequest:#Exchange",
"Header": { "Header": {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013", "RequestServerVersion": "Exchange2013",
}, },
"Body": { "Body": {
"__type": "FindFolderRequest:#Exchange", "__type": "FindFolderRequest:#Exchange",
"FolderShape": { "FolderShape": {
"__type": "FolderResponseShape:#Exchange", "__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default", "BaseShape": "Default",
}, },
"ParentFolderIds": [ "ParentFolderIds": [
{ {
"__type": "DistinguishedFolderId:#Exchange", "__type": "DistinguishedFolderId:#Exchange",
"Id": "msgfolderroot", "Id": "msgfolderroot",
} }
], ],
"Traversal": "Shallow", "Traversal": "Shallow",
"Paging": { "Paging": {
"__type": "IndexedPageView:#Exchange", "__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning", "BasePoint": "Beginning",
"Offset": 0, "Offset": 0,
"MaxEntriesReturned": 200, "MaxEntriesReturned": 200,
}, },
}, },
} }
data = self.request("FindFolder", payload) data = self.request("FindFolder", payload)
for msg in self.extract_items(data): for msg in self.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]: if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]: for f in msg["RootFolder"]["Folders"]:
if f.get("DisplayName", "").lower() == folder_lower: if f.get("DisplayName", "").lower() == folder_lower:
return f.get("FolderId", {}).get("Id") 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]: def resolve_names(
"""Call ResolveNames to search the directory. 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 Returns the list of Resolution dicts from the API, each containing
Mailbox and optionally Contact data. Mailbox and optionally Contact data.
""" """
payload = { payload = {
"__type": "ResolveNamesJsonRequest:#Exchange", "__type": "ResolveNamesJsonRequest:#Exchange",
"Header": { "Header": {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013", "RequestServerVersion": "Exchange2013",
}, },
"Body": { "Body": {
"__type": "ResolveNamesRequest:#Exchange", "__type": "ResolveNamesRequest:#Exchange",
"UnresolvedEntry": query, "UnresolvedEntry": query,
"ReturnFullContactData": full_contact, "ReturnFullContactData": full_contact,
"SearchScope": "ActiveDirectoryContacts", "SearchScope": "ActiveDirectoryContacts",
"ContactDataShape": "AllProperties" if full_contact else "Default", "ContactDataShape": "AllProperties" if full_contact else "Default",
}, },
} }
data = self.request("ResolveNames", payload) data = self.request("ResolveNames", payload)
for msg in self.extract_items(data): for msg in self.extract_items(data):
if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]: if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]:
return msg["ResolutionSet"]["Resolutions"] 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.owa_client import OWAClient
from owa_mcp.server_lifecycle import ( from owa_mcp.server_lifecycle import (
HashTokenVerifier, HashTokenVerifier,
SessionKeepalive, SessionKeepalive,
apply_tool_filters, apply_tool_filters,
create_owa_client, create_owa_client,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,32 +40,32 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class AppContext: 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 @asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: 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 A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts
immediately (pings inbox, extends the session, persists fresh cookies) and immediately (pings inbox, extends the session, persists fresh cookies) and
repeats every 5 minutes until the server shuts down. repeats every 5 minutes until the server shuts down.
""" """
client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file) client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file)
try: try:
client._ensure_loaded() client._ensure_loaded()
except Exception: except Exception:
pass # no cookies yet — login tool will handle this pass # no cookies yet — login tool will handle this
keepalive = SessionKeepalive(client) keepalive = SessionKeepalive(client)
await keepalive.start() await keepalive.start()
try: try:
yield AppContext(client=client) yield AppContext(client=client)
finally: finally:
await keepalive.stop() await keepalive.stop()
# ── Temporary storage for lifespan args (set by main() before mcp.run()) ── # ── 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: def _parse_args(argv=None) -> argparse.Namespace:
"""Parse command-line arguments.""" """Parse command-line arguments."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="OWA MCP Server — OWA/Exchange integration via MCP", description="OWA MCP Server — OWA/Exchange integration via MCP",
) )
parser.add_argument( parser.add_argument(
"--transport", "--transport",
choices=["stdio", "sse", "streamable-http"], choices=["stdio", "sse", "streamable-http"],
default="stdio", default="stdio",
help="Transport protocol (default: stdio)", help="Transport protocol (default: stdio)",
) )
parser.add_argument( parser.add_argument(
"--owa-url", "--owa-url",
required=True, required=True,
help="Base URL of the OWA instance (e.g. https://owa.example.com)", help="Base URL of the OWA instance (e.g. https://owa.example.com)",
) )
parser.add_argument( parser.add_argument(
"--cookie-file", "--cookie-file",
default=None, default=None,
help="Path to session cookies file (default: session-cookies.txt)", help="Path to session cookies file (default: session-cookies.txt)",
) )
parser.add_argument( parser.add_argument(
"--token-hash", "--token-hash",
default=None, default=None,
help=( help=(
"SHA-256 hash of the bearer token for HTTP transport auth. " "SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum"
"Compute with: echo -n 'my-token' | sha256sum" ),
), )
) parser.add_argument(
parser.add_argument( "--port",
"--port", type=int,
type=int, default=8000,
default=8000, help="Port for HTTP/SSE transport (default: 8000)",
help="Port for HTTP/SSE transport (default: 8000)", )
) parser.add_argument(
parser.add_argument( "--host",
"--host", default="127.0.0.1",
default="127.0.0.1", help="Host for HTTP/SSE transport (default: 127.0.0.1)",
help="Host for HTTP/SSE transport (default: 127.0.0.1)", )
) parser.add_argument(
parser.add_argument( "--enabled-tools",
"--enabled-tools", default=None,
default=None, help="Comma-separated whitelist of tool names to expose",
help="Comma-separated whitelist of tool names to expose", )
) parser.add_argument(
parser.add_argument( "--disabled-tools",
"--disabled-tools", default=None,
default=None, help="Comma-separated blacklist of tool names to hide",
help="Comma-separated blacklist of tool names to hide", )
) return parser.parse_args(argv)
return parser.parse_args(argv)
def _configure_auth( def _configure_auth(
mcp_server: FastMCP, mcp_server: FastMCP,
token_hash: str | None, token_hash: str | None,
) -> None: ) -> None:
"""Apply token-hash auth to *mcp_server* if *token_hash* is provided.""" """Apply token-hash auth to *mcp_server* if *token_hash* is provided."""
if not token_hash: if not token_hash:
return return
token_verifier: TokenVerifier = HashTokenVerifier(token_hash) token_verifier: TokenVerifier = HashTokenVerifier(token_hash)
mcp_server.settings.auth = AuthSettings( # type: ignore[misc] mcp_server.settings.auth = AuthSettings( # type: ignore[misc]
issuer_url="http://localhost", # type: ignore[arg-type] issuer_url="http://localhost", # type: ignore[arg-type]
resource_server_url="http://localhost", # type: ignore[arg-type] resource_server_url="http://localhost", # type: ignore[arg-type]
) )
mcp_server._token_verifier = token_verifier # noqa: SLF001 mcp_server._token_verifier = token_verifier # noqa: SLF001
def main(argv=None) -> None: def main(argv=None) -> None:
"""Entry point: parse CLI args, configure server, run transport.""" """Entry point: parse CLI args, configure server, run transport."""
global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603 global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603
args = _parse_args(argv) args = _parse_args(argv)
# ── Validate --owa-url ───────────────────────────────────────────────── # ── Validate --owa-url ─────────────────────────────────────────────────
owa_url = args.owa_url.strip().rstrip("/") owa_url = args.owa_url.strip().rstrip("/")
if not owa_url: if not owa_url:
print( print(
"ERROR: --owa-url must not be empty.", "ERROR: --owa-url must not be empty.",
file=__import__("sys").stderr, file=__import__("sys").stderr,
) )
raise SystemExit(1) raise SystemExit(1)
# ── Stash lifespan args (consumed by app_lifespan) ───────────────────── # ── Stash lifespan args (consumed by app_lifespan) ─────────────────────
_lifespan_owa_url = owa_url _lifespan_owa_url = owa_url
_lifespan_cookie_file = args.cookie_file _lifespan_cookie_file = args.cookie_file
# ── Validate --token-hash for non-stdio transports ───────────────────── # ── Validate --token-hash for non-stdio transports ─────────────────────
if args.transport != "stdio" and not args.token_hash: if args.transport != "stdio" and not args.token_hash:
print( print(
"ERROR: --token-hash is required when using HTTP transport.", "ERROR: --token-hash is required when using HTTP transport.",
file=__import__("sys").stderr, file=__import__("sys").stderr,
) )
print( print(
" Compute hash: echo -n 'my-token' | sha256sum", " Compute hash: echo -n 'my-token' | sha256sum",
file=__import__("sys").stderr, file=__import__("sys").stderr,
) )
raise SystemExit(1) raise SystemExit(1)
# ── Register tools (imports populate module-level `mcp`) ─────────────── # ── Register tools (imports populate module-level `mcp`) ───────────────
import owa_mcp.tools.analytics # noqa: E402, F401 import owa_mcp.tools.analytics # noqa: E402, F401
import owa_mcp.tools.auth # noqa: E402, F401 import owa_mcp.tools.auth # noqa: E402, F401
import owa_mcp.tools.availability # noqa: E402, F401 import owa_mcp.tools.availability # noqa: E402, F401
import owa_mcp.tools.calendar # noqa: E402, F401 import owa_mcp.tools.calendar # noqa: E402, F401
import owa_mcp.tools.email # noqa: E402, F401 import owa_mcp.tools.email # noqa: E402, F401
import owa_mcp.tools.folders # noqa: E402, F401 import owa_mcp.tools.folders # noqa: E402, F401
import owa_mcp.tools.people # noqa: E402, F401 import owa_mcp.tools.people # noqa: E402, F401
# ── Auth for HTTP transports ─────────────────────────────────────────── # ── Auth for HTTP transports ───────────────────────────────────────────
_configure_auth(mcp, args.token_hash) _configure_auth(mcp, args.token_hash)
# ── Tool filtering ───────────────────────────────────────────────────── # ── Tool filtering ─────────────────────────────────────────────────────
enabled: list[str] | None = None enabled: list[str] | None = None
if args.enabled_tools is not None: if args.enabled_tools is not None:
enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()]
disabled: list[str] | None = None disabled: list[str] | None = None
if args.disabled_tools is not None: if args.disabled_tools is not None:
disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()]
if enabled or disabled: if enabled or disabled:
apply_tool_filters(mcp, enabled=enabled, disabled=disabled) apply_tool_filters(mcp, enabled=enabled, disabled=disabled)
# ── Host / port (already passed to FastMCP at module level; update if # ── Host / port (already passed to FastMCP at module level; update if
# different from defaults — useful when server is re-invoked with # different from defaults — useful when server is re-invoked with
# different args in tests) ───────────────────────────────────────────── # different args in tests) ─────────────────────────────────────────────
mcp.settings.host = args.host mcp.settings.host = args.host
mcp.settings.port = args.port mcp.settings.port = args.port
# ── Run transport ───────────────────────────────────────────────────── # ── Run transport ─────────────────────────────────────────────────────
match args.transport: match args.transport:
case "stdio": case "stdio":
mcp.run() mcp.run()
case "sse": case "sse":
mcp.run(transport="sse") mcp.run(transport="sse")
case "streamable-http": case "streamable-http":
mcp.run(transport="streamable-http") mcp.run(transport="streamable-http")
if __name__ == "__main__": 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 from owa_mcp.owa_client import OWAClient, SessionExpiredError
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,87 +30,88 @@ logger = logging.getLogger(__name__)
# ── Client factory ─────────────────────────────────────────────────────── # ── Client factory ───────────────────────────────────────────────────────
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient: def create_owa_client(
"""Create and return a fully configured :class:`OWAClient`. owa_url: str, cookie_file: str | None = None
) -> OWAClient:
"""Create and return a fully configured :class:`OWAClient`.
Args: Args:
owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``). 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 cookie_file: Path to the session-cookie file. ``None`` falls back to the
default ``session-cookies.txt`` next to the package. default ``session-cookies.txt`` next to the package.
Returns: Returns:
A ready-to-use :class:`OWAClient`. A ready-to-use :class:`OWAClient`.
Raises: Raises:
ValueError: If *owa_url* is empty. ValueError: If *owa_url* is empty.
""" """
owa_url = owa_url.strip().rstrip("/") owa_url = owa_url.strip().rstrip("/")
if not owa_url: if not owa_url:
raise ValueError("OWA URL must not be empty.") raise ValueError("OWA URL must not be empty.")
return OWAClient(owa_url=owa_url, cookie_file=cookie_file) return OWAClient(owa_url=owa_url, cookie_file=cookie_file)
# ── Token verifier ──────────────────────────────────────────────────────── # ── Token verifier ────────────────────────────────────────────────────────
class HashTokenVerifier(TokenVerifier): 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. The server never stores the plaintext token — only its hex digest.
Clients send the plaintext token as ``Authorization: Bearer <token>``, Clients send the plaintext token as ``Authorization: Bearer <token>``,
the server hashes it and compares with the stored hash. 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: async def verify_token(self, token: str) -> AccessToken | None:
import hashlib import hashlib
if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash:
return AccessToken( return AccessToken(
token=token, token=token,
client_id="mcp-client", client_id="mcp-client",
scopes=[], scopes=[],
) )
return None return None
# ── Lifespan helper ────────────────────────────────────────────────────── # ── Lifespan helper ──────────────────────────────────────────────────────
class _LazyClient: 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 Stored as ``AppContext.client`` so that ``require_client`` can work
with both a pre-built client and a ``None`` placeholder. with both a pre-built client and a ``None`` placeholder.
""" """
def __init__(self, client: OWAClient | None) -> None: def __init__(self, client: OWAClient | None) -> None:
self._client = client self._client = client
@property @property
def resolved(self) -> OWAClient: def resolved(self) -> OWAClient:
if self._client is None: if self._client is None:
raise RuntimeError( raise RuntimeError(
"OWA client is not initialised. " "OWA client is not initialised. Check --owa-url and --cookie-file arguments."
"Check --owa-url and --cookie-file arguments." )
) return self._client
return self._client
def require_client(client: OWAClient | None) -> OWAClient: 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 Import this in tool modules and call it with ``ctx.client`` to get a
consistent error message when OWA is not configured. consistent error message when OWA is not configured.
""" """
if client is None: if client is None:
raise RuntimeError( raise RuntimeError(
"OWA client is not initialised. Pass --owa-url when starting the server." "OWA client is not initialised. Pass --owa-url when starting the server."
) )
return client return client
# ── Session keepalive ────────────────────────────────────────────────────── # ── Session keepalive ──────────────────────────────────────────────────────
@@ -119,151 +120,151 @@ _DEFAULT_KEEPALIVE_INTERVAL = 300 # seconds (5 minutes)
class SessionKeepalive: class SessionKeepalive:
"""Background task that periodically pings the OWA server to extend the """Background task that periodically pings the OWA server to extend the
session lifetime and persist fresh cookies to disk. session lifetime and persist fresh cookies to disk.
On each successful ping the OWA server may return updated ``Set-Cookie`` 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). headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT).
The client saves those cookies to disk automatically. The client saves those cookies to disk automatically.
On failure the keepalive attempts to reload cookies from disk so that a 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 fresh login performed by another process (or via the ``login`` tool) is
picked up without restarting the server. 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__( async def stop(self) -> None:
self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL """Cancel the background keepalive loop and wait for it to finish."""
) -> None: if self._task:
self._client = client self._task.cancel()
self._interval = interval try:
self._task: asyncio.Task | None = None await self._task
except asyncio.CancelledError:
pass
self._task = None
async def start(self) -> None: async def _run(self) -> None:
"""Start the background keepalive loop. """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 def _ping(self) -> None:
start-up, then waits ``interval`` seconds between subsequent pings. """Make a lightweight GetFolder request against the inbox.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._run(), name="session-keepalive")
async def stop(self) -> None: On success the OWAClient saves fresh cookies to disk automatically.
"""Cancel the background keepalive loop and wait for it to finish.""" On ``SessionExpiredError`` the on-disk cookies are re-loaded in
if self._task: case the user re-authenticated while the server was running.
self._task.cancel() """
try: try:
await self._task self._client.request(
except asyncio.CancelledError: "GetFolder",
pass {
self._task = None "__type": "GetFolderJsonRequest:#Exchange",
"Header": {
async def _run(self) -> None: "__type": "JsonRequestHeaders:#Exchange",
"""Ping immediately, then every ``interval`` seconds.""" "RequestServerVersion": "Exchange2013",
# First ping right away (on server start) },
await asyncio.to_thread(self._ping) "Body": {
while True: "__type": "GetFolderRequest:#Exchange",
await asyncio.sleep(self._interval) "FolderShape": {
await asyncio.to_thread(self._ping) "__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly",
def _ping(self) -> None: },
"""Make a lightweight GetFolder request against the inbox. "FolderIds": [
{
On success the OWAClient saves fresh cookies to disk automatically. "__type": "DistinguishedFolderId:#Exchange",
On ``SessionExpiredError`` the on-disk cookies are re-loaded in "Id": "inbox",
case the user re-authenticated while the server was running. }
""" ],
try: },
self._client.request( },
"GetFolder", )
{ logger.debug("Session keepalive ping succeeded")
"__type": "GetFolderJsonRequest:#Exchange", except SessionExpiredError:
"Header": { logger.warning(
"__type": "JsonRequestHeaders:#Exchange", "Session expired during keepalive — reloading cookies from disk"
"RequestServerVersion": "Exchange2013", )
}, try:
"Body": { self._client._ensure_loaded()
"__type": "GetFolderRequest:#Exchange", except Exception as exc:
"FolderShape": { logger.warning(
"__type": "FolderResponseShape:#Exchange", "Failed to reload cookies after keepalive failure: %s", exc
"BaseShape": "IdOnly", )
}, except Exception as exc:
"FolderIds": [ logger.debug("Keepalive ping failed (non-critical): %s", exc)
{
"__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 ─────────────────────────────────────────────────────── # ── Tool filtering ───────────────────────────────────────────────────────
def apply_tool_filters( def apply_tool_filters(
server: FastMCP, server: FastMCP,
enabled: list[str] | None = None, enabled: list[str] | None = None,
disabled: list[str] | None = None, disabled: list[str] | None = None,
) -> None: ) -> None:
"""Apply whitelist / blacklist filtering to *server*'s registered tools. """Apply whitelist / blacklist filtering to *server*'s registered tools.
Args: Args:
server: The :class:`FastMCP` instance whose tool registry is modified server: The :class:`FastMCP` instance whose tool registry is modified
in-place. in-place.
enabled: Optional list of tool names to **keep**. If ``None`` or empty enabled: Optional list of tool names to **keep**. If ``None`` or empty
all tools are eligible. Unknown names are ignored (warning all tools are eligible. Unknown names are ignored (warning
logged). logged).
disabled: Optional list of tool names to **remove**. Applied *after* disabled: Optional list of tool names to **remove**. Applied *after*
the whitelist. Unknown names are ignored (warning logged). the whitelist. Unknown names are ignored (warning logged).
""" """
canonical = {t.name for t in asyncio.run(server.list_tools())} canonical = {t.name for t in asyncio.run(server.list_tools())}
if not canonical: if not canonical:
return return
allowed: set[str] = set(canonical) allowed: set[str] = set(canonical)
# ── Whitelist ────────────────────────────────────────────────────────── # ── Whitelist ──────────────────────────────────────────────────────────
if enabled: if enabled:
enabled_set = {n.strip() for n in enabled if n.strip()} enabled_set = {n.strip() for n in enabled if n.strip()}
allowed &= enabled_set allowed &= enabled_set
unknown = enabled_set - canonical unknown = enabled_set - canonical
if unknown: if unknown:
logger.warning( logger.warning(
"Unknown tool(s) in --enabled-tools, ignoring: %s", "Unknown tool(s) in --enabled-tools, ignoring: %s",
", ".join(sorted(unknown)), ", ".join(sorted(unknown)),
) )
# ── Blacklist ────────────────────────────────────────────────────────── # ── Blacklist ──────────────────────────────────────────────────────────
if disabled: if disabled:
disabled_set = {n.strip() for n in disabled if n.strip()} disabled_set = {n.strip() for n in disabled if n.strip()}
allowed -= disabled_set allowed -= disabled_set
unknown = disabled_set - canonical unknown = disabled_set - canonical
if unknown: if unknown:
logger.warning( logger.warning(
"Unknown tool(s) in --disabled-tools, ignoring: %s", "Unknown tool(s) in --disabled-tools, ignoring: %s",
", ".join(sorted(unknown)), ", ".join(sorted(unknown)),
) )
# ── Remove ───────────────────────────────────────────────────────────── # ── Remove ─────────────────────────────────────────────────────────────
to_remove = sorted(canonical - allowed) to_remove = sorted(canonical - allowed)
if to_remove: if to_remove:
logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove)) logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove))
for name in to_remove: for name in to_remove:
server.remove_tool(name) 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: def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context.""" """Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client) 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]: def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
"""Resolve a name/email to (display_name, email). Returns ('','') on failure.""" """Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name) resolutions = client.resolve_names(name)
if resolutions: if resolutions:
mb = resolutions[0].get("Mailbox", {}) mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "") return mb.get("Name", name), mb.get("EmailAddress", "")
return "", "" return "", ""
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -41,105 +41,105 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
def _get_availability_events( def _get_availability_events(
client: OWAClient, client: OWAClient,
emails: list[str], emails: list[str],
start: date, start: date,
end: date, end: date,
batch_size: int = 5, batch_size: int = 5,
chunk_days: int = 14, chunk_days: int = 14,
) -> dict[str, list[dict]]: ) -> 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 Returns dict mapping email -> list of calendar event dicts with
keys: subject, start_date, busy_type. keys: subject, start_date, busy_type.
""" """
results: dict[str, list[dict]] = {email: [] for email in emails} results: dict[str, list[dict]] = {email: [] for email in emails}
# Batch people # Batch people
email_batches = [ email_batches = [
emails[i : i + batch_size] for i in range(0, len(emails), batch_size) emails[i : i + batch_size] for i in range(0, len(emails), batch_size)
] ]
for batch in email_batches: for batch in email_batches:
current = start current = start
while current < end: while current < end:
chunk_end = min(current + timedelta(days=chunk_days), 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", "subject": subject,
"Email": { "start_date": s[:10],
"__type": "EmailAddress:#Exchange", "busy_type": bt,
"Address": email,
},
"AttendeeType": "Required",
} }
for email in batch )
] except Exception:
pass
payload = { current = chunk_end
"__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: return results
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
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -149,105 +149,107 @@ def _get_availability_events(
@mcp.tool() @mcp.tool()
def get_meeting_stats( def get_meeting_stats(
people: str, people: str,
start_date: str, start_date: str,
end_date: str, end_date: str,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> 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 Uses GetUserAvailability to count calendar events (including
expanded recurring instances) for each person. expanded recurring instances) for each person.
Args: Args:
people: Comma-separated names or email addresses to analyze. people: Comma-separated names or email addresses to analyze.
start_date: Start date in YYYY-MM-DD format. start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format. end_date: End date in YYYY-MM-DD format.
Returns: Returns:
JSON object with per-person stats sorted by meeting count: JSON object with per-person stats sorted by meeting count:
total_meetings, meetings_per_workday, days_with_meetings. total_meetings, meetings_per_workday, days_with_meetings.
""" """
client = _get_client(ctx) client = _get_client(ctx)
try: try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date() sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, "%Y-%m-%d").date() ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e: except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"}) return json.dumps({"error": f"Invalid date format: {e}"})
# Resolve names # Resolve names
name_list = [n.strip() for n in people.split(",") if n.strip()] name_list = [n.strip() for n in people.split(",") if n.strip()]
if not name_list: if not name_list:
return json.dumps({"error": "No people specified."}) return json.dumps({"error": "No people specified."})
resolved = [] # (display_name, email) resolved = [] # (display_name, email)
for name in name_list: for name in name_list:
display, email = _resolve_to_email(client, name) display, email = _resolve_to_email(client, name)
if email: if email:
resolved.append((display, email)) resolved.append((display, email))
else: else:
resolved.append((name, "")) 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)
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps( return json.dumps(
{ {"error": "Could not resolve any names to email addresses."}
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
},
ensure_ascii=False,
) )
# 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 # Tool: get_meeting_contacts
@@ -256,194 +258,196 @@ def get_meeting_stats(
@mcp.tool() @mcp.tool()
def get_meeting_contacts( def get_meeting_contacts(
start_date: str, start_date: str,
end_date: str, end_date: str,
top_n: int = 30, top_n: int = 30,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> 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 Analyzes your calendar over a date range, accounting for recurring
meetings. For each contact found in your meetings, returns the meetings. For each contact found in your meetings, returns the
weighted count of shared meetings. weighted count of shared meetings.
Args: Args:
start_date: Start date in YYYY-MM-DD format. start_date: Start date in YYYY-MM-DD format.
end_date: End 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. top_n: Number of top contacts to return. Default 30.
Returns: Returns:
JSON object with total_meetings, unique_contacts, and a ranked JSON object with total_meetings, unique_contacts, and a ranked
contacts array of {name, email, meetings} objects. contacts array of {name, email, meetings} objects.
""" """
client = _get_client(ctx) 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: try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date() data = client.request("GetItem", payload)
ed = datetime.strptime(end_date, "%Y-%m-%d").date() for msg in client.extract_items(data):
except ValueError as e: if "Items" not in msg:
return json.dumps({"error": f"Invalid date format: {e}"}) continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
# Step 1: Get own expanded events via GetUserAvailability for att_list in [
# to count subject occurrences (handles recurring meetings) item.get("RequiredAttendees", []) or [],
own_email = client.user_email.lower() if client.user_email else "" item.get("OptionalAttendees", []) or [],
if not own_email: ]:
return json.dumps( for a in att_list:
{"error": "User email not available. Call the login tool first."} mailbox = a.get("Mailbox", {})
) name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
folder_id = client.get_folder_id("calendar") if not name or not addr or addr.startswith("/O="):
if not folder_id:
return json.dumps({"error": "Could not find calendar folder."})
# Get expanded event subjects with occurrence counts
avail_result = _get_availability_events(client, [client.user_email], sd, ed)
expanded_events = avail_result.get(client.user_email, [])
subject_counts = Counter(ev["subject"] for ev in expanded_events)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
# Scan entire calendar (descending) to match subjects
subject_to_id: dict[str, str] = {}
offset = 0
while len(subject_to_id) < len(subject_counts):
payload = {
"__type": "FindItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": 200,
},
"SortOrder": [
{
"__type": "SortResults:#Exchange",
"Order": "Descending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "Start",
},
}
],
},
}
data = client.request("FindItem", payload)
items = client.extract_items(data)
if not items:
break
folder_items = items[0].get("RootFolder", {}).get("Items", [])
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if not folder_items:
break
for item in folder_items:
subj = item.get("Subject", "")
if subj and subj in subject_counts and subj not in subject_to_id:
subject_to_id[subj] = item.get("ItemId", {}).get("Id", "")
if is_last:
break
offset += 200
if offset > 3000:
break
# Step 3: GetItem for each master to get attendees
unique_ids = list(set(subject_to_id.values()))
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi : bi + 10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": item_id_list,
},
}
try:
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
for att_list in [
item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or [],
]:
for a in att_list:
mailbox = a.get("Mailbox", {})
name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
if not name or not addr or addr.startswith("/O="):
continue
attendees.add((name, addr.lower()))
organizer = item.get("Organizer", {}).get("Mailbox", {})
if organizer:
org_addr = organizer.get("EmailAddress", "")
org_name = organizer.get("Name", "")
if org_addr and not org_addr.startswith("/O="):
attendees.add((org_name, org_addr.lower()))
id_to_attendees[item_id] = attendees
except Exception:
pass
# Step 4: Build weighted contacts, excluding self
contacts = Counter()
for subject, item_id in subject_to_id.items():
weight = subject_counts.get(subject, 1)
attendees = id_to_attendees.get(item_id, set())
for name, email in attendees:
if own_email and email == own_email:
continue continue
contacts[(name, email)] += weight attendees.add((name, addr.lower()))
result_contacts = [] organizer = item.get("Organizer", {}).get("Mailbox", {})
for (name, email), count in contacts.most_common(top_n): if organizer:
result_contacts.append( org_addr = organizer.get("EmailAddress", "")
{ org_name = organizer.get("Name", "")
"name": name, if org_addr and not org_addr.startswith("/O="):
"email": email, attendees.add((org_name, org_addr.lower()))
"meetings": count,
}
)
return json.dumps( id_to_attendees[item_id] = attendees
{ except Exception:
"period": {"start": start_date, "end": end_date}, pass
"total_meetings": total_expanded,
"unique_contacts": len(contacts), # Step 4: Build weighted contacts, excluding self
"contacts": result_contacts, contacts = Counter()
}, for subject, item_id in subject_to_id.items():
ensure_ascii=False, 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: def _get_app_ctx(ctx: Context) -> AppContext:
"""Extract the AppContext from the MCP lifespan context.""" """Extract the AppContext from the MCP lifespan context."""
return ctx.request_context.lifespan_context return ctx.request_context.lifespan_context
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context.""" """Extract the OWAClient from the MCP lifespan context."""
return require_client(_get_app_ctx(ctx).client) return require_client(_get_app_ctx(ctx).client)
def _session_is_valid(client: OWAClient) -> bool: def _session_is_valid(client: OWAClient) -> bool:
"""Quick check: can we reach the inbox?""" """Quick check: can we reach the inbox?"""
try: try:
client._ensure_loaded() client._ensure_loaded()
payload = { payload = {
"__type": "GetFolderJsonRequest:#Exchange", "__type": "GetFolderJsonRequest:#Exchange",
"Header": { "Header": {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013", "RequestServerVersion": "Exchange2013",
}, },
"Body": { "Body": {
"__type": "GetFolderRequest:#Exchange", "__type": "GetFolderRequest:#Exchange",
"FolderShape": { "FolderShape": {
"__type": "FolderResponseShape:#Exchange", "__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly", "BaseShape": "IdOnly",
}, },
"FolderIds": [ "FolderIds": [
{ {
"__type": "DistinguishedFolderId:#Exchange", "__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox", "Id": "inbox",
} }
], ],
}, },
} }
data = client.request("GetFolder", payload) data = client.request("GetFolder", payload)
items = client.extract_items(data) items = client.extract_items(data)
return bool(items) return bool(items)
except Exception: except Exception:
return False return False
@mcp.tool() @mcp.tool()
async def login( async def login(
cookies: str, cookies: str,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> 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 → Paste the cookies you copied from your browser (DevTools → Application →
Cookies) as a ``name=value`` string — one cookie per line. Cookies) as a ``name=value`` string — one cookie per line.
Example cookies string:: Example cookies string::
X-OWA-CANARY=abc123 X-OWA-CANARY=abc123
CookieAuth1=def456 CookieAuth1=def456
... ...
Args: Args:
cookies: Session cookies in ``name=value`` format, one per line. cookies: Session cookies in ``name=value`` format, one per line.
Obtain from browser DevTools → Application → Cookies → copy all Obtain from browser DevTools → Application → Cookies → copy all
cookies as ``name=value`` pairs. cookies as ``name=value`` pairs.
Returns: Returns:
JSON result with success status and message. JSON result with success status and message.
""" """
client = _get_client(ctx) client = _get_client(ctx)
# Parse and validate cookies # Parse and validate cookies
cookies_str = cookies.strip() cookies_str = cookies.strip()
if not cookies_str: if not cookies_str:
return json.dumps( return json.dumps(
{ {
"success": False, "success": False,
"error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.",
} }
) )
# Save to disk # Save to disk
try: try:
client.cookie_file.write_text(cookies_str + "\n") client.cookie_file.write_text(cookies_str + "\n")
except Exception as e: except Exception as e:
return json.dumps( return json.dumps(
{"success": False, "error": f"Failed to write cookie file: {e}"} {"success": False, "error": f"Failed to write cookie file: {e}"}
) )
# Load into memory # Load into memory
try: try:
client.load_cookies_from_string(cookies_str) client.load_cookies_from_string(cookies_str)
except SessionExpiredError as e: except SessionExpiredError as e:
return json.dumps({"success": False, "error": str(e)}) return json.dumps({"success": False, "error": str(e)})
# Verify session # Verify session
if _session_is_valid(client): if _session_is_valid(client):
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"message": f"Logged in. Session cookies saved to {client.cookie_file}.", "message": f"Logged in. Session cookies saved to {client.cookie_file}.",
} }
) )
else: else:
return json.dumps( return json.dumps(
{ {
"success": False, "success": False,
"error": "Cookies loaded but session verification failed. The cookies may have expired.", "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 from owa_mcp.server_lifecycle import require_client
_DISTINGUISHED_NAMES = { _DISTINGUISHED_NAMES = {
"msgfolderroot", "msgfolderroot",
"inbox", "inbox",
"sentitems", "sentitems",
"drafts", "drafts",
"deleteditems", "deleteditems",
"junkemail", "junkemail",
"outbox", "outbox",
"calendar", "calendar",
"contacts", "contacts",
"tasks", "tasks",
"notes", "notes",
"journal", "journal",
"searchfolders", "searchfolders",
} }
_HEADER_TZ = { _HEADER_TZ = {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013", "RequestServerVersion": "Exchange2013",
"TimeZoneContext": { "TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange", "__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": { "TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange", "__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time", "Id": "Russian Standard Time",
},
}, },
},
} }
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context.""" """Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client) return require_client(app_ctx.client)
def _folder_id_dict(folder_id: str) -> dict: def _folder_id_dict(folder_id: str) -> dict:
"""Build a typed FolderId or DistinguishedFolderId dict.""" """Build a typed FolderId or DistinguishedFolderId dict."""
if folder_id.lower() in _DISTINGUISHED_NAMES: if folder_id.lower() in _DISTINGUISHED_NAMES:
return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id} return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id}
return {"__type": "FolderId:#Exchange", "Id": folder_id} return {"__type": "FolderId:#Exchange", "Id": folder_id}
@mcp.tool() @mcp.tool()
def check_session(ctx: Context = None) -> str: 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 Makes a lightweight GetFolder call on the inbox. Returns session
status, the mailbox display name, and cookie file path. status, the mailbox display name, and cookie file path.
Returns: Returns:
JSON object with authenticated (bool), mailbox name, and details. JSON object with authenticated (bool), mailbox name, and details.
""" """
client = _get_client(ctx) client = _get_client(ctx)
payload = { payload = {
"__type": "GetFolderJsonRequest:#Exchange", "__type": "GetFolderJsonRequest:#Exchange",
"Header": { "Header": {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013", "RequestServerVersion": "Exchange2013",
}, },
"Body": { "Body": {
"__type": "GetFolderRequest:#Exchange", "__type": "GetFolderRequest:#Exchange",
"FolderShape": { "FolderShape": {
"__type": "FolderResponseShape:#Exchange", "__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default", "BaseShape": "Default",
}, },
"FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}], "FolderIds": [
}, {"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
} ],
},
try: }
data = client.request("GetFolder", payload)
except Exception as e:
return json.dumps(
{
"authenticated": False,
"error": str(e),
"cookie_file": str(client.cookie_file),
}
)
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),
}
)
try:
data = client.request("GetFolder", payload)
except Exception as e:
return json.dumps( return json.dumps(
{ {
"authenticated": False, "authenticated": False,
"error": "Unexpected response", "error": str(e),
"cookie_file": str(client.cookie_file), "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() @mcp.tool()
def get_folders( def get_folders(
parent_folder_id: str = "msgfolderroot", parent_folder_id: str = "msgfolderroot",
recursive: bool = False, recursive: bool = False,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> str:
"""List mail folders from the Exchange mailbox. """List mail folders from the Exchange mailbox.
Args: Args:
parent_folder_id: Parent folder to list children of. parent_folder_id: Parent folder to list children of.
Defaults to "msgfolderroot" (top-level). Can be a Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID. distinguished folder name or a raw folder ID.
recursive: If True, traverse all subfolders recursively (Deep). recursive: If True, traverse all subfolders recursively (Deep).
If False, only list immediate children (Shallow). If False, only list immediate children (Shallow).
Returns: Returns:
JSON array of folder objects with: name, id, total_count, JSON array of folder objects with: name, id, total_count,
unread_count, child_folder_count. unread_count, child_folder_count.
""" """
client = _get_client(ctx) client = _get_client(ctx)
traversal = "Deep" if recursive else "Shallow" traversal = "Deep" if recursive else "Shallow"
# Determine parent folder id type # Determine parent folder id type
# Distinguished folder names are short lowercase strings # Distinguished folder names are short lowercase strings
parent_folder = _folder_id_dict(parent_folder_id) parent_folder = _folder_id_dict(parent_folder_id)
payload = { payload = {
"__type": "FindFolderJsonRequest:#Exchange", "__type": "FindFolderJsonRequest:#Exchange",
"Header": { "Header": {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013", "RequestServerVersion": "Exchange2013",
}, },
"Body": { "Body": {
"__type": "FindFolderRequest:#Exchange", "__type": "FindFolderRequest:#Exchange",
"FolderShape": { "FolderShape": {
"__type": "FolderResponseShape:#Exchange", "__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default", "BaseShape": "Default",
}, },
"ParentFolderIds": [parent_folder], "ParentFolderIds": [parent_folder],
"Traversal": traversal, "Traversal": traversal,
"Paging": { "Paging": {
"__type": "IndexedPageView:#Exchange", "__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning", "BasePoint": "Beginning",
"Offset": 0, "Offset": 0,
"MaxEntriesReturned": 200, "MaxEntriesReturned": 200,
}, },
}, },
} }
try: try:
data = client.request("FindFolder", payload) data = client.request("FindFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
folders = [] folders = []
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]: if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]: for f in msg["RootFolder"]["Folders"]:
folders.append( folders.append(
{ {
"name": f.get("DisplayName", "Unknown"), "name": f.get("DisplayName", "Unknown"),
"id": f.get("FolderId", {}).get("Id", ""), "id": f.get("FolderId", {}).get("Id", ""),
"total_count": f.get("TotalCount", 0), "total_count": f.get("TotalCount", 0),
"unread_count": f.get("UnreadCount", 0), "unread_count": f.get("UnreadCount", 0),
"child_folder_count": f.get("ChildFolderCount", 0), "child_folder_count": f.get("ChildFolderCount", 0),
} }
) )
return json.dumps(folders, ensure_ascii=False) return json.dumps(folders, ensure_ascii=False)
@mcp.tool() @mcp.tool()
def create_folder( def create_folder(
name: str, name: str,
parent_folder_id: str = "msgfolderroot", parent_folder_id: str = "msgfolderroot",
ctx: Context = None, ctx: Context = None,
) -> str: ) -> str:
"""Create a new mail folder. """Create a new mail folder.
Args: Args:
name: Display name for the new folder. name: Display name for the new folder.
parent_folder_id: Parent folder to create under. parent_folder_id: Parent folder to create under.
Defaults to "msgfolderroot" (top-level). Can be a Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID. distinguished folder name or a raw folder ID.
Returns: Returns:
JSON object with the created folder's id and name. JSON object with the created folder's id and name.
""" """
client = _get_client(ctx) client = _get_client(ctx)
payload = { payload = {
"__type": "CreateFolderJsonRequest:#Exchange", "__type": "CreateFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "CreateFolderRequest:#Exchange", "__type": "CreateFolderRequest:#Exchange",
"ParentFolderId": { "ParentFolderId": {
"__type": "TargetFolderId:#Exchange", "__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(parent_folder_id), "BaseFolderId": _folder_id_dict(parent_folder_id),
}, },
"Folders": [ "Folders": [
{ {
"__type": "Folder:#Exchange", "__type": "Folder:#Exchange",
"DisplayName": name, "DisplayName": name,
"FolderClass": "IPF.Note", "FolderClass": "IPF.Note",
} }
], ],
}, },
} }
try: try:
data = client.request_header_payload("CreateFolder", payload) data = client.request_header_payload("CreateFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"name": name, "name": name,
"id": folder.get("FolderId", {}).get("Id", ""), "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() @mcp.tool()
def rename_folder( def rename_folder(
folder_id: str, folder_id: str,
new_name: str, new_name: str,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> str:
"""Rename an existing mail folder. """Rename an existing mail folder.
Args: Args:
folder_id: The Exchange folder ID to rename (from get_folders). folder_id: The Exchange folder ID to rename (from get_folders).
new_name: New display name for the folder. new_name: New display name for the folder.
Returns: Returns:
JSON object with success status and new folder id. JSON object with success status and new folder id.
""" """
client = _get_client(ctx) client = _get_client(ctx)
payload = { payload = {
"__type": "UpdateFolderJsonRequest:#Exchange", "__type": "UpdateFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "UpdateFolderRequest:#Exchange", "__type": "UpdateFolderRequest:#Exchange",
"FolderChanges": [ "FolderChanges": [
{ {
"__type": "FolderChange:#Exchange", "__type": "FolderChange:#Exchange",
"FolderId": { "FolderId": {
"__type": "FolderId:#Exchange", "__type": "FolderId:#Exchange",
"Id": folder_id, "Id": folder_id,
}, },
"Updates": [ "Updates": [
{ {
"__type": "SetFolderField:#Exchange", "__type": "SetFolderField:#Exchange",
"Path": { "Path": {
"__type": "PropertyUri:#Exchange", "__type": "PropertyUri:#Exchange",
"FieldURI": "FolderDisplayName", "FieldURI": "FolderDisplayName",
}, },
"Folder": { "Folder": {
"__type": "Folder:#Exchange", "__type": "Folder:#Exchange",
"DisplayName": new_name, "DisplayName": new_name,
}, },
} }
], ],
} }
], ],
}, },
} }
try: try:
data = client.request_header_payload("UpdateFolder", payload) data = client.request_header_payload("UpdateFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"new_name": new_name, "new_name": new_name,
"id": folder.get("FolderId", {}).get("Id", ""), "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() @mcp.tool()
def empty_folder( def empty_folder(
folder_id: str, folder_id: str,
delete_sub_folders: bool = False, delete_sub_folders: bool = False,
permanent: bool = False, permanent: bool = False,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> str:
"""Empty all items from a mail folder. """Empty all items from a mail folder.
Args: Args:
folder_id: The Exchange folder ID to empty (from get_folders). folder_id: The Exchange folder ID to empty (from get_folders).
delete_sub_folders: If True, also delete sub-folders. Default False. delete_sub_folders: If True, also delete sub-folders. Default False.
permanent: If True, permanently delete items (HardDelete). permanent: If True, permanently delete items (HardDelete).
Otherwise move to Deleted Items. Default False. Otherwise move to Deleted Items. Default False.
Returns: Returns:
JSON object with success status. JSON object with success status.
""" """
client = _get_client(ctx) client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems" delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
payload = { payload = {
"__type": "EmptyFolderJsonRequest:#Exchange", "__type": "EmptyFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "EmptyFolderRequest:#Exchange", "__type": "EmptyFolderRequest:#Exchange",
"FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"DeleteType": delete_type, "DeleteType": delete_type,
"DeleteSubFolders": delete_sub_folders, "DeleteSubFolders": delete_sub_folders,
"SuppressReadReceipt": True, "SuppressReadReceipt": True,
}, },
} }
try: try:
data = client.request_header_payload("EmptyFolder", payload) data = client.request_header_payload("EmptyFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
for msg in client.extract_items(data): for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success": if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "folder_id": folder_id}) 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() @mcp.tool()
def delete_folder( def delete_folder(
folder_id: str, folder_id: str,
permanent: bool = False, permanent: bool = False,
ctx: Context = None, ctx: Context = None,
) -> str: ) -> str:
"""Delete a mail folder. """Delete a mail folder.
Args: Args:
folder_id: The Exchange folder ID to delete (from get_folders). folder_id: The Exchange folder ID to delete (from get_folders).
permanent: If True, permanently delete (HardDelete). permanent: If True, permanently delete (HardDelete).
Otherwise move to Deleted Items. Default False. Otherwise move to Deleted Items. Default False.
Returns: Returns:
JSON object with success status. JSON object with success status.
""" """
client = _get_client(ctx) client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems" delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
payload = { payload = {
"__type": "DeleteFolderJsonRequest:#Exchange", "__type": "DeleteFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "DeleteFolderRequest:#Exchange", "__type": "DeleteFolderRequest:#Exchange",
"FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"DeleteType": delete_type, "DeleteType": delete_type,
}, },
} }
try: try:
data = client.request_header_payload("DeleteFolder", payload) data = client.request_header_payload("DeleteFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
for msg in client.extract_items(data): for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success": if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "folder_id": folder_id}) 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() @mcp.tool()
def move_folder( def move_folder(
folder_id: str, folder_id: str,
target_parent_folder_id: str = "msgfolderroot", target_parent_folder_id: str = "msgfolderroot",
ctx: Context = None, ctx: Context = None,
) -> str: ) -> str:
"""Move a mail folder to a different parent folder. """Move a mail folder to a different parent folder.
Args: Args:
folder_id: The Exchange folder ID to move (from get_folders). folder_id: The Exchange folder ID to move (from get_folders).
target_parent_folder_id: Destination parent folder. target_parent_folder_id: Destination parent folder.
Defaults to "msgfolderroot" (top-level). Can be a Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID. distinguished folder name or a raw folder ID.
Returns: Returns:
JSON object with success status and new folder ID. JSON object with success status and new folder ID.
""" """
client = _get_client(ctx) client = _get_client(ctx)
payload = { payload = {
"__type": "MoveFolderJsonRequest:#Exchange", "__type": "MoveFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "MoveFolderRequest:#Exchange", "__type": "MoveFolderRequest:#Exchange",
"FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
"ToFolderId": { "ToFolderId": {
"__type": "TargetFolderId:#Exchange", "__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(target_parent_folder_id), "BaseFolderId": _folder_id_dict(target_parent_folder_id),
}, },
}, },
} }
try: try:
data = client.request_header_payload("MoveFolder", payload) data = client.request_header_payload("MoveFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"folder_id": folder.get("FolderId", {}).get("Id", ""), "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: def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context.""" """Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client) return require_client(app_ctx.client)
def _parse_person(resolution: dict) -> dict: 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(). Preserves the exact logic from find-person.py parse_person().
""" """
mailbox = resolution.get("Mailbox", {}) mailbox = resolution.get("Mailbox", {})
contact = resolution.get("Contact", {}) contact = resolution.get("Contact", {})
person = { person = {
"name": mailbox.get("Name", contact.get("DisplayName", "")), "name": mailbox.get("Name", contact.get("DisplayName", "")),
"email": mailbox.get("EmailAddress", ""), "email": mailbox.get("EmailAddress", ""),
"type": mailbox.get("MailboxType", ""), "type": mailbox.get("MailboxType", ""),
"first_name": contact.get("GivenName", ""), "first_name": contact.get("GivenName", ""),
"last_name": contact.get("Surname", ""), "last_name": contact.get("Surname", ""),
"job_title": contact.get("JobTitle", ""), "job_title": contact.get("JobTitle", ""),
"department": contact.get("Department", ""), "department": contact.get("Department", ""),
"company": contact.get("CompanyName", ""), "company": contact.get("CompanyName", ""),
"office": contact.get("OfficeLocation", ""), "office": contact.get("OfficeLocation", ""),
"manager": "", "manager": "",
"manager_email": "", "manager_email": "",
"phones": {}, "phones": {},
"address": {}, "address": {},
"direct_reports": [], "direct_reports": [],
"alias": contact.get("Alias", ""), "alias": contact.get("Alias", ""),
} }
# Phone numbers # Phone numbers
for phone in contact.get("PhoneNumbers", []): for phone in contact.get("PhoneNumbers", []):
key = phone.get("Key", "") key = phone.get("Key", "")
number = phone.get("PhoneNumber", "") number = phone.get("PhoneNumber", "")
if number: if number:
person["phones"][key] = number person["phones"][key] = number
# Physical address # Physical address
for addr in contact.get("PhysicalAddresses", []): for addr in contact.get("PhysicalAddresses", []):
if addr.get("Key") == "Business": if addr.get("Key") == "Business":
parts = [] parts = []
if addr.get("Street"): if addr.get("Street"):
parts.append(addr["Street"]) parts.append(addr["Street"])
if addr.get("City"): if addr.get("City"):
parts.append(addr["City"]) parts.append(addr["City"])
if addr.get("PostalCode"): if addr.get("PostalCode"):
parts.append(addr["PostalCode"]) parts.append(addr["PostalCode"])
if addr.get("CountryOrRegion"): if addr.get("CountryOrRegion"):
parts.append(addr["CountryOrRegion"]) parts.append(addr["CountryOrRegion"])
if parts: if parts:
person["address"] = { person["address"] = {
"street": addr.get("Street", ""), "street": addr.get("Street", ""),
"city": addr.get("City", ""), "city": addr.get("City", ""),
"postal_code": addr.get("PostalCode", ""), "postal_code": addr.get("PostalCode", ""),
"country": addr.get("CountryOrRegion", ""), "country": addr.get("CountryOrRegion", ""),
"full": ", ".join(parts), "full": ", ".join(parts),
} }
# Manager # Manager
manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {}) manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {})
if manager_data: if manager_data:
person["manager"] = manager_data.get("Name", "") person["manager"] = manager_data.get("Name", "")
person["manager_email"] = manager_data.get("EmailAddress", "") person["manager_email"] = manager_data.get("EmailAddress", "")
elif contact.get("Manager"): elif contact.get("Manager"):
person["manager"] = contact.get("Manager", "") person["manager"] = contact.get("Manager", "")
# Direct reports # Direct reports
for report in contact.get("DirectReports", []): for report in contact.get("DirectReports", []):
person["direct_reports"].append( person["direct_reports"].append(
{ {
"name": report.get("Name", ""), "name": report.get("Name", ""),
"email": report.get("EmailAddress", ""), "email": report.get("EmailAddress", ""),
} }
) )
return person return person
@mcp.tool() @mcp.tool()
def find_person(query: str, ctx: Context) -> str: 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 Looks up employees by name, email, department, or keyword using the
Exchange ResolveNames API against Active Directory. Exchange ResolveNames API against Active Directory.
Args: Args:
query: Name, email address, or keyword to search for. query: Name, email address, or keyword to search for.
Returns: Returns:
JSON array of matching people with contact details (name, email, JSON array of matching people with contact details (name, email,
job_title, department, company, office, phones, address, manager, job_title, department, company, office, phones, address, manager,
direct_reports, alias). direct_reports, alias).
""" """
client = _get_client(ctx) client = _get_client(ctx)
try: try:
resolutions = client.resolve_names(query) resolutions = client.resolve_names(query)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
if not resolutions: if not resolutions:
return json.dumps([]) return json.dumps([])
people = [_parse_person(r) for r in resolutions] people = [_parse_person(r) for r in resolutions]
return json.dumps(people, ensure_ascii=False) 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: 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, Strips scripts, styles, converts <br>/<p>/<div> to newlines,
removes remaining tags, and unescapes HTML entities. removes remaining tags, and unescapes HTML entities.
""" """
if not html_content: if not html_content:
return "" return ""
text = re.sub( text = re.sub(
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE r"<script[^>]*>.*?</script>",
) "",
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE) html_content,
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE) flags=re.DOTALL | 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(
text = re.sub(r"<div[^>]*>", "\n", text, flags=re.IGNORECASE) r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE
text = re.sub(r"<[^>]+>", "", text) )
text = html.unescape(text) text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"\n\s*\n", "\n\n", text) text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
return text.strip() 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]: 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:, Finds <a href="...">text</a> patterns, excludes mailto:, cid:,
javascript:, and fragment-only (#) links. Deduplicates by URL. javascript:, and fragment-only (#) links. Deduplicates by URL.
Returns list of {url, text} dicts. Returns list of {url, text} dicts.
""" """
if not html_content: if not html_content:
return [] return []
# Match <a ...href="URL"...>text</a> # Match <a ...href="URL"...>text</a>
pattern = re.compile( pattern = re.compile(
r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
re.DOTALL | re.IGNORECASE, re.DOTALL | re.IGNORECASE,
) )
seen: set[str] = set() seen: set[str] = set()
links: list[dict] = [] links: list[dict] = []
for url_raw, text_raw in pattern.findall(html_content): for url_raw, text_raw in pattern.findall(html_content):
url = html.unescape(url_raw).strip() url = html.unescape(url_raw).strip()
# Skip non-http links # Skip non-http links
if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#": if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#":
continue continue
# Skip fragment-only links # Skip fragment-only links
if url.startswith("#"): if url.startswith("#"):
continue continue
if url in seen: if url in seen:
continue continue
seen.add(url) seen.add(url)
# Clean link text: strip tags and whitespace # Clean link text: strip tags and whitespace
text = re.sub(r"<[^>]+>", "", text_raw) text = re.sub(r"<[^>]+>", "", text_raw)
text = html.unescape(text).strip() 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: 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. Strips timezone suffixes (Z, +offset) for cleaner display.
""" """
if not dt_str: if not dt_str:
return "" return ""
if "T" in dt_str: if "T" in dt_str:
date_part, time_part = dt_str.split("T", 1) date_part, time_part = dt_str.split("T", 1)
time_part = time_part.split("Z")[0].split("+")[0] time_part = time_part.split("Z")[0].split("+")[0]
return f"{date_part} {time_part[:5]}" return f"{date_part} {time_part[:5]}"
return dt_str return dt_str
def format_date(dt_str: str) -> str: def format_date(dt_str: str) -> str:
"""Extract the date portion from an ISO datetime string.""" """Extract the date portion from an ISO datetime string."""
if not dt_str: if not dt_str:
return "" return ""
if "T" in dt_str: if "T" in dt_str:
return dt_str.split("T")[0] return dt_str.split("T")[0]
return dt_str return dt_str
def parse_date(date_str: str) -> datetime: 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. 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"] formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"]
for fmt in formats: for fmt in formats:
try: try:
return datetime.strptime(date_str, fmt) return datetime.strptime(date_str, fmt)
except ValueError: except ValueError:
continue continue
raise ValueError(f"Could not parse date: {date_str}") raise ValueError(f"Could not parse date: {date_str}")
def parse_iso_datetime(dt_str: str) -> datetime: 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, Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats,
stripping any timezone suffix. stripping any timezone suffix.
""" """
if "T" in dt_str: if "T" in dt_str:
clean = dt_str.split("Z")[0].split("+")[0] clean = dt_str.split("Z")[0].split("+")[0]
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S") return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S")
return datetime.strptime(dt_str, "%Y-%m-%d") return datetime.strptime(dt_str, "%Y-%m-%d")
def format_attendee(name: str, email: str) -> str: def format_attendee(name: str, email: str) -> str:
"""Format an attendee as 'Name <email>' or just the email.""" """Format an attendee as 'Name <email>' or just the email."""
if name and email and not email.startswith("/O="): if name and email and not email.startswith("/O="):
return f"{name} <{email}>" return f"{name} <{email}>"
return name or email or "" return name or email or ""