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):
|
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
@@ -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
|
||||||
|
|||||||
+9
-8
@@ -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.analytics # noqa: E402, F401
|
||||||
import owa_mcp.tools.calendar # noqa: E402, F401
|
import owa_mcp.tools.auth # noqa: E402, F401
|
||||||
import owa_mcp.tools.people # noqa: E402, F401
|
import owa_mcp.tools.availability # noqa: E402, F401
|
||||||
import owa_mcp.tools.folders # noqa: E402, F401
|
import owa_mcp.tools.calendar # noqa: E402, F401
|
||||||
import owa_mcp.tools.availability # noqa: E402, F401
|
import owa_mcp.tools.email # noqa: E402, F401
|
||||||
import owa_mcp.tools.analytics # noqa: E402, F401
|
import owa_mcp.tools.folders # noqa: E402, F401
|
||||||
import owa_mcp.tools.auth # 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)
|
||||||
|
|||||||
+39
-23
@@ -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,37 +174,47 @@ 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(
|
||||||
"__type": "GetFolderJsonRequest:#Exchange",
|
"GetFolder",
|
||||||
"Header": {
|
{
|
||||||
"__type": "JsonRequestHeaders:#Exchange",
|
"__type": "GetFolderJsonRequest:#Exchange",
|
||||||
"RequestServerVersion": "Exchange2013",
|
"Header": {
|
||||||
},
|
"__type": "JsonRequestHeaders:#Exchange",
|
||||||
"Body": {
|
"RequestServerVersion": "Exchange2013",
|
||||||
"__type": "GetFolderRequest:#Exchange",
|
},
|
||||||
"FolderShape": {
|
"Body": {
|
||||||
"__type": "FolderResponseShape:#Exchange",
|
"__type": "GetFolderRequest:#Exchange",
|
||||||
"BaseShape": "IdOnly",
|
"FolderShape": {
|
||||||
|
"__type": "FolderResponseShape:#Exchange",
|
||||||
|
"BaseShape": "IdOnly",
|
||||||
|
},
|
||||||
|
"FolderIds": [
|
||||||
|
{
|
||||||
|
"__type": "DistinguishedFolderId:#Exchange",
|
||||||
|
"Id": "inbox",
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"FolderIds": [{
|
|
||||||
"__type": "DistinguishedFolderId:#Exchange",
|
|
||||||
"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,
|
||||||
|
|||||||
+148
-121
@@ -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',
|
},
|
||||||
} for email in batch]
|
"AttendeeType": "Required",
|
||||||
|
}
|
||||||
|
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,
|
{
|
||||||
"email": email or "not_found",
|
"name": display,
|
||||||
"total_meetings": 0,
|
"email": email or "not_found",
|
||||||
"meetings_per_workday": 0,
|
"total_meetings": 0,
|
||||||
"days_with_meetings": 0,
|
"meetings_per_workday": 0,
|
||||||
"workdays": workdays,
|
"days_with_meetings": 0,
|
||||||
})
|
"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,
|
{
|
||||||
"email": email,
|
"name": display,
|
||||||
"total_meetings": total,
|
"email": email,
|
||||||
"meetings_per_workday": avg,
|
"total_meetings": total,
|
||||||
"days_with_meetings": unique_days,
|
"meetings_per_workday": avg,
|
||||||
"workdays": workdays,
|
"days_with_meetings": unique_days,
|
||||||
})
|
"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},
|
{
|
||||||
"stats": stats,
|
"period": {"start": start_date, "end": end_date, "workdays": workdays},
|
||||||
}, ensure_ascii=False)
|
"stats": stats,
|
||||||
|
},
|
||||||
|
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,
|
{
|
||||||
"email": email,
|
"name": name,
|
||||||
"meetings": count,
|
"email": email,
|
||||||
})
|
"meetings": count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"period": {"start": start_date, "end": end_date},
|
{
|
||||||
"total_meetings": total_expanded,
|
"period": {"start": start_date, "end": end_date},
|
||||||
"unique_contacts": len(contacts),
|
"total_meetings": total_expanded,
|
||||||
"contacts": result_contacts,
|
"unique_contacts": len(contacts),
|
||||||
}, ensure_ascii=False)
|
"contacts": result_contacts,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|||||||
+22
-14
@@ -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,
|
{
|
||||||
"error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.",
|
"success": False,
|
||||||
})
|
"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,
|
{
|
||||||
"message": f"Logged in. Session cookies saved to {client.cookie_file}.",
|
"success": True,
|
||||||
})
|
"message": f"Logged in. Session cookies saved to {client.cookie_file}.",
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"success": False,
|
{
|
||||||
"error": "Cookies loaded but session verification failed. The cookies may have expired.",
|
"success": False,
|
||||||
})
|
"error": "Cookies loaded but session verification failed. The cookies may have expired.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
+200
-160
@@ -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,
|
{
|
||||||
"busy_slots": busy_count,
|
"email": email,
|
||||||
"free_slots": free_count,
|
"busy_slots": busy_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,
|
{
|
||||||
"calendar_events": len(cal_events),
|
"email": email,
|
||||||
})
|
"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,
|
{
|
||||||
"status": "no_data",
|
"email": email,
|
||||||
})
|
"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)] = [
|
||||||
|
|||||||
+193
-145
@@ -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": {
|
{
|
||||||
"Name": name,
|
"Mailbox": {
|
||||||
"EmailAddress": addr,
|
"Name": name,
|
||||||
"RoutingType": "SMTP",
|
"EmailAddress": addr,
|
||||||
|
"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": {
|
{
|
||||||
"Name": name,
|
"Mailbox": {
|
||||||
"EmailAddress": addr,
|
"Name": name,
|
||||||
"RoutingType": "SMTP",
|
"EmailAddress": addr,
|
||||||
|
"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': {
|
"AttendeeType": "Required",
|
||||||
'__type': 'Duration:#Exchange',
|
}
|
||||||
'StartTime': f'{current}T00:00:00',
|
],
|
||||||
'EndTime': f'{chunk_end}T00:00:00',
|
"FreeBusyViewOptions": {
|
||||||
|
"__type": "FreeBusyViewOptions:#Exchange",
|
||||||
|
"TimeWindow": {
|
||||||
|
"__type": "Duration:#Exchange",
|
||||||
|
"StartTime": f"{current}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 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"),
|
{
|
||||||
"response_code": item.get("ResponseCode", ""),
|
"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_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"),
|
{
|
||||||
"response_code": resp_items[0].get("ResponseCode", ""),
|
"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": {
|
"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"),
|
{
|
||||||
"response_code": item.get("ResponseCode", ""),
|
"error": item.get("MessageText", "Unknown error"),
|
||||||
})
|
"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,
|
{
|
||||||
"response": response,
|
"success": True,
|
||||||
"message": f"Meeting {response.lower()}ed",
|
"response": response,
|
||||||
})
|
"message": f"Meeting {response.lower()}ed",
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"error": item.get("MessageText", "Unknown error"),
|
{
|
||||||
"response_code": item.get("ResponseCode", ""),
|
"error": item.get("MessageText", "Unknown error"),
|
||||||
})
|
"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", ""),
|
{
|
||||||
"size": att.get("Size", 0),
|
"name": att.get("Name", ""),
|
||||||
"content_type": att.get("ContentType", ""),
|
"size": att.get("Size", 0),
|
||||||
"attachment_id": att.get("AttachmentId", {}).get("Id", ""),
|
"content_type": att.get("ContentType", ""),
|
||||||
"is_inline": att.get("IsInline", False),
|
"attachment_id": att.get("AttachmentId", {}).get("Id", ""),
|
||||||
})
|
"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,
|
{
|
||||||
"path": filepath,
|
"name": filename,
|
||||||
"size": len(content),
|
"path": filepath,
|
||||||
"content_type": content_type,
|
"size": len(content),
|
||||||
})
|
"content_type": content_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({
|
errors.append(
|
||||||
"name": att.get("name", "unknown"),
|
{
|
||||||
"error": str(e),
|
"name": att.get("name", "unknown"),
|
||||||
})
|
"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,
|
{
|
||||||
"subject": subject,
|
"item_id": item_id,
|
||||||
"links": links,
|
"subject": subject,
|
||||||
"count": len(links),
|
"links": 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}"})
|
||||||
|
|||||||
+79
-50
@@ -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", ""),
|
{
|
||||||
"date": item.get("DateTimeReceived", ""),
|
"item_id": item.get("ItemId", {}).get("Id", ""),
|
||||||
"subject": item.get("Subject", ""),
|
"date": item.get("DateTimeReceived", ""),
|
||||||
})
|
"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,
|
{
|
||||||
"path": filepath,
|
"name": filename,
|
||||||
"size": len(content),
|
"path": filepath,
|
||||||
"content_type": content_type,
|
"size": len(content),
|
||||||
})
|
"content_type": content_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({
|
errors.append(
|
||||||
"name": att.get("name", "unknown"),
|
{
|
||||||
"error": str(e),
|
"name": att.get("name", "unknown"),
|
||||||
})
|
"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,
|
{
|
||||||
"subject": subject,
|
"item_id": item_id,
|
||||||
"links": links,
|
"subject": subject,
|
||||||
"count": len(links),
|
"links": 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(),
|
{
|
||||||
"search_scope": search_scope,
|
"query": query.strip(),
|
||||||
"folder_id": folder_id or "all",
|
"search_scope": search_scope,
|
||||||
"total_results": len(results),
|
"folder_id": folder_id or "all",
|
||||||
"results": results,
|
"total_results": len(results),
|
||||||
}, ensure_ascii=False)
|
"results": results,
|
||||||
|
},
|
||||||
|
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:
|
||||||
|
|||||||
+69
-54
@@ -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,
|
{
|
||||||
"error": str(e),
|
"authenticated": False,
|
||||||
"cookie_file": str(client.cookie_file),
|
"error": str(e),
|
||||||
})
|
"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,
|
{
|
||||||
"mailbox": folder.get("DisplayName", ""),
|
"authenticated": True,
|
||||||
"unread": folder.get("UnreadCount", 0),
|
"mailbox": folder.get("DisplayName", ""),
|
||||||
"cookie_file": str(client.cookie_file),
|
"unread": folder.get("UnreadCount", 0),
|
||||||
})
|
"cookie_file": str(client.cookie_file),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps(
|
||||||
"authenticated": False,
|
{
|
||||||
"error": "Unexpected response",
|
"authenticated": False,
|
||||||
"cookie_file": str(client.cookie_file),
|
"error": "Unexpected response",
|
||||||
})
|
"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"),
|
{
|
||||||
"id": f.get("FolderId", {}).get("Id", ""),
|
"name": f.get("DisplayName", "Unknown"),
|
||||||
"total_count": f.get("TotalCount", 0),
|
"id": f.get("FolderId", {}).get("Id", ""),
|
||||||
"unread_count": f.get("UnreadCount", 0),
|
"total_count": f.get("TotalCount", 0),
|
||||||
"child_folder_count": f.get("ChildFolderCount", 0),
|
"unread_count": f.get("UnreadCount", 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,
|
{
|
||||||
"name": name,
|
"success": True,
|
||||||
"id": folder.get("FolderId", {}).get("Id", ""),
|
"name": name,
|
||||||
})
|
"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,
|
{
|
||||||
"new_name": new_name,
|
"success": True,
|
||||||
"id": folder.get("FolderId", {}).get("Id", ""),
|
"new_name": new_name,
|
||||||
})
|
"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,
|
{
|
||||||
"folder_id": folder.get("FolderId", {}).get("Id", ""),
|
"success": True,
|
||||||
})
|
"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)})
|
||||||
|
|||||||
@@ -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", ""),
|
{
|
||||||
"email": report.get("EmailAddress", ""),
|
"name": report.get("Name", ""),
|
||||||
})
|
"email": report.get("EmailAddress", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return person
|
return person
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user