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