From a9497625b1b2048d68a20611cce2aede1817ca0c Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 01:46:14 +0300 Subject: [PATCH 1/8] w --- .gitignore | 12 + LICENSE | 21 + README.md | 287 ++++++ exchange_mcp/__init__.py | 1 + exchange_mcp/models.py | 109 +++ exchange_mcp/owa_client.py | 502 +++++++++++ exchange_mcp/server.py | 240 +++++ exchange_mcp/server_lifecycle.py | 253 ++++++ exchange_mcp/tools/__init__.py | 1 + exchange_mcp/tools/analytics.py | 422 +++++++++ exchange_mcp/tools/auth.py | 117 +++ exchange_mcp/tools/availability.py | 594 +++++++++++++ exchange_mcp/tools/calendar.py | 1319 ++++++++++++++++++++++++++++ exchange_mcp/tools/email.py | 1176 +++++++++++++++++++++++++ exchange_mcp/tools/folders.py | 431 +++++++++ exchange_mcp/tools/people.py | 119 +++ exchange_mcp/utils.py | 133 +++ pyproject.toml | 34 + 18 files changed, 5771 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 exchange_mcp/__init__.py create mode 100644 exchange_mcp/models.py create mode 100644 exchange_mcp/owa_client.py create mode 100644 exchange_mcp/server.py create mode 100644 exchange_mcp/server_lifecycle.py create mode 100644 exchange_mcp/tools/__init__.py create mode 100644 exchange_mcp/tools/analytics.py create mode 100644 exchange_mcp/tools/auth.py create mode 100644 exchange_mcp/tools/availability.py create mode 100644 exchange_mcp/tools/calendar.py create mode 100644 exchange_mcp/tools/email.py create mode 100644 exchange_mcp/tools/folders.py create mode 100644 exchange_mcp/tools/people.py create mode 100644 exchange_mcp/utils.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9892a8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +.credentials.enc +.mcp.json +.mcpregistry_* +.playwright-mcp/ +.salt +*.egg-info/ +*.pyc +build +dist/ +instructions.md +session-cookies.txt \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4765486 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nhype + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c61f58f --- /dev/null +++ b/README.md @@ -0,0 +1,287 @@ +# OWA MCP Server + +MCP (Model Context Protocol) server for any Microsoft Exchange / OWA (Outlook Web Access) deployment. Gives LLM agents access to email, calendar, directory search, folders, availability, and meeting analytics via 30 tools. + +Works with any on-premise or hosted Exchange server that exposes OWA. + +## Quick Start + +```bash +# Install +pip install -e . + +# Add to your MCP client config with --owa-url +# Then use the login tool to paste your session cookies +``` + +## Install + +Add to your MCP client config. Replace `https://owa.example.com` with your OWA URL. + +**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "exchange": { + "command": "uvx", + "args": [ + "exchange-mcp-server", + "--owa-url", "https://owa.example.com" + ] + } + } +} +``` + +**Cursor** (`.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "exchange": { + "command": "uvx", + "args": [ + "exchange-mcp-server", + "--owa-url", "https://owa.example.com" + ] + } + } +} +``` + +**Claude Code** (`.mcp.json`): + +```json +{ + "mcpServers": { + "exchange": { + "command": "uvx", + "args": [ + "exchange-mcp-server", + "--owa-url", "https://owa.example.com" + ] + } + } +} +``` + +## CLI Arguments + +| Argument | Default | Required | Description | +|---|---|---|---| +| `--owa-url` | — | Yes | Base URL of the OWA instance (e.g. `https://owa.example.com`) | +| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | +| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | +| `--token-hash` | — | For HTTP | SHA-256 hash of the bearer token | +| `--port` | `8000` | — | HTTP/SSE listener port | +| `--host` | `127.0.0.1` | — | HTTP/SSE listener host | +| `--enabled-tools` | — | No | Comma-separated whitelist of tool names | +| `--disabled-tools` | — | No | Comma-separated blacklist of tool names | + +### Tool filtering examples + +```bash +# Hide only the most dangerous tools +exchange-mcp-server --owa-url https://owa.example.com \ + --disabled-tools "send_email,delete_email,cancel_meeting" + +# Allow only email and calendar tools +exchange-mcp-server --owa-url https://owa.example.com \ + --enabled-tools "get_emails,get_email,send_email,get_calendar_events,create_meeting" + +# Whitelist a set, then remove one from it +exchange-mcp-server --owa-url https://owa.example.com \ + --enabled-tools "get_emails,get_email,send_email,delete_email" \ + --disabled-tools "delete_email" +# → only get_emails, get_email, send_email remain +``` + +## HTTP Transport + +Run the server over HTTP (streamable-http or SSE) instead of stdio. This is +useful when the MCP client cannot spawn a local process, or when you want to +expose the server on a network. + +### Installation + +```bash +pip install -e ".[http]" # adds uvicorn + sse-starlette +``` + +### Start the server + +```bash +# Compute the token hash once (store only the hash, never the plaintext token) +TOKEN_HASH=$(echo -n 'my-secret-token' | sha256sum | cut -d' ' -f1) + +# stdio (default, for local MCP clients) +exchange-mcp-server --owa-url https://owa.example.com + +# streamable-http (recommended for HTTP) +exchange-mcp-server \ + --owa-url https://owa.example.com \ + --transport streamable-http \ + --token-hash "$TOKEN_HASH" \ + --port 8000 \ + --host 127.0.0.1 + +# SSE +exchange-mcp-server \ + --owa-url https://owa.example.com \ + --transport sse \ + --token-hash "$TOKEN_HASH" \ + --port 8000 +``` + +### CLI arguments + +| Argument | Default | Required | Description | +|---|---|---|---| +| `--owa-url` | — | Yes | Base URL of the OWA instance (e.g. `https://owa.example.com`) | +| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | +| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | +| `--token-hash` | — | For HTTP | SHA-256 hash of the bearer token | +| `--port` | `8000` | — | HTTP/SSE listener port | +| `--host` | `127.0.0.1` | — | HTTP/SSE listener host | +| `--enabled-tools` | — | No | Comma-separated whitelist of tool names | +| `--disabled-tools` | — | No | Comma-separated blacklist of tool names | + +### Client configuration (HTTP) + +```json +{ + "mcpServers": { + "exchange": { + "url": "http://127.0.0.1:8000/mcp", + "headers": { + "Authorization": "Bearer my-secret-token" + } + } + } +} +``` + +### Authentication + +The server stores only the SHA-256 hash of your token — the plaintext token +never touches the filesystem. Every HTTP request must include: + +``` +Authorization: Bearer +``` + +The server hashes the incoming token and compares it with the stored hash. +Pass the hash via `--token-hash`: + +```bash +echo -n 'my-secret-token' | sha256sum +# → 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 +``` + +### Security notes + +- Always use a strong random token. +- Bind to `127.0.0.1` unless you have a reverse proxy / firewall. +- For `streamable-http` the client must send `Accept: text/event-stream`. +- The `--token-hash` is required whenever `--transport` is not `stdio`. + +## Login + +The `login` MCP tool accepts session cookies that you copy from your browser. + +1. Log into OWA in your browser. +2. Open DevTools (F12) → Application → Cookies → select your OWA domain. +3. Copy all cookies as `name=value` pairs (one per line). +4. Call the `login` tool with the cookies string. + +Example cookies string: + +``` +X-OWA-CANARY=abc123 +CookieAuth1=def456 +... +``` + +``` +login(cookies="X-OWA-CANARY=abc123\nCookieAuth1=def456\n...") +``` + +On success, cookies are saved to `session-cookies.txt` and all tools become usable. When the session expires, repeat the steps. + +## Tools (30) + +### Email (10) +| Tool | Description | +|---|---| +| `get_emails` | List emails from a folder with filtering | +| `get_email` | Get full email content by ID | +| `send_email` | Send a new email | +| `reply_email` | Reply to an email | +| `forward_email` | Forward an email | +| `delete_email` | Delete an email | +| `move_email` | Move email to another folder | +| `mark_email_read` | Mark email as read/unread | +| `download_attachments` | Download file attachments from an email | +| `get_email_links` | Extract hyperlinks from an email body | + +### Calendar (7) +| Tool | Description | +|---|---| +| `get_calendar_events` | Get events in a date range (supports recurring expansion) | +| `create_meeting` | Create a meeting with attendees | +| `update_meeting` | Update an existing meeting | +| `cancel_meeting` | Cancel a meeting and notify attendees | +| `respond_to_meeting` | Accept, decline, or tentatively accept | +| `download_event_attachments` | Download file attachments from a calendar event | +| `get_event_links` | Extract hyperlinks from the event description | + +### Directory (1) +| Tool | Description | +|---|---| +| `find_person` | Search people in Active Directory | + +### Folders (7) +| Tool | Description | +|---|---| +| `get_folders` | List mail folders with unread counts | +| `create_folder` | Create a new mail folder | +| `rename_folder` | Rename an existing folder | +| `empty_folder` | Empty all items from a folder | +| `delete_folder` | Delete a mail folder | +| `move_folder` | Move a folder to a different parent | +| `check_session` | Check if the OWA session is authenticated | + +### Availability (2) +| Tool | Description | +|---|---| +| `find_free_time` | Find free slots in your calendar | +| `find_meeting_time` | Find common free slots for multiple people | + +### Analytics (2) +| Tool | Description | +|---|---| +| `get_meeting_stats` | Meeting count statistics for multiple people | +| `get_meeting_contacts` | Connection matrix — who you meet with most | + +### Auth (1) +| Tool | Description | +|---|---| +| `login` | Authenticate to OWA using browser session cookies | + +## Files + +``` +exchange_mcp/ + server.py # FastMCP server entry point + owa_client.py # OWA HTTP client + tools/ + email.py # Email tools + calendar.py # Calendar tools + people.py # Directory search + folders.py # Folder management & session check + availability.py # Free time / meeting time + analytics.py # Meeting stats & contacts + auth.py # Login tool +pyproject.toml # Package config +``` diff --git a/exchange_mcp/__init__.py b/exchange_mcp/__init__.py new file mode 100644 index 0000000..ef83d87 --- /dev/null +++ b/exchange_mcp/__init__.py @@ -0,0 +1 @@ +"""Exchange MCP Server - access OWA email, calendar, directory via MCP.""" diff --git a/exchange_mcp/models.py b/exchange_mcp/models.py new file mode 100644 index 0000000..7d23b9f --- /dev/null +++ b/exchange_mcp/models.py @@ -0,0 +1,109 @@ +"""Return type definitions for MCP tools. + +Uses TypedDict for lightweight structured return types without +requiring Pydantic at the model layer. +""" + +from typing import TypedDict + + +class Email(TypedDict, total=False): + """An email message.""" + subject: str + sender: str + sender_name: str + date: str + is_read: bool + has_attachments: bool + has_links: bool + item_id: str + size: int + is_meeting: bool + item_type: str + to: list[str] + cc: list[str] + body: str + body_type: str + attachments: list[dict] + # Meeting-specific fields + location: str + start: str + end: str + attendees_required: list[str] + attendees_optional: list[str] + + +class CalendarEvent(TypedDict, total=False): + """A calendar event / meeting.""" + subject: str + start: str + end: str + location: str + is_all_day: bool + is_cancelled: bool + is_meeting: bool + is_recurring: bool + organizer: str + organizer_email: str + my_response: str + item_id: str + body: str + attendees_required: list[str] + attendees_optional: list[str] + + +class Person(TypedDict, total=False): + """A person from the Exchange directory.""" + name: str + email: str + mailbox_type: str + first_name: str + last_name: str + job_title: str + department: str + company: str + office: str + alias: str + manager: str + manager_email: str + phones: dict[str, str] + address: str + direct_reports: list[dict[str, str]] + + +class Folder(TypedDict, total=False): + """A mail folder.""" + name: str + id: str + total_count: int + unread_count: int + child_folder_count: int + + +class FreeSlot(TypedDict): + """A free time slot.""" + date: str + start: str + end: str + duration_minutes: int + + +class Availability(TypedDict, total=False): + """Availability data for a single attendee.""" + email: str + busy_slots: int + free_slots: int + merged_freebusy: str + + +class MeetingResult(TypedDict, total=False): + """Result of creating/updating a meeting.""" + success: bool + subject: str + date: str + start_time: str + end_time: str + location: str + required_attendees: list[str] + optional_attendees: list[str] + error: str diff --git a/exchange_mcp/owa_client.py b/exchange_mcp/owa_client.py new file mode 100644 index 0000000..b6b8bd3 --- /dev/null +++ b/exchange_mcp/owa_client.py @@ -0,0 +1,502 @@ +"""Centralized OWA HTTP client for Exchange API. + +Extracts and unifies the duplicated request/cookie/session patterns +from all standalone scripts into a single reusable client. + +Uses the "x-owa-urlpostdata" request pattern: JSON payload is URL-encoded +and sent in the ``x-owa-urlpostdata`` header with an empty POST body, +mirroring what the OWA web frontend sends. +""" + +import json +import os +import time +from urllib.parse import quote, urlparse +from pathlib import Path + +import requests + + +class SessionExpiredError(Exception): + """Raised when the OWA session has expired (HTTP 401/440 or HTML redirect).""" + pass + + +# Map common folder names (English + Russian) to OWA distinguished folder IDs +DISTINGUISHED_FOLDERS = { + "inbox": "inbox", + "входящие": "inbox", + "sent": "sentitems", + "отправленные": "sentitems", + "drafts": "drafts", + "черновики": "drafts", + "deleted": "deleteditems", + "удаленные": "deleteditems", + "junk": "junkemail", + "нежелательная почта": "junkemail", + "outbox": "outbox", + "исходящие": "outbox", + "calendar": "calendar", + "календарь": "calendar", +} + + +class OWAClient: + """HTTP client for OWA (Outlook Web Access) JSON API. + + Handles cookie loading, CSRF token extraction, request construction, + session expiry detection, and automatic cookie reload on first failure. + """ + + def __init__( + self, + cookie_file: str | None = None, + owa_url: str | None = None, + ): + self.cookie_file = Path( + cookie_file + or os.environ.get("EXCHANGE_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 _add_canary_header(self, headers: dict) -> dict: + """Extract X-OWA-CANARY from session cookies and add to headers.""" + canary = next( + (c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), "" + ) + if canary: + headers["x-owa-canary"] = canary + return headers + + @property + def _client_id(self) -> str: + """Extract ClientId from session cookies.""" + cid = next( + (c.value for c in self._session.cookies if c.name == "ClientId"), "" + ) + return cid if cid else "00000000000000000000000000000000" + + # ------------------------------------------------------------------ + # Cookie / session helpers + # ------------------------------------------------------------------ + + def _load_cookies(self) -> None: + """Read session-cookies.txt (name=value per line) and load into session.""" + if not self.cookie_file.exists(): + raise SessionExpiredError( + f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate." + ) + + raw = self.cookie_file.read_text().strip() + cookies: dict[str, str] = {} + for line in raw.split("\n"): + if "=" in line: + name, value = line.split("=", 1) + cookies[name] = value + + if not cookies: + raise SessionExpiredError("Cookie file is empty. Use the login MCP tool to authenticate.") + + self._session = requests.Session() + self._session.cookies.update(cookies) + self._loaded = True + + def _save_cookies(self) -> None: + """Write the current session cookie jar to the cookie file. + + Called automatically after every successful request so that the + on-disk cookies always reflect the freshest ``Set-Cookie`` values + returned by the server. + """ + lines = [f"{c.name}={c.value}" for c in self._session.cookies] + self.cookie_file.write_text("\n".join(lines)) + self._loaded = True + + def _ensure_loaded(self) -> None: + """Load cookies on first use and refresh session from the main page.""" + if not self._loaded: + self._load_cookies() + self._refresh_session() + + def load_cookies_from_string(self, cookies_str: str) -> None: + """Load cookies from a name=value string (one per line) into memory.""" + cookies: dict[str, str] = {} + for line in cookies_str.strip().split("\n"): + if "=" in line: + name, value = line.split("=", 1) + cookies[name] = value + + if not cookies: + raise SessionExpiredError("No cookies in provided data.") + + self._session = requests.Session() + self._session.cookies.update(cookies) + self._loaded = True + + # ------------------------------------------------------------------ + # Core request method + # ------------------------------------------------------------------ + + def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: + """POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1. + + On session expiry (401, 440, or text/html response), reloads cookies + once and retries. If that also fails, raises SessionExpiredError. + + Returns the parsed JSON response dict. + """ + self._ensure_loaded() + + try: + data = self._do_request(action, payload, timeout=timeout) + return data + except SessionExpiredError: + raise + + def _refresh_session(self) -> None: + """Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY).""" + try: + resp = self._session.get( + f"{self.owa_url}/owa/", + headers={ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ) + }, + timeout=10, + ) + # Extract X-OWA-CANARY from Set-Cookie if present + set_cookie = resp.headers.get("Set-Cookie", "") + if "X-OWA-CANARY=" in set_cookie: + import re + m = re.search(r'X-OWA-CANARY=([^;]+)', set_cookie) + if m: + host = urlparse(self.owa_url).hostname or "owa.b1.ru" + self._session.cookies.set( + "X-OWA-CANARY", m.group(1), domain=host + ) + # Persist any cookie changes + self._save_cookies() + except Exception: + pass # non-critical + + def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: + """Execute a single OWA API request. + + Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload + is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an + empty POST body, plus a full set of OWA-specific headers extracted + from the browser's actual network traffic. + """ + from urllib.parse import urlparse + + url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1" + + # URL-encode the JSON payload for the x-owa-urlpostdata header + url_post_data = quote(json.dumps(payload, separators=(",", ":"))) + + # Build headers — start with OWA-required headers + headers = self._fresh_headers(action) + headers["x-owa-urlpostdata"] = url_post_data + # Add canary from cookies + headers = self._add_canary_header(headers) + + # Add extra cookies that the OWA frontend always sends + host = urlparse(self.owa_url).hostname or "owa.b1.ru" + for name, val in [("PrivateComputer", "true"), ("PBack", "0")]: + self._session.cookies.set(name, val, domain=host) + + try: + resp = self._session.post(url, data="", headers=headers, timeout=timeout) + except requests.exceptions.RequestException as exc: + raise SessionExpiredError(f"Request failed: {exc}") from exc + + # Detect session expiry + if resp.status_code in (401, 440): + raise SessionExpiredError("Session expired (HTTP {}).".format(resp.status_code)) + + if "text/html" in resp.headers.get("Content-Type", ""): + body_snippet = resp.text[:300] if resp.text else "" + raise SessionExpiredError( + f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). " + f"Snippet: {body_snippet}" + ) + + # Parse JSON + try: + data = resp.json() + except (ValueError, requests.exceptions.JSONDecodeError) as exc: + raise SessionExpiredError( + f"Unexpected response (HTTP {resp.status_code}). " + "Session may have expired." + ) from exc + + # Persist any Set-Cookie updates back to disk so the next request + # (or a server restart) picks up the freshest session tokens. + self._save_cookies() + return data + + def request_header_payload( + self, action: str, payload: dict, *, timeout: int = 30 + ) -> dict: + """POST with payload in x-owa-urlpostdata header (empty body). + + Alias for :meth:`request` — both methods now use the same + ``x-owa-urlpostdata`` format that mirrors the OWA web frontend. + """ + return self.request(action, payload, timeout=timeout) + + # ------------------------------------------------------------------ + # File download (attachments) + # ------------------------------------------------------------------ + + def download_file( + self, attachment_id: str, *, timeout: int = 60 + ) -> tuple[bytes, str, str]: + """Download a file attachment by its AttachmentId. + + Uses the OWA GetFileAttachment endpoint (direct GET). + + Returns: + (content_bytes, filename, content_type) + """ + self._ensure_loaded() + + from urllib.parse import quote + + url = ( + f"{self.owa_url}/owa/service.svc/s/GetFileAttachment" + f"?id={quote(attachment_id)}" + ) + + try: + resp = self._session.get(url, timeout=timeout) + except requests.exceptions.RequestException as exc: + raise SessionExpiredError(f"Download failed: {exc}") from exc + + if resp.status_code in (401, 440): + raise SessionExpiredError( + f"Session expired (HTTP {resp.status_code})." + ) + + if "text/html" in resp.headers.get("Content-Type", ""): + raise SessionExpiredError( + "Session expired (HTML response on attachment download)." + ) + + # Persist fresh cookies from the download response + self._save_cookies() + + # Parse filename from Content-Disposition header + filename = "attachment" + cd = resp.headers.get("Content-Disposition", "") + if cd: + import re as _re + from urllib.parse import unquote + + # Try filename*= (RFC 5987) first, then filename= + match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd) + if match: + filename = unquote(match.group(1).strip()) + else: + match = _re.search(r'filename="?([^";]+)"?', cd) + if match: + filename = unquote(match.group(1).strip()) + + content_type = resp.headers.get("Content-Type", "application/octet-stream") + + return resp.content, filename, content_type + + # ------------------------------------------------------------------ + # Convenience: extract response items + # ------------------------------------------------------------------ + + @staticmethod + def extract_items(data: dict) -> list[dict]: + """Extract Items from standard OWA response envelope. + + Response shape: data["Body"]["ResponseMessages"]["Items"] + """ + try: + return data["Body"]["ResponseMessages"]["Items"] + except (KeyError, TypeError): + return [] + + # ------------------------------------------------------------------ + # Folder helpers + # ------------------------------------------------------------------ + + def get_folder_id(self, folder_name: str) -> str | None: + """Resolve a folder name to its Exchange folder ID. + + Supports distinguished folder names (inbox, sentitems, drafts, etc.) + in both English and Russian, plus custom folder names looked up + via FindFolder on msgfolderroot. + """ + folder_lower = folder_name.lower() + + # Check distinguished folders first + distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower) + if distinguished_id: + payload = { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": distinguished_id, + } + ], + }, + } + + data = self.request("GetFolder", payload) + for msg in self.extract_items(data): + if "Folders" in msg: + for f in msg["Folders"]: + fid = f.get("FolderId", {}).get("Id") + if fid: + return fid + + # Fall back to searching custom folders by name + payload = { + "__type": "FindFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "Default", + }, + "ParentFolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "msgfolderroot", + } + ], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": 0, + "MaxEntriesReturned": 200, + }, + }, + } + + data = self.request("FindFolder", payload) + for msg in self.extract_items(data): + if "RootFolder" in msg and "Folders" in msg["RootFolder"]: + for f in msg["RootFolder"]["Folders"]: + if f.get("DisplayName", "").lower() == folder_lower: + return f.get("FolderId", {}).get("Id") + + return None + + # ------------------------------------------------------------------ + # ResolveNames (directory search / attendee resolution) + # ------------------------------------------------------------------ + + def resolve_names( + self, query: str, *, full_contact: bool = True + ) -> list[dict]: + """Call ResolveNames to search the directory. + + Returns the list of Resolution dicts from the API, each containing + Mailbox and optionally Contact data. + """ + payload = { + "__type": "ResolveNamesJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "ResolveNamesRequest:#Exchange", + "UnresolvedEntry": query, + "ReturnFullContactData": full_contact, + "SearchScope": "ActiveDirectoryContacts", + "ContactDataShape": "AllProperties" if full_contact else "Default", + }, + } + + data = self.request("ResolveNames", payload) + for msg in self.extract_items(data): + if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]: + return msg["ResolutionSet"]["Resolutions"] + + return [] diff --git a/exchange_mcp/server.py b/exchange_mcp/server.py new file mode 100644 index 0000000..30ca197 --- /dev/null +++ b/exchange_mcp/server.py @@ -0,0 +1,240 @@ +"""Exchange MCP Server. + +Exposes OWA email, calendar, directory, and availability tools via MCP. +Uses FastMCP with a lifespan context manager to share a single OWAClient +instance across all tool invocations. + +CLI arguments: + --transport Transport: stdio (default), sse, streamable-http + --owa-url Base URL of the OWA instance (required) + --cookie-file Path to session cookies file (default: session-cookies.txt) + --token-hash SHA-256 hash of the bearer token (required for HTTP) + --port HTTP port (default 8000) + --host HTTP host (default 127.0.0.1) + --enabled-tools Comma-separated whitelist of tool names + --disabled-tools Comma-separated blacklist of tool names + +Tool filtering (env fallback): + EXCHANGE_ENABLED_TOOLS — comma-separated whitelist (used only when + --enabled-tools is not provided). + EXCHANGE_DISABLED_TOOLS — comma-separated blacklist (used only when + --disabled-tools is not provided). +""" + +from __future__ import annotations + +import argparse +import logging +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + +from exchange_mcp.owa_client import OWAClient +from exchange_mcp.server_lifecycle import ( + HashTokenVerifier, + SessionKeepalive, + apply_tool_filters, + create_owa_client, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class AppContext: + """Shared application state available to all tools via lifespan context.""" + client: OWAClient | None = None + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Initialise the OWA client on first connection, then keep the session alive. + + A :class:`~exchange_mcp.server_lifecycle.SessionKeepalive` task starts + immediately (pings inbox, extends the session, persists fresh cookies) and + repeats every 5 minutes until the server shuts down. + """ + client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file) + try: + client._ensure_loaded() + except Exception: + pass # no cookies yet — login tool will handle this + + keepalive = SessionKeepalive(client) + await keepalive.start() + + try: + yield AppContext(client=client) + finally: + await keepalive.stop() + + +# ── Temporary storage for lifespan args (set by main() before mcp.run()) ── +_lifespan_owa_url: str = "" +_lifespan_cookie_file: str | None = None + + +# ── Module-level MCP server (imported by all tool modules) ──────────────── +mcp: FastMCP = FastMCP("exchange", lifespan=app_lifespan) + + +def _parse_args(argv=None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Exchange MCP Server — OWA/Exchange integration via MCP", + ) + parser.add_argument( + "--transport", + choices=["stdio", "sse", "streamable-http"], + default="stdio", + help="Transport protocol (default: stdio)", + ) + parser.add_argument( + "--owa-url", + required=True, + help="Base URL of the OWA instance (e.g. https://owa.example.com)", + ) + parser.add_argument( + "--cookie-file", + default=None, + help="Path to session cookies file (default: session-cookies.txt)", + ) + parser.add_argument( + "--token-hash", + default=None, + help=( + "SHA-256 hash of the bearer token for HTTP transport auth. " + "Compute with: echo -n 'my-token' | sha256sum" + ), + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port for HTTP/SSE transport (default: 8000)", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Host for HTTP/SSE transport (default: 127.0.0.1)", + ) + parser.add_argument( + "--enabled-tools", + default=None, + help="Comma-separated whitelist of tool names to expose", + ) + parser.add_argument( + "--disabled-tools", + default=None, + help="Comma-separated blacklist of tool names to hide", + ) + return parser.parse_args(argv) + + +def _configure_auth( + mcp_server: FastMCP, + token_hash: str | None, +) -> None: + """Apply token-hash auth to *mcp_server* if *token_hash* is provided.""" + if not token_hash: + return + + token_verifier: TokenVerifier = HashTokenVerifier(token_hash) + mcp_server.settings.auth = AuthSettings( # type: ignore[misc] + issuer_url="http://localhost", # type: ignore[arg-type] + resource_server_url="http://localhost", # type: ignore[arg-type] + ) + mcp_server._token_verifier = token_verifier # noqa: SLF001 + + +def main(argv=None) -> None: + """Entry point: parse CLI args, configure server, run transport.""" + global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603 + + args = _parse_args(argv) + + # ── Validate --owa-url ───────────────────────────────────────────────── + owa_url = args.owa_url.strip().rstrip("/") + if not owa_url: + print( + "ERROR: --owa-url must not be empty.", + file=__import__("sys").stderr, + ) + raise SystemExit(1) + + # ── Stash lifespan args (consumed by app_lifespan) ───────────────────── + _lifespan_owa_url = owa_url + _lifespan_cookie_file = args.cookie_file + + # ── Validate --token-hash for non-stdio transports ───────────────────── + if args.transport != "stdio" and not args.token_hash: + print( + "ERROR: --token-hash is required when using HTTP transport.", + file=__import__("sys").stderr, + ) + print( + " Compute hash: echo -n 'my-token' | sha256sum", + file=__import__("sys").stderr, + ) + raise SystemExit(1) + + # ── Register tools (imports populate module-level `mcp`) ─────────────── + import exchange_mcp.tools.email # noqa: E402, F401 + import exchange_mcp.tools.calendar # noqa: E402, F401 + import exchange_mcp.tools.people # noqa: E402, F401 + import exchange_mcp.tools.folders # noqa: E402, F401 + import exchange_mcp.tools.availability # noqa: E402, F401 + import exchange_mcp.tools.analytics # noqa: E402, F401 + import exchange_mcp.tools.auth # noqa: E402, F401 + + # ── Auth for HTTP transports ─────────────────────────────────────────── + _configure_auth(mcp, args.token_hash) + + # ── Tool filtering ───────────────────────────────────────────────────── + # CLI args take precedence over env vars. + enabled: list[str] | None = None + if args.enabled_tools is not None: + enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] + elif os.environ.get("EXCHANGE_ENABLED_TOOLS", "").strip(): + enabled = [ + t.strip() + for t in os.environ["EXCHANGE_ENABLED_TOOLS"].split(",") + if t.strip() + ] + + disabled: list[str] | None = None + if args.disabled_tools is not None: + disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] + elif os.environ.get("EXCHANGE_DISABLED_TOOLS", "").strip(): + disabled = [ + t.strip() + for t in os.environ["EXCHANGE_DISABLED_TOOLS"].split(",") + if t.strip() + ] + + if enabled or disabled: + apply_tool_filters(mcp, enabled=enabled, disabled=disabled) + + # ── Host / port (already passed to FastMCP at module level; update if + # different from defaults — useful when server is re-invoked with + # different args in tests) ───────────────────────────────────────────── + mcp.settings.host = args.host + mcp.settings.port = args.port + + # ── Run transport ───────────────────────────────────────────────────── + match args.transport: + case "stdio": + mcp.run() + case "sse": + mcp.run(transport="sse") + case "streamable-http": + mcp.run(transport="streamable-http") + + +if __name__ == "__main__": + main() diff --git a/exchange_mcp/server_lifecycle.py b/exchange_mcp/server_lifecycle.py new file mode 100644 index 0000000..e1e26ec --- /dev/null +++ b/exchange_mcp/server_lifecycle.py @@ -0,0 +1,253 @@ +"""Shared lifecycle utilities for the Exchange MCP server. + +Centralises three concerns so that tool modules do not duplicate code: + +1. ``create_owa_client`` — builds and returns an initialised :class:`OWAClient`. +2. ``require_client`` — fetches the client from the lifespan context; raises a + clear :class:`RuntimeError` when it is ``None`` (e.g. OWA URL was not passed). +3. ``apply_tool_filters`` — reads CLI args and applies whitelist / blacklist + filtering to the tool registry. +4. ``SessionKeepalive`` — background task that pings OWA every *n* seconds to + extend the session lifetime and persist fresh cookies to disk. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from exchange_mcp.owa_client import OWAClient, SessionExpiredError +from mcp.server.auth.provider import AccessToken, TokenVerifier + +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + +logger = logging.getLogger(__name__) + + +# ── Client factory ─────────────────────────────────────────────────────── + +def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient: + """Create and return a fully configured :class:`OWAClient`. + + Args: + owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``). + cookie_file: Path to the session-cookie file. ``None`` falls back to the + default ``session-cookies.txt`` next to the package. + + Returns: + A ready-to-use :class:`OWAClient`. + + Raises: + ValueError: If *owa_url* is empty. + """ + owa_url = owa_url.strip().rstrip("/") + if not owa_url: + raise ValueError("OWA URL must not be empty.") + return OWAClient(owa_url=owa_url, cookie_file=cookie_file) + + +# ── Token verifier ──────────────────────────────────────────────────────── + +class HashTokenVerifier(TokenVerifier): + """Bearer-token verifier backed by a SHA-256 hash. + + The server never stores the plaintext token — only its hex digest. + Clients send the plaintext token as ``Authorization: Bearer ``, + the server hashes it and compares with the stored hash. + """ + + def __init__(self, token_hash: str) -> None: + import hashlib + self._expected_hash = token_hash.strip().lower() + + async def verify_token(self, token: str) -> AccessToken | None: + import hashlib + if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: + return AccessToken( + token=token, + client_id="mcp-client", + scopes=[], + ) + return None + + +# ── Lifespan helper ────────────────────────────────────────────────────── + +class _LazyClient: + """Thin wrapper that resolves the OWAClient on first access. + + Stored as ``AppContext.client`` so that ``require_client`` can work + with both a pre-built client and a ``None`` placeholder. + """ + + def __init__(self, client: OWAClient | None) -> None: + self._client = client + + @property + def resolved(self) -> OWAClient: + if self._client is None: + raise RuntimeError( + "OWA client is not initialised. " + "Check --owa-url and --cookie-file arguments." + ) + return self._client + + +def require_client(client: OWAClient | None) -> OWAClient: + """Return *client* if it is not ``None``, otherwise raise ``RuntimeError``. + + Import this in tool modules and call it with ``ctx.client`` to get a + consistent error message when OWA is not configured. + """ + if client is None: + raise RuntimeError( + "OWA client is not initialised. " + "Pass --owa-url when starting the server." + ) + return client + + +# ── Session keepalive ────────────────────────────────────────────────────── + +_DEFAULT_KEEPALIVE_INTERVAL = 300 # seconds (5 minutes) + + +class SessionKeepalive: + """Background task that periodically pings the OWA server to extend the + session lifetime and persist fresh cookies to disk. + + On each successful ping the OWA server may return updated ``Set-Cookie`` + headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT). + The client saves those cookies to disk automatically. + + On failure the keepalive attempts to reload cookies from disk so that a + fresh login performed by another process (or via the ``login`` tool) is + picked up without restarting the server. + """ + + 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") + + async def stop(self) -> None: + """Cancel the background keepalive loop and wait for it to finish.""" + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + """Ping immediately, then every ``interval`` seconds.""" + # First ping right away (on server start) + await asyncio.to_thread(self._ping) + while True: + await asyncio.sleep(self._interval) + await asyncio.to_thread(self._ping) + + def _ping(self) -> None: + """Make a lightweight GetFolder request against the inbox. + + On success the OWAClient saves fresh cookies to disk automatically. + On ``SessionExpiredError`` the on-disk cookies are re-loaded in + case the user re-authenticated while the server was running. + """ + try: + self._client.request("GetFolder", { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [{ + "__type": "DistinguishedFolderId:#Exchange", + "Id": "inbox", + }], + }, + }) + logger.debug("Session keepalive ping succeeded") + except SessionExpiredError: + logger.warning("Session expired during keepalive — reloading cookies from disk") + try: + self._client._ensure_loaded() + except Exception as exc: + logger.warning("Failed to reload cookies after keepalive failure: %s", exc) + except Exception as exc: + logger.debug("Keepalive ping failed (non-critical): %s", exc) + + +# ── Tool filtering ─────────────────────────────────────────────────────── + +def apply_tool_filters( + server: FastMCP, + enabled: list[str] | None = None, + disabled: list[str] | None = None, +) -> None: + """Apply whitelist / blacklist filtering to *server*'s registered tools. + + Args: + server: The :class:`FastMCP` instance whose tool registry is modified + in-place. + enabled: Optional list of tool names to **keep**. If ``None`` or empty + all tools are eligible. Unknown names are ignored (warning + logged). + disabled: Optional list of tool names to **remove**. Applied *after* + the whitelist. Unknown names are ignored (warning logged). + """ + canonical = {t.name for t in asyncio.run(server.list_tools())} + if not canonical: + return + + allowed: set[str] = set(canonical) + + # ── Whitelist ────────────────────────────────────────────────────────── + if enabled: + enabled_set = {n.strip() for n in enabled if n.strip()} + allowed &= enabled_set + unknown = enabled_set - canonical + if unknown: + logger.warning( + "Unknown tool(s) in --enabled-tools, ignoring: %s", + ", ".join(sorted(unknown)), + ) + + # ── Blacklist ────────────────────────────────────────────────────────── + if disabled: + disabled_set = {n.strip() for n in disabled if n.strip()} + allowed -= disabled_set + unknown = disabled_set - canonical + if unknown: + logger.warning( + "Unknown tool(s) in --disabled-tools, ignoring: %s", + ", ".join(sorted(unknown)), + ) + + # ── Remove ───────────────────────────────────────────────────────────── + to_remove = sorted(canonical - allowed) + if to_remove: + logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove)) + for name in to_remove: + server.remove_tool(name) + + logger.info("Tools available: %s", ", ".join(sorted(allowed))) diff --git a/exchange_mcp/tools/__init__.py b/exchange_mcp/tools/__init__.py new file mode 100644 index 0000000..d0c4846 --- /dev/null +++ b/exchange_mcp/tools/__init__.py @@ -0,0 +1 @@ +"""MCP tool modules for Exchange.""" diff --git a/exchange_mcp/tools/analytics.py b/exchange_mcp/tools/analytics.py new file mode 100644 index 0000000..672c499 --- /dev/null +++ b/exchange_mcp/tools/analytics.py @@ -0,0 +1,422 @@ +"""Analytics tools for the Exchange MCP server. + +Provides meeting statistics and connection matrix analysis +using GetUserAvailability and GetItem APIs. +""" + +import json +from collections import Counter, defaultdict +from datetime import datetime, timedelta, date + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient +from exchange_mcp.server_lifecycle import require_client + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) + + +# ------------------------------------------------------------------ +# Internal: resolve names to emails +# ------------------------------------------------------------------ + +def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: + """Resolve a name/email to (display_name, email). Returns ('','') on failure.""" + resolutions = client.resolve_names(name) + if resolutions: + mb = resolutions[0].get("Mailbox", {}) + return mb.get("Name", name), mb.get("EmailAddress", "") + return "", "" + + +# ------------------------------------------------------------------ +# Internal: query GetUserAvailability in chunks +# ------------------------------------------------------------------ + +def _get_availability_events( + client: OWAClient, + emails: list[str], + start: date, + end: date, + batch_size: int = 5, + chunk_days: int = 14, +) -> dict[str, list[dict]]: + """Query GetUserAvailability for multiple people across a date range. + + Returns dict mapping email -> list of calendar event dicts with + keys: subject, start_date, busy_type. + """ + results: dict[str, list[dict]] = {email: [] for email in emails} + + # Batch people + email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)] + + for batch in email_batches: + current = start + while current < end: + chunk_end = min(current + timedelta(days=chunk_days), end) + + mailbox_data = [{ + '__type': 'MailboxData:#Exchange', + 'Email': { + '__type': 'EmailAddress:#Exchange', + 'Address': email, + }, + 'AttendeeType': 'Required', + } for email in batch] + + 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({ + 'subject': subject, + 'start_date': s[:10], + 'busy_type': bt, + }) + except Exception: + pass + + current = chunk_end + + return results + + +# ------------------------------------------------------------------ +# Tool: get_meeting_stats +# ------------------------------------------------------------------ + +@mcp.tool() +def get_meeting_stats( + people: str, + start_date: str, + end_date: str, + ctx: Context = None, +) -> str: + """Get meeting count statistics for one or more people over a date range. + + Uses GetUserAvailability to count calendar events (including + expanded recurring instances) for each person. + + Args: + people: Comma-separated names or email addresses to analyze. + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + + Returns: + JSON object with per-person stats sorted by meeting count: + total_meetings, meetings_per_workday, days_with_meetings. + """ + client = _get_client(ctx) + + try: + sd = datetime.strptime(start_date, '%Y-%m-%d').date() + ed = datetime.strptime(end_date, '%Y-%m-%d').date() + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) + + # Resolve names + name_list = [n.strip() for n in people.split(',') if n.strip()] + if not name_list: + return json.dumps({"error": "No people specified."}) + + resolved = [] # (display_name, email) + for name in name_list: + display, email = _resolve_to_email(client, name) + if email: + resolved.append((display, email)) + else: + resolved.append((name, "")) + + emails = [email for _, email in resolved if email] + if not emails: + return json.dumps({"error": "Could not resolve any names to email addresses."}) + + # Query availability + avail = _get_availability_events(client, emails, sd, ed) + + # Count working days + workdays = 0 + d = sd + while d < ed: + if d.weekday() < 5: + workdays += 1 + d += timedelta(days=1) + workdays = max(workdays, 1) + + # Build stats + stats = [] + for display, email in resolved: + if not email or email not in avail: + stats.append({ + "name": display, + "email": email or "not_found", + "total_meetings": 0, + "meetings_per_workday": 0, + "days_with_meetings": 0, + "workdays": workdays, + }) + continue + + events = avail[email] + total = len(events) + unique_days = len(set(ev['start_date'] for ev in events)) + avg = round(total / workdays, 1) + + stats.append({ + "name": display, + "email": email, + "total_meetings": total, + "meetings_per_workday": avg, + "days_with_meetings": unique_days, + "workdays": workdays, + }) + + # Sort by total descending + stats.sort(key=lambda x: x["total_meetings"], reverse=True) + + return json.dumps({ + "period": {"start": start_date, "end": end_date, "workdays": workdays}, + "stats": stats, + }, ensure_ascii=False) + + +# ------------------------------------------------------------------ +# Tool: get_meeting_contacts +# ------------------------------------------------------------------ + +@mcp.tool() +def get_meeting_contacts( + start_date: str, + end_date: str, + top_n: int = 30, + ctx: Context = None, +) -> str: + """Build a connection matrix: who you meet with most often. + + Analyzes your calendar over a date range, accounting for recurring + meetings. For each contact found in your meetings, returns the + weighted count of shared meetings. + + Args: + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + top_n: Number of top contacts to return. Default 30. + + Returns: + JSON object with total_meetings, unique_contacts, and a ranked + contacts array of {name, email, meetings} objects. + """ + client = _get_client(ctx) + + 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: + 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 + 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) diff --git a/exchange_mcp/tools/auth.py b/exchange_mcp/tools/auth.py new file mode 100644 index 0000000..2d66930 --- /dev/null +++ b/exchange_mcp/tools/auth.py @@ -0,0 +1,117 @@ +"""Authentication tool for the Exchange MCP server. + +Provides a `login` tool that accepts session cookies from the user, +saves them to disk, loads them into the OWA client, and verifies the session. + +The user obtains cookies from their browser (DevTools → Application → Cookies), +pastes them as a ``name=value`` string (one cookie per line), and passes them +to this tool. +""" + +import json + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient, SessionExpiredError +from exchange_mcp.server_lifecycle import require_client + + +def _get_app_ctx(ctx: Context) -> AppContext: + """Extract the AppContext from the MCP lifespan context.""" + return ctx.request_context.lifespan_context + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + return require_client(_get_app_ctx(ctx).client) + + +def _session_is_valid(client: OWAClient) -> bool: + """Quick check: can we reach the inbox?""" + try: + client._ensure_loaded() + payload = { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "inbox", + } + ], + }, + } + data = client.request("GetFolder", payload) + items = client.extract_items(data) + return bool(items) + except Exception: + return False + + +@mcp.tool() +async def login( + cookies: str, + ctx: Context = None, +) -> str: + """Authenticate to Exchange OWA using session cookies from the browser. + + Paste the cookies you copied from your browser (DevTools → Application → + Cookies) as a ``name=value`` string — one cookie per line. + + Example cookies string:: + + X-OWA-CANARY=abc123 + CookieAuth1=def456 + ... + + Args: + cookies: Session cookies in ``name=value`` format, one per line. + Obtain from browser DevTools → Application → Cookies → copy all + cookies as ``name=value`` pairs. + + Returns: + JSON result with success status and message. + """ + client = _get_client(ctx) + + # Parse and validate cookies + cookies_str = cookies.strip() + if not cookies_str: + return json.dumps({ + "success": False, + "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", + }) + + # Save to disk + try: + client.cookie_file.write_text(cookies_str + "\n") + except Exception as e: + return json.dumps({"success": False, "error": f"Failed to write cookie file: {e}"}) + + # Load into memory + try: + client.load_cookies_from_string(cookies_str) + except SessionExpiredError as e: + return json.dumps({"success": False, "error": str(e)}) + + # Verify session + if _session_is_valid(client): + return json.dumps({ + "success": True, + "message": f"Logged in. Session cookies saved to {client.cookie_file}.", + }) + else: + return json.dumps({ + "success": False, + "error": "Cookies loaded but session verification failed. The cookies may have expired.", + }) diff --git a/exchange_mcp/tools/availability.py b/exchange_mcp/tools/availability.py new file mode 100644 index 0000000..46da79b --- /dev/null +++ b/exchange_mcp/tools/availability.py @@ -0,0 +1,594 @@ +"""Availability / free-time tools for the Exchange MCP server. + +Ports find-free-time.py and find-meeting-time.py logic into MCP tools +using OWAClient. +""" + +import json +from datetime import datetime, timedelta + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient +from exchange_mcp.server_lifecycle import require_client + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) + + +# ------------------------------------------------------------------ +# Pure helper functions (preserved from find-meeting-time.py) +# ------------------------------------------------------------------ + +def _parse_freebusy_string( + freebusy_str: str, start_time: datetime, interval_minutes: int = 30 +) -> list[tuple]: + """Parse the MergedFreeBusy string. + + Each character represents a time slot: + 0 = Free, 1 = Tentative, 2 = Busy, 3 = Out of Office, 4 = Working Elsewhere + """ + busy_periods = [] + current_time = start_time + + for char in freebusy_str: + next_time = current_time + timedelta(minutes=interval_minutes) + if char in ['1', '2', '3', '4']: # Not free + busy_periods.append((current_time, next_time, char)) + current_time = next_time + + return busy_periods + + +def _merge_busy_periods(all_busy: list) -> list[tuple]: + """Merge overlapping busy periods.""" + if not all_busy: + return [] + + # Sort by start time + sorted_busy = sorted(all_busy, key=lambda x: x[0]) + merged = [(sorted_busy[0][0], sorted_busy[0][1])] + + for start, end, *_ in sorted_busy[1:]: + if start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + + return merged + + +def _find_free_slots( + busy_periods: list, date, start_hour: int, end_hour: int, duration_minutes: int +) -> list[tuple]: + """Find free slots on a given date within working hours.""" + day_start = datetime.combine(date, datetime.min.time().replace(hour=start_hour)) + day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour)) + + # Filter busy periods to this day + day_busy = [] + for period in busy_periods: + start, end = period[0], period[1] + if end.date() < date or start.date() > date: + continue + start = max(start, day_start) + end = min(end, day_end) + if start < end: + day_busy.append((start, end)) + + # Merge overlapping + merged = _merge_busy_periods([(s, e) for s, e in day_busy]) + + # Find gaps + free_slots = [] + current = day_start + + for busy_start, busy_end in merged: + if current < busy_start: + gap_duration = (busy_start - current).total_seconds() / 60 + if gap_duration >= duration_minutes: + free_slots.append((current, busy_start)) + current = max(current, busy_end) + + # Check for time after last meeting + if current < day_end: + gap_duration = (day_end - current).total_seconds() / 60 + if gap_duration >= duration_minutes: + free_slots.append((current, day_end)) + + return free_slots + + +def _format_time(dt: datetime) -> str: + """Format datetime as HH:MM.""" + return dt.strftime('%H:%M') + + +# ------------------------------------------------------------------ +# Helper: get busy events via GetUserAvailability (includes recurring) +# ------------------------------------------------------------------ + +def _get_availability_events( + client: OWAClient, email: str, start_date, end_date +) -> list[dict]: + """Get busy events via GetUserAvailability (expands recurring events).""" + payload = { + '__type': 'GetUserAvailabilityJsonRequest:#Exchange', + 'Header': { + '__type': 'JsonRequestHeaders:#Exchange', + 'RequestServerVersion': 'Exchange2013', + 'TimeZoneContext': { + '__type': 'TimeZoneContext:#Exchange', + 'TimeZoneDefinition': { + '__type': 'TimeZoneDefinitionType:#Exchange', + 'Id': 'Russian Standard Time', + }, + }, + }, + 'Body': { + '__type': 'GetUserAvailabilityRequest:#Exchange', + 'MailboxDataArray': [{ + '__type': 'MailboxData:#Exchange', + 'Email': {'__type': 'EmailAddress:#Exchange', 'Address': email}, + 'AttendeeType': 'Required', + }], + 'FreeBusyViewOptions': { + '__type': 'FreeBusyViewOptions:#Exchange', + 'TimeWindow': { + '__type': 'Duration:#Exchange', + 'StartTime': f'{start_date}T00:00:00', + 'EndTime': f'{end_date + timedelta(days=1)}T00:00:00', + }, + 'MergedFreeBusyIntervalInMinutes': 30, + 'RequestedView': 'DetailedMerged', + }, + }, + } + + data = client.request('GetUserAvailability', payload) + body = data.get('Body', {}) + + events = [] + for fb_resp in body.get('FreeBusyResponseArray', []): + fb_view = fb_resp.get('FreeBusyView', {}) + cal_events = fb_view.get('CalendarEventArray', {}) + items = ( + cal_events.get('Items', []) + if isinstance(cal_events, dict) + else (cal_events if isinstance(cal_events, list) else []) + ) + for event in items: + bt = event.get('BusyType', '') + if bt in ('Free', 'NoData'): + continue + + start_str = event.get('StartTime', '') + end_str = event.get('EndTime', '') + if not start_str or not end_str: + continue + try: + start = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None) + end = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None) + events.append({'start': start, 'end': end, 'status': bt}) + except (ValueError, AttributeError): + continue + + return events + + +# ------------------------------------------------------------------ +# Helper: get calendar folder ID +# ------------------------------------------------------------------ + +def _get_calendar_folder_id(client: OWAClient) -> str | None: + """Get the calendar folder ID via GetFolder.""" + payload = { + '__type': 'GetFolderJsonRequest:#Exchange', + 'Header': { + '__type': 'JsonRequestHeaders:#Exchange', + 'RequestServerVersion': 'Exchange2013', + }, + 'Body': { + '__type': 'GetFolderRequest:#Exchange', + 'FolderShape': { + '__type': 'FolderResponseShape:#Exchange', + 'BaseShape': 'IdOnly', + }, + 'FolderIds': [ + {'__type': 'DistinguishedFolderId:#Exchange', 'Id': 'calendar'} + ], + }, + } + + data = client.request("GetFolder", payload) + for msg in client.extract_items(data): + if "Folders" in msg: + for f in msg["Folders"]: + fid = f.get("FolderId", {}).get("Id") + if fid: + return fid + return None + + +# ------------------------------------------------------------------ +# Helper: get own calendar events (from find-free-time.py) +# ------------------------------------------------------------------ + +def _get_calendar_events( + client: OWAClient, folder_id: str, start_date, end_date +) -> list[dict]: + """Get calendar events within a date range. Returns list of busy period dicts.""" + events = [] + offset = 0 + batch_size = 100 + + while True: + payload = { + '__type': 'FindItemJsonRequest:#Exchange', + 'Header': { + '__type': 'JsonRequestHeaders:#Exchange', + 'RequestServerVersion': 'Exchange2013', + }, + 'Body': { + '__type': 'FindItemRequest:#Exchange', + 'ItemShape': { + '__type': 'ItemResponseShape:#Exchange', + 'BaseShape': 'AllProperties', + }, + 'ParentFolderIds': [ + {'__type': 'FolderId:#Exchange', 'Id': folder_id} + ], + 'Traversal': 'Shallow', + 'Paging': { + '__type': 'IndexedPageView:#Exchange', + 'BasePoint': 'Beginning', + 'Offset': offset, + 'MaxEntriesReturned': batch_size, + }, + 'SortOrder': [ + { + '__type': 'SortResults:#Exchange', + 'Order': 'Ascending', + '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', []) + if not folder_items: + break + + for item in folder_items: + # Skip cancelled events + if item.get('IsCancelled'): + continue + + # Skip free/tentative slots + fbt = item.get('FreeBusyType', 'Busy') + if fbt in ['Free', 'NoData']: + continue + + start_str = item.get('Start', '') + end_str = item.get('End', '') + + if not start_str or not end_str: + continue + + try: + start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + + # Convert to naive datetime for comparison + start = start.replace(tzinfo=None) + end = end.replace(tzinfo=None) + + # Filter by date range + if end.date() < start_date or start.date() > end_date: + continue + + events.append({ + 'start': start, + 'end': end, + 'subject': item.get('Subject', ''), + 'status': fbt, + }) + except (ValueError, AttributeError): + continue + + # Check if there are more items + is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) + if is_last: + break + + offset += batch_size + + return events + + +# ------------------------------------------------------------------ +# Tool: find_free_time +# ------------------------------------------------------------------ + +@mcp.tool() +def find_free_time( + start_date: str, + end_date: str = "", + duration_minutes: int = 30, + start_hour: int = 9, + end_hour: int = 18, + ctx: Context = None, +) -> str: + """Find free time slots in your own calendar. + + Analyzes your calendar events and returns available time slots + within working hours for each weekday in the range. + + Args: + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. Defaults to start_date + if not provided (single-day search). + duration_minutes: Minimum slot duration in minutes. Default 30. + start_hour: Working day start hour (0-23). Default 9. + end_hour: Working day end hour (0-23). Default 18. + + Returns: + JSON object with free_slots keyed by date, each containing an + array of {start, end, duration_minutes} objects. + """ + client = _get_client(ctx) + + try: + sd = datetime.strptime(start_date, '%Y-%m-%d').date() + ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) + + try: + # Use GetUserAvailability for accurate recurring event expansion + if client.user_email: + all_busy = _get_availability_events(client, client.user_email, sd, ed) + else: + # Fallback to FindItem (misses recurring event occurrences) + folder_id = _get_calendar_folder_id(client) + if not folder_id: + return json.dumps({"error": "Could not find calendar folder. Session may have expired."}) + all_busy = _get_calendar_events(client, folder_id, sd, ed) + except Exception as e: + return json.dumps({"error": str(e)}) + + # Convert event dicts to (start, end) tuples for _find_free_slots + busy_periods = [(ev['start'], ev['end']) for ev in all_busy] + + result = {} + current_date = sd + while current_date <= ed: + # Skip weekends + if current_date.weekday() < 5: + free = _find_free_slots( + busy_periods, current_date, + start_hour, end_hour, duration_minutes, + ) + if free: + result[str(current_date)] = [ + { + "start": _format_time(s), + "end": _format_time(e), + "duration_minutes": int((e - s).total_seconds() / 60), + } + for s, e in free + ] + current_date += timedelta(days=1) + + return json.dumps({"free_slots": result}, ensure_ascii=False) + + +# ------------------------------------------------------------------ +# Tool: find_meeting_time +# ------------------------------------------------------------------ + +@mcp.tool() +def find_meeting_time( + emails: str, + start_date: str, + end_date: str = "", + duration_minutes: int = 30, + start_hour: int = 9, + end_hour: int = 18, + ctx: Context = None, +) -> str: + """Find meeting times that work for multiple people. + + Uses the OWA GetUserAvailability API to check cross-mailbox + availability and find common free slots for all attendees. + Supports multi-day ranges — searches each weekday in the range. + + Args: + emails: Comma-separated email addresses or names of attendees. + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. Defaults to start_date + if not provided (single-day search). + duration_minutes: Minimum slot duration in minutes. Default 30. + start_hour: Working day start hour (0-23). Default 9. + end_hour: Working day end hour (0-23). Default 18. + + Returns: + JSON object with attendee info and free_slots keyed by date, + each containing an array of {start, end, duration_minutes}. + """ + client = _get_client(ctx) + + raw_list = [e.strip() for e in emails.split(',') if e.strip()] + if not raw_list: + return json.dumps({"error": "No email addresses provided."}) + + try: + sd = datetime.strptime(start_date, '%Y-%m-%d').date() + ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) + + # Resolve names to email addresses via ResolveNames + email_list = [] + resolve_errors = [] + for entry in raw_list: + if '@' in entry: + email_list.append(entry) + else: + resolutions = client.resolve_names(entry, full_contact=False) + if resolutions: + addr = resolutions[0].get('Mailbox', {}).get('EmailAddress', '') + if addr: + email_list.append(addr) + else: + resolve_errors.append(entry) + else: + resolve_errors.append(entry) + + if not email_list: + return json.dumps({"error": f"Could not resolve any names to email addresses: {resolve_errors}"}) + + # Build mailbox data (reused for each day chunk) + mailbox_data = [] + for email in email_list: + mailbox_data.append({ + '__type': 'MailboxData:#Exchange', + 'Email': { + '__type': 'EmailAddress:#Exchange', + 'Address': email, + }, + 'AttendeeType': 'Required', + }) + + # Query the full date range at once (API handles multi-day windows) + payload = { + '__type': 'GetUserAvailabilityJsonRequest:#Exchange', + 'Header': { + '__type': 'JsonRequestHeaders:#Exchange', + 'RequestServerVersion': 'Exchange2013', + 'TimeZoneContext': { + '__type': 'TimeZoneContext:#Exchange', + 'TimeZoneDefinition': { + '__type': 'TimeZoneDefinitionType:#Exchange', + 'Id': 'Russian Standard Time', + }, + }, + }, + 'Body': { + '__type': 'GetUserAvailabilityRequest:#Exchange', + 'MailboxDataArray': mailbox_data, + 'FreeBusyViewOptions': { + '__type': 'FreeBusyViewOptions:#Exchange', + 'TimeWindow': { + '__type': 'Duration:#Exchange', + 'StartTime': f'{sd}T00:00:00', + 'EndTime': f'{ed + timedelta(days=1)}T00:00:00', + }, + 'MergedFreeBusyIntervalInMinutes': 30, + 'RequestedView': 'DetailedMerged', + }, + }, + } + + try: + data = client.request("GetUserAvailability", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + body = data.get('Body', {}) + if 'ErrorCode' in body: + return json.dumps({"error": body.get('FaultMessage', 'Unknown error')}) + + # Parse availability responses + all_busy = [] + attendee_info = [] + freebusy_responses = body.get('FreeBusyResponseArray', []) + + for i, fb_resp in enumerate(freebusy_responses): + fb_view = fb_resp.get('FreeBusyView', {}) + merged_fb = fb_view.get('MergedFreeBusy', '') + email = email_list[i] if i < len(email_list) else f"Person {i+1}" + + if merged_fb: + start_time = datetime.combine(sd, datetime.min.time()) + busy_periods = _parse_freebusy_string(merged_fb, start_time) + + busy_count = sum(1 for c in merged_fb if c != '0') + free_count = sum(1 for c in merged_fb if c == '0') + attendee_info.append({ + "email": email, + "busy_slots": busy_count, + "free_slots": free_count, + }) + + all_busy.extend(busy_periods) + else: + # Fallback: parse CalendarEventArray + cal_events_raw = fb_view.get('CalendarEventArray', {}) + cal_events = cal_events_raw.get('Items', []) if isinstance(cal_events_raw, dict) else (cal_events_raw if isinstance(cal_events_raw, list) else []) + if cal_events: + attendee_info.append({ + "email": email, + "calendar_events": len(cal_events), + }) + for event in cal_events: + start_str = event.get('StartTime', '') + end_str = event.get('EndTime', '') + if start_str and end_str: + try: + start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + all_busy.append((start, end)) + except Exception: + pass + else: + attendee_info.append({ + "email": email, + "status": "no_data", + }) + + merged_busy = _merge_busy_periods(all_busy) + + # Find free slots for each weekday in range + free_by_date = {} + current_date = sd + while current_date <= ed: + if current_date.weekday() < 5: # Skip weekends + free = _find_free_slots( + merged_busy, current_date, + start_hour, end_hour, duration_minutes, + ) + if free: + free_by_date[str(current_date)] = [ + { + "start": _format_time(s), + "end": _format_time(e), + "duration_minutes": int((e - s).total_seconds() / 60), + } + for s, e in free + ] + current_date += timedelta(days=1) + + result = { + "period": {"start": str(sd), "end": str(ed)}, + "attendees": attendee_info, + "free_slots": free_by_date, + } + + if resolve_errors: + result["unresolved"] = resolve_errors + + return json.dumps(result, ensure_ascii=False) diff --git a/exchange_mcp/tools/calendar.py b/exchange_mcp/tools/calendar.py new file mode 100644 index 0000000..9df712b --- /dev/null +++ b/exchange_mcp/tools/calendar.py @@ -0,0 +1,1319 @@ +"""Calendar tools for the Exchange MCP server. + +Provides MCP tools for calendar event retrieval, meeting creation, +update, cancellation, and meeting response management via OWA API. +""" + +import html as html_mod +import json +import uuid +from datetime import datetime, timedelta + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient +from exchange_mcp.server_lifecycle import require_client +from exchange_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) + + +# ------------------------------------------------------------------ +# Internal helpers +# ------------------------------------------------------------------ + + +def _utc_to_local_str(dt_str: str) -> str: + """Convert a UTC ISO timestamp to local Moscow time string. + + FindItem returns UTC (e.g. '2026-02-17T06:30:00Z'), while + GetUserAvailability returns local Moscow time ('2026-02-17T09:30:00'). + This normalizes to the local format for key matching. + Moscow is permanently UTC+3 (no DST since 2014). + """ + if not dt_str: + return dt_str + if dt_str.endswith("Z"): + try: + utc_dt = datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ") + local_dt = utc_dt + timedelta(hours=3) + return local_dt.strftime("%Y-%m-%dT%H:%M:%S") + except ValueError: + return dt_str.rstrip("Z") + # No timezone suffix — already local time + return dt_str + + +def _get_event_details(client: OWAClient, item_id: str) -> dict: + """Get full event details (body, organizer, attendees) via GetItem.""" + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": [ + {"__type": "ItemId:#Exchange", "Id": item_id} + ], + }, + } + + data = client.request("GetItem", payload) + result = { + "organizer": "", + "organizer_email": "", + "location": "", + "body": "", + "attendees_required": [], + "attendees_optional": [], + } + + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + # Location + result["location"] = item.get("Location", "") + if not result["location"]: + enhanced = item.get("EnhancedLocation", {}) + if enhanced: + result["location"] = enhanced.get("DisplayName", "") + + # Body + body_data = item.get("Body", {}) + if body_data: + body_text = body_data.get("Value", "") + if body_data.get("BodyType") == "HTML": + body_text = html_to_text(body_text) + result["body"] = body_text.strip() + + # Organizer with SMTP email + organizer = item.get("Organizer", {}).get("Mailbox", {}) + if organizer: + name = organizer.get("Name", "") + addr = organizer.get("EmailAddress", "") + if addr and not addr.startswith("/O="): + result["organizer"] = f"{name} <{addr}>" if name else addr + result["organizer_email"] = addr + else: + result["organizer"] = name + + # Required attendees with SMTP emails + for a in item.get("RequiredAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + response = a.get("ResponseType", "") + + if name or addr: + if addr and not addr.startswith("/O="): + entry = f"{name} <{addr}>" if name else addr + else: + entry = name + if response and response not in ("Unknown", "Organizer"): + entry += f" [{response}]" + result["attendees_required"].append(entry) + + # Optional attendees with SMTP emails + for a in item.get("OptionalAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + response = a.get("ResponseType", "") + + if name or addr: + if addr and not addr.startswith("/O="): + entry = f"{name} <{addr}>" if name else addr + else: + entry = name + if response and response not in ("Unknown", "Organizer"): + entry += f" [{response}]" + result["attendees_optional"].append(entry) + + return result + + return result + + +def _get_full_event(client: OWAClient, item_id: str) -> dict: + """Get full event details for update_meeting preservation.""" + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": [ + {"__type": "ItemId:#Exchange", "Id": item_id} + ], + }, + } + + data = client.request("GetItem", payload) + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + result = { + "subject": item.get("Subject", ""), + "start": item.get("Start", ""), + "end": item.get("End", ""), + "is_all_day": item.get("IsAllDayEvent", False), + "sensitivity": item.get("Sensitivity", "Normal"), + "location": "", + "body_html": "", + "resolved_required": [], + "resolved_optional": [], + } + + # Location + loc = item.get("Location", "") + if not loc: + enhanced = item.get("EnhancedLocation", {}) + if enhanced: + loc = enhanced.get("DisplayName", "") + result["location"] = loc + + # Body (keep HTML) + body_data = item.get("Body", {}) + if body_data: + result["body_html"] = body_data.get("Value", "") + + # Required attendees as resolved dicts + for a in item.get("RequiredAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + if addr and not addr.startswith("/O="): + result["resolved_required"].append({ + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } + }) + + # Optional attendees + for a in item.get("OptionalAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + if addr and not addr.startswith("/O="): + result["resolved_optional"].append({ + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } + }) + + return result + + return {"error": "Meeting not found"} + + +def _resolve_attendee(client: OWAClient, email: str) -> dict: + """Resolve an email to attendee details via ResolveNames. + + Uses V2017_08_18 RequestServerVersion to match the original + create-meeting.py behaviour. + """ + payload = { + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "ResolveNamesRequest:#Exchange", + "UnresolvedEntry": email, + "ReturnFullContactData": True, + "ContactDataShape": "Default", + }, + } + + try: + data = client.request("ResolveNames", payload) + items = client.extract_items(data) + if items and "ResolutionSet" in items[0]: + resolutions = items[0]["ResolutionSet"].get("Resolutions", []) + if resolutions: + mailbox = resolutions[0].get("Mailbox", {}) + return { + "Mailbox": { + "Name": mailbox.get("Name", email), + "EmailAddress": mailbox.get("EmailAddress", email), + "RoutingType": "SMTP", + } + } + except Exception: + pass + + # Fallback + return { + "Mailbox": { + "Name": email, + "EmailAddress": email, + "RoutingType": "SMTP", + } + } + + +def _resolve_attendee_list(client: OWAClient, emails: list[str]) -> list[dict]: + """Resolve a list of email strings into attendee dicts.""" + attendees = [] + for email in emails: + email = email.strip() + if email: + attendees.append(_resolve_attendee(client, email)) + return attendees + + +def _build_html_body(description: str | None) -> str: + """Build the HTML body for a calendar item, matching create-meeting.py.""" + body = ( + '' + ) + if description: + desc_escaped = html_mod.escape(description).replace("\n", "
") + body += ( + '
{desc_escaped}
' + ) + else: + body += ( + '


' + ) + body += "" + return body + + +# ------------------------------------------------------------------ +# Tool 1: get_calendar_events +# ------------------------------------------------------------------ + + +def _get_expanded_events( + client: OWAClient, start_date, end_date, chunk_days: int = 14 +) -> list[dict]: + """Get expanded calendar events via GetUserAvailability. + + Unlike FindItem (which returns only master items for recurring series), + this returns every individual occurrence within the date range. + + Requires client.user_email to be set (done by the login tool). + """ + if not client.user_email: + return [] + + expanded = [] + current = start_date + + while current < end_date: + chunk_end = min(current + timedelta(days=chunk_days), end_date) + + payload = { + '__type': 'GetUserAvailabilityJsonRequest:#Exchange', + 'Header': { + '__type': 'JsonRequestHeaders:#Exchange', + 'RequestServerVersion': 'Exchange2013', + 'TimeZoneContext': { + '__type': 'TimeZoneContext:#Exchange', + 'TimeZoneDefinition': { + '__type': 'TimeZoneDefinitionType:#Exchange', + 'Id': 'Russian Standard Time', + }, + }, + }, + 'Body': { + '__type': 'GetUserAvailabilityRequest:#Exchange', + 'MailboxDataArray': [{ + '__type': 'MailboxData:#Exchange', + 'Email': {'__type': 'EmailAddress:#Exchange', 'Address': client.user_email}, + 'AttendeeType': 'Required', + }], + 'FreeBusyViewOptions': { + '__type': 'FreeBusyViewOptions:#Exchange', + 'TimeWindow': { + '__type': 'Duration:#Exchange', + 'StartTime': f'{current}T00:00:00', + 'EndTime': f'{chunk_end}T00:00:00', + }, + 'MergedFreeBusyIntervalInMinutes': 30, + 'RequestedView': 'DetailedMerged', + }, + }, + } + + try: + data = client.request('GetUserAvailability', payload) + body = data.get('Body', {}) + for fb_resp in body.get('FreeBusyResponseArray', []): + fb_view = fb_resp.get('FreeBusyView', {}) + cal_events = fb_view.get('CalendarEventArray', {}) + items = ( + cal_events.get('Items', []) + if isinstance(cal_events, dict) + else (cal_events if isinstance(cal_events, list) else []) + ) + for event in items: + bt = event.get('BusyType', '') + details = event.get('CalendarEventDetails', {}) + subject = details.get('Subject', '') if details else '' + location = details.get('Location', '') if details else '' + is_meeting = details.get('IsMeeting', False) if details else False + is_recurring = details.get('IsRecurring', False) if details else False + + expanded.append({ + 'subject': subject or '(No subject)', + 'start': event.get('StartTime', ''), + 'end': event.get('EndTime', ''), + 'busy_type': bt, + 'location': location, + 'is_meeting': is_meeting, + 'is_recurring': is_recurring, + }) + except Exception: + pass + + current = chunk_end + + return sorted(expanded, key=lambda x: x.get('start', '')) + + +@mcp.tool() +def get_calendar_events( + start_date: str, + end_date: str, + include_body: bool = True, + expand_recurring: bool = False, + ctx: Context = None, +) -> str: + """Get calendar events within a date range. + + Args: + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + include_body: If True, fetch full event details (organizer, attendees, body) + via GetItem for each event. Slower but more complete. + Ignored when expand_recurring=True. + expand_recurring: If True, show every individual occurrence of recurring + meetings (via GetUserAvailability). This gives an accurate + count of all events but returns fewer fields per event + (no item_id, attendees, or body). Default False. + + Returns: + JSON array of event objects with subject, start, end, location, attendees, etc. + """ + client = _get_client(ctx) + + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) + + # --- Expanded mode: uses GetUserAvailability for accurate recurring counts --- + if expand_recurring: + expanded = _get_expanded_events( + client, start_dt.date(), (end_dt + timedelta(days=1)).date() + ) + events = [] + for ev in expanded: + events.append({ + "subject": ev['subject'], + "start": ev['start'], + "end": ev['end'], + "location": ev.get('location', ''), + "busy_type": ev.get('busy_type', ''), + "is_meeting": ev.get('is_meeting', False), + "is_recurring": ev.get('is_recurring', False), + }) + return json.dumps(events, ensure_ascii=False) + + # --- Default mode --- + # Step 1: Get all events (including recurring) via GetUserAvailability + expanded = _get_expanded_events( + client, start_dt.date(), (end_dt + timedelta(days=1)).date() + ) + + # Step 2: Get events with item_ids via FindItem + CalendarView. + # CalendarView restricts results to the date range and expands recurring + # events into individual occurrences (each with its own ItemId). + folder_id = client.get_folder_id("calendar") + finditem_by_key: dict[str, dict] = {} # "subject|start" -> item + if folder_id: + cv_start = start_dt.strftime("%Y-%m-%dT00:00:00") + cv_end = (end_dt + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00") + + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ParentFolderIds": [ + {"__type": "FolderId:#Exchange", "Id": folder_id} + ], + "Traversal": "Shallow", + "CalendarView": { + "__type": "CalendarView:#Exchange", + "StartDate": cv_start, + "EndDate": cv_end, + }, + }, + } + + data = client.request("FindItem", payload) + + all_items = [] + for msg in client.extract_items(data): + if "RootFolder" in msg: + all_items = msg["RootFolder"].get("Items", []) + break + + # Index FindItem results by subject + normalized local start time. + # FindItem returns UTC timestamps (e.g. "2026-02-17T06:30:00Z"), + # while GetUserAvailability returns Moscow local time ("2026-02-17T09:30:00"). + # Normalize FindItem timestamps to local time for matching. + for item in all_items: + subject = item.get("Subject", "") + start = item.get("Start", "") + local_start = _utc_to_local_str(start) + key = f"{subject}|{local_start}" + finditem_by_key[key] = item + + # Step 3: Merge — use expanded list as the authoritative event list, + # enrich with FindItem data (item_id, details) when available + events = [] + for ev in expanded: + subject = ev.get("subject", "(No subject)") + start = ev.get("start", "") + end = ev.get("end", "") + is_recurring = ev.get("is_recurring", False) + + # Match expanded event (local time) with FindItem result (normalized to local) + fi_item = finditem_by_key.get(f"{subject}|{start}") + + item_id = fi_item.get("ItemId", {}).get("Id", "") if fi_item else "" + + event = { + "subject": subject, + "start": fi_item.get("Start", start) if fi_item else start, + "end": fi_item.get("End", end) if fi_item else end, + "location": ev.get("location", ""), + "is_all_day": fi_item.get("IsAllDayEvent", False) if fi_item else False, + "is_cancelled": fi_item.get("IsCancelled", False) if fi_item else False, + "is_meeting": ev.get("is_meeting", False), + "is_recurring": is_recurring, + "organizer": "", + "my_response": fi_item.get("MyResponseType", "") if fi_item else "", + "item_id": item_id, + "body": "", + "attendees_required": [], + "attendees_optional": [], + } + + # Get full details via GetItem if requested and item_id is available + if include_body and item_id: + details = _get_event_details(client, item_id) + event["organizer"] = details["organizer"] + event["location"] = details["location"] or event["location"] + event["body"] = details["body"] + event["attendees_required"] = details["attendees_required"] + event["attendees_optional"] = details["attendees_optional"] + + events.append(event) + + return json.dumps(events, ensure_ascii=False) + + +# ------------------------------------------------------------------ +# Tool 2: create_meeting +# ------------------------------------------------------------------ + + +@mcp.tool() +def create_meeting( + subject: str, + date: str, + start_time: str, + duration_minutes: int = 30, + required_attendees: list[str] | None = None, + optional_attendees: list[str] | None = None, + location: str | None = None, + description: str | None = None, + is_all_day: bool = False, + reminder_minutes: int = 15, + importance: str = "Normal", + sensitivity: str = "Normal", + ctx: Context = None, +) -> str: + """Create a new calendar meeting. + + Args: + subject: Meeting subject/topic. + date: Meeting date in YYYY-MM-DD format. + start_time: Start time in HH:MM format. + duration_minutes: Duration in minutes (default 30). + required_attendees: List of email addresses for required attendees. + optional_attendees: List of email addresses for optional attendees. + location: Location or video link. + description: Meeting description/body text. + is_all_day: Whether this is an all-day event. + reminder_minutes: Minutes before start for reminder (default 15). + importance: Importance level: Low, Normal, or High. + sensitivity: Sensitivity: Normal, Personal, Private, or Confidential. + + Returns: + JSON object with creation result including item_id on success. + """ + client = _get_client(ctx) + + # Parse date and time + try: + start_dt = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M") + end_dt = start_dt + timedelta(minutes=duration_minutes) + except ValueError as e: + return json.dumps({"error": f"Invalid date/time: {e}"}) + + # Resolve attendees + resolved_required = _resolve_attendee_list(client, required_attendees or []) + resolved_optional = _resolve_attendee_list(client, optional_attendees or []) + + # Build HTML body + html_body = _build_html_body(description) + + # Build location object + location_obj = { + "__type": "EnhancedLocation:#Exchange", + "Annotation": "", + "DisplayName": location or "", + "PostalAddress": { + "__type": "PersonaPostalAddress:#Exchange", + "Type": "Business", + "LocationSource": "None", + }, + } + + # Build calendar item + calendar_item = { + "__type": "CalendarItem:#Exchange", + "ClientSeriesId": str(uuid.uuid4()), + "Subject": subject, + "Body": { + "__type": "BodyContentType:#Exchange", + "BodyType": "HTML", + "Value": html_body, + }, + "Sensitivity": sensitivity, + "ReminderIsSet": True, + "ReminderMinutesBeforeStart": reminder_minutes, + "IsResponseRequested": True, + "DoNotForwardMeeting": False, + "IsAllDayEvent": is_all_day, + "Start": start_dt.strftime("%Y-%m-%dT%H:%M:%S.000"), + "End": end_dt.strftime("%Y-%m-%dT%H:%M:%S.000"), + "FreeBusyType": "Busy", + "Location": location_obj, + "unfoldedIndex": 0, + } + + if importance != "Normal": + calendar_item["Importance"] = importance + + if resolved_required: + calendar_item["RequiredAttendees"] = resolved_required + if resolved_optional: + calendar_item["OptionalAttendees"] = resolved_optional + + # Build request - uses CreateCalendarEvent action and V2017_08_18 + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", + }, + }, + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [calendar_item], + "ClientSupportsIrm": True, + "SavedItemFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "calendar", + }, + }, + }, + } + + # Send invitations if there are attendees + if resolved_required or resolved_optional: + payload["Body"]["SendMeetingInvitations"] = "SendToAllAndSaveCopy" + + data = client.request("CreateCalendarEvent", payload) + + # Check for top-level error + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + + # Check response messages + items = body.get("ResponseMessages", {}).get("Items", []) + if items: + item = items[0] + if item.get("ResponseClass") == "Success": + result = { + "success": True, + "subject": subject, + "date": date, + "start_time": start_time, + "end_time": end_dt.strftime("%H:%M"), + "duration_minutes": duration_minutes, + } + # Extract created item ID if available + created_items = item.get("Items", []) + if created_items: + item_id = created_items[0].get("ItemId", {}) + if item_id: + result["item_id"] = item_id.get("Id", "") + result["change_key"] = item_id.get("ChangeKey", "") + if location: + result["location"] = location + if resolved_required: + result["required_attendees"] = [ + a["Mailbox"]["EmailAddress"] for a in resolved_required + ] + if resolved_optional: + result["optional_attendees"] = [ + a["Mailbox"]["EmailAddress"] for a in resolved_optional + ] + return json.dumps(result, ensure_ascii=False) + else: + return json.dumps({ + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + }) + + return json.dumps({"success": True, "subject": subject, "note": "No confirmation details"}) + + +# ------------------------------------------------------------------ +# Tool 3: update_meeting +# ------------------------------------------------------------------ + + +@mcp.tool() +def update_meeting( + item_id: str, + subject: str | None = None, + date: str | None = None, + start_time: str | None = None, + duration_minutes: int | None = None, + location: str | None = None, + description: str | None = None, + required_attendees: list[str] | None = None, + optional_attendees: list[str] | None = None, + change_key: str = "", + ctx: Context = None, +) -> str: + """Update an existing calendar meeting. + + Internally cancels the old meeting and creates a new one with + updated fields, because OWA's JSON API does not support UpdateItem + for calendar items reliably. Unchanged fields are preserved from + the original meeting. + + Args: + item_id: The ItemId of the meeting to update (from get_calendar_events). + subject: New subject (omit to keep original). + date: New date in YYYY-MM-DD format (omit to keep original). + start_time: New start time in HH:MM format (omit to keep original). + duration_minutes: New duration in minutes (omit to keep original). + location: New location (omit to keep original). + description: New description/body text (omit to keep original). + required_attendees: Email addresses for required attendees. + Replaces existing list. Omit to keep original attendees. + optional_attendees: Email addresses for optional attendees. + Replaces existing list. Omit to keep original attendees. + change_key: Ignored (kept for backward compatibility). + + Returns: + JSON object with update result including new item_id. + """ + client = _get_client(ctx) + + # Step 1: Get the original meeting details + try: + orig = _get_full_event(client, item_id) + except Exception as e: + return json.dumps({"error": f"Could not fetch original meeting: {e}"}) + + if "error" in orig: + return json.dumps(orig) + + # Step 2: Merge original values with updates + new_subject = subject if subject is not None else orig.get("subject", "") + + # Parse original start/end for date and time defaults + orig_start_str = orig.get("start", "") + orig_end_str = orig.get("end", "") + try: + orig_start = datetime.fromisoformat(orig_start_str.replace("Z", "+00:00")).replace(tzinfo=None) + orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(tzinfo=None) + orig_duration = int((orig_end - orig_start).total_seconds() / 60) + except (ValueError, AttributeError): + orig_start = None + orig_end = None + orig_duration = 30 + + if date is not None and start_time is not None: + new_start = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M") + dur = duration_minutes if duration_minutes is not None else orig_duration + new_end = new_start + timedelta(minutes=dur) + elif date is not None and orig_start is not None: + new_start = datetime.strptime(date, "%Y-%m-%d").replace( + hour=orig_start.hour, minute=orig_start.minute + ) + dur = duration_minutes if duration_minutes is not None else orig_duration + new_end = new_start + timedelta(minutes=dur) + elif start_time is not None and orig_start is not None: + parts = start_time.split(":") + new_start = orig_start.replace(hour=int(parts[0]), minute=int(parts[1])) + dur = duration_minutes if duration_minutes is not None else orig_duration + new_end = new_start + timedelta(minutes=dur) + elif duration_minutes is not None and orig_start is not None: + new_start = orig_start + new_end = new_start + timedelta(minutes=duration_minutes) + elif orig_start is not None: + new_start = orig_start + new_end = orig_end + else: + return json.dumps({"error": "Cannot determine meeting time. Provide date and start_time."}) + + new_location = location if location is not None else orig.get("location", "") + + # Resolve attendees + if required_attendees is not None: + resolved_required = _resolve_attendee_list(client, required_attendees) + else: + resolved_required = orig.get("resolved_required", []) + + if optional_attendees is not None: + resolved_optional = _resolve_attendee_list(client, optional_attendees) + else: + resolved_optional = orig.get("resolved_optional", []) + + # Step 3: Cancel the original meeting + cancel_payload = { + "__type": "DeleteItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "DeleteItemRequest:#Exchange", + "ItemIds": [ + {"__type": "ItemId:#Exchange", "Id": item_id} + ], + "DeleteType": "MoveToDeletedItems", + "SendMeetingCancellations": "SendToAllAndSaveCopy", + "SuppressReadReceipts": True, + }, + } + + try: + client.request("DeleteItem", cancel_payload) + except Exception as e: + return json.dumps({"error": f"Failed to cancel original meeting: {e}"}) + + # Step 4: Create the new meeting + new_body = description if description is not None else orig.get("body_html", "") + if description is not None: + new_body = _build_html_body(description) + + location_obj = { + "__type": "EnhancedLocation:#Exchange", + "Annotation": "", + "DisplayName": new_location, + "PostalAddress": { + "__type": "PersonaPostalAddress:#Exchange", + "Type": "Business", + "LocationSource": "None", + }, + } + + calendar_item = { + "__type": "CalendarItem:#Exchange", + "ClientSeriesId": str(uuid.uuid4()), + "Subject": new_subject, + "Body": { + "__type": "BodyContentType:#Exchange", + "BodyType": "HTML", + "Value": new_body, + }, + "Sensitivity": orig.get("sensitivity", "Normal"), + "ReminderIsSet": True, + "ReminderMinutesBeforeStart": 15, + "IsResponseRequested": True, + "DoNotForwardMeeting": False, + "IsAllDayEvent": orig.get("is_all_day", False), + "Start": new_start.strftime("%Y-%m-%dT%H:%M:%S.000"), + "End": new_end.strftime("%Y-%m-%dT%H:%M:%S.000"), + "FreeBusyType": "Busy", + "Location": location_obj, + "unfoldedIndex": 0, + } + + if resolved_required: + calendar_item["RequiredAttendees"] = resolved_required + if resolved_optional: + calendar_item["OptionalAttendees"] = resolved_optional + + create_payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", + }, + }, + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [calendar_item], + "ClientSupportsIrm": True, + "SavedItemFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "calendar", + }, + }, + }, + } + + if resolved_required or resolved_optional: + create_payload["Body"]["SendMeetingInvitations"] = "SendToAllAndSaveCopy" + + try: + data = client.request("CreateCalendarEvent", create_payload) + except Exception as e: + return json.dumps({"error": f"Original cancelled but failed to create new: {e}"}) + + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + + resp_items = body.get("ResponseMessages", {}).get("Items", []) + if resp_items and resp_items[0].get("ResponseClass") == "Success": + result = { + "success": True, + "subject": new_subject, + "start": new_start.strftime("%Y-%m-%d %H:%M"), + "end": new_end.strftime("%Y-%m-%d %H:%M"), + "duration_minutes": int((new_end - new_start).total_seconds() / 60), + } + created_items = resp_items[0].get("Items", []) + if created_items: + new_item_id = created_items[0].get("ItemId", {}) + if new_item_id: + result["item_id"] = new_item_id.get("Id", "") + result["change_key"] = new_item_id.get("ChangeKey", "") + return json.dumps(result, ensure_ascii=False) + + if resp_items: + return json.dumps({ + "error": resp_items[0].get("MessageText", "Unknown error"), + "response_code": resp_items[0].get("ResponseCode", ""), + }) + + return json.dumps({"success": True, "subject": new_subject, "note": "No confirmation details"}) + + +# ------------------------------------------------------------------ +# Tool 4: cancel_meeting +# ------------------------------------------------------------------ + + +@mcp.tool() +def cancel_meeting( + item_id: str, + message: str | None = None, + ctx: Context = None, +) -> str: + """Cancel (delete) a calendar meeting and notify attendees. + + Args: + item_id: The ItemId of the meeting to cancel. + message: Optional cancellation message to attendees. + + Returns: + JSON object with cancellation result. + """ + client = _get_client(ctx) + + payload = { + "__type": "DeleteItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "DeleteItemRequest:#Exchange", + "ItemIds": [ + {"__type": "ItemId:#Exchange", "Id": item_id} + ], + "DeleteType": "MoveToDeletedItems", + "SendMeetingCancellations": "SendToAllAndSaveCopy", + "SuppressReadReceipts": True, + }, + } + + data = client.request("DeleteItem", payload) + + items = client.extract_items(data) + if items: + item = items[0] + if item.get("ResponseClass") == "Success": + return json.dumps({"success": True, "message": "Meeting cancelled"}) + else: + return json.dumps({ + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + }) + + # DeleteItem may return empty on success + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + + return json.dumps({"success": True, "message": "Meeting cancelled"}) + + +# ------------------------------------------------------------------ +# Tool 5: respond_to_meeting +# ------------------------------------------------------------------ + + +@mcp.tool() +def respond_to_meeting( + item_id: str, + response: str, + message: str | None = None, + ctx: Context = None, +) -> str: + """Respond to a meeting invitation (accept, decline, or tentative). + + Args: + item_id: The ItemId of the meeting to respond to. + response: Response type: "Accept", "Decline", or "Tentative". + message: Optional message to include with the response. + + Returns: + JSON object with response result. + """ + client = _get_client(ctx) + + # Map response to the correct __type + response_types = { + "Accept": "AcceptItem:#Exchange", + "Decline": "DeclineItem:#Exchange", + "Tentative": "TentativelyAcceptItem:#Exchange", + } + + response_type = response_types.get(response) + if not response_type: + return json.dumps({ + "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." + }) + + response_item = { + "__type": response_type, + "ReferenceItemId": { + "__type": "ItemId:#Exchange", + "Id": item_id, + }, + } + + if message: + response_item["Body"] = { + "__type": "BodyContentType:#Exchange", + "BodyType": "Text", + "Value": message, + } + + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [response_item], + "MessageDisposition": "SendAndSaveCopy", + }, + } + + data = client.request("CreateItem", payload) + + items = client.extract_items(data) + if items: + item = items[0] + if item.get("ResponseClass") == "Success": + return json.dumps({ + "success": True, + "response": response, + "message": f"Meeting {response.lower()}ed", + }) + else: + return json.dumps({ + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + }) + + return json.dumps({"error": "No response from server"}) + + +# ------------------------------------------------------------------ +# Tool 6: download_event_attachments +# ------------------------------------------------------------------ + + +@mcp.tool() +def download_event_attachments( + item_id: str, + target_folder: str = "/tmp/attachments", + ctx: Context = None, +) -> str: + """Download all file attachments from a calendar event to disk. + + Args: + item_id: The Exchange ItemId of the calendar event. + target_folder: Local directory to save files (default /tmp/attachments). + """ + import os + + try: + client = _get_client(ctx) + + # Get full event details (AllProperties includes Attachments) + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": [ + {"__type": "ItemId:#Exchange", "Id": item_id} + ], + }, + } + + data = client.request("GetItem", payload) + + # Extract attachments from the item + attachments = [] + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + for att in item.get("Attachments", []): + attachments.append({ + "name": att.get("Name", ""), + "size": att.get("Size", 0), + "content_type": att.get("ContentType", ""), + "attachment_id": att.get("AttachmentId", {}).get("Id", ""), + "is_inline": att.get("IsInline", False), + }) + break + + if not attachments: + return json.dumps({"success": True, "downloaded": [], "count": 0, + "message": "No attachments found on this event."}) + + # Filter to non-inline file attachments + file_attachments = [ + a for a in attachments + if a.get("attachment_id") and not a.get("is_inline", False) + ] + + if not file_attachments: + return json.dumps({"success": True, "downloaded": [], "count": 0, + "message": "No downloadable file attachments."}) + + os.makedirs(target_folder, exist_ok=True) + + downloaded = [] + errors = [] + used_names: set[str] = set() + + for att in file_attachments: + try: + content, filename, content_type = client.download_file( + att["attachment_id"] + ) + + # Sanitize filename + filename = os.path.basename(filename) + if not filename: + filename = att.get("name", "attachment") or "attachment" + + # Handle collisions + base_name = filename + name_part, _, ext_part = base_name.rpartition(".") + if not name_part: + name_part = base_name + ext_part = "" + + counter = 1 + while filename.lower() in used_names: + if ext_part: + filename = f"{name_part}_{counter}.{ext_part}" + else: + filename = f"{name_part}_{counter}" + counter += 1 + + used_names.add(filename.lower()) + + filepath = os.path.join(target_folder, filename) + with open(filepath, "wb") as f: + f.write(content) + + downloaded.append({ + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + }) + except Exception as e: + errors.append({ + "name": att.get("name", "unknown"), + "error": str(e), + }) + + result = { + "success": len(errors) == 0, + "downloaded": downloaded, + "count": len(downloaded), + } + if errors: + result["errors"] = errors + + return json.dumps(result) + + except Exception as e: + return json.dumps({"error": f"Failed to download event attachments: {e}"}) + + +# ------------------------------------------------------------------ +# Tool 7: get_event_links +# ------------------------------------------------------------------ + + +@mcp.tool() +def get_event_links( + item_id: str, + ctx: Context = None, +) -> str: + """Extract all hyperlinks from a calendar event's HTML description. + + Args: + item_id: The Exchange ItemId of the calendar event to extract links from. + """ + try: + client = _get_client(ctx) + + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + "BodyType": "HTML", + "AdditionalProperties": [ + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Subject", + }, + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Body", + }, + ], + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } + + data = client.request("GetItem", payload) + + subject = "" + links = [] + + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + subject = item.get("Subject", "") + body_val = item.get("Body", {}).get("Value", "") + links = extract_links_from_html(body_val) + break + + return json.dumps({ + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + }) + + except Exception as e: + return json.dumps({"error": f"Failed to extract event links: {e}"}) diff --git a/exchange_mcp/tools/email.py b/exchange_mcp/tools/email.py new file mode 100644 index 0000000..4cb2095 --- /dev/null +++ b/exchange_mcp/tools/email.py @@ -0,0 +1,1176 @@ +"""Email tools for the Exchange MCP server. + +Provides tools for reading, sending, replying, forwarding, and managing +emails via the OWA Exchange API. +""" + +import json + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient, SessionExpiredError +from exchange_mcp.server_lifecycle import require_client +from exchange_mcp.utils import html_to_text, extract_links_from_html + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) + + +def _get_change_key(client: OWAClient, item_id: str) -> str | None: + """Fetch the ChangeKey for an item via GetItem (IdOnly). + + OWA requires the ChangeKey on write operations like reply/forward. + """ + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } + data = client.request("GetItem", payload) + for msg in client.extract_items(data): + if "Items" in msg: + for item in msg["Items"]: + return item.get("ItemId", {}).get("ChangeKey") + return None + + +def _extract_email_summary(item: dict) -> dict: + """Extract a summary dict from a FindItem result item.""" + item_type = item.get("__type", "Message:#Exchange") + is_meeting = any( + t in item_type + for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation") + ) + + # Resolve sender from From -> Organizer -> Sender + from_data = item.get("From", {}).get("Mailbox", {}) + if not from_data: + from_data = item.get("Organizer", {}).get("Mailbox", {}) + if not from_data: + from_data = item.get("Sender", {}).get("Mailbox", {}) + + email = { + "subject": item.get("Subject", "(No subject)"), + "from": from_data.get("EmailAddress", ""), + "from_name": from_data.get("Name", ""), + "date": item.get( + "DateTimeSent", + item.get("DateTimeReceived", item.get("DateTimeCreated", "")), + ), + "is_read": item.get("IsRead", False), + "has_attachments": item.get("HasAttachments", False), + "item_id": item.get("ItemId", {}).get("Id", ""), + "size": item.get("Size", 0), + "is_meeting": is_meeting, + "type": "Meeting" if is_meeting else "Email", + "preview": item.get("Preview", ""), + "has_links": False, + } + + # Basic recipients from DisplayTo/DisplayCc + email["to"] = ( + [t.strip() for t in item.get("DisplayTo", "").split(";") if t.strip()] + if item.get("DisplayTo") + else [] + ) + email["cc"] = ( + [c.strip() for c in item.get("DisplayCc", "").split(";") if c.strip()] + if item.get("DisplayCc") + else [] + ) + + if is_meeting: + email["location"] = item.get("Location", "") + email["start"] = item.get( + "Start", item.get("StartWallClock", item.get("ReminderDueBy", "")) + ) + email["end"] = item.get("End", item.get("EndWallClock", "")) + + return email + + +def _get_item_details(client: OWAClient, item_id: str) -> dict: + """Get full email details (body, recipients, attachments) via GetItem.""" + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + "BodyType": "HTML", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } + + data = client.request("GetItem", payload) + result = { + "item_id": item_id, + "subject": "", + "from": "", + "from_name": "", + "date": "", + "to": [], + "cc": [], + "bcc": [], + "body": "", + "body_type": "Text", + "is_read": False, + "has_attachments": False, + "has_links": False, + "importance": "Normal", + "attachments": [], + } + + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + result["subject"] = item.get("Subject", "(No subject)") + + # Sender + from_data = item.get("From", {}).get("Mailbox", {}) + if not from_data: + from_data = item.get("Sender", {}).get("Mailbox", {}) + result["from"] = from_data.get("EmailAddress", "") + result["from_name"] = from_data.get("Name", "") + + result["date"] = item.get( + "DateTimeSent", + item.get("DateTimeReceived", item.get("DateTimeCreated", "")), + ) + result["is_read"] = item.get("IsRead", False) + result["has_attachments"] = item.get("HasAttachments", False) + result["importance"] = item.get("Importance", "Normal") + + # Body + body_val = item.get("Body", {}).get("Value", "") + body_type = item.get("Body", {}).get("BodyType", "Text") + if body_type == "HTML": + result["has_links"] = bool(extract_links_from_html(body_val)) + result["body"] = html_to_text(body_val) + else: + result["body"] = body_val + result["body_type"] = body_type + + # To recipients + for r in item.get("ToRecipients", []): + name = r.get("Name", "") + addr = r.get("EmailAddress", "") + if name and addr: + result["to"].append(f"{name} <{addr}>") + elif addr: + result["to"].append(addr) + + # CC recipients + for r in item.get("CcRecipients", []): + name = r.get("Name", "") + addr = r.get("EmailAddress", "") + if name and addr: + result["cc"].append(f"{name} <{addr}>") + elif addr: + result["cc"].append(addr) + + # BCC recipients + for r in item.get("BccRecipients", []): + name = r.get("Name", "") + addr = r.get("EmailAddress", "") + if name and addr: + result["bcc"].append(f"{name} <{addr}>") + elif addr: + result["bcc"].append(addr) + + # Attachments (with IDs for download) + for att in item.get("Attachments", []): + result["attachments"].append( + { + "name": att.get("Name", ""), + "size": att.get("Size", 0), + "content_type": att.get("ContentType", ""), + "attachment_id": att.get("AttachmentId", {}).get("Id", ""), + "is_inline": att.get("IsInline", False), + } + ) + + # Meeting-specific fields + item_type = item.get("__type", "") + if any( + t in item_type + for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation", "CalendarItem") + ): + result["location"] = item.get( + "Location", + item.get("EnhancedLocation", {}).get("DisplayName", ""), + ) + result["start"] = item.get("Start", "") + result["end"] = item.get("End", "") + result["required_attendees"] = [] + result["optional_attendees"] = [] + for a in item.get("RequiredAttendees", []): + mb = a.get("Mailbox", {}) + name = mb.get("Name", "") + addr = mb.get("EmailAddress", "") + if name and addr: + result["required_attendees"].append(f"{name} <{addr}>") + elif addr: + result["required_attendees"].append(addr) + for a in item.get("OptionalAttendees", []): + mb = a.get("Mailbox", {}) + name = mb.get("Name", "") + addr = mb.get("EmailAddress", "") + if name and addr: + result["optional_attendees"].append(f"{name} <{addr}>") + elif addr: + result["optional_attendees"].append(addr) + + return result + + return result + + +def _build_recipient_list(emails: str) -> list[dict]: + """Build a list of Mailbox dicts from a comma-separated email string. + + NOTE: OWA rejects __type annotations on recipient Mailbox dicts for + CreateItem (Message). Use plain dicts without __type. + """ + recipients = [] + for addr in emails.split(","): + addr = addr.strip() + if addr: + recipients.append( + { + "Name": addr, + "EmailAddress": addr, + "RoutingType": "SMTP", + } + ) + return recipients + + +# ------------------------------------------------------------------ +# Tools +# ------------------------------------------------------------------ + + +@mcp.tool() +def get_emails( + folder: str = "Inbox", + limit: int = 10, + offset: int = 0, + include_body: bool = False, + unread_only: bool = False, + ids_only: bool = False, + ctx: Context = None, +) -> str: + """Get emails from a mailbox folder. + + Args: + folder: Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom name). + limit: Maximum number of emails to return (default 10, max 50). + offset: Number of emails to skip for pagination. + include_body: If True, fetch full body for each email (slower). + unread_only: If True, only return unread emails. + ids_only: If True, return only item IDs and dates (compact, for bulk ops). + Max limit raised to 500 in this mode. + """ + try: + client = _get_client(ctx) + + # Clamp limit (higher cap for ids_only) + max_limit = 500 if ids_only else 50 + if limit > max_limit: + limit = max_limit + + # Resolve folder name to ID + folder_id = client.get_folder_id(folder) + if not folder_id: + return json.dumps({"error": f"Folder '{folder}' not found."}) + + # Build FindItem payload + if ids_only: + item_shape = { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + "AdditionalProperties": [ + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "DateTimeReceived", + }, + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Subject", + }, + ], + } + else: + item_shape = { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + } + + find_body = { + "__type": "FindItemRequest:#Exchange", + "ItemShape": item_shape, + "ParentFolderIds": [ + {"__type": "FolderId:#Exchange", "Id": folder_id} + ], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": limit, + }, + "SortOrder": [ + { + "__type": "SortResults:#Exchange", + "Order": "Descending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "DateTimeReceived", + }, + } + ], + } + + # Add filter for unread only + if unread_only: + find_body["Restriction"] = { + "__type": "RestrictionType:#Exchange", + "Item": { + "__type": "IsEqualTo:#Exchange", + "FieldURIOrConstant": { + "__type": "FieldURIOrConstantType:#Exchange", + "Item": { + "__type": "ConstantValueType:#Exchange", + "Value": "false", + }, + }, + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "IsRead", + }, + }, + } + + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": find_body, + } + + data = client.request("FindItem", payload) + + items = [] + for msg in client.extract_items(data): + if "RootFolder" in msg and "Items" in msg["RootFolder"]: + items = msg["RootFolder"]["Items"] + break + + if not items: + return json.dumps( + {"item_ids": [], "count": 0} if ids_only + else {"emails": [], "count": 0} + ) + + if ids_only: + result = [] + for item in items: + result.append({ + "item_id": item.get("ItemId", {}).get("Id", ""), + "date": item.get("DateTimeReceived", ""), + "subject": item.get("Subject", ""), + }) + return json.dumps({"item_ids": result, "count": len(result)}) + + emails = [] + for item in items: + email = _extract_email_summary(item) + + if include_body and email["item_id"]: + details = _get_item_details(client, email["item_id"]) + email["to"] = details["to"] + email["cc"] = details["cc"] + email["body"] = details["body"] + email["has_links"] = details.get("has_links", False) + + if email.get("is_meeting"): + email["location"] = details.get("location", "") + email["start"] = details.get("start", "") or email.get("start", "") + email["end"] = details.get("end", "") or email.get("end", "") + email["required_attendees"] = details.get("required_attendees", []) + email["optional_attendees"] = details.get("optional_attendees", []) + + emails.append(email) + + return json.dumps({"emails": emails, "count": len(emails)}) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to get emails: {e}"}) + + +@mcp.tool() +def get_email(item_id: str, ctx: Context = None) -> str: + """Get a single email with full body and details. + + Args: + item_id: The Exchange ItemId of the email to retrieve. + """ + try: + client = _get_client(ctx) + result = _get_item_details(client, item_id) + return json.dumps(result) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to get email: {e}"}) + + +@mcp.tool() +def send_email( + to: str, + subject: str, + body: str, + cc: str = "", + bcc: str = "", + importance: str = "Normal", + is_html: bool = False, + ctx: Context = None, +) -> str: + """Send a new email. + + Args: + to: Comma-separated list of recipient email addresses. + subject: Email subject line. + body: Email body text. + cc: Comma-separated CC recipients (optional). + bcc: Comma-separated BCC recipients (optional). + importance: Email importance: Low, Normal, or High (default Normal). + is_html: If True, body is treated as HTML. Otherwise plain text. + """ + try: + client = _get_client(ctx) + + to_recipients = _build_recipient_list(to) + if not to_recipients: + return json.dumps({"error": "At least one recipient is required."}) + + message = { + "__type": "Message:#Exchange", + "Subject": subject, + "Body": { + "__type": "BodyContentType:#Exchange", + "BodyType": "HTML" if is_html else "Text", + "Value": body, + }, + "Importance": importance, + "ToRecipients": to_recipients, + } + + if cc: + message["CcRecipients"] = _build_recipient_list(cc) + if bcc: + message["BccRecipients"] = _build_recipient_list(bcc) + + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [message], + "MessageDisposition": "SendAndSaveCopy", + }, + } + + data = client.request("CreateItem", payload) + + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Success": + return json.dumps({"success": True, "message": "Email sent."}) + elif msg.get("ResponseClass") == "Error": + return json.dumps( + {"error": msg.get("MessageText", "Failed to send email.")} + ) + + return json.dumps({"success": True, "message": "Email sent."}) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to send email: {e}"}) + + +@mcp.tool() +def reply_email( + item_id: str, + body: str, + reply_all: bool = False, + ctx: Context = None, +) -> str: + """Reply to an email. + + Args: + item_id: The Exchange ItemId of the email to reply to. + body: Reply body text. + reply_all: If True, reply to all recipients. Otherwise reply to sender only. + """ + try: + client = _get_client(ctx) + + change_key = _get_change_key(client, item_id) + if not change_key: + return json.dumps({"error": "Could not resolve item ChangeKey."}) + + item_type = "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange" + + reply_item = { + "__type": item_type, + "ReferenceItemId": { + "__type": "ItemId:#Exchange", + "Id": item_id, + "ChangeKey": change_key, + }, + "NewBodyContent": { + "__type": "BodyContentType:#Exchange", + "BodyType": "Text", + "Value": body, + }, + } + + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [reply_item], + "MessageDisposition": "SendAndSaveCopy", + }, + } + + data = client.request("CreateItem", payload) + + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + return json.dumps( + {"error": msg.get("MessageText", "Failed to send reply.")} + ) + + return json.dumps({"success": True, "message": "Reply sent."}) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to reply: {e}"}) + + +@mcp.tool() +def forward_email( + item_id: str, + to: str, + body: str = "", + ctx: Context = None, +) -> str: + """Forward an email to other recipients. + + Args: + item_id: The Exchange ItemId of the email to forward. + to: Comma-separated list of recipient email addresses. + body: Optional message to include above the forwarded content. + """ + try: + client = _get_client(ctx) + + to_recipients = _build_recipient_list(to) + if not to_recipients: + return json.dumps({"error": "At least one recipient is required."}) + + change_key = _get_change_key(client, item_id) + if not change_key: + return json.dumps({"error": "Could not resolve item ChangeKey."}) + + forward_item = { + "__type": "ForwardItem:#Exchange", + "ReferenceItemId": { + "__type": "ItemId:#Exchange", + "Id": item_id, + "ChangeKey": change_key, + }, + "ToRecipients": to_recipients, + } + + if body: + forward_item["NewBodyContent"] = { + "__type": "BodyContentType:#Exchange", + "BodyType": "Text", + "Value": body, + } + + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [forward_item], + "MessageDisposition": "SendAndSaveCopy", + }, + } + + data = client.request("CreateItem", payload) + + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + return json.dumps( + {"error": msg.get("MessageText", "Failed to forward.")} + ) + + return json.dumps({"success": True, "message": "Email forwarded."}) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to forward email: {e}"}) + + +@mcp.tool() +def mark_email_read( + item_ids: list[str], + is_read: bool = True, + ctx: Context = None, +) -> str: + """Mark one or more emails as read or unread. + + Args: + item_ids: List of Exchange ItemIds to update. + is_read: True to mark as read, False to mark as unread (default True). + """ + try: + client = _get_client(ctx) + + changes = [] + for iid in item_ids: + change_key = _get_change_key(client, iid) + item_id_dict = {"__type": "ItemId:#Exchange", "Id": iid} + if change_key: + item_id_dict["ChangeKey"] = change_key + changes.append( + { + "__type": "ItemChange:#Exchange", + "ItemId": item_id_dict, + "Updates": [ + { + "__type": "SetItemField:#Exchange", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "IsRead", + }, + "Item": { + "__type": "Message:#Exchange", + "IsRead": is_read, + }, + } + ], + } + ) + + payload = { + "__type": "UpdateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "UpdateItemRequest:#Exchange", + "ItemChanges": changes, + "ConflictResolution": "AutoResolve", + "MessageDisposition": "SaveOnly", + }, + } + + data = client.request("UpdateItem", payload) + + errors = [] + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + errors.append(msg.get("MessageText", "Unknown error")) + + if errors: + return json.dumps({"error": "; ".join(errors)}) + + status = "read" if is_read else "unread" + return json.dumps( + { + "success": True, + "message": f"Marked {len(item_ids)} email(s) as {status}.", + } + ) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to update emails: {e}"}) + + +@mcp.tool() +def move_email( + item_ids: list[str], + target_folder: str, + ctx: Context = None, +) -> str: + """Move one or more emails to a different folder. + + Args: + item_ids: List of Exchange ItemIds to move. + target_folder: Destination folder name (e.g. Inbox, Sent, Deleted, or custom). + """ + try: + client = _get_client(ctx) + + folder_id = client.get_folder_id(target_folder) + if not folder_id: + return json.dumps({"error": f"Folder '{target_folder}' not found."}) + + items = [ + {"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids + ] + + payload = { + "__type": "MoveItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "MoveItemRequest:#Exchange", + "ItemIds": items, + "ToFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": { + "__type": "FolderId:#Exchange", + "Id": folder_id, + }, + }, + }, + } + + data = client.request("MoveItem", payload) + + errors = [] + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + errors.append(msg.get("MessageText", "Unknown error")) + + if errors: + return json.dumps({"error": "; ".join(errors)}) + + return json.dumps( + { + "success": True, + "message": f"Moved {len(item_ids)} email(s) to '{target_folder}'.", + } + ) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to move emails: {e}"}) + + +@mcp.tool() +def delete_email( + item_ids: list[str], + permanent: bool = False, + ctx: Context = None, +) -> str: + """Delete one or more emails. + + Args: + item_ids: List of Exchange ItemIds to delete. + permanent: If True, permanently delete (HardDelete). Otherwise move to Deleted Items. + """ + try: + client = _get_client(ctx) + + items = [ + {"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids + ] + + payload = { + "__type": "DeleteItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "DeleteItemRequest:#Exchange", + "ItemIds": items, + "DeleteType": "HardDelete" if permanent else "MoveToDeletedItems", + }, + } + + data = client.request("DeleteItem", payload) + + errors = [] + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + errors.append(msg.get("MessageText", "Unknown error")) + + if errors: + return json.dumps({"error": "; ".join(errors)}) + + action = "permanently deleted" if permanent else "moved to Deleted Items" + return json.dumps( + { + "success": True, + "message": f"{len(item_ids)} email(s) {action}.", + } + ) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to delete emails: {e}"}) + + +@mcp.tool() +def download_attachments( + item_id: str, + target_folder: str = "/tmp/attachments", + ctx: Context = None, +) -> str: + """Download all file attachments from an email to disk. + + Args: + item_id: The Exchange ItemId of the email to download attachments from. + target_folder: Local directory to save files (default /tmp/attachments). + """ + import os + + try: + client = _get_client(ctx) + + # Get email details to find attachment IDs + details = _get_item_details(client, item_id) + attachments = details.get("attachments", []) + + if not attachments: + return json.dumps({"success": True, "downloaded": [], "count": 0, + "message": "No attachments found."}) + + # Filter to non-inline file attachments with IDs + file_attachments = [ + a for a in attachments + if a.get("attachment_id") and not a.get("is_inline", False) + ] + + if not file_attachments: + return json.dumps({"success": True, "downloaded": [], "count": 0, + "message": "No downloadable file attachments."}) + + os.makedirs(target_folder, exist_ok=True) + + downloaded = [] + errors = [] + used_names: set[str] = set() + + for att in file_attachments: + try: + content, filename, content_type = client.download_file( + att["attachment_id"] + ) + + # Sanitize filename + filename = os.path.basename(filename) + if not filename: + filename = att.get("name", "attachment") or "attachment" + + # Handle collisions + base_name = filename + name_part, _, ext_part = base_name.rpartition(".") + if not name_part: + name_part = base_name + ext_part = "" + + counter = 1 + while filename.lower() in used_names: + if ext_part: + filename = f"{name_part}_{counter}.{ext_part}" + else: + filename = f"{name_part}_{counter}" + counter += 1 + + used_names.add(filename.lower()) + + filepath = os.path.join(target_folder, filename) + with open(filepath, "wb") as f: + f.write(content) + + downloaded.append({ + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + }) + except Exception as e: + errors.append({ + "name": att.get("name", "unknown"), + "error": str(e), + }) + + result = { + "success": len(errors) == 0, + "downloaded": downloaded, + "count": len(downloaded), + } + if errors: + result["errors"] = errors + + return json.dumps(result) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to download attachments: {e}"}) + + +@mcp.tool() +def get_email_links( + item_id: str, + ctx: Context = None, +) -> str: + """Extract all hyperlinks from an email's HTML body. + + Args: + item_id: The Exchange ItemId of the email to extract links from. + """ + try: + client = _get_client(ctx) + + # Fetch email with HTML body + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + "BodyType": "HTML", + "AdditionalProperties": [ + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Subject", + }, + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Body", + }, + ], + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } + + data = client.request("GetItem", payload) + + subject = "" + links = [] + + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + subject = item.get("Subject", "") + body_val = item.get("Body", {}).get("Value", "") + links = extract_links_from_html(body_val) + break + + return json.dumps({ + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + }) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to extract links: {e}"}) + + +# ------------------------------------------------------------------ +# Search (full-text search via FindItem + ContainsExpression) +# ------------------------------------------------------------------ + +_FIELD_URI = { + "subject": "item:Subject", + "body": "item:Body", + "from": "message:From", +} + + +def _contains_expression(field_uri: str, value: str) -> dict: + return { + "__type": "ContainsExpression:#Exchange", + "ContainmentMode": "Substring", + "ContainmentComparison": "IgnoreCase", + "Path": { + "__type": "PathToUnindexedField:#Exchange", + "FieldURI": field_uri, + }, + "Constant": { + "__type": "ConstantValue:#Exchange", + "Value": value, + }, + } + + +def _build_search_restriction(query: str, scope: str) -> dict | None: + if scope == "all": + return { + "__type": "Or:#Exchange", + "Items": [ + _contains_expression("item:Subject", query), + _contains_expression("item:Body", query), + ], + } + field_uri = _FIELD_URI.get(scope) + if not field_uri: + return None + return _contains_expression(field_uri, query) + + +@mcp.tool( + name="search_emails", + description=( + "Search emails by text across one or all folders. " + "Supports searching by subject, body, sender, or all fields combined." + ), +) +async def search_emails( + ctx: Context, + query: str, + folder_id: str | None = None, + max_results: int = 20, + search_scope: str = "all", +) -> str: + """Search emails by text content. + + Args: + query: The text to search for. + folder_id: Optional folder ID to limit the search. When omitted, + searches across all mail folders (deep traversal). + max_results: Maximum number of results to return (default 20, max 100). + search_scope: Where to search — ``"all"`` (subject + body, default), + ``"subject"``, ``"body"``, or ``"from"``. + """ + client = _get_client(ctx) + + if not query.strip(): + return json.dumps({"error": "query must not be empty", "results": []}) + + max_results = max(1, min(max_results, 100)) + if search_scope not in ("all", "subject", "body", "from"): + return json.dumps({"error": f"invalid search_scope: {search_scope}", "results": []}) + + restriction = _build_search_restriction(query.strip(), search_scope) + if restriction is None: + return json.dumps({"error": f"unsupported search_scope: {search_scope}", "results": []}) + + if folder_id: + parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}] + else: + parent_folder = [{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}] + + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + "BodyType": "HTML", + }, + "ParentFolderIds": parent_folder, + "Restriction": restriction, + "Traversal": "Shallow" if folder_id else "Deep", + }, + } + + try: + data = client.request("FindItem", payload) + results = [] + for msg in client.extract_items(data): + items = msg.get("RootFolder", {}).get("Items", []) + if not items: + items = msg.get("Items", []) + for item in items: + if len(results) >= max_results: + break + summary = _extract_email_summary(item) + # add body_preview + body_html = item.get("Body", {}).get("_", "") + body_type = item.get("Body", {}).get("BodyType", "HTML") + if body_type == "HTML" and body_html: + import re + body_preview = re.sub(r"<[^>]+>", " ", body_html) + body_preview = " ".join(body_preview.split())[:200] + else: + body_preview = body_html[:200] + summary["body_preview"] = body_preview + summary["folder_id"] = item.get("ParentFolderId", {}).get("Id", "") + results.append(summary) + if len(results) >= max_results: + break + + return json.dumps({ + "query": query.strip(), + "search_scope": search_scope, + "folder_id": folder_id or "all", + "total_results": len(results), + "results": results, + }, ensure_ascii=False) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Search failed: {e}"}) diff --git a/exchange_mcp/tools/folders.py b/exchange_mcp/tools/folders.py new file mode 100644 index 0000000..f81a0af --- /dev/null +++ b/exchange_mcp/tools/folders.py @@ -0,0 +1,431 @@ +"""Folder tools for the Exchange MCP server. + +Ports the get-folders.py logic into an MCP tool using OWAClient. +""" + +import json + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient +from exchange_mcp.server_lifecycle import require_client + + +_DISTINGUISHED_NAMES = { + "msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems", + "junkemail", "outbox", "calendar", "contacts", "tasks", "notes", + "journal", "searchfolders", +} + +_HEADER_TZ = { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", + }, + }, +} + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) + + +def _folder_id_dict(folder_id: str) -> dict: + """Build a typed FolderId or DistinguishedFolderId dict.""" + if folder_id.lower() in _DISTINGUISHED_NAMES: + return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id} + return {"__type": "FolderId:#Exchange", "Id": folder_id} + + +@mcp.tool() +def check_session(ctx: Context = None) -> str: + """Check whether the current OWA session is authenticated. + + Makes a lightweight GetFolder call on the inbox. Returns session + status, the mailbox display name, and cookie file path. + + Returns: + JSON object with authenticated (bool), mailbox name, and details. + """ + client = _get_client(ctx) + + payload = { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "Default", + }, + "FolderIds": [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"} + ], + }, + } + + try: + data = client.request("GetFolder", payload) + except Exception as e: + return json.dumps({ + "authenticated": False, + "error": str(e), + "cookie_file": str(client.cookie_file), + }) + + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps({ + "authenticated": True, + "mailbox": folder.get("DisplayName", ""), + "unread": folder.get("UnreadCount", 0), + "cookie_file": str(client.cookie_file), + }) + + return json.dumps({ + "authenticated": False, + "error": "Unexpected response", + "cookie_file": str(client.cookie_file), + }) + + +@mcp.tool() +def get_folders( + parent_folder_id: str = "msgfolderroot", + recursive: bool = False, + ctx: Context = None, +) -> str: + """List mail folders from the Exchange mailbox. + + Args: + parent_folder_id: Parent folder to list children of. + Defaults to "msgfolderroot" (top-level). Can be a + distinguished folder name or a raw folder ID. + recursive: If True, traverse all subfolders recursively (Deep). + If False, only list immediate children (Shallow). + + Returns: + JSON array of folder objects with: name, id, total_count, + unread_count, child_folder_count. + """ + client = _get_client(ctx) + + traversal = "Deep" if recursive else "Shallow" + + # Determine parent folder id type + # Distinguished folder names are short lowercase strings + parent_folder = _folder_id_dict(parent_folder_id) + + payload = { + "__type": "FindFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "Default", + }, + "ParentFolderIds": [parent_folder], + "Traversal": traversal, + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": 0, + "MaxEntriesReturned": 200, + }, + }, + } + + try: + data = client.request("FindFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + folders = [] + for msg in client.extract_items(data): + if "RootFolder" in msg and "Folders" in msg["RootFolder"]: + for f in msg["RootFolder"]["Folders"]: + folders.append({ + "name": f.get("DisplayName", "Unknown"), + "id": f.get("FolderId", {}).get("Id", ""), + "total_count": f.get("TotalCount", 0), + "unread_count": f.get("UnreadCount", 0), + "child_folder_count": f.get("ChildFolderCount", 0), + }) + + return json.dumps(folders, ensure_ascii=False) + + +@mcp.tool() +def create_folder( + name: str, + parent_folder_id: str = "msgfolderroot", + ctx: Context = None, +) -> str: + """Create a new mail folder. + + Args: + name: Display name for the new folder. + parent_folder_id: Parent folder to create under. + Defaults to "msgfolderroot" (top-level). Can be a + distinguished folder name or a raw folder ID. + + Returns: + JSON object with the created folder's id and name. + """ + client = _get_client(ctx) + + payload = { + "__type": "CreateFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "CreateFolderRequest:#Exchange", + "ParentFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": _folder_id_dict(parent_folder_id), + }, + "Folders": [ + { + "__type": "Folder:#Exchange", + "DisplayName": name, + "FolderClass": "IPF.Note", + } + ], + }, + } + + try: + data = client.request_header_payload("CreateFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps({ + "success": True, + "name": name, + "id": folder.get("FolderId", {}).get("Id", ""), + }) + + return json.dumps({"error": "Unexpected response", "raw": str(data)}) + + +@mcp.tool() +def rename_folder( + folder_id: str, + new_name: str, + ctx: Context = None, +) -> str: + """Rename an existing mail folder. + + Args: + folder_id: The Exchange folder ID to rename (from get_folders). + new_name: New display name for the folder. + + Returns: + JSON object with success status and new folder id. + """ + client = _get_client(ctx) + + payload = { + "__type": "UpdateFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "UpdateFolderRequest:#Exchange", + "FolderChanges": [ + { + "__type": "FolderChange:#Exchange", + "FolderId": { + "__type": "FolderId:#Exchange", + "Id": folder_id, + }, + "Updates": [ + { + "__type": "SetFolderField:#Exchange", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "FolderDisplayName", + }, + "Folder": { + "__type": "Folder:#Exchange", + "DisplayName": new_name, + }, + } + ], + } + ], + }, + } + + try: + data = client.request_header_payload("UpdateFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps({ + "success": True, + "new_name": new_name, + "id": folder.get("FolderId", {}).get("Id", ""), + }) + + return json.dumps({"error": "Unexpected response", "raw": str(data)}) + + +@mcp.tool() +def empty_folder( + folder_id: str, + delete_sub_folders: bool = False, + permanent: bool = False, + ctx: Context = None, +) -> str: + """Empty all items from a mail folder. + + Args: + folder_id: The Exchange folder ID to empty (from get_folders). + delete_sub_folders: If True, also delete sub-folders. Default False. + permanent: If True, permanently delete items (HardDelete). + Otherwise move to Deleted Items. Default False. + + Returns: + JSON object with success status. + """ + client = _get_client(ctx) + + delete_type = "HardDelete" if permanent else "MoveToDeletedItems" + + payload = { + "__type": "EmptyFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "EmptyFolderRequest:#Exchange", + "FolderIds": [ + {"__type": "FolderId:#Exchange", "Id": folder_id} + ], + "DeleteType": delete_type, + "DeleteSubFolders": delete_sub_folders, + "SuppressReadReceipt": True, + }, + } + + try: + data = client.request_header_payload("EmptyFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Success": + return json.dumps({"success": True, "folder_id": folder_id}) + + return json.dumps({"error": "Unexpected response", "raw": str(data)}) + + +@mcp.tool() +def delete_folder( + folder_id: str, + permanent: bool = False, + ctx: Context = None, +) -> str: + """Delete a mail folder. + + Args: + folder_id: The Exchange folder ID to delete (from get_folders). + permanent: If True, permanently delete (HardDelete). + Otherwise move to Deleted Items. Default False. + + Returns: + JSON object with success status. + """ + client = _get_client(ctx) + + delete_type = "HardDelete" if permanent else "MoveToDeletedItems" + + payload = { + "__type": "DeleteFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "DeleteFolderRequest:#Exchange", + "FolderIds": [ + {"__type": "FolderId:#Exchange", "Id": folder_id} + ], + "DeleteType": delete_type, + }, + } + + try: + data = client.request_header_payload("DeleteFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Success": + return json.dumps({"success": True, "folder_id": folder_id}) + + return json.dumps({"error": "Unexpected response", "raw": str(data)}) + + +@mcp.tool() +def move_folder( + folder_id: str, + target_parent_folder_id: str = "msgfolderroot", + ctx: Context = None, +) -> str: + """Move a mail folder to a different parent folder. + + Args: + folder_id: The Exchange folder ID to move (from get_folders). + target_parent_folder_id: Destination parent folder. + Defaults to "msgfolderroot" (top-level). Can be a + distinguished folder name or a raw folder ID. + + Returns: + JSON object with success status and new folder ID. + """ + client = _get_client(ctx) + + payload = { + "__type": "MoveFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "MoveFolderRequest:#Exchange", + "FolderIds": [ + {"__type": "FolderId:#Exchange", "Id": folder_id} + ], + "ToFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": _folder_id_dict(target_parent_folder_id), + }, + }, + } + + try: + data = client.request_header_payload("MoveFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps({ + "success": True, + "folder_id": folder.get("FolderId", {}).get("Id", ""), + }) + + return json.dumps({"error": "Unexpected response", "raw": str(data)}) diff --git a/exchange_mcp/tools/people.py b/exchange_mcp/tools/people.py new file mode 100644 index 0000000..083a14b --- /dev/null +++ b/exchange_mcp/tools/people.py @@ -0,0 +1,119 @@ +"""People / directory search tools for the Exchange MCP server. + +Ports the find-person.py logic into an MCP tool using OWAClient. +""" + +import json + +from mcp.server.fastmcp import Context + +from exchange_mcp.server import mcp, AppContext +from exchange_mcp.owa_client import OWAClient +from exchange_mcp.server_lifecycle import require_client + + +def _get_client(ctx: Context) -> OWAClient: + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) + + +def _parse_person(resolution: dict) -> dict: + """Parse person data from a ResolveNames resolution entry. + + Preserves the exact logic from find-person.py parse_person(). + """ + mailbox = resolution.get("Mailbox", {}) + contact = resolution.get("Contact", {}) + + person = { + "name": mailbox.get("Name", contact.get("DisplayName", "")), + "email": mailbox.get("EmailAddress", ""), + "type": mailbox.get("MailboxType", ""), + "first_name": contact.get("GivenName", ""), + "last_name": contact.get("Surname", ""), + "job_title": contact.get("JobTitle", ""), + "department": contact.get("Department", ""), + "company": contact.get("CompanyName", ""), + "office": contact.get("OfficeLocation", ""), + "manager": "", + "manager_email": "", + "phones": {}, + "address": {}, + "direct_reports": [], + "alias": contact.get("Alias", ""), + } + + # Phone numbers + for phone in contact.get("PhoneNumbers", []): + key = phone.get("Key", "") + number = phone.get("PhoneNumber", "") + if number: + person["phones"][key] = number + + # Physical address + for addr in contact.get("PhysicalAddresses", []): + if addr.get("Key") == "Business": + parts = [] + if addr.get("Street"): + parts.append(addr["Street"]) + if addr.get("City"): + parts.append(addr["City"]) + if addr.get("PostalCode"): + parts.append(addr["PostalCode"]) + if addr.get("CountryOrRegion"): + parts.append(addr["CountryOrRegion"]) + if parts: + person["address"] = { + "street": addr.get("Street", ""), + "city": addr.get("City", ""), + "postal_code": addr.get("PostalCode", ""), + "country": addr.get("CountryOrRegion", ""), + "full": ", ".join(parts), + } + + # Manager + manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {}) + if manager_data: + person["manager"] = manager_data.get("Name", "") + person["manager_email"] = manager_data.get("EmailAddress", "") + elif contact.get("Manager"): + person["manager"] = contact.get("Manager", "") + + # Direct reports + for report in contact.get("DirectReports", []): + person["direct_reports"].append({ + "name": report.get("Name", ""), + "email": report.get("EmailAddress", ""), + }) + + return person + + +@mcp.tool() +def find_person(query: str, ctx: Context) -> str: + """Search for people in the corporate directory. + + Looks up employees by name, email, department, or keyword using the + Exchange ResolveNames API against Active Directory. + + Args: + query: Name, email address, or keyword to search for. + + Returns: + JSON array of matching people with contact details (name, email, + job_title, department, company, office, phones, address, manager, + direct_reports, alias). + """ + client = _get_client(ctx) + + try: + resolutions = client.resolve_names(query) + except Exception as e: + return json.dumps({"error": str(e)}) + + if not resolutions: + return json.dumps([]) + + people = [_parse_person(r) for r in resolutions] + return json.dumps(people, ensure_ascii=False) diff --git a/exchange_mcp/utils.py b/exchange_mcp/utils.py new file mode 100644 index 0000000..d263c96 --- /dev/null +++ b/exchange_mcp/utils.py @@ -0,0 +1,133 @@ +"""Shared utility functions for the Exchange MCP server. + +Extracts duplicated helpers from the standalone scripts: +html_to_text, date/time formatting and parsing. +""" + +import html +import re +from datetime import datetime + + +def html_to_text(html_content: str) -> str: + """Convert HTML to plain text. + + Strips scripts, styles, converts
/

/

to newlines, + removes remaining tags, and unescapes HTML entities. + """ + if not html_content: + return "" + text = re.sub( + r"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE + ) + text = re.sub( + r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE + ) + text = re.sub(r"", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + text = html.unescape(text) + text = re.sub(r"\n\s*\n", "\n\n", text) + text = re.sub(r"[ \t]+", " ", text) + return text.strip() + + +def extract_links_from_html(html_content: str) -> list[dict]: + """Extract hyperlinks from HTML content. + + Finds text patterns, excludes mailto:, cid:, + javascript:, and fragment-only (#) links. Deduplicates by URL. + + Returns list of {url, text} dicts. + """ + if not html_content: + return [] + + # Match text + pattern = re.compile( + r']*href=["\']([^"\']+)["\'][^>]*>(.*?)', + re.DOTALL | re.IGNORECASE, + ) + + seen: set[str] = set() + links: list[dict] = [] + + for url_raw, text_raw in pattern.findall(html_content): + url = html.unescape(url_raw).strip() + + # Skip non-http links + if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#": + continue + # Skip fragment-only links + if url.startswith("#"): + continue + + if url in seen: + continue + seen.add(url) + + # Clean link text: strip tags and whitespace + text = re.sub(r"<[^>]+>", "", text_raw) + text = html.unescape(text).strip() + + links.append({"url": url, "text": text}) + + return links + + +def format_datetime(dt_str: str) -> str: + """Format an ISO datetime string as 'YYYY-MM-DD HH:MM'. + + Strips timezone suffixes (Z, +offset) for cleaner display. + """ + if not dt_str: + return "" + if "T" in dt_str: + date_part, time_part = dt_str.split("T", 1) + time_part = time_part.split("Z")[0].split("+")[0] + return f"{date_part} {time_part[:5]}" + return dt_str + + +def format_date(dt_str: str) -> str: + """Extract the date portion from an ISO datetime string.""" + if not dt_str: + return "" + if "T" in dt_str: + return dt_str.split("T")[0] + return dt_str + + +def parse_date(date_str: str) -> datetime: + """Parse a date string in common formats. + + Supports: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY. + """ + formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"] + for fmt in formats: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + raise ValueError(f"Could not parse date: {date_str}") + + +def parse_iso_datetime(dt_str: str) -> datetime: + """Parse an ISO datetime string to a naive datetime. + + Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats, + stripping any timezone suffix. + """ + if "T" in dt_str: + clean = dt_str.split("Z")[0].split("+")[0] + return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S") + return datetime.strptime(dt_str, "%Y-%m-%d") + + +def format_attendee(name: str, email: str) -> str: + """Format an attendee as 'Name ' or just the email.""" + if name and email and not email.startswith("/O="): + return f"{name} <{email}>" + return name or email or "" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..433a7a4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "exchange-mcp-server" +version = "1.0.1" +description = "MCP server for Exchange / OWA - email, calendar, directory, availability" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +keywords = ["mcp", "exchange", "owa", "outlook", "email", "calendar"] +classifiers = [ + "Programming Language :: Python :: 3", +] +dependencies = [ + "mcp>=1.0.0", + "requests>=2.28.0", + "sse-starlette>=2.0.0", +] + +[project.urls] +Homepage = "https://git.kosyrev.name/zoo/owa-mcp" +Repository = "https://git.kosyrev.name/zoo/owa-mcp" + +[project.optional-dependencies] +http = ["uvicorn>=0.30.0"] + +[project.scripts] +exchange-mcp-server = "exchange_mcp.server:main" + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["exchange_mcp*"] -- 2.52.0 From 40dea9a2c252684712b642a4abd1c110ce1588e1 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 01:58:46 +0300 Subject: [PATCH 2/8] w --- README.md | 273 +----------------- exchange_mcp/__init__.py | 1 - exchange_mcp/tools/__init__.py | 1 - owa_mcp/__init__.py | 1 + {exchange_mcp => owa_mcp}/models.py | 0 {exchange_mcp => owa_mcp}/owa_client.py | 2 - {exchange_mcp => owa_mcp}/server.py | 46 +-- {exchange_mcp => owa_mcp}/server_lifecycle.py | 4 +- owa_mcp/tools/__init__.py | 1 + {exchange_mcp => owa_mcp}/tools/analytics.py | 8 +- {exchange_mcp => owa_mcp}/tools/auth.py | 8 +- .../tools/availability.py | 8 +- {exchange_mcp => owa_mcp}/tools/calendar.py | 10 +- {exchange_mcp => owa_mcp}/tools/email.py | 10 +- {exchange_mcp => owa_mcp}/tools/folders.py | 8 +- {exchange_mcp => owa_mcp}/tools/people.py | 8 +- {exchange_mcp => owa_mcp}/utils.py | 2 +- pyproject.toml | 12 +- 18 files changed, 69 insertions(+), 334 deletions(-) delete mode 100644 exchange_mcp/__init__.py delete mode 100644 exchange_mcp/tools/__init__.py create mode 100644 owa_mcp/__init__.py rename {exchange_mcp => owa_mcp}/models.py (100%) rename {exchange_mcp => owa_mcp}/owa_client.py (99%) rename {exchange_mcp => owa_mcp}/server.py (82%) rename {exchange_mcp => owa_mcp}/server_lifecycle.py (98%) create mode 100644 owa_mcp/tools/__init__.py rename {exchange_mcp => owa_mcp}/tools/analytics.py (98%) rename {exchange_mcp => owa_mcp}/tools/auth.py (93%) rename {exchange_mcp => owa_mcp}/tools/availability.py (98%) rename {exchange_mcp => owa_mcp}/tools/calendar.py (99%) rename {exchange_mcp => owa_mcp}/tools/email.py (99%) rename {exchange_mcp => owa_mcp}/tools/folders.py (98%) rename {exchange_mcp => owa_mcp}/tools/people.py (94%) rename {exchange_mcp => owa_mcp}/utils.py (98%) diff --git a/README.md b/README.md index c61f58f..ae385b0 100644 --- a/README.md +++ b/README.md @@ -1,287 +1,44 @@ # OWA MCP Server -MCP (Model Context Protocol) server for any Microsoft Exchange / OWA (Outlook Web Access) deployment. Gives LLM agents access to email, calendar, directory search, folders, availability, and meeting analytics via 30 tools. - -Works with any on-premise or hosted Exchange server that exposes OWA. - -## Quick Start - -```bash -# Install -pip install -e . - -# Add to your MCP client config with --owa-url -# Then use the login tool to paste your session cookies -``` - -## Install - -Add to your MCP client config. Replace `https://owa.example.com` with your OWA URL. - -**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): - -```json -{ - "mcpServers": { - "exchange": { - "command": "uvx", - "args": [ - "exchange-mcp-server", - "--owa-url", "https://owa.example.com" - ] - } - } -} -``` - -**Cursor** (`.cursor/mcp.json`): - -```json -{ - "mcpServers": { - "exchange": { - "command": "uvx", - "args": [ - "exchange-mcp-server", - "--owa-url", "https://owa.example.com" - ] - } - } -} -``` - -**Claude Code** (`.mcp.json`): - -```json -{ - "mcpServers": { - "exchange": { - "command": "uvx", - "args": [ - "exchange-mcp-server", - "--owa-url", "https://owa.example.com" - ] - } - } -} -``` +MCP server for any Microsoft Exchange / OWA deployment. 30 tools for email, +calendar, directory search, folder management, availability, and analytics. ## CLI Arguments | Argument | Default | Required | Description | |---|---|---|---| -| `--owa-url` | — | Yes | Base URL of the OWA instance (e.g. `https://owa.example.com`) | +| `--owa-url` | — | Yes | Base URL of the OWA instance | | `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | | `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | -| `--token-hash` | — | For HTTP | SHA-256 hash of the bearer token | +| `--token-hash` | — | HTTP only | SHA-256 hash of the bearer token | | `--port` | `8000` | — | HTTP/SSE listener port | | `--host` | `127.0.0.1` | — | HTTP/SSE listener host | | `--enabled-tools` | — | No | Comma-separated whitelist of tool names | | `--disabled-tools` | — | No | Comma-separated blacklist of tool names | -### Tool filtering examples - -```bash -# Hide only the most dangerous tools -exchange-mcp-server --owa-url https://owa.example.com \ - --disabled-tools "send_email,delete_email,cancel_meeting" - -# Allow only email and calendar tools -exchange-mcp-server --owa-url https://owa.example.com \ - --enabled-tools "get_emails,get_email,send_email,get_calendar_events,create_meeting" - -# Whitelist a set, then remove one from it -exchange-mcp-server --owa-url https://owa.example.com \ - --enabled-tools "get_emails,get_email,send_email,delete_email" \ - --disabled-tools "delete_email" -# → only get_emails, get_email, send_email remain -``` - -## HTTP Transport - -Run the server over HTTP (streamable-http or SSE) instead of stdio. This is -useful when the MCP client cannot spawn a local process, or when you want to -expose the server on a network. - -### Installation - -```bash -pip install -e ".[http]" # adds uvicorn + sse-starlette -``` - -### Start the server - -```bash -# Compute the token hash once (store only the hash, never the plaintext token) -TOKEN_HASH=$(echo -n 'my-secret-token' | sha256sum | cut -d' ' -f1) - -# stdio (default, for local MCP clients) -exchange-mcp-server --owa-url https://owa.example.com - -# streamable-http (recommended for HTTP) -exchange-mcp-server \ - --owa-url https://owa.example.com \ - --transport streamable-http \ - --token-hash "$TOKEN_HASH" \ - --port 8000 \ - --host 127.0.0.1 - -# SSE -exchange-mcp-server \ - --owa-url https://owa.example.com \ - --transport sse \ - --token-hash "$TOKEN_HASH" \ - --port 8000 -``` - -### CLI arguments - -| Argument | Default | Required | Description | -|---|---|---|---| -| `--owa-url` | — | Yes | Base URL of the OWA instance (e.g. `https://owa.example.com`) | -| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | -| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | -| `--token-hash` | — | For HTTP | SHA-256 hash of the bearer token | -| `--port` | `8000` | — | HTTP/SSE listener port | -| `--host` | `127.0.0.1` | — | HTTP/SSE listener host | -| `--enabled-tools` | — | No | Comma-separated whitelist of tool names | -| `--disabled-tools` | — | No | Comma-separated blacklist of tool names | - -### Client configuration (HTTP) - -```json -{ - "mcpServers": { - "exchange": { - "url": "http://127.0.0.1:8000/mcp", - "headers": { - "Authorization": "Bearer my-secret-token" - } - } - } -} -``` - -### Authentication - -The server stores only the SHA-256 hash of your token — the plaintext token -never touches the filesystem. Every HTTP request must include: - -``` -Authorization: Bearer -``` - -The server hashes the incoming token and compares it with the stored hash. -Pass the hash via `--token-hash`: - -```bash -echo -n 'my-secret-token' | sha256sum -# → 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 -``` - -### Security notes - -- Always use a strong random token. -- Bind to `127.0.0.1` unless you have a reverse proxy / firewall. -- For `streamable-http` the client must send `Accept: text/event-stream`. -- The `--token-hash` is required whenever `--transport` is not `stdio`. - -## Login - -The `login` MCP tool accepts session cookies that you copy from your browser. - -1. Log into OWA in your browser. -2. Open DevTools (F12) → Application → Cookies → select your OWA domain. -3. Copy all cookies as `name=value` pairs (one per line). -4. Call the `login` tool with the cookies string. - -Example cookies string: - -``` -X-OWA-CANARY=abc123 -CookieAuth1=def456 -... -``` - -``` -login(cookies="X-OWA-CANARY=abc123\nCookieAuth1=def456\n...") -``` - -On success, cookies are saved to `session-cookies.txt` and all tools become usable. When the session expires, repeat the steps. - ## Tools (30) ### Email (10) -| Tool | Description | -|---|---| -| `get_emails` | List emails from a folder with filtering | -| `get_email` | Get full email content by ID | -| `send_email` | Send a new email | -| `reply_email` | Reply to an email | -| `forward_email` | Forward an email | -| `delete_email` | Delete an email | -| `move_email` | Move email to another folder | -| `mark_email_read` | Mark email as read/unread | -| `download_attachments` | Download file attachments from an email | -| `get_email_links` | Extract hyperlinks from an email body | +`get_emails`, `get_email`, `send_email`, `reply_email`, `forward_email`, +`delete_email`, `move_email`, `mark_email_read`, `download_attachments`, +`get_email_links`, `search_emails` ### Calendar (7) -| Tool | Description | -|---|---| -| `get_calendar_events` | Get events in a date range (supports recurring expansion) | -| `create_meeting` | Create a meeting with attendees | -| `update_meeting` | Update an existing meeting | -| `cancel_meeting` | Cancel a meeting and notify attendees | -| `respond_to_meeting` | Accept, decline, or tentatively accept | -| `download_event_attachments` | Download file attachments from a calendar event | -| `get_event_links` | Extract hyperlinks from the event description | +`get_calendar_events`, `create_meeting`, `update_meeting`, `cancel_meeting`, +`respond_to_meeting`, `download_event_attachments`, `get_event_links` ### Directory (1) -| Tool | Description | -|---|---| -| `find_person` | Search people in Active Directory | +`find_person` ### Folders (7) -| Tool | Description | -|---|---| -| `get_folders` | List mail folders with unread counts | -| `create_folder` | Create a new mail folder | -| `rename_folder` | Rename an existing folder | -| `empty_folder` | Empty all items from a folder | -| `delete_folder` | Delete a mail folder | -| `move_folder` | Move a folder to a different parent | -| `check_session` | Check if the OWA session is authenticated | +`get_folders`, `create_folder`, `rename_folder`, `empty_folder`, `delete_folder`, +`move_folder`, `check_session` ### Availability (2) -| Tool | Description | -|---|---| -| `find_free_time` | Find free slots in your calendar | -| `find_meeting_time` | Find common free slots for multiple people | +`find_free_time`, `find_meeting_time` ### Analytics (2) -| Tool | Description | -|---|---| -| `get_meeting_stats` | Meeting count statistics for multiple people | -| `get_meeting_contacts` | Connection matrix — who you meet with most | +`get_meeting_stats`, `get_meeting_contacts` ### Auth (1) -| Tool | Description | -|---|---| -| `login` | Authenticate to OWA using browser session cookies | - -## Files - -``` -exchange_mcp/ - server.py # FastMCP server entry point - owa_client.py # OWA HTTP client - tools/ - email.py # Email tools - calendar.py # Calendar tools - people.py # Directory search - folders.py # Folder management & session check - availability.py # Free time / meeting time - analytics.py # Meeting stats & contacts - auth.py # Login tool -pyproject.toml # Package config -``` +`login` diff --git a/exchange_mcp/__init__.py b/exchange_mcp/__init__.py deleted file mode 100644 index ef83d87..0000000 --- a/exchange_mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Exchange MCP Server - access OWA email, calendar, directory via MCP.""" diff --git a/exchange_mcp/tools/__init__.py b/exchange_mcp/tools/__init__.py deleted file mode 100644 index d0c4846..0000000 --- a/exchange_mcp/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""MCP tool modules for Exchange.""" diff --git a/owa_mcp/__init__.py b/owa_mcp/__init__.py new file mode 100644 index 0000000..506a731 --- /dev/null +++ b/owa_mcp/__init__.py @@ -0,0 +1 @@ +"""OWA MCP Server — access OWA email, calendar, directory via MCP.""" diff --git a/exchange_mcp/models.py b/owa_mcp/models.py similarity index 100% rename from exchange_mcp/models.py rename to owa_mcp/models.py diff --git a/exchange_mcp/owa_client.py b/owa_mcp/owa_client.py similarity index 99% rename from exchange_mcp/owa_client.py rename to owa_mcp/owa_client.py index b6b8bd3..c4a5e28 100644 --- a/exchange_mcp/owa_client.py +++ b/owa_mcp/owa_client.py @@ -9,7 +9,6 @@ mirroring what the OWA web frontend sends. """ import json -import os import time from urllib.parse import quote, urlparse from pathlib import Path @@ -55,7 +54,6 @@ class OWAClient: ): self.cookie_file = Path( cookie_file - or os.environ.get("EXCHANGE_COOKIE_FILE", "") or str(Path(__file__).parent.parent / "session-cookies.txt") ) self.owa_url = (owa_url or "").rstrip("/") diff --git a/exchange_mcp/server.py b/owa_mcp/server.py similarity index 82% rename from exchange_mcp/server.py rename to owa_mcp/server.py index 30ca197..6e1afbc 100644 --- a/exchange_mcp/server.py +++ b/owa_mcp/server.py @@ -1,4 +1,4 @@ -"""Exchange MCP Server. +"""OWA MCP Server. Exposes OWA email, calendar, directory, and availability tools via MCP. Uses FastMCP with a lifespan context manager to share a single OWAClient @@ -13,19 +13,12 @@ CLI arguments: --host HTTP host (default 127.0.0.1) --enabled-tools Comma-separated whitelist of tool names --disabled-tools Comma-separated blacklist of tool names - -Tool filtering (env fallback): - EXCHANGE_ENABLED_TOOLS — comma-separated whitelist (used only when - --enabled-tools is not provided). - EXCHANGE_DISABLED_TOOLS — comma-separated blacklist (used only when - --disabled-tools is not provided). """ from __future__ import annotations import argparse import logging -import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass @@ -34,8 +27,8 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import FastMCP -from exchange_mcp.owa_client import OWAClient -from exchange_mcp.server_lifecycle import ( +from owa_mcp.owa_client import OWAClient +from owa_mcp.server_lifecycle import ( HashTokenVerifier, SessionKeepalive, apply_tool_filters, @@ -55,7 +48,7 @@ class AppContext: async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Initialise the OWA client on first connection, then keep the session alive. - A :class:`~exchange_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 repeats every 5 minutes until the server shuts down. """ @@ -80,13 +73,13 @@ _lifespan_cookie_file: str | None = None # ── Module-level MCP server (imported by all tool modules) ──────────────── -mcp: FastMCP = FastMCP("exchange", lifespan=app_lifespan) +mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan) def _parse_args(argv=None) -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( - description="Exchange MCP Server — OWA/Exchange integration via MCP", + description="OWA MCP Server — OWA/Exchange integration via MCP", ) parser.add_argument( "--transport", @@ -184,38 +177,25 @@ def main(argv=None) -> None: raise SystemExit(1) # ── Register tools (imports populate module-level `mcp`) ─────────────── - import exchange_mcp.tools.email # noqa: E402, F401 - import exchange_mcp.tools.calendar # noqa: E402, F401 - import exchange_mcp.tools.people # noqa: E402, F401 - import exchange_mcp.tools.folders # noqa: E402, F401 - import exchange_mcp.tools.availability # noqa: E402, F401 - import exchange_mcp.tools.analytics # noqa: E402, F401 - import exchange_mcp.tools.auth # noqa: E402, F401 + import owa_mcp.tools.email # noqa: E402, F401 + import owa_mcp.tools.calendar # noqa: E402, F401 + import owa_mcp.tools.people # noqa: E402, F401 + import owa_mcp.tools.folders # noqa: E402, F401 + import owa_mcp.tools.availability # noqa: E402, F401 + import owa_mcp.tools.analytics # noqa: E402, F401 + import owa_mcp.tools.auth # noqa: E402, F401 # ── Auth for HTTP transports ─────────────────────────────────────────── _configure_auth(mcp, args.token_hash) # ── Tool filtering ───────────────────────────────────────────────────── - # CLI args take precedence over env vars. enabled: list[str] | None = None if args.enabled_tools is not None: enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] - elif os.environ.get("EXCHANGE_ENABLED_TOOLS", "").strip(): - enabled = [ - t.strip() - for t in os.environ["EXCHANGE_ENABLED_TOOLS"].split(",") - if t.strip() - ] disabled: list[str] | None = None if args.disabled_tools is not None: disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] - elif os.environ.get("EXCHANGE_DISABLED_TOOLS", "").strip(): - disabled = [ - t.strip() - for t in os.environ["EXCHANGE_DISABLED_TOOLS"].split(",") - if t.strip() - ] if enabled or disabled: apply_tool_filters(mcp, enabled=enabled, disabled=disabled) diff --git a/exchange_mcp/server_lifecycle.py b/owa_mcp/server_lifecycle.py similarity index 98% rename from exchange_mcp/server_lifecycle.py rename to owa_mcp/server_lifecycle.py index e1e26ec..c37ed74 100644 --- a/exchange_mcp/server_lifecycle.py +++ b/owa_mcp/server_lifecycle.py @@ -1,4 +1,4 @@ -"""Shared lifecycle utilities for the Exchange MCP server. +"""Shared lifecycle utilities for the OWA MCP server. Centralises three concerns so that tool modules do not duplicate code: @@ -17,7 +17,7 @@ import asyncio import logging from typing import TYPE_CHECKING -from exchange_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.owa_client import OWAClient, SessionExpiredError from mcp.server.auth.provider import AccessToken, TokenVerifier if TYPE_CHECKING: diff --git a/owa_mcp/tools/__init__.py b/owa_mcp/tools/__init__.py new file mode 100644 index 0000000..58c1ffb --- /dev/null +++ b/owa_mcp/tools/__init__.py @@ -0,0 +1 @@ +"""MCP tool modules for OWA.""" diff --git a/exchange_mcp/tools/analytics.py b/owa_mcp/tools/analytics.py similarity index 98% rename from exchange_mcp/tools/analytics.py rename to owa_mcp/tools/analytics.py index 672c499..cadbade 100644 --- a/exchange_mcp/tools/analytics.py +++ b/owa_mcp/tools/analytics.py @@ -1,4 +1,4 @@ -"""Analytics tools for the Exchange MCP server. +"""Analytics tools for the OWA MCP server. Provides meeting statistics and connection matrix analysis using GetUserAvailability and GetItem APIs. @@ -10,9 +10,9 @@ from datetime import datetime, timedelta, date from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient -from exchange_mcp.server_lifecycle import require_client +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient +from owa_mcp.server_lifecycle import require_client def _get_client(ctx: Context) -> OWAClient: diff --git a/exchange_mcp/tools/auth.py b/owa_mcp/tools/auth.py similarity index 93% rename from exchange_mcp/tools/auth.py rename to owa_mcp/tools/auth.py index 2d66930..df85919 100644 --- a/exchange_mcp/tools/auth.py +++ b/owa_mcp/tools/auth.py @@ -1,4 +1,4 @@ -"""Authentication tool for the Exchange MCP server. +"""Authentication tool for the OWA MCP server. Provides a `login` tool that accepts session cookies from the user, saves them to disk, loads them into the OWA client, and verifies the session. @@ -12,9 +12,9 @@ import json from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient, SessionExpiredError -from exchange_mcp.server_lifecycle import require_client +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server_lifecycle import require_client def _get_app_ctx(ctx: Context) -> AppContext: diff --git a/exchange_mcp/tools/availability.py b/owa_mcp/tools/availability.py similarity index 98% rename from exchange_mcp/tools/availability.py rename to owa_mcp/tools/availability.py index 46da79b..aa8bd02 100644 --- a/exchange_mcp/tools/availability.py +++ b/owa_mcp/tools/availability.py @@ -1,4 +1,4 @@ -"""Availability / free-time tools for the Exchange MCP server. +"""Availability / free-time tools for the OWA MCP server. Ports find-free-time.py and find-meeting-time.py logic into MCP tools using OWAClient. @@ -9,9 +9,9 @@ from datetime import datetime, timedelta from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient -from exchange_mcp.server_lifecycle import require_client +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient +from owa_mcp.server_lifecycle import require_client def _get_client(ctx: Context) -> OWAClient: diff --git a/exchange_mcp/tools/calendar.py b/owa_mcp/tools/calendar.py similarity index 99% rename from exchange_mcp/tools/calendar.py rename to owa_mcp/tools/calendar.py index 9df712b..06dabd7 100644 --- a/exchange_mcp/tools/calendar.py +++ b/owa_mcp/tools/calendar.py @@ -1,4 +1,4 @@ -"""Calendar tools for the Exchange MCP server. +"""Calendar tools for the OWA MCP server. Provides MCP tools for calendar event retrieval, meeting creation, update, cancellation, and meeting response management via OWA API. @@ -11,10 +11,10 @@ from datetime import datetime, timedelta from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient -from exchange_mcp.server_lifecycle import require_client -from exchange_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient +from owa_mcp.server_lifecycle import require_client +from owa_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html def _get_client(ctx: Context) -> OWAClient: diff --git a/exchange_mcp/tools/email.py b/owa_mcp/tools/email.py similarity index 99% rename from exchange_mcp/tools/email.py rename to owa_mcp/tools/email.py index 4cb2095..681fa03 100644 --- a/exchange_mcp/tools/email.py +++ b/owa_mcp/tools/email.py @@ -1,4 +1,4 @@ -"""Email tools for the Exchange MCP server. +"""Email tools for the OWA MCP server. Provides tools for reading, sending, replying, forwarding, and managing emails via the OWA Exchange API. @@ -8,10 +8,10 @@ import json from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient, SessionExpiredError -from exchange_mcp.server_lifecycle import require_client -from exchange_mcp.utils import html_to_text, extract_links_from_html +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server_lifecycle import require_client +from owa_mcp.utils import html_to_text, extract_links_from_html def _get_client(ctx: Context) -> OWAClient: diff --git a/exchange_mcp/tools/folders.py b/owa_mcp/tools/folders.py similarity index 98% rename from exchange_mcp/tools/folders.py rename to owa_mcp/tools/folders.py index f81a0af..6ff7603 100644 --- a/exchange_mcp/tools/folders.py +++ b/owa_mcp/tools/folders.py @@ -1,4 +1,4 @@ -"""Folder tools for the Exchange MCP server. +"""Folder tools for the OWA MCP server. Ports the get-folders.py logic into an MCP tool using OWAClient. """ @@ -7,9 +7,9 @@ import json from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient -from exchange_mcp.server_lifecycle import require_client +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient +from owa_mcp.server_lifecycle import require_client _DISTINGUISHED_NAMES = { diff --git a/exchange_mcp/tools/people.py b/owa_mcp/tools/people.py similarity index 94% rename from exchange_mcp/tools/people.py rename to owa_mcp/tools/people.py index 083a14b..88a7db8 100644 --- a/exchange_mcp/tools/people.py +++ b/owa_mcp/tools/people.py @@ -1,4 +1,4 @@ -"""People / directory search tools for the Exchange MCP server. +"""People / directory search tools for the OWA MCP server. Ports the find-person.py logic into an MCP tool using OWAClient. """ @@ -7,9 +7,9 @@ import json from mcp.server.fastmcp import Context -from exchange_mcp.server import mcp, AppContext -from exchange_mcp.owa_client import OWAClient -from exchange_mcp.server_lifecycle import require_client +from owa_mcp.server import mcp, AppContext +from owa_mcp.owa_client import OWAClient +from owa_mcp.server_lifecycle import require_client def _get_client(ctx: Context) -> OWAClient: diff --git a/exchange_mcp/utils.py b/owa_mcp/utils.py similarity index 98% rename from exchange_mcp/utils.py rename to owa_mcp/utils.py index d263c96..8f8f996 100644 --- a/exchange_mcp/utils.py +++ b/owa_mcp/utils.py @@ -1,4 +1,4 @@ -"""Shared utility functions for the Exchange MCP server. +"""Shared utility functions for the OWA MCP server. Extracts duplicated helpers from the standalone scripts: html_to_text, date/time formatting and parsing. diff --git a/pyproject.toml b/pyproject.toml index 433a7a4..205b3b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] -name = "exchange-mcp-server" -version = "1.0.1" -description = "MCP server for Exchange / OWA - email, calendar, directory, availability" +name = "owa-mcp-server" +version = "0.1.0" +description = "MCP server for OWA — email, calendar, directory, availability" readme = "README.md" license = "MIT" requires-python = ">=3.10" -keywords = ["mcp", "exchange", "owa", "outlook", "email", "calendar"] +keywords = ["mcp", "owa", "outlook", "exchange", "email", "calendar"] classifiers = [ "Programming Language :: Python :: 3", ] @@ -23,7 +23,7 @@ Repository = "https://git.kosyrev.name/zoo/owa-mcp" http = ["uvicorn>=0.30.0"] [project.scripts] -exchange-mcp-server = "exchange_mcp.server:main" +owa-mcp-server = "owa_mcp.server:main" [build-system] requires = ["setuptools>=68.0"] @@ -31,4 +31,4 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] -include = ["exchange_mcp*"] +include = ["owa_mcp*"] -- 2.52.0 From 4207b38b1675c0197ac89d33a7163f3dfb7e9ccc Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 02:05:16 +0300 Subject: [PATCH 3/8] w --- .dprint.json | 21 ++ owa_mcp/models.py | 7 + owa_mcp/owa_client.py | 44 ++--- owa_mcp/server.py | 17 +- owa_mcp/server_lifecycle.py | 62 +++--- owa_mcp/tools/analytics.py | 269 +++++++++++++------------ owa_mcp/tools/auth.py | 36 ++-- owa_mcp/tools/availability.py | 360 +++++++++++++++++++--------------- owa_mcp/tools/calendar.py | 338 +++++++++++++++++-------------- owa_mcp/tools/email.py | 129 +++++++----- owa_mcp/tools/folders.py | 123 +++++++----- owa_mcp/tools/people.py | 12 +- owa_mcp/utils.py | 4 +- pyproject.toml | 8 + 14 files changed, 823 insertions(+), 607 deletions(-) create mode 100644 .dprint.json diff --git a/.dprint.json b/.dprint.json new file mode 100644 index 0000000..696337b --- /dev/null +++ b/.dprint.json @@ -0,0 +1,21 @@ +{ + "excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"], + "includes": ["**/*.{md,json,py}"], + "indentWidth": 2, + "json": { + "deno": true + }, + "lineWidth": 80, + "markdown": { + "deno": true + }, + "plugins": [ + "https://plugins.dprint.dev/json-0.22.0.wasm", + "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/ruff-0.7.20.wasm" + ], + "ruff": { + "lineLength": 88, + "targetVersion": "py310" + } +} diff --git a/owa_mcp/models.py b/owa_mcp/models.py index 7d23b9f..1bcf051 100644 --- a/owa_mcp/models.py +++ b/owa_mcp/models.py @@ -9,6 +9,7 @@ from typing import TypedDict class Email(TypedDict, total=False): """An email message.""" + subject: str sender: str sender_name: str @@ -35,6 +36,7 @@ class Email(TypedDict, total=False): class CalendarEvent(TypedDict, total=False): """A calendar event / meeting.""" + subject: str start: str end: str @@ -54,6 +56,7 @@ class CalendarEvent(TypedDict, total=False): class Person(TypedDict, total=False): """A person from the Exchange directory.""" + name: str email: str mailbox_type: str @@ -73,6 +76,7 @@ class Person(TypedDict, total=False): class Folder(TypedDict, total=False): """A mail folder.""" + name: str id: str total_count: int @@ -82,6 +86,7 @@ class Folder(TypedDict, total=False): class FreeSlot(TypedDict): """A free time slot.""" + date: str start: str end: str @@ -90,6 +95,7 @@ class FreeSlot(TypedDict): class Availability(TypedDict, total=False): """Availability data for a single attendee.""" + email: str busy_slots: int free_slots: int @@ -98,6 +104,7 @@ class Availability(TypedDict, total=False): class MeetingResult(TypedDict, total=False): """Result of creating/updating a meeting.""" + success: bool subject: str date: str diff --git a/owa_mcp/owa_client.py b/owa_mcp/owa_client.py index c4a5e28..244a553 100644 --- a/owa_mcp/owa_client.py +++ b/owa_mcp/owa_client.py @@ -10,14 +10,15 @@ mirroring what the OWA web frontend sends. import json import time -from urllib.parse import quote, urlparse from pathlib import Path +from urllib.parse import quote, urlparse import requests class SessionExpiredError(Exception): """Raised when the OWA session has expired (HTTP 401/440 or HTML redirect).""" + pass @@ -53,14 +54,11 @@ class OWAClient: owa_url: str | None = None, ): self.cookie_file = Path( - cookie_file - or str(Path(__file__).parent.parent / "session-cookies.txt") + cookie_file or str(Path(__file__).parent.parent / "session-cookies.txt") ) self.owa_url = (owa_url or "").rstrip("/") if not self.owa_url: - raise ValueError( - "OWA URL must be provided via --owa-url or owa_url=…" - ) + raise ValueError("OWA URL must be provided via --owa-url or owa_url=…") self._session = requests.Session() self._loaded = False self.user_email: str = "" @@ -81,9 +79,10 @@ class OWAClient: """ now_ms = int(time.time() * 1000) client_req_id = f"{self._client_id}_{now_ms}" - now_str = time.strftime( - "%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000) - ) + f"{now_ms % 1000:03d}" + now_str = ( + time.strftime("%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)) + + f"{now_ms % 1000:03d}" + ) return { # Basic "accept": "*/*", @@ -129,9 +128,7 @@ class OWAClient: @property def _client_id(self) -> str: """Extract ClientId from session cookies.""" - cid = next( - (c.value for c in self._session.cookies if c.name == "ClientId"), "" - ) + cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "") return cid if cid else "00000000000000000000000000000000" # ------------------------------------------------------------------ @@ -153,7 +150,9 @@ class OWAClient: cookies[name] = value if not cookies: - raise SessionExpiredError("Cookie file is empty. Use the login MCP tool to authenticate.") + raise SessionExpiredError( + "Cookie file is empty. Use the login MCP tool to authenticate." + ) self._session = requests.Session() self._session.cookies.update(cookies) @@ -229,12 +228,11 @@ class OWAClient: set_cookie = resp.headers.get("Set-Cookie", "") if "X-OWA-CANARY=" in set_cookie: import re - m = re.search(r'X-OWA-CANARY=([^;]+)', set_cookie) + + m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie) if m: host = urlparse(self.owa_url).hostname or "owa.b1.ru" - self._session.cookies.set( - "X-OWA-CANARY", m.group(1), domain=host - ) + self._session.cookies.set("X-OWA-CANARY", m.group(1), domain=host) # Persist any cookie changes self._save_cookies() except Exception: @@ -273,7 +271,9 @@ class OWAClient: # Detect session expiry if resp.status_code in (401, 440): - raise SessionExpiredError("Session expired (HTTP {}).".format(resp.status_code)) + raise SessionExpiredError( + f"Session expired (HTTP {resp.status_code})." + ) if "text/html" in resp.headers.get("Content-Type", ""): body_snippet = resp.text[:300] if resp.text else "" @@ -335,9 +335,7 @@ class OWAClient: raise SessionExpiredError(f"Download failed: {exc}") from exc if resp.status_code in (401, 440): - raise SessionExpiredError( - f"Session expired (HTTP {resp.status_code})." - ) + raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).") if "text/html" in resp.headers.get("Content-Type", ""): raise SessionExpiredError( @@ -469,9 +467,7 @@ class OWAClient: # ResolveNames (directory search / attendee resolution) # ------------------------------------------------------------------ - def resolve_names( - self, query: str, *, full_contact: bool = True - ) -> list[dict]: + def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]: """Call ResolveNames to search the directory. Returns the list of Resolution dicts from the API, each containing diff --git a/owa_mcp/server.py b/owa_mcp/server.py index 6e1afbc..e88023a 100644 --- a/owa_mcp/server.py +++ b/owa_mcp/server.py @@ -23,7 +23,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass -from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.provider import TokenVerifier from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import FastMCP @@ -41,6 +41,7 @@ logger = logging.getLogger(__name__) @dataclass class AppContext: """Shared application state available to all tools via lifespan context.""" + client: OWAClient | None = None @@ -177,13 +178,13 @@ def main(argv=None) -> None: raise SystemExit(1) # ── Register tools (imports populate module-level `mcp`) ─────────────── - import owa_mcp.tools.email # noqa: E402, F401 - import owa_mcp.tools.calendar # noqa: E402, F401 - import owa_mcp.tools.people # noqa: E402, F401 - import owa_mcp.tools.folders # noqa: E402, F401 - import owa_mcp.tools.availability # noqa: E402, F401 - import owa_mcp.tools.analytics # noqa: E402, F401 - import owa_mcp.tools.auth # noqa: E402, F401 + import owa_mcp.tools.analytics # noqa: E402, F401 + import owa_mcp.tools.auth # noqa: E402, F401 + import owa_mcp.tools.availability # noqa: E402, F401 + import owa_mcp.tools.calendar # noqa: E402, F401 + import owa_mcp.tools.email # noqa: E402, F401 + import owa_mcp.tools.folders # noqa: E402, F401 + import owa_mcp.tools.people # noqa: E402, F401 # ── Auth for HTTP transports ─────────────────────────────────────────── _configure_auth(mcp, args.token_hash) diff --git a/owa_mcp/server_lifecycle.py b/owa_mcp/server_lifecycle.py index c37ed74..00333be 100644 --- a/owa_mcp/server_lifecycle.py +++ b/owa_mcp/server_lifecycle.py @@ -17,9 +17,10 @@ import asyncio import logging from typing import TYPE_CHECKING -from owa_mcp.owa_client import OWAClient, SessionExpiredError from mcp.server.auth.provider import AccessToken, TokenVerifier +from owa_mcp.owa_client import OWAClient, SessionExpiredError + if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -28,6 +29,7 @@ logger = logging.getLogger(__name__) # ── Client factory ─────────────────────────────────────────────────────── + def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient: """Create and return a fully configured :class:`OWAClient`. @@ -50,6 +52,7 @@ def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient # ── Token verifier ──────────────────────────────────────────────────────── + class HashTokenVerifier(TokenVerifier): """Bearer-token verifier backed by a SHA-256 hash. @@ -59,11 +62,12 @@ class HashTokenVerifier(TokenVerifier): """ def __init__(self, token_hash: str) -> None: - import hashlib + self._expected_hash = token_hash.strip().lower() async def verify_token(self, token: str) -> AccessToken | None: import hashlib + if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: return AccessToken( token=token, @@ -75,6 +79,7 @@ class HashTokenVerifier(TokenVerifier): # ── Lifespan helper ────────────────────────────────────────────────────── + class _LazyClient: """Thin wrapper that resolves the OWAClient on first access. @@ -103,8 +108,7 @@ def require_client(client: OWAClient | None) -> OWAClient: """ if client is None: raise RuntimeError( - "OWA client is not initialised. " - "Pass --owa-url when starting the server." + "OWA client is not initialised. Pass --owa-url when starting the server." ) return client @@ -127,7 +131,9 @@ class SessionKeepalive: picked up without restarting the server. """ - def __init__(self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL) -> None: + def __init__( + self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL + ) -> None: self._client = client self._interval = interval self._task: asyncio.Task | None = None @@ -168,37 +174,47 @@ class SessionKeepalive: case the user re-authenticated while the server was running. """ try: - self._client.request("GetFolder", { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "IdOnly", + self._client.request( + "GetFolder", + { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "inbox", + } + ], }, - "FolderIds": [{ - "__type": "DistinguishedFolderId:#Exchange", - "Id": "inbox", - }], }, - }) + ) logger.debug("Session keepalive ping succeeded") except SessionExpiredError: - logger.warning("Session expired during keepalive — reloading cookies from disk") + logger.warning( + "Session expired during keepalive — reloading cookies from disk" + ) try: self._client._ensure_loaded() except Exception as exc: - logger.warning("Failed to reload cookies after keepalive failure: %s", exc) + logger.warning( + "Failed to reload cookies after keepalive failure: %s", exc + ) except Exception as exc: logger.debug("Keepalive ping failed (non-critical): %s", exc) # ── Tool filtering ─────────────────────────────────────────────────────── + def apply_tool_filters( server: FastMCP, enabled: list[str] | None = None, diff --git a/owa_mcp/tools/analytics.py b/owa_mcp/tools/analytics.py index cadbade..1c7872d 100644 --- a/owa_mcp/tools/analytics.py +++ b/owa_mcp/tools/analytics.py @@ -5,13 +5,13 @@ using GetUserAvailability and GetItem APIs. """ import json -from collections import Counter, defaultdict -from datetime import datetime, timedelta, date +from collections import Counter +from datetime import date, datetime, timedelta from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -25,6 +25,7 @@ def _get_client(ctx: Context) -> OWAClient: # Internal: resolve names to emails # ------------------------------------------------------------------ + def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: """Resolve a name/email to (display_name, email). Returns ('','') on failure.""" resolutions = client.resolve_names(name) @@ -38,6 +39,7 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: # Internal: query GetUserAvailability in chunks # ------------------------------------------------------------------ + def _get_availability_events( client: OWAClient, emails: list[str], @@ -54,77 +56,84 @@ def _get_availability_events( results: dict[str, list[dict]] = {email: [] for email in emails} # Batch people - email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)] + email_batches = [ + emails[i : i + batch_size] for i in range(0, len(emails), batch_size) + ] for batch in email_batches: current = start while current < end: chunk_end = min(current + timedelta(days=chunk_days), end) - mailbox_data = [{ - '__type': 'MailboxData:#Exchange', - 'Email': { - '__type': 'EmailAddress:#Exchange', - 'Address': email, - }, - 'AttendeeType': 'Required', - } for email in batch] + mailbox_data = [ + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": email, + }, + "AttendeeType": "Required", + } + for email in batch + ] payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': mailbox_data, - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{current}T00:00:00', - 'EndTime': f'{chunk_end}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": mailbox_data, + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{current}T00:00:00", + "EndTime": f"{chunk_end}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } try: - data = client.request('GetUserAvailability', payload) - body = data.get('Body', {}) - for i, fb_resp in enumerate(body.get('FreeBusyResponseArray', [])): + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) + for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])): if i >= len(batch): break email = batch[i] - fb_view = fb_resp.get('FreeBusyView', {}) - cal_events = fb_view.get('CalendarEventArray', {}) + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) if isinstance(cal_events, dict): - items = cal_events.get('Items', []) + items = cal_events.get("Items", []) elif isinstance(cal_events, list): items = cal_events else: items = [] for event in items: - s = event.get('StartTime', '') - bt = event.get('BusyType', '') - if s and bt != 'Free': - details = event.get('CalendarEventDetails', {}) - subject = details.get('Subject', '') if details else '' - results[email].append({ - 'subject': subject, - 'start_date': s[:10], - 'busy_type': bt, - }) + s = event.get("StartTime", "") + bt = event.get("BusyType", "") + if s and bt != "Free": + details = event.get("CalendarEventDetails", {}) + subject = details.get("Subject", "") if details else "" + results[email].append( + { + "subject": subject, + "start_date": s[:10], + "busy_type": bt, + } + ) except Exception: pass @@ -137,6 +146,7 @@ def _get_availability_events( # Tool: get_meeting_stats # ------------------------------------------------------------------ + @mcp.tool() def get_meeting_stats( people: str, @@ -161,13 +171,13 @@ def get_meeting_stats( client = _get_client(ctx) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) # Resolve names - name_list = [n.strip() for n in people.split(',') if n.strip()] + name_list = [n.strip() for n in people.split(",") if n.strip()] if not name_list: return json.dumps({"error": "No people specified."}) @@ -199,43 +209,51 @@ def get_meeting_stats( stats = [] for display, email in resolved: if not email or email not in avail: - stats.append({ - "name": display, - "email": email or "not_found", - "total_meetings": 0, - "meetings_per_workday": 0, - "days_with_meetings": 0, - "workdays": workdays, - }) + stats.append( + { + "name": display, + "email": email or "not_found", + "total_meetings": 0, + "meetings_per_workday": 0, + "days_with_meetings": 0, + "workdays": workdays, + } + ) continue events = avail[email] total = len(events) - unique_days = len(set(ev['start_date'] for ev in events)) + unique_days = len(set(ev["start_date"] for ev in events)) avg = round(total / workdays, 1) - stats.append({ - "name": display, - "email": email, - "total_meetings": total, - "meetings_per_workday": avg, - "days_with_meetings": unique_days, - "workdays": workdays, - }) + stats.append( + { + "name": display, + "email": email, + "total_meetings": total, + "meetings_per_workday": avg, + "days_with_meetings": unique_days, + "workdays": workdays, + } + ) # Sort by total descending stats.sort(key=lambda x: x["total_meetings"], reverse=True) - return json.dumps({ - "period": {"start": start_date, "end": end_date, "workdays": workdays}, - "stats": stats, - }, ensure_ascii=False) + return json.dumps( + { + "period": {"start": start_date, "end": end_date, "workdays": workdays}, + "stats": stats, + }, + ensure_ascii=False, + ) # ------------------------------------------------------------------ # Tool: get_meeting_contacts # ------------------------------------------------------------------ + @mcp.tool() def get_meeting_contacts( start_date: str, @@ -261,8 +279,8 @@ def get_meeting_contacts( client = _get_client(ctx) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) @@ -270,7 +288,9 @@ def get_meeting_contacts( # to count subject occurrences (handles recurring meetings) own_email = client.user_email.lower() if client.user_email else "" if not own_email: - return json.dumps({"error": "User email not available. Call the login tool first."}) + return json.dumps( + {"error": "User email not available. Call the login tool first."} + ) folder_id = client.get_folder_id("calendar") if not folder_id: @@ -280,7 +300,7 @@ def get_meeting_contacts( avail_result = _get_availability_events(client, [client.user_email], sd, ed) expanded_events = avail_result.get(client.user_email, []) - subject_counts = Counter(ev['subject'] for ev in expanded_events) + subject_counts = Counter(ev["subject"] for ev in expanded_events) total_expanded = len(expanded_events) # Step 2: Find master calendar items for each unique subject @@ -290,51 +310,51 @@ def get_meeting_contacts( while len(subject_to_id) < len(subject_counts): payload = { - '__type': 'FindItemJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", }, - 'Body': { - '__type': 'FindItemRequest:#Exchange', - 'ItemShape': { - '__type': 'ItemResponseShape:#Exchange', - 'BaseShape': 'Default', + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "Default", }, - 'ParentFolderIds': [ - {'__type': 'FolderId:#Exchange', 'Id': folder_id} + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": 200, + }, + "SortOrder": [ + { + "__type": "SortResults:#Exchange", + "Order": "Descending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Start", + }, + } ], - 'Traversal': 'Shallow', - 'Paging': { - '__type': 'IndexedPageView:#Exchange', - 'BasePoint': 'Beginning', - 'Offset': offset, - 'MaxEntriesReturned': 200, - }, - 'SortOrder': [{ - '__type': 'SortResults:#Exchange', - 'Order': 'Descending', - 'Path': { - '__type': 'PropertyUri:#Exchange', - 'FieldURI': 'Start', - }, - }], }, } - data = client.request('FindItem', payload) + data = client.request("FindItem", payload) items = client.extract_items(data) if not items: break - folder_items = items[0].get('RootFolder', {}).get('Items', []) - is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) + folder_items = items[0].get("RootFolder", {}).get("Items", []) + is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) if not folder_items: break for item in folder_items: - subj = item.get('Subject', '') + subj = item.get("Subject", "") if subj and subj in subject_counts and subj not in subject_to_id: - subject_to_id[subj] = item.get('ItemId', {}).get('Id', '') + subject_to_id[subj] = item.get("ItemId", {}).get("Id", "") if is_last: break @@ -347,7 +367,7 @@ def get_meeting_contacts( id_to_attendees: dict[str, set] = {} for bi in range(0, len(unique_ids), 10): - batch = unique_ids[bi:bi+10] + batch = unique_ids[bi : bi + 10] item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch] payload = { @@ -375,8 +395,10 @@ def get_meeting_contacts( item_id = item.get("ItemId", {}).get("Id", "") attendees = set() - for att_list in [item.get("RequiredAttendees", []) or [], - item.get("OptionalAttendees", []) or []]: + for att_list in [ + item.get("RequiredAttendees", []) or [], + item.get("OptionalAttendees", []) or [], + ]: for a in att_list: mailbox = a.get("Mailbox", {}) name = mailbox.get("Name", "") @@ -408,15 +430,20 @@ def get_meeting_contacts( result_contacts = [] for (name, email), count in contacts.most_common(top_n): - result_contacts.append({ - "name": name, - "email": email, - "meetings": count, - }) + result_contacts.append( + { + "name": name, + "email": email, + "meetings": count, + } + ) - return json.dumps({ - "period": {"start": start_date, "end": end_date}, - "total_meetings": total_expanded, - "unique_contacts": len(contacts), - "contacts": result_contacts, - }, ensure_ascii=False) + return json.dumps( + { + "period": {"start": start_date, "end": end_date}, + "total_meetings": total_expanded, + "unique_contacts": len(contacts), + "contacts": result_contacts, + }, + ensure_ascii=False, + ) diff --git a/owa_mcp/tools/auth.py b/owa_mcp/tools/auth.py index df85919..b2d354d 100644 --- a/owa_mcp/tools/auth.py +++ b/owa_mcp/tools/auth.py @@ -12,8 +12,8 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -87,16 +87,20 @@ async def login( # Parse and validate cookies cookies_str = cookies.strip() if not cookies_str: - return json.dumps({ - "success": False, - "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", - }) + return json.dumps( + { + "success": False, + "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", + } + ) # Save to disk try: client.cookie_file.write_text(cookies_str + "\n") except Exception as e: - return json.dumps({"success": False, "error": f"Failed to write cookie file: {e}"}) + return json.dumps( + {"success": False, "error": f"Failed to write cookie file: {e}"} + ) # Load into memory try: @@ -106,12 +110,16 @@ async def login( # Verify session if _session_is_valid(client): - return json.dumps({ - "success": True, - "message": f"Logged in. Session cookies saved to {client.cookie_file}.", - }) + return json.dumps( + { + "success": True, + "message": f"Logged in. Session cookies saved to {client.cookie_file}.", + } + ) else: - return json.dumps({ - "success": False, - "error": "Cookies loaded but session verification failed. The cookies may have expired.", - }) + return json.dumps( + { + "success": False, + "error": "Cookies loaded but session verification failed. The cookies may have expired.", + } + ) diff --git a/owa_mcp/tools/availability.py b/owa_mcp/tools/availability.py index aa8bd02..a4ac504 100644 --- a/owa_mcp/tools/availability.py +++ b/owa_mcp/tools/availability.py @@ -9,8 +9,8 @@ from datetime import datetime, timedelta from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -24,6 +24,7 @@ def _get_client(ctx: Context) -> OWAClient: # Pure helper functions (preserved from find-meeting-time.py) # ------------------------------------------------------------------ + def _parse_freebusy_string( freebusy_str: str, start_time: datetime, interval_minutes: int = 30 ) -> list[tuple]: @@ -37,7 +38,7 @@ def _parse_freebusy_string( for char in freebusy_str: next_time = current_time + timedelta(minutes=interval_minutes) - if char in ['1', '2', '3', '4']: # Not free + if char in ["1", "2", "3", "4"]: # Not free busy_periods.append((current_time, next_time, char)) current_time = next_time @@ -105,75 +106,82 @@ def _find_free_slots( def _format_time(dt: datetime) -> str: """Format datetime as HH:MM.""" - return dt.strftime('%H:%M') + return dt.strftime("%H:%M") # ------------------------------------------------------------------ # Helper: get busy events via GetUserAvailability (includes recurring) # ------------------------------------------------------------------ + def _get_availability_events( client: OWAClient, email: str, start_date, end_date ) -> list[dict]: """Get busy events via GetUserAvailability (expands recurring events).""" payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': [{ - '__type': 'MailboxData:#Exchange', - 'Email': {'__type': 'EmailAddress:#Exchange', 'Address': email}, - 'AttendeeType': 'Required', - }], - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{start_date}T00:00:00', - 'EndTime': f'{end_date + timedelta(days=1)}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": [ + { + "__type": "MailboxData:#Exchange", + "Email": {"__type": "EmailAddress:#Exchange", "Address": email}, + "AttendeeType": "Required", + } + ], + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{start_date}T00:00:00", + "EndTime": f"{end_date + timedelta(days=1)}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } - data = client.request('GetUserAvailability', payload) - body = data.get('Body', {}) + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) events = [] - for fb_resp in body.get('FreeBusyResponseArray', []): - fb_view = fb_resp.get('FreeBusyView', {}) - cal_events = fb_view.get('CalendarEventArray', {}) + for fb_resp in body.get("FreeBusyResponseArray", []): + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) items = ( - cal_events.get('Items', []) + cal_events.get("Items", []) if isinstance(cal_events, dict) else (cal_events if isinstance(cal_events, list) else []) ) for event in items: - bt = event.get('BusyType', '') - if bt in ('Free', 'NoData'): + bt = event.get("BusyType", "") + if bt in ("Free", "NoData"): continue - start_str = event.get('StartTime', '') - end_str = event.get('EndTime', '') + start_str = event.get("StartTime", "") + end_str = event.get("EndTime", "") if not start_str or not end_str: continue try: - start = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None) - end = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None) - events.append({'start': start, 'end': end, 'status': bt}) + start = datetime.fromisoformat( + start_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) + events.append({"start": start, "end": end, "status": bt}) except (ValueError, AttributeError): continue @@ -184,22 +192,23 @@ def _get_availability_events( # Helper: get calendar folder ID # ------------------------------------------------------------------ + def _get_calendar_folder_id(client: OWAClient) -> str | None: """Get the calendar folder ID via GetFolder.""" payload = { - '__type': 'GetFolderJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", }, - 'Body': { - '__type': 'GetFolderRequest:#Exchange', - 'FolderShape': { - '__type': 'FolderResponseShape:#Exchange', - 'BaseShape': 'IdOnly', + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", }, - 'FolderIds': [ - {'__type': 'DistinguishedFolderId:#Exchange', 'Id': 'calendar'} + "FolderIds": [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "calendar"} ], }, } @@ -218,6 +227,7 @@ def _get_calendar_folder_id(client: OWAClient) -> str | None: # Helper: get own calendar events (from find-free-time.py) # ------------------------------------------------------------------ + def _get_calendar_events( client: OWAClient, folder_id: str, start_date, end_date ) -> list[dict]: @@ -228,34 +238,32 @@ def _get_calendar_events( while True: payload = { - '__type': 'FindItemJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", }, - 'Body': { - '__type': 'FindItemRequest:#Exchange', - 'ItemShape': { - '__type': 'ItemResponseShape:#Exchange', - 'BaseShape': 'AllProperties', + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", }, - 'ParentFolderIds': [ - {'__type': 'FolderId:#Exchange', 'Id': folder_id} - ], - 'Traversal': 'Shallow', - 'Paging': { - '__type': 'IndexedPageView:#Exchange', - 'BasePoint': 'Beginning', - 'Offset': offset, - 'MaxEntriesReturned': batch_size, + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": batch_size, }, - 'SortOrder': [ + "SortOrder": [ { - '__type': 'SortResults:#Exchange', - 'Order': 'Ascending', - 'Path': { - '__type': 'PropertyUri:#Exchange', - 'FieldURI': 'Start', + "__type": "SortResults:#Exchange", + "Order": "Ascending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Start", }, } ], @@ -268,29 +276,29 @@ def _get_calendar_events( if not items: break - folder_items = items[0].get('RootFolder', {}).get('Items', []) + folder_items = items[0].get("RootFolder", {}).get("Items", []) if not folder_items: break for item in folder_items: # Skip cancelled events - if item.get('IsCancelled'): + if item.get("IsCancelled"): continue # Skip free/tentative slots - fbt = item.get('FreeBusyType', 'Busy') - if fbt in ['Free', 'NoData']: + fbt = item.get("FreeBusyType", "Busy") + if fbt in ["Free", "NoData"]: continue - start_str = item.get('Start', '') - end_str = item.get('End', '') + start_str = item.get("Start", "") + end_str = item.get("End", "") if not start_str or not end_str: continue try: - start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) - end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + start = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) # Convert to naive datetime for comparison start = start.replace(tzinfo=None) @@ -300,17 +308,19 @@ def _get_calendar_events( if end.date() < start_date or start.date() > end_date: continue - events.append({ - 'start': start, - 'end': end, - 'subject': item.get('Subject', ''), - 'status': fbt, - }) + events.append( + { + "start": start, + "end": end, + "subject": item.get("Subject", ""), + "status": fbt, + } + ) except (ValueError, AttributeError): continue # Check if there are more items - is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) + is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) if is_last: break @@ -323,6 +333,7 @@ def _get_calendar_events( # Tool: find_free_time # ------------------------------------------------------------------ + @mcp.tool() def find_free_time( start_date: str, @@ -352,8 +363,8 @@ def find_free_time( client = _get_client(ctx) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) @@ -365,13 +376,17 @@ def find_free_time( # Fallback to FindItem (misses recurring event occurrences) folder_id = _get_calendar_folder_id(client) if not folder_id: - return json.dumps({"error": "Could not find calendar folder. Session may have expired."}) + return json.dumps( + { + "error": "Could not find calendar folder. Session may have expired." + } + ) all_busy = _get_calendar_events(client, folder_id, sd, ed) except Exception as e: return json.dumps({"error": str(e)}) # Convert event dicts to (start, end) tuples for _find_free_slots - busy_periods = [(ev['start'], ev['end']) for ev in all_busy] + busy_periods = [(ev["start"], ev["end"]) for ev in all_busy] result = {} current_date = sd @@ -379,8 +394,11 @@ def find_free_time( # Skip weekends if current_date.weekday() < 5: free = _find_free_slots( - busy_periods, current_date, - start_hour, end_hour, duration_minutes, + busy_periods, + current_date, + start_hour, + end_hour, + duration_minutes, ) if free: result[str(current_date)] = [ @@ -400,6 +418,7 @@ def find_free_time( # Tool: find_meeting_time # ------------------------------------------------------------------ + @mcp.tool() def find_meeting_time( emails: str, @@ -431,13 +450,13 @@ def find_meeting_time( """ client = _get_client(ctx) - raw_list = [e.strip() for e in emails.split(',') if e.strip()] + raw_list = [e.strip() for e in emails.split(",") if e.strip()] if not raw_list: return json.dumps({"error": "No email addresses provided."}) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) @@ -445,12 +464,12 @@ def find_meeting_time( email_list = [] resolve_errors = [] for entry in raw_list: - if '@' in entry: + if "@" in entry: email_list.append(entry) else: resolutions = client.resolve_names(entry, full_contact=False) if resolutions: - addr = resolutions[0].get('Mailbox', {}).get('EmailAddress', '') + addr = resolutions[0].get("Mailbox", {}).get("EmailAddress", "") if addr: email_list.append(addr) else: @@ -459,46 +478,52 @@ def find_meeting_time( resolve_errors.append(entry) if not email_list: - return json.dumps({"error": f"Could not resolve any names to email addresses: {resolve_errors}"}) + return json.dumps( + { + "error": f"Could not resolve any names to email addresses: {resolve_errors}" + } + ) # Build mailbox data (reused for each day chunk) mailbox_data = [] for email in email_list: - mailbox_data.append({ - '__type': 'MailboxData:#Exchange', - 'Email': { - '__type': 'EmailAddress:#Exchange', - 'Address': email, - }, - 'AttendeeType': 'Required', - }) + mailbox_data.append( + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": email, + }, + "AttendeeType": "Required", + } + ) # Query the full date range at once (API handles multi-day windows) payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': mailbox_data, - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{sd}T00:00:00', - 'EndTime': f'{ed + timedelta(days=1)}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": mailbox_data, + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{sd}T00:00:00", + "EndTime": f"{ed + timedelta(days=1)}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } @@ -508,57 +533,69 @@ def find_meeting_time( except Exception as e: return json.dumps({"error": str(e)}) - body = data.get('Body', {}) - if 'ErrorCode' in body: - return json.dumps({"error": body.get('FaultMessage', 'Unknown error')}) + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) # Parse availability responses all_busy = [] attendee_info = [] - freebusy_responses = body.get('FreeBusyResponseArray', []) + freebusy_responses = body.get("FreeBusyResponseArray", []) for i, fb_resp in enumerate(freebusy_responses): - fb_view = fb_resp.get('FreeBusyView', {}) - merged_fb = fb_view.get('MergedFreeBusy', '') - email = email_list[i] if i < len(email_list) else f"Person {i+1}" + fb_view = fb_resp.get("FreeBusyView", {}) + merged_fb = fb_view.get("MergedFreeBusy", "") + email = email_list[i] if i < len(email_list) else f"Person {i + 1}" if merged_fb: start_time = datetime.combine(sd, datetime.min.time()) busy_periods = _parse_freebusy_string(merged_fb, start_time) - busy_count = sum(1 for c in merged_fb if c != '0') - free_count = sum(1 for c in merged_fb if c == '0') - attendee_info.append({ - "email": email, - "busy_slots": busy_count, - "free_slots": free_count, - }) + busy_count = sum(1 for c in merged_fb if c != "0") + free_count = sum(1 for c in merged_fb if c == "0") + attendee_info.append( + { + "email": email, + "busy_slots": busy_count, + "free_slots": free_count, + } + ) all_busy.extend(busy_periods) else: # Fallback: parse CalendarEventArray - cal_events_raw = fb_view.get('CalendarEventArray', {}) - cal_events = cal_events_raw.get('Items', []) if isinstance(cal_events_raw, dict) else (cal_events_raw if isinstance(cal_events_raw, list) else []) + cal_events_raw = fb_view.get("CalendarEventArray", {}) + cal_events = ( + cal_events_raw.get("Items", []) + if isinstance(cal_events_raw, dict) + else (cal_events_raw if isinstance(cal_events_raw, list) else []) + ) if cal_events: - attendee_info.append({ - "email": email, - "calendar_events": len(cal_events), - }) + attendee_info.append( + { + "email": email, + "calendar_events": len(cal_events), + } + ) for event in cal_events: - start_str = event.get('StartTime', '') - end_str = event.get('EndTime', '') + start_str = event.get("StartTime", "") + end_str = event.get("EndTime", "") if start_str and end_str: try: - start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) - end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + start = datetime.fromisoformat( + start_str.replace("Z", "+00:00") + ) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) all_busy.append((start, end)) except Exception: pass else: - attendee_info.append({ - "email": email, - "status": "no_data", - }) + attendee_info.append( + { + "email": email, + "status": "no_data", + } + ) merged_busy = _merge_busy_periods(all_busy) @@ -568,8 +605,11 @@ def find_meeting_time( while current_date <= ed: if current_date.weekday() < 5: # Skip weekends free = _find_free_slots( - merged_busy, current_date, - start_hour, end_hour, duration_minutes, + merged_busy, + current_date, + start_hour, + end_hour, + duration_minutes, ) if free: free_by_date[str(current_date)] = [ diff --git a/owa_mcp/tools/calendar.py b/owa_mcp/tools/calendar.py index 06dabd7..49cd484 100644 --- a/owa_mcp/tools/calendar.py +++ b/owa_mcp/tools/calendar.py @@ -11,10 +11,10 @@ from datetime import datetime, timedelta from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client -from owa_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html +from owa_mcp.utils import extract_links_from_html, html_to_text def _get_client(ctx: Context) -> OWAClient: @@ -63,9 +63,7 @@ def _get_event_details(client: OWAClient, item_id: str) -> dict: "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], }, } @@ -160,9 +158,7 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict: "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], }, } @@ -202,13 +198,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict: name = mailbox.get("Name", "") addr = mailbox.get("EmailAddress", "") if addr and not addr.startswith("/O="): - result["resolved_required"].append({ - "Mailbox": { - "Name": name, - "EmailAddress": addr, - "RoutingType": "SMTP", + result["resolved_required"].append( + { + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } } - }) + ) # Optional attendees for a in item.get("OptionalAttendees", []) or []: @@ -216,13 +214,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict: name = mailbox.get("Name", "") addr = mailbox.get("EmailAddress", "") if addr and not addr.startswith("/O="): - result["resolved_optional"].append({ - "Mailbox": { - "Name": name, - "EmailAddress": addr, - "RoutingType": "SMTP", + result["resolved_optional"].append( + { + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } } - }) + ) return result @@ -331,72 +331,81 @@ def _get_expanded_events( chunk_end = min(current + timedelta(days=chunk_days), end_date) payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': [{ - '__type': 'MailboxData:#Exchange', - 'Email': {'__type': 'EmailAddress:#Exchange', 'Address': client.user_email}, - 'AttendeeType': 'Required', - }], - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{current}T00:00:00', - 'EndTime': f'{chunk_end}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": [ + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": client.user_email, + }, + "AttendeeType": "Required", + } + ], + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{current}T00:00:00", + "EndTime": f"{chunk_end}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } try: - data = client.request('GetUserAvailability', payload) - body = data.get('Body', {}) - for fb_resp in body.get('FreeBusyResponseArray', []): - fb_view = fb_resp.get('FreeBusyView', {}) - cal_events = fb_view.get('CalendarEventArray', {}) + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) + for fb_resp in body.get("FreeBusyResponseArray", []): + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) items = ( - cal_events.get('Items', []) + cal_events.get("Items", []) if isinstance(cal_events, dict) else (cal_events if isinstance(cal_events, list) else []) ) for event in items: - bt = event.get('BusyType', '') - details = event.get('CalendarEventDetails', {}) - subject = details.get('Subject', '') if details else '' - location = details.get('Location', '') if details else '' - is_meeting = details.get('IsMeeting', False) if details else False - is_recurring = details.get('IsRecurring', False) if details else False + bt = event.get("BusyType", "") + details = event.get("CalendarEventDetails", {}) + subject = details.get("Subject", "") if details else "" + location = details.get("Location", "") if details else "" + is_meeting = details.get("IsMeeting", False) if details else False + is_recurring = ( + details.get("IsRecurring", False) if details else False + ) - expanded.append({ - 'subject': subject or '(No subject)', - 'start': event.get('StartTime', ''), - 'end': event.get('EndTime', ''), - 'busy_type': bt, - 'location': location, - 'is_meeting': is_meeting, - 'is_recurring': is_recurring, - }) + expanded.append( + { + "subject": subject or "(No subject)", + "start": event.get("StartTime", ""), + "end": event.get("EndTime", ""), + "busy_type": bt, + "location": location, + "is_meeting": is_meeting, + "is_recurring": is_recurring, + } + ) except Exception: pass current = chunk_end - return sorted(expanded, key=lambda x: x.get('start', '')) + return sorted(expanded, key=lambda x: x.get("start", "")) @mcp.tool() @@ -438,15 +447,17 @@ def get_calendar_events( ) events = [] for ev in expanded: - events.append({ - "subject": ev['subject'], - "start": ev['start'], - "end": ev['end'], - "location": ev.get('location', ''), - "busy_type": ev.get('busy_type', ''), - "is_meeting": ev.get('is_meeting', False), - "is_recurring": ev.get('is_recurring', False), - }) + events.append( + { + "subject": ev["subject"], + "start": ev["start"], + "end": ev["end"], + "location": ev.get("location", ""), + "busy_type": ev.get("busy_type", ""), + "is_meeting": ev.get("is_meeting", False), + "is_recurring": ev.get("is_recurring", False), + } + ) return json.dumps(events, ensure_ascii=False) # --- Default mode --- @@ -476,9 +487,7 @@ def get_calendar_events( "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ParentFolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "Traversal": "Shallow", "CalendarView": { "__type": "CalendarView:#Exchange", @@ -722,12 +731,16 @@ def create_meeting( ] return json.dumps(result, ensure_ascii=False) else: - return json.dumps({ - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - }) + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) - return json.dumps({"success": True, "subject": subject, "note": "No confirmation details"}) + return json.dumps( + {"success": True, "subject": subject, "note": "No confirmation details"} + ) # ------------------------------------------------------------------ @@ -791,8 +804,12 @@ def update_meeting( orig_start_str = orig.get("start", "") orig_end_str = orig.get("end", "") try: - orig_start = datetime.fromisoformat(orig_start_str.replace("Z", "+00:00")).replace(tzinfo=None) - orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(tzinfo=None) + orig_start = datetime.fromisoformat( + orig_start_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) orig_duration = int((orig_end - orig_start).total_seconds() / 60) except (ValueError, AttributeError): orig_start = None @@ -821,7 +838,9 @@ def update_meeting( new_start = orig_start new_end = orig_end else: - return json.dumps({"error": "Cannot determine meeting time. Provide date and start_time."}) + return json.dumps( + {"error": "Cannot determine meeting time. Provide date and start_time."} + ) new_location = location if location is not None else orig.get("location", "") @@ -845,9 +864,7 @@ def update_meeting( }, "Body": { "__type": "DeleteItemRequest:#Exchange", - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], "DeleteType": "MoveToDeletedItems", "SendMeetingCancellations": "SendToAllAndSaveCopy", "SuppressReadReceipts": True, @@ -935,7 +952,9 @@ def update_meeting( try: data = client.request("CreateCalendarEvent", create_payload) except Exception as e: - return json.dumps({"error": f"Original cancelled but failed to create new: {e}"}) + return json.dumps( + {"error": f"Original cancelled but failed to create new: {e}"} + ) body = data.get("Body", {}) if "ErrorCode" in body: @@ -959,12 +978,16 @@ def update_meeting( return json.dumps(result, ensure_ascii=False) if resp_items: - return json.dumps({ - "error": resp_items[0].get("MessageText", "Unknown error"), - "response_code": resp_items[0].get("ResponseCode", ""), - }) + return json.dumps( + { + "error": resp_items[0].get("MessageText", "Unknown error"), + "response_code": resp_items[0].get("ResponseCode", ""), + } + ) - return json.dumps({"success": True, "subject": new_subject, "note": "No confirmation details"}) + return json.dumps( + {"success": True, "subject": new_subject, "note": "No confirmation details"} + ) # ------------------------------------------------------------------ @@ -997,9 +1020,7 @@ def cancel_meeting( }, "Body": { "__type": "DeleteItemRequest:#Exchange", - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], "DeleteType": "MoveToDeletedItems", "SendMeetingCancellations": "SendToAllAndSaveCopy", "SuppressReadReceipts": True, @@ -1014,10 +1035,12 @@ def cancel_meeting( if item.get("ResponseClass") == "Success": return json.dumps({"success": True, "message": "Meeting cancelled"}) else: - return json.dumps({ - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - }) + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) # DeleteItem may return empty on success body = data.get("Body", {}) @@ -1060,9 +1083,11 @@ def respond_to_meeting( response_type = response_types.get(response) if not response_type: - return json.dumps({ - "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." - }) + return json.dumps( + { + "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." + } + ) response_item = { "__type": response_type, @@ -1098,16 +1123,20 @@ def respond_to_meeting( if items: item = items[0] if item.get("ResponseClass") == "Success": - return json.dumps({ - "success": True, - "response": response, - "message": f"Meeting {response.lower()}ed", - }) + return json.dumps( + { + "success": True, + "response": response, + "message": f"Meeting {response.lower()}ed", + } + ) else: - return json.dumps({ - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - }) + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) return json.dumps({"error": "No response from server"}) @@ -1147,9 +1176,7 @@ def download_event_attachments( "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], }, } @@ -1162,28 +1189,43 @@ def download_event_attachments( continue for item in msg["Items"]: for att in item.get("Attachments", []): - attachments.append({ - "name": att.get("Name", ""), - "size": att.get("Size", 0), - "content_type": att.get("ContentType", ""), - "attachment_id": att.get("AttachmentId", {}).get("Id", ""), - "is_inline": att.get("IsInline", False), - }) + attachments.append( + { + "name": att.get("Name", ""), + "size": att.get("Size", 0), + "content_type": att.get("ContentType", ""), + "attachment_id": att.get("AttachmentId", {}).get("Id", ""), + "is_inline": att.get("IsInline", False), + } + ) break if not attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No attachments found on this event."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No attachments found on this event.", + } + ) # Filter to non-inline file attachments file_attachments = [ - a for a in attachments + a + for a in attachments if a.get("attachment_id") and not a.get("is_inline", False) ] if not file_attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No downloadable file attachments."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No downloadable file attachments.", + } + ) os.makedirs(target_folder, exist_ok=True) @@ -1223,17 +1265,21 @@ def download_event_attachments( with open(filepath, "wb") as f: f.write(content) - downloaded.append({ - "name": filename, - "path": filepath, - "size": len(content), - "content_type": content_type, - }) + downloaded.append( + { + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + } + ) except Exception as e: - errors.append({ - "name": att.get("name", "unknown"), - "error": str(e), - }) + errors.append( + { + "name": att.get("name", "unknown"), + "error": str(e), + } + ) result = { "success": len(errors) == 0, @@ -1308,12 +1354,14 @@ def get_event_links( links = extract_links_from_html(body_val) break - return json.dumps({ - "item_id": item_id, - "subject": subject, - "links": links, - "count": len(links), - }) + return json.dumps( + { + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + } + ) except Exception as e: return json.dumps({"error": f"Failed to extract event links: {e}"}) diff --git a/owa_mcp/tools/email.py b/owa_mcp/tools/email.py index 681fa03..a7e437c 100644 --- a/owa_mcp/tools/email.py +++ b/owa_mcp/tools/email.py @@ -8,10 +8,10 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client -from owa_mcp.utils import html_to_text, extract_links_from_html +from owa_mcp.utils import extract_links_from_html, html_to_text def _get_client(ctx: Context) -> OWAClient: @@ -215,7 +215,12 @@ def _get_item_details(client: OWAClient, item_id: str) -> dict: item_type = item.get("__type", "") if any( t in item_type - for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation", "CalendarItem") + for t in ( + "MeetingRequest", + "MeetingResponse", + "MeetingCancellation", + "CalendarItem", + ) ): result["location"] = item.get( "Location", @@ -331,9 +336,7 @@ def get_emails( find_body = { "__type": "FindItemRequest:#Exchange", "ItemShape": item_shape, - "ParentFolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "Traversal": "Shallow", "Paging": { "__type": "IndexedPageView:#Exchange", @@ -392,18 +395,19 @@ def get_emails( if not items: return json.dumps( - {"item_ids": [], "count": 0} if ids_only - else {"emails": [], "count": 0} + {"item_ids": [], "count": 0} if ids_only else {"emails": [], "count": 0} ) if ids_only: result = [] for item in items: - result.append({ - "item_id": item.get("ItemId", {}).get("Id", ""), - "date": item.get("DateTimeReceived", ""), - "subject": item.get("Subject", ""), - }) + result.append( + { + "item_id": item.get("ItemId", {}).get("Id", ""), + "date": item.get("DateTimeReceived", ""), + "subject": item.get("Subject", ""), + } + ) return json.dumps({"item_ids": result, "count": len(result)}) emails = [] @@ -763,9 +767,7 @@ def move_email( if not folder_id: return json.dumps({"error": f"Folder '{target_folder}' not found."}) - items = [ - {"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids - ] + items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] payload = { "__type": "MoveItemJsonRequest:#Exchange", @@ -824,9 +826,7 @@ def delete_email( try: client = _get_client(ctx) - items = [ - {"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids - ] + items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] payload = { "__type": "DeleteItemJsonRequest:#Exchange", @@ -887,18 +887,31 @@ def download_attachments( attachments = details.get("attachments", []) if not attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No attachments found."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No attachments found.", + } + ) # Filter to non-inline file attachments with IDs file_attachments = [ - a for a in attachments + a + for a in attachments if a.get("attachment_id") and not a.get("is_inline", False) ] if not file_attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No downloadable file attachments."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No downloadable file attachments.", + } + ) os.makedirs(target_folder, exist_ok=True) @@ -938,17 +951,21 @@ def download_attachments( with open(filepath, "wb") as f: f.write(content) - downloaded.append({ - "name": filename, - "path": filepath, - "size": len(content), - "content_type": content_type, - }) + downloaded.append( + { + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + } + ) except Exception as e: - errors.append({ - "name": att.get("name", "unknown"), - "error": str(e), - }) + errors.append( + { + "name": att.get("name", "unknown"), + "error": str(e), + } + ) result = { "success": len(errors) == 0, @@ -1021,12 +1038,14 @@ def get_email_links( links = extract_links_from_html(body_val) break - return json.dumps({ - "item_id": item_id, - "subject": subject, - "links": links, - "count": len(links), - }) + return json.dumps( + { + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + } + ) except SessionExpiredError as e: return json.dumps({"error": str(e)}) @@ -1107,16 +1126,22 @@ async def search_emails( max_results = max(1, min(max_results, 100)) if search_scope not in ("all", "subject", "body", "from"): - return json.dumps({"error": f"invalid search_scope: {search_scope}", "results": []}) + return json.dumps( + {"error": f"invalid search_scope: {search_scope}", "results": []} + ) restriction = _build_search_restriction(query.strip(), search_scope) if restriction is None: - return json.dumps({"error": f"unsupported search_scope: {search_scope}", "results": []}) + return json.dumps( + {"error": f"unsupported search_scope: {search_scope}", "results": []} + ) if folder_id: parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}] else: - parent_folder = [{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}] + parent_folder = [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"} + ] payload = { "__type": "FindItemJsonRequest:#Exchange", @@ -1153,6 +1178,7 @@ async def search_emails( body_type = item.get("Body", {}).get("BodyType", "HTML") if body_type == "HTML" and body_html: import re + body_preview = re.sub(r"<[^>]+>", " ", body_html) body_preview = " ".join(body_preview.split())[:200] else: @@ -1163,13 +1189,16 @@ async def search_emails( if len(results) >= max_results: break - return json.dumps({ - "query": query.strip(), - "search_scope": search_scope, - "folder_id": folder_id or "all", - "total_results": len(results), - "results": results, - }, ensure_ascii=False) + return json.dumps( + { + "query": query.strip(), + "search_scope": search_scope, + "folder_id": folder_id or "all", + "total_results": len(results), + "results": results, + }, + ensure_ascii=False, + ) except SessionExpiredError as e: return json.dumps({"error": str(e)}) except Exception as e: diff --git a/owa_mcp/tools/folders.py b/owa_mcp/tools/folders.py index 6ff7603..9ce08a5 100644 --- a/owa_mcp/tools/folders.py +++ b/owa_mcp/tools/folders.py @@ -7,15 +7,24 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client - _DISTINGUISHED_NAMES = { - "msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems", - "junkemail", "outbox", "calendar", "contacts", "tasks", "notes", - "journal", "searchfolders", + "msgfolderroot", + "inbox", + "sentitems", + "drafts", + "deleteditems", + "junkemail", + "outbox", + "calendar", + "contacts", + "tasks", + "notes", + "journal", + "searchfolders", } _HEADER_TZ = { @@ -68,36 +77,40 @@ def check_session(ctx: Context = None) -> str: "__type": "FolderResponseShape:#Exchange", "BaseShape": "Default", }, - "FolderIds": [ - {"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"} - ], + "FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}], }, } try: data = client.request("GetFolder", payload) except Exception as e: - return json.dumps({ - "authenticated": False, - "error": str(e), - "cookie_file": str(client.cookie_file), - }) + return json.dumps( + { + "authenticated": False, + "error": str(e), + "cookie_file": str(client.cookie_file), + } + ) for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "authenticated": True, - "mailbox": folder.get("DisplayName", ""), - "unread": folder.get("UnreadCount", 0), - "cookie_file": str(client.cookie_file), - }) + return json.dumps( + { + "authenticated": True, + "mailbox": folder.get("DisplayName", ""), + "unread": folder.get("UnreadCount", 0), + "cookie_file": str(client.cookie_file), + } + ) - return json.dumps({ - "authenticated": False, - "error": "Unexpected response", - "cookie_file": str(client.cookie_file), - }) + return json.dumps( + { + "authenticated": False, + "error": "Unexpected response", + "cookie_file": str(client.cookie_file), + } + ) @mcp.tool() @@ -159,13 +172,15 @@ def get_folders( for msg in client.extract_items(data): if "RootFolder" in msg and "Folders" in msg["RootFolder"]: for f in msg["RootFolder"]["Folders"]: - folders.append({ - "name": f.get("DisplayName", "Unknown"), - "id": f.get("FolderId", {}).get("Id", ""), - "total_count": f.get("TotalCount", 0), - "unread_count": f.get("UnreadCount", 0), - "child_folder_count": f.get("ChildFolderCount", 0), - }) + folders.append( + { + "name": f.get("DisplayName", "Unknown"), + "id": f.get("FolderId", {}).get("Id", ""), + "total_count": f.get("TotalCount", 0), + "unread_count": f.get("UnreadCount", 0), + "child_folder_count": f.get("ChildFolderCount", 0), + } + ) return json.dumps(folders, ensure_ascii=False) @@ -216,11 +231,13 @@ def create_folder( for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "success": True, - "name": name, - "id": folder.get("FolderId", {}).get("Id", ""), - }) + return json.dumps( + { + "success": True, + "name": name, + "id": folder.get("FolderId", {}).get("Id", ""), + } + ) return json.dumps({"error": "Unexpected response", "raw": str(data)}) @@ -280,11 +297,13 @@ def rename_folder( for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "success": True, - "new_name": new_name, - "id": folder.get("FolderId", {}).get("Id", ""), - }) + return json.dumps( + { + "success": True, + "new_name": new_name, + "id": folder.get("FolderId", {}).get("Id", ""), + } + ) return json.dumps({"error": "Unexpected response", "raw": str(data)}) @@ -316,9 +335,7 @@ def empty_folder( "Header": _HEADER_TZ, "Body": { "__type": "EmptyFolderRequest:#Exchange", - "FolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "DeleteType": delete_type, "DeleteSubFolders": delete_sub_folders, "SuppressReadReceipt": True, @@ -362,9 +379,7 @@ def delete_folder( "Header": _HEADER_TZ, "Body": { "__type": "DeleteFolderRequest:#Exchange", - "FolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "DeleteType": delete_type, }, } @@ -405,9 +420,7 @@ def move_folder( "Header": _HEADER_TZ, "Body": { "__type": "MoveFolderRequest:#Exchange", - "FolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "ToFolderId": { "__type": "TargetFolderId:#Exchange", "BaseFolderId": _folder_id_dict(target_parent_folder_id), @@ -423,9 +436,11 @@ def move_folder( for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "success": True, - "folder_id": folder.get("FolderId", {}).get("Id", ""), - }) + return json.dumps( + { + "success": True, + "folder_id": folder.get("FolderId", {}).get("Id", ""), + } + ) return json.dumps({"error": "Unexpected response", "raw": str(data)}) diff --git a/owa_mcp/tools/people.py b/owa_mcp/tools/people.py index 88a7db8..df6f449 100644 --- a/owa_mcp/tools/people.py +++ b/owa_mcp/tools/people.py @@ -7,8 +7,8 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -82,10 +82,12 @@ def _parse_person(resolution: dict) -> dict: # Direct reports for report in contact.get("DirectReports", []): - person["direct_reports"].append({ - "name": report.get("Name", ""), - "email": report.get("EmailAddress", ""), - }) + person["direct_reports"].append( + { + "name": report.get("Name", ""), + "email": report.get("EmailAddress", ""), + } + ) return person diff --git a/owa_mcp/utils.py b/owa_mcp/utils.py index 8f8f996..ddcff53 100644 --- a/owa_mcp/utils.py +++ b/owa_mcp/utils.py @@ -20,9 +20,7 @@ def html_to_text(html_content: str) -> str: text = re.sub( r"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE ) - text = re.sub( - r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE - ) + text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r"", "\n", text, flags=re.IGNORECASE) text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) diff --git a/pyproject.toml b/pyproject.toml index 205b3b4..a69a537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,11 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] include = ["owa_mcp*"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] +ignore = ["E501"] -- 2.52.0 From 67ede4ab67ad9f5ffa31f71d121c91a34fa2ddc5 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 02:06:10 +0300 Subject: [PATCH 4/8] w --- .dprint.json | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.dprint.json b/.dprint.json index 696337b..847d98e 100644 --- a/.dprint.json +++ b/.dprint.json @@ -2,20 +2,16 @@ "excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"], "includes": ["**/*.{md,json,py}"], "indentWidth": 2, - "json": { - "deno": true - }, "lineWidth": 80, - "markdown": { - "deno": true + "json": {}, + "markdown": {}, + "ruff": { + "lineLength": 88, + "targetVersion": "py310" }, "plugins": [ "https://plugins.dprint.dev/json-0.22.0.wasm", "https://plugins.dprint.dev/markdown-0.22.1.wasm", "https://plugins.dprint.dev/ruff-0.7.20.wasm" ], - "ruff": { - "lineLength": 88, - "targetVersion": "py310" - } } -- 2.52.0 From f7619a31ec4dc0dbaecb78476e99c6528fbef334 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 10:36:06 +0300 Subject: [PATCH 5/8] W --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ae385b0..0006f68 100644 --- a/README.md +++ b/README.md @@ -5,40 +5,47 @@ calendar, directory search, folder management, availability, and analytics. ## CLI Arguments -| Argument | Default | Required | Description | -|---|---|---|---| -| `--owa-url` | — | Yes | Base URL of the OWA instance | -| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | -| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | -| `--token-hash` | — | HTTP only | SHA-256 hash of the bearer token | -| `--port` | `8000` | — | HTTP/SSE listener port | -| `--host` | `127.0.0.1` | — | HTTP/SSE listener host | -| `--enabled-tools` | — | No | Comma-separated whitelist of tool names | -| `--disabled-tools` | — | No | Comma-separated blacklist of tool names | +| Argument | Default | Required | Description | +| ------------------ | --------------------- | --------- | --------------------------------------- | +| `--owa-url` | — | Yes | Base URL of the OWA instance | +| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | +| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | +| `--token-hash` | — | HTTP only | SHA-256 hash of the bearer token | +| `--port` | `8000` | — | HTTP/SSE listener port | +| `--host` | `127.0.0.1` | — | HTTP/SSE listener host | +| `--enabled-tools` | — | No | Comma-separated whitelist of tool names | +| `--disabled-tools` | — | No | Comma-separated blacklist of tool names | ## Tools (30) ### Email (10) + `get_emails`, `get_email`, `send_email`, `reply_email`, `forward_email`, `delete_email`, `move_email`, `mark_email_read`, `download_attachments`, `get_email_links`, `search_emails` ### Calendar (7) + `get_calendar_events`, `create_meeting`, `update_meeting`, `cancel_meeting`, `respond_to_meeting`, `download_event_attachments`, `get_event_links` ### Directory (1) + `find_person` ### Folders (7) + `get_folders`, `create_folder`, `rename_folder`, `empty_folder`, `delete_folder`, `move_folder`, `check_session` ### Availability (2) + `find_free_time`, `find_meeting_time` ### Analytics (2) + `get_meeting_stats`, `get_meeting_contacts` ### Auth (1) + `login` -- 2.52.0 From 5ec3cb4ab4090ebd61a7f250ddd5e138cb332452 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 10:39:24 +0300 Subject: [PATCH 6/8] w --- .dprint.json | 5 +- owa_mcp/models.py | 162 +-- owa_mcp/owa_client.py | 799 ++++++------ owa_mcp/server.py | 281 ++-- owa_mcp/server_lifecycle.py | 367 +++--- owa_mcp/tools/analytics.py | 738 +++++------ owa_mcp/tools/auth.py | 170 +-- owa_mcp/tools/availability.py | 994 +++++++------- owa_mcp/tools/calendar.py | 2305 ++++++++++++++++----------------- owa_mcp/tools/email.py | 1995 ++++++++++++++-------------- owa_mcp/tools/folders.py | 684 +++++----- owa_mcp/tools/people.py | 172 +-- owa_mcp/utils.py | 179 +-- 13 files changed, 4425 insertions(+), 4426 deletions(-) diff --git a/.dprint.json b/.dprint.json index 847d98e..b409141 100644 --- a/.dprint.json +++ b/.dprint.json @@ -3,11 +3,8 @@ "includes": ["**/*.{md,json,py}"], "indentWidth": 2, "lineWidth": 80, - "json": {}, - "markdown": {}, "ruff": { - "lineLength": 88, - "targetVersion": "py310" + "lineLength": 80, }, "plugins": [ "https://plugins.dprint.dev/json-0.22.0.wasm", diff --git a/owa_mcp/models.py b/owa_mcp/models.py index 1bcf051..66e2fcd 100644 --- a/owa_mcp/models.py +++ b/owa_mcp/models.py @@ -8,109 +8,109 @@ from typing import TypedDict class Email(TypedDict, total=False): - """An email message.""" + """An email message.""" - subject: str - sender: str - sender_name: str - date: str - is_read: bool - has_attachments: bool - has_links: bool - item_id: str - size: int - is_meeting: bool - item_type: str - to: list[str] - cc: list[str] - body: str - body_type: str - attachments: list[dict] - # Meeting-specific fields - location: str - start: str - end: str - attendees_required: list[str] - attendees_optional: list[str] + subject: str + sender: str + sender_name: str + date: str + is_read: bool + has_attachments: bool + has_links: bool + item_id: str + size: int + is_meeting: bool + item_type: str + to: list[str] + cc: list[str] + body: str + body_type: str + attachments: list[dict] + # Meeting-specific fields + location: str + start: str + end: str + attendees_required: list[str] + attendees_optional: list[str] class CalendarEvent(TypedDict, total=False): - """A calendar event / meeting.""" + """A calendar event / meeting.""" - subject: str - start: str - end: str - location: str - is_all_day: bool - is_cancelled: bool - is_meeting: bool - is_recurring: bool - organizer: str - organizer_email: str - my_response: str - item_id: str - body: str - attendees_required: list[str] - attendees_optional: list[str] + subject: str + start: str + end: str + location: str + is_all_day: bool + is_cancelled: bool + is_meeting: bool + is_recurring: bool + organizer: str + organizer_email: str + my_response: str + item_id: str + body: str + attendees_required: list[str] + attendees_optional: list[str] class Person(TypedDict, total=False): - """A person from the Exchange directory.""" + """A person from the Exchange directory.""" - name: str - email: str - mailbox_type: str - first_name: str - last_name: str - job_title: str - department: str - company: str - office: str - alias: str - manager: str - manager_email: str - phones: dict[str, str] - address: str - direct_reports: list[dict[str, str]] + name: str + email: str + mailbox_type: str + first_name: str + last_name: str + job_title: str + department: str + company: str + office: str + alias: str + manager: str + manager_email: str + phones: dict[str, str] + address: str + direct_reports: list[dict[str, str]] class Folder(TypedDict, total=False): - """A mail folder.""" + """A mail folder.""" - name: str - id: str - total_count: int - unread_count: int - child_folder_count: int + name: str + id: str + total_count: int + unread_count: int + child_folder_count: int class FreeSlot(TypedDict): - """A free time slot.""" + """A free time slot.""" - date: str - start: str - end: str - duration_minutes: int + date: str + start: str + end: str + duration_minutes: int class Availability(TypedDict, total=False): - """Availability data for a single attendee.""" + """Availability data for a single attendee.""" - email: str - busy_slots: int - free_slots: int - merged_freebusy: str + email: str + busy_slots: int + free_slots: int + merged_freebusy: str class MeetingResult(TypedDict, total=False): - """Result of creating/updating a meeting.""" + """Result of creating/updating a meeting.""" - success: bool - subject: str - date: str - start_time: str - end_time: str - location: str - required_attendees: list[str] - optional_attendees: list[str] - error: str + success: bool + subject: str + date: str + start_time: str + end_time: str + location: str + required_attendees: list[str] + optional_attendees: list[str] + error: str diff --git a/owa_mcp/owa_client.py b/owa_mcp/owa_client.py index 244a553..bf9c647 100644 --- a/owa_mcp/owa_client.py +++ b/owa_mcp/owa_client.py @@ -17,480 +17,475 @@ import requests class SessionExpiredError(Exception): - """Raised when the OWA session has expired (HTTP 401/440 or HTML redirect).""" + """Raised when the OWA session has expired (HTTP 401/440 or HTML redirect).""" - pass + pass # Map common folder names (English + Russian) to OWA distinguished folder IDs DISTINGUISHED_FOLDERS = { - "inbox": "inbox", - "входящие": "inbox", - "sent": "sentitems", - "отправленные": "sentitems", - "drafts": "drafts", - "черновики": "drafts", - "deleted": "deleteditems", - "удаленные": "deleteditems", - "junk": "junkemail", - "нежелательная почта": "junkemail", - "outbox": "outbox", - "исходящие": "outbox", - "calendar": "calendar", - "календарь": "calendar", + "inbox": "inbox", + "входящие": "inbox", + "sent": "sentitems", + "отправленные": "sentitems", + "drafts": "drafts", + "черновики": "drafts", + "deleted": "deleteditems", + "удаленные": "deleteditems", + "junk": "junkemail", + "нежелательная почта": "junkemail", + "outbox": "outbox", + "исходящие": "outbox", + "calendar": "calendar", + "календарь": "calendar", } class OWAClient: - """HTTP client for OWA (Outlook Web Access) JSON API. + """HTTP client for OWA (Outlook Web Access) JSON API. - Handles cookie loading, CSRF token extraction, request construction, - session expiry detection, and automatic cookie reload on first failure. + Handles cookie loading, CSRF token extraction, request construction, + session expiry detection, and automatic cookie reload on first failure. + """ + + def __init__( + self, + cookie_file: str | None = None, + owa_url: str | None = None, + ): + self.cookie_file = Path( + cookie_file or str(Path(__file__).parent.parent / "session-cookies.txt") + ) + self.owa_url = (owa_url or "").rstrip("/") + if not self.owa_url: + raise ValueError("OWA URL must be provided via --owa-url or owa_url=…") + self._session = requests.Session() + self._loaded = False + self.user_email: str = "" + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _action_name(action: str) -> str: + """Convert e.g. 'GetFolder' → 'GetFolderAction'.""" + return action + "Action" + + def _fresh_headers(self, action: str) -> dict: + """Build the full set of OWA headers required by Exchange 2013. + + Mirrors the headers the OWA web frontend sends for every POST. """ + now_ms = int(time.time() * 1000) + client_req_id = f"{self._client_id}_{now_ms}" + now_str = ( + time.strftime("%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)) + + f"{now_ms % 1000:03d}" + ) + return { + # Basic + "accept": "*/*", + "accept-language": "en-US,en;q=0.9,ru;q=0.8", + "cache-control": "no-cache", + "client-request-id": client_req_id, + "content-length": "0", + "content-type": "application/json; charset=UTF-8", + "origin": self.owa_url, + "pragma": "no-cache", + "priority": "u=1, i", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + # OWA-specific + "action": action, + "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "x-owa-actionid": "-1", + "x-owa-actionname": self._action_name(action), + "x-owa-attempt": "1", + "x-owa-clientbegin": now_str, + "x-owa-clientbuildversion": "15.2.1748.39", + "x-owa-correlationid": client_req_id, + "x-requested-with": "XMLHttpRequest", + } - def __init__( - self, - cookie_file: str | None = None, - owa_url: str | None = None, - ): - self.cookie_file = Path( - cookie_file or str(Path(__file__).parent.parent / "session-cookies.txt") - ) - self.owa_url = (owa_url or "").rstrip("/") - if not self.owa_url: - raise ValueError("OWA URL must be provided via --owa-url or owa_url=…") - self._session = requests.Session() - self._loaded = False - self.user_email: str = "" + def _add_canary_header(self, headers: dict) -> dict: + """Extract X-OWA-CANARY from session cookies and add to headers.""" + canary = next( + (c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), "" + ) + if canary: + headers["x-owa-canary"] = canary + return headers - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ + @property + def _client_id(self) -> str: + """Extract ClientId from session cookies.""" + cid = next( + (c.value for c in self._session.cookies if c.name == "ClientId"), "" + ) + return cid if cid else "00000000000000000000000000000000" - @staticmethod - def _action_name(action: str) -> str: - """Convert e.g. 'GetFolder' → 'GetFolderAction'.""" - return action + "Action" + # ------------------------------------------------------------------ + # Cookie / session helpers + # ------------------------------------------------------------------ - def _fresh_headers(self, action: str) -> dict: - """Build the full set of OWA headers required by Exchange 2013. + def _load_cookies(self) -> None: + """Read session-cookies.txt (name=value per line) and load into session.""" + if not self.cookie_file.exists(): + raise SessionExpiredError( + f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate." + ) - Mirrors the headers the OWA web frontend sends for every POST. - """ - now_ms = int(time.time() * 1000) - client_req_id = f"{self._client_id}_{now_ms}" - now_str = ( - time.strftime("%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)) - + f"{now_ms % 1000:03d}" - ) - return { - # Basic - "accept": "*/*", - "accept-language": "en-US,en;q=0.9,ru;q=0.8", - "cache-control": "no-cache", - "client-request-id": client_req_id, - "content-length": "0", - "content-type": "application/json; charset=UTF-8", - "origin": self.owa_url, - "pragma": "no-cache", - "priority": "u=1, i", - "user-agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/131.0.0.0 Safari/537.36" - ), - # OWA-specific - "action": action, - "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - "x-owa-actionid": "-1", - "x-owa-actionname": self._action_name(action), - "x-owa-attempt": "1", - "x-owa-clientbegin": now_str, - "x-owa-clientbuildversion": "15.2.1748.39", - "x-owa-correlationid": client_req_id, - "x-requested-with": "XMLHttpRequest", - } + raw = self.cookie_file.read_text().strip() + cookies: dict[str, str] = {} + for line in raw.split("\n"): + if "=" in line: + name, value = line.split("=", 1) + cookies[name] = value - def _add_canary_header(self, headers: dict) -> dict: - """Extract X-OWA-CANARY from session cookies and add to headers.""" - canary = next( - (c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), "" - ) - if canary: - headers["x-owa-canary"] = canary - return headers + if not cookies: + raise SessionExpiredError( + "Cookie file is empty. Use the login MCP tool to authenticate." + ) - @property - def _client_id(self) -> str: - """Extract ClientId from session cookies.""" - cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "") - return cid if cid else "00000000000000000000000000000000" + self._session = requests.Session() + self._session.cookies.update(cookies) + self._loaded = True - # ------------------------------------------------------------------ - # Cookie / session helpers - # ------------------------------------------------------------------ + def _save_cookies(self) -> None: + """Write the current session cookie jar to the cookie file. - def _load_cookies(self) -> None: - """Read session-cookies.txt (name=value per line) and load into session.""" - if not self.cookie_file.exists(): - raise SessionExpiredError( - f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate." - ) + Called automatically after every successful request so that the + on-disk cookies always reflect the freshest ``Set-Cookie`` values + returned by the server. + """ + lines = [f"{c.name}={c.value}" for c in self._session.cookies] + self.cookie_file.write_text("\n".join(lines)) + self._loaded = True - raw = self.cookie_file.read_text().strip() - cookies: dict[str, str] = {} - for line in raw.split("\n"): - if "=" in line: - name, value = line.split("=", 1) - cookies[name] = value + def _ensure_loaded(self) -> None: + """Load cookies on first use and refresh session from the main page.""" + if not self._loaded: + self._load_cookies() + self._refresh_session() - if not cookies: - raise SessionExpiredError( - "Cookie file is empty. Use the login MCP tool to authenticate." - ) + def load_cookies_from_string(self, cookies_str: str) -> None: + """Load cookies from a name=value string (one per line) into memory.""" + cookies: dict[str, str] = {} + for line in cookies_str.strip().split("\n"): + if "=" in line: + name, value = line.split("=", 1) + cookies[name] = value - self._session = requests.Session() - self._session.cookies.update(cookies) - self._loaded = True + if not cookies: + raise SessionExpiredError("No cookies in provided data.") - def _save_cookies(self) -> None: - """Write the current session cookie jar to the cookie file. + self._session = requests.Session() + self._session.cookies.update(cookies) + self._loaded = True - Called automatically after every successful request so that the - on-disk cookies always reflect the freshest ``Set-Cookie`` values - returned by the server. - """ - lines = [f"{c.name}={c.value}" for c in self._session.cookies] - self.cookie_file.write_text("\n".join(lines)) - self._loaded = True + # ------------------------------------------------------------------ + # Core request method + # ------------------------------------------------------------------ - def _ensure_loaded(self) -> None: - """Load cookies on first use and refresh session from the main page.""" - if not self._loaded: - self._load_cookies() - self._refresh_session() + def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: + """POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1. - def load_cookies_from_string(self, cookies_str: str) -> None: - """Load cookies from a name=value string (one per line) into memory.""" - cookies: dict[str, str] = {} - for line in cookies_str.strip().split("\n"): - if "=" in line: - name, value = line.split("=", 1) - cookies[name] = value + On session expiry (401, 440, or text/html response), reloads cookies + once and retries. If that also fails, raises SessionExpiredError. - if not cookies: - raise SessionExpiredError("No cookies in provided data.") + Returns the parsed JSON response dict. + """ + self._ensure_loaded() - self._session = requests.Session() - self._session.cookies.update(cookies) - self._loaded = True + try: + data = self._do_request(action, payload, timeout=timeout) + return data + except SessionExpiredError: + raise - # ------------------------------------------------------------------ - # Core request method - # ------------------------------------------------------------------ + def _refresh_session(self) -> None: + """Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY).""" + try: + resp = self._session.get( + f"{self.owa_url}/owa/", + headers={ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + }, + timeout=10, + ) + # Extract X-OWA-CANARY from Set-Cookie if present + set_cookie = resp.headers.get("Set-Cookie", "") + if "X-OWA-CANARY=" in set_cookie: + import re - def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: - """POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1. + m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie) + if m: + host = urlparse(self.owa_url).hostname or "owa.b1.ru" + self._session.cookies.set("X-OWA-CANARY", m.group(1), domain=host) + # Persist any cookie changes + self._save_cookies() + except Exception: + pass # non-critical - On session expiry (401, 440, or text/html response), reloads cookies - once and retries. If that also fails, raises SessionExpiredError. + def _do_request( + self, action: str, payload: dict, *, timeout: int = 30 + ) -> dict: + """Execute a single OWA API request. - Returns the parsed JSON response dict. - """ - self._ensure_loaded() + Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload + is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an + empty POST body, plus a full set of OWA-specific headers extracted + from the browser's actual network traffic. + """ + from urllib.parse import urlparse - try: - data = self._do_request(action, payload, timeout=timeout) - return data - except SessionExpiredError: - raise + url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1" - def _refresh_session(self) -> None: - """Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY).""" - try: - resp = self._session.get( - f"{self.owa_url}/owa/", - headers={ - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/131.0.0.0 Safari/537.36" - ) - }, - timeout=10, - ) - # Extract X-OWA-CANARY from Set-Cookie if present - set_cookie = resp.headers.get("Set-Cookie", "") - if "X-OWA-CANARY=" in set_cookie: - import re + # URL-encode the JSON payload for the x-owa-urlpostdata header + url_post_data = quote(json.dumps(payload, separators=(",", ":"))) - m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie) - if m: - host = urlparse(self.owa_url).hostname or "owa.b1.ru" - self._session.cookies.set("X-OWA-CANARY", m.group(1), domain=host) - # Persist any cookie changes - self._save_cookies() - except Exception: - pass # non-critical + # Build headers — start with OWA-required headers + headers = self._fresh_headers(action) + headers["x-owa-urlpostdata"] = url_post_data + # Add canary from cookies + headers = self._add_canary_header(headers) - def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: - """Execute a single OWA API request. + # Add extra cookies that the OWA frontend always sends + host = urlparse(self.owa_url).hostname or "owa.b1.ru" + for name, val in [("PrivateComputer", "true"), ("PBack", "0")]: + self._session.cookies.set(name, val, domain=host) - Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload - is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an - empty POST body, plus a full set of OWA-specific headers extracted - from the browser's actual network traffic. - """ - from urllib.parse import urlparse + try: + resp = self._session.post(url, data="", headers=headers, timeout=timeout) + except requests.exceptions.RequestException as exc: + raise SessionExpiredError(f"Request failed: {exc}") from exc - url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1" + # Detect session expiry + if resp.status_code in (401, 440): + raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).") - # URL-encode the JSON payload for the x-owa-urlpostdata header - url_post_data = quote(json.dumps(payload, separators=(",", ":"))) + if "text/html" in resp.headers.get("Content-Type", ""): + body_snippet = resp.text[:300] if resp.text else "" + raise SessionExpiredError( + f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). Snippet: {body_snippet}" + ) - # Build headers — start with OWA-required headers - headers = self._fresh_headers(action) - headers["x-owa-urlpostdata"] = url_post_data - # Add canary from cookies - headers = self._add_canary_header(headers) + # Parse JSON + try: + data = resp.json() + except (ValueError, requests.exceptions.JSONDecodeError) as exc: + raise SessionExpiredError( + f"Unexpected response (HTTP {resp.status_code}). Session may have expired." + ) from exc - # Add extra cookies that the OWA frontend always sends - host = urlparse(self.owa_url).hostname or "owa.b1.ru" - for name, val in [("PrivateComputer", "true"), ("PBack", "0")]: - self._session.cookies.set(name, val, domain=host) + # Persist any Set-Cookie updates back to disk so the next request + # (or a server restart) picks up the freshest session tokens. + self._save_cookies() + return data - try: - resp = self._session.post(url, data="", headers=headers, timeout=timeout) - except requests.exceptions.RequestException as exc: - raise SessionExpiredError(f"Request failed: {exc}") from exc + def request_header_payload( + self, action: str, payload: dict, *, timeout: int = 30 + ) -> dict: + """POST with payload in x-owa-urlpostdata header (empty body). - # Detect session expiry - if resp.status_code in (401, 440): - raise SessionExpiredError( - f"Session expired (HTTP {resp.status_code})." - ) + Alias for :meth:`request` — both methods now use the same + ``x-owa-urlpostdata`` format that mirrors the OWA web frontend. + """ + return self.request(action, payload, timeout=timeout) - if "text/html" in resp.headers.get("Content-Type", ""): - body_snippet = resp.text[:300] if resp.text else "" - raise SessionExpiredError( - f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). " - f"Snippet: {body_snippet}" - ) + # ------------------------------------------------------------------ + # File download (attachments) + # ------------------------------------------------------------------ - # Parse JSON - try: - data = resp.json() - except (ValueError, requests.exceptions.JSONDecodeError) as exc: - raise SessionExpiredError( - f"Unexpected response (HTTP {resp.status_code}). " - "Session may have expired." - ) from exc + def download_file( + self, attachment_id: str, *, timeout: int = 60 + ) -> tuple[bytes, str, str]: + """Download a file attachment by its AttachmentId. - # Persist any Set-Cookie updates back to disk so the next request - # (or a server restart) picks up the freshest session tokens. - self._save_cookies() - return data + Uses the OWA GetFileAttachment endpoint (direct GET). - def request_header_payload( - self, action: str, payload: dict, *, timeout: int = 30 - ) -> dict: - """POST with payload in x-owa-urlpostdata header (empty body). + Returns: + (content_bytes, filename, content_type) + """ + self._ensure_loaded() - Alias for :meth:`request` — both methods now use the same - ``x-owa-urlpostdata`` format that mirrors the OWA web frontend. - """ - return self.request(action, payload, timeout=timeout) + from urllib.parse import quote - # ------------------------------------------------------------------ - # File download (attachments) - # ------------------------------------------------------------------ + url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}" - def download_file( - self, attachment_id: str, *, timeout: int = 60 - ) -> tuple[bytes, str, str]: - """Download a file attachment by its AttachmentId. + try: + resp = self._session.get(url, timeout=timeout) + except requests.exceptions.RequestException as exc: + raise SessionExpiredError(f"Download failed: {exc}") from exc - Uses the OWA GetFileAttachment endpoint (direct GET). + if resp.status_code in (401, 440): + raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).") - Returns: - (content_bytes, filename, content_type) - """ - self._ensure_loaded() + if "text/html" in resp.headers.get("Content-Type", ""): + raise SessionExpiredError( + "Session expired (HTML response on attachment download)." + ) - from urllib.parse import quote + # Persist fresh cookies from the download response + self._save_cookies() - url = ( - f"{self.owa_url}/owa/service.svc/s/GetFileAttachment" - f"?id={quote(attachment_id)}" - ) + # Parse filename from Content-Disposition header + filename = "attachment" + cd = resp.headers.get("Content-Disposition", "") + if cd: + import re as _re + from urllib.parse import unquote - try: - resp = self._session.get(url, timeout=timeout) - except requests.exceptions.RequestException as exc: - raise SessionExpiredError(f"Download failed: {exc}") from exc + # Try filename*= (RFC 5987) first, then filename= + match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd) + if match: + filename = unquote(match.group(1).strip()) + else: + match = _re.search(r'filename="?([^";]+)"?', cd) + if match: + filename = unquote(match.group(1).strip()) - if resp.status_code in (401, 440): - raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).") + content_type = resp.headers.get("Content-Type", "application/octet-stream") - if "text/html" in resp.headers.get("Content-Type", ""): - raise SessionExpiredError( - "Session expired (HTML response on attachment download)." - ) + return resp.content, filename, content_type - # Persist fresh cookies from the download response - self._save_cookies() + # ------------------------------------------------------------------ + # Convenience: extract response items + # ------------------------------------------------------------------ - # Parse filename from Content-Disposition header - filename = "attachment" - cd = resp.headers.get("Content-Disposition", "") - if cd: - import re as _re - from urllib.parse import unquote + @staticmethod + def extract_items(data: dict) -> list[dict]: + """Extract Items from standard OWA response envelope. - # Try filename*= (RFC 5987) first, then filename= - match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd) - if match: - filename = unquote(match.group(1).strip()) - else: - match = _re.search(r'filename="?([^";]+)"?', cd) - if match: - filename = unquote(match.group(1).strip()) + Response shape: data["Body"]["ResponseMessages"]["Items"] + """ + try: + return data["Body"]["ResponseMessages"]["Items"] + except (KeyError, TypeError): + return [] - content_type = resp.headers.get("Content-Type", "application/octet-stream") + # ------------------------------------------------------------------ + # Folder helpers + # ------------------------------------------------------------------ - return resp.content, filename, content_type + def get_folder_id(self, folder_name: str) -> str | None: + """Resolve a folder name to its Exchange folder ID. - # ------------------------------------------------------------------ - # Convenience: extract response items - # ------------------------------------------------------------------ + Supports distinguished folder names (inbox, sentitems, drafts, etc.) + in both English and Russian, plus custom folder names looked up + via FindFolder on msgfolderroot. + """ + folder_lower = folder_name.lower() - @staticmethod - def extract_items(data: dict) -> list[dict]: - """Extract Items from standard OWA response envelope. - - Response shape: data["Body"]["ResponseMessages"]["Items"] - """ - try: - return data["Body"]["ResponseMessages"]["Items"] - except (KeyError, TypeError): - return [] - - # ------------------------------------------------------------------ - # Folder helpers - # ------------------------------------------------------------------ - - def get_folder_id(self, folder_name: str) -> str | None: - """Resolve a folder name to its Exchange folder ID. - - Supports distinguished folder names (inbox, sentitems, drafts, etc.) - in both English and Russian, plus custom folder names looked up - via FindFolder on msgfolderroot. - """ - folder_lower = folder_name.lower() - - # Check distinguished folders first - distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower) - if distinguished_id: - payload = { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "IdOnly", - }, - "FolderIds": [ - { - "__type": "DistinguishedFolderId:#Exchange", - "Id": distinguished_id, - } - ], - }, + # Check distinguished folders first + distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower) + if distinguished_id: + payload = { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": distinguished_id, } + ], + }, + } - data = self.request("GetFolder", payload) - for msg in self.extract_items(data): - if "Folders" in msg: - for f in msg["Folders"]: - fid = f.get("FolderId", {}).get("Id") - if fid: - return fid + data = self.request("GetFolder", payload) + for msg in self.extract_items(data): + if "Folders" in msg: + for f in msg["Folders"]: + fid = f.get("FolderId", {}).get("Id") + if fid: + return fid - # Fall back to searching custom folders by name - payload = { - "__type": "FindFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "FindFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "Default", - }, - "ParentFolderIds": [ - { - "__type": "DistinguishedFolderId:#Exchange", - "Id": "msgfolderroot", - } - ], - "Traversal": "Shallow", - "Paging": { - "__type": "IndexedPageView:#Exchange", - "BasePoint": "Beginning", - "Offset": 0, - "MaxEntriesReturned": 200, - }, - }, - } + # Fall back to searching custom folders by name + payload = { + "__type": "FindFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "Default", + }, + "ParentFolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "msgfolderroot", + } + ], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": 0, + "MaxEntriesReturned": 200, + }, + }, + } - data = self.request("FindFolder", payload) - for msg in self.extract_items(data): - if "RootFolder" in msg and "Folders" in msg["RootFolder"]: - for f in msg["RootFolder"]["Folders"]: - if f.get("DisplayName", "").lower() == folder_lower: - return f.get("FolderId", {}).get("Id") + data = self.request("FindFolder", payload) + for msg in self.extract_items(data): + if "RootFolder" in msg and "Folders" in msg["RootFolder"]: + for f in msg["RootFolder"]["Folders"]: + if f.get("DisplayName", "").lower() == folder_lower: + return f.get("FolderId", {}).get("Id") - return None + return None - # ------------------------------------------------------------------ - # ResolveNames (directory search / attendee resolution) - # ------------------------------------------------------------------ + # ------------------------------------------------------------------ + # ResolveNames (directory search / attendee resolution) + # ------------------------------------------------------------------ - def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]: - """Call ResolveNames to search the directory. + def resolve_names( + self, query: str, *, full_contact: bool = True + ) -> list[dict]: + """Call ResolveNames to search the directory. - Returns the list of Resolution dicts from the API, each containing - Mailbox and optionally Contact data. - """ - payload = { - "__type": "ResolveNamesJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "ResolveNamesRequest:#Exchange", - "UnresolvedEntry": query, - "ReturnFullContactData": full_contact, - "SearchScope": "ActiveDirectoryContacts", - "ContactDataShape": "AllProperties" if full_contact else "Default", - }, - } + Returns the list of Resolution dicts from the API, each containing + Mailbox and optionally Contact data. + """ + payload = { + "__type": "ResolveNamesJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "ResolveNamesRequest:#Exchange", + "UnresolvedEntry": query, + "ReturnFullContactData": full_contact, + "SearchScope": "ActiveDirectoryContacts", + "ContactDataShape": "AllProperties" if full_contact else "Default", + }, + } - data = self.request("ResolveNames", payload) - for msg in self.extract_items(data): - if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]: - return msg["ResolutionSet"]["Resolutions"] + data = self.request("ResolveNames", payload) + for msg in self.extract_items(data): + if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]: + return msg["ResolutionSet"]["Resolutions"] - return [] + return [] diff --git a/owa_mcp/server.py b/owa_mcp/server.py index e88023a..3807931 100644 --- a/owa_mcp/server.py +++ b/owa_mcp/server.py @@ -29,10 +29,10 @@ from mcp.server.fastmcp import FastMCP from owa_mcp.owa_client import OWAClient from owa_mcp.server_lifecycle import ( - HashTokenVerifier, - SessionKeepalive, - apply_tool_filters, - create_owa_client, + HashTokenVerifier, + SessionKeepalive, + apply_tool_filters, + create_owa_client, ) logger = logging.getLogger(__name__) @@ -40,32 +40,32 @@ logger = logging.getLogger(__name__) @dataclass class AppContext: - """Shared application state available to all tools via lifespan context.""" + """Shared application state available to all tools via lifespan context.""" - client: OWAClient | None = None + client: OWAClient | None = None @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Initialise the OWA client on first connection, then keep the session alive. + """Initialise the OWA client on first connection, then keep the session alive. - A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts - immediately (pings inbox, extends the session, persists fresh cookies) and - repeats every 5 minutes until the server shuts down. - """ - client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file) - try: - client._ensure_loaded() - except Exception: - pass # no cookies yet — login tool will handle this + A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts + immediately (pings inbox, extends the session, persists fresh cookies) and + repeats every 5 minutes until the server shuts down. + """ + client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file) + try: + client._ensure_loaded() + except Exception: + pass # no cookies yet — login tool will handle this - keepalive = SessionKeepalive(client) - await keepalive.start() + keepalive = SessionKeepalive(client) + await keepalive.start() - try: - yield AppContext(client=client) - finally: - await keepalive.stop() + try: + yield AppContext(client=client) + finally: + await keepalive.stop() # ── Temporary storage for lifespan args (set by main() before mcp.run()) ── @@ -78,144 +78,143 @@ mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan) def _parse_args(argv=None) -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser( - description="OWA MCP Server — OWA/Exchange integration via MCP", - ) - parser.add_argument( - "--transport", - choices=["stdio", "sse", "streamable-http"], - default="stdio", - help="Transport protocol (default: stdio)", - ) - parser.add_argument( - "--owa-url", - required=True, - help="Base URL of the OWA instance (e.g. https://owa.example.com)", - ) - parser.add_argument( - "--cookie-file", - default=None, - help="Path to session cookies file (default: session-cookies.txt)", - ) - parser.add_argument( - "--token-hash", - default=None, - help=( - "SHA-256 hash of the bearer token for HTTP transport auth. " - "Compute with: echo -n 'my-token' | sha256sum" - ), - ) - parser.add_argument( - "--port", - type=int, - default=8000, - help="Port for HTTP/SSE transport (default: 8000)", - ) - parser.add_argument( - "--host", - default="127.0.0.1", - help="Host for HTTP/SSE transport (default: 127.0.0.1)", - ) - parser.add_argument( - "--enabled-tools", - default=None, - help="Comma-separated whitelist of tool names to expose", - ) - parser.add_argument( - "--disabled-tools", - default=None, - help="Comma-separated blacklist of tool names to hide", - ) - return parser.parse_args(argv) + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="OWA MCP Server — OWA/Exchange integration via MCP", + ) + parser.add_argument( + "--transport", + choices=["stdio", "sse", "streamable-http"], + default="stdio", + help="Transport protocol (default: stdio)", + ) + parser.add_argument( + "--owa-url", + required=True, + help="Base URL of the OWA instance (e.g. https://owa.example.com)", + ) + parser.add_argument( + "--cookie-file", + default=None, + help="Path to session cookies file (default: session-cookies.txt)", + ) + parser.add_argument( + "--token-hash", + default=None, + help=( + "SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum" + ), + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port for HTTP/SSE transport (default: 8000)", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Host for HTTP/SSE transport (default: 127.0.0.1)", + ) + parser.add_argument( + "--enabled-tools", + default=None, + help="Comma-separated whitelist of tool names to expose", + ) + parser.add_argument( + "--disabled-tools", + default=None, + help="Comma-separated blacklist of tool names to hide", + ) + return parser.parse_args(argv) def _configure_auth( - mcp_server: FastMCP, - token_hash: str | None, + mcp_server: FastMCP, + token_hash: str | None, ) -> None: - """Apply token-hash auth to *mcp_server* if *token_hash* is provided.""" - if not token_hash: - return + """Apply token-hash auth to *mcp_server* if *token_hash* is provided.""" + if not token_hash: + return - token_verifier: TokenVerifier = HashTokenVerifier(token_hash) - mcp_server.settings.auth = AuthSettings( # type: ignore[misc] - issuer_url="http://localhost", # type: ignore[arg-type] - resource_server_url="http://localhost", # type: ignore[arg-type] - ) - mcp_server._token_verifier = token_verifier # noqa: SLF001 + token_verifier: TokenVerifier = HashTokenVerifier(token_hash) + mcp_server.settings.auth = AuthSettings( # type: ignore[misc] + issuer_url="http://localhost", # type: ignore[arg-type] + resource_server_url="http://localhost", # type: ignore[arg-type] + ) + mcp_server._token_verifier = token_verifier # noqa: SLF001 def main(argv=None) -> None: - """Entry point: parse CLI args, configure server, run transport.""" - global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603 + """Entry point: parse CLI args, configure server, run transport.""" + global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603 - args = _parse_args(argv) + args = _parse_args(argv) - # ── Validate --owa-url ───────────────────────────────────────────────── - owa_url = args.owa_url.strip().rstrip("/") - if not owa_url: - print( - "ERROR: --owa-url must not be empty.", - file=__import__("sys").stderr, - ) - raise SystemExit(1) + # ── Validate --owa-url ───────────────────────────────────────────────── + owa_url = args.owa_url.strip().rstrip("/") + if not owa_url: + print( + "ERROR: --owa-url must not be empty.", + file=__import__("sys").stderr, + ) + raise SystemExit(1) - # ── Stash lifespan args (consumed by app_lifespan) ───────────────────── - _lifespan_owa_url = owa_url - _lifespan_cookie_file = args.cookie_file + # ── Stash lifespan args (consumed by app_lifespan) ───────────────────── + _lifespan_owa_url = owa_url + _lifespan_cookie_file = args.cookie_file - # ── Validate --token-hash for non-stdio transports ───────────────────── - if args.transport != "stdio" and not args.token_hash: - print( - "ERROR: --token-hash is required when using HTTP transport.", - file=__import__("sys").stderr, - ) - print( - " Compute hash: echo -n 'my-token' | sha256sum", - file=__import__("sys").stderr, - ) - raise SystemExit(1) + # ── Validate --token-hash for non-stdio transports ───────────────────── + if args.transport != "stdio" and not args.token_hash: + print( + "ERROR: --token-hash is required when using HTTP transport.", + file=__import__("sys").stderr, + ) + print( + " Compute hash: echo -n 'my-token' | sha256sum", + file=__import__("sys").stderr, + ) + raise SystemExit(1) - # ── Register tools (imports populate module-level `mcp`) ─────────────── - import owa_mcp.tools.analytics # noqa: E402, F401 - import owa_mcp.tools.auth # noqa: E402, F401 - import owa_mcp.tools.availability # noqa: E402, F401 - import owa_mcp.tools.calendar # noqa: E402, F401 - import owa_mcp.tools.email # noqa: E402, F401 - import owa_mcp.tools.folders # noqa: E402, F401 - import owa_mcp.tools.people # noqa: E402, F401 + # ── Register tools (imports populate module-level `mcp`) ─────────────── + import owa_mcp.tools.analytics # noqa: E402, F401 + import owa_mcp.tools.auth # noqa: E402, F401 + import owa_mcp.tools.availability # noqa: E402, F401 + import owa_mcp.tools.calendar # noqa: E402, F401 + import owa_mcp.tools.email # noqa: E402, F401 + import owa_mcp.tools.folders # noqa: E402, F401 + import owa_mcp.tools.people # noqa: E402, F401 - # ── Auth for HTTP transports ─────────────────────────────────────────── - _configure_auth(mcp, args.token_hash) + # ── Auth for HTTP transports ─────────────────────────────────────────── + _configure_auth(mcp, args.token_hash) - # ── Tool filtering ───────────────────────────────────────────────────── - enabled: list[str] | None = None - if args.enabled_tools is not None: - enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] + # ── Tool filtering ───────────────────────────────────────────────────── + enabled: list[str] | None = None + if args.enabled_tools is not None: + enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] - disabled: list[str] | None = None - if args.disabled_tools is not None: - disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] + disabled: list[str] | None = None + if args.disabled_tools is not None: + disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] - if enabled or disabled: - apply_tool_filters(mcp, enabled=enabled, disabled=disabled) + if enabled or disabled: + apply_tool_filters(mcp, enabled=enabled, disabled=disabled) - # ── Host / port (already passed to FastMCP at module level; update if - # different from defaults — useful when server is re-invoked with - # different args in tests) ───────────────────────────────────────────── - mcp.settings.host = args.host - mcp.settings.port = args.port + # ── Host / port (already passed to FastMCP at module level; update if + # different from defaults — useful when server is re-invoked with + # different args in tests) ───────────────────────────────────────────── + mcp.settings.host = args.host + mcp.settings.port = args.port - # ── Run transport ───────────────────────────────────────────────────── - match args.transport: - case "stdio": - mcp.run() - case "sse": - mcp.run(transport="sse") - case "streamable-http": - mcp.run(transport="streamable-http") + # ── Run transport ───────────────────────────────────────────────────── + match args.transport: + case "stdio": + mcp.run() + case "sse": + mcp.run(transport="sse") + case "streamable-http": + mcp.run(transport="streamable-http") if __name__ == "__main__": - main() + main() diff --git a/owa_mcp/server_lifecycle.py b/owa_mcp/server_lifecycle.py index 00333be..154f8a0 100644 --- a/owa_mcp/server_lifecycle.py +++ b/owa_mcp/server_lifecycle.py @@ -22,7 +22,7 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from owa_mcp.owa_client import OWAClient, SessionExpiredError if TYPE_CHECKING: - from mcp.server.fastmcp import FastMCP + from mcp.server.fastmcp import FastMCP logger = logging.getLogger(__name__) @@ -30,87 +30,88 @@ logger = logging.getLogger(__name__) # ── Client factory ─────────────────────────────────────────────────────── -def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient: - """Create and return a fully configured :class:`OWAClient`. +def create_owa_client( + owa_url: str, cookie_file: str | None = None +) -> OWAClient: + """Create and return a fully configured :class:`OWAClient`. - Args: - owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``). - cookie_file: Path to the session-cookie file. ``None`` falls back to the - default ``session-cookies.txt`` next to the package. + Args: + owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``). + cookie_file: Path to the session-cookie file. ``None`` falls back to the + default ``session-cookies.txt`` next to the package. - Returns: - A ready-to-use :class:`OWAClient`. + Returns: + A ready-to-use :class:`OWAClient`. - Raises: - ValueError: If *owa_url* is empty. - """ - owa_url = owa_url.strip().rstrip("/") - if not owa_url: - raise ValueError("OWA URL must not be empty.") - return OWAClient(owa_url=owa_url, cookie_file=cookie_file) + Raises: + ValueError: If *owa_url* is empty. + """ + owa_url = owa_url.strip().rstrip("/") + if not owa_url: + raise ValueError("OWA URL must not be empty.") + return OWAClient(owa_url=owa_url, cookie_file=cookie_file) # ── Token verifier ──────────────────────────────────────────────────────── class HashTokenVerifier(TokenVerifier): - """Bearer-token verifier backed by a SHA-256 hash. + """Bearer-token verifier backed by a SHA-256 hash. - The server never stores the plaintext token — only its hex digest. - Clients send the plaintext token as ``Authorization: Bearer ``, - the server hashes it and compares with the stored hash. - """ + The server never stores the plaintext token — only its hex digest. + Clients send the plaintext token as ``Authorization: Bearer ``, + the server hashes it and compares with the stored hash. + """ - def __init__(self, token_hash: str) -> None: + def __init__(self, token_hash: str) -> None: - self._expected_hash = token_hash.strip().lower() + self._expected_hash = token_hash.strip().lower() - async def verify_token(self, token: str) -> AccessToken | None: - import hashlib + async def verify_token(self, token: str) -> AccessToken | None: + import hashlib - if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: - return AccessToken( - token=token, - client_id="mcp-client", - scopes=[], - ) - return None + if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: + return AccessToken( + token=token, + client_id="mcp-client", + scopes=[], + ) + return None # ── Lifespan helper ────────────────────────────────────────────────────── class _LazyClient: - """Thin wrapper that resolves the OWAClient on first access. + """Thin wrapper that resolves the OWAClient on first access. - Stored as ``AppContext.client`` so that ``require_client`` can work - with both a pre-built client and a ``None`` placeholder. - """ + Stored as ``AppContext.client`` so that ``require_client`` can work + with both a pre-built client and a ``None`` placeholder. + """ - def __init__(self, client: OWAClient | None) -> None: - self._client = client + def __init__(self, client: OWAClient | None) -> None: + self._client = client - @property - def resolved(self) -> OWAClient: - if self._client is None: - raise RuntimeError( - "OWA client is not initialised. " - "Check --owa-url and --cookie-file arguments." - ) - return self._client + @property + def resolved(self) -> OWAClient: + if self._client is None: + raise RuntimeError( + "OWA client is not initialised. Check --owa-url and --cookie-file arguments." + ) + return self._client def require_client(client: OWAClient | None) -> OWAClient: - """Return *client* if it is not ``None``, otherwise raise ``RuntimeError``. + """Return *client* if it is not ``None``, otherwise raise ``RuntimeError``. - Import this in tool modules and call it with ``ctx.client`` to get a - consistent error message when OWA is not configured. - """ - if client is None: - raise RuntimeError( - "OWA client is not initialised. Pass --owa-url when starting the server." - ) - return client + Import this in tool modules and call it with ``ctx.client`` to get a + consistent error message when OWA is not configured. + """ + if client is None: + raise RuntimeError( + "OWA client is not initialised. Pass --owa-url when starting the server." + ) + return client # ── Session keepalive ────────────────────────────────────────────────────── @@ -119,151 +120,151 @@ _DEFAULT_KEEPALIVE_INTERVAL = 300 # seconds (5 minutes) class SessionKeepalive: - """Background task that periodically pings the OWA server to extend the - session lifetime and persist fresh cookies to disk. + """Background task that periodically pings the OWA server to extend the + session lifetime and persist fresh cookies to disk. - On each successful ping the OWA server may return updated ``Set-Cookie`` - headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT). - The client saves those cookies to disk automatically. + On each successful ping the OWA server may return updated ``Set-Cookie`` + headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT). + The client saves those cookies to disk automatically. - On failure the keepalive attempts to reload cookies from disk so that a - fresh login performed by another process (or via the ``login`` tool) is - picked up without restarting the server. + On failure the keepalive attempts to reload cookies from disk so that a + fresh login performed by another process (or via the ``login`` tool) is + picked up without restarting the server. + """ + + def __init__( + self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL + ) -> None: + self._client = client + self._interval = interval + self._task: asyncio.Task | None = None + + async def start(self) -> None: + """Start the background keepalive loop. + + Fires one ping immediately so the session is refreshed on server + start-up, then waits ``interval`` seconds between subsequent pings. """ + if self._task is not None: + return + self._task = asyncio.create_task(self._run(), name="session-keepalive") - def __init__( - self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL - ) -> None: - self._client = client - self._interval = interval - self._task: asyncio.Task | None = None + async def stop(self) -> None: + """Cancel the background keepalive loop and wait for it to finish.""" + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None - async def start(self) -> None: - """Start the background keepalive loop. + async def _run(self) -> None: + """Ping immediately, then every ``interval`` seconds.""" + # First ping right away (on server start) + await asyncio.to_thread(self._ping) + while True: + await asyncio.sleep(self._interval) + await asyncio.to_thread(self._ping) - Fires one ping immediately so the session is refreshed on server - start-up, then waits ``interval`` seconds between subsequent pings. - """ - if self._task is not None: - return - self._task = asyncio.create_task(self._run(), name="session-keepalive") + def _ping(self) -> None: + """Make a lightweight GetFolder request against the inbox. - async def stop(self) -> None: - """Cancel the background keepalive loop and wait for it to finish.""" - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - - async def _run(self) -> None: - """Ping immediately, then every ``interval`` seconds.""" - # First ping right away (on server start) - await asyncio.to_thread(self._ping) - while True: - await asyncio.sleep(self._interval) - await asyncio.to_thread(self._ping) - - def _ping(self) -> None: - """Make a lightweight GetFolder request against the inbox. - - On success the OWAClient saves fresh cookies to disk automatically. - On ``SessionExpiredError`` the on-disk cookies are re-loaded in - case the user re-authenticated while the server was running. - """ - try: - self._client.request( - "GetFolder", - { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "IdOnly", - }, - "FolderIds": [ - { - "__type": "DistinguishedFolderId:#Exchange", - "Id": "inbox", - } - ], - }, - }, - ) - logger.debug("Session keepalive ping succeeded") - except SessionExpiredError: - logger.warning( - "Session expired during keepalive — reloading cookies from disk" - ) - try: - self._client._ensure_loaded() - except Exception as exc: - logger.warning( - "Failed to reload cookies after keepalive failure: %s", exc - ) - except Exception as exc: - logger.debug("Keepalive ping failed (non-critical): %s", exc) + On success the OWAClient saves fresh cookies to disk automatically. + On ``SessionExpiredError`` the on-disk cookies are re-loaded in + case the user re-authenticated while the server was running. + """ + try: + self._client.request( + "GetFolder", + { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "inbox", + } + ], + }, + }, + ) + logger.debug("Session keepalive ping succeeded") + except SessionExpiredError: + logger.warning( + "Session expired during keepalive — reloading cookies from disk" + ) + try: + self._client._ensure_loaded() + except Exception as exc: + logger.warning( + "Failed to reload cookies after keepalive failure: %s", exc + ) + except Exception as exc: + logger.debug("Keepalive ping failed (non-critical): %s", exc) # ── Tool filtering ─────────────────────────────────────────────────────── def apply_tool_filters( - server: FastMCP, - enabled: list[str] | None = None, - disabled: list[str] | None = None, + server: FastMCP, + enabled: list[str] | None = None, + disabled: list[str] | None = None, ) -> None: - """Apply whitelist / blacklist filtering to *server*'s registered tools. + """Apply whitelist / blacklist filtering to *server*'s registered tools. - Args: - server: The :class:`FastMCP` instance whose tool registry is modified - in-place. - enabled: Optional list of tool names to **keep**. If ``None`` or empty - all tools are eligible. Unknown names are ignored (warning - logged). - disabled: Optional list of tool names to **remove**. Applied *after* - the whitelist. Unknown names are ignored (warning logged). - """ - canonical = {t.name for t in asyncio.run(server.list_tools())} - if not canonical: - return + Args: + server: The :class:`FastMCP` instance whose tool registry is modified + in-place. + enabled: Optional list of tool names to **keep**. If ``None`` or empty + all tools are eligible. Unknown names are ignored (warning + logged). + disabled: Optional list of tool names to **remove**. Applied *after* + the whitelist. Unknown names are ignored (warning logged). + """ + canonical = {t.name for t in asyncio.run(server.list_tools())} + if not canonical: + return - allowed: set[str] = set(canonical) + allowed: set[str] = set(canonical) - # ── Whitelist ────────────────────────────────────────────────────────── - if enabled: - enabled_set = {n.strip() for n in enabled if n.strip()} - allowed &= enabled_set - unknown = enabled_set - canonical - if unknown: - logger.warning( - "Unknown tool(s) in --enabled-tools, ignoring: %s", - ", ".join(sorted(unknown)), - ) + # ── Whitelist ────────────────────────────────────────────────────────── + if enabled: + enabled_set = {n.strip() for n in enabled if n.strip()} + allowed &= enabled_set + unknown = enabled_set - canonical + if unknown: + logger.warning( + "Unknown tool(s) in --enabled-tools, ignoring: %s", + ", ".join(sorted(unknown)), + ) - # ── Blacklist ────────────────────────────────────────────────────────── - if disabled: - disabled_set = {n.strip() for n in disabled if n.strip()} - allowed -= disabled_set - unknown = disabled_set - canonical - if unknown: - logger.warning( - "Unknown tool(s) in --disabled-tools, ignoring: %s", - ", ".join(sorted(unknown)), - ) + # ── Blacklist ────────────────────────────────────────────────────────── + if disabled: + disabled_set = {n.strip() for n in disabled if n.strip()} + allowed -= disabled_set + unknown = disabled_set - canonical + if unknown: + logger.warning( + "Unknown tool(s) in --disabled-tools, ignoring: %s", + ", ".join(sorted(unknown)), + ) - # ── Remove ───────────────────────────────────────────────────────────── - to_remove = sorted(canonical - allowed) - if to_remove: - logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove)) - for name in to_remove: - server.remove_tool(name) + # ── Remove ───────────────────────────────────────────────────────────── + to_remove = sorted(canonical - allowed) + if to_remove: + logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove)) + for name in to_remove: + server.remove_tool(name) - logger.info("Tools available: %s", ", ".join(sorted(allowed))) + logger.info("Tools available: %s", ", ".join(sorted(allowed))) diff --git a/owa_mcp/tools/analytics.py b/owa_mcp/tools/analytics.py index 1c7872d..c1c5a05 100644 --- a/owa_mcp/tools/analytics.py +++ b/owa_mcp/tools/analytics.py @@ -16,9 +16,9 @@ from owa_mcp.server_lifecycle import require_client def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - app_ctx: AppContext = ctx.request_context.lifespan_context - return require_client(app_ctx.client) + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) # ------------------------------------------------------------------ @@ -27,12 +27,12 @@ def _get_client(ctx: Context) -> OWAClient: def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: - """Resolve a name/email to (display_name, email). Returns ('','') on failure.""" - resolutions = client.resolve_names(name) - if resolutions: - mb = resolutions[0].get("Mailbox", {}) - return mb.get("Name", name), mb.get("EmailAddress", "") - return "", "" + """Resolve a name/email to (display_name, email). Returns ('','') on failure.""" + resolutions = client.resolve_names(name) + if resolutions: + mb = resolutions[0].get("Mailbox", {}) + return mb.get("Name", name), mb.get("EmailAddress", "") + return "", "" # ------------------------------------------------------------------ @@ -41,105 +41,105 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: def _get_availability_events( - client: OWAClient, - emails: list[str], - start: date, - end: date, - batch_size: int = 5, - chunk_days: int = 14, + client: OWAClient, + emails: list[str], + start: date, + end: date, + batch_size: int = 5, + chunk_days: int = 14, ) -> dict[str, list[dict]]: - """Query GetUserAvailability for multiple people across a date range. + """Query GetUserAvailability for multiple people across a date range. - Returns dict mapping email -> list of calendar event dicts with - keys: subject, start_date, busy_type. - """ - results: dict[str, list[dict]] = {email: [] for email in emails} + Returns dict mapping email -> list of calendar event dicts with + keys: subject, start_date, busy_type. + """ + results: dict[str, list[dict]] = {email: [] for email in emails} - # Batch people - email_batches = [ - emails[i : i + batch_size] for i in range(0, len(emails), batch_size) - ] + # Batch people + email_batches = [ + emails[i : i + batch_size] for i in range(0, len(emails), batch_size) + ] - for batch in email_batches: - current = start - while current < end: - chunk_end = min(current + timedelta(days=chunk_days), end) + for batch in email_batches: + current = start + while current < end: + chunk_end = min(current + timedelta(days=chunk_days), end) - mailbox_data = [ + mailbox_data = [ + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": email, + }, + "AttendeeType": "Required", + } + for email in batch + ] + + payload = { + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", + }, + }, + }, + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": mailbox_data, + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{current}T00:00:00", + "EndTime": f"{chunk_end}T00:00:00", + }, + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", + }, + }, + } + + try: + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) + for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])): + if i >= len(batch): + break + email = batch[i] + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) + if isinstance(cal_events, dict): + items = cal_events.get("Items", []) + elif isinstance(cal_events, list): + items = cal_events + else: + items = [] + for event in items: + s = event.get("StartTime", "") + bt = event.get("BusyType", "") + if s and bt != "Free": + details = event.get("CalendarEventDetails", {}) + subject = details.get("Subject", "") if details else "" + results[email].append( { - "__type": "MailboxData:#Exchange", - "Email": { - "__type": "EmailAddress:#Exchange", - "Address": email, - }, - "AttendeeType": "Required", + "subject": subject, + "start_date": s[:10], + "busy_type": bt, } - for email in batch - ] + ) + except Exception: + pass - payload = { - "__type": "GetUserAvailabilityJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, - }, - }, - "Body": { - "__type": "GetUserAvailabilityRequest:#Exchange", - "MailboxDataArray": mailbox_data, - "FreeBusyViewOptions": { - "__type": "FreeBusyViewOptions:#Exchange", - "TimeWindow": { - "__type": "Duration:#Exchange", - "StartTime": f"{current}T00:00:00", - "EndTime": f"{chunk_end}T00:00:00", - }, - "MergedFreeBusyIntervalInMinutes": 30, - "RequestedView": "DetailedMerged", - }, - }, - } + current = chunk_end - try: - data = client.request("GetUserAvailability", payload) - body = data.get("Body", {}) - for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])): - if i >= len(batch): - break - email = batch[i] - fb_view = fb_resp.get("FreeBusyView", {}) - cal_events = fb_view.get("CalendarEventArray", {}) - if isinstance(cal_events, dict): - items = cal_events.get("Items", []) - elif isinstance(cal_events, list): - items = cal_events - else: - items = [] - for event in items: - s = event.get("StartTime", "") - bt = event.get("BusyType", "") - if s and bt != "Free": - details = event.get("CalendarEventDetails", {}) - subject = details.get("Subject", "") if details else "" - results[email].append( - { - "subject": subject, - "start_date": s[:10], - "busy_type": bt, - } - ) - except Exception: - pass - - current = chunk_end - - return results + return results # ------------------------------------------------------------------ @@ -149,105 +149,107 @@ def _get_availability_events( @mcp.tool() def get_meeting_stats( - people: str, - start_date: str, - end_date: str, - ctx: Context = None, + people: str, + start_date: str, + end_date: str, + ctx: Context = None, ) -> str: - """Get meeting count statistics for one or more people over a date range. + """Get meeting count statistics for one or more people over a date range. - Uses GetUserAvailability to count calendar events (including - expanded recurring instances) for each person. + Uses GetUserAvailability to count calendar events (including + expanded recurring instances) for each person. - Args: - people: Comma-separated names or email addresses to analyze. - start_date: Start date in YYYY-MM-DD format. - end_date: End date in YYYY-MM-DD format. + Args: + people: Comma-separated names or email addresses to analyze. + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. - Returns: - JSON object with per-person stats sorted by meeting count: - total_meetings, meetings_per_workday, days_with_meetings. - """ - client = _get_client(ctx) + Returns: + JSON object with per-person stats sorted by meeting count: + total_meetings, meetings_per_workday, days_with_meetings. + """ + client = _get_client(ctx) - try: - sd = datetime.strptime(start_date, "%Y-%m-%d").date() - ed = datetime.strptime(end_date, "%Y-%m-%d").date() - except ValueError as e: - return json.dumps({"error": f"Invalid date format: {e}"}) + try: + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) - # Resolve names - name_list = [n.strip() for n in people.split(",") if n.strip()] - if not name_list: - return json.dumps({"error": "No people specified."}) + # Resolve names + name_list = [n.strip() for n in people.split(",") if n.strip()] + if not name_list: + return json.dumps({"error": "No people specified."}) - resolved = [] # (display_name, email) - for name in name_list: - display, email = _resolve_to_email(client, name) - if email: - resolved.append((display, email)) - else: - resolved.append((name, "")) - - emails = [email for _, email in resolved if email] - if not emails: - return json.dumps({"error": "Could not resolve any names to email addresses."}) - - # Query availability - avail = _get_availability_events(client, emails, sd, ed) - - # Count working days - workdays = 0 - d = sd - while d < ed: - if d.weekday() < 5: - workdays += 1 - d += timedelta(days=1) - workdays = max(workdays, 1) - - # Build stats - stats = [] - for display, email in resolved: - if not email or email not in avail: - stats.append( - { - "name": display, - "email": email or "not_found", - "total_meetings": 0, - "meetings_per_workday": 0, - "days_with_meetings": 0, - "workdays": workdays, - } - ) - continue - - events = avail[email] - total = len(events) - unique_days = len(set(ev["start_date"] for ev in events)) - avg = round(total / workdays, 1) - - stats.append( - { - "name": display, - "email": email, - "total_meetings": total, - "meetings_per_workday": avg, - "days_with_meetings": unique_days, - "workdays": workdays, - } - ) - - # Sort by total descending - stats.sort(key=lambda x: x["total_meetings"], reverse=True) + resolved = [] # (display_name, email) + for name in name_list: + display, email = _resolve_to_email(client, name) + if email: + resolved.append((display, email)) + else: + resolved.append((name, "")) + emails = [email for _, email in resolved if email] + if not emails: return json.dumps( - { - "period": {"start": start_date, "end": end_date, "workdays": workdays}, - "stats": stats, - }, - ensure_ascii=False, + {"error": "Could not resolve any names to email addresses."} ) + # Query availability + avail = _get_availability_events(client, emails, sd, ed) + + # Count working days + workdays = 0 + d = sd + while d < ed: + if d.weekday() < 5: + workdays += 1 + d += timedelta(days=1) + workdays = max(workdays, 1) + + # Build stats + stats = [] + for display, email in resolved: + if not email or email not in avail: + stats.append( + { + "name": display, + "email": email or "not_found", + "total_meetings": 0, + "meetings_per_workday": 0, + "days_with_meetings": 0, + "workdays": workdays, + } + ) + continue + + events = avail[email] + total = len(events) + unique_days = len(set(ev["start_date"] for ev in events)) + avg = round(total / workdays, 1) + + stats.append( + { + "name": display, + "email": email, + "total_meetings": total, + "meetings_per_workday": avg, + "days_with_meetings": unique_days, + "workdays": workdays, + } + ) + + # Sort by total descending + stats.sort(key=lambda x: x["total_meetings"], reverse=True) + + return json.dumps( + { + "period": {"start": start_date, "end": end_date, "workdays": workdays}, + "stats": stats, + }, + ensure_ascii=False, + ) + # ------------------------------------------------------------------ # Tool: get_meeting_contacts @@ -256,194 +258,196 @@ def get_meeting_stats( @mcp.tool() def get_meeting_contacts( - start_date: str, - end_date: str, - top_n: int = 30, - ctx: Context = None, + start_date: str, + end_date: str, + top_n: int = 30, + ctx: Context = None, ) -> str: - """Build a connection matrix: who you meet with most often. + """Build a connection matrix: who you meet with most often. - Analyzes your calendar over a date range, accounting for recurring - meetings. For each contact found in your meetings, returns the - weighted count of shared meetings. + Analyzes your calendar over a date range, accounting for recurring + meetings. For each contact found in your meetings, returns the + weighted count of shared meetings. - Args: - start_date: Start date in YYYY-MM-DD format. - end_date: End date in YYYY-MM-DD format. - top_n: Number of top contacts to return. Default 30. + Args: + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + top_n: Number of top contacts to return. Default 30. - Returns: - JSON object with total_meetings, unique_contacts, and a ranked - contacts array of {name, email, meetings} objects. - """ - client = _get_client(ctx) + Returns: + JSON object with total_meetings, unique_contacts, and a ranked + contacts array of {name, email, meetings} objects. + """ + client = _get_client(ctx) + + try: + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) + + # Step 1: Get own expanded events via GetUserAvailability + # to count subject occurrences (handles recurring meetings) + own_email = client.user_email.lower() if client.user_email else "" + if not own_email: + return json.dumps( + {"error": "User email not available. Call the login tool first."} + ) + + folder_id = client.get_folder_id("calendar") + if not folder_id: + return json.dumps({"error": "Could not find calendar folder."}) + + # Get expanded event subjects with occurrence counts + avail_result = _get_availability_events(client, [client.user_email], sd, ed) + expanded_events = avail_result.get(client.user_email, []) + + subject_counts = Counter(ev["subject"] for ev in expanded_events) + total_expanded = len(expanded_events) + + # Step 2: Find master calendar items for each unique subject + # Scan entire calendar (descending) to match subjects + subject_to_id: dict[str, str] = {} + offset = 0 + + while len(subject_to_id) < len(subject_counts): + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "Default", + }, + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": 200, + }, + "SortOrder": [ + { + "__type": "SortResults:#Exchange", + "Order": "Descending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Start", + }, + } + ], + }, + } + + data = client.request("FindItem", payload) + items = client.extract_items(data) + if not items: + break + folder_items = items[0].get("RootFolder", {}).get("Items", []) + is_last = ( + items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) + ) + if not folder_items: + break + + for item in folder_items: + subj = item.get("Subject", "") + if subj and subj in subject_counts and subj not in subject_to_id: + subject_to_id[subj] = item.get("ItemId", {}).get("Id", "") + + if is_last: + break + offset += 200 + if offset > 3000: + break + + # Step 3: GetItem for each master to get attendees + unique_ids = list(set(subject_to_id.values())) + id_to_attendees: dict[str, set] = {} + + for bi in range(0, len(unique_ids), 10): + batch = unique_ids[bi : bi + 10] + item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch] + + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": item_id_list, + }, + } try: - sd = datetime.strptime(start_date, "%Y-%m-%d").date() - ed = datetime.strptime(end_date, "%Y-%m-%d").date() - except ValueError as e: - return json.dumps({"error": f"Invalid date format: {e}"}) + data = client.request("GetItem", payload) + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + item_id = item.get("ItemId", {}).get("Id", "") + attendees = set() - # Step 1: Get own expanded events via GetUserAvailability - # to count subject occurrences (handles recurring meetings) - own_email = client.user_email.lower() if client.user_email else "" - if not own_email: - return json.dumps( - {"error": "User email not available. Call the login tool first."} - ) - - folder_id = client.get_folder_id("calendar") - if not folder_id: - return json.dumps({"error": "Could not find calendar folder."}) - - # Get expanded event subjects with occurrence counts - avail_result = _get_availability_events(client, [client.user_email], sd, ed) - expanded_events = avail_result.get(client.user_email, []) - - subject_counts = Counter(ev["subject"] for ev in expanded_events) - total_expanded = len(expanded_events) - - # Step 2: Find master calendar items for each unique subject - # Scan entire calendar (descending) to match subjects - subject_to_id: dict[str, str] = {} - offset = 0 - - while len(subject_to_id) < len(subject_counts): - payload = { - "__type": "FindItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "FindItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "Default", - }, - "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "Traversal": "Shallow", - "Paging": { - "__type": "IndexedPageView:#Exchange", - "BasePoint": "Beginning", - "Offset": offset, - "MaxEntriesReturned": 200, - }, - "SortOrder": [ - { - "__type": "SortResults:#Exchange", - "Order": "Descending", - "Path": { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Start", - }, - } - ], - }, - } - - data = client.request("FindItem", payload) - items = client.extract_items(data) - if not items: - break - folder_items = items[0].get("RootFolder", {}).get("Items", []) - is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) - if not folder_items: - break - - for item in folder_items: - subj = item.get("Subject", "") - if subj and subj in subject_counts and subj not in subject_to_id: - subject_to_id[subj] = item.get("ItemId", {}).get("Id", "") - - if is_last: - break - offset += 200 - if offset > 3000: - break - - # Step 3: GetItem for each master to get attendees - unique_ids = list(set(subject_to_id.values())) - id_to_attendees: dict[str, set] = {} - - for bi in range(0, len(unique_ids), 10): - batch = unique_ids[bi : bi + 10] - item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch] - - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - }, - "ItemIds": item_id_list, - }, - } - - try: - data = client.request("GetItem", payload) - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - item_id = item.get("ItemId", {}).get("Id", "") - attendees = set() - - for att_list in [ - item.get("RequiredAttendees", []) or [], - item.get("OptionalAttendees", []) or [], - ]: - for a in att_list: - mailbox = a.get("Mailbox", {}) - name = mailbox.get("Name", "") - addr = mailbox.get("EmailAddress", "") - if not name or not addr or addr.startswith("/O="): - continue - attendees.add((name, addr.lower())) - - organizer = item.get("Organizer", {}).get("Mailbox", {}) - if organizer: - org_addr = organizer.get("EmailAddress", "") - org_name = organizer.get("Name", "") - if org_addr and not org_addr.startswith("/O="): - attendees.add((org_name, org_addr.lower())) - - id_to_attendees[item_id] = attendees - except Exception: - pass - - # Step 4: Build weighted contacts, excluding self - contacts = Counter() - for subject, item_id in subject_to_id.items(): - weight = subject_counts.get(subject, 1) - attendees = id_to_attendees.get(item_id, set()) - for name, email in attendees: - if own_email and email == own_email: + for att_list in [ + item.get("RequiredAttendees", []) or [], + item.get("OptionalAttendees", []) or [], + ]: + for a in att_list: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + if not name or not addr or addr.startswith("/O="): continue - contacts[(name, email)] += weight + attendees.add((name, addr.lower())) - result_contacts = [] - for (name, email), count in contacts.most_common(top_n): - result_contacts.append( - { - "name": name, - "email": email, - "meetings": count, - } - ) + organizer = item.get("Organizer", {}).get("Mailbox", {}) + if organizer: + org_addr = organizer.get("EmailAddress", "") + org_name = organizer.get("Name", "") + if org_addr and not org_addr.startswith("/O="): + attendees.add((org_name, org_addr.lower())) - return json.dumps( - { - "period": {"start": start_date, "end": end_date}, - "total_meetings": total_expanded, - "unique_contacts": len(contacts), - "contacts": result_contacts, - }, - ensure_ascii=False, + id_to_attendees[item_id] = attendees + except Exception: + pass + + # Step 4: Build weighted contacts, excluding self + contacts = Counter() + for subject, item_id in subject_to_id.items(): + weight = subject_counts.get(subject, 1) + attendees = id_to_attendees.get(item_id, set()) + for name, email in attendees: + if own_email and email == own_email: + continue + contacts[(name, email)] += weight + + result_contacts = [] + for (name, email), count in contacts.most_common(top_n): + result_contacts.append( + { + "name": name, + "email": email, + "meetings": count, + } ) + + return json.dumps( + { + "period": {"start": start_date, "end": end_date}, + "total_meetings": total_expanded, + "unique_contacts": len(contacts), + "contacts": result_contacts, + }, + ensure_ascii=False, + ) diff --git a/owa_mcp/tools/auth.py b/owa_mcp/tools/auth.py index b2d354d..46b84d6 100644 --- a/owa_mcp/tools/auth.py +++ b/owa_mcp/tools/auth.py @@ -18,108 +18,108 @@ from owa_mcp.server_lifecycle import require_client def _get_app_ctx(ctx: Context) -> AppContext: - """Extract the AppContext from the MCP lifespan context.""" - return ctx.request_context.lifespan_context + """Extract the AppContext from the MCP lifespan context.""" + return ctx.request_context.lifespan_context def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - return require_client(_get_app_ctx(ctx).client) + """Extract the OWAClient from the MCP lifespan context.""" + return require_client(_get_app_ctx(ctx).client) def _session_is_valid(client: OWAClient) -> bool: - """Quick check: can we reach the inbox?""" - try: - client._ensure_loaded() - payload = { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "IdOnly", - }, - "FolderIds": [ - { - "__type": "DistinguishedFolderId:#Exchange", - "Id": "inbox", - } - ], - }, - } - data = client.request("GetFolder", payload) - items = client.extract_items(data) - return bool(items) - except Exception: - return False + """Quick check: can we reach the inbox?""" + try: + client._ensure_loaded() + payload = { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "inbox", + } + ], + }, + } + data = client.request("GetFolder", payload) + items = client.extract_items(data) + return bool(items) + except Exception: + return False @mcp.tool() async def login( - cookies: str, - ctx: Context = None, + cookies: str, + ctx: Context = None, ) -> str: - """Authenticate to Exchange OWA using session cookies from the browser. + """Authenticate to Exchange OWA using session cookies from the browser. - Paste the cookies you copied from your browser (DevTools → Application → - Cookies) as a ``name=value`` string — one cookie per line. + Paste the cookies you copied from your browser (DevTools → Application → + Cookies) as a ``name=value`` string — one cookie per line. - Example cookies string:: + Example cookies string:: - X-OWA-CANARY=abc123 - CookieAuth1=def456 - ... + X-OWA-CANARY=abc123 + CookieAuth1=def456 + ... - Args: - cookies: Session cookies in ``name=value`` format, one per line. - Obtain from browser DevTools → Application → Cookies → copy all - cookies as ``name=value`` pairs. + Args: + cookies: Session cookies in ``name=value`` format, one per line. + Obtain from browser DevTools → Application → Cookies → copy all + cookies as ``name=value`` pairs. - Returns: - JSON result with success status and message. - """ - client = _get_client(ctx) + Returns: + JSON result with success status and message. + """ + client = _get_client(ctx) - # Parse and validate cookies - cookies_str = cookies.strip() - if not cookies_str: - return json.dumps( - { - "success": False, - "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", - } - ) + # Parse and validate cookies + cookies_str = cookies.strip() + if not cookies_str: + return json.dumps( + { + "success": False, + "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", + } + ) - # Save to disk - try: - client.cookie_file.write_text(cookies_str + "\n") - except Exception as e: - return json.dumps( - {"success": False, "error": f"Failed to write cookie file: {e}"} - ) + # Save to disk + try: + client.cookie_file.write_text(cookies_str + "\n") + except Exception as e: + return json.dumps( + {"success": False, "error": f"Failed to write cookie file: {e}"} + ) - # Load into memory - try: - client.load_cookies_from_string(cookies_str) - except SessionExpiredError as e: - return json.dumps({"success": False, "error": str(e)}) + # Load into memory + try: + client.load_cookies_from_string(cookies_str) + except SessionExpiredError as e: + return json.dumps({"success": False, "error": str(e)}) - # Verify session - if _session_is_valid(client): - return json.dumps( - { - "success": True, - "message": f"Logged in. Session cookies saved to {client.cookie_file}.", - } - ) - else: - return json.dumps( - { - "success": False, - "error": "Cookies loaded but session verification failed. The cookies may have expired.", - } - ) + # Verify session + if _session_is_valid(client): + return json.dumps( + { + "success": True, + "message": f"Logged in. Session cookies saved to {client.cookie_file}.", + } + ) + else: + return json.dumps( + { + "success": False, + "error": "Cookies loaded but session verification failed. The cookies may have expired.", + } + ) diff --git a/owa_mcp/tools/availability.py b/owa_mcp/tools/availability.py index a4ac504..40e2ce5 100644 --- a/owa_mcp/tools/availability.py +++ b/owa_mcp/tools/availability.py @@ -15,9 +15,9 @@ from owa_mcp.server_lifecycle import require_client def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - app_ctx: AppContext = ctx.request_context.lifespan_context - return require_client(app_ctx.client) + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) # ------------------------------------------------------------------ @@ -26,87 +26,93 @@ def _get_client(ctx: Context) -> OWAClient: def _parse_freebusy_string( - freebusy_str: str, start_time: datetime, interval_minutes: int = 30 + freebusy_str: str, start_time: datetime, interval_minutes: int = 30 ) -> list[tuple]: - """Parse the MergedFreeBusy string. + """Parse the MergedFreeBusy string. - Each character represents a time slot: - 0 = Free, 1 = Tentative, 2 = Busy, 3 = Out of Office, 4 = Working Elsewhere - """ - busy_periods = [] - current_time = start_time + Each character represents a time slot: + 0 = Free, 1 = Tentative, 2 = Busy, 3 = Out of Office, 4 = Working Elsewhere + """ + busy_periods = [] + current_time = start_time - for char in freebusy_str: - next_time = current_time + timedelta(minutes=interval_minutes) - if char in ["1", "2", "3", "4"]: # Not free - busy_periods.append((current_time, next_time, char)) - current_time = next_time + for char in freebusy_str: + next_time = current_time + timedelta(minutes=interval_minutes) + if char in ["1", "2", "3", "4"]: # Not free + busy_periods.append((current_time, next_time, char)) + current_time = next_time - return busy_periods + return busy_periods def _merge_busy_periods(all_busy: list) -> list[tuple]: - """Merge overlapping busy periods.""" - if not all_busy: - return [] + """Merge overlapping busy periods.""" + if not all_busy: + return [] - # Sort by start time - sorted_busy = sorted(all_busy, key=lambda x: x[0]) - merged = [(sorted_busy[0][0], sorted_busy[0][1])] + # Sort by start time + sorted_busy = sorted(all_busy, key=lambda x: x[0]) + merged = [(sorted_busy[0][0], sorted_busy[0][1])] - for start, end, *_ in sorted_busy[1:]: - if start <= merged[-1][1]: - merged[-1] = (merged[-1][0], max(merged[-1][1], end)) - else: - merged.append((start, end)) + for start, end, *_ in sorted_busy[1:]: + if start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) - return merged + return merged def _find_free_slots( - busy_periods: list, date, start_hour: int, end_hour: int, duration_minutes: int + busy_periods: list, + date, + start_hour: int, + end_hour: int, + duration_minutes: int, ) -> list[tuple]: - """Find free slots on a given date within working hours.""" - day_start = datetime.combine(date, datetime.min.time().replace(hour=start_hour)) - day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour)) + """Find free slots on a given date within working hours.""" + day_start = datetime.combine( + date, datetime.min.time().replace(hour=start_hour) + ) + day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour)) - # Filter busy periods to this day - day_busy = [] - for period in busy_periods: - start, end = period[0], period[1] - if end.date() < date or start.date() > date: - continue - start = max(start, day_start) - end = min(end, day_end) - if start < end: - day_busy.append((start, end)) + # Filter busy periods to this day + day_busy = [] + for period in busy_periods: + start, end = period[0], period[1] + if end.date() < date or start.date() > date: + continue + start = max(start, day_start) + end = min(end, day_end) + if start < end: + day_busy.append((start, end)) - # Merge overlapping - merged = _merge_busy_periods([(s, e) for s, e in day_busy]) + # Merge overlapping + merged = _merge_busy_periods([(s, e) for s, e in day_busy]) - # Find gaps - free_slots = [] - current = day_start + # Find gaps + free_slots = [] + current = day_start - for busy_start, busy_end in merged: - if current < busy_start: - gap_duration = (busy_start - current).total_seconds() / 60 - if gap_duration >= duration_minutes: - free_slots.append((current, busy_start)) - current = max(current, busy_end) + for busy_start, busy_end in merged: + if current < busy_start: + gap_duration = (busy_start - current).total_seconds() / 60 + if gap_duration >= duration_minutes: + free_slots.append((current, busy_start)) + current = max(current, busy_end) - # Check for time after last meeting - if current < day_end: - gap_duration = (day_end - current).total_seconds() / 60 - if gap_duration >= duration_minutes: - free_slots.append((current, day_end)) + # Check for time after last meeting + if current < day_end: + gap_duration = (day_end - current).total_seconds() / 60 + if gap_duration >= duration_minutes: + free_slots.append((current, day_end)) - return free_slots + return free_slots def _format_time(dt: datetime) -> str: - """Format datetime as HH:MM.""" - return dt.strftime("%H:%M") + """Format datetime as HH:MM.""" + return dt.strftime("%H:%M") # ------------------------------------------------------------------ @@ -115,77 +121,77 @@ def _format_time(dt: datetime) -> str: def _get_availability_events( - client: OWAClient, email: str, start_date, end_date + client: OWAClient, email: str, start_date, end_date ) -> list[dict]: - """Get busy events via GetUserAvailability (expands recurring events).""" - payload = { - "__type": "GetUserAvailabilityJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, - }, + """Get busy events via GetUserAvailability (expands recurring events).""" + payload = { + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, - "Body": { - "__type": "GetUserAvailabilityRequest:#Exchange", - "MailboxDataArray": [ - { - "__type": "MailboxData:#Exchange", - "Email": {"__type": "EmailAddress:#Exchange", "Address": email}, - "AttendeeType": "Required", - } - ], - "FreeBusyViewOptions": { - "__type": "FreeBusyViewOptions:#Exchange", - "TimeWindow": { - "__type": "Duration:#Exchange", - "StartTime": f"{start_date}T00:00:00", - "EndTime": f"{end_date + timedelta(days=1)}T00:00:00", - }, - "MergedFreeBusyIntervalInMinutes": 30, - "RequestedView": "DetailedMerged", - }, + }, + }, + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": [ + { + "__type": "MailboxData:#Exchange", + "Email": {"__type": "EmailAddress:#Exchange", "Address": email}, + "AttendeeType": "Required", + } + ], + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{start_date}T00:00:00", + "EndTime": f"{end_date + timedelta(days=1)}T00:00:00", }, - } + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", + }, + }, + } - data = client.request("GetUserAvailability", payload) - body = data.get("Body", {}) + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) - events = [] - for fb_resp in body.get("FreeBusyResponseArray", []): - fb_view = fb_resp.get("FreeBusyView", {}) - cal_events = fb_view.get("CalendarEventArray", {}) - items = ( - cal_events.get("Items", []) - if isinstance(cal_events, dict) - else (cal_events if isinstance(cal_events, list) else []) + events = [] + for fb_resp in body.get("FreeBusyResponseArray", []): + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) + items = ( + cal_events.get("Items", []) + if isinstance(cal_events, dict) + else (cal_events if isinstance(cal_events, list) else []) + ) + for event in items: + bt = event.get("BusyType", "") + if bt in ("Free", "NoData"): + continue + + start_str = event.get("StartTime", "") + end_str = event.get("EndTime", "") + if not start_str or not end_str: + continue + try: + start = datetime.fromisoformat( + start_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace( + tzinfo=None ) - for event in items: - bt = event.get("BusyType", "") - if bt in ("Free", "NoData"): - continue + events.append({"start": start, "end": end, "status": bt}) + except (ValueError, AttributeError): + continue - start_str = event.get("StartTime", "") - end_str = event.get("EndTime", "") - if not start_str or not end_str: - continue - try: - start = datetime.fromisoformat( - start_str.replace("Z", "+00:00") - ).replace(tzinfo=None) - end = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace( - tzinfo=None - ) - events.append({"start": start, "end": end, "status": bt}) - except (ValueError, AttributeError): - continue - - return events + return events # ------------------------------------------------------------------ @@ -194,33 +200,33 @@ def _get_availability_events( def _get_calendar_folder_id(client: OWAClient) -> str | None: - """Get the calendar folder ID via GetFolder.""" - payload = { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "IdOnly", - }, - "FolderIds": [ - {"__type": "DistinguishedFolderId:#Exchange", "Id": "calendar"} - ], - }, - } + """Get the calendar folder ID via GetFolder.""" + 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": "calendar"} + ], + }, + } - data = client.request("GetFolder", payload) - for msg in client.extract_items(data): - if "Folders" in msg: - for f in msg["Folders"]: - fid = f.get("FolderId", {}).get("Id") - if fid: - return fid - return None + data = client.request("GetFolder", payload) + for msg in client.extract_items(data): + if "Folders" in msg: + for f in msg["Folders"]: + fid = f.get("FolderId", {}).get("Id") + if fid: + return fid + return None # ------------------------------------------------------------------ @@ -229,104 +235,106 @@ def _get_calendar_folder_id(client: OWAClient) -> str | None: def _get_calendar_events( - client: OWAClient, folder_id: str, start_date, end_date + client: OWAClient, folder_id: str, start_date, end_date ) -> list[dict]: - """Get calendar events within a date range. Returns list of busy period dicts.""" - events = [] - offset = 0 - batch_size = 100 + """Get calendar events within a date range. Returns list of busy period dicts.""" + events = [] + offset = 0 + batch_size = 100 - while True: - payload = { - "__type": "FindItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", + while True: + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": batch_size, + }, + "SortOrder": [ + { + "__type": "SortResults:#Exchange", + "Order": "Ascending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Start", }, - "Body": { - "__type": "FindItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - }, - "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "Traversal": "Shallow", - "Paging": { - "__type": "IndexedPageView:#Exchange", - "BasePoint": "Beginning", - "Offset": offset, - "MaxEntriesReturned": batch_size, - }, - "SortOrder": [ - { - "__type": "SortResults:#Exchange", - "Order": "Ascending", - "Path": { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Start", - }, - } - ], - }, - } + } + ], + }, + } - data = client.request("FindItem", payload) - items = client.extract_items(data) + data = client.request("FindItem", payload) + items = client.extract_items(data) - if not items: - break + if not items: + break - folder_items = items[0].get("RootFolder", {}).get("Items", []) - if not folder_items: - break + folder_items = items[0].get("RootFolder", {}).get("Items", []) + if not folder_items: + break - for item in folder_items: - # Skip cancelled events - if item.get("IsCancelled"): - continue + for item in folder_items: + # Skip cancelled events + if item.get("IsCancelled"): + continue - # Skip free/tentative slots - fbt = item.get("FreeBusyType", "Busy") - if fbt in ["Free", "NoData"]: - continue + # Skip free/tentative slots + fbt = item.get("FreeBusyType", "Busy") + if fbt in ["Free", "NoData"]: + continue - start_str = item.get("Start", "") - end_str = item.get("End", "") + start_str = item.get("Start", "") + end_str = item.get("End", "") - if not start_str or not end_str: - continue + if not start_str or not end_str: + continue - try: - start = datetime.fromisoformat(start_str.replace("Z", "+00:00")) - end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) + try: + start = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) - # Convert to naive datetime for comparison - start = start.replace(tzinfo=None) - end = end.replace(tzinfo=None) + # Convert to naive datetime for comparison + start = start.replace(tzinfo=None) + end = end.replace(tzinfo=None) - # Filter by date range - if end.date() < start_date or start.date() > end_date: - continue + # Filter by date range + if end.date() < start_date or start.date() > end_date: + continue - events.append( - { - "start": start, - "end": end, - "subject": item.get("Subject", ""), - "status": fbt, - } - ) - except (ValueError, AttributeError): - continue + events.append( + { + "start": start, + "end": end, + "subject": item.get("Subject", ""), + "status": fbt, + } + ) + except (ValueError, AttributeError): + continue - # Check if there are more items - is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) - if is_last: - break + # Check if there are more items + is_last = ( + items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) + ) + if is_last: + break - offset += batch_size + offset += batch_size - return events + return events # ------------------------------------------------------------------ @@ -336,82 +344,80 @@ def _get_calendar_events( @mcp.tool() def find_free_time( - start_date: str, - end_date: str = "", - duration_minutes: int = 30, - start_hour: int = 9, - end_hour: int = 18, - ctx: Context = None, + start_date: str, + end_date: str = "", + duration_minutes: int = 30, + start_hour: int = 9, + end_hour: int = 18, + ctx: Context = None, ) -> str: - """Find free time slots in your own calendar. + """Find free time slots in your own calendar. - Analyzes your calendar events and returns available time slots - within working hours for each weekday in the range. + Analyzes your calendar events and returns available time slots + within working hours for each weekday in the range. - Args: - start_date: Start date in YYYY-MM-DD format. - end_date: End date in YYYY-MM-DD format. Defaults to start_date - if not provided (single-day search). - duration_minutes: Minimum slot duration in minutes. Default 30. - start_hour: Working day start hour (0-23). Default 9. - end_hour: Working day end hour (0-23). Default 18. + Args: + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. Defaults to start_date + if not provided (single-day search). + duration_minutes: Minimum slot duration in minutes. Default 30. + start_hour: Working day start hour (0-23). Default 9. + end_hour: Working day end hour (0-23). Default 18. - Returns: - JSON object with free_slots keyed by date, each containing an - array of {start, end, duration_minutes} objects. - """ - client = _get_client(ctx) + Returns: + JSON object with free_slots keyed by date, each containing an + array of {start, end, duration_minutes} objects. + """ + client = _get_client(ctx) - try: - sd = datetime.strptime(start_date, "%Y-%m-%d").date() - ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd - except ValueError as e: - return json.dumps({"error": f"Invalid date format: {e}"}) + try: + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) - try: - # Use GetUserAvailability for accurate recurring event expansion - if client.user_email: - all_busy = _get_availability_events(client, client.user_email, sd, ed) - else: - # Fallback to FindItem (misses recurring event occurrences) - folder_id = _get_calendar_folder_id(client) - if not folder_id: - return json.dumps( - { - "error": "Could not find calendar folder. Session may have expired." - } - ) - all_busy = _get_calendar_events(client, folder_id, sd, ed) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + # Use GetUserAvailability for accurate recurring event expansion + if client.user_email: + all_busy = _get_availability_events(client, client.user_email, sd, ed) + else: + # Fallback to FindItem (misses recurring event occurrences) + folder_id = _get_calendar_folder_id(client) + if not folder_id: + return json.dumps( + {"error": "Could not find calendar folder. Session may have expired."} + ) + all_busy = _get_calendar_events(client, folder_id, sd, ed) + except Exception as e: + return json.dumps({"error": str(e)}) - # Convert event dicts to (start, end) tuples for _find_free_slots - busy_periods = [(ev["start"], ev["end"]) for ev in all_busy] + # Convert event dicts to (start, end) tuples for _find_free_slots + busy_periods = [(ev["start"], ev["end"]) for ev in all_busy] - result = {} - current_date = sd - while current_date <= ed: - # Skip weekends - if current_date.weekday() < 5: - free = _find_free_slots( - busy_periods, - current_date, - start_hour, - end_hour, - duration_minutes, - ) - if free: - result[str(current_date)] = [ - { - "start": _format_time(s), - "end": _format_time(e), - "duration_minutes": int((e - s).total_seconds() / 60), - } - for s, e in free - ] - current_date += timedelta(days=1) + result = {} + current_date = sd + while current_date <= ed: + # Skip weekends + if current_date.weekday() < 5: + free = _find_free_slots( + busy_periods, + current_date, + start_hour, + end_hour, + duration_minutes, + ) + if free: + result[str(current_date)] = [ + { + "start": _format_time(s), + "end": _format_time(e), + "duration_minutes": int((e - s).total_seconds() / 60), + } + for s, e in free + ] + current_date += timedelta(days=1) - return json.dumps({"free_slots": result}, ensure_ascii=False) + return json.dumps({"free_slots": result}, ensure_ascii=False) # ------------------------------------------------------------------ @@ -421,214 +427,212 @@ def find_free_time( @mcp.tool() def find_meeting_time( - emails: str, - start_date: str, - end_date: str = "", - duration_minutes: int = 30, - start_hour: int = 9, - end_hour: int = 18, - ctx: Context = None, + emails: str, + start_date: str, + end_date: str = "", + duration_minutes: int = 30, + start_hour: int = 9, + end_hour: int = 18, + ctx: Context = None, ) -> str: - """Find meeting times that work for multiple people. + """Find meeting times that work for multiple people. - Uses the OWA GetUserAvailability API to check cross-mailbox - availability and find common free slots for all attendees. - Supports multi-day ranges — searches each weekday in the range. + Uses the OWA GetUserAvailability API to check cross-mailbox + availability and find common free slots for all attendees. + Supports multi-day ranges — searches each weekday in the range. - Args: - emails: Comma-separated email addresses or names of attendees. - start_date: Start date in YYYY-MM-DD format. - end_date: End date in YYYY-MM-DD format. Defaults to start_date - if not provided (single-day search). - duration_minutes: Minimum slot duration in minutes. Default 30. - start_hour: Working day start hour (0-23). Default 9. - end_hour: Working day end hour (0-23). Default 18. + Args: + emails: Comma-separated email addresses or names of attendees. + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. Defaults to start_date + if not provided (single-day search). + duration_minutes: Minimum slot duration in minutes. Default 30. + start_hour: Working day start hour (0-23). Default 9. + end_hour: Working day end hour (0-23). Default 18. - Returns: - JSON object with attendee info and free_slots keyed by date, - each containing an array of {start, end, duration_minutes}. - """ - client = _get_client(ctx) + Returns: + JSON object with attendee info and free_slots keyed by date, + each containing an array of {start, end, duration_minutes}. + """ + client = _get_client(ctx) - raw_list = [e.strip() for e in emails.split(",") if e.strip()] - if not raw_list: - return json.dumps({"error": "No email addresses provided."}) + raw_list = [e.strip() for e in emails.split(",") if e.strip()] + if not raw_list: + return json.dumps({"error": "No email addresses provided."}) - try: - sd = datetime.strptime(start_date, "%Y-%m-%d").date() - ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd - except ValueError as e: - return json.dumps({"error": f"Invalid date format: {e}"}) + try: + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) - # Resolve names to email addresses via ResolveNames - email_list = [] - resolve_errors = [] - for entry in raw_list: - if "@" in entry: - email_list.append(entry) + # Resolve names to email addresses via ResolveNames + email_list = [] + resolve_errors = [] + for entry in raw_list: + if "@" in entry: + email_list.append(entry) + else: + resolutions = client.resolve_names(entry, full_contact=False) + if resolutions: + addr = resolutions[0].get("Mailbox", {}).get("EmailAddress", "") + if addr: + email_list.append(addr) else: - resolutions = client.resolve_names(entry, full_contact=False) - if resolutions: - addr = resolutions[0].get("Mailbox", {}).get("EmailAddress", "") - if addr: - email_list.append(addr) - else: - resolve_errors.append(entry) - else: - resolve_errors.append(entry) + resolve_errors.append(entry) + else: + resolve_errors.append(entry) - if not email_list: - return json.dumps( - { - "error": f"Could not resolve any names to email addresses: {resolve_errors}" - } + if not email_list: + return json.dumps( + { + "error": f"Could not resolve any names to email addresses: {resolve_errors}" + } + ) + + # Build mailbox data (reused for each day chunk) + mailbox_data = [] + for email in email_list: + mailbox_data.append( + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": email, + }, + "AttendeeType": "Required", + } + ) + + # Query the full date range at once (API handles multi-day windows) + payload = { + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", + }, + }, + }, + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": mailbox_data, + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{sd}T00:00:00", + "EndTime": f"{ed + timedelta(days=1)}T00:00:00", + }, + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", + }, + }, + } + + try: + data = client.request("GetUserAvailability", payload) + except Exception as e: + return json.dumps({"error": str(e)}) + + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + + # Parse availability responses + all_busy = [] + attendee_info = [] + freebusy_responses = body.get("FreeBusyResponseArray", []) + + for i, fb_resp in enumerate(freebusy_responses): + fb_view = fb_resp.get("FreeBusyView", {}) + merged_fb = fb_view.get("MergedFreeBusy", "") + email = email_list[i] if i < len(email_list) else f"Person {i + 1}" + + if merged_fb: + start_time = datetime.combine(sd, datetime.min.time()) + busy_periods = _parse_freebusy_string(merged_fb, start_time) + + busy_count = sum(1 for c in merged_fb if c != "0") + free_count = sum(1 for c in merged_fb if c == "0") + attendee_info.append( + { + "email": email, + "busy_slots": busy_count, + "free_slots": free_count, + } + ) + + all_busy.extend(busy_periods) + else: + # Fallback: parse CalendarEventArray + cal_events_raw = fb_view.get("CalendarEventArray", {}) + cal_events = ( + cal_events_raw.get("Items", []) + if isinstance(cal_events_raw, dict) + else (cal_events_raw if isinstance(cal_events_raw, list) else []) + ) + if cal_events: + attendee_info.append( + { + "email": email, + "calendar_events": len(cal_events), + } + ) + for event in cal_events: + start_str = event.get("StartTime", "") + end_str = event.get("EndTime", "") + if start_str and end_str: + try: + start = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) + all_busy.append((start, end)) + except Exception: + pass + else: + attendee_info.append( + { + "email": email, + "status": "no_data", + } ) - # Build mailbox data (reused for each day chunk) - mailbox_data = [] - for email in email_list: - mailbox_data.append( - { - "__type": "MailboxData:#Exchange", - "Email": { - "__type": "EmailAddress:#Exchange", - "Address": email, - }, - "AttendeeType": "Required", - } - ) + merged_busy = _merge_busy_periods(all_busy) - # Query the full date range at once (API handles multi-day windows) - payload = { - "__type": "GetUserAvailabilityJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, - }, - }, - "Body": { - "__type": "GetUserAvailabilityRequest:#Exchange", - "MailboxDataArray": mailbox_data, - "FreeBusyViewOptions": { - "__type": "FreeBusyViewOptions:#Exchange", - "TimeWindow": { - "__type": "Duration:#Exchange", - "StartTime": f"{sd}T00:00:00", - "EndTime": f"{ed + timedelta(days=1)}T00:00:00", - }, - "MergedFreeBusyIntervalInMinutes": 30, - "RequestedView": "DetailedMerged", - }, - }, - } + # Find free slots for each weekday in range + free_by_date = {} + current_date = sd + while current_date <= ed: + if current_date.weekday() < 5: # Skip weekends + free = _find_free_slots( + merged_busy, + current_date, + start_hour, + end_hour, + duration_minutes, + ) + if free: + free_by_date[str(current_date)] = [ + { + "start": _format_time(s), + "end": _format_time(e), + "duration_minutes": int((e - s).total_seconds() / 60), + } + for s, e in free + ] + current_date += timedelta(days=1) - try: - data = client.request("GetUserAvailability", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + result = { + "period": {"start": str(sd), "end": str(ed)}, + "attendees": attendee_info, + "free_slots": free_by_date, + } - body = data.get("Body", {}) - if "ErrorCode" in body: - return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + if resolve_errors: + result["unresolved"] = resolve_errors - # Parse availability responses - all_busy = [] - attendee_info = [] - freebusy_responses = body.get("FreeBusyResponseArray", []) - - for i, fb_resp in enumerate(freebusy_responses): - fb_view = fb_resp.get("FreeBusyView", {}) - merged_fb = fb_view.get("MergedFreeBusy", "") - email = email_list[i] if i < len(email_list) else f"Person {i + 1}" - - if merged_fb: - start_time = datetime.combine(sd, datetime.min.time()) - busy_periods = _parse_freebusy_string(merged_fb, start_time) - - busy_count = sum(1 for c in merged_fb if c != "0") - free_count = sum(1 for c in merged_fb if c == "0") - attendee_info.append( - { - "email": email, - "busy_slots": busy_count, - "free_slots": free_count, - } - ) - - all_busy.extend(busy_periods) - else: - # Fallback: parse CalendarEventArray - cal_events_raw = fb_view.get("CalendarEventArray", {}) - cal_events = ( - cal_events_raw.get("Items", []) - if isinstance(cal_events_raw, dict) - else (cal_events_raw if isinstance(cal_events_raw, list) else []) - ) - if cal_events: - attendee_info.append( - { - "email": email, - "calendar_events": len(cal_events), - } - ) - for event in cal_events: - start_str = event.get("StartTime", "") - end_str = event.get("EndTime", "") - if start_str and end_str: - try: - start = datetime.fromisoformat( - start_str.replace("Z", "+00:00") - ) - end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) - all_busy.append((start, end)) - except Exception: - pass - else: - attendee_info.append( - { - "email": email, - "status": "no_data", - } - ) - - merged_busy = _merge_busy_periods(all_busy) - - # Find free slots for each weekday in range - free_by_date = {} - current_date = sd - while current_date <= ed: - if current_date.weekday() < 5: # Skip weekends - free = _find_free_slots( - merged_busy, - current_date, - start_hour, - end_hour, - duration_minutes, - ) - if free: - free_by_date[str(current_date)] = [ - { - "start": _format_time(s), - "end": _format_time(e), - "duration_minutes": int((e - s).total_seconds() / 60), - } - for s, e in free - ] - current_date += timedelta(days=1) - - result = { - "period": {"start": str(sd), "end": str(ed)}, - "attendees": attendee_info, - "free_slots": free_by_date, - } - - if resolve_errors: - result["unresolved"] = resolve_errors - - return json.dumps(result, ensure_ascii=False) + return json.dumps(result, ensure_ascii=False) diff --git a/owa_mcp/tools/calendar.py b/owa_mcp/tools/calendar.py index 49cd484..8cfecb8 100644 --- a/owa_mcp/tools/calendar.py +++ b/owa_mcp/tools/calendar.py @@ -18,9 +18,9 @@ from owa_mcp.utils import extract_links_from_html, html_to_text def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - app_ctx: AppContext = ctx.request_context.lifespan_context - return require_client(app_ctx.client) + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) # ------------------------------------------------------------------ @@ -29,281 +29,272 @@ def _get_client(ctx: Context) -> OWAClient: def _utc_to_local_str(dt_str: str) -> str: - """Convert a UTC ISO timestamp to local Moscow time string. + """Convert a UTC ISO timestamp to local Moscow time string. - FindItem returns UTC (e.g. '2026-02-17T06:30:00Z'), while - GetUserAvailability returns local Moscow time ('2026-02-17T09:30:00'). - This normalizes to the local format for key matching. - Moscow is permanently UTC+3 (no DST since 2014). - """ - if not dt_str: - return dt_str - if dt_str.endswith("Z"): - try: - utc_dt = datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ") - local_dt = utc_dt + timedelta(hours=3) - return local_dt.strftime("%Y-%m-%dT%H:%M:%S") - except ValueError: - return dt_str.rstrip("Z") - # No timezone suffix — already local time + FindItem returns UTC (e.g. '2026-02-17T06:30:00Z'), while + GetUserAvailability returns local Moscow time ('2026-02-17T09:30:00'). + This normalizes to the local format for key matching. + Moscow is permanently UTC+3 (no DST since 2014). + """ + if not dt_str: return dt_str + if dt_str.endswith("Z"): + try: + utc_dt = datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ") + local_dt = utc_dt + timedelta(hours=3) + return local_dt.strftime("%Y-%m-%dT%H:%M:%S") + except ValueError: + return dt_str.rstrip("Z") + # No timezone suffix — already local time + return dt_str def _get_event_details(client: OWAClient, item_id: str) -> dict: - """Get full event details (body, organizer, attendees) via GetItem.""" - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, - } + """Get full event details (body, organizer, attendees) via GetItem.""" + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } - data = client.request("GetItem", payload) - result = { - "organizer": "", - "organizer_email": "", - "location": "", - "body": "", - "attendees_required": [], - "attendees_optional": [], - } + data = client.request("GetItem", payload) + result = { + "organizer": "", + "organizer_email": "", + "location": "", + "body": "", + "attendees_required": [], + "attendees_optional": [], + } - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - # Location - result["location"] = item.get("Location", "") - if not result["location"]: - enhanced = item.get("EnhancedLocation", {}) - if enhanced: - result["location"] = enhanced.get("DisplayName", "") + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + # Location + result["location"] = item.get("Location", "") + if not result["location"]: + enhanced = item.get("EnhancedLocation", {}) + if enhanced: + result["location"] = enhanced.get("DisplayName", "") - # Body - body_data = item.get("Body", {}) - if body_data: - body_text = body_data.get("Value", "") - if body_data.get("BodyType") == "HTML": - body_text = html_to_text(body_text) - result["body"] = body_text.strip() + # Body + body_data = item.get("Body", {}) + if body_data: + body_text = body_data.get("Value", "") + if body_data.get("BodyType") == "HTML": + body_text = html_to_text(body_text) + result["body"] = body_text.strip() - # Organizer with SMTP email - organizer = item.get("Organizer", {}).get("Mailbox", {}) - if organizer: - name = organizer.get("Name", "") - addr = organizer.get("EmailAddress", "") - if addr and not addr.startswith("/O="): - result["organizer"] = f"{name} <{addr}>" if name else addr - result["organizer_email"] = addr - else: - result["organizer"] = name + # Organizer with SMTP email + organizer = item.get("Organizer", {}).get("Mailbox", {}) + if organizer: + name = organizer.get("Name", "") + addr = organizer.get("EmailAddress", "") + if addr and not addr.startswith("/O="): + result["organizer"] = f"{name} <{addr}>" if name else addr + result["organizer_email"] = addr + else: + result["organizer"] = name - # Required attendees with SMTP emails - for a in item.get("RequiredAttendees", []) or []: - mailbox = a.get("Mailbox", {}) - name = mailbox.get("Name", "") - addr = mailbox.get("EmailAddress", "") - response = a.get("ResponseType", "") + # Required attendees with SMTP emails + for a in item.get("RequiredAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + response = a.get("ResponseType", "") - if name or addr: - if addr and not addr.startswith("/O="): - entry = f"{name} <{addr}>" if name else addr - else: - entry = name - if response and response not in ("Unknown", "Organizer"): - entry += f" [{response}]" - result["attendees_required"].append(entry) + if name or addr: + if addr and not addr.startswith("/O="): + entry = f"{name} <{addr}>" if name else addr + else: + entry = name + if response and response not in ("Unknown", "Organizer"): + entry += f" [{response}]" + result["attendees_required"].append(entry) - # Optional attendees with SMTP emails - for a in item.get("OptionalAttendees", []) or []: - mailbox = a.get("Mailbox", {}) - name = mailbox.get("Name", "") - addr = mailbox.get("EmailAddress", "") - response = a.get("ResponseType", "") + # Optional attendees with SMTP emails + for a in item.get("OptionalAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + response = a.get("ResponseType", "") - if name or addr: - if addr and not addr.startswith("/O="): - entry = f"{name} <{addr}>" if name else addr - else: - entry = name - if response and response not in ("Unknown", "Organizer"): - entry += f" [{response}]" - result["attendees_optional"].append(entry) + if name or addr: + if addr and not addr.startswith("/O="): + entry = f"{name} <{addr}>" if name else addr + else: + entry = name + if response and response not in ("Unknown", "Organizer"): + entry += f" [{response}]" + result["attendees_optional"].append(entry) - return result + return result - return result + return result def _get_full_event(client: OWAClient, item_id: str) -> dict: - """Get full event details for update_meeting preservation.""" - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, - } + """Get full event details for update_meeting preservation.""" + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } - data = client.request("GetItem", payload) - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - result = { - "subject": item.get("Subject", ""), - "start": item.get("Start", ""), - "end": item.get("End", ""), - "is_all_day": item.get("IsAllDayEvent", False), - "sensitivity": item.get("Sensitivity", "Normal"), - "location": "", - "body_html": "", - "resolved_required": [], - "resolved_optional": [], + data = client.request("GetItem", payload) + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + result = { + "subject": item.get("Subject", ""), + "start": item.get("Start", ""), + "end": item.get("End", ""), + "is_all_day": item.get("IsAllDayEvent", False), + "sensitivity": item.get("Sensitivity", "Normal"), + "location": "", + "body_html": "", + "resolved_required": [], + "resolved_optional": [], + } + + # Location + loc = item.get("Location", "") + if not loc: + enhanced = item.get("EnhancedLocation", {}) + if enhanced: + loc = enhanced.get("DisplayName", "") + result["location"] = loc + + # Body (keep HTML) + body_data = item.get("Body", {}) + if body_data: + result["body_html"] = body_data.get("Value", "") + + # Required attendees as resolved dicts + for a in item.get("RequiredAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + if addr and not addr.startswith("/O="): + result["resolved_required"].append( + { + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } } + ) - # Location - loc = item.get("Location", "") - if not loc: - enhanced = item.get("EnhancedLocation", {}) - if enhanced: - loc = enhanced.get("DisplayName", "") - result["location"] = loc + # Optional attendees + for a in item.get("OptionalAttendees", []) or []: + mailbox = a.get("Mailbox", {}) + name = mailbox.get("Name", "") + addr = mailbox.get("EmailAddress", "") + if addr and not addr.startswith("/O="): + result["resolved_optional"].append( + { + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } + } + ) - # Body (keep HTML) - body_data = item.get("Body", {}) - if body_data: - result["body_html"] = body_data.get("Value", "") + return result - # Required attendees as resolved dicts - for a in item.get("RequiredAttendees", []) or []: - mailbox = a.get("Mailbox", {}) - name = mailbox.get("Name", "") - addr = mailbox.get("EmailAddress", "") - if addr and not addr.startswith("/O="): - result["resolved_required"].append( - { - "Mailbox": { - "Name": name, - "EmailAddress": addr, - "RoutingType": "SMTP", - } - } - ) - - # Optional attendees - for a in item.get("OptionalAttendees", []) or []: - mailbox = a.get("Mailbox", {}) - name = mailbox.get("Name", "") - addr = mailbox.get("EmailAddress", "") - if addr and not addr.startswith("/O="): - result["resolved_optional"].append( - { - "Mailbox": { - "Name": name, - "EmailAddress": addr, - "RoutingType": "SMTP", - } - } - ) - - return result - - return {"error": "Meeting not found"} + return {"error": "Meeting not found"} def _resolve_attendee(client: OWAClient, email: str) -> dict: - """Resolve an email to attendee details via ResolveNames. + """Resolve an email to attendee details via ResolveNames. - Uses V2017_08_18 RequestServerVersion to match the original - create-meeting.py behaviour. - """ - payload = { - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "ResolveNamesRequest:#Exchange", - "UnresolvedEntry": email, - "ReturnFullContactData": True, - "ContactDataShape": "Default", - }, - } + Uses V2017_08_18 RequestServerVersion to match the original + create-meeting.py behaviour. + """ + payload = { + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "ResolveNamesRequest:#Exchange", + "UnresolvedEntry": email, + "ReturnFullContactData": True, + "ContactDataShape": "Default", + }, + } - try: - data = client.request("ResolveNames", payload) - items = client.extract_items(data) - if items and "ResolutionSet" in items[0]: - resolutions = items[0]["ResolutionSet"].get("Resolutions", []) - if resolutions: - mailbox = resolutions[0].get("Mailbox", {}) - return { - "Mailbox": { - "Name": mailbox.get("Name", email), - "EmailAddress": mailbox.get("EmailAddress", email), - "RoutingType": "SMTP", - } - } - except Exception: - pass - - # Fallback - return { - "Mailbox": { - "Name": email, - "EmailAddress": email, + try: + data = client.request("ResolveNames", payload) + items = client.extract_items(data) + if items and "ResolutionSet" in items[0]: + resolutions = items[0]["ResolutionSet"].get("Resolutions", []) + if resolutions: + mailbox = resolutions[0].get("Mailbox", {}) + return { + "Mailbox": { + "Name": mailbox.get("Name", email), + "EmailAddress": mailbox.get("EmailAddress", email), "RoutingType": "SMTP", + } } + except Exception: + pass + + # Fallback + return { + "Mailbox": { + "Name": email, + "EmailAddress": email, + "RoutingType": "SMTP", } + } def _resolve_attendee_list(client: OWAClient, emails: list[str]) -> list[dict]: - """Resolve a list of email strings into attendee dicts.""" - attendees = [] - for email in emails: - email = email.strip() - if email: - attendees.append(_resolve_attendee(client, email)) - return attendees + """Resolve a list of email strings into attendee dicts.""" + attendees = [] + for email in emails: + email = email.strip() + if email: + attendees.append(_resolve_attendee(client, email)) + return attendees def _build_html_body(description: str | None) -> str: - """Build the HTML body for a calendar item, matching create-meeting.py.""" - body = ( - '' - ) - if description: - desc_escaped = html_mod.escape(description).replace("\n", "
") - body += ( - '
{desc_escaped}
' - ) - else: - body += ( - '


' - ) - body += "" - return body + """Build the HTML body for a calendar item, matching create-meeting.py.""" + body = '' + if description: + desc_escaped = html_mod.escape(description).replace("\n", "
") + body += f'
{desc_escaped}
' + else: + body += '


' + body += "" + return body # ------------------------------------------------------------------ @@ -312,254 +303,252 @@ def _build_html_body(description: str | None) -> str: def _get_expanded_events( - client: OWAClient, start_date, end_date, chunk_days: int = 14 + client: OWAClient, start_date, end_date, chunk_days: int = 14 ) -> list[dict]: - """Get expanded calendar events via GetUserAvailability. + """Get expanded calendar events via GetUserAvailability. - Unlike FindItem (which returns only master items for recurring series), - this returns every individual occurrence within the date range. + Unlike FindItem (which returns only master items for recurring series), + this returns every individual occurrence within the date range. - Requires client.user_email to be set (done by the login tool). - """ - if not client.user_email: - return [] + Requires client.user_email to be set (done by the login tool). + """ + if not client.user_email: + return [] - expanded = [] - current = start_date + expanded = [] + current = start_date - while current < end_date: - chunk_end = min(current + timedelta(days=chunk_days), end_date) + while current < end_date: + chunk_end = min(current + timedelta(days=chunk_days), end_date) - payload = { - "__type": "GetUserAvailabilityJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, - }, + 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": [ + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": client.user_email, }, - "Body": { - "__type": "GetUserAvailabilityRequest:#Exchange", - "MailboxDataArray": [ - { - "__type": "MailboxData:#Exchange", - "Email": { - "__type": "EmailAddress:#Exchange", - "Address": client.user_email, - }, - "AttendeeType": "Required", - } - ], - "FreeBusyViewOptions": { - "__type": "FreeBusyViewOptions:#Exchange", - "TimeWindow": { - "__type": "Duration:#Exchange", - "StartTime": f"{current}T00:00:00", - "EndTime": f"{chunk_end}T00:00:00", - }, - "MergedFreeBusyIntervalInMinutes": 30, - "RequestedView": "DetailedMerged", - }, - }, - } + "AttendeeType": "Required", + } + ], + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{current}T00:00:00", + "EndTime": f"{chunk_end}T00:00:00", + }, + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", + }, + }, + } - try: - data = client.request("GetUserAvailability", payload) - body = data.get("Body", {}) - for fb_resp in body.get("FreeBusyResponseArray", []): - fb_view = fb_resp.get("FreeBusyView", {}) - cal_events = fb_view.get("CalendarEventArray", {}) - items = ( - cal_events.get("Items", []) - if isinstance(cal_events, dict) - else (cal_events if isinstance(cal_events, list) else []) - ) - for event in items: - bt = event.get("BusyType", "") - details = event.get("CalendarEventDetails", {}) - subject = details.get("Subject", "") if details else "" - location = details.get("Location", "") if details else "" - is_meeting = details.get("IsMeeting", False) if details else False - is_recurring = ( - details.get("IsRecurring", False) if details else False - ) + try: + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) + for fb_resp in body.get("FreeBusyResponseArray", []): + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) + items = ( + cal_events.get("Items", []) + if isinstance(cal_events, dict) + else (cal_events if isinstance(cal_events, list) else []) + ) + for event in items: + bt = event.get("BusyType", "") + details = event.get("CalendarEventDetails", {}) + subject = details.get("Subject", "") if details else "" + location = details.get("Location", "") if details else "" + is_meeting = details.get("IsMeeting", False) if details else False + is_recurring = details.get("IsRecurring", False) if details else False - expanded.append( - { - "subject": subject or "(No subject)", - "start": event.get("StartTime", ""), - "end": event.get("EndTime", ""), - "busy_type": bt, - "location": location, - "is_meeting": is_meeting, - "is_recurring": is_recurring, - } - ) - except Exception: - pass + expanded.append( + { + "subject": subject or "(No subject)", + "start": event.get("StartTime", ""), + "end": event.get("EndTime", ""), + "busy_type": bt, + "location": location, + "is_meeting": is_meeting, + "is_recurring": is_recurring, + } + ) + except Exception: + pass - current = chunk_end + current = chunk_end - return sorted(expanded, key=lambda x: x.get("start", "")) + return sorted(expanded, key=lambda x: x.get("start", "")) @mcp.tool() def get_calendar_events( - start_date: str, - end_date: str, - include_body: bool = True, - expand_recurring: bool = False, - ctx: Context = None, + start_date: str, + end_date: str, + include_body: bool = True, + expand_recurring: bool = False, + ctx: Context = None, ) -> str: - """Get calendar events within a date range. + """Get calendar events within a date range. - Args: - start_date: Start date in YYYY-MM-DD format. - end_date: End date in YYYY-MM-DD format. - include_body: If True, fetch full event details (organizer, attendees, body) - via GetItem for each event. Slower but more complete. - Ignored when expand_recurring=True. - expand_recurring: If True, show every individual occurrence of recurring - meetings (via GetUserAvailability). This gives an accurate - count of all events but returns fewer fields per event - (no item_id, attendees, or body). Default False. + Args: + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + include_body: If True, fetch full event details (organizer, attendees, body) + via GetItem for each event. Slower but more complete. + Ignored when expand_recurring=True. + expand_recurring: If True, show every individual occurrence of recurring + meetings (via GetUserAvailability). This gives an accurate + count of all events but returns fewer fields per event + (no item_id, attendees, or body). Default False. - Returns: - JSON array of event objects with subject, start, end, location, attendees, etc. - """ - client = _get_client(ctx) + Returns: + JSON array of event objects with subject, start, end, location, attendees, etc. + """ + client = _get_client(ctx) - try: - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - end_dt = datetime.strptime(end_date, "%Y-%m-%d") - except ValueError as e: - return json.dumps({"error": f"Invalid date format: {e}"}) + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError as e: + return json.dumps({"error": f"Invalid date format: {e}"}) - # --- Expanded mode: uses GetUserAvailability for accurate recurring counts --- - if expand_recurring: - expanded = _get_expanded_events( - client, start_dt.date(), (end_dt + timedelta(days=1)).date() - ) - events = [] - for ev in expanded: - events.append( - { - "subject": ev["subject"], - "start": ev["start"], - "end": ev["end"], - "location": ev.get("location", ""), - "busy_type": ev.get("busy_type", ""), - "is_meeting": ev.get("is_meeting", False), - "is_recurring": ev.get("is_recurring", False), - } - ) - return json.dumps(events, ensure_ascii=False) - - # --- Default mode --- - # Step 1: Get all events (including recurring) via GetUserAvailability + # --- Expanded mode: uses GetUserAvailability for accurate recurring counts --- + if expand_recurring: expanded = _get_expanded_events( - client, start_dt.date(), (end_dt + timedelta(days=1)).date() + client, start_dt.date(), (end_dt + timedelta(days=1)).date() ) - - # Step 2: Get events with item_ids via FindItem + CalendarView. - # CalendarView restricts results to the date range and expands recurring - # events into individual occurrences (each with its own ItemId). - folder_id = client.get_folder_id("calendar") - finditem_by_key: dict[str, dict] = {} # "subject|start" -> item - if folder_id: - cv_start = start_dt.strftime("%Y-%m-%dT00:00:00") - cv_end = (end_dt + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00") - - payload = { - "__type": "FindItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "FindItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - }, - "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "Traversal": "Shallow", - "CalendarView": { - "__type": "CalendarView:#Exchange", - "StartDate": cv_start, - "EndDate": cv_end, - }, - }, - } - - data = client.request("FindItem", payload) - - all_items = [] - for msg in client.extract_items(data): - if "RootFolder" in msg: - all_items = msg["RootFolder"].get("Items", []) - break - - # Index FindItem results by subject + normalized local start time. - # FindItem returns UTC timestamps (e.g. "2026-02-17T06:30:00Z"), - # while GetUserAvailability returns Moscow local time ("2026-02-17T09:30:00"). - # Normalize FindItem timestamps to local time for matching. - for item in all_items: - subject = item.get("Subject", "") - start = item.get("Start", "") - local_start = _utc_to_local_str(start) - key = f"{subject}|{local_start}" - finditem_by_key[key] = item - - # Step 3: Merge — use expanded list as the authoritative event list, - # enrich with FindItem data (item_id, details) when available events = [] for ev in expanded: - subject = ev.get("subject", "(No subject)") - start = ev.get("start", "") - end = ev.get("end", "") - is_recurring = ev.get("is_recurring", False) - - # Match expanded event (local time) with FindItem result (normalized to local) - fi_item = finditem_by_key.get(f"{subject}|{start}") - - item_id = fi_item.get("ItemId", {}).get("Id", "") if fi_item else "" - - event = { - "subject": subject, - "start": fi_item.get("Start", start) if fi_item else start, - "end": fi_item.get("End", end) if fi_item else end, - "location": ev.get("location", ""), - "is_all_day": fi_item.get("IsAllDayEvent", False) if fi_item else False, - "is_cancelled": fi_item.get("IsCancelled", False) if fi_item else False, - "is_meeting": ev.get("is_meeting", False), - "is_recurring": is_recurring, - "organizer": "", - "my_response": fi_item.get("MyResponseType", "") if fi_item else "", - "item_id": item_id, - "body": "", - "attendees_required": [], - "attendees_optional": [], + events.append( + { + "subject": ev["subject"], + "start": ev["start"], + "end": ev["end"], + "location": ev.get("location", ""), + "busy_type": ev.get("busy_type", ""), + "is_meeting": ev.get("is_meeting", False), + "is_recurring": ev.get("is_recurring", False), } - - # Get full details via GetItem if requested and item_id is available - if include_body and item_id: - details = _get_event_details(client, item_id) - event["organizer"] = details["organizer"] - event["location"] = details["location"] or event["location"] - event["body"] = details["body"] - event["attendees_required"] = details["attendees_required"] - event["attendees_optional"] = details["attendees_optional"] - - events.append(event) - + ) return json.dumps(events, ensure_ascii=False) + # --- Default mode --- + # Step 1: Get all events (including recurring) via GetUserAvailability + expanded = _get_expanded_events( + client, start_dt.date(), (end_dt + timedelta(days=1)).date() + ) + + # Step 2: Get events with item_ids via FindItem + CalendarView. + # CalendarView restricts results to the date range and expands recurring + # events into individual occurrences (each with its own ItemId). + folder_id = client.get_folder_id("calendar") + finditem_by_key: dict[str, dict] = {} # "subject|start" -> item + if folder_id: + cv_start = start_dt.strftime("%Y-%m-%dT00:00:00") + cv_end = (end_dt + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00") + + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "CalendarView": { + "__type": "CalendarView:#Exchange", + "StartDate": cv_start, + "EndDate": cv_end, + }, + }, + } + + data = client.request("FindItem", payload) + + all_items = [] + for msg in client.extract_items(data): + if "RootFolder" in msg: + all_items = msg["RootFolder"].get("Items", []) + break + + # Index FindItem results by subject + normalized local start time. + # FindItem returns UTC timestamps (e.g. "2026-02-17T06:30:00Z"), + # while GetUserAvailability returns Moscow local time ("2026-02-17T09:30:00"). + # Normalize FindItem timestamps to local time for matching. + for item in all_items: + subject = item.get("Subject", "") + start = item.get("Start", "") + local_start = _utc_to_local_str(start) + key = f"{subject}|{local_start}" + finditem_by_key[key] = item + + # Step 3: Merge — use expanded list as the authoritative event list, + # enrich with FindItem data (item_id, details) when available + events = [] + for ev in expanded: + subject = ev.get("subject", "(No subject)") + start = ev.get("start", "") + end = ev.get("end", "") + is_recurring = ev.get("is_recurring", False) + + # Match expanded event (local time) with FindItem result (normalized to local) + fi_item = finditem_by_key.get(f"{subject}|{start}") + + item_id = fi_item.get("ItemId", {}).get("Id", "") if fi_item else "" + + event = { + "subject": subject, + "start": fi_item.get("Start", start) if fi_item else start, + "end": fi_item.get("End", end) if fi_item else end, + "location": ev.get("location", ""), + "is_all_day": fi_item.get("IsAllDayEvent", False) if fi_item else False, + "is_cancelled": fi_item.get("IsCancelled", False) if fi_item else False, + "is_meeting": ev.get("is_meeting", False), + "is_recurring": is_recurring, + "organizer": "", + "my_response": fi_item.get("MyResponseType", "") if fi_item else "", + "item_id": item_id, + "body": "", + "attendees_required": [], + "attendees_optional": [], + } + + # Get full details via GetItem if requested and item_id is available + if include_body and item_id: + details = _get_event_details(client, item_id) + event["organizer"] = details["organizer"] + event["location"] = details["location"] or event["location"] + event["body"] = details["body"] + event["attendees_required"] = details["attendees_required"] + event["attendees_optional"] = details["attendees_optional"] + + events.append(event) + + return json.dumps(events, ensure_ascii=False) + # ------------------------------------------------------------------ # Tool 2: create_meeting @@ -568,179 +557,179 @@ def get_calendar_events( @mcp.tool() def create_meeting( - subject: str, - date: str, - start_time: str, - duration_minutes: int = 30, - required_attendees: list[str] | None = None, - optional_attendees: list[str] | None = None, - location: str | None = None, - description: str | None = None, - is_all_day: bool = False, - reminder_minutes: int = 15, - importance: str = "Normal", - sensitivity: str = "Normal", - ctx: Context = None, + subject: str, + date: str, + start_time: str, + duration_minutes: int = 30, + required_attendees: list[str] | None = None, + optional_attendees: list[str] | None = None, + location: str | None = None, + description: str | None = None, + is_all_day: bool = False, + reminder_minutes: int = 15, + importance: str = "Normal", + sensitivity: str = "Normal", + ctx: Context = None, ) -> str: - """Create a new calendar meeting. + """Create a new calendar meeting. - Args: - subject: Meeting subject/topic. - date: Meeting date in YYYY-MM-DD format. - start_time: Start time in HH:MM format. - duration_minutes: Duration in minutes (default 30). - required_attendees: List of email addresses for required attendees. - optional_attendees: List of email addresses for optional attendees. - location: Location or video link. - description: Meeting description/body text. - is_all_day: Whether this is an all-day event. - reminder_minutes: Minutes before start for reminder (default 15). - importance: Importance level: Low, Normal, or High. - sensitivity: Sensitivity: Normal, Personal, Private, or Confidential. + Args: + subject: Meeting subject/topic. + date: Meeting date in YYYY-MM-DD format. + start_time: Start time in HH:MM format. + duration_minutes: Duration in minutes (default 30). + required_attendees: List of email addresses for required attendees. + optional_attendees: List of email addresses for optional attendees. + location: Location or video link. + description: Meeting description/body text. + is_all_day: Whether this is an all-day event. + reminder_minutes: Minutes before start for reminder (default 15). + importance: Importance level: Low, Normal, or High. + sensitivity: Sensitivity: Normal, Personal, Private, or Confidential. - Returns: - JSON object with creation result including item_id on success. - """ - client = _get_client(ctx) + Returns: + JSON object with creation result including item_id on success. + """ + client = _get_client(ctx) - # Parse date and time - try: - start_dt = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M") - end_dt = start_dt + timedelta(minutes=duration_minutes) - except ValueError as e: - return json.dumps({"error": f"Invalid date/time: {e}"}) + # Parse date and time + try: + start_dt = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M") + end_dt = start_dt + timedelta(minutes=duration_minutes) + except ValueError as e: + return json.dumps({"error": f"Invalid date/time: {e}"}) - # Resolve attendees - resolved_required = _resolve_attendee_list(client, required_attendees or []) - resolved_optional = _resolve_attendee_list(client, optional_attendees or []) + # Resolve attendees + resolved_required = _resolve_attendee_list(client, required_attendees or []) + resolved_optional = _resolve_attendee_list(client, optional_attendees or []) - # Build HTML body - html_body = _build_html_body(description) + # Build HTML body + html_body = _build_html_body(description) - # Build location object - location_obj = { - "__type": "EnhancedLocation:#Exchange", - "Annotation": "", - "DisplayName": location or "", - "PostalAddress": { - "__type": "PersonaPostalAddress:#Exchange", - "Type": "Business", - "LocationSource": "None", + # Build location object + location_obj = { + "__type": "EnhancedLocation:#Exchange", + "Annotation": "", + "DisplayName": location or "", + "PostalAddress": { + "__type": "PersonaPostalAddress:#Exchange", + "Type": "Business", + "LocationSource": "None", + }, + } + + # Build calendar item + calendar_item = { + "__type": "CalendarItem:#Exchange", + "ClientSeriesId": str(uuid.uuid4()), + "Subject": subject, + "Body": { + "__type": "BodyContentType:#Exchange", + "BodyType": "HTML", + "Value": html_body, + }, + "Sensitivity": sensitivity, + "ReminderIsSet": True, + "ReminderMinutesBeforeStart": reminder_minutes, + "IsResponseRequested": True, + "DoNotForwardMeeting": False, + "IsAllDayEvent": is_all_day, + "Start": start_dt.strftime("%Y-%m-%dT%H:%M:%S.000"), + "End": end_dt.strftime("%Y-%m-%dT%H:%M:%S.000"), + "FreeBusyType": "Busy", + "Location": location_obj, + "unfoldedIndex": 0, + } + + if importance != "Normal": + calendar_item["Importance"] = importance + + if resolved_required: + calendar_item["RequiredAttendees"] = resolved_required + if resolved_optional: + calendar_item["OptionalAttendees"] = resolved_optional + + # Build request - uses CreateCalendarEvent action and V2017_08_18 + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, - } - - # Build calendar item - calendar_item = { - "__type": "CalendarItem:#Exchange", - "ClientSeriesId": str(uuid.uuid4()), - "Subject": subject, - "Body": { - "__type": "BodyContentType:#Exchange", - "BodyType": "HTML", - "Value": html_body, + }, + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [calendar_item], + "ClientSupportsIrm": True, + "SavedItemFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "calendar", }, - "Sensitivity": sensitivity, - "ReminderIsSet": True, - "ReminderMinutesBeforeStart": reminder_minutes, - "IsResponseRequested": True, - "DoNotForwardMeeting": False, - "IsAllDayEvent": is_all_day, - "Start": start_dt.strftime("%Y-%m-%dT%H:%M:%S.000"), - "End": end_dt.strftime("%Y-%m-%dT%H:%M:%S.000"), - "FreeBusyType": "Busy", - "Location": location_obj, - "unfoldedIndex": 0, - } + }, + }, + } - if importance != "Normal": - calendar_item["Importance"] = importance + # Send invitations if there are attendees + if resolved_required or resolved_optional: + payload["Body"]["SendMeetingInvitations"] = "SendToAllAndSaveCopy" - if resolved_required: - calendar_item["RequiredAttendees"] = resolved_required - if resolved_optional: - calendar_item["OptionalAttendees"] = resolved_optional + data = client.request("CreateCalendarEvent", payload) - # Build request - uses CreateCalendarEvent action and V2017_08_18 - payload = { - "__type": "CreateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, - }, - }, - "Body": { - "__type": "CreateItemRequest:#Exchange", - "Items": [calendar_item], - "ClientSupportsIrm": True, - "SavedItemFolderId": { - "__type": "TargetFolderId:#Exchange", - "BaseFolderId": { - "__type": "DistinguishedFolderId:#Exchange", - "Id": "calendar", - }, - }, - }, - } + # Check for top-level error + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) - # Send invitations if there are attendees - if resolved_required or resolved_optional: - payload["Body"]["SendMeetingInvitations"] = "SendToAllAndSaveCopy" + # Check response messages + items = body.get("ResponseMessages", {}).get("Items", []) + if items: + item = items[0] + if item.get("ResponseClass") == "Success": + result = { + "success": True, + "subject": subject, + "date": date, + "start_time": start_time, + "end_time": end_dt.strftime("%H:%M"), + "duration_minutes": duration_minutes, + } + # Extract created item ID if available + created_items = item.get("Items", []) + if created_items: + item_id = created_items[0].get("ItemId", {}) + if item_id: + result["item_id"] = item_id.get("Id", "") + result["change_key"] = item_id.get("ChangeKey", "") + if location: + result["location"] = location + if resolved_required: + result["required_attendees"] = [ + a["Mailbox"]["EmailAddress"] for a in resolved_required + ] + if resolved_optional: + result["optional_attendees"] = [ + a["Mailbox"]["EmailAddress"] for a in resolved_optional + ] + return json.dumps(result, ensure_ascii=False) + else: + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) - data = client.request("CreateCalendarEvent", payload) - - # Check for top-level error - body = data.get("Body", {}) - if "ErrorCode" in body: - return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) - - # Check response messages - items = body.get("ResponseMessages", {}).get("Items", []) - if items: - item = items[0] - if item.get("ResponseClass") == "Success": - result = { - "success": True, - "subject": subject, - "date": date, - "start_time": start_time, - "end_time": end_dt.strftime("%H:%M"), - "duration_minutes": duration_minutes, - } - # Extract created item ID if available - created_items = item.get("Items", []) - if created_items: - item_id = created_items[0].get("ItemId", {}) - if item_id: - result["item_id"] = item_id.get("Id", "") - result["change_key"] = item_id.get("ChangeKey", "") - if location: - result["location"] = location - if resolved_required: - result["required_attendees"] = [ - a["Mailbox"]["EmailAddress"] for a in resolved_required - ] - if resolved_optional: - result["optional_attendees"] = [ - a["Mailbox"]["EmailAddress"] for a in resolved_optional - ] - return json.dumps(result, ensure_ascii=False) - else: - return json.dumps( - { - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - } - ) - - return json.dumps( - {"success": True, "subject": subject, "note": "No confirmation details"} - ) + return json.dumps( + {"success": True, "subject": subject, "note": "No confirmation details"} + ) # ------------------------------------------------------------------ @@ -750,244 +739,246 @@ def create_meeting( @mcp.tool() def update_meeting( - item_id: str, - subject: str | None = None, - date: str | None = None, - start_time: str | None = None, - duration_minutes: int | None = None, - location: str | None = None, - description: str | None = None, - required_attendees: list[str] | None = None, - optional_attendees: list[str] | None = None, - change_key: str = "", - ctx: Context = None, + item_id: str, + subject: str | None = None, + date: str | None = None, + start_time: str | None = None, + duration_minutes: int | None = None, + location: str | None = None, + description: str | None = None, + required_attendees: list[str] | None = None, + optional_attendees: list[str] | None = None, + change_key: str = "", + ctx: Context = None, ) -> str: - """Update an existing calendar meeting. + """Update an existing calendar meeting. - Internally cancels the old meeting and creates a new one with - updated fields, because OWA's JSON API does not support UpdateItem - for calendar items reliably. Unchanged fields are preserved from - the original meeting. + Internally cancels the old meeting and creates a new one with + updated fields, because OWA's JSON API does not support UpdateItem + for calendar items reliably. Unchanged fields are preserved from + the original meeting. - Args: - item_id: The ItemId of the meeting to update (from get_calendar_events). - subject: New subject (omit to keep original). - date: New date in YYYY-MM-DD format (omit to keep original). - start_time: New start time in HH:MM format (omit to keep original). - duration_minutes: New duration in minutes (omit to keep original). - location: New location (omit to keep original). - description: New description/body text (omit to keep original). - required_attendees: Email addresses for required attendees. - Replaces existing list. Omit to keep original attendees. - optional_attendees: Email addresses for optional attendees. - Replaces existing list. Omit to keep original attendees. - change_key: Ignored (kept for backward compatibility). + Args: + item_id: The ItemId of the meeting to update (from get_calendar_events). + subject: New subject (omit to keep original). + date: New date in YYYY-MM-DD format (omit to keep original). + start_time: New start time in HH:MM format (omit to keep original). + duration_minutes: New duration in minutes (omit to keep original). + location: New location (omit to keep original). + description: New description/body text (omit to keep original). + required_attendees: Email addresses for required attendees. + Replaces existing list. Omit to keep original attendees. + optional_attendees: Email addresses for optional attendees. + Replaces existing list. Omit to keep original attendees. + change_key: Ignored (kept for backward compatibility). - Returns: - JSON object with update result including new item_id. - """ - client = _get_client(ctx) + Returns: + JSON object with update result including new item_id. + """ + client = _get_client(ctx) - # Step 1: Get the original meeting details - try: - orig = _get_full_event(client, item_id) - except Exception as e: - return json.dumps({"error": f"Could not fetch original meeting: {e}"}) + # Step 1: Get the original meeting details + try: + orig = _get_full_event(client, item_id) + except Exception as e: + return json.dumps({"error": f"Could not fetch original meeting: {e}"}) - if "error" in orig: - return json.dumps(orig) + if "error" in orig: + return json.dumps(orig) - # Step 2: Merge original values with updates - new_subject = subject if subject is not None else orig.get("subject", "") + # Step 2: Merge original values with updates + new_subject = subject if subject is not None else orig.get("subject", "") - # Parse original start/end for date and time defaults - orig_start_str = orig.get("start", "") - orig_end_str = orig.get("end", "") - try: - orig_start = datetime.fromisoformat( - orig_start_str.replace("Z", "+00:00") - ).replace(tzinfo=None) - orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace( - tzinfo=None - ) - orig_duration = int((orig_end - orig_start).total_seconds() / 60) - except (ValueError, AttributeError): - orig_start = None - orig_end = None - orig_duration = 30 + # Parse original start/end for date and time defaults + orig_start_str = orig.get("start", "") + orig_end_str = orig.get("end", "") + try: + orig_start = datetime.fromisoformat( + orig_start_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + orig_end = datetime.fromisoformat( + orig_end_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + orig_duration = int((orig_end - orig_start).total_seconds() / 60) + except (ValueError, AttributeError): + orig_start = None + orig_end = None + orig_duration = 30 - if date is not None and start_time is not None: - new_start = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M") - dur = duration_minutes if duration_minutes is not None else orig_duration - new_end = new_start + timedelta(minutes=dur) - elif date is not None and orig_start is not None: - new_start = datetime.strptime(date, "%Y-%m-%d").replace( - hour=orig_start.hour, minute=orig_start.minute - ) - dur = duration_minutes if duration_minutes is not None else orig_duration - new_end = new_start + timedelta(minutes=dur) - elif start_time is not None and orig_start is not None: - parts = start_time.split(":") - new_start = orig_start.replace(hour=int(parts[0]), minute=int(parts[1])) - dur = duration_minutes if duration_minutes is not None else orig_duration - new_end = new_start + timedelta(minutes=dur) - elif duration_minutes is not None and orig_start is not None: - new_start = orig_start - new_end = new_start + timedelta(minutes=duration_minutes) - elif orig_start is not None: - new_start = orig_start - new_end = orig_end - else: - return json.dumps( - {"error": "Cannot determine meeting time. Provide date and start_time."} - ) - - new_location = location if location is not None else orig.get("location", "") - - # Resolve attendees - if required_attendees is not None: - resolved_required = _resolve_attendee_list(client, required_attendees) - else: - resolved_required = orig.get("resolved_required", []) - - if optional_attendees is not None: - resolved_optional = _resolve_attendee_list(client, optional_attendees) - else: - resolved_optional = orig.get("resolved_optional", []) - - # Step 3: Cancel the original meeting - cancel_payload = { - "__type": "DeleteItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "DeleteItemRequest:#Exchange", - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - "DeleteType": "MoveToDeletedItems", - "SendMeetingCancellations": "SendToAllAndSaveCopy", - "SuppressReadReceipts": True, - }, - } - - try: - client.request("DeleteItem", cancel_payload) - except Exception as e: - return json.dumps({"error": f"Failed to cancel original meeting: {e}"}) - - # Step 4: Create the new meeting - new_body = description if description is not None else orig.get("body_html", "") - if description is not None: - new_body = _build_html_body(description) - - location_obj = { - "__type": "EnhancedLocation:#Exchange", - "Annotation": "", - "DisplayName": new_location, - "PostalAddress": { - "__type": "PersonaPostalAddress:#Exchange", - "Type": "Business", - "LocationSource": "None", - }, - } - - calendar_item = { - "__type": "CalendarItem:#Exchange", - "ClientSeriesId": str(uuid.uuid4()), - "Subject": new_subject, - "Body": { - "__type": "BodyContentType:#Exchange", - "BodyType": "HTML", - "Value": new_body, - }, - "Sensitivity": orig.get("sensitivity", "Normal"), - "ReminderIsSet": True, - "ReminderMinutesBeforeStart": 15, - "IsResponseRequested": True, - "DoNotForwardMeeting": False, - "IsAllDayEvent": orig.get("is_all_day", False), - "Start": new_start.strftime("%Y-%m-%dT%H:%M:%S.000"), - "End": new_end.strftime("%Y-%m-%dT%H:%M:%S.000"), - "FreeBusyType": "Busy", - "Location": location_obj, - "unfoldedIndex": 0, - } - - if resolved_required: - calendar_item["RequiredAttendees"] = resolved_required - if resolved_optional: - calendar_item["OptionalAttendees"] = resolved_optional - - create_payload = { - "__type": "CreateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, - }, - }, - "Body": { - "__type": "CreateItemRequest:#Exchange", - "Items": [calendar_item], - "ClientSupportsIrm": True, - "SavedItemFolderId": { - "__type": "TargetFolderId:#Exchange", - "BaseFolderId": { - "__type": "DistinguishedFolderId:#Exchange", - "Id": "calendar", - }, - }, - }, - } - - if resolved_required or resolved_optional: - create_payload["Body"]["SendMeetingInvitations"] = "SendToAllAndSaveCopy" - - try: - data = client.request("CreateCalendarEvent", create_payload) - except Exception as e: - return json.dumps( - {"error": f"Original cancelled but failed to create new: {e}"} - ) - - body = data.get("Body", {}) - if "ErrorCode" in body: - return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) - - resp_items = body.get("ResponseMessages", {}).get("Items", []) - if resp_items and resp_items[0].get("ResponseClass") == "Success": - result = { - "success": True, - "subject": new_subject, - "start": new_start.strftime("%Y-%m-%d %H:%M"), - "end": new_end.strftime("%Y-%m-%d %H:%M"), - "duration_minutes": int((new_end - new_start).total_seconds() / 60), - } - created_items = resp_items[0].get("Items", []) - if created_items: - new_item_id = created_items[0].get("ItemId", {}) - if new_item_id: - result["item_id"] = new_item_id.get("Id", "") - result["change_key"] = new_item_id.get("ChangeKey", "") - return json.dumps(result, ensure_ascii=False) - - if resp_items: - return json.dumps( - { - "error": resp_items[0].get("MessageText", "Unknown error"), - "response_code": resp_items[0].get("ResponseCode", ""), - } - ) - - return json.dumps( - {"success": True, "subject": new_subject, "note": "No confirmation details"} + if date is not None and start_time is not None: + new_start = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M") + dur = duration_minutes if duration_minutes is not None else orig_duration + new_end = new_start + timedelta(minutes=dur) + elif date is not None and orig_start is not None: + new_start = datetime.strptime(date, "%Y-%m-%d").replace( + hour=orig_start.hour, minute=orig_start.minute ) + dur = duration_minutes if duration_minutes is not None else orig_duration + new_end = new_start + timedelta(minutes=dur) + elif start_time is not None and orig_start is not None: + parts = start_time.split(":") + new_start = orig_start.replace(hour=int(parts[0]), minute=int(parts[1])) + dur = duration_minutes if duration_minutes is not None else orig_duration + new_end = new_start + timedelta(minutes=dur) + elif duration_minutes is not None and orig_start is not None: + new_start = orig_start + new_end = new_start + timedelta(minutes=duration_minutes) + elif orig_start is not None: + new_start = orig_start + new_end = orig_end + else: + return json.dumps( + {"error": "Cannot determine meeting time. Provide date and start_time."} + ) + + new_location = location if location is not None else orig.get("location", "") + + # Resolve attendees + if required_attendees is not None: + resolved_required = _resolve_attendee_list(client, required_attendees) + else: + resolved_required = orig.get("resolved_required", []) + + if optional_attendees is not None: + resolved_optional = _resolve_attendee_list(client, optional_attendees) + else: + resolved_optional = orig.get("resolved_optional", []) + + # Step 3: Cancel the original meeting + cancel_payload = { + "__type": "DeleteItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "DeleteItemRequest:#Exchange", + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + "DeleteType": "MoveToDeletedItems", + "SendMeetingCancellations": "SendToAllAndSaveCopy", + "SuppressReadReceipts": True, + }, + } + + try: + client.request("DeleteItem", cancel_payload) + except Exception as e: + return json.dumps({"error": f"Failed to cancel original meeting: {e}"}) + + # Step 4: Create the new meeting + new_body = ( + description if description is not None else orig.get("body_html", "") + ) + if description is not None: + new_body = _build_html_body(description) + + location_obj = { + "__type": "EnhancedLocation:#Exchange", + "Annotation": "", + "DisplayName": new_location, + "PostalAddress": { + "__type": "PersonaPostalAddress:#Exchange", + "Type": "Business", + "LocationSource": "None", + }, + } + + calendar_item = { + "__type": "CalendarItem:#Exchange", + "ClientSeriesId": str(uuid.uuid4()), + "Subject": new_subject, + "Body": { + "__type": "BodyContentType:#Exchange", + "BodyType": "HTML", + "Value": new_body, + }, + "Sensitivity": orig.get("sensitivity", "Normal"), + "ReminderIsSet": True, + "ReminderMinutesBeforeStart": 15, + "IsResponseRequested": True, + "DoNotForwardMeeting": False, + "IsAllDayEvent": orig.get("is_all_day", False), + "Start": new_start.strftime("%Y-%m-%dT%H:%M:%S.000"), + "End": new_end.strftime("%Y-%m-%dT%H:%M:%S.000"), + "FreeBusyType": "Busy", + "Location": location_obj, + "unfoldedIndex": 0, + } + + if resolved_required: + calendar_item["RequiredAttendees"] = resolved_required + if resolved_optional: + calendar_item["OptionalAttendees"] = resolved_optional + + create_payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", + }, + }, + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [calendar_item], + "ClientSupportsIrm": True, + "SavedItemFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "calendar", + }, + }, + }, + } + + if resolved_required or resolved_optional: + create_payload["Body"]["SendMeetingInvitations"] = "SendToAllAndSaveCopy" + + try: + data = client.request("CreateCalendarEvent", create_payload) + except Exception as e: + return json.dumps( + {"error": f"Original cancelled but failed to create new: {e}"} + ) + + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + + resp_items = body.get("ResponseMessages", {}).get("Items", []) + if resp_items and resp_items[0].get("ResponseClass") == "Success": + result = { + "success": True, + "subject": new_subject, + "start": new_start.strftime("%Y-%m-%d %H:%M"), + "end": new_end.strftime("%Y-%m-%d %H:%M"), + "duration_minutes": int((new_end - new_start).total_seconds() / 60), + } + created_items = resp_items[0].get("Items", []) + if created_items: + new_item_id = created_items[0].get("ItemId", {}) + if new_item_id: + result["item_id"] = new_item_id.get("Id", "") + result["change_key"] = new_item_id.get("ChangeKey", "") + return json.dumps(result, ensure_ascii=False) + + if resp_items: + return json.dumps( + { + "error": resp_items[0].get("MessageText", "Unknown error"), + "response_code": resp_items[0].get("ResponseCode", ""), + } + ) + + return json.dumps( + {"success": True, "subject": new_subject, "note": "No confirmation details"} + ) # ------------------------------------------------------------------ @@ -997,57 +988,57 @@ def update_meeting( @mcp.tool() def cancel_meeting( - item_id: str, - message: str | None = None, - ctx: Context = None, + item_id: str, + message: str | None = None, + ctx: Context = None, ) -> str: - """Cancel (delete) a calendar meeting and notify attendees. + """Cancel (delete) a calendar meeting and notify attendees. - Args: - item_id: The ItemId of the meeting to cancel. - message: Optional cancellation message to attendees. + Args: + item_id: The ItemId of the meeting to cancel. + message: Optional cancellation message to attendees. - Returns: - JSON object with cancellation result. - """ - client = _get_client(ctx) + Returns: + JSON object with cancellation result. + """ + client = _get_client(ctx) - payload = { - "__type": "DeleteItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "DeleteItemRequest:#Exchange", - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - "DeleteType": "MoveToDeletedItems", - "SendMeetingCancellations": "SendToAllAndSaveCopy", - "SuppressReadReceipts": True, - }, - } + payload = { + "__type": "DeleteItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "DeleteItemRequest:#Exchange", + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + "DeleteType": "MoveToDeletedItems", + "SendMeetingCancellations": "SendToAllAndSaveCopy", + "SuppressReadReceipts": True, + }, + } - data = client.request("DeleteItem", payload) + data = client.request("DeleteItem", payload) - items = client.extract_items(data) - if items: - item = items[0] - if item.get("ResponseClass") == "Success": - return json.dumps({"success": True, "message": "Meeting cancelled"}) - else: - return json.dumps( - { - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - } - ) + items = client.extract_items(data) + if items: + item = items[0] + if item.get("ResponseClass") == "Success": + return json.dumps({"success": True, "message": "Meeting cancelled"}) + else: + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) - # DeleteItem may return empty on success - body = data.get("Body", {}) - if "ErrorCode" in body: - return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) + # DeleteItem may return empty on success + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) - return json.dumps({"success": True, "message": "Meeting cancelled"}) + return json.dumps({"success": True, "message": "Meeting cancelled"}) # ------------------------------------------------------------------ @@ -1057,88 +1048,88 @@ def cancel_meeting( @mcp.tool() def respond_to_meeting( - item_id: str, - response: str, - message: str | None = None, - ctx: Context = None, + item_id: str, + response: str, + message: str | None = None, + ctx: Context = None, ) -> str: - """Respond to a meeting invitation (accept, decline, or tentative). + """Respond to a meeting invitation (accept, decline, or tentative). - Args: - item_id: The ItemId of the meeting to respond to. - response: Response type: "Accept", "Decline", or "Tentative". - message: Optional message to include with the response. + Args: + item_id: The ItemId of the meeting to respond to. + response: Response type: "Accept", "Decline", or "Tentative". + message: Optional message to include with the response. - Returns: - JSON object with response result. - """ - client = _get_client(ctx) + Returns: + JSON object with response result. + """ + client = _get_client(ctx) - # Map response to the correct __type - response_types = { - "Accept": "AcceptItem:#Exchange", - "Decline": "DeclineItem:#Exchange", - "Tentative": "TentativelyAcceptItem:#Exchange", + # Map response to the correct __type + response_types = { + "Accept": "AcceptItem:#Exchange", + "Decline": "DeclineItem:#Exchange", + "Tentative": "TentativelyAcceptItem:#Exchange", + } + + response_type = response_types.get(response) + if not response_type: + return json.dumps( + { + "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." + } + ) + + response_item = { + "__type": response_type, + "ReferenceItemId": { + "__type": "ItemId:#Exchange", + "Id": item_id, + }, + } + + if message: + response_item["Body"] = { + "__type": "BodyContentType:#Exchange", + "BodyType": "Text", + "Value": message, } - response_type = response_types.get(response) - if not response_type: - return json.dumps( - { - "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." - } - ) + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [response_item], + "MessageDisposition": "SendAndSaveCopy", + }, + } - response_item = { - "__type": response_type, - "ReferenceItemId": { - "__type": "ItemId:#Exchange", - "Id": item_id, - }, - } + data = client.request("CreateItem", payload) - if message: - response_item["Body"] = { - "__type": "BodyContentType:#Exchange", - "BodyType": "Text", - "Value": message, + items = client.extract_items(data) + if items: + item = items[0] + if item.get("ResponseClass") == "Success": + return json.dumps( + { + "success": True, + "response": response, + "message": f"Meeting {response.lower()}ed", } + ) + else: + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) - payload = { - "__type": "CreateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "CreateItemRequest:#Exchange", - "Items": [response_item], - "MessageDisposition": "SendAndSaveCopy", - }, - } - - data = client.request("CreateItem", payload) - - items = client.extract_items(data) - if items: - item = items[0] - if item.get("ResponseClass") == "Success": - return json.dumps( - { - "success": True, - "response": response, - "message": f"Meeting {response.lower()}ed", - } - ) - else: - return json.dumps( - { - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - } - ) - - return json.dumps({"error": "No response from server"}) + return json.dumps({"error": "No response from server"}) # ------------------------------------------------------------------ @@ -1148,151 +1139,151 @@ def respond_to_meeting( @mcp.tool() def download_event_attachments( - item_id: str, - target_folder: str = "/tmp/attachments", - ctx: Context = None, + item_id: str, + target_folder: str = "/tmp/attachments", + ctx: Context = None, ) -> str: - """Download all file attachments from a calendar event to disk. + """Download all file attachments from a calendar event to disk. - Args: - item_id: The Exchange ItemId of the calendar event. - target_folder: Local directory to save files (default /tmp/attachments). - """ - import os + Args: + item_id: The Exchange ItemId of the calendar event. + target_folder: Local directory to save files (default /tmp/attachments). + """ + import os - try: - client = _get_client(ctx) + try: + client = _get_client(ctx) - # Get full event details (AllProperties includes Attachments) - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, + # Get full event details (AllProperties includes Attachments) + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } + + data = client.request("GetItem", payload) + + # Extract attachments from the item + attachments = [] + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + for att in item.get("Attachments", []): + attachments.append( + { + "name": att.get("Name", ""), + "size": att.get("Size", 0), + "content_type": att.get("ContentType", ""), + "attachment_id": att.get("AttachmentId", {}).get("Id", ""), + "is_inline": att.get("IsInline", False), + } + ) + break + + if not attachments: + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No attachments found on this event.", } + ) - data = client.request("GetItem", payload) + # Filter to non-inline file attachments + file_attachments = [ + a + for a in attachments + if a.get("attachment_id") and not a.get("is_inline", False) + ] - # Extract attachments from the item - attachments = [] - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - for att in item.get("Attachments", []): - attachments.append( - { - "name": att.get("Name", ""), - "size": att.get("Size", 0), - "content_type": att.get("ContentType", ""), - "attachment_id": att.get("AttachmentId", {}).get("Id", ""), - "is_inline": att.get("IsInline", False), - } - ) - break - - if not attachments: - return json.dumps( - { - "success": True, - "downloaded": [], - "count": 0, - "message": "No attachments found on this event.", - } - ) - - # Filter to non-inline file attachments - file_attachments = [ - a - for a in attachments - if a.get("attachment_id") and not a.get("is_inline", False) - ] - - if not file_attachments: - return json.dumps( - { - "success": True, - "downloaded": [], - "count": 0, - "message": "No downloadable file attachments.", - } - ) - - os.makedirs(target_folder, exist_ok=True) - - downloaded = [] - errors = [] - used_names: set[str] = set() - - for att in file_attachments: - try: - content, filename, content_type = client.download_file( - att["attachment_id"] - ) - - # Sanitize filename - filename = os.path.basename(filename) - if not filename: - filename = att.get("name", "attachment") or "attachment" - - # Handle collisions - base_name = filename - name_part, _, ext_part = base_name.rpartition(".") - if not name_part: - name_part = base_name - ext_part = "" - - counter = 1 - while filename.lower() in used_names: - if ext_part: - filename = f"{name_part}_{counter}.{ext_part}" - else: - filename = f"{name_part}_{counter}" - counter += 1 - - used_names.add(filename.lower()) - - filepath = os.path.join(target_folder, filename) - with open(filepath, "wb") as f: - f.write(content) - - downloaded.append( - { - "name": filename, - "path": filepath, - "size": len(content), - "content_type": content_type, - } - ) - except Exception as e: - errors.append( - { - "name": att.get("name", "unknown"), - "error": str(e), - } - ) - - result = { - "success": len(errors) == 0, - "downloaded": downloaded, - "count": len(downloaded), + if not file_attachments: + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No downloadable file attachments.", } - if errors: - result["errors"] = errors + ) - return json.dumps(result) + os.makedirs(target_folder, exist_ok=True) - except Exception as e: - return json.dumps({"error": f"Failed to download event attachments: {e}"}) + downloaded = [] + errors = [] + used_names: set[str] = set() + + for att in file_attachments: + try: + content, filename, content_type = client.download_file( + att["attachment_id"] + ) + + # Sanitize filename + filename = os.path.basename(filename) + if not filename: + filename = att.get("name", "attachment") or "attachment" + + # Handle collisions + base_name = filename + name_part, _, ext_part = base_name.rpartition(".") + if not name_part: + name_part = base_name + ext_part = "" + + counter = 1 + while filename.lower() in used_names: + if ext_part: + filename = f"{name_part}_{counter}.{ext_part}" + else: + filename = f"{name_part}_{counter}" + counter += 1 + + used_names.add(filename.lower()) + + filepath = os.path.join(target_folder, filename) + with open(filepath, "wb") as f: + f.write(content) + + downloaded.append( + { + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + } + ) + except Exception as e: + errors.append( + { + "name": att.get("name", "unknown"), + "error": str(e), + } + ) + + result = { + "success": len(errors) == 0, + "downloaded": downloaded, + "count": len(downloaded), + } + if errors: + result["errors"] = errors + + return json.dumps(result) + + except Exception as e: + return json.dumps({"error": f"Failed to download event attachments: {e}"}) # ------------------------------------------------------------------ @@ -1302,66 +1293,66 @@ def download_event_attachments( @mcp.tool() def get_event_links( - item_id: str, - ctx: Context = None, + item_id: str, + ctx: Context = None, ) -> str: - """Extract all hyperlinks from a calendar event's HTML description. + """Extract all hyperlinks from a calendar event's HTML description. - Args: - item_id: The Exchange ItemId of the calendar event to extract links from. - """ - try: - client = _get_client(ctx) + Args: + item_id: The Exchange ItemId of the calendar event to extract links from. + """ + try: + client = _get_client(ctx) - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "IdOnly", - "BodyType": "HTML", - "AdditionalProperties": [ - { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Subject", - }, - { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Body", - }, - ], - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, - } - - data = client.request("GetItem", payload) - - subject = "" - links = [] - - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - subject = item.get("Subject", "") - body_val = item.get("Body", {}).get("Value", "") - links = extract_links_from_html(body_val) - break - - return json.dumps( + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + "BodyType": "HTML", + "AdditionalProperties": [ { - "item_id": item_id, - "subject": subject, - "links": links, - "count": len(links), - } - ) + "__type": "PropertyUri:#Exchange", + "FieldURI": "Subject", + }, + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Body", + }, + ], + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } - except Exception as e: - return json.dumps({"error": f"Failed to extract event links: {e}"}) + data = client.request("GetItem", payload) + + subject = "" + links = [] + + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + subject = item.get("Subject", "") + body_val = item.get("Body", {}).get("Value", "") + links = extract_links_from_html(body_val) + break + + return json.dumps( + { + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + } + ) + + except Exception as e: + return json.dumps({"error": f"Failed to extract event links: {e}"}) diff --git a/owa_mcp/tools/email.py b/owa_mcp/tools/email.py index a7e437c..e236709 100644 --- a/owa_mcp/tools/email.py +++ b/owa_mcp/tools/email.py @@ -15,261 +15,261 @@ from owa_mcp.utils import extract_links_from_html, html_to_text def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - app_ctx: AppContext = ctx.request_context.lifespan_context - return require_client(app_ctx.client) + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) def _get_change_key(client: OWAClient, item_id: str) -> str | None: - """Fetch the ChangeKey for an item via GetItem (IdOnly). + """Fetch the ChangeKey for an item via GetItem (IdOnly). - OWA requires the ChangeKey on write operations like reply/forward. - """ - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "IdOnly", - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, - } - data = client.request("GetItem", payload) - for msg in client.extract_items(data): - if "Items" in msg: - for item in msg["Items"]: - return item.get("ItemId", {}).get("ChangeKey") - return None + OWA requires the ChangeKey on write operations like reply/forward. + """ + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } + data = client.request("GetItem", payload) + for msg in client.extract_items(data): + if "Items" in msg: + for item in msg["Items"]: + return item.get("ItemId", {}).get("ChangeKey") + return None def _extract_email_summary(item: dict) -> dict: - """Extract a summary dict from a FindItem result item.""" - item_type = item.get("__type", "Message:#Exchange") - is_meeting = any( - t in item_type - for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation") + """Extract a summary dict from a FindItem result item.""" + item_type = item.get("__type", "Message:#Exchange") + is_meeting = any( + t in item_type + for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation") + ) + + # Resolve sender from From -> Organizer -> Sender + from_data = item.get("From", {}).get("Mailbox", {}) + if not from_data: + from_data = item.get("Organizer", {}).get("Mailbox", {}) + if not from_data: + from_data = item.get("Sender", {}).get("Mailbox", {}) + + email = { + "subject": item.get("Subject", "(No subject)"), + "from": from_data.get("EmailAddress", ""), + "from_name": from_data.get("Name", ""), + "date": item.get( + "DateTimeSent", + item.get("DateTimeReceived", item.get("DateTimeCreated", "")), + ), + "is_read": item.get("IsRead", False), + "has_attachments": item.get("HasAttachments", False), + "item_id": item.get("ItemId", {}).get("Id", ""), + "size": item.get("Size", 0), + "is_meeting": is_meeting, + "type": "Meeting" if is_meeting else "Email", + "preview": item.get("Preview", ""), + "has_links": False, + } + + # Basic recipients from DisplayTo/DisplayCc + email["to"] = ( + [t.strip() for t in item.get("DisplayTo", "").split(";") if t.strip()] + if item.get("DisplayTo") + else [] + ) + email["cc"] = ( + [c.strip() for c in item.get("DisplayCc", "").split(";") if c.strip()] + if item.get("DisplayCc") + else [] + ) + + if is_meeting: + email["location"] = item.get("Location", "") + email["start"] = item.get( + "Start", item.get("StartWallClock", item.get("ReminderDueBy", "")) ) + email["end"] = item.get("End", item.get("EndWallClock", "")) - # Resolve sender from From -> Organizer -> Sender - from_data = item.get("From", {}).get("Mailbox", {}) - if not from_data: - from_data = item.get("Organizer", {}).get("Mailbox", {}) - if not from_data: - from_data = item.get("Sender", {}).get("Mailbox", {}) - - email = { - "subject": item.get("Subject", "(No subject)"), - "from": from_data.get("EmailAddress", ""), - "from_name": from_data.get("Name", ""), - "date": item.get( - "DateTimeSent", - item.get("DateTimeReceived", item.get("DateTimeCreated", "")), - ), - "is_read": item.get("IsRead", False), - "has_attachments": item.get("HasAttachments", False), - "item_id": item.get("ItemId", {}).get("Id", ""), - "size": item.get("Size", 0), - "is_meeting": is_meeting, - "type": "Meeting" if is_meeting else "Email", - "preview": item.get("Preview", ""), - "has_links": False, - } - - # Basic recipients from DisplayTo/DisplayCc - email["to"] = ( - [t.strip() for t in item.get("DisplayTo", "").split(";") if t.strip()] - if item.get("DisplayTo") - else [] - ) - email["cc"] = ( - [c.strip() for c in item.get("DisplayCc", "").split(";") if c.strip()] - if item.get("DisplayCc") - else [] - ) - - if is_meeting: - email["location"] = item.get("Location", "") - email["start"] = item.get( - "Start", item.get("StartWallClock", item.get("ReminderDueBy", "")) - ) - email["end"] = item.get("End", item.get("EndWallClock", "")) - - return email + return email def _get_item_details(client: OWAClient, item_id: str) -> dict: - """Get full email details (body, recipients, attachments) via GetItem.""" - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - "BodyType": "HTML", - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, - } + """Get full email details (body, recipients, attachments) via GetItem.""" + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + "BodyType": "HTML", + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } - data = client.request("GetItem", payload) - result = { - "item_id": item_id, - "subject": "", - "from": "", - "from_name": "", - "date": "", - "to": [], - "cc": [], - "bcc": [], - "body": "", - "body_type": "Text", - "is_read": False, - "has_attachments": False, - "has_links": False, - "importance": "Normal", - "attachments": [], - } + data = client.request("GetItem", payload) + result = { + "item_id": item_id, + "subject": "", + "from": "", + "from_name": "", + "date": "", + "to": [], + "cc": [], + "bcc": [], + "body": "", + "body_type": "Text", + "is_read": False, + "has_attachments": False, + "has_links": False, + "importance": "Normal", + "attachments": [], + } - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - result["subject"] = item.get("Subject", "(No subject)") + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + result["subject"] = item.get("Subject", "(No subject)") - # Sender - from_data = item.get("From", {}).get("Mailbox", {}) - if not from_data: - from_data = item.get("Sender", {}).get("Mailbox", {}) - result["from"] = from_data.get("EmailAddress", "") - result["from_name"] = from_data.get("Name", "") + # Sender + from_data = item.get("From", {}).get("Mailbox", {}) + if not from_data: + from_data = item.get("Sender", {}).get("Mailbox", {}) + result["from"] = from_data.get("EmailAddress", "") + result["from_name"] = from_data.get("Name", "") - result["date"] = item.get( - "DateTimeSent", - item.get("DateTimeReceived", item.get("DateTimeCreated", "")), - ) - result["is_read"] = item.get("IsRead", False) - result["has_attachments"] = item.get("HasAttachments", False) - result["importance"] = item.get("Importance", "Normal") + result["date"] = item.get( + "DateTimeSent", + item.get("DateTimeReceived", item.get("DateTimeCreated", "")), + ) + result["is_read"] = item.get("IsRead", False) + result["has_attachments"] = item.get("HasAttachments", False) + result["importance"] = item.get("Importance", "Normal") - # Body - body_val = item.get("Body", {}).get("Value", "") - body_type = item.get("Body", {}).get("BodyType", "Text") - if body_type == "HTML": - result["has_links"] = bool(extract_links_from_html(body_val)) - result["body"] = html_to_text(body_val) - else: - result["body"] = body_val - result["body_type"] = body_type + # Body + body_val = item.get("Body", {}).get("Value", "") + body_type = item.get("Body", {}).get("BodyType", "Text") + if body_type == "HTML": + result["has_links"] = bool(extract_links_from_html(body_val)) + result["body"] = html_to_text(body_val) + else: + result["body"] = body_val + result["body_type"] = body_type - # To recipients - for r in item.get("ToRecipients", []): - name = r.get("Name", "") - addr = r.get("EmailAddress", "") - if name and addr: - result["to"].append(f"{name} <{addr}>") - elif addr: - result["to"].append(addr) + # To recipients + for r in item.get("ToRecipients", []): + name = r.get("Name", "") + addr = r.get("EmailAddress", "") + if name and addr: + result["to"].append(f"{name} <{addr}>") + elif addr: + result["to"].append(addr) - # CC recipients - for r in item.get("CcRecipients", []): - name = r.get("Name", "") - addr = r.get("EmailAddress", "") - if name and addr: - result["cc"].append(f"{name} <{addr}>") - elif addr: - result["cc"].append(addr) + # CC recipients + for r in item.get("CcRecipients", []): + name = r.get("Name", "") + addr = r.get("EmailAddress", "") + if name and addr: + result["cc"].append(f"{name} <{addr}>") + elif addr: + result["cc"].append(addr) - # BCC recipients - for r in item.get("BccRecipients", []): - name = r.get("Name", "") - addr = r.get("EmailAddress", "") - if name and addr: - result["bcc"].append(f"{name} <{addr}>") - elif addr: - result["bcc"].append(addr) + # BCC recipients + for r in item.get("BccRecipients", []): + name = r.get("Name", "") + addr = r.get("EmailAddress", "") + if name and addr: + result["bcc"].append(f"{name} <{addr}>") + elif addr: + result["bcc"].append(addr) - # Attachments (with IDs for download) - for att in item.get("Attachments", []): - result["attachments"].append( - { - "name": att.get("Name", ""), - "size": att.get("Size", 0), - "content_type": att.get("ContentType", ""), - "attachment_id": att.get("AttachmentId", {}).get("Id", ""), - "is_inline": att.get("IsInline", False), - } - ) + # Attachments (with IDs for download) + for att in item.get("Attachments", []): + result["attachments"].append( + { + "name": att.get("Name", ""), + "size": att.get("Size", 0), + "content_type": att.get("ContentType", ""), + "attachment_id": att.get("AttachmentId", {}).get("Id", ""), + "is_inline": att.get("IsInline", False), + } + ) - # Meeting-specific fields - item_type = item.get("__type", "") - if any( - t in item_type - for t in ( - "MeetingRequest", - "MeetingResponse", - "MeetingCancellation", - "CalendarItem", - ) - ): - result["location"] = item.get( - "Location", - item.get("EnhancedLocation", {}).get("DisplayName", ""), - ) - result["start"] = item.get("Start", "") - result["end"] = item.get("End", "") - result["required_attendees"] = [] - result["optional_attendees"] = [] - for a in item.get("RequiredAttendees", []): - mb = a.get("Mailbox", {}) - name = mb.get("Name", "") - addr = mb.get("EmailAddress", "") - if name and addr: - result["required_attendees"].append(f"{name} <{addr}>") - elif addr: - result["required_attendees"].append(addr) - for a in item.get("OptionalAttendees", []): - mb = a.get("Mailbox", {}) - name = mb.get("Name", "") - addr = mb.get("EmailAddress", "") - if name and addr: - result["optional_attendees"].append(f"{name} <{addr}>") - elif addr: - result["optional_attendees"].append(addr) + # Meeting-specific fields + item_type = item.get("__type", "") + if any( + t in item_type + for t in ( + "MeetingRequest", + "MeetingResponse", + "MeetingCancellation", + "CalendarItem", + ) + ): + result["location"] = item.get( + "Location", + item.get("EnhancedLocation", {}).get("DisplayName", ""), + ) + result["start"] = item.get("Start", "") + result["end"] = item.get("End", "") + result["required_attendees"] = [] + result["optional_attendees"] = [] + for a in item.get("RequiredAttendees", []): + mb = a.get("Mailbox", {}) + name = mb.get("Name", "") + addr = mb.get("EmailAddress", "") + if name and addr: + result["required_attendees"].append(f"{name} <{addr}>") + elif addr: + result["required_attendees"].append(addr) + for a in item.get("OptionalAttendees", []): + mb = a.get("Mailbox", {}) + name = mb.get("Name", "") + addr = mb.get("EmailAddress", "") + if name and addr: + result["optional_attendees"].append(f"{name} <{addr}>") + elif addr: + result["optional_attendees"].append(addr) - return result + return result - return result + return result def _build_recipient_list(emails: str) -> list[dict]: - """Build a list of Mailbox dicts from a comma-separated email string. + """Build a list of Mailbox dicts from a comma-separated email string. - NOTE: OWA rejects __type annotations on recipient Mailbox dicts for - CreateItem (Message). Use plain dicts without __type. - """ - recipients = [] - for addr in emails.split(","): - addr = addr.strip() - if addr: - recipients.append( - { - "Name": addr, - "EmailAddress": addr, - "RoutingType": "SMTP", - } - ) - return recipients + NOTE: OWA rejects __type annotations on recipient Mailbox dicts for + CreateItem (Message). Use plain dicts without __type. + """ + recipients = [] + for addr in emails.split(","): + addr = addr.strip() + if addr: + recipients.append( + { + "Name": addr, + "EmailAddress": addr, + "RoutingType": "SMTP", + } + ) + return recipients # ------------------------------------------------------------------ @@ -279,778 +279,780 @@ def _build_recipient_list(emails: str) -> list[dict]: @mcp.tool() def get_emails( - folder: str = "Inbox", - limit: int = 10, - offset: int = 0, - include_body: bool = False, - unread_only: bool = False, - ids_only: bool = False, - ctx: Context = None, + folder: str = "Inbox", + limit: int = 10, + offset: int = 0, + include_body: bool = False, + unread_only: bool = False, + ids_only: bool = False, + ctx: Context = None, ) -> str: - """Get emails from a mailbox folder. + """Get emails from a mailbox folder. - Args: - folder: Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom name). - limit: Maximum number of emails to return (default 10, max 50). - offset: Number of emails to skip for pagination. - include_body: If True, fetch full body for each email (slower). - unread_only: If True, only return unread emails. - ids_only: If True, return only item IDs and dates (compact, for bulk ops). - Max limit raised to 500 in this mode. - """ - try: - client = _get_client(ctx) + Args: + folder: Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom name). + limit: Maximum number of emails to return (default 10, max 50). + offset: Number of emails to skip for pagination. + include_body: If True, fetch full body for each email (slower). + unread_only: If True, only return unread emails. + ids_only: If True, return only item IDs and dates (compact, for bulk ops). + Max limit raised to 500 in this mode. + """ + try: + client = _get_client(ctx) - # Clamp limit (higher cap for ids_only) - max_limit = 500 if ids_only else 50 - if limit > max_limit: - limit = max_limit + # Clamp limit (higher cap for ids_only) + max_limit = 500 if ids_only else 50 + if limit > max_limit: + limit = max_limit - # Resolve folder name to ID - folder_id = client.get_folder_id(folder) - if not folder_id: - return json.dumps({"error": f"Folder '{folder}' not found."}) + # Resolve folder name to ID + folder_id = client.get_folder_id(folder) + if not folder_id: + return json.dumps({"error": f"Folder '{folder}' not found."}) - # Build FindItem payload - if ids_only: - item_shape = { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "IdOnly", - "AdditionalProperties": [ - { - "__type": "PropertyUri:#Exchange", - "FieldURI": "DateTimeReceived", - }, - { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Subject", - }, - ], - } - else: - item_shape = { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - } + # Build FindItem payload + if ids_only: + item_shape = { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + "AdditionalProperties": [ + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "DateTimeReceived", + }, + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Subject", + }, + ], + } + else: + item_shape = { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + } - find_body = { - "__type": "FindItemRequest:#Exchange", - "ItemShape": item_shape, - "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "Traversal": "Shallow", - "Paging": { - "__type": "IndexedPageView:#Exchange", - "BasePoint": "Beginning", - "Offset": offset, - "MaxEntriesReturned": limit, - }, - "SortOrder": [ - { - "__type": "SortResults:#Exchange", - "Order": "Descending", - "Path": { - "__type": "PropertyUri:#Exchange", - "FieldURI": "DateTimeReceived", - }, - } - ], + find_body = { + "__type": "FindItemRequest:#Exchange", + "ItemShape": item_shape, + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": limit, + }, + "SortOrder": [ + { + "__type": "SortResults:#Exchange", + "Order": "Descending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "DateTimeReceived", + }, } + ], + } - # Add filter for unread only - if unread_only: - find_body["Restriction"] = { - "__type": "RestrictionType:#Exchange", - "Item": { - "__type": "IsEqualTo:#Exchange", - "FieldURIOrConstant": { - "__type": "FieldURIOrConstantType:#Exchange", - "Item": { - "__type": "ConstantValueType:#Exchange", - "Value": "false", - }, - }, - "Path": { - "__type": "PropertyUri:#Exchange", - "FieldURI": "IsRead", - }, - }, - } - - payload = { - "__type": "FindItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", + # Add filter for unread only + if unread_only: + find_body["Restriction"] = { + "__type": "RestrictionType:#Exchange", + "Item": { + "__type": "IsEqualTo:#Exchange", + "FieldURIOrConstant": { + "__type": "FieldURIOrConstantType:#Exchange", + "Item": { + "__type": "ConstantValueType:#Exchange", + "Value": "false", }, - "Body": find_body, - } + }, + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "IsRead", + }, + }, + } - data = client.request("FindItem", payload) + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": find_body, + } - items = [] - for msg in client.extract_items(data): - if "RootFolder" in msg and "Items" in msg["RootFolder"]: - items = msg["RootFolder"]["Items"] - break + data = client.request("FindItem", payload) - if not items: - return json.dumps( - {"item_ids": [], "count": 0} if ids_only else {"emails": [], "count": 0} - ) + items = [] + for msg in client.extract_items(data): + if "RootFolder" in msg and "Items" in msg["RootFolder"]: + items = msg["RootFolder"]["Items"] + break - if ids_only: - result = [] - for item in items: - result.append( - { - "item_id": item.get("ItemId", {}).get("Id", ""), - "date": item.get("DateTimeReceived", ""), - "subject": item.get("Subject", ""), - } - ) - return json.dumps({"item_ids": result, "count": len(result)}) + if not items: + return json.dumps( + {"item_ids": [], "count": 0} if ids_only else {"emails": [], "count": 0} + ) - emails = [] - for item in items: - email = _extract_email_summary(item) + if ids_only: + result = [] + for item in items: + result.append( + { + "item_id": item.get("ItemId", {}).get("Id", ""), + "date": item.get("DateTimeReceived", ""), + "subject": item.get("Subject", ""), + } + ) + return json.dumps({"item_ids": result, "count": len(result)}) - if include_body and email["item_id"]: - details = _get_item_details(client, email["item_id"]) - email["to"] = details["to"] - email["cc"] = details["cc"] - email["body"] = details["body"] - email["has_links"] = details.get("has_links", False) + emails = [] + for item in items: + email = _extract_email_summary(item) - if email.get("is_meeting"): - email["location"] = details.get("location", "") - email["start"] = details.get("start", "") or email.get("start", "") - email["end"] = details.get("end", "") or email.get("end", "") - email["required_attendees"] = details.get("required_attendees", []) - email["optional_attendees"] = details.get("optional_attendees", []) + if include_body and email["item_id"]: + details = _get_item_details(client, email["item_id"]) + email["to"] = details["to"] + email["cc"] = details["cc"] + email["body"] = details["body"] + email["has_links"] = details.get("has_links", False) - emails.append(email) + if email.get("is_meeting"): + email["location"] = details.get("location", "") + email["start"] = details.get("start", "") or email.get("start", "") + email["end"] = details.get("end", "") or email.get("end", "") + email["required_attendees"] = details.get("required_attendees", []) + email["optional_attendees"] = details.get("optional_attendees", []) - return json.dumps({"emails": emails, "count": len(emails)}) + emails.append(email) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to get emails: {e}"}) + return json.dumps({"emails": emails, "count": len(emails)}) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to get emails: {e}"}) @mcp.tool() def get_email(item_id: str, ctx: Context = None) -> str: - """Get a single email with full body and details. + """Get a single email with full body and details. - Args: - item_id: The Exchange ItemId of the email to retrieve. - """ - try: - client = _get_client(ctx) - result = _get_item_details(client, item_id) - return json.dumps(result) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to get email: {e}"}) + Args: + item_id: The Exchange ItemId of the email to retrieve. + """ + try: + client = _get_client(ctx) + result = _get_item_details(client, item_id) + return json.dumps(result) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to get email: {e}"}) @mcp.tool() def send_email( - to: str, - subject: str, - body: str, - cc: str = "", - bcc: str = "", - importance: str = "Normal", - is_html: bool = False, - ctx: Context = None, + to: str, + subject: str, + body: str, + cc: str = "", + bcc: str = "", + importance: str = "Normal", + is_html: bool = False, + ctx: Context = None, ) -> str: - """Send a new email. + """Send a new email. - Args: - to: Comma-separated list of recipient email addresses. - subject: Email subject line. - body: Email body text. - cc: Comma-separated CC recipients (optional). - bcc: Comma-separated BCC recipients (optional). - importance: Email importance: Low, Normal, or High (default Normal). - is_html: If True, body is treated as HTML. Otherwise plain text. - """ - try: - client = _get_client(ctx) + Args: + to: Comma-separated list of recipient email addresses. + subject: Email subject line. + body: Email body text. + cc: Comma-separated CC recipients (optional). + bcc: Comma-separated BCC recipients (optional). + importance: Email importance: Low, Normal, or High (default Normal). + is_html: If True, body is treated as HTML. Otherwise plain text. + """ + try: + client = _get_client(ctx) - to_recipients = _build_recipient_list(to) - if not to_recipients: - return json.dumps({"error": "At least one recipient is required."}) + to_recipients = _build_recipient_list(to) + if not to_recipients: + return json.dumps({"error": "At least one recipient is required."}) - message = { - "__type": "Message:#Exchange", - "Subject": subject, - "Body": { - "__type": "BodyContentType:#Exchange", - "BodyType": "HTML" if is_html else "Text", - "Value": body, - }, - "Importance": importance, - "ToRecipients": to_recipients, - } + message = { + "__type": "Message:#Exchange", + "Subject": subject, + "Body": { + "__type": "BodyContentType:#Exchange", + "BodyType": "HTML" if is_html else "Text", + "Value": body, + }, + "Importance": importance, + "ToRecipients": to_recipients, + } - if cc: - message["CcRecipients"] = _build_recipient_list(cc) - if bcc: - message["BccRecipients"] = _build_recipient_list(bcc) + if cc: + message["CcRecipients"] = _build_recipient_list(cc) + if bcc: + message["BccRecipients"] = _build_recipient_list(bcc) - payload = { - "__type": "CreateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "CreateItemRequest:#Exchange", - "Items": [message], - "MessageDisposition": "SendAndSaveCopy", - }, - } + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [message], + "MessageDisposition": "SendAndSaveCopy", + }, + } - data = client.request("CreateItem", payload) - - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Success": - return json.dumps({"success": True, "message": "Email sent."}) - elif msg.get("ResponseClass") == "Error": - return json.dumps( - {"error": msg.get("MessageText", "Failed to send email.")} - ) + data = client.request("CreateItem", payload) + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Success": return json.dumps({"success": True, "message": "Email sent."}) + elif msg.get("ResponseClass") == "Error": + return json.dumps( + {"error": msg.get("MessageText", "Failed to send email.")} + ) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to send email: {e}"}) + return json.dumps({"success": True, "message": "Email sent."}) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to send email: {e}"}) @mcp.tool() def reply_email( - item_id: str, - body: str, - reply_all: bool = False, - ctx: Context = None, + item_id: str, + body: str, + reply_all: bool = False, + ctx: Context = None, ) -> str: - """Reply to an email. + """Reply to an email. - Args: - item_id: The Exchange ItemId of the email to reply to. - body: Reply body text. - reply_all: If True, reply to all recipients. Otherwise reply to sender only. - """ - try: - client = _get_client(ctx) + Args: + item_id: The Exchange ItemId of the email to reply to. + body: Reply body text. + reply_all: If True, reply to all recipients. Otherwise reply to sender only. + """ + try: + client = _get_client(ctx) - change_key = _get_change_key(client, item_id) - if not change_key: - return json.dumps({"error": "Could not resolve item ChangeKey."}) + change_key = _get_change_key(client, item_id) + if not change_key: + return json.dumps({"error": "Could not resolve item ChangeKey."}) - item_type = "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange" + item_type = ( + "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange" + ) - reply_item = { - "__type": item_type, - "ReferenceItemId": { - "__type": "ItemId:#Exchange", - "Id": item_id, - "ChangeKey": change_key, - }, - "NewBodyContent": { - "__type": "BodyContentType:#Exchange", - "BodyType": "Text", - "Value": body, - }, - } + reply_item = { + "__type": item_type, + "ReferenceItemId": { + "__type": "ItemId:#Exchange", + "Id": item_id, + "ChangeKey": change_key, + }, + "NewBodyContent": { + "__type": "BodyContentType:#Exchange", + "BodyType": "Text", + "Value": body, + }, + } - payload = { - "__type": "CreateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "CreateItemRequest:#Exchange", - "Items": [reply_item], - "MessageDisposition": "SendAndSaveCopy", - }, - } + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [reply_item], + "MessageDisposition": "SendAndSaveCopy", + }, + } - data = client.request("CreateItem", payload) + data = client.request("CreateItem", payload) - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Error": - return json.dumps( - {"error": msg.get("MessageText", "Failed to send reply.")} - ) + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + return json.dumps( + {"error": msg.get("MessageText", "Failed to send reply.")} + ) - return json.dumps({"success": True, "message": "Reply sent."}) + return json.dumps({"success": True, "message": "Reply sent."}) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to reply: {e}"}) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to reply: {e}"}) @mcp.tool() def forward_email( - item_id: str, - to: str, - body: str = "", - ctx: Context = None, + item_id: str, + to: str, + body: str = "", + ctx: Context = None, ) -> str: - """Forward an email to other recipients. + """Forward an email to other recipients. - Args: - item_id: The Exchange ItemId of the email to forward. - to: Comma-separated list of recipient email addresses. - body: Optional message to include above the forwarded content. - """ - try: - client = _get_client(ctx) + Args: + item_id: The Exchange ItemId of the email to forward. + to: Comma-separated list of recipient email addresses. + body: Optional message to include above the forwarded content. + """ + try: + client = _get_client(ctx) - to_recipients = _build_recipient_list(to) - if not to_recipients: - return json.dumps({"error": "At least one recipient is required."}) + to_recipients = _build_recipient_list(to) + if not to_recipients: + return json.dumps({"error": "At least one recipient is required."}) - change_key = _get_change_key(client, item_id) - if not change_key: - return json.dumps({"error": "Could not resolve item ChangeKey."}) + change_key = _get_change_key(client, item_id) + if not change_key: + return json.dumps({"error": "Could not resolve item ChangeKey."}) - forward_item = { - "__type": "ForwardItem:#Exchange", - "ReferenceItemId": { - "__type": "ItemId:#Exchange", - "Id": item_id, - "ChangeKey": change_key, - }, - "ToRecipients": to_recipients, - } + forward_item = { + "__type": "ForwardItem:#Exchange", + "ReferenceItemId": { + "__type": "ItemId:#Exchange", + "Id": item_id, + "ChangeKey": change_key, + }, + "ToRecipients": to_recipients, + } - if body: - forward_item["NewBodyContent"] = { - "__type": "BodyContentType:#Exchange", - "BodyType": "Text", - "Value": body, - } + if body: + forward_item["NewBodyContent"] = { + "__type": "BodyContentType:#Exchange", + "BodyType": "Text", + "Value": body, + } - payload = { - "__type": "CreateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "CreateItemRequest:#Exchange", - "Items": [forward_item], - "MessageDisposition": "SendAndSaveCopy", - }, - } + payload = { + "__type": "CreateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "CreateItemRequest:#Exchange", + "Items": [forward_item], + "MessageDisposition": "SendAndSaveCopy", + }, + } - data = client.request("CreateItem", payload) + data = client.request("CreateItem", payload) - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Error": - return json.dumps( - {"error": msg.get("MessageText", "Failed to forward.")} - ) + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + return json.dumps( + {"error": msg.get("MessageText", "Failed to forward.")} + ) - return json.dumps({"success": True, "message": "Email forwarded."}) + return json.dumps({"success": True, "message": "Email forwarded."}) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to forward email: {e}"}) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to forward email: {e}"}) @mcp.tool() def mark_email_read( - item_ids: list[str], - is_read: bool = True, - ctx: Context = None, + item_ids: list[str], + is_read: bool = True, + ctx: Context = None, ) -> str: - """Mark one or more emails as read or unread. + """Mark one or more emails as read or unread. - Args: - item_ids: List of Exchange ItemIds to update. - is_read: True to mark as read, False to mark as unread (default True). - """ - try: - client = _get_client(ctx) + Args: + item_ids: List of Exchange ItemIds to update. + is_read: True to mark as read, False to mark as unread (default True). + """ + try: + client = _get_client(ctx) - changes = [] - for iid in item_ids: - change_key = _get_change_key(client, iid) - item_id_dict = {"__type": "ItemId:#Exchange", "Id": iid} - if change_key: - item_id_dict["ChangeKey"] = change_key - changes.append( - { - "__type": "ItemChange:#Exchange", - "ItemId": item_id_dict, - "Updates": [ - { - "__type": "SetItemField:#Exchange", - "Path": { - "__type": "PropertyUri:#Exchange", - "FieldURI": "IsRead", - }, - "Item": { - "__type": "Message:#Exchange", - "IsRead": is_read, - }, - } - ], - } - ) - - payload = { - "__type": "UpdateItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "UpdateItemRequest:#Exchange", - "ItemChanges": changes, - "ConflictResolution": "AutoResolve", - "MessageDisposition": "SaveOnly", - }, - } - - data = client.request("UpdateItem", payload) - - errors = [] - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Error": - errors.append(msg.get("MessageText", "Unknown error")) - - if errors: - return json.dumps({"error": "; ".join(errors)}) - - status = "read" if is_read else "unread" - return json.dumps( + changes = [] + for iid in item_ids: + change_key = _get_change_key(client, iid) + item_id_dict = {"__type": "ItemId:#Exchange", "Id": iid} + if change_key: + item_id_dict["ChangeKey"] = change_key + changes.append( + { + "__type": "ItemChange:#Exchange", + "ItemId": item_id_dict, + "Updates": [ { - "success": True, - "message": f"Marked {len(item_ids)} email(s) as {status}.", + "__type": "SetItemField:#Exchange", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "IsRead", + }, + "Item": { + "__type": "Message:#Exchange", + "IsRead": is_read, + }, } - ) + ], + } + ) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to update emails: {e}"}) + payload = { + "__type": "UpdateItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "UpdateItemRequest:#Exchange", + "ItemChanges": changes, + "ConflictResolution": "AutoResolve", + "MessageDisposition": "SaveOnly", + }, + } + + data = client.request("UpdateItem", payload) + + errors = [] + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + errors.append(msg.get("MessageText", "Unknown error")) + + if errors: + return json.dumps({"error": "; ".join(errors)}) + + status = "read" if is_read else "unread" + return json.dumps( + { + "success": True, + "message": f"Marked {len(item_ids)} email(s) as {status}.", + } + ) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to update emails: {e}"}) @mcp.tool() def move_email( - item_ids: list[str], - target_folder: str, - ctx: Context = None, + item_ids: list[str], + target_folder: str, + ctx: Context = None, ) -> str: - """Move one or more emails to a different folder. + """Move one or more emails to a different folder. - Args: - item_ids: List of Exchange ItemIds to move. - target_folder: Destination folder name (e.g. Inbox, Sent, Deleted, or custom). - """ - try: - client = _get_client(ctx) + Args: + item_ids: List of Exchange ItemIds to move. + target_folder: Destination folder name (e.g. Inbox, Sent, Deleted, or custom). + """ + try: + client = _get_client(ctx) - folder_id = client.get_folder_id(target_folder) - if not folder_id: - return json.dumps({"error": f"Folder '{target_folder}' not found."}) + folder_id = client.get_folder_id(target_folder) + if not folder_id: + return json.dumps({"error": f"Folder '{target_folder}' not found."}) - items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] + items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] - payload = { - "__type": "MoveItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "MoveItemRequest:#Exchange", - "ItemIds": items, - "ToFolderId": { - "__type": "TargetFolderId:#Exchange", - "BaseFolderId": { - "__type": "FolderId:#Exchange", - "Id": folder_id, - }, - }, - }, - } + payload = { + "__type": "MoveItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "MoveItemRequest:#Exchange", + "ItemIds": items, + "ToFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": { + "__type": "FolderId:#Exchange", + "Id": folder_id, + }, + }, + }, + } - data = client.request("MoveItem", payload) + data = client.request("MoveItem", payload) - errors = [] - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Error": - errors.append(msg.get("MessageText", "Unknown error")) + errors = [] + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + errors.append(msg.get("MessageText", "Unknown error")) - if errors: - return json.dumps({"error": "; ".join(errors)}) + if errors: + return json.dumps({"error": "; ".join(errors)}) - return json.dumps( - { - "success": True, - "message": f"Moved {len(item_ids)} email(s) to '{target_folder}'.", - } - ) + return json.dumps( + { + "success": True, + "message": f"Moved {len(item_ids)} email(s) to '{target_folder}'.", + } + ) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to move emails: {e}"}) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to move emails: {e}"}) @mcp.tool() def delete_email( - item_ids: list[str], - permanent: bool = False, - ctx: Context = None, + item_ids: list[str], + permanent: bool = False, + ctx: Context = None, ) -> str: - """Delete one or more emails. + """Delete one or more emails. - Args: - item_ids: List of Exchange ItemIds to delete. - permanent: If True, permanently delete (HardDelete). Otherwise move to Deleted Items. - """ - try: - client = _get_client(ctx) + Args: + item_ids: List of Exchange ItemIds to delete. + permanent: If True, permanently delete (HardDelete). Otherwise move to Deleted Items. + """ + try: + client = _get_client(ctx) - items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] + items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] - payload = { - "__type": "DeleteItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "DeleteItemRequest:#Exchange", - "ItemIds": items, - "DeleteType": "HardDelete" if permanent else "MoveToDeletedItems", - }, - } + payload = { + "__type": "DeleteItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "DeleteItemRequest:#Exchange", + "ItemIds": items, + "DeleteType": "HardDelete" if permanent else "MoveToDeletedItems", + }, + } - data = client.request("DeleteItem", payload) + data = client.request("DeleteItem", payload) - errors = [] - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Error": - errors.append(msg.get("MessageText", "Unknown error")) + errors = [] + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Error": + errors.append(msg.get("MessageText", "Unknown error")) - if errors: - return json.dumps({"error": "; ".join(errors)}) + if errors: + return json.dumps({"error": "; ".join(errors)}) - action = "permanently deleted" if permanent else "moved to Deleted Items" - return json.dumps( - { - "success": True, - "message": f"{len(item_ids)} email(s) {action}.", - } - ) + action = "permanently deleted" if permanent else "moved to Deleted Items" + return json.dumps( + { + "success": True, + "message": f"{len(item_ids)} email(s) {action}.", + } + ) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to delete emails: {e}"}) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to delete emails: {e}"}) @mcp.tool() def download_attachments( - item_id: str, - target_folder: str = "/tmp/attachments", - ctx: Context = None, + item_id: str, + target_folder: str = "/tmp/attachments", + ctx: Context = None, ) -> str: - """Download all file attachments from an email to disk. + """Download all file attachments from an email to disk. - Args: - item_id: The Exchange ItemId of the email to download attachments from. - target_folder: Local directory to save files (default /tmp/attachments). - """ - import os + Args: + item_id: The Exchange ItemId of the email to download attachments from. + target_folder: Local directory to save files (default /tmp/attachments). + """ + import os - try: - client = _get_client(ctx) + try: + client = _get_client(ctx) - # Get email details to find attachment IDs - details = _get_item_details(client, item_id) - attachments = details.get("attachments", []) + # Get email details to find attachment IDs + details = _get_item_details(client, item_id) + attachments = details.get("attachments", []) - if not attachments: - return json.dumps( - { - "success": True, - "downloaded": [], - "count": 0, - "message": "No attachments found.", - } - ) - - # Filter to non-inline file attachments with IDs - file_attachments = [ - a - for a in attachments - if a.get("attachment_id") and not a.get("is_inline", False) - ] - - if not file_attachments: - return json.dumps( - { - "success": True, - "downloaded": [], - "count": 0, - "message": "No downloadable file attachments.", - } - ) - - os.makedirs(target_folder, exist_ok=True) - - downloaded = [] - errors = [] - used_names: set[str] = set() - - for att in file_attachments: - try: - content, filename, content_type = client.download_file( - att["attachment_id"] - ) - - # Sanitize filename - filename = os.path.basename(filename) - if not filename: - filename = att.get("name", "attachment") or "attachment" - - # Handle collisions - base_name = filename - name_part, _, ext_part = base_name.rpartition(".") - if not name_part: - name_part = base_name - ext_part = "" - - counter = 1 - while filename.lower() in used_names: - if ext_part: - filename = f"{name_part}_{counter}.{ext_part}" - else: - filename = f"{name_part}_{counter}" - counter += 1 - - used_names.add(filename.lower()) - - filepath = os.path.join(target_folder, filename) - with open(filepath, "wb") as f: - f.write(content) - - downloaded.append( - { - "name": filename, - "path": filepath, - "size": len(content), - "content_type": content_type, - } - ) - except Exception as e: - errors.append( - { - "name": att.get("name", "unknown"), - "error": str(e), - } - ) - - result = { - "success": len(errors) == 0, - "downloaded": downloaded, - "count": len(downloaded), + if not attachments: + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No attachments found.", } - if errors: - result["errors"] = errors + ) - return json.dumps(result) + # Filter to non-inline file attachments with IDs + file_attachments = [ + a + for a in attachments + if a.get("attachment_id") and not a.get("is_inline", False) + ] - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to download attachments: {e}"}) + if not file_attachments: + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No downloadable file attachments.", + } + ) + + os.makedirs(target_folder, exist_ok=True) + + downloaded = [] + errors = [] + used_names: set[str] = set() + + for att in file_attachments: + try: + content, filename, content_type = client.download_file( + att["attachment_id"] + ) + + # Sanitize filename + filename = os.path.basename(filename) + if not filename: + filename = att.get("name", "attachment") or "attachment" + + # Handle collisions + base_name = filename + name_part, _, ext_part = base_name.rpartition(".") + if not name_part: + name_part = base_name + ext_part = "" + + counter = 1 + while filename.lower() in used_names: + if ext_part: + filename = f"{name_part}_{counter}.{ext_part}" + else: + filename = f"{name_part}_{counter}" + counter += 1 + + used_names.add(filename.lower()) + + filepath = os.path.join(target_folder, filename) + with open(filepath, "wb") as f: + f.write(content) + + downloaded.append( + { + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + } + ) + except Exception as e: + errors.append( + { + "name": att.get("name", "unknown"), + "error": str(e), + } + ) + + result = { + "success": len(errors) == 0, + "downloaded": downloaded, + "count": len(downloaded), + } + if errors: + result["errors"] = errors + + return json.dumps(result) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to download attachments: {e}"}) @mcp.tool() def get_email_links( - item_id: str, - ctx: Context = None, + item_id: str, + ctx: Context = None, ) -> str: - """Extract all hyperlinks from an email's HTML body. + """Extract all hyperlinks from an email's HTML body. - Args: - item_id: The Exchange ItemId of the email to extract links from. - """ - try: - client = _get_client(ctx) + Args: + item_id: The Exchange ItemId of the email to extract links from. + """ + try: + client = _get_client(ctx) - # Fetch email with HTML body - payload = { - "__type": "GetItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "V2017_08_18", - }, - "Body": { - "__type": "GetItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "IdOnly", - "BodyType": "HTML", - "AdditionalProperties": [ - { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Subject", - }, - { - "__type": "PropertyUri:#Exchange", - "FieldURI": "Body", - }, - ], - }, - "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], - }, - } - - data = client.request("GetItem", payload) - - subject = "" - links = [] - - for msg in client.extract_items(data): - if "Items" not in msg: - continue - for item in msg["Items"]: - subject = item.get("Subject", "") - body_val = item.get("Body", {}).get("Value", "") - links = extract_links_from_html(body_val) - break - - return json.dumps( + # Fetch email with HTML body + payload = { + "__type": "GetItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "V2017_08_18", + }, + "Body": { + "__type": "GetItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "IdOnly", + "BodyType": "HTML", + "AdditionalProperties": [ { - "item_id": item_id, - "subject": subject, - "links": links, - "count": len(links), - } - ) + "__type": "PropertyUri:#Exchange", + "FieldURI": "Subject", + }, + { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Body", + }, + ], + }, + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], + }, + } - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Failed to extract links: {e}"}) + data = client.request("GetItem", payload) + + subject = "" + links = [] + + for msg in client.extract_items(data): + if "Items" not in msg: + continue + for item in msg["Items"]: + subject = item.get("Subject", "") + body_val = item.get("Body", {}).get("Value", "") + links = extract_links_from_html(body_val) + break + + return json.dumps( + { + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + } + ) + + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Failed to extract links: {e}"}) # ------------------------------------------------------------------ @@ -1058,148 +1060,147 @@ def get_email_links( # ------------------------------------------------------------------ _FIELD_URI = { - "subject": "item:Subject", - "body": "item:Body", - "from": "message:From", + "subject": "item:Subject", + "body": "item:Body", + "from": "message:From", } def _contains_expression(field_uri: str, value: str) -> dict: - return { - "__type": "ContainsExpression:#Exchange", - "ContainmentMode": "Substring", - "ContainmentComparison": "IgnoreCase", - "Path": { - "__type": "PathToUnindexedField:#Exchange", - "FieldURI": field_uri, - }, - "Constant": { - "__type": "ConstantValue:#Exchange", - "Value": value, - }, - } + return { + "__type": "ContainsExpression:#Exchange", + "ContainmentMode": "Substring", + "ContainmentComparison": "IgnoreCase", + "Path": { + "__type": "PathToUnindexedField:#Exchange", + "FieldURI": field_uri, + }, + "Constant": { + "__type": "ConstantValue:#Exchange", + "Value": value, + }, + } def _build_search_restriction(query: str, scope: str) -> dict | None: - if scope == "all": - return { - "__type": "Or:#Exchange", - "Items": [ - _contains_expression("item:Subject", query), - _contains_expression("item:Body", query), - ], - } - field_uri = _FIELD_URI.get(scope) - if not field_uri: - return None - return _contains_expression(field_uri, query) + if scope == "all": + return { + "__type": "Or:#Exchange", + "Items": [ + _contains_expression("item:Subject", query), + _contains_expression("item:Body", query), + ], + } + field_uri = _FIELD_URI.get(scope) + if not field_uri: + return None + return _contains_expression(field_uri, query) @mcp.tool( - name="search_emails", - description=( - "Search emails by text across one or all folders. " - "Supports searching by subject, body, sender, or all fields combined." - ), + name="search_emails", + description=( + "Search emails by text across one or all folders. Supports searching by subject, body, sender, or all fields combined." + ), ) async def search_emails( - ctx: Context, - query: str, - folder_id: str | None = None, - max_results: int = 20, - search_scope: str = "all", + ctx: Context, + query: str, + folder_id: str | None = None, + max_results: int = 20, + search_scope: str = "all", ) -> str: - """Search emails by text content. + """Search emails by text content. - Args: - query: The text to search for. - folder_id: Optional folder ID to limit the search. When omitted, - searches across all mail folders (deep traversal). - max_results: Maximum number of results to return (default 20, max 100). - search_scope: Where to search — ``"all"`` (subject + body, default), - ``"subject"``, ``"body"``, or ``"from"``. - """ - client = _get_client(ctx) + Args: + query: The text to search for. + folder_id: Optional folder ID to limit the search. When omitted, + searches across all mail folders (deep traversal). + max_results: Maximum number of results to return (default 20, max 100). + search_scope: Where to search — ``"all"`` (subject + body, default), + ``"subject"``, ``"body"``, or ``"from"``. + """ + client = _get_client(ctx) - if not query.strip(): - return json.dumps({"error": "query must not be empty", "results": []}) + if not query.strip(): + return json.dumps({"error": "query must not be empty", "results": []}) - max_results = max(1, min(max_results, 100)) - if search_scope not in ("all", "subject", "body", "from"): - return json.dumps( - {"error": f"invalid search_scope: {search_scope}", "results": []} - ) + max_results = max(1, min(max_results, 100)) + if search_scope not in ("all", "subject", "body", "from"): + return json.dumps( + {"error": f"invalid search_scope: {search_scope}", "results": []} + ) - restriction = _build_search_restriction(query.strip(), search_scope) - if restriction is None: - return json.dumps( - {"error": f"unsupported search_scope: {search_scope}", "results": []} - ) + restriction = _build_search_restriction(query.strip(), search_scope) + if restriction is None: + return json.dumps( + {"error": f"unsupported search_scope: {search_scope}", "results": []} + ) - if folder_id: - parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}] - else: - parent_folder = [ - {"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"} - ] + if folder_id: + parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}] + else: + parent_folder = [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"} + ] - payload = { - "__type": "FindItemJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "FindItemRequest:#Exchange", - "ItemShape": { - "__type": "ItemResponseShape:#Exchange", - "BaseShape": "AllProperties", - "BodyType": "HTML", - }, - "ParentFolderIds": parent_folder, - "Restriction": restriction, - "Traversal": "Shallow" if folder_id else "Deep", - }, - } + payload = { + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", + "BodyType": "HTML", + }, + "ParentFolderIds": parent_folder, + "Restriction": restriction, + "Traversal": "Shallow" if folder_id else "Deep", + }, + } - try: - data = client.request("FindItem", payload) - results = [] - for msg in client.extract_items(data): - items = msg.get("RootFolder", {}).get("Items", []) - if not items: - items = msg.get("Items", []) - for item in items: - if len(results) >= max_results: - break - summary = _extract_email_summary(item) - # add body_preview - body_html = item.get("Body", {}).get("_", "") - body_type = item.get("Body", {}).get("BodyType", "HTML") - if body_type == "HTML" and body_html: - import re + try: + data = client.request("FindItem", payload) + results = [] + for msg in client.extract_items(data): + items = msg.get("RootFolder", {}).get("Items", []) + if not items: + items = msg.get("Items", []) + for item in items: + if len(results) >= max_results: + break + summary = _extract_email_summary(item) + # add body_preview + body_html = item.get("Body", {}).get("_", "") + body_type = item.get("Body", {}).get("BodyType", "HTML") + if body_type == "HTML" and body_html: + import re - body_preview = re.sub(r"<[^>]+>", " ", body_html) - body_preview = " ".join(body_preview.split())[:200] - else: - body_preview = body_html[:200] - summary["body_preview"] = body_preview - summary["folder_id"] = item.get("ParentFolderId", {}).get("Id", "") - results.append(summary) - if len(results) >= max_results: - break + body_preview = re.sub(r"<[^>]+>", " ", body_html) + body_preview = " ".join(body_preview.split())[:200] + else: + body_preview = body_html[:200] + summary["body_preview"] = body_preview + summary["folder_id"] = item.get("ParentFolderId", {}).get("Id", "") + results.append(summary) + if len(results) >= max_results: + break - return json.dumps( - { - "query": query.strip(), - "search_scope": search_scope, - "folder_id": folder_id or "all", - "total_results": len(results), - "results": results, - }, - ensure_ascii=False, - ) - except SessionExpiredError as e: - return json.dumps({"error": str(e)}) - except Exception as e: - return json.dumps({"error": f"Search failed: {e}"}) + return json.dumps( + { + "query": query.strip(), + "search_scope": search_scope, + "folder_id": folder_id or "all", + "total_results": len(results), + "results": results, + }, + ensure_ascii=False, + ) + except SessionExpiredError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + return json.dumps({"error": f"Search failed: {e}"}) diff --git a/owa_mcp/tools/folders.py b/owa_mcp/tools/folders.py index 9ce08a5..f3e93b6 100644 --- a/owa_mcp/tools/folders.py +++ b/owa_mcp/tools/folders.py @@ -12,435 +12,437 @@ from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client _DISTINGUISHED_NAMES = { - "msgfolderroot", - "inbox", - "sentitems", - "drafts", - "deleteditems", - "junkemail", - "outbox", - "calendar", - "contacts", - "tasks", - "notes", - "journal", - "searchfolders", + "msgfolderroot", + "inbox", + "sentitems", + "drafts", + "deleteditems", + "junkemail", + "outbox", + "calendar", + "contacts", + "tasks", + "notes", + "journal", + "searchfolders", } _HEADER_TZ = { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - "TimeZoneContext": { - "__type": "TimeZoneContext:#Exchange", - "TimeZoneDefinition": { - "__type": "TimeZoneDefinitionType:#Exchange", - "Id": "Russian Standard Time", - }, + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, + }, } def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - app_ctx: AppContext = ctx.request_context.lifespan_context - return require_client(app_ctx.client) + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) def _folder_id_dict(folder_id: str) -> dict: - """Build a typed FolderId or DistinguishedFolderId dict.""" - if folder_id.lower() in _DISTINGUISHED_NAMES: - return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id} - return {"__type": "FolderId:#Exchange", "Id": folder_id} + """Build a typed FolderId or DistinguishedFolderId dict.""" + if folder_id.lower() in _DISTINGUISHED_NAMES: + return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id} + return {"__type": "FolderId:#Exchange", "Id": folder_id} @mcp.tool() def check_session(ctx: Context = None) -> str: - """Check whether the current OWA session is authenticated. + """Check whether the current OWA session is authenticated. - Makes a lightweight GetFolder call on the inbox. Returns session - status, the mailbox display name, and cookie file path. + Makes a lightweight GetFolder call on the inbox. Returns session + status, the mailbox display name, and cookie file path. - Returns: - JSON object with authenticated (bool), mailbox name, and details. - """ - client = _get_client(ctx) + Returns: + JSON object with authenticated (bool), mailbox name, and details. + """ + client = _get_client(ctx) - payload = { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "Default", - }, - "FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}], - }, - } - - try: - data = client.request("GetFolder", payload) - except Exception as e: - return json.dumps( - { - "authenticated": False, - "error": str(e), - "cookie_file": str(client.cookie_file), - } - ) - - for msg in client.extract_items(data): - if "Folders" in msg: - folder = msg["Folders"][0] - return json.dumps( - { - "authenticated": True, - "mailbox": folder.get("DisplayName", ""), - "unread": folder.get("UnreadCount", 0), - "cookie_file": str(client.cookie_file), - } - ) + payload = { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "Default", + }, + "FolderIds": [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"} + ], + }, + } + try: + data = client.request("GetFolder", payload) + except Exception as e: return json.dumps( - { - "authenticated": False, - "error": "Unexpected response", - "cookie_file": str(client.cookie_file), - } + { + "authenticated": False, + "error": str(e), + "cookie_file": str(client.cookie_file), + } ) + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps( + { + "authenticated": True, + "mailbox": folder.get("DisplayName", ""), + "unread": folder.get("UnreadCount", 0), + "cookie_file": str(client.cookie_file), + } + ) + + return json.dumps( + { + "authenticated": False, + "error": "Unexpected response", + "cookie_file": str(client.cookie_file), + } + ) + @mcp.tool() def get_folders( - parent_folder_id: str = "msgfolderroot", - recursive: bool = False, - ctx: Context = None, + parent_folder_id: str = "msgfolderroot", + recursive: bool = False, + ctx: Context = None, ) -> str: - """List mail folders from the Exchange mailbox. + """List mail folders from the Exchange mailbox. - Args: - parent_folder_id: Parent folder to list children of. - Defaults to "msgfolderroot" (top-level). Can be a - distinguished folder name or a raw folder ID. - recursive: If True, traverse all subfolders recursively (Deep). - If False, only list immediate children (Shallow). + Args: + parent_folder_id: Parent folder to list children of. + Defaults to "msgfolderroot" (top-level). Can be a + distinguished folder name or a raw folder ID. + recursive: If True, traverse all subfolders recursively (Deep). + If False, only list immediate children (Shallow). - Returns: - JSON array of folder objects with: name, id, total_count, - unread_count, child_folder_count. - """ - client = _get_client(ctx) + Returns: + JSON array of folder objects with: name, id, total_count, + unread_count, child_folder_count. + """ + client = _get_client(ctx) - traversal = "Deep" if recursive else "Shallow" + traversal = "Deep" if recursive else "Shallow" - # Determine parent folder id type - # Distinguished folder names are short lowercase strings - parent_folder = _folder_id_dict(parent_folder_id) + # Determine parent folder id type + # Distinguished folder names are short lowercase strings + parent_folder = _folder_id_dict(parent_folder_id) - payload = { - "__type": "FindFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "FindFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "Default", - }, - "ParentFolderIds": [parent_folder], - "Traversal": traversal, - "Paging": { - "__type": "IndexedPageView:#Exchange", - "BasePoint": "Beginning", - "Offset": 0, - "MaxEntriesReturned": 200, - }, - }, - } + payload = { + "__type": "FindFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "FindFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "Default", + }, + "ParentFolderIds": [parent_folder], + "Traversal": traversal, + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": 0, + "MaxEntriesReturned": 200, + }, + }, + } - try: - data = client.request("FindFolder", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + data = client.request("FindFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) - folders = [] - for msg in client.extract_items(data): - if "RootFolder" in msg and "Folders" in msg["RootFolder"]: - for f in msg["RootFolder"]["Folders"]: - folders.append( - { - "name": f.get("DisplayName", "Unknown"), - "id": f.get("FolderId", {}).get("Id", ""), - "total_count": f.get("TotalCount", 0), - "unread_count": f.get("UnreadCount", 0), - "child_folder_count": f.get("ChildFolderCount", 0), - } - ) + folders = [] + for msg in client.extract_items(data): + if "RootFolder" in msg and "Folders" in msg["RootFolder"]: + for f in msg["RootFolder"]["Folders"]: + folders.append( + { + "name": f.get("DisplayName", "Unknown"), + "id": f.get("FolderId", {}).get("Id", ""), + "total_count": f.get("TotalCount", 0), + "unread_count": f.get("UnreadCount", 0), + "child_folder_count": f.get("ChildFolderCount", 0), + } + ) - return json.dumps(folders, ensure_ascii=False) + return json.dumps(folders, ensure_ascii=False) @mcp.tool() def create_folder( - name: str, - parent_folder_id: str = "msgfolderroot", - ctx: Context = None, + name: str, + parent_folder_id: str = "msgfolderroot", + ctx: Context = None, ) -> str: - """Create a new mail folder. + """Create a new mail folder. - Args: - name: Display name for the new folder. - parent_folder_id: Parent folder to create under. - Defaults to "msgfolderroot" (top-level). Can be a - distinguished folder name or a raw folder ID. + Args: + name: Display name for the new folder. + parent_folder_id: Parent folder to create under. + Defaults to "msgfolderroot" (top-level). Can be a + distinguished folder name or a raw folder ID. - Returns: - JSON object with the created folder's id and name. - """ - client = _get_client(ctx) + Returns: + JSON object with the created folder's id and name. + """ + client = _get_client(ctx) - payload = { - "__type": "CreateFolderJsonRequest:#Exchange", - "Header": _HEADER_TZ, - "Body": { - "__type": "CreateFolderRequest:#Exchange", - "ParentFolderId": { - "__type": "TargetFolderId:#Exchange", - "BaseFolderId": _folder_id_dict(parent_folder_id), - }, - "Folders": [ - { - "__type": "Folder:#Exchange", - "DisplayName": name, - "FolderClass": "IPF.Note", - } - ], - }, - } + payload = { + "__type": "CreateFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "CreateFolderRequest:#Exchange", + "ParentFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": _folder_id_dict(parent_folder_id), + }, + "Folders": [ + { + "__type": "Folder:#Exchange", + "DisplayName": name, + "FolderClass": "IPF.Note", + } + ], + }, + } - try: - data = client.request_header_payload("CreateFolder", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + data = client.request_header_payload("CreateFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) - for msg in client.extract_items(data): - if "Folders" in msg: - folder = msg["Folders"][0] - return json.dumps( - { - "success": True, - "name": name, - "id": folder.get("FolderId", {}).get("Id", ""), - } - ) + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps( + { + "success": True, + "name": name, + "id": folder.get("FolderId", {}).get("Id", ""), + } + ) - return json.dumps({"error": "Unexpected response", "raw": str(data)}) + return json.dumps({"error": "Unexpected response", "raw": str(data)}) @mcp.tool() def rename_folder( - folder_id: str, - new_name: str, - ctx: Context = None, + folder_id: str, + new_name: str, + ctx: Context = None, ) -> str: - """Rename an existing mail folder. + """Rename an existing mail folder. - Args: - folder_id: The Exchange folder ID to rename (from get_folders). - new_name: New display name for the folder. + Args: + folder_id: The Exchange folder ID to rename (from get_folders). + new_name: New display name for the folder. - Returns: - JSON object with success status and new folder id. - """ - client = _get_client(ctx) + Returns: + JSON object with success status and new folder id. + """ + client = _get_client(ctx) - payload = { - "__type": "UpdateFolderJsonRequest:#Exchange", - "Header": _HEADER_TZ, - "Body": { - "__type": "UpdateFolderRequest:#Exchange", - "FolderChanges": [ - { - "__type": "FolderChange:#Exchange", - "FolderId": { - "__type": "FolderId:#Exchange", - "Id": folder_id, - }, - "Updates": [ - { - "__type": "SetFolderField:#Exchange", - "Path": { - "__type": "PropertyUri:#Exchange", - "FieldURI": "FolderDisplayName", - }, - "Folder": { - "__type": "Folder:#Exchange", - "DisplayName": new_name, - }, - } - ], - } - ], - }, - } + payload = { + "__type": "UpdateFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "UpdateFolderRequest:#Exchange", + "FolderChanges": [ + { + "__type": "FolderChange:#Exchange", + "FolderId": { + "__type": "FolderId:#Exchange", + "Id": folder_id, + }, + "Updates": [ + { + "__type": "SetFolderField:#Exchange", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "FolderDisplayName", + }, + "Folder": { + "__type": "Folder:#Exchange", + "DisplayName": new_name, + }, + } + ], + } + ], + }, + } - try: - data = client.request_header_payload("UpdateFolder", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + data = client.request_header_payload("UpdateFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) - for msg in client.extract_items(data): - if "Folders" in msg: - folder = msg["Folders"][0] - return json.dumps( - { - "success": True, - "new_name": new_name, - "id": folder.get("FolderId", {}).get("Id", ""), - } - ) + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps( + { + "success": True, + "new_name": new_name, + "id": folder.get("FolderId", {}).get("Id", ""), + } + ) - return json.dumps({"error": "Unexpected response", "raw": str(data)}) + return json.dumps({"error": "Unexpected response", "raw": str(data)}) @mcp.tool() def empty_folder( - folder_id: str, - delete_sub_folders: bool = False, - permanent: bool = False, - ctx: Context = None, + folder_id: str, + delete_sub_folders: bool = False, + permanent: bool = False, + ctx: Context = None, ) -> str: - """Empty all items from a mail folder. + """Empty all items from a mail folder. - Args: - folder_id: The Exchange folder ID to empty (from get_folders). - delete_sub_folders: If True, also delete sub-folders. Default False. - permanent: If True, permanently delete items (HardDelete). - Otherwise move to Deleted Items. Default False. + Args: + folder_id: The Exchange folder ID to empty (from get_folders). + delete_sub_folders: If True, also delete sub-folders. Default False. + permanent: If True, permanently delete items (HardDelete). + Otherwise move to Deleted Items. Default False. - Returns: - JSON object with success status. - """ - client = _get_client(ctx) + Returns: + JSON object with success status. + """ + client = _get_client(ctx) - delete_type = "HardDelete" if permanent else "MoveToDeletedItems" + delete_type = "HardDelete" if permanent else "MoveToDeletedItems" - payload = { - "__type": "EmptyFolderJsonRequest:#Exchange", - "Header": _HEADER_TZ, - "Body": { - "__type": "EmptyFolderRequest:#Exchange", - "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "DeleteType": delete_type, - "DeleteSubFolders": delete_sub_folders, - "SuppressReadReceipt": True, - }, - } + payload = { + "__type": "EmptyFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "EmptyFolderRequest:#Exchange", + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "DeleteType": delete_type, + "DeleteSubFolders": delete_sub_folders, + "SuppressReadReceipt": True, + }, + } - try: - data = client.request_header_payload("EmptyFolder", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + data = client.request_header_payload("EmptyFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Success": - return json.dumps({"success": True, "folder_id": folder_id}) + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Success": + return json.dumps({"success": True, "folder_id": folder_id}) - return json.dumps({"error": "Unexpected response", "raw": str(data)}) + return json.dumps({"error": "Unexpected response", "raw": str(data)}) @mcp.tool() def delete_folder( - folder_id: str, - permanent: bool = False, - ctx: Context = None, + folder_id: str, + permanent: bool = False, + ctx: Context = None, ) -> str: - """Delete a mail folder. + """Delete a mail folder. - Args: - folder_id: The Exchange folder ID to delete (from get_folders). - permanent: If True, permanently delete (HardDelete). - Otherwise move to Deleted Items. Default False. + Args: + folder_id: The Exchange folder ID to delete (from get_folders). + permanent: If True, permanently delete (HardDelete). + Otherwise move to Deleted Items. Default False. - Returns: - JSON object with success status. - """ - client = _get_client(ctx) + Returns: + JSON object with success status. + """ + client = _get_client(ctx) - delete_type = "HardDelete" if permanent else "MoveToDeletedItems" + delete_type = "HardDelete" if permanent else "MoveToDeletedItems" - payload = { - "__type": "DeleteFolderJsonRequest:#Exchange", - "Header": _HEADER_TZ, - "Body": { - "__type": "DeleteFolderRequest:#Exchange", - "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "DeleteType": delete_type, - }, - } + payload = { + "__type": "DeleteFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "DeleteFolderRequest:#Exchange", + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "DeleteType": delete_type, + }, + } - try: - data = client.request_header_payload("DeleteFolder", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + data = client.request_header_payload("DeleteFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) - for msg in client.extract_items(data): - if msg.get("ResponseClass") == "Success": - return json.dumps({"success": True, "folder_id": folder_id}) + for msg in client.extract_items(data): + if msg.get("ResponseClass") == "Success": + return json.dumps({"success": True, "folder_id": folder_id}) - return json.dumps({"error": "Unexpected response", "raw": str(data)}) + return json.dumps({"error": "Unexpected response", "raw": str(data)}) @mcp.tool() def move_folder( - folder_id: str, - target_parent_folder_id: str = "msgfolderroot", - ctx: Context = None, + folder_id: str, + target_parent_folder_id: str = "msgfolderroot", + ctx: Context = None, ) -> str: - """Move a mail folder to a different parent folder. + """Move a mail folder to a different parent folder. - Args: - folder_id: The Exchange folder ID to move (from get_folders). - target_parent_folder_id: Destination parent folder. - Defaults to "msgfolderroot" (top-level). Can be a - distinguished folder name or a raw folder ID. + Args: + folder_id: The Exchange folder ID to move (from get_folders). + target_parent_folder_id: Destination parent folder. + Defaults to "msgfolderroot" (top-level). Can be a + distinguished folder name or a raw folder ID. - Returns: - JSON object with success status and new folder ID. - """ - client = _get_client(ctx) + Returns: + JSON object with success status and new folder ID. + """ + client = _get_client(ctx) - payload = { - "__type": "MoveFolderJsonRequest:#Exchange", - "Header": _HEADER_TZ, - "Body": { - "__type": "MoveFolderRequest:#Exchange", - "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], - "ToFolderId": { - "__type": "TargetFolderId:#Exchange", - "BaseFolderId": _folder_id_dict(target_parent_folder_id), - }, - }, - } + payload = { + "__type": "MoveFolderJsonRequest:#Exchange", + "Header": _HEADER_TZ, + "Body": { + "__type": "MoveFolderRequest:#Exchange", + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "ToFolderId": { + "__type": "TargetFolderId:#Exchange", + "BaseFolderId": _folder_id_dict(target_parent_folder_id), + }, + }, + } - try: - data = client.request_header_payload("MoveFolder", payload) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + data = client.request_header_payload("MoveFolder", payload) + except Exception as e: + return json.dumps({"error": str(e)}) - for msg in client.extract_items(data): - if "Folders" in msg: - folder = msg["Folders"][0] - return json.dumps( - { - "success": True, - "folder_id": folder.get("FolderId", {}).get("Id", ""), - } - ) + for msg in client.extract_items(data): + if "Folders" in msg: + folder = msg["Folders"][0] + return json.dumps( + { + "success": True, + "folder_id": folder.get("FolderId", {}).get("Id", ""), + } + ) - return json.dumps({"error": "Unexpected response", "raw": str(data)}) + return json.dumps({"error": "Unexpected response", "raw": str(data)}) diff --git a/owa_mcp/tools/people.py b/owa_mcp/tools/people.py index df6f449..f4220b1 100644 --- a/owa_mcp/tools/people.py +++ b/owa_mcp/tools/people.py @@ -13,109 +13,109 @@ from owa_mcp.server_lifecycle import require_client def _get_client(ctx: Context) -> OWAClient: - """Extract the OWAClient from the MCP lifespan context.""" - app_ctx: AppContext = ctx.request_context.lifespan_context - return require_client(app_ctx.client) + """Extract the OWAClient from the MCP lifespan context.""" + app_ctx: AppContext = ctx.request_context.lifespan_context + return require_client(app_ctx.client) def _parse_person(resolution: dict) -> dict: - """Parse person data from a ResolveNames resolution entry. + """Parse person data from a ResolveNames resolution entry. - Preserves the exact logic from find-person.py parse_person(). - """ - mailbox = resolution.get("Mailbox", {}) - contact = resolution.get("Contact", {}) + Preserves the exact logic from find-person.py parse_person(). + """ + mailbox = resolution.get("Mailbox", {}) + contact = resolution.get("Contact", {}) - person = { - "name": mailbox.get("Name", contact.get("DisplayName", "")), - "email": mailbox.get("EmailAddress", ""), - "type": mailbox.get("MailboxType", ""), - "first_name": contact.get("GivenName", ""), - "last_name": contact.get("Surname", ""), - "job_title": contact.get("JobTitle", ""), - "department": contact.get("Department", ""), - "company": contact.get("CompanyName", ""), - "office": contact.get("OfficeLocation", ""), - "manager": "", - "manager_email": "", - "phones": {}, - "address": {}, - "direct_reports": [], - "alias": contact.get("Alias", ""), - } + person = { + "name": mailbox.get("Name", contact.get("DisplayName", "")), + "email": mailbox.get("EmailAddress", ""), + "type": mailbox.get("MailboxType", ""), + "first_name": contact.get("GivenName", ""), + "last_name": contact.get("Surname", ""), + "job_title": contact.get("JobTitle", ""), + "department": contact.get("Department", ""), + "company": contact.get("CompanyName", ""), + "office": contact.get("OfficeLocation", ""), + "manager": "", + "manager_email": "", + "phones": {}, + "address": {}, + "direct_reports": [], + "alias": contact.get("Alias", ""), + } - # Phone numbers - for phone in contact.get("PhoneNumbers", []): - key = phone.get("Key", "") - number = phone.get("PhoneNumber", "") - if number: - person["phones"][key] = number + # Phone numbers + for phone in contact.get("PhoneNumbers", []): + key = phone.get("Key", "") + number = phone.get("PhoneNumber", "") + if number: + person["phones"][key] = number - # Physical address - for addr in contact.get("PhysicalAddresses", []): - if addr.get("Key") == "Business": - parts = [] - if addr.get("Street"): - parts.append(addr["Street"]) - if addr.get("City"): - parts.append(addr["City"]) - if addr.get("PostalCode"): - parts.append(addr["PostalCode"]) - if addr.get("CountryOrRegion"): - parts.append(addr["CountryOrRegion"]) - if parts: - person["address"] = { - "street": addr.get("Street", ""), - "city": addr.get("City", ""), - "postal_code": addr.get("PostalCode", ""), - "country": addr.get("CountryOrRegion", ""), - "full": ", ".join(parts), - } + # Physical address + for addr in contact.get("PhysicalAddresses", []): + if addr.get("Key") == "Business": + parts = [] + if addr.get("Street"): + parts.append(addr["Street"]) + if addr.get("City"): + parts.append(addr["City"]) + if addr.get("PostalCode"): + parts.append(addr["PostalCode"]) + if addr.get("CountryOrRegion"): + parts.append(addr["CountryOrRegion"]) + if parts: + person["address"] = { + "street": addr.get("Street", ""), + "city": addr.get("City", ""), + "postal_code": addr.get("PostalCode", ""), + "country": addr.get("CountryOrRegion", ""), + "full": ", ".join(parts), + } - # Manager - manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {}) - if manager_data: - person["manager"] = manager_data.get("Name", "") - person["manager_email"] = manager_data.get("EmailAddress", "") - elif contact.get("Manager"): - person["manager"] = contact.get("Manager", "") + # Manager + manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {}) + if manager_data: + person["manager"] = manager_data.get("Name", "") + person["manager_email"] = manager_data.get("EmailAddress", "") + elif contact.get("Manager"): + person["manager"] = contact.get("Manager", "") - # Direct reports - for report in contact.get("DirectReports", []): - person["direct_reports"].append( - { - "name": report.get("Name", ""), - "email": report.get("EmailAddress", ""), - } - ) + # Direct reports + for report in contact.get("DirectReports", []): + person["direct_reports"].append( + { + "name": report.get("Name", ""), + "email": report.get("EmailAddress", ""), + } + ) - return person + return person @mcp.tool() def find_person(query: str, ctx: Context) -> str: - """Search for people in the corporate directory. + """Search for people in the corporate directory. - Looks up employees by name, email, department, or keyword using the - Exchange ResolveNames API against Active Directory. + Looks up employees by name, email, department, or keyword using the + Exchange ResolveNames API against Active Directory. - Args: - query: Name, email address, or keyword to search for. + Args: + query: Name, email address, or keyword to search for. - Returns: - JSON array of matching people with contact details (name, email, - job_title, department, company, office, phones, address, manager, - direct_reports, alias). - """ - client = _get_client(ctx) + Returns: + JSON array of matching people with contact details (name, email, + job_title, department, company, office, phones, address, manager, + direct_reports, alias). + """ + client = _get_client(ctx) - try: - resolutions = client.resolve_names(query) - except Exception as e: - return json.dumps({"error": str(e)}) + try: + resolutions = client.resolve_names(query) + except Exception as e: + return json.dumps({"error": str(e)}) - if not resolutions: - return json.dumps([]) + if not resolutions: + return json.dumps([]) - people = [_parse_person(r) for r in resolutions] - return json.dumps(people, ensure_ascii=False) + people = [_parse_person(r) for r in resolutions] + return json.dumps(people, ensure_ascii=False) diff --git a/owa_mcp/utils.py b/owa_mcp/utils.py index ddcff53..bbe40c2 100644 --- a/owa_mcp/utils.py +++ b/owa_mcp/utils.py @@ -10,122 +10,127 @@ from datetime import datetime def html_to_text(html_content: str) -> str: - """Convert HTML to plain text. + """Convert HTML to plain text. - Strips scripts, styles, converts
/

/

to newlines, - removes remaining tags, and unescapes HTML entities. - """ - if not html_content: - return "" - text = re.sub( - r"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE - ) - text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE) - text = re.sub(r"", "\n", text, flags=re.IGNORECASE) - text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) - text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) - text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) - text = re.sub(r"<[^>]+>", "", text) - text = html.unescape(text) - text = re.sub(r"\n\s*\n", "\n\n", text) - text = re.sub(r"[ \t]+", " ", text) - return text.strip() + Strips scripts, styles, converts
/

/

to newlines, + removes remaining tags, and unescapes HTML entities. + """ + if not html_content: + return "" + text = re.sub( + r"]*>.*?", + "", + html_content, + flags=re.DOTALL | re.IGNORECASE, + ) + text = re.sub( + r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE + ) + text = re.sub(r"", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + text = html.unescape(text) + text = re.sub(r"\n\s*\n", "\n\n", text) + text = re.sub(r"[ \t]+", " ", text) + return text.strip() def extract_links_from_html(html_content: str) -> list[dict]: - """Extract hyperlinks from HTML content. + """Extract hyperlinks from HTML content. - Finds text patterns, excludes mailto:, cid:, - javascript:, and fragment-only (#) links. Deduplicates by URL. + Finds text patterns, excludes mailto:, cid:, + javascript:, and fragment-only (#) links. Deduplicates by URL. - Returns list of {url, text} dicts. - """ - if not html_content: - return [] + Returns list of {url, text} dicts. + """ + if not html_content: + return [] - # Match text - pattern = re.compile( - r']*href=["\']([^"\']+)["\'][^>]*>(.*?)', - re.DOTALL | re.IGNORECASE, - ) + # Match text + pattern = re.compile( + r']*href=["\']([^"\']+)["\'][^>]*>(.*?)', + re.DOTALL | re.IGNORECASE, + ) - seen: set[str] = set() - links: list[dict] = [] + seen: set[str] = set() + links: list[dict] = [] - for url_raw, text_raw in pattern.findall(html_content): - url = html.unescape(url_raw).strip() + for url_raw, text_raw in pattern.findall(html_content): + url = html.unescape(url_raw).strip() - # Skip non-http links - if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#": - continue - # Skip fragment-only links - if url.startswith("#"): - continue + # Skip non-http links + if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#": + continue + # Skip fragment-only links + if url.startswith("#"): + continue - if url in seen: - continue - seen.add(url) + if url in seen: + continue + seen.add(url) - # Clean link text: strip tags and whitespace - text = re.sub(r"<[^>]+>", "", text_raw) - text = html.unescape(text).strip() + # Clean link text: strip tags and whitespace + text = re.sub(r"<[^>]+>", "", text_raw) + text = html.unescape(text).strip() - links.append({"url": url, "text": text}) + links.append({"url": url, "text": text}) - return links + return links def format_datetime(dt_str: str) -> str: - """Format an ISO datetime string as 'YYYY-MM-DD HH:MM'. + """Format an ISO datetime string as 'YYYY-MM-DD HH:MM'. - Strips timezone suffixes (Z, +offset) for cleaner display. - """ - if not dt_str: - return "" - if "T" in dt_str: - date_part, time_part = dt_str.split("T", 1) - time_part = time_part.split("Z")[0].split("+")[0] - return f"{date_part} {time_part[:5]}" - return dt_str + Strips timezone suffixes (Z, +offset) for cleaner display. + """ + if not dt_str: + return "" + if "T" in dt_str: + date_part, time_part = dt_str.split("T", 1) + time_part = time_part.split("Z")[0].split("+")[0] + return f"{date_part} {time_part[:5]}" + return dt_str def format_date(dt_str: str) -> str: - """Extract the date portion from an ISO datetime string.""" - if not dt_str: - return "" - if "T" in dt_str: - return dt_str.split("T")[0] - return dt_str + """Extract the date portion from an ISO datetime string.""" + if not dt_str: + return "" + if "T" in dt_str: + return dt_str.split("T")[0] + return dt_str def parse_date(date_str: str) -> datetime: - """Parse a date string in common formats. + """Parse a date string in common formats. - Supports: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY. - """ - formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"] - for fmt in formats: - try: - return datetime.strptime(date_str, fmt) - except ValueError: - continue - raise ValueError(f"Could not parse date: {date_str}") + Supports: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY. + """ + formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"] + for fmt in formats: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + raise ValueError(f"Could not parse date: {date_str}") def parse_iso_datetime(dt_str: str) -> datetime: - """Parse an ISO datetime string to a naive datetime. + """Parse an ISO datetime string to a naive datetime. - Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats, - stripping any timezone suffix. - """ - if "T" in dt_str: - clean = dt_str.split("Z")[0].split("+")[0] - return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S") - return datetime.strptime(dt_str, "%Y-%m-%d") + Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats, + stripping any timezone suffix. + """ + if "T" in dt_str: + clean = dt_str.split("Z")[0].split("+")[0] + return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S") + return datetime.strptime(dt_str, "%Y-%m-%d") def format_attendee(name: str, email: str) -> str: - """Format an attendee as 'Name ' or just the email.""" - if name and email and not email.startswith("/O="): - return f"{name} <{email}>" - return name or email or "" + """Format an attendee as 'Name ' or just the email.""" + if name and email and not email.startswith("/O="): + return f"{name} <{email}>" + return name or email or "" -- 2.52.0 From 333a4c592df83d5b0431216f81e2f5bfcc89ebb1 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 10:46:11 +0300 Subject: [PATCH 7/8] w --- dockerfile | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 dockerfile diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..91f50e0 --- /dev/null +++ b/dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e ".[http]" +COPY . . +CMD ["owa-mcp"] diff --git a/pyproject.toml b/pyproject.toml index a69a537..9d75f84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "owa-mcp-server" +name = "owa-mcp" version = "0.1.0" description = "MCP server for OWA — email, calendar, directory, availability" readme = "README.md" @@ -23,7 +23,7 @@ Repository = "https://git.kosyrev.name/zoo/owa-mcp" http = ["uvicorn>=0.30.0"] [project.scripts] -owa-mcp-server = "owa_mcp.server:main" +owa-mcp = "owa_mcp.server:main" [build-system] requires = ["setuptools>=68.0"] -- 2.52.0 From 66087d57974572f78693269c297a414ba35f76c1 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 10:55:06 +0300 Subject: [PATCH 8/8] w --- .gitignore | 1 + owa_mcp/server.py | 4 +-- owa_mcp/server_lifecycle.py | 69 +++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9892a8f..68a06dc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .mcpregistry_* .playwright-mcp/ .salt +.venv *.egg-info/ *.pyc build diff --git a/owa_mcp/server.py b/owa_mcp/server.py index 3807931..361f576 100644 --- a/owa_mcp/server.py +++ b/owa_mcp/server.py @@ -119,12 +119,12 @@ def _parse_args(argv=None) -> argparse.Namespace: parser.add_argument( "--enabled-tools", default=None, - help="Comma-separated whitelist of tool names to expose", + help="Comma-separated whitelist of tool names or group aliases (read-only, write-only) to expose", ) parser.add_argument( "--disabled-tools", default=None, - help="Comma-separated blacklist of tool names to hide", + help="Comma-separated blacklist of tool names or group aliases (read-only, write-only) to hide", ) return parser.parse_args(argv) diff --git a/owa_mcp/server_lifecycle.py b/owa_mcp/server_lifecycle.py index 154f8a0..8e84485 100644 --- a/owa_mcp/server_lifecycle.py +++ b/owa_mcp/server_lifecycle.py @@ -213,6 +213,59 @@ class SessionKeepalive: logger.debug("Keepalive ping failed (non-critical): %s", exc) +# ── Tool groups ───────────────────────────────────────────────────────── + +TOOL_GROUPS: dict[str, set[str]] = { + "read-only": { + "get_emails", + "get_email", + "search_emails", + "get_calendar_events", + "get_event_links", + "find_person", + "get_folders", + "check_session", + "find_free_time", + "find_meeting_time", + "get_meeting_stats", + "get_meeting_contacts", + "download_event_attachments", + "get_email_links", + "download_attachments", + "login", + }, + "write-only": { + "send_email", + "reply_email", + "forward_email", + "delete_email", + "move_email", + "mark_email_read", + "create_meeting", + "update_meeting", + "cancel_meeting", + "respond_to_meeting", + "create_folder", + "rename_folder", + "empty_folder", + "delete_folder", + "move_folder", + }, +} + +# ── Alias resolution ──────────────────────────────────────────────────── + + +def _resolve_aliases(names: list[str]) -> set[str]: + resolved: set[str] = set() + for name in names: + if name in TOOL_GROUPS: + resolved |= TOOL_GROUPS[name] + else: + resolved.add(name) + return resolved + + # ── Tool filtering ─────────────────────────────────────────────────────── @@ -236,8 +289,24 @@ def apply_tool_filters( if not canonical: return + # ── Group integrity check ─────────────────────────────────────────────── + all_grouped: set[str] = set().union(*TOOL_GROUPS.values()) + missing = canonical - all_grouped + assert not missing, f"Tools not in any group: {sorted(missing)}" + overlaps: set[str] = set() + for i, (g1, s1) in enumerate(TOOL_GROUPS.items()): + for g2, s2 in list(TOOL_GROUPS.items())[i + 1 :]: + overlaps |= s1 & s2 + assert not overlaps, f"Tools in multiple groups: {sorted(overlaps)}" + allowed: set[str] = set(canonical) + # ── Resolve aliases ──────────────────────────────────────────────────── + if enabled: + enabled = sorted(_resolve_aliases(enabled)) + if disabled: + disabled = sorted(_resolve_aliases(disabled)) + # ── Whitelist ────────────────────────────────────────────────────────── if enabled: enabled_set = {n.strip() for n in enabled if n.strip()} -- 2.52.0