Compare commits

..

2 Commits

Author SHA1 Message Date
albnnc 4207b38b16 w 2026-07-06 02:05:16 +03:00
albnnc 40dea9a2c2 w 2026-07-06 01:58:46 +03:00
19 changed files with 875 additions and 924 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"],
"includes": ["**/*.{md,json,py}"],
"indentWidth": 2,
"json": {
"deno": true
},
"lineWidth": 80,
"markdown": {
"deno": true
},
"plugins": [
"https://plugins.dprint.dev/json-0.22.0.wasm",
"https://plugins.dprint.dev/markdown-0.22.1.wasm",
"https://plugins.dprint.dev/ruff-0.7.20.wasm"
],
"ruff": {
"lineLength": 88,
"targetVersion": "py310"
}
}
+15 -258
View File
@@ -1,287 +1,44 @@
# OWA MCP Server # OWA MCP Server
MCP (Model Context Protocol) server for any Microsoft Exchange / OWA (Outlook Web Access) deployment. Gives LLM agents access to email, calendar, directory search, folders, availability, and meeting analytics via 30 tools. MCP server for any Microsoft Exchange / OWA deployment. 30 tools for email,
calendar, directory search, folder management, availability, and analytics.
Works with any on-premise or hosted Exchange server that exposes OWA.
## Quick Start
```bash
# Install
pip install -e .
# Add to your MCP client config with --owa-url
# Then use the login tool to paste your session cookies
```
## Install
Add to your MCP client config. Replace `https://owa.example.com` with your OWA URL.
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"exchange": {
"command": "uvx",
"args": [
"exchange-mcp-server",
"--owa-url", "https://owa.example.com"
]
}
}
}
```
**Cursor** (`.cursor/mcp.json`):
```json
{
"mcpServers": {
"exchange": {
"command": "uvx",
"args": [
"exchange-mcp-server",
"--owa-url", "https://owa.example.com"
]
}
}
}
```
**Claude Code** (`.mcp.json`):
```json
{
"mcpServers": {
"exchange": {
"command": "uvx",
"args": [
"exchange-mcp-server",
"--owa-url", "https://owa.example.com"
]
}
}
}
```
## CLI Arguments ## CLI Arguments
| Argument | Default | Required | Description | | Argument | Default | Required | Description |
|---|---|---|---| |---|---|---|---|
| `--owa-url` | — | Yes | Base URL of the OWA instance (e.g. `https://owa.example.com`) | | `--owa-url` | — | Yes | Base URL of the OWA instance |
| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file | | `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file |
| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` | | `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` |
| `--token-hash` | — | For HTTP | SHA-256 hash of the bearer token | | `--token-hash` | — | HTTP only | SHA-256 hash of the bearer token |
| `--port` | `8000` | — | HTTP/SSE listener port | | `--port` | `8000` | — | HTTP/SSE listener port |
| `--host` | `127.0.0.1` | — | HTTP/SSE listener host | | `--host` | `127.0.0.1` | — | HTTP/SSE listener host |
| `--enabled-tools` | — | No | Comma-separated whitelist of tool names | | `--enabled-tools` | — | No | Comma-separated whitelist of tool names |
| `--disabled-tools` | — | No | Comma-separated blacklist of tool names | | `--disabled-tools` | — | No | Comma-separated blacklist of tool names |
### Tool filtering examples
```bash
# Hide only the most dangerous tools
exchange-mcp-server --owa-url https://owa.example.com \
--disabled-tools "send_email,delete_email,cancel_meeting"
# Allow only email and calendar tools
exchange-mcp-server --owa-url https://owa.example.com \
--enabled-tools "get_emails,get_email,send_email,get_calendar_events,create_meeting"
# Whitelist a set, then remove one from it
exchange-mcp-server --owa-url https://owa.example.com \
--enabled-tools "get_emails,get_email,send_email,delete_email" \
--disabled-tools "delete_email"
# → only get_emails, get_email, send_email remain
```
## HTTP Transport
Run the server over HTTP (streamable-http or SSE) instead of stdio. This is
useful when the MCP client cannot spawn a local process, or when you want to
expose the server on a network.
### Installation
```bash
pip install -e ".[http]" # adds uvicorn + sse-starlette
```
### Start the server
```bash
# Compute the token hash once (store only the hash, never the plaintext token)
TOKEN_HASH=$(echo -n 'my-secret-token' | sha256sum | cut -d' ' -f1)
# stdio (default, for local MCP clients)
exchange-mcp-server --owa-url https://owa.example.com
# streamable-http (recommended for HTTP)
exchange-mcp-server \
--owa-url https://owa.example.com \
--transport streamable-http \
--token-hash "$TOKEN_HASH" \
--port 8000 \
--host 127.0.0.1
# SSE
exchange-mcp-server \
--owa-url https://owa.example.com \
--transport sse \
--token-hash "$TOKEN_HASH" \
--port 8000
```
### CLI arguments
| Argument | Default | Required | Description |
|---|---|---|---|
| `--owa-url` | — | Yes | Base URL of the OWA instance (e.g. `https://owa.example.com`) |
| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file |
| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` |
| `--token-hash` | — | For HTTP | SHA-256 hash of the bearer token |
| `--port` | `8000` | — | HTTP/SSE listener port |
| `--host` | `127.0.0.1` | — | HTTP/SSE listener host |
| `--enabled-tools` | — | No | Comma-separated whitelist of tool names |
| `--disabled-tools` | — | No | Comma-separated blacklist of tool names |
### Client configuration (HTTP)
```json
{
"mcpServers": {
"exchange": {
"url": "http://127.0.0.1:8000/mcp",
"headers": {
"Authorization": "Bearer my-secret-token"
}
}
}
}
```
### Authentication
The server stores only the SHA-256 hash of your token — the plaintext token
never touches the filesystem. Every HTTP request must include:
```
Authorization: Bearer <plaintext-token>
```
The server hashes the incoming token and compares it with the stored hash.
Pass the hash via `--token-hash`:
```bash
echo -n 'my-secret-token' | sha256sum
# → 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
```
### Security notes
- Always use a strong random token.
- Bind to `127.0.0.1` unless you have a reverse proxy / firewall.
- For `streamable-http` the client must send `Accept: text/event-stream`.
- The `--token-hash` is required whenever `--transport` is not `stdio`.
## Login
The `login` MCP tool accepts session cookies that you copy from your browser.
1. Log into OWA in your browser.
2. Open DevTools (F12) → Application → Cookies → select your OWA domain.
3. Copy all cookies as `name=value` pairs (one per line).
4. Call the `login` tool with the cookies string.
Example cookies string:
```
X-OWA-CANARY=abc123
CookieAuth1=def456
...
```
```
login(cookies="X-OWA-CANARY=abc123\nCookieAuth1=def456\n...")
```
On success, cookies are saved to `session-cookies.txt` and all tools become usable. When the session expires, repeat the steps.
## Tools (30) ## Tools (30)
### Email (10) ### Email (10)
| Tool | Description | `get_emails`, `get_email`, `send_email`, `reply_email`, `forward_email`,
|---|---| `delete_email`, `move_email`, `mark_email_read`, `download_attachments`,
| `get_emails` | List emails from a folder with filtering | `get_email_links`, `search_emails`
| `get_email` | Get full email content by ID |
| `send_email` | Send a new email |
| `reply_email` | Reply to an email |
| `forward_email` | Forward an email |
| `delete_email` | Delete an email |
| `move_email` | Move email to another folder |
| `mark_email_read` | Mark email as read/unread |
| `download_attachments` | Download file attachments from an email |
| `get_email_links` | Extract hyperlinks from an email body |
### Calendar (7) ### Calendar (7)
| Tool | Description | `get_calendar_events`, `create_meeting`, `update_meeting`, `cancel_meeting`,
|---|---| `respond_to_meeting`, `download_event_attachments`, `get_event_links`
| `get_calendar_events` | Get events in a date range (supports recurring expansion) |
| `create_meeting` | Create a meeting with attendees |
| `update_meeting` | Update an existing meeting |
| `cancel_meeting` | Cancel a meeting and notify attendees |
| `respond_to_meeting` | Accept, decline, or tentatively accept |
| `download_event_attachments` | Download file attachments from a calendar event |
| `get_event_links` | Extract hyperlinks from the event description |
### Directory (1) ### Directory (1)
| Tool | Description | `find_person`
|---|---|
| `find_person` | Search people in Active Directory |
### Folders (7) ### Folders (7)
| Tool | Description | `get_folders`, `create_folder`, `rename_folder`, `empty_folder`, `delete_folder`,
|---|---| `move_folder`, `check_session`
| `get_folders` | List mail folders with unread counts |
| `create_folder` | Create a new mail folder |
| `rename_folder` | Rename an existing folder |
| `empty_folder` | Empty all items from a folder |
| `delete_folder` | Delete a mail folder |
| `move_folder` | Move a folder to a different parent |
| `check_session` | Check if the OWA session is authenticated |
### Availability (2) ### Availability (2)
| Tool | Description | `find_free_time`, `find_meeting_time`
|---|---|
| `find_free_time` | Find free slots in your calendar |
| `find_meeting_time` | Find common free slots for multiple people |
### Analytics (2) ### Analytics (2)
| Tool | Description | `get_meeting_stats`, `get_meeting_contacts`
|---|---|
| `get_meeting_stats` | Meeting count statistics for multiple people |
| `get_meeting_contacts` | Connection matrix — who you meet with most |
### Auth (1) ### Auth (1)
| Tool | Description | `login`
|---|---|
| `login` | Authenticate to OWA using browser session cookies |
## Files
```
exchange_mcp/
server.py # FastMCP server entry point
owa_client.py # OWA HTTP client
tools/
email.py # Email tools
calendar.py # Calendar tools
people.py # Directory search
folders.py # Folder management & session check
availability.py # Free time / meeting time
analytics.py # Meeting stats & contacts
auth.py # Login tool
pyproject.toml # Package config
```
-1
View File
@@ -1 +0,0 @@
"""Exchange MCP Server - access OWA email, calendar, directory via MCP."""
-1
View File
@@ -1 +0,0 @@
"""MCP tool modules for Exchange."""
+1
View File
@@ -0,0 +1 @@
"""OWA MCP Server — access OWA email, calendar, directory via MCP."""
@@ -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
@@ -9,16 +9,16 @@ mirroring what the OWA web frontend sends.
""" """
import json import json
import os
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
@@ -54,15 +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 os.environ.get("EXCHANGE_COOKIE_FILE", "")
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 = ""
@@ -83,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": "*/*",
@@ -131,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"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -155,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)
@@ -231,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:
@@ -275,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 ""
@@ -337,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(
@@ -471,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
+15 -34
View File
@@ -1,4 +1,4 @@
"""Exchange MCP Server. """OWA MCP Server.
Exposes OWA email, calendar, directory, and availability tools via MCP. Exposes OWA email, calendar, directory, and availability tools via MCP.
Uses FastMCP with a lifespan context manager to share a single OWAClient Uses FastMCP with a lifespan context manager to share a single OWAClient
@@ -13,29 +13,22 @@ CLI arguments:
--host HTTP host (default 127.0.0.1) --host HTTP host (default 127.0.0.1)
--enabled-tools Comma-separated whitelist of tool names --enabled-tools Comma-separated whitelist of tool names
--disabled-tools Comma-separated blacklist of tool names --disabled-tools Comma-separated blacklist of tool names
Tool filtering (env fallback):
EXCHANGE_ENABLED_TOOLS comma-separated whitelist (used only when
--enabled-tools is not provided).
EXCHANGE_DISABLED_TOOLS comma-separated blacklist (used only when
--disabled-tools is not provided).
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import logging import logging
import os
from collections.abc import AsyncIterator 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
from exchange_mcp.owa_client import OWAClient from owa_mcp.owa_client import OWAClient
from exchange_mcp.server_lifecycle import ( from owa_mcp.server_lifecycle import (
HashTokenVerifier, HashTokenVerifier,
SessionKeepalive, SessionKeepalive,
apply_tool_filters, apply_tool_filters,
@@ -48,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
@@ -55,7 +49,7 @@ class AppContext:
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Initialise the OWA client on first connection, then keep the session alive. """Initialise the OWA client on first connection, then keep the session alive.
A :class:`~exchange_mcp.server_lifecycle.SessionKeepalive` task starts A :class:`~owa_mcp.server_lifecycle.SessionKeepalive` task starts
immediately (pings inbox, extends the session, persists fresh cookies) and immediately (pings inbox, extends the session, persists fresh cookies) and
repeats every 5 minutes until the server shuts down. repeats every 5 minutes until the server shuts down.
""" """
@@ -80,13 +74,13 @@ _lifespan_cookie_file: str | None = None
# ── Module-level MCP server (imported by all tool modules) ──────────────── # ── Module-level MCP server (imported by all tool modules) ────────────────
mcp: FastMCP = FastMCP("exchange", lifespan=app_lifespan) mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan)
def _parse_args(argv=None) -> argparse.Namespace: def _parse_args(argv=None) -> argparse.Namespace:
"""Parse command-line arguments.""" """Parse command-line arguments."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Exchange MCP Server — OWA/Exchange integration via MCP", description="OWA MCP Server — OWA/Exchange integration via MCP",
) )
parser.add_argument( parser.add_argument(
"--transport", "--transport",
@@ -184,38 +178,25 @@ 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 exchange_mcp.tools.email # noqa: E402, F401 import owa_mcp.tools.analytics # noqa: E402, F401
import exchange_mcp.tools.calendar # noqa: E402, F401 import owa_mcp.tools.auth # noqa: E402, F401
import exchange_mcp.tools.people # noqa: E402, F401 import owa_mcp.tools.availability # noqa: E402, F401
import exchange_mcp.tools.folders # noqa: E402, F401 import owa_mcp.tools.calendar # noqa: E402, F401
import exchange_mcp.tools.availability # noqa: E402, F401 import owa_mcp.tools.email # noqa: E402, F401
import exchange_mcp.tools.analytics # noqa: E402, F401 import owa_mcp.tools.folders # noqa: E402, F401
import exchange_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)
# ── Tool filtering ───────────────────────────────────────────────────── # ── Tool filtering ─────────────────────────────────────────────────────
# CLI args take precedence over env vars.
enabled: list[str] | None = None enabled: list[str] | None = None
if args.enabled_tools is not None: if args.enabled_tools is not None:
enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()] enabled = [t.strip() for t in args.enabled_tools.split(",") if t.strip()]
elif os.environ.get("EXCHANGE_ENABLED_TOOLS", "").strip():
enabled = [
t.strip()
for t in os.environ["EXCHANGE_ENABLED_TOOLS"].split(",")
if t.strip()
]
disabled: list[str] | None = None disabled: list[str] | None = None
if args.disabled_tools is not None: if args.disabled_tools is not None:
disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()] disabled = [t.strip() for t in args.disabled_tools.split(",") if t.strip()]
elif os.environ.get("EXCHANGE_DISABLED_TOOLS", "").strip():
disabled = [
t.strip()
for t in os.environ["EXCHANGE_DISABLED_TOOLS"].split(",")
if t.strip()
]
if enabled or disabled: if enabled or disabled:
apply_tool_filters(mcp, enabled=enabled, disabled=disabled) apply_tool_filters(mcp, enabled=enabled, disabled=disabled)
@@ -1,4 +1,4 @@
"""Shared lifecycle utilities for the Exchange MCP server. """Shared lifecycle utilities for the OWA MCP server.
Centralises three concerns so that tool modules do not duplicate code: Centralises three concerns so that tool modules do not duplicate code:
@@ -17,9 +17,10 @@ import asyncio
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from exchange_mcp.owa_client import OWAClient, SessionExpiredError
from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.provider import AccessToken, TokenVerifier
from owa_mcp.owa_client import OWAClient, SessionExpiredError
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -28,6 +29,7 @@ logger = logging.getLogger(__name__)
# ── Client factory ─────────────────────────────────────────────────────── # ── Client factory ───────────────────────────────────────────────────────
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient: def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
"""Create and return a fully configured :class:`OWAClient`. """Create and return a fully configured :class:`OWAClient`.
@@ -50,6 +52,7 @@ def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient
# ── Token verifier ──────────────────────────────────────────────────────── # ── Token verifier ────────────────────────────────────────────────────────
class HashTokenVerifier(TokenVerifier): class HashTokenVerifier(TokenVerifier):
"""Bearer-token verifier backed by a SHA-256 hash. """Bearer-token verifier backed by a SHA-256 hash.
@@ -59,11 +62,12 @@ class HashTokenVerifier(TokenVerifier):
""" """
def __init__(self, token_hash: str) -> None: def __init__(self, token_hash: str) -> None:
import hashlib
self._expected_hash = token_hash.strip().lower() self._expected_hash = token_hash.strip().lower()
async def verify_token(self, token: str) -> AccessToken | None: async def verify_token(self, token: str) -> AccessToken | None:
import hashlib import hashlib
if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash: if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash:
return AccessToken( return AccessToken(
token=token, token=token,
@@ -75,6 +79,7 @@ class HashTokenVerifier(TokenVerifier):
# ── Lifespan helper ────────────────────────────────────────────────────── # ── Lifespan helper ──────────────────────────────────────────────────────
class _LazyClient: class _LazyClient:
"""Thin wrapper that resolves the OWAClient on first access. """Thin wrapper that resolves the OWAClient on first access.
@@ -103,8 +108,7 @@ def require_client(client: OWAClient | None) -> OWAClient:
""" """
if client is None: if client is None:
raise RuntimeError( raise RuntimeError(
"OWA client is not initialised. " "OWA client is not initialised. Pass --owa-url when starting the server."
"Pass --owa-url when starting the server."
) )
return client return client
@@ -127,7 +131,9 @@ class SessionKeepalive:
picked up without restarting the server. picked up without restarting the server.
""" """
def __init__(self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL) -> None: def __init__(
self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL
) -> None:
self._client = client self._client = client
self._interval = interval self._interval = interval
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
@@ -168,7 +174,9 @@ class SessionKeepalive:
case the user re-authenticated while the server was running. case the user re-authenticated while the server was running.
""" """
try: try:
self._client.request("GetFolder", { self._client.request(
"GetFolder",
{
"__type": "GetFolderJsonRequest:#Exchange", "__type": "GetFolderJsonRequest:#Exchange",
"Header": { "Header": {
"__type": "JsonRequestHeaders:#Exchange", "__type": "JsonRequestHeaders:#Exchange",
@@ -180,25 +188,33 @@ class SessionKeepalive:
"__type": "FolderResponseShape:#Exchange", "__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly", "BaseShape": "IdOnly",
}, },
"FolderIds": [{ "FolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange", "__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox", "Id": "inbox",
}], }
],
}, },
}) },
)
logger.debug("Session keepalive ping succeeded") logger.debug("Session keepalive ping succeeded")
except SessionExpiredError: except SessionExpiredError:
logger.warning("Session expired during keepalive — reloading cookies from disk") logger.warning(
"Session expired during keepalive — reloading cookies from disk"
)
try: try:
self._client._ensure_loaded() self._client._ensure_loaded()
except Exception as exc: except Exception as exc:
logger.warning("Failed to reload cookies after keepalive failure: %s", exc) logger.warning(
"Failed to reload cookies after keepalive failure: %s", exc
)
except Exception as exc: except Exception as exc:
logger.debug("Keepalive ping failed (non-critical): %s", exc) logger.debug("Keepalive ping failed (non-critical): %s", exc)
# ── Tool filtering ─────────────────────────────────────────────────────── # ── Tool filtering ───────────────────────────────────────────────────────
def apply_tool_filters( def apply_tool_filters(
server: FastMCP, server: FastMCP,
enabled: list[str] | None = None, enabled: list[str] | None = None,
+1
View File
@@ -0,0 +1 @@
"""MCP tool modules for OWA."""
@@ -1,18 +1,18 @@
"""Analytics tools for the Exchange MCP server. """Analytics tools for the OWA MCP server.
Provides meeting statistics and connection matrix analysis Provides meeting statistics and connection matrix analysis
using GetUserAvailability and GetItem APIs. 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 exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient
from exchange_mcp.owa_client import OWAClient from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
@@ -25,6 +25,7 @@ def _get_client(ctx: Context) -> OWAClient:
# Internal: resolve names to emails # Internal: resolve names to emails
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]: def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
"""Resolve a name/email to (display_name, email). Returns ('','') on failure.""" """Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name) resolutions = client.resolve_names(name)
@@ -38,6 +39,7 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
# Internal: query GetUserAvailability in chunks # Internal: query GetUserAvailability in chunks
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _get_availability_events( def _get_availability_events(
client: OWAClient, client: OWAClient,
emails: list[str], emails: list[str],
@@ -54,77 +56,84 @@ def _get_availability_events(
results: dict[str, list[dict]] = {email: [] for email in emails} results: dict[str, list[dict]] = {email: [] for email in emails}
# Batch people # Batch people
email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)] email_batches = [
emails[i : i + batch_size] for i in range(0, len(emails), batch_size)
]
for batch in email_batches: for batch in email_batches:
current = start current = start
while current < end: while current < end:
chunk_end = min(current + timedelta(days=chunk_days), end) chunk_end = min(current + timedelta(days=chunk_days), end)
mailbox_data = [{ mailbox_data = [
'__type': 'MailboxData:#Exchange', {
'Email': { "__type": "MailboxData:#Exchange",
'__type': 'EmailAddress:#Exchange', "Email": {
'Address': email, "__type": "EmailAddress:#Exchange",
"Address": email,
}, },
'AttendeeType': 'Required', "AttendeeType": "Required",
} for email in batch] }
for email in batch
]
payload = { payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange', "__type": "GetUserAvailabilityJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
'TimeZoneContext': { "TimeZoneContext": {
'__type': 'TimeZoneContext:#Exchange', "__type": "TimeZoneContext:#Exchange",
'TimeZoneDefinition': { "TimeZoneDefinition": {
'__type': 'TimeZoneDefinitionType:#Exchange', "__type": "TimeZoneDefinitionType:#Exchange",
'Id': 'Russian Standard Time', "Id": "Russian Standard Time",
}, },
}, },
}, },
'Body': { "Body": {
'__type': 'GetUserAvailabilityRequest:#Exchange', "__type": "GetUserAvailabilityRequest:#Exchange",
'MailboxDataArray': mailbox_data, "MailboxDataArray": mailbox_data,
'FreeBusyViewOptions': { "FreeBusyViewOptions": {
'__type': 'FreeBusyViewOptions:#Exchange', "__type": "FreeBusyViewOptions:#Exchange",
'TimeWindow': { "TimeWindow": {
'__type': 'Duration:#Exchange', "__type": "Duration:#Exchange",
'StartTime': f'{current}T00:00:00', "StartTime": f"{current}T00:00:00",
'EndTime': f'{chunk_end}T00:00:00', "EndTime": f"{chunk_end}T00:00:00",
}, },
'MergedFreeBusyIntervalInMinutes': 30, "MergedFreeBusyIntervalInMinutes": 30,
'RequestedView': 'DetailedMerged', "RequestedView": "DetailedMerged",
}, },
}, },
} }
try: try:
data = client.request('GetUserAvailability', payload) data = client.request("GetUserAvailability", payload)
body = data.get('Body', {}) body = data.get("Body", {})
for i, fb_resp in enumerate(body.get('FreeBusyResponseArray', [])): for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])):
if i >= len(batch): if i >= len(batch):
break break
email = batch[i] email = batch[i]
fb_view = fb_resp.get('FreeBusyView', {}) fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get('CalendarEventArray', {}) cal_events = fb_view.get("CalendarEventArray", {})
if isinstance(cal_events, dict): if isinstance(cal_events, dict):
items = cal_events.get('Items', []) items = cal_events.get("Items", [])
elif isinstance(cal_events, list): elif isinstance(cal_events, list):
items = cal_events items = cal_events
else: else:
items = [] items = []
for event in items: for event in items:
s = event.get('StartTime', '') s = event.get("StartTime", "")
bt = event.get('BusyType', '') bt = event.get("BusyType", "")
if s and bt != 'Free': if s and bt != "Free":
details = event.get('CalendarEventDetails', {}) details = event.get("CalendarEventDetails", {})
subject = details.get('Subject', '') if details else '' subject = details.get("Subject", "") if details else ""
results[email].append({ results[email].append(
'subject': subject, {
'start_date': s[:10], "subject": subject,
'busy_type': bt, "start_date": s[:10],
}) "busy_type": bt,
}
)
except Exception: except Exception:
pass pass
@@ -137,6 +146,7 @@ def _get_availability_events(
# Tool: get_meeting_stats # Tool: get_meeting_stats
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@mcp.tool() @mcp.tool()
def get_meeting_stats( def get_meeting_stats(
people: str, people: str,
@@ -161,13 +171,13 @@ def get_meeting_stats(
client = _get_client(ctx) client = _get_client(ctx)
try: try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date() sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e: except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"}) return json.dumps({"error": f"Invalid date format: {e}"})
# Resolve names # Resolve names
name_list = [n.strip() for n in people.split(',') if n.strip()] name_list = [n.strip() for n in people.split(",") if n.strip()]
if not name_list: if not name_list:
return json.dumps({"error": "No people specified."}) return json.dumps({"error": "No people specified."})
@@ -199,43 +209,51 @@ def get_meeting_stats(
stats = [] stats = []
for display, email in resolved: for display, email in resolved:
if not email or email not in avail: if not email or email not in avail:
stats.append({ stats.append(
{
"name": display, "name": display,
"email": email or "not_found", "email": email or "not_found",
"total_meetings": 0, "total_meetings": 0,
"meetings_per_workday": 0, "meetings_per_workday": 0,
"days_with_meetings": 0, "days_with_meetings": 0,
"workdays": workdays, "workdays": workdays,
}) }
)
continue continue
events = avail[email] events = avail[email]
total = len(events) total = len(events)
unique_days = len(set(ev['start_date'] for ev in events)) unique_days = len(set(ev["start_date"] for ev in events))
avg = round(total / workdays, 1) avg = round(total / workdays, 1)
stats.append({ stats.append(
{
"name": display, "name": display,
"email": email, "email": email,
"total_meetings": total, "total_meetings": total,
"meetings_per_workday": avg, "meetings_per_workday": avg,
"days_with_meetings": unique_days, "days_with_meetings": unique_days,
"workdays": workdays, "workdays": workdays,
}) }
)
# Sort by total descending # Sort by total descending
stats.sort(key=lambda x: x["total_meetings"], reverse=True) stats.sort(key=lambda x: x["total_meetings"], reverse=True)
return json.dumps({ return json.dumps(
{
"period": {"start": start_date, "end": end_date, "workdays": workdays}, "period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats, "stats": stats,
}, ensure_ascii=False) },
ensure_ascii=False,
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Tool: get_meeting_contacts # Tool: get_meeting_contacts
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@mcp.tool() @mcp.tool()
def get_meeting_contacts( def get_meeting_contacts(
start_date: str, start_date: str,
@@ -261,8 +279,8 @@ def get_meeting_contacts(
client = _get_client(ctx) client = _get_client(ctx)
try: try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date() sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() ed = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError as e: except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"}) return json.dumps({"error": f"Invalid date format: {e}"})
@@ -270,7 +288,9 @@ def get_meeting_contacts(
# to count subject occurrences (handles recurring meetings) # to count subject occurrences (handles recurring meetings)
own_email = client.user_email.lower() if client.user_email else "" own_email = client.user_email.lower() if client.user_email else ""
if not own_email: if not own_email:
return json.dumps({"error": "User email not available. Call the login tool first."}) return json.dumps(
{"error": "User email not available. Call the login tool first."}
)
folder_id = client.get_folder_id("calendar") folder_id = client.get_folder_id("calendar")
if not folder_id: if not folder_id:
@@ -280,7 +300,7 @@ def get_meeting_contacts(
avail_result = _get_availability_events(client, [client.user_email], sd, ed) avail_result = _get_availability_events(client, [client.user_email], sd, ed)
expanded_events = avail_result.get(client.user_email, []) expanded_events = avail_result.get(client.user_email, [])
subject_counts = Counter(ev['subject'] for ev in expanded_events) subject_counts = Counter(ev["subject"] for ev in expanded_events)
total_expanded = len(expanded_events) total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject # Step 2: Find master calendar items for each unique subject
@@ -290,51 +310,51 @@ def get_meeting_contacts(
while len(subject_to_id) < len(subject_counts): while len(subject_to_id) < len(subject_counts):
payload = { payload = {
'__type': 'FindItemJsonRequest:#Exchange', "__type": "FindItemJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
}, },
'Body': { "Body": {
'__type': 'FindItemRequest:#Exchange', "__type": "FindItemRequest:#Exchange",
'ItemShape': { "ItemShape": {
'__type': 'ItemResponseShape:#Exchange', "__type": "ItemResponseShape:#Exchange",
'BaseShape': 'Default', "BaseShape": "Default",
}, },
'ParentFolderIds': [ "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{'__type': 'FolderId:#Exchange', 'Id': folder_id} "Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": offset,
"MaxEntriesReturned": 200,
},
"SortOrder": [
{
"__type": "SortResults:#Exchange",
"Order": "Descending",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "Start",
},
}
], ],
'Traversal': 'Shallow',
'Paging': {
'__type': 'IndexedPageView:#Exchange',
'BasePoint': 'Beginning',
'Offset': offset,
'MaxEntriesReturned': 200,
},
'SortOrder': [{
'__type': 'SortResults:#Exchange',
'Order': 'Descending',
'Path': {
'__type': 'PropertyUri:#Exchange',
'FieldURI': 'Start',
},
}],
}, },
} }
data = client.request('FindItem', payload) data = client.request("FindItem", payload)
items = client.extract_items(data) items = client.extract_items(data)
if not items: if not items:
break break
folder_items = items[0].get('RootFolder', {}).get('Items', []) folder_items = items[0].get("RootFolder", {}).get("Items", [])
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if not folder_items: if not folder_items:
break break
for item in folder_items: for item in folder_items:
subj = item.get('Subject', '') subj = item.get("Subject", "")
if subj and subj in subject_counts and subj not in subject_to_id: if subj and subj in subject_counts and subj not in subject_to_id:
subject_to_id[subj] = item.get('ItemId', {}).get('Id', '') subject_to_id[subj] = item.get("ItemId", {}).get("Id", "")
if is_last: if is_last:
break break
@@ -375,8 +395,10 @@ def get_meeting_contacts(
item_id = item.get("ItemId", {}).get("Id", "") item_id = item.get("ItemId", {}).get("Id", "")
attendees = set() attendees = set()
for att_list in [item.get("RequiredAttendees", []) or [], for att_list in [
item.get("OptionalAttendees", []) or []]: item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or [],
]:
for a in att_list: for a in att_list:
mailbox = a.get("Mailbox", {}) mailbox = a.get("Mailbox", {})
name = mailbox.get("Name", "") name = mailbox.get("Name", "")
@@ -408,15 +430,20 @@ def get_meeting_contacts(
result_contacts = [] result_contacts = []
for (name, email), count in contacts.most_common(top_n): for (name, email), count in contacts.most_common(top_n):
result_contacts.append({ result_contacts.append(
{
"name": name, "name": name,
"email": email, "email": email,
"meetings": count, "meetings": count,
}) }
)
return json.dumps({ return json.dumps(
{
"period": {"start": start_date, "end": end_date}, "period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded, "total_meetings": total_expanded,
"unique_contacts": len(contacts), "unique_contacts": len(contacts),
"contacts": result_contacts, "contacts": result_contacts,
}, ensure_ascii=False) },
ensure_ascii=False,
)
@@ -1,4 +1,4 @@
"""Authentication tool for the Exchange MCP server. """Authentication tool for the OWA MCP server.
Provides a `login` tool that accepts session cookies from the user, Provides a `login` tool that accepts session cookies from the user,
saves them to disk, loads them into the OWA client, and verifies the session. saves them to disk, loads them into the OWA client, and verifies the session.
@@ -12,9 +12,9 @@ import json
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient, SessionExpiredError
from exchange_mcp.owa_client import OWAClient, SessionExpiredError from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
def _get_app_ctx(ctx: Context) -> AppContext: def _get_app_ctx(ctx: Context) -> AppContext:
@@ -87,16 +87,20 @@ async def login(
# Parse and validate cookies # Parse and validate cookies
cookies_str = cookies.strip() cookies_str = cookies.strip()
if not cookies_str: if not cookies_str:
return json.dumps({ return json.dumps(
{
"success": False, "success": False,
"error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.", "error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.",
}) }
)
# Save to disk # Save to disk
try: try:
client.cookie_file.write_text(cookies_str + "\n") client.cookie_file.write_text(cookies_str + "\n")
except Exception as e: except Exception as e:
return json.dumps({"success": False, "error": f"Failed to write cookie file: {e}"}) return json.dumps(
{"success": False, "error": f"Failed to write cookie file: {e}"}
)
# Load into memory # Load into memory
try: try:
@@ -106,12 +110,16 @@ async def login(
# Verify session # Verify session
if _session_is_valid(client): if _session_is_valid(client):
return json.dumps({ return json.dumps(
{
"success": True, "success": True,
"message": f"Logged in. Session cookies saved to {client.cookie_file}.", "message": f"Logged in. Session cookies saved to {client.cookie_file}.",
}) }
)
else: else:
return json.dumps({ return json.dumps(
{
"success": False, "success": False,
"error": "Cookies loaded but session verification failed. The cookies may have expired.", "error": "Cookies loaded but session verification failed. The cookies may have expired.",
}) }
)
@@ -1,4 +1,4 @@
"""Availability / free-time tools for the Exchange MCP server. """Availability / free-time tools for the OWA MCP server.
Ports find-free-time.py and find-meeting-time.py logic into MCP tools Ports find-free-time.py and find-meeting-time.py logic into MCP tools
using OWAClient. using OWAClient.
@@ -9,9 +9,9 @@ from datetime import datetime, timedelta
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient
from exchange_mcp.owa_client import OWAClient from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
@@ -24,6 +24,7 @@ def _get_client(ctx: Context) -> OWAClient:
# Pure helper functions (preserved from find-meeting-time.py) # Pure helper functions (preserved from find-meeting-time.py)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _parse_freebusy_string( def _parse_freebusy_string(
freebusy_str: str, start_time: datetime, interval_minutes: int = 30 freebusy_str: str, start_time: datetime, interval_minutes: int = 30
) -> list[tuple]: ) -> list[tuple]:
@@ -37,7 +38,7 @@ def _parse_freebusy_string(
for char in freebusy_str: for char in freebusy_str:
next_time = current_time + timedelta(minutes=interval_minutes) next_time = current_time + timedelta(minutes=interval_minutes)
if char in ['1', '2', '3', '4']: # Not free if char in ["1", "2", "3", "4"]: # Not free
busy_periods.append((current_time, next_time, char)) busy_periods.append((current_time, next_time, char))
current_time = next_time current_time = next_time
@@ -105,75 +106,82 @@ def _find_free_slots(
def _format_time(dt: datetime) -> str: def _format_time(dt: datetime) -> str:
"""Format datetime as HH:MM.""" """Format datetime as HH:MM."""
return dt.strftime('%H:%M') return dt.strftime("%H:%M")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Helper: get busy events via GetUserAvailability (includes recurring) # Helper: get busy events via GetUserAvailability (includes recurring)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _get_availability_events( def _get_availability_events(
client: OWAClient, email: str, start_date, end_date client: OWAClient, email: str, start_date, end_date
) -> list[dict]: ) -> list[dict]:
"""Get busy events via GetUserAvailability (expands recurring events).""" """Get busy events via GetUserAvailability (expands recurring events)."""
payload = { payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange', "__type": "GetUserAvailabilityJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
'TimeZoneContext': { "TimeZoneContext": {
'__type': 'TimeZoneContext:#Exchange', "__type": "TimeZoneContext:#Exchange",
'TimeZoneDefinition': { "TimeZoneDefinition": {
'__type': 'TimeZoneDefinitionType:#Exchange', "__type": "TimeZoneDefinitionType:#Exchange",
'Id': 'Russian Standard Time', "Id": "Russian Standard Time",
}, },
}, },
}, },
'Body': { "Body": {
'__type': 'GetUserAvailabilityRequest:#Exchange', "__type": "GetUserAvailabilityRequest:#Exchange",
'MailboxDataArray': [{ "MailboxDataArray": [
'__type': 'MailboxData:#Exchange', {
'Email': {'__type': 'EmailAddress:#Exchange', 'Address': email}, "__type": "MailboxData:#Exchange",
'AttendeeType': 'Required', "Email": {"__type": "EmailAddress:#Exchange", "Address": email},
}], "AttendeeType": "Required",
'FreeBusyViewOptions': { }
'__type': 'FreeBusyViewOptions:#Exchange', ],
'TimeWindow': { "FreeBusyViewOptions": {
'__type': 'Duration:#Exchange', "__type": "FreeBusyViewOptions:#Exchange",
'StartTime': f'{start_date}T00:00:00', "TimeWindow": {
'EndTime': f'{end_date + timedelta(days=1)}T00:00:00', "__type": "Duration:#Exchange",
"StartTime": f"{start_date}T00:00:00",
"EndTime": f"{end_date + timedelta(days=1)}T00:00:00",
}, },
'MergedFreeBusyIntervalInMinutes': 30, "MergedFreeBusyIntervalInMinutes": 30,
'RequestedView': 'DetailedMerged', "RequestedView": "DetailedMerged",
}, },
}, },
} }
data = client.request('GetUserAvailability', payload) data = client.request("GetUserAvailability", payload)
body = data.get('Body', {}) body = data.get("Body", {})
events = [] events = []
for fb_resp in body.get('FreeBusyResponseArray', []): for fb_resp in body.get("FreeBusyResponseArray", []):
fb_view = fb_resp.get('FreeBusyView', {}) fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get('CalendarEventArray', {}) cal_events = fb_view.get("CalendarEventArray", {})
items = ( items = (
cal_events.get('Items', []) cal_events.get("Items", [])
if isinstance(cal_events, dict) if isinstance(cal_events, dict)
else (cal_events if isinstance(cal_events, list) else []) else (cal_events if isinstance(cal_events, list) else [])
) )
for event in items: for event in items:
bt = event.get('BusyType', '') bt = event.get("BusyType", "")
if bt in ('Free', 'NoData'): if bt in ("Free", "NoData"):
continue continue
start_str = event.get('StartTime', '') start_str = event.get("StartTime", "")
end_str = event.get('EndTime', '') end_str = event.get("EndTime", "")
if not start_str or not end_str: if not start_str or not end_str:
continue continue
try: try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None) start = datetime.fromisoformat(
end = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None) start_str.replace("Z", "+00:00")
events.append({'start': start, 'end': end, 'status': bt}) ).replace(tzinfo=None)
end = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
events.append({"start": start, "end": end, "status": bt})
except (ValueError, AttributeError): except (ValueError, AttributeError):
continue continue
@@ -184,22 +192,23 @@ def _get_availability_events(
# Helper: get calendar folder ID # Helper: get calendar folder ID
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _get_calendar_folder_id(client: OWAClient) -> str | None: def _get_calendar_folder_id(client: OWAClient) -> str | None:
"""Get the calendar folder ID via GetFolder.""" """Get the calendar folder ID via GetFolder."""
payload = { payload = {
'__type': 'GetFolderJsonRequest:#Exchange', "__type": "GetFolderJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
}, },
'Body': { "Body": {
'__type': 'GetFolderRequest:#Exchange', "__type": "GetFolderRequest:#Exchange",
'FolderShape': { "FolderShape": {
'__type': 'FolderResponseShape:#Exchange', "__type": "FolderResponseShape:#Exchange",
'BaseShape': 'IdOnly', "BaseShape": "IdOnly",
}, },
'FolderIds': [ "FolderIds": [
{'__type': 'DistinguishedFolderId:#Exchange', 'Id': 'calendar'} {"__type": "DistinguishedFolderId:#Exchange", "Id": "calendar"}
], ],
}, },
} }
@@ -218,6 +227,7 @@ def _get_calendar_folder_id(client: OWAClient) -> str | None:
# Helper: get own calendar events (from find-free-time.py) # Helper: get own calendar events (from find-free-time.py)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _get_calendar_events( def _get_calendar_events(
client: OWAClient, folder_id: str, start_date, end_date client: OWAClient, folder_id: str, start_date, end_date
) -> list[dict]: ) -> list[dict]:
@@ -228,34 +238,32 @@ def _get_calendar_events(
while True: while True:
payload = { payload = {
'__type': 'FindItemJsonRequest:#Exchange', "__type": "FindItemJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
}, },
'Body': { "Body": {
'__type': 'FindItemRequest:#Exchange', "__type": "FindItemRequest:#Exchange",
'ItemShape': { "ItemShape": {
'__type': 'ItemResponseShape:#Exchange', "__type": "ItemResponseShape:#Exchange",
'BaseShape': 'AllProperties', "BaseShape": "AllProperties",
}, },
'ParentFolderIds': [ "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{'__type': 'FolderId:#Exchange', 'Id': folder_id} "Traversal": "Shallow",
], "Paging": {
'Traversal': 'Shallow', "__type": "IndexedPageView:#Exchange",
'Paging': { "BasePoint": "Beginning",
'__type': 'IndexedPageView:#Exchange', "Offset": offset,
'BasePoint': 'Beginning', "MaxEntriesReturned": batch_size,
'Offset': offset,
'MaxEntriesReturned': batch_size,
}, },
'SortOrder': [ "SortOrder": [
{ {
'__type': 'SortResults:#Exchange', "__type": "SortResults:#Exchange",
'Order': 'Ascending', "Order": "Ascending",
'Path': { "Path": {
'__type': 'PropertyUri:#Exchange', "__type": "PropertyUri:#Exchange",
'FieldURI': 'Start', "FieldURI": "Start",
}, },
} }
], ],
@@ -268,29 +276,29 @@ def _get_calendar_events(
if not items: if not items:
break break
folder_items = items[0].get('RootFolder', {}).get('Items', []) folder_items = items[0].get("RootFolder", {}).get("Items", [])
if not folder_items: if not folder_items:
break break
for item in folder_items: for item in folder_items:
# Skip cancelled events # Skip cancelled events
if item.get('IsCancelled'): if item.get("IsCancelled"):
continue continue
# Skip free/tentative slots # Skip free/tentative slots
fbt = item.get('FreeBusyType', 'Busy') fbt = item.get("FreeBusyType", "Busy")
if fbt in ['Free', 'NoData']: if fbt in ["Free", "NoData"]:
continue continue
start_str = item.get('Start', '') start_str = item.get("Start", "")
end_str = item.get('End', '') end_str = item.get("End", "")
if not start_str or not end_str: if not start_str or not end_str:
continue continue
try: try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
# Convert to naive datetime for comparison # Convert to naive datetime for comparison
start = start.replace(tzinfo=None) start = start.replace(tzinfo=None)
@@ -300,17 +308,19 @@ def _get_calendar_events(
if end.date() < start_date or start.date() > end_date: if end.date() < start_date or start.date() > end_date:
continue continue
events.append({ events.append(
'start': start, {
'end': end, "start": start,
'subject': item.get('Subject', ''), "end": end,
'status': fbt, "subject": item.get("Subject", ""),
}) "status": fbt,
}
)
except (ValueError, AttributeError): except (ValueError, AttributeError):
continue continue
# Check if there are more items # Check if there are more items
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True) is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if is_last: if is_last:
break break
@@ -323,6 +333,7 @@ def _get_calendar_events(
# Tool: find_free_time # Tool: find_free_time
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@mcp.tool() @mcp.tool()
def find_free_time( def find_free_time(
start_date: str, start_date: str,
@@ -352,8 +363,8 @@ def find_free_time(
client = _get_client(ctx) client = _get_client(ctx)
try: try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date() sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd
except ValueError as e: except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"}) return json.dumps({"error": f"Invalid date format: {e}"})
@@ -365,13 +376,17 @@ def find_free_time(
# Fallback to FindItem (misses recurring event occurrences) # Fallback to FindItem (misses recurring event occurrences)
folder_id = _get_calendar_folder_id(client) folder_id = _get_calendar_folder_id(client)
if not folder_id: if not folder_id:
return json.dumps({"error": "Could not find calendar folder. Session may have expired."}) return json.dumps(
{
"error": "Could not find calendar folder. Session may have expired."
}
)
all_busy = _get_calendar_events(client, folder_id, sd, ed) all_busy = _get_calendar_events(client, folder_id, sd, ed)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
# Convert event dicts to (start, end) tuples for _find_free_slots # Convert event dicts to (start, end) tuples for _find_free_slots
busy_periods = [(ev['start'], ev['end']) for ev in all_busy] busy_periods = [(ev["start"], ev["end"]) for ev in all_busy]
result = {} result = {}
current_date = sd current_date = sd
@@ -379,8 +394,11 @@ def find_free_time(
# Skip weekends # Skip weekends
if current_date.weekday() < 5: if current_date.weekday() < 5:
free = _find_free_slots( free = _find_free_slots(
busy_periods, current_date, busy_periods,
start_hour, end_hour, duration_minutes, current_date,
start_hour,
end_hour,
duration_minutes,
) )
if free: if free:
result[str(current_date)] = [ result[str(current_date)] = [
@@ -400,6 +418,7 @@ def find_free_time(
# Tool: find_meeting_time # Tool: find_meeting_time
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@mcp.tool() @mcp.tool()
def find_meeting_time( def find_meeting_time(
emails: str, emails: str,
@@ -431,13 +450,13 @@ def find_meeting_time(
""" """
client = _get_client(ctx) client = _get_client(ctx)
raw_list = [e.strip() for e in emails.split(',') if e.strip()] raw_list = [e.strip() for e in emails.split(",") if e.strip()]
if not raw_list: if not raw_list:
return json.dumps({"error": "No email addresses provided."}) return json.dumps({"error": "No email addresses provided."})
try: try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date() sd = datetime.strptime(start_date, "%Y-%m-%d").date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd ed = datetime.strptime(end_date, "%Y-%m-%d").date() if end_date else sd
except ValueError as e: except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"}) return json.dumps({"error": f"Invalid date format: {e}"})
@@ -445,12 +464,12 @@ def find_meeting_time(
email_list = [] email_list = []
resolve_errors = [] resolve_errors = []
for entry in raw_list: for entry in raw_list:
if '@' in entry: if "@" in entry:
email_list.append(entry) email_list.append(entry)
else: else:
resolutions = client.resolve_names(entry, full_contact=False) resolutions = client.resolve_names(entry, full_contact=False)
if resolutions: if resolutions:
addr = resolutions[0].get('Mailbox', {}).get('EmailAddress', '') addr = resolutions[0].get("Mailbox", {}).get("EmailAddress", "")
if addr: if addr:
email_list.append(addr) email_list.append(addr)
else: else:
@@ -459,46 +478,52 @@ def find_meeting_time(
resolve_errors.append(entry) resolve_errors.append(entry)
if not email_list: if not email_list:
return json.dumps({"error": f"Could not resolve any names to email addresses: {resolve_errors}"}) return json.dumps(
{
"error": f"Could not resolve any names to email addresses: {resolve_errors}"
}
)
# Build mailbox data (reused for each day chunk) # Build mailbox data (reused for each day chunk)
mailbox_data = [] mailbox_data = []
for email in email_list: for email in email_list:
mailbox_data.append({ mailbox_data.append(
'__type': 'MailboxData:#Exchange', {
'Email': { "__type": "MailboxData:#Exchange",
'__type': 'EmailAddress:#Exchange', "Email": {
'Address': email, "__type": "EmailAddress:#Exchange",
"Address": email,
}, },
'AttendeeType': 'Required', "AttendeeType": "Required",
}) }
)
# Query the full date range at once (API handles multi-day windows) # Query the full date range at once (API handles multi-day windows)
payload = { payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange', "__type": "GetUserAvailabilityJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
'TimeZoneContext': { "TimeZoneContext": {
'__type': 'TimeZoneContext:#Exchange', "__type": "TimeZoneContext:#Exchange",
'TimeZoneDefinition': { "TimeZoneDefinition": {
'__type': 'TimeZoneDefinitionType:#Exchange', "__type": "TimeZoneDefinitionType:#Exchange",
'Id': 'Russian Standard Time', "Id": "Russian Standard Time",
}, },
}, },
}, },
'Body': { "Body": {
'__type': 'GetUserAvailabilityRequest:#Exchange', "__type": "GetUserAvailabilityRequest:#Exchange",
'MailboxDataArray': mailbox_data, "MailboxDataArray": mailbox_data,
'FreeBusyViewOptions': { "FreeBusyViewOptions": {
'__type': 'FreeBusyViewOptions:#Exchange', "__type": "FreeBusyViewOptions:#Exchange",
'TimeWindow': { "TimeWindow": {
'__type': 'Duration:#Exchange', "__type": "Duration:#Exchange",
'StartTime': f'{sd}T00:00:00', "StartTime": f"{sd}T00:00:00",
'EndTime': f'{ed + timedelta(days=1)}T00:00:00', "EndTime": f"{ed + timedelta(days=1)}T00:00:00",
}, },
'MergedFreeBusyIntervalInMinutes': 30, "MergedFreeBusyIntervalInMinutes": 30,
'RequestedView': 'DetailedMerged', "RequestedView": "DetailedMerged",
}, },
}, },
} }
@@ -508,57 +533,69 @@ def find_meeting_time(
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
body = data.get('Body', {}) body = data.get("Body", {})
if 'ErrorCode' in body: if "ErrorCode" in body:
return json.dumps({"error": body.get('FaultMessage', 'Unknown error')}) return json.dumps({"error": body.get("FaultMessage", "Unknown error")})
# Parse availability responses # Parse availability responses
all_busy = [] all_busy = []
attendee_info = [] attendee_info = []
freebusy_responses = body.get('FreeBusyResponseArray', []) freebusy_responses = body.get("FreeBusyResponseArray", [])
for i, fb_resp in enumerate(freebusy_responses): for i, fb_resp in enumerate(freebusy_responses):
fb_view = fb_resp.get('FreeBusyView', {}) fb_view = fb_resp.get("FreeBusyView", {})
merged_fb = fb_view.get('MergedFreeBusy', '') merged_fb = fb_view.get("MergedFreeBusy", "")
email = email_list[i] if i < len(email_list) else f"Person {i + 1}" email = email_list[i] if i < len(email_list) else f"Person {i + 1}"
if merged_fb: if merged_fb:
start_time = datetime.combine(sd, datetime.min.time()) start_time = datetime.combine(sd, datetime.min.time())
busy_periods = _parse_freebusy_string(merged_fb, start_time) busy_periods = _parse_freebusy_string(merged_fb, start_time)
busy_count = sum(1 for c in merged_fb if c != '0') busy_count = sum(1 for c in merged_fb if c != "0")
free_count = sum(1 for c in merged_fb if c == '0') free_count = sum(1 for c in merged_fb if c == "0")
attendee_info.append({ attendee_info.append(
{
"email": email, "email": email,
"busy_slots": busy_count, "busy_slots": busy_count,
"free_slots": free_count, "free_slots": free_count,
}) }
)
all_busy.extend(busy_periods) all_busy.extend(busy_periods)
else: else:
# Fallback: parse CalendarEventArray # Fallback: parse CalendarEventArray
cal_events_raw = fb_view.get('CalendarEventArray', {}) cal_events_raw = fb_view.get("CalendarEventArray", {})
cal_events = cal_events_raw.get('Items', []) if isinstance(cal_events_raw, dict) else (cal_events_raw if isinstance(cal_events_raw, list) else []) cal_events = (
cal_events_raw.get("Items", [])
if isinstance(cal_events_raw, dict)
else (cal_events_raw if isinstance(cal_events_raw, list) else [])
)
if cal_events: if cal_events:
attendee_info.append({ attendee_info.append(
{
"email": email, "email": email,
"calendar_events": len(cal_events), "calendar_events": len(cal_events),
}) }
)
for event in cal_events: for event in cal_events:
start_str = event.get('StartTime', '') start_str = event.get("StartTime", "")
end_str = event.get('EndTime', '') end_str = event.get("EndTime", "")
if start_str and end_str: if start_str and end_str:
try: try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00')) start = datetime.fromisoformat(
end = datetime.fromisoformat(end_str.replace('Z', '+00:00')) start_str.replace("Z", "+00:00")
)
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
all_busy.append((start, end)) all_busy.append((start, end))
except Exception: except Exception:
pass pass
else: else:
attendee_info.append({ attendee_info.append(
{
"email": email, "email": email,
"status": "no_data", "status": "no_data",
}) }
)
merged_busy = _merge_busy_periods(all_busy) merged_busy = _merge_busy_periods(all_busy)
@@ -568,8 +605,11 @@ def find_meeting_time(
while current_date <= ed: while current_date <= ed:
if current_date.weekday() < 5: # Skip weekends if current_date.weekday() < 5: # Skip weekends
free = _find_free_slots( free = _find_free_slots(
merged_busy, current_date, merged_busy,
start_hour, end_hour, duration_minutes, current_date,
start_hour,
end_hour,
duration_minutes,
) )
if free: if free:
free_by_date[str(current_date)] = [ free_by_date[str(current_date)] = [
@@ -1,4 +1,4 @@
"""Calendar tools for the Exchange MCP server. """Calendar tools for the OWA MCP server.
Provides MCP tools for calendar event retrieval, meeting creation, Provides MCP tools for calendar event retrieval, meeting creation,
update, cancellation, and meeting response management via OWA API. update, cancellation, and meeting response management via OWA API.
@@ -11,10 +11,10 @@ from datetime import datetime, timedelta
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient
from exchange_mcp.owa_client import OWAClient from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
from exchange_mcp.utils import html_to_text, parse_iso_datetime, extract_links_from_html from owa_mcp.utils import extract_links_from_html, html_to_text
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
@@ -63,9 +63,7 @@ def _get_event_details(client: OWAClient, item_id: str) -> dict:
"__type": "ItemResponseShape:#Exchange", "__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties", "BaseShape": "AllProperties",
}, },
"ItemIds": [ "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
{"__type": "ItemId:#Exchange", "Id": item_id}
],
}, },
} }
@@ -160,9 +158,7 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict:
"__type": "ItemResponseShape:#Exchange", "__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties", "BaseShape": "AllProperties",
}, },
"ItemIds": [ "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
{"__type": "ItemId:#Exchange", "Id": item_id}
],
}, },
} }
@@ -202,13 +198,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict:
name = mailbox.get("Name", "") name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "") addr = mailbox.get("EmailAddress", "")
if addr and not addr.startswith("/O="): if addr and not addr.startswith("/O="):
result["resolved_required"].append({ result["resolved_required"].append(
{
"Mailbox": { "Mailbox": {
"Name": name, "Name": name,
"EmailAddress": addr, "EmailAddress": addr,
"RoutingType": "SMTP", "RoutingType": "SMTP",
} }
}) }
)
# Optional attendees # Optional attendees
for a in item.get("OptionalAttendees", []) or []: for a in item.get("OptionalAttendees", []) or []:
@@ -216,13 +214,15 @@ def _get_full_event(client: OWAClient, item_id: str) -> dict:
name = mailbox.get("Name", "") name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "") addr = mailbox.get("EmailAddress", "")
if addr and not addr.startswith("/O="): if addr and not addr.startswith("/O="):
result["resolved_optional"].append({ result["resolved_optional"].append(
{
"Mailbox": { "Mailbox": {
"Name": name, "Name": name,
"EmailAddress": addr, "EmailAddress": addr,
"RoutingType": "SMTP", "RoutingType": "SMTP",
} }
}) }
)
return result return result
@@ -331,72 +331,81 @@ def _get_expanded_events(
chunk_end = min(current + timedelta(days=chunk_days), end_date) chunk_end = min(current + timedelta(days=chunk_days), end_date)
payload = { payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange', "__type": "GetUserAvailabilityJsonRequest:#Exchange",
'Header': { "Header": {
'__type': 'JsonRequestHeaders:#Exchange', "__type": "JsonRequestHeaders:#Exchange",
'RequestServerVersion': 'Exchange2013', "RequestServerVersion": "Exchange2013",
'TimeZoneContext': { "TimeZoneContext": {
'__type': 'TimeZoneContext:#Exchange', "__type": "TimeZoneContext:#Exchange",
'TimeZoneDefinition': { "TimeZoneDefinition": {
'__type': 'TimeZoneDefinitionType:#Exchange', "__type": "TimeZoneDefinitionType:#Exchange",
'Id': 'Russian Standard Time', "Id": "Russian Standard Time",
}, },
}, },
}, },
'Body': { "Body": {
'__type': 'GetUserAvailabilityRequest:#Exchange', "__type": "GetUserAvailabilityRequest:#Exchange",
'MailboxDataArray': [{ "MailboxDataArray": [
'__type': 'MailboxData:#Exchange', {
'Email': {'__type': 'EmailAddress:#Exchange', 'Address': client.user_email}, "__type": "MailboxData:#Exchange",
'AttendeeType': 'Required', "Email": {
}], "__type": "EmailAddress:#Exchange",
'FreeBusyViewOptions': { "Address": client.user_email,
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{current}T00:00:00',
'EndTime': f'{chunk_end}T00:00:00',
}, },
'MergedFreeBusyIntervalInMinutes': 30, "AttendeeType": "Required",
'RequestedView': 'DetailedMerged', }
],
"FreeBusyViewOptions": {
"__type": "FreeBusyViewOptions:#Exchange",
"TimeWindow": {
"__type": "Duration:#Exchange",
"StartTime": f"{current}T00:00:00",
"EndTime": f"{chunk_end}T00:00:00",
},
"MergedFreeBusyIntervalInMinutes": 30,
"RequestedView": "DetailedMerged",
}, },
}, },
} }
try: try:
data = client.request('GetUserAvailability', payload) data = client.request("GetUserAvailability", payload)
body = data.get('Body', {}) body = data.get("Body", {})
for fb_resp in body.get('FreeBusyResponseArray', []): for fb_resp in body.get("FreeBusyResponseArray", []):
fb_view = fb_resp.get('FreeBusyView', {}) fb_view = fb_resp.get("FreeBusyView", {})
cal_events = fb_view.get('CalendarEventArray', {}) cal_events = fb_view.get("CalendarEventArray", {})
items = ( items = (
cal_events.get('Items', []) cal_events.get("Items", [])
if isinstance(cal_events, dict) if isinstance(cal_events, dict)
else (cal_events if isinstance(cal_events, list) else []) else (cal_events if isinstance(cal_events, list) else [])
) )
for event in items: for event in items:
bt = event.get('BusyType', '') bt = event.get("BusyType", "")
details = event.get('CalendarEventDetails', {}) details = event.get("CalendarEventDetails", {})
subject = details.get('Subject', '') if details else '' subject = details.get("Subject", "") if details else ""
location = details.get('Location', '') if details else '' location = details.get("Location", "") if details else ""
is_meeting = details.get('IsMeeting', False) if details else False is_meeting = details.get("IsMeeting", False) if details else False
is_recurring = details.get('IsRecurring', False) if details else False is_recurring = (
details.get("IsRecurring", False) if details else False
)
expanded.append({ expanded.append(
'subject': subject or '(No subject)', {
'start': event.get('StartTime', ''), "subject": subject or "(No subject)",
'end': event.get('EndTime', ''), "start": event.get("StartTime", ""),
'busy_type': bt, "end": event.get("EndTime", ""),
'location': location, "busy_type": bt,
'is_meeting': is_meeting, "location": location,
'is_recurring': is_recurring, "is_meeting": is_meeting,
}) "is_recurring": is_recurring,
}
)
except Exception: except Exception:
pass pass
current = chunk_end current = chunk_end
return sorted(expanded, key=lambda x: x.get('start', '')) return sorted(expanded, key=lambda x: x.get("start", ""))
@mcp.tool() @mcp.tool()
@@ -438,15 +447,17 @@ def get_calendar_events(
) )
events = [] events = []
for ev in expanded: for ev in expanded:
events.append({ events.append(
"subject": ev['subject'], {
"start": ev['start'], "subject": ev["subject"],
"end": ev['end'], "start": ev["start"],
"location": ev.get('location', ''), "end": ev["end"],
"busy_type": ev.get('busy_type', ''), "location": ev.get("location", ""),
"is_meeting": ev.get('is_meeting', False), "busy_type": ev.get("busy_type", ""),
"is_recurring": ev.get('is_recurring', False), "is_meeting": ev.get("is_meeting", False),
}) "is_recurring": ev.get("is_recurring", False),
}
)
return json.dumps(events, ensure_ascii=False) return json.dumps(events, ensure_ascii=False)
# --- Default mode --- # --- Default mode ---
@@ -476,9 +487,7 @@ def get_calendar_events(
"__type": "ItemResponseShape:#Exchange", "__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties", "BaseShape": "AllProperties",
}, },
"ParentFolderIds": [ "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"Traversal": "Shallow", "Traversal": "Shallow",
"CalendarView": { "CalendarView": {
"__type": "CalendarView:#Exchange", "__type": "CalendarView:#Exchange",
@@ -722,12 +731,16 @@ def create_meeting(
] ]
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)
else: else:
return json.dumps({ return json.dumps(
{
"error": item.get("MessageText", "Unknown error"), "error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""), "response_code": item.get("ResponseCode", ""),
}) }
)
return json.dumps({"success": True, "subject": subject, "note": "No confirmation details"}) return json.dumps(
{"success": True, "subject": subject, "note": "No confirmation details"}
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -791,8 +804,12 @@ def update_meeting(
orig_start_str = orig.get("start", "") orig_start_str = orig.get("start", "")
orig_end_str = orig.get("end", "") orig_end_str = orig.get("end", "")
try: try:
orig_start = datetime.fromisoformat(orig_start_str.replace("Z", "+00:00")).replace(tzinfo=None) orig_start = datetime.fromisoformat(
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(tzinfo=None) orig_start_str.replace("Z", "+00:00")
).replace(tzinfo=None)
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
orig_duration = int((orig_end - orig_start).total_seconds() / 60) orig_duration = int((orig_end - orig_start).total_seconds() / 60)
except (ValueError, AttributeError): except (ValueError, AttributeError):
orig_start = None orig_start = None
@@ -821,7 +838,9 @@ def update_meeting(
new_start = orig_start new_start = orig_start
new_end = orig_end new_end = orig_end
else: else:
return json.dumps({"error": "Cannot determine meeting time. Provide date and start_time."}) return json.dumps(
{"error": "Cannot determine meeting time. Provide date and start_time."}
)
new_location = location if location is not None else orig.get("location", "") new_location = location if location is not None else orig.get("location", "")
@@ -845,9 +864,7 @@ def update_meeting(
}, },
"Body": { "Body": {
"__type": "DeleteItemRequest:#Exchange", "__type": "DeleteItemRequest:#Exchange",
"ItemIds": [ "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"DeleteType": "MoveToDeletedItems", "DeleteType": "MoveToDeletedItems",
"SendMeetingCancellations": "SendToAllAndSaveCopy", "SendMeetingCancellations": "SendToAllAndSaveCopy",
"SuppressReadReceipts": True, "SuppressReadReceipts": True,
@@ -935,7 +952,9 @@ def update_meeting(
try: try:
data = client.request("CreateCalendarEvent", create_payload) data = client.request("CreateCalendarEvent", create_payload)
except Exception as e: except Exception as e:
return json.dumps({"error": f"Original cancelled but failed to create new: {e}"}) return json.dumps(
{"error": f"Original cancelled but failed to create new: {e}"}
)
body = data.get("Body", {}) body = data.get("Body", {})
if "ErrorCode" in body: if "ErrorCode" in body:
@@ -959,12 +978,16 @@ def update_meeting(
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)
if resp_items: if resp_items:
return json.dumps({ return json.dumps(
{
"error": resp_items[0].get("MessageText", "Unknown error"), "error": resp_items[0].get("MessageText", "Unknown error"),
"response_code": resp_items[0].get("ResponseCode", ""), "response_code": resp_items[0].get("ResponseCode", ""),
}) }
)
return json.dumps({"success": True, "subject": new_subject, "note": "No confirmation details"}) return json.dumps(
{"success": True, "subject": new_subject, "note": "No confirmation details"}
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -997,9 +1020,7 @@ def cancel_meeting(
}, },
"Body": { "Body": {
"__type": "DeleteItemRequest:#Exchange", "__type": "DeleteItemRequest:#Exchange",
"ItemIds": [ "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
{"__type": "ItemId:#Exchange", "Id": item_id}
],
"DeleteType": "MoveToDeletedItems", "DeleteType": "MoveToDeletedItems",
"SendMeetingCancellations": "SendToAllAndSaveCopy", "SendMeetingCancellations": "SendToAllAndSaveCopy",
"SuppressReadReceipts": True, "SuppressReadReceipts": True,
@@ -1014,10 +1035,12 @@ def cancel_meeting(
if item.get("ResponseClass") == "Success": if item.get("ResponseClass") == "Success":
return json.dumps({"success": True, "message": "Meeting cancelled"}) return json.dumps({"success": True, "message": "Meeting cancelled"})
else: else:
return json.dumps({ return json.dumps(
{
"error": item.get("MessageText", "Unknown error"), "error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""), "response_code": item.get("ResponseCode", ""),
}) }
)
# DeleteItem may return empty on success # DeleteItem may return empty on success
body = data.get("Body", {}) body = data.get("Body", {})
@@ -1060,9 +1083,11 @@ def respond_to_meeting(
response_type = response_types.get(response) response_type = response_types.get(response)
if not response_type: if not response_type:
return json.dumps({ return json.dumps(
{
"error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative." "error": f"Invalid response: {response}. Must be Accept, Decline, or Tentative."
}) }
)
response_item = { response_item = {
"__type": response_type, "__type": response_type,
@@ -1098,16 +1123,20 @@ def respond_to_meeting(
if items: if items:
item = items[0] item = items[0]
if item.get("ResponseClass") == "Success": if item.get("ResponseClass") == "Success":
return json.dumps({ return json.dumps(
{
"success": True, "success": True,
"response": response, "response": response,
"message": f"Meeting {response.lower()}ed", "message": f"Meeting {response.lower()}ed",
}) }
)
else: else:
return json.dumps({ return json.dumps(
{
"error": item.get("MessageText", "Unknown error"), "error": item.get("MessageText", "Unknown error"),
"response_code": item.get("ResponseCode", ""), "response_code": item.get("ResponseCode", ""),
}) }
)
return json.dumps({"error": "No response from server"}) return json.dumps({"error": "No response from server"})
@@ -1147,9 +1176,7 @@ def download_event_attachments(
"__type": "ItemResponseShape:#Exchange", "__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties", "BaseShape": "AllProperties",
}, },
"ItemIds": [ "ItemIds": [{"__type": "ItemId:#Exchange", "Id": item_id}],
{"__type": "ItemId:#Exchange", "Id": item_id}
],
}, },
} }
@@ -1162,28 +1189,43 @@ def download_event_attachments(
continue continue
for item in msg["Items"]: for item in msg["Items"]:
for att in item.get("Attachments", []): for att in item.get("Attachments", []):
attachments.append({ attachments.append(
{
"name": att.get("Name", ""), "name": att.get("Name", ""),
"size": att.get("Size", 0), "size": att.get("Size", 0),
"content_type": att.get("ContentType", ""), "content_type": att.get("ContentType", ""),
"attachment_id": att.get("AttachmentId", {}).get("Id", ""), "attachment_id": att.get("AttachmentId", {}).get("Id", ""),
"is_inline": att.get("IsInline", False), "is_inline": att.get("IsInline", False),
}) }
)
break break
if not attachments: if not attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0, return json.dumps(
"message": "No attachments found on this event."}) {
"success": True,
"downloaded": [],
"count": 0,
"message": "No attachments found on this event.",
}
)
# Filter to non-inline file attachments # Filter to non-inline file attachments
file_attachments = [ file_attachments = [
a for a in attachments a
for a in attachments
if a.get("attachment_id") and not a.get("is_inline", False) if a.get("attachment_id") and not a.get("is_inline", False)
] ]
if not file_attachments: if not file_attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0, return json.dumps(
"message": "No downloadable file attachments."}) {
"success": True,
"downloaded": [],
"count": 0,
"message": "No downloadable file attachments.",
}
)
os.makedirs(target_folder, exist_ok=True) os.makedirs(target_folder, exist_ok=True)
@@ -1223,17 +1265,21 @@ def download_event_attachments(
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(content) f.write(content)
downloaded.append({ downloaded.append(
{
"name": filename, "name": filename,
"path": filepath, "path": filepath,
"size": len(content), "size": len(content),
"content_type": content_type, "content_type": content_type,
}) }
)
except Exception as e: except Exception as e:
errors.append({ errors.append(
{
"name": att.get("name", "unknown"), "name": att.get("name", "unknown"),
"error": str(e), "error": str(e),
}) }
)
result = { result = {
"success": len(errors) == 0, "success": len(errors) == 0,
@@ -1308,12 +1354,14 @@ def get_event_links(
links = extract_links_from_html(body_val) links = extract_links_from_html(body_val)
break break
return json.dumps({ return json.dumps(
{
"item_id": item_id, "item_id": item_id,
"subject": subject, "subject": subject,
"links": links, "links": links,
"count": len(links), "count": len(links),
}) }
)
except Exception as e: except Exception as e:
return json.dumps({"error": f"Failed to extract event links: {e}"}) return json.dumps({"error": f"Failed to extract event links: {e}"})
@@ -1,4 +1,4 @@
"""Email tools for the Exchange MCP server. """Email tools for the OWA MCP server.
Provides tools for reading, sending, replying, forwarding, and managing Provides tools for reading, sending, replying, forwarding, and managing
emails via the OWA Exchange API. emails via the OWA Exchange API.
@@ -8,10 +8,10 @@ import json
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient, SessionExpiredError
from exchange_mcp.owa_client import OWAClient, SessionExpiredError from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
from exchange_mcp.utils import html_to_text, extract_links_from_html from owa_mcp.utils import extract_links_from_html, html_to_text
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
@@ -215,7 +215,12 @@ def _get_item_details(client: OWAClient, item_id: str) -> dict:
item_type = item.get("__type", "") item_type = item.get("__type", "")
if any( if any(
t in item_type t in item_type
for t in ("MeetingRequest", "MeetingResponse", "MeetingCancellation", "CalendarItem") for t in (
"MeetingRequest",
"MeetingResponse",
"MeetingCancellation",
"CalendarItem",
)
): ):
result["location"] = item.get( result["location"] = item.get(
"Location", "Location",
@@ -331,9 +336,7 @@ def get_emails(
find_body = { find_body = {
"__type": "FindItemRequest:#Exchange", "__type": "FindItemRequest:#Exchange",
"ItemShape": item_shape, "ItemShape": item_shape,
"ParentFolderIds": [ "ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"Traversal": "Shallow", "Traversal": "Shallow",
"Paging": { "Paging": {
"__type": "IndexedPageView:#Exchange", "__type": "IndexedPageView:#Exchange",
@@ -392,18 +395,19 @@ def get_emails(
if not items: if not items:
return json.dumps( return json.dumps(
{"item_ids": [], "count": 0} if ids_only {"item_ids": [], "count": 0} if ids_only else {"emails": [], "count": 0}
else {"emails": [], "count": 0}
) )
if ids_only: if ids_only:
result = [] result = []
for item in items: for item in items:
result.append({ result.append(
{
"item_id": item.get("ItemId", {}).get("Id", ""), "item_id": item.get("ItemId", {}).get("Id", ""),
"date": item.get("DateTimeReceived", ""), "date": item.get("DateTimeReceived", ""),
"subject": item.get("Subject", ""), "subject": item.get("Subject", ""),
}) }
)
return json.dumps({"item_ids": result, "count": len(result)}) return json.dumps({"item_ids": result, "count": len(result)})
emails = [] emails = []
@@ -763,9 +767,7 @@ def move_email(
if not folder_id: if not folder_id:
return json.dumps({"error": f"Folder '{target_folder}' not found."}) return json.dumps({"error": f"Folder '{target_folder}' not found."})
items = [ items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids]
{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids
]
payload = { payload = {
"__type": "MoveItemJsonRequest:#Exchange", "__type": "MoveItemJsonRequest:#Exchange",
@@ -824,9 +826,7 @@ def delete_email(
try: try:
client = _get_client(ctx) client = _get_client(ctx)
items = [ items = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids]
{"__type": "ItemId:#Exchange", "Id": iid} for iid in item_ids
]
payload = { payload = {
"__type": "DeleteItemJsonRequest:#Exchange", "__type": "DeleteItemJsonRequest:#Exchange",
@@ -887,18 +887,31 @@ def download_attachments(
attachments = details.get("attachments", []) attachments = details.get("attachments", [])
if not attachments: if not attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0, return json.dumps(
"message": "No attachments found."}) {
"success": True,
"downloaded": [],
"count": 0,
"message": "No attachments found.",
}
)
# Filter to non-inline file attachments with IDs # Filter to non-inline file attachments with IDs
file_attachments = [ file_attachments = [
a for a in attachments a
for a in attachments
if a.get("attachment_id") and not a.get("is_inline", False) if a.get("attachment_id") and not a.get("is_inline", False)
] ]
if not file_attachments: if not file_attachments:
return json.dumps({"success": True, "downloaded": [], "count": 0, return json.dumps(
"message": "No downloadable file attachments."}) {
"success": True,
"downloaded": [],
"count": 0,
"message": "No downloadable file attachments.",
}
)
os.makedirs(target_folder, exist_ok=True) os.makedirs(target_folder, exist_ok=True)
@@ -938,17 +951,21 @@ def download_attachments(
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(content) f.write(content)
downloaded.append({ downloaded.append(
{
"name": filename, "name": filename,
"path": filepath, "path": filepath,
"size": len(content), "size": len(content),
"content_type": content_type, "content_type": content_type,
}) }
)
except Exception as e: except Exception as e:
errors.append({ errors.append(
{
"name": att.get("name", "unknown"), "name": att.get("name", "unknown"),
"error": str(e), "error": str(e),
}) }
)
result = { result = {
"success": len(errors) == 0, "success": len(errors) == 0,
@@ -1021,12 +1038,14 @@ def get_email_links(
links = extract_links_from_html(body_val) links = extract_links_from_html(body_val)
break break
return json.dumps({ return json.dumps(
{
"item_id": item_id, "item_id": item_id,
"subject": subject, "subject": subject,
"links": links, "links": links,
"count": len(links), "count": len(links),
}) }
)
except SessionExpiredError as e: except SessionExpiredError as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
@@ -1107,16 +1126,22 @@ async def search_emails(
max_results = max(1, min(max_results, 100)) max_results = max(1, min(max_results, 100))
if search_scope not in ("all", "subject", "body", "from"): if search_scope not in ("all", "subject", "body", "from"):
return json.dumps({"error": f"invalid search_scope: {search_scope}", "results": []}) return json.dumps(
{"error": f"invalid search_scope: {search_scope}", "results": []}
)
restriction = _build_search_restriction(query.strip(), search_scope) restriction = _build_search_restriction(query.strip(), search_scope)
if restriction is None: if restriction is None:
return json.dumps({"error": f"unsupported search_scope: {search_scope}", "results": []}) return json.dumps(
{"error": f"unsupported search_scope: {search_scope}", "results": []}
)
if folder_id: if folder_id:
parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}] parent_folder = [{"__type": "FolderId:#Exchange", "Id": folder_id}]
else: else:
parent_folder = [{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}] parent_folder = [
{"__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot"}
]
payload = { payload = {
"__type": "FindItemJsonRequest:#Exchange", "__type": "FindItemJsonRequest:#Exchange",
@@ -1153,6 +1178,7 @@ async def search_emails(
body_type = item.get("Body", {}).get("BodyType", "HTML") body_type = item.get("Body", {}).get("BodyType", "HTML")
if body_type == "HTML" and body_html: if body_type == "HTML" and body_html:
import re import re
body_preview = re.sub(r"<[^>]+>", " ", body_html) body_preview = re.sub(r"<[^>]+>", " ", body_html)
body_preview = " ".join(body_preview.split())[:200] body_preview = " ".join(body_preview.split())[:200]
else: else:
@@ -1163,13 +1189,16 @@ async def search_emails(
if len(results) >= max_results: if len(results) >= max_results:
break break
return json.dumps({ return json.dumps(
{
"query": query.strip(), "query": query.strip(),
"search_scope": search_scope, "search_scope": search_scope,
"folder_id": folder_id or "all", "folder_id": folder_id or "all",
"total_results": len(results), "total_results": len(results),
"results": results, "results": results,
}, ensure_ascii=False) },
ensure_ascii=False,
)
except SessionExpiredError as e: except SessionExpiredError as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
except Exception as e: except Exception as e:
@@ -1,4 +1,4 @@
"""Folder tools for the Exchange MCP server. """Folder tools for the OWA MCP server.
Ports the get-folders.py logic into an MCP tool using OWAClient. Ports the get-folders.py logic into an MCP tool using OWAClient.
""" """
@@ -7,15 +7,24 @@ import json
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient
from exchange_mcp.owa_client import OWAClient from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
_DISTINGUISHED_NAMES = { _DISTINGUISHED_NAMES = {
"msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems", "msgfolderroot",
"junkemail", "outbox", "calendar", "contacts", "tasks", "notes", "inbox",
"journal", "searchfolders", "sentitems",
"drafts",
"deleteditems",
"junkemail",
"outbox",
"calendar",
"contacts",
"tasks",
"notes",
"journal",
"searchfolders",
} }
_HEADER_TZ = { _HEADER_TZ = {
@@ -68,36 +77,40 @@ def check_session(ctx: Context = None) -> str:
"__type": "FolderResponseShape:#Exchange", "__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default", "BaseShape": "Default",
}, },
"FolderIds": [ "FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}],
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
],
}, },
} }
try: try:
data = client.request("GetFolder", payload) data = client.request("GetFolder", payload)
except Exception as e: except Exception as e:
return json.dumps({ return json.dumps(
{
"authenticated": False, "authenticated": False,
"error": str(e), "error": str(e),
"cookie_file": str(client.cookie_file), "cookie_file": str(client.cookie_file),
}) }
)
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps({ return json.dumps(
{
"authenticated": True, "authenticated": True,
"mailbox": folder.get("DisplayName", ""), "mailbox": folder.get("DisplayName", ""),
"unread": folder.get("UnreadCount", 0), "unread": folder.get("UnreadCount", 0),
"cookie_file": str(client.cookie_file), "cookie_file": str(client.cookie_file),
}) }
)
return json.dumps({ return json.dumps(
{
"authenticated": False, "authenticated": False,
"error": "Unexpected response", "error": "Unexpected response",
"cookie_file": str(client.cookie_file), "cookie_file": str(client.cookie_file),
}) }
)
@mcp.tool() @mcp.tool()
@@ -159,13 +172,15 @@ def get_folders(
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]: if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]: for f in msg["RootFolder"]["Folders"]:
folders.append({ folders.append(
{
"name": f.get("DisplayName", "Unknown"), "name": f.get("DisplayName", "Unknown"),
"id": f.get("FolderId", {}).get("Id", ""), "id": f.get("FolderId", {}).get("Id", ""),
"total_count": f.get("TotalCount", 0), "total_count": f.get("TotalCount", 0),
"unread_count": f.get("UnreadCount", 0), "unread_count": f.get("UnreadCount", 0),
"child_folder_count": f.get("ChildFolderCount", 0), "child_folder_count": f.get("ChildFolderCount", 0),
}) }
)
return json.dumps(folders, ensure_ascii=False) return json.dumps(folders, ensure_ascii=False)
@@ -216,11 +231,13 @@ def create_folder(
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps({ return json.dumps(
{
"success": True, "success": True,
"name": name, "name": name,
"id": folder.get("FolderId", {}).get("Id", ""), "id": folder.get("FolderId", {}).get("Id", ""),
}) }
)
return json.dumps({"error": "Unexpected response", "raw": str(data)}) return json.dumps({"error": "Unexpected response", "raw": str(data)})
@@ -280,11 +297,13 @@ def rename_folder(
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps({ return json.dumps(
{
"success": True, "success": True,
"new_name": new_name, "new_name": new_name,
"id": folder.get("FolderId", {}).get("Id", ""), "id": folder.get("FolderId", {}).get("Id", ""),
}) }
)
return json.dumps({"error": "Unexpected response", "raw": str(data)}) return json.dumps({"error": "Unexpected response", "raw": str(data)})
@@ -316,9 +335,7 @@ def empty_folder(
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "EmptyFolderRequest:#Exchange", "__type": "EmptyFolderRequest:#Exchange",
"FolderIds": [ "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"DeleteType": delete_type, "DeleteType": delete_type,
"DeleteSubFolders": delete_sub_folders, "DeleteSubFolders": delete_sub_folders,
"SuppressReadReceipt": True, "SuppressReadReceipt": True,
@@ -362,9 +379,7 @@ def delete_folder(
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "DeleteFolderRequest:#Exchange", "__type": "DeleteFolderRequest:#Exchange",
"FolderIds": [ "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"DeleteType": delete_type, "DeleteType": delete_type,
}, },
} }
@@ -405,9 +420,7 @@ def move_folder(
"Header": _HEADER_TZ, "Header": _HEADER_TZ,
"Body": { "Body": {
"__type": "MoveFolderRequest:#Exchange", "__type": "MoveFolderRequest:#Exchange",
"FolderIds": [ "FolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"ToFolderId": { "ToFolderId": {
"__type": "TargetFolderId:#Exchange", "__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(target_parent_folder_id), "BaseFolderId": _folder_id_dict(target_parent_folder_id),
@@ -423,9 +436,11 @@ def move_folder(
for msg in client.extract_items(data): for msg in client.extract_items(data):
if "Folders" in msg: if "Folders" in msg:
folder = msg["Folders"][0] folder = msg["Folders"][0]
return json.dumps({ return json.dumps(
{
"success": True, "success": True,
"folder_id": folder.get("FolderId", {}).get("Id", ""), "folder_id": folder.get("FolderId", {}).get("Id", ""),
}) }
)
return json.dumps({"error": "Unexpected response", "raw": str(data)}) return json.dumps({"error": "Unexpected response", "raw": str(data)})
@@ -1,4 +1,4 @@
"""People / directory search tools for the Exchange MCP server. """People / directory search tools for the OWA MCP server.
Ports the find-person.py logic into an MCP tool using OWAClient. Ports the find-person.py logic into an MCP tool using OWAClient.
""" """
@@ -7,9 +7,9 @@ import json
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext from owa_mcp.owa_client import OWAClient
from exchange_mcp.owa_client import OWAClient from owa_mcp.server import AppContext, mcp
from exchange_mcp.server_lifecycle import require_client from owa_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient: def _get_client(ctx: Context) -> OWAClient:
@@ -82,10 +82,12 @@ def _parse_person(resolution: dict) -> dict:
# Direct reports # Direct reports
for report in contact.get("DirectReports", []): for report in contact.get("DirectReports", []):
person["direct_reports"].append({ person["direct_reports"].append(
{
"name": report.get("Name", ""), "name": report.get("Name", ""),
"email": report.get("EmailAddress", ""), "email": report.get("EmailAddress", ""),
}) }
)
return person return person
+2 -4
View File
@@ -1,4 +1,4 @@
"""Shared utility functions for the Exchange MCP server. """Shared utility functions for the OWA MCP server.
Extracts duplicated helpers from the standalone scripts: Extracts duplicated helpers from the standalone scripts:
html_to_text, date/time formatting and parsing. html_to_text, date/time formatting and parsing.
@@ -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)
+14 -6
View File
@@ -1,11 +1,11 @@
[project] [project]
name = "exchange-mcp-server" name = "owa-mcp-server"
version = "1.0.1" version = "0.1.0"
description = "MCP server for Exchange / OWA - email, calendar, directory, availability" description = "MCP server for OWA email, calendar, directory, availability"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.10" requires-python = ">=3.10"
keywords = ["mcp", "exchange", "owa", "outlook", "email", "calendar"] keywords = ["mcp", "owa", "outlook", "exchange", "email", "calendar"]
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
] ]
@@ -23,7 +23,7 @@ Repository = "https://git.kosyrev.name/zoo/owa-mcp"
http = ["uvicorn>=0.30.0"] http = ["uvicorn>=0.30.0"]
[project.scripts] [project.scripts]
exchange-mcp-server = "exchange_mcp.server:main" owa-mcp-server = "owa_mcp.server:main"
[build-system] [build-system]
requires = ["setuptools>=68.0"] requires = ["setuptools>=68.0"]
@@ -31,4 +31,12 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["exchange_mcp*"] include = ["owa_mcp*"]
[tool.ruff]
line-length = 88
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
ignore = ["E501"]