From fc976cd6ef0c5a6a1be911c4f5b3a3637c7ad6c7 Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 07:55:40 +0000 Subject: [PATCH] feat: basics --- .dprint.json | 14 + .gitignore | 13 + LICENSE | 21 + README.md | 51 ++ dockerfile | 6 + owa_mcp/__init__.py | 1 + owa_mcp/models.py | 116 +++ owa_mcp/owa_client.py | 491 ++++++++++++ owa_mcp/server.py | 220 ++++++ owa_mcp/server_lifecycle.py | 339 ++++++++ owa_mcp/tools/__init__.py | 1 + owa_mcp/tools/analytics.py | 453 +++++++++++ owa_mcp/tools/auth.py | 125 +++ owa_mcp/tools/availability.py | 638 ++++++++++++++++ owa_mcp/tools/calendar.py | 1358 +++++++++++++++++++++++++++++++++ owa_mcp/tools/email.py | 1206 +++++++++++++++++++++++++++++ owa_mcp/tools/folders.py | 448 +++++++++++ owa_mcp/tools/people.py | 121 +++ owa_mcp/utils.py | 136 ++++ pyproject.toml | 42 + 20 files changed, 5800 insertions(+) create mode 100644 .dprint.json create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dockerfile create mode 100644 owa_mcp/__init__.py create mode 100644 owa_mcp/models.py create mode 100644 owa_mcp/owa_client.py create mode 100644 owa_mcp/server.py create mode 100644 owa_mcp/server_lifecycle.py create mode 100644 owa_mcp/tools/__init__.py create mode 100644 owa_mcp/tools/analytics.py create mode 100644 owa_mcp/tools/auth.py create mode 100644 owa_mcp/tools/availability.py create mode 100644 owa_mcp/tools/calendar.py create mode 100644 owa_mcp/tools/email.py create mode 100644 owa_mcp/tools/folders.py create mode 100644 owa_mcp/tools/people.py create mode 100644 owa_mcp/utils.py create mode 100644 pyproject.toml diff --git a/.dprint.json b/.dprint.json new file mode 100644 index 0000000..b409141 --- /dev/null +++ b/.dprint.json @@ -0,0 +1,14 @@ +{ + "excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"], + "includes": ["**/*.{md,json,py}"], + "indentWidth": 2, + "lineWidth": 80, + "ruff": { + "lineLength": 80, + }, + "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" + ], +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68a06dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +.credentials.enc +.mcp.json +.mcpregistry_* +.playwright-mcp/ +.salt +.venv +*.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..0006f68 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# OWA MCP Server + +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 | +| `--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` 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/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/owa_mcp/models.py b/owa_mcp/models.py new file mode 100644 index 0000000..66e2fcd --- /dev/null +++ b/owa_mcp/models.py @@ -0,0 +1,116 @@ +"""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/owa_mcp/owa_client.py b/owa_mcp/owa_client.py new file mode 100644 index 0000000..bf9c647 --- /dev/null +++ b/owa_mcp/owa_client.py @@ -0,0 +1,491 @@ +"""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 time +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 + + +# 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 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(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 "" + raise SessionExpiredError( + f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). 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?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/owa_mcp/server.py b/owa_mcp/server.py new file mode 100644 index 0000000..361f576 --- /dev/null +++ b/owa_mcp/server.py @@ -0,0 +1,220 @@ +"""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 +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 +""" + +from __future__ import annotations + +import argparse +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.auth.provider import TokenVerifier +from mcp.server.auth.settings import AuthSettings +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, +) + +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:`~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() + + 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("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 or group aliases (read-only, write-only) to expose", + ) + parser.add_argument( + "--disabled-tools", + default=None, + help="Comma-separated blacklist of tool names or group aliases (read-only, write-only) 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 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) + + # ── 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()] + + 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/owa_mcp/server_lifecycle.py b/owa_mcp/server_lifecycle.py new file mode 100644 index 0000000..8e84485 --- /dev/null +++ b/owa_mcp/server_lifecycle.py @@ -0,0 +1,339 @@ +"""Shared lifecycle utilities for the OWA 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 mcp.server.auth.provider import AccessToken, TokenVerifier + +from owa_mcp.owa_client import OWAClient, SessionExpiredError + +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: + + 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 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 ─────────────────────────────────────────────────────── + + +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 + + # ── 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()} + 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/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/owa_mcp/tools/analytics.py b/owa_mcp/tools/analytics.py new file mode 100644 index 0000000..c1c5a05 --- /dev/null +++ b/owa_mcp/tools/analytics.py @@ -0,0 +1,453 @@ +"""Analytics tools for the OWA MCP server. + +Provides meeting statistics and connection matrix analysis +using GetUserAvailability and GetItem APIs. +""" + +import json +from collections import Counter +from datetime import date, datetime, timedelta + +from mcp.server.fastmcp import Context + +from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp +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) + + +# ------------------------------------------------------------------ +# 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/owa_mcp/tools/auth.py b/owa_mcp/tools/auth.py new file mode 100644 index 0000000..46b84d6 --- /dev/null +++ b/owa_mcp/tools/auth.py @@ -0,0 +1,125 @@ +"""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. + +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 owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server import AppContext, mcp +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 + + +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/owa_mcp/tools/availability.py b/owa_mcp/tools/availability.py new file mode 100644 index 0000000..40e2ce5 --- /dev/null +++ b/owa_mcp/tools/availability.py @@ -0,0 +1,638 @@ +"""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. +""" + +import json +from datetime import datetime, timedelta + +from mcp.server.fastmcp import Context + +from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp +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) + + +# ------------------------------------------------------------------ +# 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/owa_mcp/tools/calendar.py b/owa_mcp/tools/calendar.py new file mode 100644 index 0000000..8cfecb8 --- /dev/null +++ b/owa_mcp/tools/calendar.py @@ -0,0 +1,1358 @@ +"""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. +""" + +import html as html_mod +import json +import uuid +from datetime import datetime, timedelta + +from mcp.server.fastmcp import Context + +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 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) + + +# ------------------------------------------------------------------ +# 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 += f'
{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/owa_mcp/tools/email.py b/owa_mcp/tools/email.py new file mode 100644 index 0000000..e236709 --- /dev/null +++ b/owa_mcp/tools/email.py @@ -0,0 +1,1206 @@ +"""Email tools for the OWA 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 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 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) + + +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/owa_mcp/tools/folders.py b/owa_mcp/tools/folders.py new file mode 100644 index 0000000..f3e93b6 --- /dev/null +++ b/owa_mcp/tools/folders.py @@ -0,0 +1,448 @@ +"""Folder tools for the OWA MCP server. + +Ports the get-folders.py logic into an MCP tool using OWAClient. +""" + +import json + +from mcp.server.fastmcp import Context + +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", +} + +_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/owa_mcp/tools/people.py b/owa_mcp/tools/people.py new file mode 100644 index 0000000..f4220b1 --- /dev/null +++ b/owa_mcp/tools/people.py @@ -0,0 +1,121 @@ +"""People / directory search tools for the OWA MCP server. + +Ports the find-person.py logic into an MCP tool using OWAClient. +""" + +import json + +from mcp.server.fastmcp import Context + +from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp +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) + + +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/owa_mcp/utils.py b/owa_mcp/utils.py new file mode 100644 index 0000000..bbe40c2 --- /dev/null +++ b/owa_mcp/utils.py @@ -0,0 +1,136 @@ +"""Shared utility functions for the OWA 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..9d75f84 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "owa-mcp" +version = "0.1.0" +description = "MCP server for OWA — email, calendar, directory, availability" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +keywords = ["mcp", "owa", "outlook", "exchange", "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] +owa-mcp = "owa_mcp.server:main" + +[build-system] +requires = ["setuptools>=68.0"] +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"]