From 4207b38b1675c0197ac89d33a7163f3dfb7e9ccc Mon Sep 17 00:00:00 2001 From: albnnc Date: Mon, 6 Jul 2026 02:05:16 +0300 Subject: [PATCH] w --- .dprint.json | 21 ++ owa_mcp/models.py | 7 + owa_mcp/owa_client.py | 44 ++--- owa_mcp/server.py | 17 +- owa_mcp/server_lifecycle.py | 62 +++--- owa_mcp/tools/analytics.py | 269 +++++++++++++------------ owa_mcp/tools/auth.py | 36 ++-- owa_mcp/tools/availability.py | 360 +++++++++++++++++++--------------- owa_mcp/tools/calendar.py | 338 +++++++++++++++++-------------- owa_mcp/tools/email.py | 129 +++++++----- owa_mcp/tools/folders.py | 123 +++++++----- owa_mcp/tools/people.py | 12 +- owa_mcp/utils.py | 4 +- pyproject.toml | 8 + 14 files changed, 823 insertions(+), 607 deletions(-) create mode 100644 .dprint.json diff --git a/.dprint.json b/.dprint.json new file mode 100644 index 0000000..696337b --- /dev/null +++ b/.dprint.json @@ -0,0 +1,21 @@ +{ + "excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"], + "includes": ["**/*.{md,json,py}"], + "indentWidth": 2, + "json": { + "deno": true + }, + "lineWidth": 80, + "markdown": { + "deno": true + }, + "plugins": [ + "https://plugins.dprint.dev/json-0.22.0.wasm", + "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/ruff-0.7.20.wasm" + ], + "ruff": { + "lineLength": 88, + "targetVersion": "py310" + } +} diff --git a/owa_mcp/models.py b/owa_mcp/models.py index 7d23b9f..1bcf051 100644 --- a/owa_mcp/models.py +++ b/owa_mcp/models.py @@ -9,6 +9,7 @@ from typing import TypedDict class Email(TypedDict, total=False): """An email message.""" + subject: str sender: str sender_name: str @@ -35,6 +36,7 @@ class Email(TypedDict, total=False): class CalendarEvent(TypedDict, total=False): """A calendar event / meeting.""" + subject: str start: str end: str @@ -54,6 +56,7 @@ class CalendarEvent(TypedDict, total=False): class Person(TypedDict, total=False): """A person from the Exchange directory.""" + name: str email: str mailbox_type: str @@ -73,6 +76,7 @@ class Person(TypedDict, total=False): class Folder(TypedDict, total=False): """A mail folder.""" + name: str id: str total_count: int @@ -82,6 +86,7 @@ class Folder(TypedDict, total=False): class FreeSlot(TypedDict): """A free time slot.""" + date: str start: str end: str @@ -90,6 +95,7 @@ class FreeSlot(TypedDict): class Availability(TypedDict, total=False): """Availability data for a single attendee.""" + email: str busy_slots: int free_slots: int @@ -98,6 +104,7 @@ class Availability(TypedDict, total=False): class MeetingResult(TypedDict, total=False): """Result of creating/updating a meeting.""" + success: bool subject: str date: str diff --git a/owa_mcp/owa_client.py b/owa_mcp/owa_client.py index c4a5e28..244a553 100644 --- a/owa_mcp/owa_client.py +++ b/owa_mcp/owa_client.py @@ -10,14 +10,15 @@ mirroring what the OWA web frontend sends. import json import time -from urllib.parse import quote, urlparse from pathlib import Path +from urllib.parse import quote, urlparse import requests class SessionExpiredError(Exception): """Raised when the OWA session has expired (HTTP 401/440 or HTML redirect).""" + pass @@ -53,14 +54,11 @@ class OWAClient: owa_url: str | None = None, ): self.cookie_file = Path( - cookie_file - or str(Path(__file__).parent.parent / "session-cookies.txt") + cookie_file or str(Path(__file__).parent.parent / "session-cookies.txt") ) self.owa_url = (owa_url or "").rstrip("/") if not self.owa_url: - raise ValueError( - "OWA URL must be provided via --owa-url or owa_url=…" - ) + raise ValueError("OWA URL must be provided via --owa-url or owa_url=…") self._session = requests.Session() self._loaded = False self.user_email: str = "" @@ -81,9 +79,10 @@ class OWAClient: """ now_ms = int(time.time() * 1000) client_req_id = f"{self._client_id}_{now_ms}" - now_str = time.strftime( - "%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000) - ) + f"{now_ms % 1000:03d}" + now_str = ( + time.strftime("%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)) + + f"{now_ms % 1000:03d}" + ) return { # Basic "accept": "*/*", @@ -129,9 +128,7 @@ class OWAClient: @property def _client_id(self) -> str: """Extract ClientId from session cookies.""" - cid = next( - (c.value for c in self._session.cookies if c.name == "ClientId"), "" - ) + cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "") return cid if cid else "00000000000000000000000000000000" # ------------------------------------------------------------------ @@ -153,7 +150,9 @@ class OWAClient: cookies[name] = value if not cookies: - raise SessionExpiredError("Cookie file is empty. Use the login MCP tool to authenticate.") + raise SessionExpiredError( + "Cookie file is empty. Use the login MCP tool to authenticate." + ) self._session = requests.Session() self._session.cookies.update(cookies) @@ -229,12 +228,11 @@ class OWAClient: set_cookie = resp.headers.get("Set-Cookie", "") if "X-OWA-CANARY=" in set_cookie: import re - m = re.search(r'X-OWA-CANARY=([^;]+)', set_cookie) + + m = re.search(r"X-OWA-CANARY=([^;]+)", set_cookie) if m: host = urlparse(self.owa_url).hostname or "owa.b1.ru" - self._session.cookies.set( - "X-OWA-CANARY", m.group(1), domain=host - ) + self._session.cookies.set("X-OWA-CANARY", m.group(1), domain=host) # Persist any cookie changes self._save_cookies() except Exception: @@ -273,7 +271,9 @@ class OWAClient: # Detect session expiry if resp.status_code in (401, 440): - raise SessionExpiredError("Session expired (HTTP {}).".format(resp.status_code)) + raise SessionExpiredError( + f"Session expired (HTTP {resp.status_code})." + ) if "text/html" in resp.headers.get("Content-Type", ""): body_snippet = resp.text[:300] if resp.text else "" @@ -335,9 +335,7 @@ class OWAClient: raise SessionExpiredError(f"Download failed: {exc}") from exc if resp.status_code in (401, 440): - raise SessionExpiredError( - f"Session expired (HTTP {resp.status_code})." - ) + raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).") if "text/html" in resp.headers.get("Content-Type", ""): raise SessionExpiredError( @@ -469,9 +467,7 @@ class OWAClient: # ResolveNames (directory search / attendee resolution) # ------------------------------------------------------------------ - def resolve_names( - self, query: str, *, full_contact: bool = True - ) -> list[dict]: + def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]: """Call ResolveNames to search the directory. Returns the list of Resolution dicts from the API, each containing diff --git a/owa_mcp/server.py b/owa_mcp/server.py index 6e1afbc..e88023a 100644 --- a/owa_mcp/server.py +++ b/owa_mcp/server.py @@ -23,7 +23,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass -from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.provider import TokenVerifier from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import FastMCP @@ -41,6 +41,7 @@ logger = logging.getLogger(__name__) @dataclass class AppContext: """Shared application state available to all tools via lifespan context.""" + client: OWAClient | None = None @@ -177,13 +178,13 @@ def main(argv=None) -> None: raise SystemExit(1) # ── Register tools (imports populate module-level `mcp`) ─────────────── - import owa_mcp.tools.email # noqa: E402, F401 - import owa_mcp.tools.calendar # noqa: E402, F401 - import owa_mcp.tools.people # noqa: E402, F401 - import owa_mcp.tools.folders # noqa: E402, F401 - import owa_mcp.tools.availability # noqa: E402, F401 - import owa_mcp.tools.analytics # noqa: E402, F401 - import owa_mcp.tools.auth # noqa: E402, F401 + import owa_mcp.tools.analytics # noqa: E402, F401 + import owa_mcp.tools.auth # noqa: E402, F401 + import owa_mcp.tools.availability # noqa: E402, F401 + import owa_mcp.tools.calendar # noqa: E402, F401 + import owa_mcp.tools.email # noqa: E402, F401 + import owa_mcp.tools.folders # noqa: E402, F401 + import owa_mcp.tools.people # noqa: E402, F401 # ── Auth for HTTP transports ─────────────────────────────────────────── _configure_auth(mcp, args.token_hash) diff --git a/owa_mcp/server_lifecycle.py b/owa_mcp/server_lifecycle.py index c37ed74..00333be 100644 --- a/owa_mcp/server_lifecycle.py +++ b/owa_mcp/server_lifecycle.py @@ -17,9 +17,10 @@ import asyncio import logging from typing import TYPE_CHECKING -from owa_mcp.owa_client import OWAClient, SessionExpiredError from mcp.server.auth.provider import AccessToken, TokenVerifier +from owa_mcp.owa_client import OWAClient, SessionExpiredError + if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -28,6 +29,7 @@ logger = logging.getLogger(__name__) # ── Client factory ─────────────────────────────────────────────────────── + def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient: """Create and return a fully configured :class:`OWAClient`. @@ -50,6 +52,7 @@ def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient # ── Token verifier ──────────────────────────────────────────────────────── + class HashTokenVerifier(TokenVerifier): """Bearer-token verifier backed by a SHA-256 hash. @@ -59,11 +62,12 @@ class HashTokenVerifier(TokenVerifier): """ def __init__(self, token_hash: str) -> None: - import hashlib + self._expected_hash = token_hash.strip().lower() async def verify_token(self, token: str) -> AccessToken | None: import hashlib + if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: return AccessToken( token=token, @@ -75,6 +79,7 @@ class HashTokenVerifier(TokenVerifier): # ── Lifespan helper ────────────────────────────────────────────────────── + class _LazyClient: """Thin wrapper that resolves the OWAClient on first access. @@ -103,8 +108,7 @@ def require_client(client: OWAClient | None) -> OWAClient: """ if client is None: raise RuntimeError( - "OWA client is not initialised. " - "Pass --owa-url when starting the server." + "OWA client is not initialised. Pass --owa-url when starting the server." ) return client @@ -127,7 +131,9 @@ class SessionKeepalive: picked up without restarting the server. """ - def __init__(self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL) -> None: + def __init__( + self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL + ) -> None: self._client = client self._interval = interval self._task: asyncio.Task | None = None @@ -168,37 +174,47 @@ class SessionKeepalive: case the user re-authenticated while the server was running. """ try: - self._client.request("GetFolder", { - "__type": "GetFolderJsonRequest:#Exchange", - "Header": { - "__type": "JsonRequestHeaders:#Exchange", - "RequestServerVersion": "Exchange2013", - }, - "Body": { - "__type": "GetFolderRequest:#Exchange", - "FolderShape": { - "__type": "FolderResponseShape:#Exchange", - "BaseShape": "IdOnly", + self._client.request( + "GetFolder", + { + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + }, + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", + }, + "FolderIds": [ + { + "__type": "DistinguishedFolderId:#Exchange", + "Id": "inbox", + } + ], }, - "FolderIds": [{ - "__type": "DistinguishedFolderId:#Exchange", - "Id": "inbox", - }], }, - }) + ) logger.debug("Session keepalive ping succeeded") except SessionExpiredError: - logger.warning("Session expired during keepalive — reloading cookies from disk") + logger.warning( + "Session expired during keepalive — reloading cookies from disk" + ) try: self._client._ensure_loaded() except Exception as exc: - logger.warning("Failed to reload cookies after keepalive failure: %s", exc) + logger.warning( + "Failed to reload cookies after keepalive failure: %s", exc + ) except Exception as exc: logger.debug("Keepalive ping failed (non-critical): %s", exc) # ── Tool filtering ─────────────────────────────────────────────────────── + def apply_tool_filters( server: FastMCP, enabled: list[str] | None = None, diff --git a/owa_mcp/tools/analytics.py b/owa_mcp/tools/analytics.py index cadbade..1c7872d 100644 --- a/owa_mcp/tools/analytics.py +++ b/owa_mcp/tools/analytics.py @@ -5,13 +5,13 @@ using GetUserAvailability and GetItem APIs. """ import json -from collections import Counter, defaultdict -from datetime import datetime, timedelta, date +from collections import Counter +from datetime import date, datetime, timedelta from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -25,6 +25,7 @@ def _get_client(ctx: Context) -> OWAClient: # Internal: resolve names to emails # ------------------------------------------------------------------ + def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: """Resolve a name/email to (display_name, email). Returns ('','') on failure.""" resolutions = client.resolve_names(name) @@ -38,6 +39,7 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: # Internal: query GetUserAvailability in chunks # ------------------------------------------------------------------ + def _get_availability_events( client: OWAClient, emails: list[str], @@ -54,77 +56,84 @@ def _get_availability_events( results: dict[str, list[dict]] = {email: [] for email in emails} # Batch people - email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)] + email_batches = [ + emails[i : i + batch_size] for i in range(0, len(emails), batch_size) + ] for batch in email_batches: current = start while current < end: chunk_end = min(current + timedelta(days=chunk_days), end) - mailbox_data = [{ - '__type': 'MailboxData:#Exchange', - 'Email': { - '__type': 'EmailAddress:#Exchange', - 'Address': email, - }, - 'AttendeeType': 'Required', - } for email in batch] + mailbox_data = [ + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": email, + }, + "AttendeeType": "Required", + } + for email in batch + ] payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': mailbox_data, - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{current}T00:00:00', - 'EndTime': f'{chunk_end}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": mailbox_data, + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{current}T00:00:00", + "EndTime": f"{chunk_end}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } try: - data = client.request('GetUserAvailability', payload) - body = data.get('Body', {}) - for i, fb_resp in enumerate(body.get('FreeBusyResponseArray', [])): + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) + for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])): if i >= len(batch): break email = batch[i] - fb_view = fb_resp.get('FreeBusyView', {}) - cal_events = fb_view.get('CalendarEventArray', {}) + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) if isinstance(cal_events, dict): - items = cal_events.get('Items', []) + items = cal_events.get("Items", []) elif isinstance(cal_events, list): items = cal_events else: items = [] for event in items: - s = event.get('StartTime', '') - bt = event.get('BusyType', '') - if s and bt != 'Free': - details = event.get('CalendarEventDetails', {}) - subject = details.get('Subject', '') if details else '' - results[email].append({ - 'subject': subject, - 'start_date': s[:10], - 'busy_type': bt, - }) + s = event.get("StartTime", "") + bt = event.get("BusyType", "") + if s and bt != "Free": + details = event.get("CalendarEventDetails", {}) + subject = details.get("Subject", "") if details else "" + results[email].append( + { + "subject": subject, + "start_date": s[:10], + "busy_type": bt, + } + ) except Exception: pass @@ -137,6 +146,7 @@ def _get_availability_events( # Tool: get_meeting_stats # ------------------------------------------------------------------ + @mcp.tool() def get_meeting_stats( people: str, @@ -161,13 +171,13 @@ def get_meeting_stats( client = _get_client(ctx) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) # Resolve names - name_list = [n.strip() for n in people.split(',') if n.strip()] + name_list = [n.strip() for n in people.split(",") if n.strip()] if not name_list: return json.dumps({"error": "No people specified."}) @@ -199,43 +209,51 @@ def get_meeting_stats( stats = [] for display, email in resolved: if not email or email not in avail: - stats.append({ - "name": display, - "email": email or "not_found", - "total_meetings": 0, - "meetings_per_workday": 0, - "days_with_meetings": 0, - "workdays": workdays, - }) + stats.append( + { + "name": display, + "email": email or "not_found", + "total_meetings": 0, + "meetings_per_workday": 0, + "days_with_meetings": 0, + "workdays": workdays, + } + ) continue events = avail[email] total = len(events) - unique_days = len(set(ev['start_date'] for ev in events)) + unique_days = len(set(ev["start_date"] for ev in events)) avg = round(total / workdays, 1) - stats.append({ - "name": display, - "email": email, - "total_meetings": total, - "meetings_per_workday": avg, - "days_with_meetings": unique_days, - "workdays": workdays, - }) + stats.append( + { + "name": display, + "email": email, + "total_meetings": total, + "meetings_per_workday": avg, + "days_with_meetings": unique_days, + "workdays": workdays, + } + ) # Sort by total descending stats.sort(key=lambda x: x["total_meetings"], reverse=True) - return json.dumps({ - "period": {"start": start_date, "end": end_date, "workdays": workdays}, - "stats": stats, - }, ensure_ascii=False) + return json.dumps( + { + "period": {"start": start_date, "end": end_date, "workdays": workdays}, + "stats": stats, + }, + ensure_ascii=False, + ) # ------------------------------------------------------------------ # Tool: get_meeting_contacts # ------------------------------------------------------------------ + @mcp.tool() def get_meeting_contacts( start_date: str, @@ -261,8 +279,8 @@ def get_meeting_contacts( client = _get_client(ctx) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) @@ -270,7 +288,9 @@ def get_meeting_contacts( # to count subject occurrences (handles recurring meetings) own_email = client.user_email.lower() if client.user_email else "" if not own_email: - return json.dumps({"error": "User email not available. Call the login tool first."}) + return json.dumps( + {"error": "User email not available. Call the login tool first."} + ) folder_id = client.get_folder_id("calendar") if not folder_id: @@ -280,7 +300,7 @@ def get_meeting_contacts( avail_result = _get_availability_events(client, [client.user_email], sd, ed) expanded_events = avail_result.get(client.user_email, []) - subject_counts = Counter(ev['subject'] for ev in expanded_events) + subject_counts = Counter(ev["subject"] for ev in expanded_events) total_expanded = len(expanded_events) # Step 2: Find master calendar items for each unique subject @@ -290,51 +310,51 @@ def get_meeting_contacts( while len(subject_to_id) < len(subject_counts): payload = { - '__type': 'FindItemJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", }, - 'Body': { - '__type': 'FindItemRequest:#Exchange', - 'ItemShape': { - '__type': 'ItemResponseShape:#Exchange', - 'BaseShape': 'Default', + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "Default", }, - 'ParentFolderIds': [ - {'__type': 'FolderId:#Exchange', 'Id': folder_id} + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": 200, + }, + "SortOrder": [ + { + "__type": "SortResults:#Exchange", + "Order": "Descending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Start", + }, + } ], - 'Traversal': 'Shallow', - 'Paging': { - '__type': 'IndexedPageView:#Exchange', - 'BasePoint': 'Beginning', - 'Offset': offset, - 'MaxEntriesReturned': 200, - }, - 'SortOrder': [{ - '__type': 'SortResults:#Exchange', - 'Order': 'Descending', - 'Path': { - '__type': 'PropertyUri:#Exchange', - 'FieldURI': 'Start', - }, - }], }, } - data = client.request('FindItem', payload) + data = client.request("FindItem", payload) items = client.extract_items(data) if not items: break - folder_items = items[0].get('RootFolder', {}).get('Items', []) - is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) + folder_items = items[0].get("RootFolder", {}).get("Items", []) + is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) if not folder_items: break for item in folder_items: - subj = item.get('Subject', '') + subj = item.get("Subject", "") if subj and subj in subject_counts and subj not in subject_to_id: - subject_to_id[subj] = item.get('ItemId', {}).get('Id', '') + subject_to_id[subj] = item.get("ItemId", {}).get("Id", "") if is_last: break @@ -347,7 +367,7 @@ def get_meeting_contacts( id_to_attendees: dict[str, set] = {} for bi in range(0, len(unique_ids), 10): - batch = unique_ids[bi:bi+10] + batch = unique_ids[bi : bi + 10] item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch] payload = { @@ -375,8 +395,10 @@ def get_meeting_contacts( item_id = item.get("ItemId", {}).get("Id", "") attendees = set() - for att_list in [item.get("RequiredAttendees", []) or [], - item.get("OptionalAttendees", []) or []]: + for att_list in [ + item.get("RequiredAttendees", []) or [], + item.get("OptionalAttendees", []) or [], + ]: for a in att_list: mailbox = a.get("Mailbox", {}) name = mailbox.get("Name", "") @@ -408,15 +430,20 @@ def get_meeting_contacts( result_contacts = [] for (name, email), count in contacts.most_common(top_n): - result_contacts.append({ - "name": name, - "email": email, - "meetings": count, - }) + result_contacts.append( + { + "name": name, + "email": email, + "meetings": count, + } + ) - return json.dumps({ - "period": {"start": start_date, "end": end_date}, - "total_meetings": total_expanded, - "unique_contacts": len(contacts), - "contacts": result_contacts, - }, ensure_ascii=False) + return json.dumps( + { + "period": {"start": start_date, "end": end_date}, + "total_meetings": total_expanded, + "unique_contacts": len(contacts), + "contacts": result_contacts, + }, + ensure_ascii=False, + ) diff --git a/owa_mcp/tools/auth.py b/owa_mcp/tools/auth.py index df85919..b2d354d 100644 --- a/owa_mcp/tools/auth.py +++ b/owa_mcp/tools/auth.py @@ -12,8 +12,8 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -87,16 +87,20 @@ async def login( # Parse and validate cookies cookies_str = cookies.strip() if not cookies_str: - return json.dumps({ - "success": False, - "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", - }) + return json.dumps( + { + "success": False, + "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", + } + ) # Save to disk try: client.cookie_file.write_text(cookies_str + "\n") except Exception as e: - return json.dumps({"success": False, "error": f"Failed to write cookie file: {e}"}) + return json.dumps( + {"success": False, "error": f"Failed to write cookie file: {e}"} + ) # Load into memory try: @@ -106,12 +110,16 @@ async def login( # Verify session if _session_is_valid(client): - return json.dumps({ - "success": True, - "message": f"Logged in. Session cookies saved to {client.cookie_file}.", - }) + return json.dumps( + { + "success": True, + "message": f"Logged in. Session cookies saved to {client.cookie_file}.", + } + ) else: - return json.dumps({ - "success": False, - "error": "Cookies loaded but session verification failed. The cookies may have expired.", - }) + return json.dumps( + { + "success": False, + "error": "Cookies loaded but session verification failed. The cookies may have expired.", + } + ) diff --git a/owa_mcp/tools/availability.py b/owa_mcp/tools/availability.py index aa8bd02..a4ac504 100644 --- a/owa_mcp/tools/availability.py +++ b/owa_mcp/tools/availability.py @@ -9,8 +9,8 @@ from datetime import datetime, timedelta from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -24,6 +24,7 @@ def _get_client(ctx: Context) -> OWAClient: # Pure helper functions (preserved from find-meeting-time.py) # ------------------------------------------------------------------ + def _parse_freebusy_string( freebusy_str: str, start_time: datetime, interval_minutes: int = 30 ) -> list[tuple]: @@ -37,7 +38,7 @@ def _parse_freebusy_string( for char in freebusy_str: next_time = current_time + timedelta(minutes=interval_minutes) - if char in ['1', '2', '3', '4']: # Not free + if char in ["1", "2", "3", "4"]: # Not free busy_periods.append((current_time, next_time, char)) current_time = next_time @@ -105,75 +106,82 @@ def _find_free_slots( def _format_time(dt: datetime) -> str: """Format datetime as HH:MM.""" - return dt.strftime('%H:%M') + return dt.strftime("%H:%M") # ------------------------------------------------------------------ # Helper: get busy events via GetUserAvailability (includes recurring) # ------------------------------------------------------------------ + def _get_availability_events( client: OWAClient, email: str, start_date, end_date ) -> list[dict]: """Get busy events via GetUserAvailability (expands recurring events).""" payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': [{ - '__type': 'MailboxData:#Exchange', - 'Email': {'__type': 'EmailAddress:#Exchange', 'Address': email}, - 'AttendeeType': 'Required', - }], - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{start_date}T00:00:00', - 'EndTime': f'{end_date + timedelta(days=1)}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": [ + { + "__type": "MailboxData:#Exchange", + "Email": {"__type": "EmailAddress:#Exchange", "Address": email}, + "AttendeeType": "Required", + } + ], + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{start_date}T00:00:00", + "EndTime": f"{end_date + timedelta(days=1)}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } - data = client.request('GetUserAvailability', payload) - body = data.get('Body', {}) + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) events = [] - for fb_resp in body.get('FreeBusyResponseArray', []): - fb_view = fb_resp.get('FreeBusyView', {}) - cal_events = fb_view.get('CalendarEventArray', {}) + for fb_resp in body.get("FreeBusyResponseArray", []): + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) items = ( - cal_events.get('Items', []) + cal_events.get("Items", []) if isinstance(cal_events, dict) else (cal_events if isinstance(cal_events, list) else []) ) for event in items: - bt = event.get('BusyType', '') - if bt in ('Free', 'NoData'): + bt = event.get("BusyType", "") + if bt in ("Free", "NoData"): continue - start_str = event.get('StartTime', '') - end_str = event.get('EndTime', '') + start_str = event.get("StartTime", "") + end_str = event.get("EndTime", "") if not start_str or not end_str: continue try: - start = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None) - end = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None) - events.append({'start': start, 'end': end, 'status': bt}) + start = datetime.fromisoformat( + start_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) + events.append({"start": start, "end": end, "status": bt}) except (ValueError, AttributeError): continue @@ -184,22 +192,23 @@ def _get_availability_events( # Helper: get calendar folder ID # ------------------------------------------------------------------ + def _get_calendar_folder_id(client: OWAClient) -> str | None: """Get the calendar folder ID via GetFolder.""" payload = { - '__type': 'GetFolderJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', + "__type": "GetFolderJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", }, - 'Body': { - '__type': 'GetFolderRequest:#Exchange', - 'FolderShape': { - '__type': 'FolderResponseShape:#Exchange', - 'BaseShape': 'IdOnly', + "Body": { + "__type": "GetFolderRequest:#Exchange", + "FolderShape": { + "__type": "FolderResponseShape:#Exchange", + "BaseShape": "IdOnly", }, - 'FolderIds': [ - {'__type': 'DistinguishedFolderId:#Exchange', 'Id': 'calendar'} + "FolderIds": [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "calendar"} ], }, } @@ -218,6 +227,7 @@ def _get_calendar_folder_id(client: OWAClient) -> str | None: # Helper: get own calendar events (from find-free-time.py) # ------------------------------------------------------------------ + def _get_calendar_events( client: OWAClient, folder_id: str, start_date, end_date ) -> list[dict]: @@ -228,34 +238,32 @@ def _get_calendar_events( while True: payload = { - '__type': 'FindItemJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', + "__type": "FindItemJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", }, - 'Body': { - '__type': 'FindItemRequest:#Exchange', - 'ItemShape': { - '__type': 'ItemResponseShape:#Exchange', - 'BaseShape': 'AllProperties', + "Body": { + "__type": "FindItemRequest:#Exchange", + "ItemShape": { + "__type": "ItemResponseShape:#Exchange", + "BaseShape": "AllProperties", }, - 'ParentFolderIds': [ - {'__type': 'FolderId:#Exchange', 'Id': folder_id} - ], - 'Traversal': 'Shallow', - 'Paging': { - '__type': 'IndexedPageView:#Exchange', - 'BasePoint': 'Beginning', - 'Offset': offset, - 'MaxEntriesReturned': batch_size, + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], + "Traversal": "Shallow", + "Paging": { + "__type": "IndexedPageView:#Exchange", + "BasePoint": "Beginning", + "Offset": offset, + "MaxEntriesReturned": batch_size, }, - 'SortOrder': [ + "SortOrder": [ { - '__type': 'SortResults:#Exchange', - 'Order': 'Ascending', - 'Path': { - '__type': 'PropertyUri:#Exchange', - 'FieldURI': 'Start', + "__type": "SortResults:#Exchange", + "Order": "Ascending", + "Path": { + "__type": "PropertyUri:#Exchange", + "FieldURI": "Start", }, } ], @@ -268,29 +276,29 @@ def _get_calendar_events( if not items: break - folder_items = items[0].get('RootFolder', {}).get('Items', []) + folder_items = items[0].get("RootFolder", {}).get("Items", []) if not folder_items: break for item in folder_items: # Skip cancelled events - if item.get('IsCancelled'): + if item.get("IsCancelled"): continue # Skip free/tentative slots - fbt = item.get('FreeBusyType', 'Busy') - if fbt in ['Free', 'NoData']: + fbt = item.get("FreeBusyType", "Busy") + if fbt in ["Free", "NoData"]: continue - start_str = item.get('Start', '') - end_str = item.get('End', '') + start_str = item.get("Start", "") + end_str = item.get("End", "") if not start_str or not end_str: continue try: - start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) - end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + start = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) # Convert to naive datetime for comparison start = start.replace(tzinfo=None) @@ -300,17 +308,19 @@ def _get_calendar_events( if end.date() < start_date or start.date() > end_date: continue - events.append({ - 'start': start, - 'end': end, - 'subject': item.get('Subject', ''), - 'status': fbt, - }) + events.append( + { + "start": start, + "end": end, + "subject": item.get("Subject", ""), + "status": fbt, + } + ) except (ValueError, AttributeError): continue # Check if there are more items - is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) + is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True) if is_last: break @@ -323,6 +333,7 @@ def _get_calendar_events( # Tool: find_free_time # ------------------------------------------------------------------ + @mcp.tool() def find_free_time( start_date: str, @@ -352,8 +363,8 @@ def find_free_time( client = _get_client(ctx) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) @@ -365,13 +376,17 @@ def find_free_time( # Fallback to FindItem (misses recurring event occurrences) folder_id = _get_calendar_folder_id(client) if not folder_id: - return json.dumps({"error": "Could not find calendar folder. Session may have expired."}) + return json.dumps( + { + "error": "Could not find calendar folder. Session may have expired." + } + ) all_busy = _get_calendar_events(client, folder_id, sd, ed) except Exception as e: return json.dumps({"error": str(e)}) # Convert event dicts to (start, end) tuples for _find_free_slots - busy_periods = [(ev['start'], ev['end']) for ev in all_busy] + busy_periods = [(ev["start"], ev["end"]) for ev in all_busy] result = {} current_date = sd @@ -379,8 +394,11 @@ def find_free_time( # Skip weekends if current_date.weekday() < 5: free = _find_free_slots( - busy_periods, current_date, - start_hour, end_hour, duration_minutes, + busy_periods, + current_date, + start_hour, + end_hour, + duration_minutes, ) if free: result[str(current_date)] = [ @@ -400,6 +418,7 @@ def find_free_time( # Tool: find_meeting_time # ------------------------------------------------------------------ + @mcp.tool() def find_meeting_time( emails: str, @@ -431,13 +450,13 @@ def find_meeting_time( """ client = _get_client(ctx) - raw_list = [e.strip() for e in emails.split(',') if e.strip()] + raw_list = [e.strip() for e in emails.split(",") if e.strip()] if not raw_list: return json.dumps({"error": "No email addresses provided."}) try: - sd = datetime.strptime(start_date, '%Y-%m-%d').date() - ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd + sd = datetime.strptime(start_date, "%Y-%m-%d").date() + ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd except ValueError as e: return json.dumps({"error": f"Invalid date format: {e}"}) @@ -445,12 +464,12 @@ def find_meeting_time( email_list = [] resolve_errors = [] for entry in raw_list: - if '@' in entry: + if "@" in entry: email_list.append(entry) else: resolutions = client.resolve_names(entry, full_contact=False) if resolutions: - addr = resolutions[0].get('Mailbox', {}).get('EmailAddress', '') + addr = resolutions[0].get("Mailbox", {}).get("EmailAddress", "") if addr: email_list.append(addr) else: @@ -459,46 +478,52 @@ def find_meeting_time( resolve_errors.append(entry) if not email_list: - return json.dumps({"error": f"Could not resolve any names to email addresses: {resolve_errors}"}) + return json.dumps( + { + "error": f"Could not resolve any names to email addresses: {resolve_errors}" + } + ) # Build mailbox data (reused for each day chunk) mailbox_data = [] for email in email_list: - mailbox_data.append({ - '__type': 'MailboxData:#Exchange', - 'Email': { - '__type': 'EmailAddress:#Exchange', - 'Address': email, - }, - 'AttendeeType': 'Required', - }) + mailbox_data.append( + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": email, + }, + "AttendeeType": "Required", + } + ) # Query the full date range at once (API handles multi-day windows) payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': mailbox_data, - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{sd}T00:00:00', - 'EndTime': f'{ed + timedelta(days=1)}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": mailbox_data, + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{sd}T00:00:00", + "EndTime": f"{ed + timedelta(days=1)}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } @@ -508,57 +533,69 @@ def find_meeting_time( except Exception as e: return json.dumps({"error": str(e)}) - body = data.get('Body', {}) - if 'ErrorCode' in body: - return json.dumps({"error": body.get('FaultMessage', 'Unknown error')}) + body = data.get("Body", {}) + if "ErrorCode" in body: + return json.dumps({"error": body.get("FaultMessage", "Unknown error")}) # Parse availability responses all_busy = [] attendee_info = [] - freebusy_responses = body.get('FreeBusyResponseArray', []) + freebusy_responses = body.get("FreeBusyResponseArray", []) for i, fb_resp in enumerate(freebusy_responses): - fb_view = fb_resp.get('FreeBusyView', {}) - merged_fb = fb_view.get('MergedFreeBusy', '') - email = email_list[i] if i < len(email_list) else f"Person {i+1}" + fb_view = fb_resp.get("FreeBusyView", {}) + merged_fb = fb_view.get("MergedFreeBusy", "") + email = email_list[i] if i < len(email_list) else f"Person {i + 1}" if merged_fb: start_time = datetime.combine(sd, datetime.min.time()) busy_periods = _parse_freebusy_string(merged_fb, start_time) - busy_count = sum(1 for c in merged_fb if c != '0') - free_count = sum(1 for c in merged_fb if c == '0') - attendee_info.append({ - "email": email, - "busy_slots": busy_count, - "free_slots": free_count, - }) + busy_count = sum(1 for c in merged_fb if c != "0") + free_count = sum(1 for c in merged_fb if c == "0") + attendee_info.append( + { + "email": email, + "busy_slots": busy_count, + "free_slots": free_count, + } + ) all_busy.extend(busy_periods) else: # Fallback: parse CalendarEventArray - cal_events_raw = fb_view.get('CalendarEventArray', {}) - cal_events = cal_events_raw.get('Items', []) if isinstance(cal_events_raw, dict) else (cal_events_raw if isinstance(cal_events_raw, list) else []) + cal_events_raw = fb_view.get("CalendarEventArray", {}) + cal_events = ( + cal_events_raw.get("Items", []) + if isinstance(cal_events_raw, dict) + else (cal_events_raw if isinstance(cal_events_raw, list) else []) + ) if cal_events: - attendee_info.append({ - "email": email, - "calendar_events": len(cal_events), - }) + attendee_info.append( + { + "email": email, + "calendar_events": len(cal_events), + } + ) for event in cal_events: - start_str = event.get('StartTime', '') - end_str = event.get('EndTime', '') + start_str = event.get("StartTime", "") + end_str = event.get("EndTime", "") if start_str and end_str: try: - start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) - end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + start = datetime.fromisoformat( + start_str.replace("Z", "+00:00") + ) + end = datetime.fromisoformat(end_str.replace("Z", "+00:00")) all_busy.append((start, end)) except Exception: pass else: - attendee_info.append({ - "email": email, - "status": "no_data", - }) + attendee_info.append( + { + "email": email, + "status": "no_data", + } + ) merged_busy = _merge_busy_periods(all_busy) @@ -568,8 +605,11 @@ def find_meeting_time( while current_date <= ed: if current_date.weekday() < 5: # Skip weekends free = _find_free_slots( - merged_busy, current_date, - start_hour, end_hour, duration_minutes, + merged_busy, + current_date, + start_hour, + end_hour, + duration_minutes, ) if free: free_by_date[str(current_date)] = [ diff --git a/owa_mcp/tools/calendar.py b/owa_mcp/tools/calendar.py index 06dabd7..49cd484 100644 --- a/owa_mcp/tools/calendar.py +++ b/owa_mcp/tools/calendar.py @@ -11,10 +11,10 @@ from datetime import datetime, timedelta from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client -from owa_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html +from owa_mcp.utils import extract_links_from_html, html_to_text def _get_client(ctx: Context) -> OWAClient: @@ -63,9 +63,7 @@ def _get_event_details(client: OWAClient, item_id: str) -> dict: "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], }, } @@ -160,9 +158,7 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict: "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], }, } @@ -202,13 +198,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict: name = mailbox.get("Name", "") addr = mailbox.get("EmailAddress", "") if addr and not addr.startswith("/O="): - result["resolved_required"].append({ - "Mailbox": { - "Name": name, - "EmailAddress": addr, - "RoutingType": "SMTP", + result["resolved_required"].append( + { + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } } - }) + ) # Optional attendees for a in item.get("OptionalAttendees", []) or []: @@ -216,13 +214,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict: name = mailbox.get("Name", "") addr = mailbox.get("EmailAddress", "") if addr and not addr.startswith("/O="): - result["resolved_optional"].append({ - "Mailbox": { - "Name": name, - "EmailAddress": addr, - "RoutingType": "SMTP", + result["resolved_optional"].append( + { + "Mailbox": { + "Name": name, + "EmailAddress": addr, + "RoutingType": "SMTP", + } } - }) + ) return result @@ -331,72 +331,81 @@ def _get_expanded_events( chunk_end = min(current + timedelta(days=chunk_days), end_date) payload = { - '__type': 'GetUserAvailabilityJsonRequest:#Exchange', - 'Header': { - '__type': 'JsonRequestHeaders:#Exchange', - 'RequestServerVersion': 'Exchange2013', - 'TimeZoneContext': { - '__type': 'TimeZoneContext:#Exchange', - 'TimeZoneDefinition': { - '__type': 'TimeZoneDefinitionType:#Exchange', - 'Id': 'Russian Standard Time', + "__type": "GetUserAvailabilityJsonRequest:#Exchange", + "Header": { + "__type": "JsonRequestHeaders:#Exchange", + "RequestServerVersion": "Exchange2013", + "TimeZoneContext": { + "__type": "TimeZoneContext:#Exchange", + "TimeZoneDefinition": { + "__type": "TimeZoneDefinitionType:#Exchange", + "Id": "Russian Standard Time", }, }, }, - 'Body': { - '__type': 'GetUserAvailabilityRequest:#Exchange', - 'MailboxDataArray': [{ - '__type': 'MailboxData:#Exchange', - 'Email': {'__type': 'EmailAddress:#Exchange', 'Address': client.user_email}, - 'AttendeeType': 'Required', - }], - 'FreeBusyViewOptions': { - '__type': 'FreeBusyViewOptions:#Exchange', - 'TimeWindow': { - '__type': 'Duration:#Exchange', - 'StartTime': f'{current}T00:00:00', - 'EndTime': f'{chunk_end}T00:00:00', + "Body": { + "__type": "GetUserAvailabilityRequest:#Exchange", + "MailboxDataArray": [ + { + "__type": "MailboxData:#Exchange", + "Email": { + "__type": "EmailAddress:#Exchange", + "Address": client.user_email, + }, + "AttendeeType": "Required", + } + ], + "FreeBusyViewOptions": { + "__type": "FreeBusyViewOptions:#Exchange", + "TimeWindow": { + "__type": "Duration:#Exchange", + "StartTime": f"{current}T00:00:00", + "EndTime": f"{chunk_end}T00:00:00", }, - 'MergedFreeBusyIntervalInMinutes': 30, - 'RequestedView': 'DetailedMerged', + "MergedFreeBusyIntervalInMinutes": 30, + "RequestedView": "DetailedMerged", }, }, } try: - data = client.request('GetUserAvailability', payload) - body = data.get('Body', {}) - for fb_resp in body.get('FreeBusyResponseArray', []): - fb_view = fb_resp.get('FreeBusyView', {}) - cal_events = fb_view.get('CalendarEventArray', {}) + data = client.request("GetUserAvailability", payload) + body = data.get("Body", {}) + for fb_resp in body.get("FreeBusyResponseArray", []): + fb_view = fb_resp.get("FreeBusyView", {}) + cal_events = fb_view.get("CalendarEventArray", {}) items = ( - cal_events.get('Items', []) + cal_events.get("Items", []) if isinstance(cal_events, dict) else (cal_events if isinstance(cal_events, list) else []) ) for event in items: - bt = event.get('BusyType', '') - details = event.get('CalendarEventDetails', {}) - subject = details.get('Subject', '') if details else '' - location = details.get('Location', '') if details else '' - is_meeting = details.get('IsMeeting', False) if details else False - is_recurring = details.get('IsRecurring', False) if details else False + bt = event.get("BusyType", "") + details = event.get("CalendarEventDetails", {}) + subject = details.get("Subject", "") if details else "" + location = details.get("Location", "") if details else "" + is_meeting = details.get("IsMeeting", False) if details else False + is_recurring = ( + details.get("IsRecurring", False) if details else False + ) - expanded.append({ - 'subject': subject or '(No subject)', - 'start': event.get('StartTime', ''), - 'end': event.get('EndTime', ''), - 'busy_type': bt, - 'location': location, - 'is_meeting': is_meeting, - 'is_recurring': is_recurring, - }) + expanded.append( + { + "subject": subject or "(No subject)", + "start": event.get("StartTime", ""), + "end": event.get("EndTime", ""), + "busy_type": bt, + "location": location, + "is_meeting": is_meeting, + "is_recurring": is_recurring, + } + ) except Exception: pass current = chunk_end - return sorted(expanded, key=lambda x: x.get('start', '')) + return sorted(expanded, key=lambda x: x.get("start", "")) @mcp.tool() @@ -438,15 +447,17 @@ def get_calendar_events( ) events = [] for ev in expanded: - events.append({ - "subject": ev['subject'], - "start": ev['start'], - "end": ev['end'], - "location": ev.get('location', ''), - "busy_type": ev.get('busy_type', ''), - "is_meeting": ev.get('is_meeting', False), - "is_recurring": ev.get('is_recurring', False), - }) + events.append( + { + "subject": ev["subject"], + "start": ev["start"], + "end": ev["end"], + "location": ev.get("location", ""), + "busy_type": ev.get("busy_type", ""), + "is_meeting": ev.get("is_meeting", False), + "is_recurring": ev.get("is_recurring", False), + } + ) return json.dumps(events, ensure_ascii=False) # --- Default mode --- @@ -476,9 +487,7 @@ def get_calendar_events( "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ParentFolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "Traversal": "Shallow", "CalendarView": { "__type": "CalendarView:#Exchange", @@ -722,12 +731,16 @@ def create_meeting( ] return json.dumps(result, ensure_ascii=False) else: - return json.dumps({ - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - }) + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) - return json.dumps({"success": True, "subject": subject, "note": "No confirmation details"}) + return json.dumps( + {"success": True, "subject": subject, "note": "No confirmation details"} + ) # ------------------------------------------------------------------ @@ -791,8 +804,12 @@ def update_meeting( orig_start_str = orig.get("start", "") orig_end_str = orig.get("end", "") try: - orig_start = datetime.fromisoformat(orig_start_str.replace("Z", "+00:00")).replace(tzinfo=None) - orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(tzinfo=None) + orig_start = datetime.fromisoformat( + orig_start_str.replace("Z", "+00:00") + ).replace(tzinfo=None) + orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) orig_duration = int((orig_end - orig_start).total_seconds() / 60) except (ValueError, AttributeError): orig_start = None @@ -821,7 +838,9 @@ def update_meeting( new_start = orig_start new_end = orig_end else: - return json.dumps({"error": "Cannot determine meeting time. Provide date and start_time."}) + return json.dumps( + {"error": "Cannot determine meeting time. Provide date and start_time."} + ) new_location = location if location is not None else orig.get("location", "") @@ -845,9 +864,7 @@ def update_meeting( }, "Body": { "__type": "DeleteItemRequest:#Exchange", - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], "DeleteType": "MoveToDeletedItems", "SendMeetingCancellations": "SendToAllAndSaveCopy", "SuppressReadReceipts": True, @@ -935,7 +952,9 @@ def update_meeting( try: data = client.request("CreateCalendarEvent", create_payload) except Exception as e: - return json.dumps({"error": f"Original cancelled but failed to create new: {e}"}) + return json.dumps( + {"error": f"Original cancelled but failed to create new: {e}"} + ) body = data.get("Body", {}) if "ErrorCode" in body: @@ -959,12 +978,16 @@ def update_meeting( return json.dumps(result, ensure_ascii=False) if resp_items: - return json.dumps({ - "error": resp_items[0].get("MessageText", "Unknown error"), - "response_code": resp_items[0].get("ResponseCode", ""), - }) + return json.dumps( + { + "error": resp_items[0].get("MessageText", "Unknown error"), + "response_code": resp_items[0].get("ResponseCode", ""), + } + ) - return json.dumps({"success": True, "subject": new_subject, "note": "No confirmation details"}) + return json.dumps( + {"success": True, "subject": new_subject, "note": "No confirmation details"} + ) # ------------------------------------------------------------------ @@ -997,9 +1020,7 @@ def cancel_meeting( }, "Body": { "__type": "DeleteItemRequest:#Exchange", - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], "DeleteType": "MoveToDeletedItems", "SendMeetingCancellations": "SendToAllAndSaveCopy", "SuppressReadReceipts": True, @@ -1014,10 +1035,12 @@ def cancel_meeting( if item.get("ResponseClass") == "Success": return json.dumps({"success": True, "message": "Meeting cancelled"}) else: - return json.dumps({ - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - }) + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) # DeleteItem may return empty on success body = data.get("Body", {}) @@ -1060,9 +1083,11 @@ def respond_to_meeting( response_type = response_types.get(response) if not response_type: - return json.dumps({ - "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." - }) + return json.dumps( + { + "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." + } + ) response_item = { "__type": response_type, @@ -1098,16 +1123,20 @@ def respond_to_meeting( if items: item = items[0] if item.get("ResponseClass") == "Success": - return json.dumps({ - "success": True, - "response": response, - "message": f"Meeting {response.lower()}ed", - }) + return json.dumps( + { + "success": True, + "response": response, + "message": f"Meeting {response.lower()}ed", + } + ) else: - return json.dumps({ - "error": item.get("MessageText", "Unknown error"), - "response_code": item.get("ResponseCode", ""), - }) + return json.dumps( + { + "error": item.get("MessageText", "Unknown error"), + "response_code": item.get("ResponseCode", ""), + } + ) return json.dumps({"error": "No response from server"}) @@ -1147,9 +1176,7 @@ def download_event_attachments( "__type": "ItemResponseShape:#Exchange", "BaseShape": "AllProperties", }, - "ItemIds": [ - {"__type": "ItemId:#Exchange", "Id": item_id} - ], + "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}], }, } @@ -1162,28 +1189,43 @@ def download_event_attachments( continue for item in msg["Items"]: for att in item.get("Attachments", []): - attachments.append({ - "name": att.get("Name", ""), - "size": att.get("Size", 0), - "content_type": att.get("ContentType", ""), - "attachment_id": att.get("AttachmentId", {}).get("Id", ""), - "is_inline": att.get("IsInline", False), - }) + attachments.append( + { + "name": att.get("Name", ""), + "size": att.get("Size", 0), + "content_type": att.get("ContentType", ""), + "attachment_id": att.get("AttachmentId", {}).get("Id", ""), + "is_inline": att.get("IsInline", False), + } + ) break if not attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No attachments found on this event."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No attachments found on this event.", + } + ) # Filter to non-inline file attachments file_attachments = [ - a for a in attachments + a + for a in attachments if a.get("attachment_id") and not a.get("is_inline", False) ] if not file_attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No downloadable file attachments."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No downloadable file attachments.", + } + ) os.makedirs(target_folder, exist_ok=True) @@ -1223,17 +1265,21 @@ def download_event_attachments( with open(filepath, "wb") as f: f.write(content) - downloaded.append({ - "name": filename, - "path": filepath, - "size": len(content), - "content_type": content_type, - }) + downloaded.append( + { + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + } + ) except Exception as e: - errors.append({ - "name": att.get("name", "unknown"), - "error": str(e), - }) + errors.append( + { + "name": att.get("name", "unknown"), + "error": str(e), + } + ) result = { "success": len(errors) == 0, @@ -1308,12 +1354,14 @@ def get_event_links( links = extract_links_from_html(body_val) break - return json.dumps({ - "item_id": item_id, - "subject": subject, - "links": links, - "count": len(links), - }) + return json.dumps( + { + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + } + ) except Exception as e: return json.dumps({"error": f"Failed to extract event links: {e}"}) diff --git a/owa_mcp/tools/email.py b/owa_mcp/tools/email.py index 681fa03..a7e437c 100644 --- a/owa_mcp/tools/email.py +++ b/owa_mcp/tools/email.py @@ -8,10 +8,10 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient, SessionExpiredError +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client -from owa_mcp.utils import html_to_text, extract_links_from_html +from owa_mcp.utils import extract_links_from_html, html_to_text def _get_client(ctx: Context) -> OWAClient: @@ -215,7 +215,12 @@ def _get_item_details(client: OWAClient, item_id: str) -> dict: item_type = item.get("__type", "") if any( t in item_type - for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation", "CalendarItem") + for t in ( + "MeetingRequest", + "MeetingResponse", + "MeetingCancellation", + "CalendarItem", + ) ): result["location"] = item.get( "Location", @@ -331,9 +336,7 @@ def get_emails( find_body = { "__type": "FindItemRequest:#Exchange", "ItemShape": item_shape, - "ParentFolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "Traversal": "Shallow", "Paging": { "__type": "IndexedPageView:#Exchange", @@ -392,18 +395,19 @@ def get_emails( if not items: return json.dumps( - {"item_ids": [], "count": 0} if ids_only - else {"emails": [], "count": 0} + {"item_ids": [], "count": 0} if ids_only else {"emails": [], "count": 0} ) if ids_only: result = [] for item in items: - result.append({ - "item_id": item.get("ItemId", {}).get("Id", ""), - "date": item.get("DateTimeReceived", ""), - "subject": item.get("Subject", ""), - }) + result.append( + { + "item_id": item.get("ItemId", {}).get("Id", ""), + "date": item.get("DateTimeReceived", ""), + "subject": item.get("Subject", ""), + } + ) return json.dumps({"item_ids": result, "count": len(result)}) emails = [] @@ -763,9 +767,7 @@ def move_email( if not folder_id: return json.dumps({"error": f"Folder '{target_folder}' not found."}) - items = [ - {"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids - ] + items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] payload = { "__type": "MoveItemJsonRequest:#Exchange", @@ -824,9 +826,7 @@ def delete_email( try: client = _get_client(ctx) - items = [ - {"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids - ] + items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids] payload = { "__type": "DeleteItemJsonRequest:#Exchange", @@ -887,18 +887,31 @@ def download_attachments( attachments = details.get("attachments", []) if not attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No attachments found."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No attachments found.", + } + ) # Filter to non-inline file attachments with IDs file_attachments = [ - a for a in attachments + a + for a in attachments if a.get("attachment_id") and not a.get("is_inline", False) ] if not file_attachments: - return json.dumps({"success": True, "downloaded": [], "count": 0, - "message": "No downloadable file attachments."}) + return json.dumps( + { + "success": True, + "downloaded": [], + "count": 0, + "message": "No downloadable file attachments.", + } + ) os.makedirs(target_folder, exist_ok=True) @@ -938,17 +951,21 @@ def download_attachments( with open(filepath, "wb") as f: f.write(content) - downloaded.append({ - "name": filename, - "path": filepath, - "size": len(content), - "content_type": content_type, - }) + downloaded.append( + { + "name": filename, + "path": filepath, + "size": len(content), + "content_type": content_type, + } + ) except Exception as e: - errors.append({ - "name": att.get("name", "unknown"), - "error": str(e), - }) + errors.append( + { + "name": att.get("name", "unknown"), + "error": str(e), + } + ) result = { "success": len(errors) == 0, @@ -1021,12 +1038,14 @@ def get_email_links( links = extract_links_from_html(body_val) break - return json.dumps({ - "item_id": item_id, - "subject": subject, - "links": links, - "count": len(links), - }) + return json.dumps( + { + "item_id": item_id, + "subject": subject, + "links": links, + "count": len(links), + } + ) except SessionExpiredError as e: return json.dumps({"error": str(e)}) @@ -1107,16 +1126,22 @@ async def search_emails( max_results = max(1, min(max_results, 100)) if search_scope not in ("all", "subject", "body", "from"): - return json.dumps({"error": f"invalid search_scope: {search_scope}", "results": []}) + return json.dumps( + {"error": f"invalid search_scope: {search_scope}", "results": []} + ) restriction = _build_search_restriction(query.strip(), search_scope) if restriction is None: - return json.dumps({"error": f"unsupported search_scope: {search_scope}", "results": []}) + return json.dumps( + {"error": f"unsupported search_scope: {search_scope}", "results": []} + ) if folder_id: parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}] else: - parent_folder = [{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}] + parent_folder = [ + {"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"} + ] payload = { "__type": "FindItemJsonRequest:#Exchange", @@ -1153,6 +1178,7 @@ async def search_emails( body_type = item.get("Body", {}).get("BodyType", "HTML") if body_type == "HTML" and body_html: import re + body_preview = re.sub(r"<[^>]+>", " ", body_html) body_preview = " ".join(body_preview.split())[:200] else: @@ -1163,13 +1189,16 @@ async def search_emails( if len(results) >= max_results: break - return json.dumps({ - "query": query.strip(), - "search_scope": search_scope, - "folder_id": folder_id or "all", - "total_results": len(results), - "results": results, - }, ensure_ascii=False) + return json.dumps( + { + "query": query.strip(), + "search_scope": search_scope, + "folder_id": folder_id or "all", + "total_results": len(results), + "results": results, + }, + ensure_ascii=False, + ) except SessionExpiredError as e: return json.dumps({"error": str(e)}) except Exception as e: diff --git a/owa_mcp/tools/folders.py b/owa_mcp/tools/folders.py index 6ff7603..9ce08a5 100644 --- a/owa_mcp/tools/folders.py +++ b/owa_mcp/tools/folders.py @@ -7,15 +7,24 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client - _DISTINGUISHED_NAMES = { - "msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems", - "junkemail", "outbox", "calendar", "contacts", "tasks", "notes", - "journal", "searchfolders", + "msgfolderroot", + "inbox", + "sentitems", + "drafts", + "deleteditems", + "junkemail", + "outbox", + "calendar", + "contacts", + "tasks", + "notes", + "journal", + "searchfolders", } _HEADER_TZ = { @@ -68,36 +77,40 @@ def check_session(ctx: Context = None) -> str: "__type": "FolderResponseShape:#Exchange", "BaseShape": "Default", }, - "FolderIds": [ - {"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"} - ], + "FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}], }, } try: data = client.request("GetFolder", payload) except Exception as e: - return json.dumps({ - "authenticated": False, - "error": str(e), - "cookie_file": str(client.cookie_file), - }) + return json.dumps( + { + "authenticated": False, + "error": str(e), + "cookie_file": str(client.cookie_file), + } + ) for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "authenticated": True, - "mailbox": folder.get("DisplayName", ""), - "unread": folder.get("UnreadCount", 0), - "cookie_file": str(client.cookie_file), - }) + return json.dumps( + { + "authenticated": True, + "mailbox": folder.get("DisplayName", ""), + "unread": folder.get("UnreadCount", 0), + "cookie_file": str(client.cookie_file), + } + ) - return json.dumps({ - "authenticated": False, - "error": "Unexpected response", - "cookie_file": str(client.cookie_file), - }) + return json.dumps( + { + "authenticated": False, + "error": "Unexpected response", + "cookie_file": str(client.cookie_file), + } + ) @mcp.tool() @@ -159,13 +172,15 @@ def get_folders( for msg in client.extract_items(data): if "RootFolder" in msg and "Folders" in msg["RootFolder"]: for f in msg["RootFolder"]["Folders"]: - folders.append({ - "name": f.get("DisplayName", "Unknown"), - "id": f.get("FolderId", {}).get("Id", ""), - "total_count": f.get("TotalCount", 0), - "unread_count": f.get("UnreadCount", 0), - "child_folder_count": f.get("ChildFolderCount", 0), - }) + folders.append( + { + "name": f.get("DisplayName", "Unknown"), + "id": f.get("FolderId", {}).get("Id", ""), + "total_count": f.get("TotalCount", 0), + "unread_count": f.get("UnreadCount", 0), + "child_folder_count": f.get("ChildFolderCount", 0), + } + ) return json.dumps(folders, ensure_ascii=False) @@ -216,11 +231,13 @@ def create_folder( for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "success": True, - "name": name, - "id": folder.get("FolderId", {}).get("Id", ""), - }) + return json.dumps( + { + "success": True, + "name": name, + "id": folder.get("FolderId", {}).get("Id", ""), + } + ) return json.dumps({"error": "Unexpected response", "raw": str(data)}) @@ -280,11 +297,13 @@ def rename_folder( for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "success": True, - "new_name": new_name, - "id": folder.get("FolderId", {}).get("Id", ""), - }) + return json.dumps( + { + "success": True, + "new_name": new_name, + "id": folder.get("FolderId", {}).get("Id", ""), + } + ) return json.dumps({"error": "Unexpected response", "raw": str(data)}) @@ -316,9 +335,7 @@ def empty_folder( "Header": _HEADER_TZ, "Body": { "__type": "EmptyFolderRequest:#Exchange", - "FolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "DeleteType": delete_type, "DeleteSubFolders": delete_sub_folders, "SuppressReadReceipt": True, @@ -362,9 +379,7 @@ def delete_folder( "Header": _HEADER_TZ, "Body": { "__type": "DeleteFolderRequest:#Exchange", - "FolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "DeleteType": delete_type, }, } @@ -405,9 +420,7 @@ def move_folder( "Header": _HEADER_TZ, "Body": { "__type": "MoveFolderRequest:#Exchange", - "FolderIds": [ - {"__type": "FolderId:#Exchange", "Id": folder_id} - ], + "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}], "ToFolderId": { "__type": "TargetFolderId:#Exchange", "BaseFolderId": _folder_id_dict(target_parent_folder_id), @@ -423,9 +436,11 @@ def move_folder( for msg in client.extract_items(data): if "Folders" in msg: folder = msg["Folders"][0] - return json.dumps({ - "success": True, - "folder_id": folder.get("FolderId", {}).get("Id", ""), - }) + return json.dumps( + { + "success": True, + "folder_id": folder.get("FolderId", {}).get("Id", ""), + } + ) return json.dumps({"error": "Unexpected response", "raw": str(data)}) diff --git a/owa_mcp/tools/people.py b/owa_mcp/tools/people.py index 88a7db8..df6f449 100644 --- a/owa_mcp/tools/people.py +++ b/owa_mcp/tools/people.py @@ -7,8 +7,8 @@ import json from mcp.server.fastmcp import Context -from owa_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient +from owa_mcp.server import AppContext, mcp from owa_mcp.server_lifecycle import require_client @@ -82,10 +82,12 @@ def _parse_person(resolution: dict) -> dict: # Direct reports for report in contact.get("DirectReports", []): - person["direct_reports"].append({ - "name": report.get("Name", ""), - "email": report.get("EmailAddress", ""), - }) + person["direct_reports"].append( + { + "name": report.get("Name", ""), + "email": report.get("EmailAddress", ""), + } + ) return person diff --git a/owa_mcp/utils.py b/owa_mcp/utils.py index 8f8f996..ddcff53 100644 --- a/owa_mcp/utils.py +++ b/owa_mcp/utils.py @@ -20,9 +20,7 @@ def html_to_text(html_content: str) -> str: text = re.sub( r"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE ) - text = re.sub( - r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE - ) + text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r"", "\n", text, flags=re.IGNORECASE) text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) diff --git a/pyproject.toml b/pyproject.toml index 205b3b4..a69a537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,11 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] include = ["owa_mcp*"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] +ignore = ["E501"]