w
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+20
-24
@@ -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
|
||||
|
||||
+7
-6
@@ -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.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)
|
||||
|
||||
+27
-11
@@ -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,7 +174,9 @@ class SessionKeepalive:
|
||||
case the user re-authenticated while the server was running.
|
||||
"""
|
||||
try:
|
||||
self._client.request("GetFolder", {
|
||||
self._client.request(
|
||||
"GetFolder",
|
||||
{
|
||||
"__type": "GetFolderJsonRequest:#Exchange",
|
||||
"Header": {
|
||||
"__type": "JsonRequestHeaders:#Exchange",
|
||||
@@ -180,25 +188,33 @@ class SessionKeepalive:
|
||||
"__type": "FolderResponseShape:#Exchange",
|
||||
"BaseShape": "IdOnly",
|
||||
},
|
||||
"FolderIds": [{
|
||||
"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,
|
||||
|
||||
+125
-98
@@ -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,
|
||||
mailbox_data = [
|
||||
{
|
||||
"__type": "MailboxData:#Exchange",
|
||||
"Email": {
|
||||
"__type": "EmailAddress:#Exchange",
|
||||
"Address": email,
|
||||
},
|
||||
'AttendeeType': 'Required',
|
||||
} for email in batch]
|
||||
"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({
|
||||
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({
|
||||
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({
|
||||
return json.dumps(
|
||||
{
|
||||
"period": {"start": start_date, "end": end_date, "workdays": workdays},
|
||||
"stats": stats,
|
||||
}, ensure_ascii=False)
|
||||
},
|
||||
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
|
||||
@@ -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({
|
||||
result_contacts.append(
|
||||
{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"meetings": count,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
return json.dumps(
|
||||
{
|
||||
"period": {"start": start_date, "end": end_date},
|
||||
"total_meetings": total_expanded,
|
||||
"unique_contacts": len(contacts),
|
||||
"contacts": result_contacts,
|
||||
}, ensure_ascii=False)
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
+16
-8
@@ -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({
|
||||
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({
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Logged in. Session cookies saved to {client.cookie_file}.",
|
||||
})
|
||||
}
|
||||
)
|
||||
else:
|
||||
return json.dumps({
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Cookies loaded but session verification failed. The cookies may have expired.",
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
+191
-151
@@ -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,
|
||||
mailbox_data.append(
|
||||
{
|
||||
"__type": "MailboxData:#Exchange",
|
||||
"Email": {
|
||||
"__type": "EmailAddress:#Exchange",
|
||||
"Address": email,
|
||||
},
|
||||
'AttendeeType': 'Required',
|
||||
})
|
||||
"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', '')
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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)] = [
|
||||
|
||||
+158
-110
@@ -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({
|
||||
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({
|
||||
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,
|
||||
},
|
||||
'MergedFreeBusyIntervalInMinutes': 30,
|
||||
'RequestedView': 'DetailedMerged',
|
||||
"AttendeeType": "Required",
|
||||
}
|
||||
],
|
||||
"FreeBusyViewOptions": {
|
||||
"__type": "FreeBusyViewOptions:#Exchange",
|
||||
"TimeWindow": {
|
||||
"__type": "Duration:#Exchange",
|
||||
"StartTime": f"{current}T00:00:00",
|
||||
"EndTime": f"{chunk_end}T00:00:00",
|
||||
},
|
||||
"MergedFreeBusyIntervalInMinutes": 30,
|
||||
"RequestedView": "DetailedMerged",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
data = client.request('GetUserAvailability', payload)
|
||||
body = data.get('Body', {})
|
||||
for fb_resp in body.get('FreeBusyResponseArray', []):
|
||||
fb_view = fb_resp.get('FreeBusyView', {})
|
||||
cal_events = fb_view.get('CalendarEventArray', {})
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"response": response,
|
||||
"message": f"Meeting {response.lower()}ed",
|
||||
})
|
||||
}
|
||||
)
|
||||
else:
|
||||
return json.dumps({
|
||||
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({
|
||||
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({
|
||||
downloaded.append(
|
||||
{
|
||||
"name": filename,
|
||||
"path": filepath,
|
||||
"size": len(content),
|
||||
"content_type": content_type,
|
||||
})
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
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({
|
||||
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}"})
|
||||
|
||||
+61
-32
@@ -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({
|
||||
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({
|
||||
downloaded.append(
|
||||
{
|
||||
"name": filename,
|
||||
"path": filepath,
|
||||
"size": len(content),
|
||||
"content_type": content_type,
|
||||
})
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
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({
|
||||
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({
|
||||
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)
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except SessionExpiredError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
|
||||
+46
-31
@@ -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({
|
||||
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({
|
||||
return json.dumps(
|
||||
{
|
||||
"authenticated": True,
|
||||
"mailbox": folder.get("DisplayName", ""),
|
||||
"unread": folder.get("UnreadCount", 0),
|
||||
"cookie_file": str(client.cookie_file),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"folder_id": folder.get("FolderId", {}).get("Id", ""),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps({"error": "Unexpected response", "raw": str(data)})
|
||||
|
||||
@@ -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({
|
||||
person["direct_reports"].append(
|
||||
{
|
||||
"name": report.get("Name", ""),
|
||||
"email": report.get("EmailAddress", ""),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return person
|
||||
|
||||
|
||||
+1
-3
@@ -20,9 +20,7 @@ def html_to_text(html_content: str) -> str:
|
||||
text = re.sub(
|
||||
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
text = re.sub(
|
||||
r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | 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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user