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"]