feat: basics #1

Merged
albnnc merged 8 commits from itchy-gayal into main 2026-07-06 07:55:40 +00:00
18 changed files with 5771 additions and 0 deletions
Showing only changes of commit a9497625b1 - Show all commits
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
.credentials.enc
.mcp.json
.mcpregistry_*
.playwright-mcp/
.salt
*.egg-info/
*.pyc
build
dist/
instructions.md
session-cookies.txt
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 nhype
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+287
View File
@@ -0,0 +1,287 @@
# 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.
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
| 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 |
### 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)
### Email (10)
| Tool | Description |
|---|---|
| `get_emails` | List emails from a folder with filtering |
| `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)
| Tool | Description |
|---|---|
| `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)
| Tool | Description |
|---|---|
| `find_person` | Search people in Active Directory |
### Folders (7)
| Tool | Description |
|---|---|
| `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)
| Tool | Description |
|---|---|
| `find_free_time` | Find free slots in your calendar |
| `find_meeting_time` | Find common free slots for multiple people |
### Analytics (2)
| Tool | Description |
|---|---|
| `get_meeting_stats` | Meeting count statistics for multiple people |
| `get_meeting_contacts` | Connection matrix — who you meet with most |
### Auth (1)
| Tool | Description |
|---|---|
| `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
@@ -0,0 +1 @@
"""Exchange MCP Server - access OWA email, calendar, directory via MCP."""
+109
View File
@@ -0,0 +1,109 @@
"""Return type definitions for MCP tools.
Uses TypedDict for lightweight structured return types without
requiring Pydantic at the model layer.
"""
from typing import TypedDict
class Email(TypedDict, total=False):
"""An email message."""
subject: str
sender: str
sender_name: str
date: str
is_read: bool
has_attachments: bool
has_links: bool
item_id: str
size: int
is_meeting: bool
item_type: str
to: list[str]
cc: list[str]
body: str
body_type: str
attachments: list[dict]
# Meeting-specific fields
location: str
start: str
end: str
attendees_required: list[str]
attendees_optional: list[str]
class CalendarEvent(TypedDict, total=False):
"""A calendar event / meeting."""
subject: str
start: str
end: str
location: str
is_all_day: bool
is_cancelled: bool
is_meeting: bool
is_recurring: bool
organizer: str
organizer_email: str
my_response: str
item_id: str
body: str
attendees_required: list[str]
attendees_optional: list[str]
class Person(TypedDict, total=False):
"""A person from the Exchange directory."""
name: str
email: str
mailbox_type: str
first_name: str
last_name: str
job_title: str
department: str
company: str
office: str
alias: str
manager: str
manager_email: str
phones: dict[str, str]
address: str
direct_reports: list[dict[str, str]]
class Folder(TypedDict, total=False):
"""A mail folder."""
name: str
id: str
total_count: int
unread_count: int
child_folder_count: int
class FreeSlot(TypedDict):
"""A free time slot."""
date: str
start: str
end: str
duration_minutes: int
class Availability(TypedDict, total=False):
"""Availability data for a single attendee."""
email: str
busy_slots: int
free_slots: int
merged_freebusy: str
class MeetingResult(TypedDict, total=False):
"""Result of creating/updating a meeting."""
success: bool
subject: str
date: str
start_time: str
end_time: str
location: str
required_attendees: list[str]
optional_attendees: list[str]
error: str
+502
View File
@@ -0,0 +1,502 @@
"""Centralized OWA HTTP client for Exchange API.
Extracts and unifies the duplicated request/cookie/session patterns
from all standalone scripts into a single reusable client.
Uses the "x-owa-urlpostdata" request pattern: JSON payload is URL-encoded
and sent in the ``x-owa-urlpostdata`` header with an empty POST body,
mirroring what the OWA web frontend sends.
"""
import json
import os
import time
from urllib.parse import quote, urlparse
from pathlib import Path
import requests
class SessionExpiredError(Exception):
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
pass
# Map common folder names (English + Russian) to OWA distinguished folder IDs
DISTINGUISHED_FOLDERS = {
"inbox": "inbox",
"входящие": "inbox",
"sent": "sentitems",
"отправленные": "sentitems",
"drafts": "drafts",
"черновики": "drafts",
"deleted": "deleteditems",
"удаленные": "deleteditems",
"junk": "junkemail",
"нежелательная почта": "junkemail",
"outbox": "outbox",
"исходящие": "outbox",
"calendar": "calendar",
"календарь": "calendar",
}
class OWAClient:
"""HTTP client for OWA (Outlook Web Access) JSON API.
Handles cookie loading, CSRF token extraction, request construction,
session expiry detection, and automatic cookie reload on first failure.
"""
def __init__(
self,
cookie_file: str | None = None,
owa_url: str | None = None,
):
self.cookie_file = Path(
cookie_file
or os.environ.get("EXCHANGE_COOKIE_FILE", "")
or str(Path(__file__).parent.parent / "session-cookies.txt")
)
self.owa_url = (owa_url or "").rstrip("/")
if not self.owa_url:
raise ValueError(
"OWA URL must be provided via --owa-url or owa_url=…"
)
self._session = requests.Session()
self._loaded = False
self.user_email: str = ""
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _action_name(action: str) -> str:
"""Convert e.g. 'GetFolder''GetFolderAction'."""
return action + "Action"
def _fresh_headers(self, action: str) -> dict:
"""Build the full set of OWA headers required by Exchange 2013.
Mirrors the headers the OWA web frontend sends for every POST.
"""
now_ms = int(time.time() * 1000)
client_req_id = f"{self._client_id}_{now_ms}"
now_str = time.strftime(
"%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000)
) + f"{now_ms % 1000:03d}"
return {
# Basic
"accept": "*/*",
"accept-language": "en-US,en;q=0.9,ru;q=0.8",
"cache-control": "no-cache",
"client-request-id": client_req_id,
"content-length": "0",
"content-type": "application/json; charset=UTF-8",
"origin": self.owa_url,
"pragma": "no-cache",
"priority": "u=1, i",
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
# OWA-specific
"action": action,
"sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not)A;Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-owa-actionid": "-1",
"x-owa-actionname": self._action_name(action),
"x-owa-attempt": "1",
"x-owa-clientbegin": now_str,
"x-owa-clientbuildversion": "15.2.1748.39",
"x-owa-correlationid": client_req_id,
"x-requested-with": "XMLHttpRequest",
}
def _add_canary_header(self, headers: dict) -> dict:
"""Extract X-OWA-CANARY from session cookies and add to headers."""
canary = next(
(c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), ""
)
if canary:
headers["x-owa-canary"] = canary
return headers
@property
def _client_id(self) -> str:
"""Extract ClientId from session cookies."""
cid = next(
(c.value for c in self._session.cookies if c.name == "ClientId"), ""
)
return cid if cid else "00000000000000000000000000000000"
# ------------------------------------------------------------------
# Cookie / session helpers
# ------------------------------------------------------------------
def _load_cookies(self) -> None:
"""Read session-cookies.txt (name=value per line) and load into session."""
if not self.cookie_file.exists():
raise SessionExpiredError(
f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate."
)
raw = self.cookie_file.read_text().strip()
cookies: dict[str, str] = {}
for line in raw.split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
if not cookies:
raise SessionExpiredError("Cookie file is empty. Use the login MCP tool to authenticate.")
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
def _save_cookies(self) -> None:
"""Write the current session cookie jar to the cookie file.
Called automatically after every successful request so that the
on-disk cookies always reflect the freshest ``Set-Cookie`` values
returned by the server.
"""
lines = [f"{c.name}={c.value}" for c in self._session.cookies]
self.cookie_file.write_text("\n".join(lines))
self._loaded = True
def _ensure_loaded(self) -> None:
"""Load cookies on first use and refresh session from the main page."""
if not self._loaded:
self._load_cookies()
self._refresh_session()
def load_cookies_from_string(self, cookies_str: str) -> None:
"""Load cookies from a name=value string (one per line) into memory."""
cookies: dict[str, str] = {}
for line in cookies_str.strip().split("\n"):
if "=" in line:
name, value = line.split("=", 1)
cookies[name] = value
if not cookies:
raise SessionExpiredError("No cookies in provided data.")
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
# ------------------------------------------------------------------
# Core request method
# ------------------------------------------------------------------
def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1.
On session expiry (401, 440, or text/html response), reloads cookies
once and retries. If that also fails, raises SessionExpiredError.
Returns the parsed JSON response dict.
"""
self._ensure_loaded()
try:
data = self._do_request(action, payload, timeout=timeout)
return data
except SessionExpiredError:
raise
def _refresh_session(self) -> None:
"""Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY)."""
try:
resp = self._session.get(
f"{self.owa_url}/owa/",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
},
timeout=10,
)
# Extract X-OWA-CANARY from Set-Cookie if present
set_cookie = resp.headers.get("Set-Cookie", "")
if "X-OWA-CANARY=" in set_cookie:
import re
m = re.search(r'X-OWA-CANARY=([^;]+)', set_cookie)
if m:
host = urlparse(self.owa_url).hostname or "owa.b1.ru"
self._session.cookies.set(
"X-OWA-CANARY", m.group(1), domain=host
)
# Persist any cookie changes
self._save_cookies()
except Exception:
pass # non-critical
def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""Execute a single OWA API request.
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an
empty POST body, plus a full set of OWA-specific headers extracted
from the browser's actual network traffic.
"""
from urllib.parse import urlparse
url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1"
# URL-encode the JSON payload for the x-owa-urlpostdata header
url_post_data = quote(json.dumps(payload, separators=(",", ":")))
# Build headers — start with OWA-required headers
headers = self._fresh_headers(action)
headers["x-owa-urlpostdata"] = url_post_data
# Add canary from cookies
headers = self._add_canary_header(headers)
# Add extra cookies that the OWA frontend always sends
host = urlparse(self.owa_url).hostname or "owa.b1.ru"
for name, val in [("PrivateComputer", "true"), ("PBack", "0")]:
self._session.cookies.set(name, val, domain=host)
try:
resp = self._session.post(url, data="", headers=headers, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Request failed: {exc}") from exc
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError("Session expired (HTTP {}).".format(resp.status_code))
if "text/html" in resp.headers.get("Content-Type", ""):
body_snippet = resp.text[:300] if resp.text else ""
raise SessionExpiredError(
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
f"Snippet: {body_snippet}"
)
# Parse JSON
try:
data = resp.json()
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
raise SessionExpiredError(
f"Unexpected response (HTTP {resp.status_code}). "
"Session may have expired."
) from exc
# Persist any Set-Cookie updates back to disk so the next request
# (or a server restart) picks up the freshest session tokens.
self._save_cookies()
return data
def request_header_payload(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""POST with payload in x-owa-urlpostdata header (empty body).
Alias for :meth:`request` — both methods now use the same
``x-owa-urlpostdata`` format that mirrors the OWA web frontend.
"""
return self.request(action, payload, timeout=timeout)
# ------------------------------------------------------------------
# File download (attachments)
# ------------------------------------------------------------------
def download_file(
self, attachment_id: str, *, timeout: int = 60
) -> tuple[bytes, str, str]:
"""Download a file attachment by its AttachmentId.
Uses the OWA GetFileAttachment endpoint (direct GET).
Returns:
(content_bytes, filename, content_type)
"""
self._ensure_loaded()
from urllib.parse import quote
url = (
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
f"?id={quote(attachment_id)}"
)
try:
resp = self._session.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Download failed: {exc}") from exc
if resp.status_code in (401, 440):
raise SessionExpiredError(
f"Session expired (HTTP {resp.status_code})."
)
if "text/html" in resp.headers.get("Content-Type", ""):
raise SessionExpiredError(
"Session expired (HTML response on attachment download)."
)
# Persist fresh cookies from the download response
self._save_cookies()
# Parse filename from Content-Disposition header
filename = "attachment"
cd = resp.headers.get("Content-Disposition", "")
if cd:
import re as _re
from urllib.parse import unquote
# Try filename*= (RFC 5987) first, then filename=
match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd)
if match:
filename = unquote(match.group(1).strip())
else:
match = _re.search(r'filename="?([^";]+)"?', cd)
if match:
filename = unquote(match.group(1).strip())
content_type = resp.headers.get("Content-Type", "application/octet-stream")
return resp.content, filename, content_type
# ------------------------------------------------------------------
# Convenience: extract response items
# ------------------------------------------------------------------
@staticmethod
def extract_items(data: dict) -> list[dict]:
"""Extract Items from standard OWA response envelope.
Response shape: data["Body"]["ResponseMessages"]["Items"]
"""
try:
return data["Body"]["ResponseMessages"]["Items"]
except (KeyError, TypeError):
return []
# ------------------------------------------------------------------
# Folder helpers
# ------------------------------------------------------------------
def get_folder_id(self, folder_name: str) -> str | None:
"""Resolve a folder name to its Exchange folder ID.
Supports distinguished folder names (inbox, sentitems, drafts, etc.)
in both English and Russian, plus custom folder names looked up
via FindFolder on msgfolderroot.
"""
folder_lower = folder_name.lower()
# Check distinguished folders first
distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower)
if distinguished_id:
payload = {
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly",
},
"FolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": distinguished_id,
}
],
},
}
data = self.request("GetFolder", payload)
for msg in self.extract_items(data):
if "Folders" in msg:
for f in msg["Folders"]:
fid = f.get("FolderId", {}).get("Id")
if fid:
return fid
# Fall back to searching custom folders by name
payload = {
"__type": "FindFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "msgfolderroot",
}
],
"Traversal": "Shallow",
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": 0,
"MaxEntriesReturned": 200,
},
},
}
data = self.request("FindFolder", payload)
for msg in self.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]:
if f.get("DisplayName", "").lower() == folder_lower:
return f.get("FolderId", {}).get("Id")
return None
# ------------------------------------------------------------------
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
def resolve_names(
self, query: str, *, full_contact: bool = True
) -> list[dict]:
"""Call ResolveNames to search the directory.
Returns the list of Resolution dicts from the API, each containing
Mailbox and optionally Contact data.
"""
payload = {
"__type": "ResolveNamesJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "ResolveNamesRequest:#Exchange",
"UnresolvedEntry": query,
"ReturnFullContactData": full_contact,
"SearchScope": "ActiveDirectoryContacts",
"ContactDataShape": "AllProperties" if full_contact else "Default",
},
}
data = self.request("ResolveNames", payload)
for msg in self.extract_items(data):
if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]:
return msg["ResolutionSet"]["Resolutions"]
return []
+240
View File
@@ -0,0 +1,240 @@
"""Exchange MCP Server.
Exposes OWA email, calendar, directory, and availability tools via MCP.
Uses FastMCP with a lifespan context manager to share a single OWAClient
instance across all tool invocations.
CLI arguments:
--transport Transport: stdio (default), sse, streamable-http
--owa-url Base URL of the OWA instance (required)
--cookie-file Path to session cookies file (default: session-cookies.txt)
--token-hash SHA-256 hash of the bearer token (required for HTTP)
--port HTTP port (default 8000)
--host HTTP host (default 127.0.0.1)
--enabled-tools Comma-separated whitelist 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
import argparse
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
from exchange_mcp.owa_client import OWAClient
from exchange_mcp.server_lifecycle import (
HashTokenVerifier,
SessionKeepalive,
apply_tool_filters,
create_owa_client,
)
logger = logging.getLogger(__name__)
@dataclass
class AppContext:
"""Shared application state available to all tools via lifespan context."""
client: OWAClient | None = None
@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Initialise the OWA client on first connection, then keep the session alive.
A :class:`~exchange_mcp.server_lifecycle.SessionKeepalive` task starts
immediately (pings inbox, extends the session, persists fresh cookies) and
repeats every 5 minutes until the server shuts down.
"""
client = create_owa_client(_lifespan_owa_url, _lifespan_cookie_file)
try:
client._ensure_loaded()
except Exception:
pass # no cookies yet — login tool will handle this
keepalive = SessionKeepalive(client)
await keepalive.start()
try:
yield AppContext(client=client)
finally:
await keepalive.stop()
# ── Temporary storage for lifespan args (set by main() before mcp.run()) ──
_lifespan_owa_url: str = ""
_lifespan_cookie_file: str | None = None
# ── Module-level MCP server (imported by all tool modules) ────────────────
mcp: FastMCP = FastMCP("exchange", lifespan=app_lifespan)
def _parse_args(argv=None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Exchange MCP Server — OWA/Exchange integration via MCP",
)
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default="stdio",
help="Transport protocol (default: stdio)",
)
parser.add_argument(
"--owa-url",
required=True,
help="Base URL of the OWA instance (e.g. https://owa.example.com)",
)
parser.add_argument(
"--cookie-file",
default=None,
help="Path to session cookies file (default: session-cookies.txt)",
)
parser.add_argument(
"--token-hash",
default=None,
help=(
"SHA-256 hash of the bearer token for HTTP transport auth. "
"Compute with: echo -n 'my-token' | sha256sum"
),
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port for HTTP/SSE transport (default: 8000)",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host for HTTP/SSE transport (default: 127.0.0.1)",
)
parser.add_argument(
"--enabled-tools",
default=None,
help="Comma-separated whitelist of tool names to expose",
)
parser.add_argument(
"--disabled-tools",
default=None,
help="Comma-separated blacklist of tool names to hide",
)
return parser.parse_args(argv)
def _configure_auth(
mcp_server: FastMCP,
token_hash: str | None,
) -> None:
"""Apply token-hash auth to *mcp_server* if *token_hash* is provided."""
if not token_hash:
return
token_verifier: TokenVerifier = HashTokenVerifier(token_hash)
mcp_server.settings.auth = AuthSettings( # type: ignore[misc]
issuer_url="http://localhost", # type: ignore[arg-type]
resource_server_url="http://localhost", # type: ignore[arg-type]
)
mcp_server._token_verifier = token_verifier # noqa: SLF001
def main(argv=None) -> None:
"""Entry point: parse CLI args, configure server, run transport."""
global _lifespan_owa_url, _lifespan_cookie_file # noqa: PLW0603
args = _parse_args(argv)
# ── Validate --owa-url ─────────────────────────────────────────────────
owa_url = args.owa_url.strip().rstrip("/")
if not owa_url:
print(
"ERROR: --owa-url must not be empty.",
file=__import__("sys").stderr,
)
raise SystemExit(1)
# ── Stash lifespan args (consumed by app_lifespan) ─────────────────────
_lifespan_owa_url = owa_url
_lifespan_cookie_file = args.cookie_file
# ── Validate --token-hash for non-stdio transports ─────────────────────
if args.transport != "stdio" and not args.token_hash:
print(
"ERROR: --token-hash is required when using HTTP transport.",
file=__import__("sys").stderr,
)
print(
" Compute hash: echo -n 'my-token' | sha256sum",
file=__import__("sys").stderr,
)
raise SystemExit(1)
# ── Register tools (imports populate module-level `mcp`) ───────────────
import exchange_mcp.tools.email # noqa: E402, F401
import exchange_mcp.tools.calendar # noqa: E402, F401
import exchange_mcp.tools.people # noqa: E402, F401
import exchange_mcp.tools.folders # noqa: E402, F401
import exchange_mcp.tools.availability # noqa: E402, F401
import exchange_mcp.tools.analytics # noqa: E402, F401
import exchange_mcp.tools.auth # noqa: E402, F401
# ── Auth for HTTP transports ───────────────────────────────────────────
_configure_auth(mcp, args.token_hash)
# ── Tool filtering ─────────────────────────────────────────────────────
# CLI args take precedence over env vars.
enabled: list[str] | None = None
if args.enabled_tools is not None:
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
if args.disabled_tools is not None:
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:
apply_tool_filters(mcp, enabled=enabled, disabled=disabled)
# ── Host / port (already passed to FastMCP at module level; update if
# different from defaults — useful when server is re-invoked with
# different args in tests) ─────────────────────────────────────────────
mcp.settings.host = args.host
mcp.settings.port = args.port
# ── Run transport ─────────────────────────────────────────────────────
match args.transport:
case "stdio":
mcp.run()
case "sse":
mcp.run(transport="sse")
case "streamable-http":
mcp.run(transport="streamable-http")
if __name__ == "__main__":
main()
+253
View File
@@ -0,0 +1,253 @@
"""Shared lifecycle utilities for the Exchange MCP server.
Centralises three concerns so that tool modules do not duplicate code:
1. ``create_owa_client`` — builds and returns an initialised :class:`OWAClient`.
2. ``require_client`` — fetches the client from the lifespan context; raises a
clear :class:`RuntimeError` when it is ``None`` (e.g. OWA URL was not passed).
3. ``apply_tool_filters`` — reads CLI args and applies whitelist / blacklist
filtering to the tool registry.
4. ``SessionKeepalive`` — background task that pings OWA every *n* seconds to
extend the session lifetime and persist fresh cookies to disk.
"""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
from exchange_mcp.owa_client import OWAClient, SessionExpiredError
from mcp.server.auth.provider import AccessToken, TokenVerifier
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
logger = logging.getLogger(__name__)
# ── Client factory ───────────────────────────────────────────────────────
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
"""Create and return a fully configured :class:`OWAClient`.
Args:
owa_url: Base URL of the OWA instance (e.g. ``https://owa.example.com``).
cookie_file: Path to the session-cookie file. ``None`` falls back to the
default ``session-cookies.txt`` next to the package.
Returns:
A ready-to-use :class:`OWAClient`.
Raises:
ValueError: If *owa_url* is empty.
"""
owa_url = owa_url.strip().rstrip("/")
if not owa_url:
raise ValueError("OWA URL must not be empty.")
return OWAClient(owa_url=owa_url, cookie_file=cookie_file)
# ── Token verifier ────────────────────────────────────────────────────────
class HashTokenVerifier(TokenVerifier):
"""Bearer-token verifier backed by a SHA-256 hash.
The server never stores the plaintext token — only its hex digest.
Clients send the plaintext token as ``Authorization: Bearer <token>``,
the server hashes it and compares with the stored hash.
"""
def __init__(self, token_hash: str) -> None:
import hashlib
self._expected_hash = token_hash.strip().lower()
async def verify_token(self, token: str) -> AccessToken | None:
import hashlib
if hashlib.sha256(token.encode()).hexdigest() == self._expected_hash:
return AccessToken(
token=token,
client_id="mcp-client",
scopes=[],
)
return None
# ── Lifespan helper ──────────────────────────────────────────────────────
class _LazyClient:
"""Thin wrapper that resolves the OWAClient on first access.
Stored as ``AppContext.client`` so that ``require_client`` can work
with both a pre-built client and a ``None`` placeholder.
"""
def __init__(self, client: OWAClient | None) -> None:
self._client = client
@property
def resolved(self) -> OWAClient:
if self._client is None:
raise RuntimeError(
"OWA client is not initialised. "
"Check --owa-url and --cookie-file arguments."
)
return self._client
def require_client(client: OWAClient | None) -> OWAClient:
"""Return *client* if it is not ``None``, otherwise raise ``RuntimeError``.
Import this in tool modules and call it with ``ctx.client`` to get a
consistent error message when OWA is not configured.
"""
if client is None:
raise RuntimeError(
"OWA client is not initialised. "
"Pass --owa-url when starting the server."
)
return client
# ── Session keepalive ──────────────────────────────────────────────────────
_DEFAULT_KEEPALIVE_INTERVAL = 300 # seconds (5 minutes)
class SessionKeepalive:
"""Background task that periodically pings the OWA server to extend the
session lifetime and persist fresh cookies to disk.
On each successful ping the OWA server may return updated ``Set-Cookie``
headers (e.g. a new ``X-OWA-CANARY`` or a renewed ``multifactor`` JWT).
The client saves those cookies to disk automatically.
On failure the keepalive attempts to reload cookies from disk so that a
fresh login performed by another process (or via the ``login`` tool) is
picked up without restarting the server.
"""
def __init__(self, client: OWAClient, interval: int = _DEFAULT_KEEPALIVE_INTERVAL) -> None:
self._client = client
self._interval = interval
self._task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the background keepalive loop.
Fires one ping immediately so the session is refreshed on server
start-up, then waits ``interval`` seconds between subsequent pings.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._run(), name="session-keepalive")
async def stop(self) -> None:
"""Cancel the background keepalive loop and wait for it to finish."""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _run(self) -> None:
"""Ping immediately, then every ``interval`` seconds."""
# First ping right away (on server start)
await asyncio.to_thread(self._ping)
while True:
await asyncio.sleep(self._interval)
await asyncio.to_thread(self._ping)
def _ping(self) -> None:
"""Make a lightweight GetFolder request against the inbox.
On success the OWAClient saves fresh cookies to disk automatically.
On ``SessionExpiredError`` the on-disk cookies are re-loaded in
case the user re-authenticated while the server was running.
"""
try:
self._client.request("GetFolder", {
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly",
},
"FolderIds": [{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox",
}],
},
})
logger.debug("Session keepalive ping succeeded")
except SessionExpiredError:
logger.warning("Session expired during keepalive — reloading cookies from disk")
try:
self._client._ensure_loaded()
except Exception as exc:
logger.warning("Failed to reload cookies after keepalive failure: %s", exc)
except Exception as exc:
logger.debug("Keepalive ping failed (non-critical): %s", exc)
# ── Tool filtering ───────────────────────────────────────────────────────
def apply_tool_filters(
server: FastMCP,
enabled: list[str] | None = None,
disabled: list[str] | None = None,
) -> None:
"""Apply whitelist / blacklist filtering to *server*'s registered tools.
Args:
server: The :class:`FastMCP` instance whose tool registry is modified
in-place.
enabled: Optional list of tool names to **keep**. If ``None`` or empty
all tools are eligible. Unknown names are ignored (warning
logged).
disabled: Optional list of tool names to **remove**. Applied *after*
the whitelist. Unknown names are ignored (warning logged).
"""
canonical = {t.name for t in asyncio.run(server.list_tools())}
if not canonical:
return
allowed: set[str] = set(canonical)
# ── Whitelist ──────────────────────────────────────────────────────────
if enabled:
enabled_set = {n.strip() for n in enabled if n.strip()}
allowed &= enabled_set
unknown = enabled_set - canonical
if unknown:
logger.warning(
"Unknown tool(s) in --enabled-tools, ignoring: %s",
", ".join(sorted(unknown)),
)
# ── Blacklist ──────────────────────────────────────────────────────────
if disabled:
disabled_set = {n.strip() for n in disabled if n.strip()}
allowed -= disabled_set
unknown = disabled_set - canonical
if unknown:
logger.warning(
"Unknown tool(s) in --disabled-tools, ignoring: %s",
", ".join(sorted(unknown)),
)
# ── Remove ─────────────────────────────────────────────────────────────
to_remove = sorted(canonical - allowed)
if to_remove:
logger.info("Removing %d tool(s): %s", len(to_remove), ", ".join(to_remove))
for name in to_remove:
server.remove_tool(name)
logger.info("Tools available: %s", ", ".join(sorted(allowed)))
+1
View File
@@ -0,0 +1 @@
"""MCP tool modules for Exchange."""
+422
View File
@@ -0,0 +1,422 @@
"""Analytics tools for the Exchange MCP server.
Provides meeting statistics and connection matrix analysis
using GetUserAvailability and GetItem APIs.
"""
import json
from collections import Counter, defaultdict
from datetime import datetime, timedelta, date
from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext
from exchange_mcp.owa_client import OWAClient
from exchange_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
# ------------------------------------------------------------------
# Internal: resolve names to emails
# ------------------------------------------------------------------
def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
"""Resolve a name/email to (display_name, email). Returns ('','') on failure."""
resolutions = client.resolve_names(name)
if resolutions:
mb = resolutions[0].get("Mailbox", {})
return mb.get("Name", name), mb.get("EmailAddress", "")
return "", ""
# ------------------------------------------------------------------
# Internal: query GetUserAvailability in chunks
# ------------------------------------------------------------------
def _get_availability_events(
client: OWAClient,
emails: list[str],
start: date,
end: date,
batch_size: int = 5,
chunk_days: int = 14,
) -> dict[str, list[dict]]:
"""Query GetUserAvailability for multiple people across a date range.
Returns dict mapping email -> list of calendar event dicts with
keys: subject, start_date, busy_type.
"""
results: dict[str, list[dict]] = {email: [] for email in emails}
# Batch people
email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)]
for batch in email_batches:
current = start
while current < end:
chunk_end = min(current + timedelta(days=chunk_days), end)
mailbox_data = [{
'__type': 'MailboxData:#Exchange',
'Email': {
'__type': 'EmailAddress:#Exchange',
'Address': email,
},
'AttendeeType': 'Required',
} for email in batch]
payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
'TimeZoneContext': {
'__type': 'TimeZoneContext:#Exchange',
'TimeZoneDefinition': {
'__type': 'TimeZoneDefinitionType:#Exchange',
'Id': 'Russian Standard Time',
},
},
},
'Body': {
'__type': 'GetUserAvailabilityRequest:#Exchange',
'MailboxDataArray': mailbox_data,
'FreeBusyViewOptions': {
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{current}T00:00:00',
'EndTime': f'{chunk_end}T00:00:00',
},
'MergedFreeBusyIntervalInMinutes': 30,
'RequestedView': 'DetailedMerged',
},
},
}
try:
data = client.request('GetUserAvailability', payload)
body = data.get('Body', {})
for i, fb_resp in enumerate(body.get('FreeBusyResponseArray', [])):
if i >= len(batch):
break
email = batch[i]
fb_view = fb_resp.get('FreeBusyView', {})
cal_events = fb_view.get('CalendarEventArray', {})
if isinstance(cal_events, dict):
items = cal_events.get('Items', [])
elif isinstance(cal_events, list):
items = cal_events
else:
items = []
for event in items:
s = event.get('StartTime', '')
bt = event.get('BusyType', '')
if s and bt != 'Free':
details = event.get('CalendarEventDetails', {})
subject = details.get('Subject', '') if details else ''
results[email].append({
'subject': subject,
'start_date': s[:10],
'busy_type': bt,
})
except Exception:
pass
current = chunk_end
return results
# ------------------------------------------------------------------
# Tool: get_meeting_stats
# ------------------------------------------------------------------
@mcp.tool()
def get_meeting_stats(
people: str,
start_date: str,
end_date: str,
ctx: Context = None,
) -> str:
"""Get meeting count statistics for one or more people over a date range.
Uses GetUserAvailability to count calendar events (including
expanded recurring instances) for each person.
Args:
people: Comma-separated names or email addresses to analyze.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
Returns:
JSON object with per-person stats sorted by meeting count:
total_meetings, meetings_per_workday, days_with_meetings.
"""
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
# Resolve names
name_list = [n.strip() for n in people.split(',') if n.strip()]
if not name_list:
return json.dumps({"error": "No people specified."})
resolved = [] # (display_name, email)
for name in name_list:
display, email = _resolve_to_email(client, name)
if email:
resolved.append((display, email))
else:
resolved.append((name, ""))
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps({"error": "Could not resolve any names to email addresses."})
# Query availability
avail = _get_availability_events(client, emails, sd, ed)
# Count working days
workdays = 0
d = sd
while d < ed:
if d.weekday() < 5:
workdays += 1
d += timedelta(days=1)
workdays = max(workdays, 1)
# Build stats
stats = []
for display, email in resolved:
if not email or email not in avail:
stats.append({
"name": display,
"email": email or "not_found",
"total_meetings": 0,
"meetings_per_workday": 0,
"days_with_meetings": 0,
"workdays": workdays,
})
continue
events = avail[email]
total = len(events)
unique_days = len(set(ev['start_date'] for ev in events))
avg = round(total / workdays, 1)
stats.append({
"name": display,
"email": email,
"total_meetings": total,
"meetings_per_workday": avg,
"days_with_meetings": unique_days,
"workdays": workdays,
})
# Sort by total descending
stats.sort(key=lambda x: x["total_meetings"], reverse=True)
return json.dumps({
"period": {"start": start_date, "end": end_date, "workdays": workdays},
"stats": stats,
}, ensure_ascii=False)
# ------------------------------------------------------------------
# Tool: get_meeting_contacts
# ------------------------------------------------------------------
@mcp.tool()
def get_meeting_contacts(
start_date: str,
end_date: str,
top_n: int = 30,
ctx: Context = None,
) -> str:
"""Build a connection matrix: who you meet with most often.
Analyzes your calendar over a date range, accounting for recurring
meetings. For each contact found in your meetings, returns the
weighted count of shared meetings.
Args:
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format.
top_n: Number of top contacts to return. Default 30.
Returns:
JSON object with total_meetings, unique_contacts, and a ranked
contacts array of {name, email, meetings} objects.
"""
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
# Step 1: Get own expanded events via GetUserAvailability
# to count subject occurrences (handles recurring meetings)
own_email = client.user_email.lower() if client.user_email else ""
if not own_email:
return json.dumps({"error": "User email not available. Call the login tool first."})
folder_id = client.get_folder_id("calendar")
if not folder_id:
return json.dumps({"error": "Could not find calendar folder."})
# Get expanded event subjects with occurrence counts
avail_result = _get_availability_events(client, [client.user_email], sd, ed)
expanded_events = avail_result.get(client.user_email, [])
subject_counts = Counter(ev['subject'] for ev in expanded_events)
total_expanded = len(expanded_events)
# Step 2: Find master calendar items for each unique subject
# Scan entire calendar (descending) to match subjects
subject_to_id: dict[str, str] = {}
offset = 0
while len(subject_to_id) < len(subject_counts):
payload = {
'__type': 'FindItemJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
},
'Body': {
'__type': 'FindItemRequest:#Exchange',
'ItemShape': {
'__type': 'ItemResponseShape:#Exchange',
'BaseShape': 'Default',
},
'ParentFolderIds': [
{'__type': 'FolderId:#Exchange', 'Id': folder_id}
],
'Traversal': 'Shallow',
'Paging': {
'__type': 'IndexedPageView:#Exchange',
'BasePoint': 'Beginning',
'Offset': offset,
'MaxEntriesReturned': 200,
},
'SortOrder': [{
'__type': 'SortResults:#Exchange',
'Order': 'Descending',
'Path': {
'__type': 'PropertyUri:#Exchange',
'FieldURI': 'Start',
},
}],
},
}
data = client.request('FindItem', payload)
items = client.extract_items(data)
if not items:
break
folder_items = items[0].get('RootFolder', {}).get('Items', [])
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True)
if not folder_items:
break
for item in folder_items:
subj = item.get('Subject', '')
if subj and subj in subject_counts and subj not in subject_to_id:
subject_to_id[subj] = item.get('ItemId', {}).get('Id', '')
if is_last:
break
offset += 200
if offset > 3000:
break
# Step 3: GetItem for each master to get attendees
unique_ids = list(set(subject_to_id.values()))
id_to_attendees: dict[str, set] = {}
for bi in range(0, len(unique_ids), 10):
batch = unique_ids[bi:bi+10]
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
payload = {
"__type": "GetItemJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetItemRequest:#Exchange",
"ItemShape": {
"__type": "ItemResponseShape:#Exchange",
"BaseShape": "AllProperties",
},
"ItemIds": item_id_list,
},
}
try:
data = client.request("GetItem", payload)
for msg in client.extract_items(data):
if "Items" not in msg:
continue
for item in msg["Items"]:
item_id = item.get("ItemId", {}).get("Id", "")
attendees = set()
for att_list in [item.get("RequiredAttendees", []) or [],
item.get("OptionalAttendees", []) or []]:
for a in att_list:
mailbox = a.get("Mailbox", {})
name = mailbox.get("Name", "")
addr = mailbox.get("EmailAddress", "")
if not name or not addr or addr.startswith("/O="):
continue
attendees.add((name, addr.lower()))
organizer = item.get("Organizer", {}).get("Mailbox", {})
if organizer:
org_addr = organizer.get("EmailAddress", "")
org_name = organizer.get("Name", "")
if org_addr and not org_addr.startswith("/O="):
attendees.add((org_name, org_addr.lower()))
id_to_attendees[item_id] = attendees
except Exception:
pass
# Step 4: Build weighted contacts, excluding self
contacts = Counter()
for subject, item_id in subject_to_id.items():
weight = subject_counts.get(subject, 1)
attendees = id_to_attendees.get(item_id, set())
for name, email in attendees:
if own_email and email == own_email:
continue
contacts[(name, email)] += weight
result_contacts = []
for (name, email), count in contacts.most_common(top_n):
result_contacts.append({
"name": name,
"email": email,
"meetings": count,
})
return json.dumps({
"period": {"start": start_date, "end": end_date},
"total_meetings": total_expanded,
"unique_contacts": len(contacts),
"contacts": result_contacts,
}, ensure_ascii=False)
+117
View File
@@ -0,0 +1,117 @@
"""Authentication tool for the Exchange MCP server.
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.
The user obtains cookies from their browser (DevTools → Application → Cookies),
pastes them as a ``name=value`` string (one cookie per line), and passes them
to this tool.
"""
import json
from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext
from exchange_mcp.owa_client import OWAClient, SessionExpiredError
from exchange_mcp.server_lifecycle import require_client
def _get_app_ctx(ctx: Context) -> AppContext:
"""Extract the AppContext from the MCP lifespan context."""
return ctx.request_context.lifespan_context
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
return require_client(_get_app_ctx(ctx).client)
def _session_is_valid(client: OWAClient) -> bool:
"""Quick check: can we reach the inbox?"""
try:
client._ensure_loaded()
payload = {
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "IdOnly",
},
"FolderIds": [
{
"__type": "DistinguishedFolderId:#Exchange",
"Id": "inbox",
}
],
},
}
data = client.request("GetFolder", payload)
items = client.extract_items(data)
return bool(items)
except Exception:
return False
@mcp.tool()
async def login(
cookies: str,
ctx: Context = None,
) -> str:
"""Authenticate to Exchange OWA using session cookies from the browser.
Paste the cookies you copied from your browser (DevTools → Application →
Cookies) as a ``name=value`` string — one cookie per line.
Example cookies string::
X-OWA-CANARY=abc123
CookieAuth1=def456
...
Args:
cookies: Session cookies in ``name=value`` format, one per line.
Obtain from browser DevTools → Application → Cookies → copy all
cookies as ``name=value`` pairs.
Returns:
JSON result with success status and message.
"""
client = _get_client(ctx)
# Parse and validate cookies
cookies_str = cookies.strip()
if not cookies_str:
return json.dumps({
"success": False,
"error": "Cookies string is empty. Paste your browser cookies as name=value pairs, one per line.",
})
# Save to disk
try:
client.cookie_file.write_text(cookies_str + "\n")
except Exception as e:
return json.dumps({"success": False, "error": f"Failed to write cookie file: {e}"})
# Load into memory
try:
client.load_cookies_from_string(cookies_str)
except SessionExpiredError as e:
return json.dumps({"success": False, "error": str(e)})
# Verify session
if _session_is_valid(client):
return json.dumps({
"success": True,
"message": f"Logged in. Session cookies saved to {client.cookie_file}.",
})
else:
return json.dumps({
"success": False,
"error": "Cookies loaded but session verification failed. The cookies may have expired.",
})
+594
View File
@@ -0,0 +1,594 @@
"""Availability / free-time tools for the Exchange MCP server.
Ports find-free-time.py and find-meeting-time.py logic into MCP tools
using OWAClient.
"""
import json
from datetime import datetime, timedelta
from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext
from exchange_mcp.owa_client import OWAClient
from exchange_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
# ------------------------------------------------------------------
# Pure helper functions (preserved from find-meeting-time.py)
# ------------------------------------------------------------------
def _parse_freebusy_string(
freebusy_str: str, start_time: datetime, interval_minutes: int = 30
) -> list[tuple]:
"""Parse the MergedFreeBusy string.
Each character represents a time slot:
0 = Free, 1 = Tentative, 2 = Busy, 3 = Out of Office, 4 = Working Elsewhere
"""
busy_periods = []
current_time = start_time
for char in freebusy_str:
next_time = current_time + timedelta(minutes=interval_minutes)
if char in ['1', '2', '3', '4']: # Not free
busy_periods.append((current_time, next_time, char))
current_time = next_time
return busy_periods
def _merge_busy_periods(all_busy: list) -> list[tuple]:
"""Merge overlapping busy periods."""
if not all_busy:
return []
# Sort by start time
sorted_busy = sorted(all_busy, key=lambda x: x[0])
merged = [(sorted_busy[0][0], sorted_busy[0][1])]
for start, end, *_ in sorted_busy[1:]:
if start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
def _find_free_slots(
busy_periods: list, date, start_hour: int, end_hour: int, duration_minutes: int
) -> list[tuple]:
"""Find free slots on a given date within working hours."""
day_start = datetime.combine(date, datetime.min.time().replace(hour=start_hour))
day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour))
# Filter busy periods to this day
day_busy = []
for period in busy_periods:
start, end = period[0], period[1]
if end.date() < date or start.date() > date:
continue
start = max(start, day_start)
end = min(end, day_end)
if start < end:
day_busy.append((start, end))
# Merge overlapping
merged = _merge_busy_periods([(s, e) for s, e in day_busy])
# Find gaps
free_slots = []
current = day_start
for busy_start, busy_end in merged:
if current < busy_start:
gap_duration = (busy_start - current).total_seconds() / 60
if gap_duration >= duration_minutes:
free_slots.append((current, busy_start))
current = max(current, busy_end)
# Check for time after last meeting
if current < day_end:
gap_duration = (day_end - current).total_seconds() / 60
if gap_duration >= duration_minutes:
free_slots.append((current, day_end))
return free_slots
def _format_time(dt: datetime) -> str:
"""Format datetime as HH:MM."""
return dt.strftime('%H:%M')
# ------------------------------------------------------------------
# Helper: get busy events via GetUserAvailability (includes recurring)
# ------------------------------------------------------------------
def _get_availability_events(
client: OWAClient, email: str, start_date, end_date
) -> list[dict]:
"""Get busy events via GetUserAvailability (expands recurring events)."""
payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
'TimeZoneContext': {
'__type': 'TimeZoneContext:#Exchange',
'TimeZoneDefinition': {
'__type': 'TimeZoneDefinitionType:#Exchange',
'Id': 'Russian Standard Time',
},
},
},
'Body': {
'__type': 'GetUserAvailabilityRequest:#Exchange',
'MailboxDataArray': [{
'__type': 'MailboxData:#Exchange',
'Email': {'__type': 'EmailAddress:#Exchange', 'Address': email},
'AttendeeType': 'Required',
}],
'FreeBusyViewOptions': {
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{start_date}T00:00:00',
'EndTime': f'{end_date + timedelta(days=1)}T00:00:00',
},
'MergedFreeBusyIntervalInMinutes': 30,
'RequestedView': 'DetailedMerged',
},
},
}
data = client.request('GetUserAvailability', payload)
body = data.get('Body', {})
events = []
for fb_resp in body.get('FreeBusyResponseArray', []):
fb_view = fb_resp.get('FreeBusyView', {})
cal_events = fb_view.get('CalendarEventArray', {})
items = (
cal_events.get('Items', [])
if isinstance(cal_events, dict)
else (cal_events if isinstance(cal_events, list) else [])
)
for event in items:
bt = event.get('BusyType', '')
if bt in ('Free', 'NoData'):
continue
start_str = event.get('StartTime', '')
end_str = event.get('EndTime', '')
if not start_str or not end_str:
continue
try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None)
end = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None)
events.append({'start': start, 'end': end, 'status': bt})
except (ValueError, AttributeError):
continue
return events
# ------------------------------------------------------------------
# Helper: get calendar folder ID
# ------------------------------------------------------------------
def _get_calendar_folder_id(client: OWAClient) -> str | None:
"""Get the calendar folder ID via GetFolder."""
payload = {
'__type': 'GetFolderJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
},
'Body': {
'__type': 'GetFolderRequest:#Exchange',
'FolderShape': {
'__type': 'FolderResponseShape:#Exchange',
'BaseShape': 'IdOnly',
},
'FolderIds': [
{'__type': 'DistinguishedFolderId:#Exchange', 'Id': 'calendar'}
],
},
}
data = client.request("GetFolder", payload)
for msg in client.extract_items(data):
if "Folders" in msg:
for f in msg["Folders"]:
fid = f.get("FolderId", {}).get("Id")
if fid:
return fid
return None
# ------------------------------------------------------------------
# Helper: get own calendar events (from find-free-time.py)
# ------------------------------------------------------------------
def _get_calendar_events(
client: OWAClient, folder_id: str, start_date, end_date
) -> list[dict]:
"""Get calendar events within a date range. Returns list of busy period dicts."""
events = []
offset = 0
batch_size = 100
while True:
payload = {
'__type': 'FindItemJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
},
'Body': {
'__type': 'FindItemRequest:#Exchange',
'ItemShape': {
'__type': 'ItemResponseShape:#Exchange',
'BaseShape': 'AllProperties',
},
'ParentFolderIds': [
{'__type': 'FolderId:#Exchange', 'Id': folder_id}
],
'Traversal': 'Shallow',
'Paging': {
'__type': 'IndexedPageView:#Exchange',
'BasePoint': 'Beginning',
'Offset': offset,
'MaxEntriesReturned': batch_size,
},
'SortOrder': [
{
'__type': 'SortResults:#Exchange',
'Order': 'Ascending',
'Path': {
'__type': 'PropertyUri:#Exchange',
'FieldURI': 'Start',
},
}
],
},
}
data = client.request("FindItem", payload)
items = client.extract_items(data)
if not items:
break
folder_items = items[0].get('RootFolder', {}).get('Items', [])
if not folder_items:
break
for item in folder_items:
# Skip cancelled events
if item.get('IsCancelled'):
continue
# Skip free/tentative slots
fbt = item.get('FreeBusyType', 'Busy')
if fbt in ['Free', 'NoData']:
continue
start_str = item.get('Start', '')
end_str = item.get('End', '')
if not start_str or not end_str:
continue
try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
# Convert to naive datetime for comparison
start = start.replace(tzinfo=None)
end = end.replace(tzinfo=None)
# Filter by date range
if end.date() < start_date or start.date() > end_date:
continue
events.append({
'start': start,
'end': end,
'subject': item.get('Subject', ''),
'status': fbt,
})
except (ValueError, AttributeError):
continue
# Check if there are more items
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True)
if is_last:
break
offset += batch_size
return events
# ------------------------------------------------------------------
# Tool: find_free_time
# ------------------------------------------------------------------
@mcp.tool()
def find_free_time(
start_date: str,
end_date: str = "",
duration_minutes: int = 30,
start_hour: int = 9,
end_hour: int = 18,
ctx: Context = None,
) -> str:
"""Find free time slots in your own calendar.
Analyzes your calendar events and returns available time slots
within working hours for each weekday in the range.
Args:
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format. Defaults to start_date
if not provided (single-day search).
duration_minutes: Minimum slot duration in minutes. Default 30.
start_hour: Working day start hour (0-23). Default 9.
end_hour: Working day end hour (0-23). Default 18.
Returns:
JSON object with free_slots keyed by date, each containing an
array of {start, end, duration_minutes} objects.
"""
client = _get_client(ctx)
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
try:
# Use GetUserAvailability for accurate recurring event expansion
if client.user_email:
all_busy = _get_availability_events(client, client.user_email, sd, ed)
else:
# Fallback to FindItem (misses recurring event occurrences)
folder_id = _get_calendar_folder_id(client)
if not folder_id:
return json.dumps({"error": "Could not find calendar folder. Session may have expired."})
all_busy = _get_calendar_events(client, folder_id, sd, ed)
except Exception as e:
return json.dumps({"error": str(e)})
# Convert event dicts to (start, end) tuples for _find_free_slots
busy_periods = [(ev['start'], ev['end']) for ev in all_busy]
result = {}
current_date = sd
while current_date <= ed:
# Skip weekends
if current_date.weekday() < 5:
free = _find_free_slots(
busy_periods, current_date,
start_hour, end_hour, duration_minutes,
)
if free:
result[str(current_date)] = [
{
"start": _format_time(s),
"end": _format_time(e),
"duration_minutes": int((e - s).total_seconds() / 60),
}
for s, e in free
]
current_date += timedelta(days=1)
return json.dumps({"free_slots": result}, ensure_ascii=False)
# ------------------------------------------------------------------
# Tool: find_meeting_time
# ------------------------------------------------------------------
@mcp.tool()
def find_meeting_time(
emails: str,
start_date: str,
end_date: str = "",
duration_minutes: int = 30,
start_hour: int = 9,
end_hour: int = 18,
ctx: Context = None,
) -> str:
"""Find meeting times that work for multiple people.
Uses the OWA GetUserAvailability API to check cross-mailbox
availability and find common free slots for all attendees.
Supports multi-day ranges — searches each weekday in the range.
Args:
emails: Comma-separated email addresses or names of attendees.
start_date: Start date in YYYY-MM-DD format.
end_date: End date in YYYY-MM-DD format. Defaults to start_date
if not provided (single-day search).
duration_minutes: Minimum slot duration in minutes. Default 30.
start_hour: Working day start hour (0-23). Default 9.
end_hour: Working day end hour (0-23). Default 18.
Returns:
JSON object with attendee info and free_slots keyed by date,
each containing an array of {start, end, duration_minutes}.
"""
client = _get_client(ctx)
raw_list = [e.strip() for e in emails.split(',') if e.strip()]
if not raw_list:
return json.dumps({"error": "No email addresses provided."})
try:
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
ed = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else sd
except ValueError as e:
return json.dumps({"error": f"Invalid date format: {e}"})
# Resolve names to email addresses via ResolveNames
email_list = []
resolve_errors = []
for entry in raw_list:
if '@' in entry:
email_list.append(entry)
else:
resolutions = client.resolve_names(entry, full_contact=False)
if resolutions:
addr = resolutions[0].get('Mailbox', {}).get('EmailAddress', '')
if addr:
email_list.append(addr)
else:
resolve_errors.append(entry)
else:
resolve_errors.append(entry)
if not email_list:
return json.dumps({"error": f"Could not resolve any names to email addresses: {resolve_errors}"})
# Build mailbox data (reused for each day chunk)
mailbox_data = []
for email in email_list:
mailbox_data.append({
'__type': 'MailboxData:#Exchange',
'Email': {
'__type': 'EmailAddress:#Exchange',
'Address': email,
},
'AttendeeType': 'Required',
})
# Query the full date range at once (API handles multi-day windows)
payload = {
'__type': 'GetUserAvailabilityJsonRequest:#Exchange',
'Header': {
'__type': 'JsonRequestHeaders:#Exchange',
'RequestServerVersion': 'Exchange2013',
'TimeZoneContext': {
'__type': 'TimeZoneContext:#Exchange',
'TimeZoneDefinition': {
'__type': 'TimeZoneDefinitionType:#Exchange',
'Id': 'Russian Standard Time',
},
},
},
'Body': {
'__type': 'GetUserAvailabilityRequest:#Exchange',
'MailboxDataArray': mailbox_data,
'FreeBusyViewOptions': {
'__type': 'FreeBusyViewOptions:#Exchange',
'TimeWindow': {
'__type': 'Duration:#Exchange',
'StartTime': f'{sd}T00:00:00',
'EndTime': f'{ed + timedelta(days=1)}T00:00:00',
},
'MergedFreeBusyIntervalInMinutes': 30,
'RequestedView': 'DetailedMerged',
},
},
}
try:
data = client.request("GetUserAvailability", payload)
except Exception as e:
return json.dumps({"error": str(e)})
body = data.get('Body', {})
if 'ErrorCode' in body:
return json.dumps({"error": body.get('FaultMessage', 'Unknown error')})
# Parse availability responses
all_busy = []
attendee_info = []
freebusy_responses = body.get('FreeBusyResponseArray', [])
for i, fb_resp in enumerate(freebusy_responses):
fb_view = fb_resp.get('FreeBusyView', {})
merged_fb = fb_view.get('MergedFreeBusy', '')
email = email_list[i] if i < len(email_list) else f"Person {i+1}"
if merged_fb:
start_time = datetime.combine(sd, datetime.min.time())
busy_periods = _parse_freebusy_string(merged_fb, start_time)
busy_count = sum(1 for c in merged_fb if c != '0')
free_count = sum(1 for c in merged_fb if c == '0')
attendee_info.append({
"email": email,
"busy_slots": busy_count,
"free_slots": free_count,
})
all_busy.extend(busy_periods)
else:
# Fallback: parse CalendarEventArray
cal_events_raw = fb_view.get('CalendarEventArray', {})
cal_events = cal_events_raw.get('Items', []) if isinstance(cal_events_raw, dict) else (cal_events_raw if isinstance(cal_events_raw, list) else [])
if cal_events:
attendee_info.append({
"email": email,
"calendar_events": len(cal_events),
})
for event in cal_events:
start_str = event.get('StartTime', '')
end_str = event.get('EndTime', '')
if start_str and end_str:
try:
start = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
all_busy.append((start, end))
except Exception:
pass
else:
attendee_info.append({
"email": email,
"status": "no_data",
})
merged_busy = _merge_busy_periods(all_busy)
# Find free slots for each weekday in range
free_by_date = {}
current_date = sd
while current_date <= ed:
if current_date.weekday() < 5: # Skip weekends
free = _find_free_slots(
merged_busy, current_date,
start_hour, end_hour, duration_minutes,
)
if free:
free_by_date[str(current_date)] = [
{
"start": _format_time(s),
"end": _format_time(e),
"duration_minutes": int((e - s).total_seconds() / 60),
}
for s, e in free
]
current_date += timedelta(days=1)
result = {
"period": {"start": str(sd), "end": str(ed)},
"attendees": attendee_info,
"free_slots": free_by_date,
}
if resolve_errors:
result["unresolved"] = resolve_errors
return json.dumps(result, ensure_ascii=False)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
"""Folder tools for the Exchange MCP server.
Ports the get-folders.py logic into an MCP tool using OWAClient.
"""
import json
from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext
from exchange_mcp.owa_client import OWAClient
from exchange_mcp.server_lifecycle import require_client
_DISTINGUISHED_NAMES = {
"msgfolderroot", "inbox", "sentitems", "drafts", "deleteditems",
"junkemail", "outbox", "calendar", "contacts", "tasks", "notes",
"journal", "searchfolders",
}
_HEADER_TZ = {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
"TimeZoneContext": {
"__type": "TimeZoneContext:#Exchange",
"TimeZoneDefinition": {
"__type": "TimeZoneDefinitionType:#Exchange",
"Id": "Russian Standard Time",
},
},
}
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
def _folder_id_dict(folder_id: str) -> dict:
"""Build a typed FolderId or DistinguishedFolderId dict."""
if folder_id.lower() in _DISTINGUISHED_NAMES:
return {"__type": "DistinguishedFolderId:#Exchange", "Id": folder_id}
return {"__type": "FolderId:#Exchange", "Id": folder_id}
@mcp.tool()
def check_session(ctx: Context = None) -> str:
"""Check whether the current OWA session is authenticated.
Makes a lightweight GetFolder call on the inbox. Returns session
status, the mailbox display name, and cookie file path.
Returns:
JSON object with authenticated (bool), mailbox name, and details.
"""
client = _get_client(ctx)
payload = {
"__type": "GetFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "GetFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"FolderIds": [
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
],
},
}
try:
data = client.request("GetFolder", payload)
except Exception as e:
return json.dumps({
"authenticated": False,
"error": str(e),
"cookie_file": str(client.cookie_file),
})
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"authenticated": True,
"mailbox": folder.get("DisplayName", ""),
"unread": folder.get("UnreadCount", 0),
"cookie_file": str(client.cookie_file),
})
return json.dumps({
"authenticated": False,
"error": "Unexpected response",
"cookie_file": str(client.cookie_file),
})
@mcp.tool()
def get_folders(
parent_folder_id: str = "msgfolderroot",
recursive: bool = False,
ctx: Context = None,
) -> str:
"""List mail folders from the Exchange mailbox.
Args:
parent_folder_id: Parent folder to list children of.
Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID.
recursive: If True, traverse all subfolders recursively (Deep).
If False, only list immediate children (Shallow).
Returns:
JSON array of folder objects with: name, id, total_count,
unread_count, child_folder_count.
"""
client = _get_client(ctx)
traversal = "Deep" if recursive else "Shallow"
# Determine parent folder id type
# Distinguished folder names are short lowercase strings
parent_folder = _folder_id_dict(parent_folder_id)
payload = {
"__type": "FindFolderJsonRequest:#Exchange",
"Header": {
"__type": "JsonRequestHeaders:#Exchange",
"RequestServerVersion": "Exchange2013",
},
"Body": {
"__type": "FindFolderRequest:#Exchange",
"FolderShape": {
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"ParentFolderIds": [parent_folder],
"Traversal": traversal,
"Paging": {
"__type": "IndexedPageView:#Exchange",
"BasePoint": "Beginning",
"Offset": 0,
"MaxEntriesReturned": 200,
},
},
}
try:
data = client.request("FindFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
folders = []
for msg in client.extract_items(data):
if "RootFolder" in msg and "Folders" in msg["RootFolder"]:
for f in msg["RootFolder"]["Folders"]:
folders.append({
"name": f.get("DisplayName", "Unknown"),
"id": f.get("FolderId", {}).get("Id", ""),
"total_count": f.get("TotalCount", 0),
"unread_count": f.get("UnreadCount", 0),
"child_folder_count": f.get("ChildFolderCount", 0),
})
return json.dumps(folders, ensure_ascii=False)
@mcp.tool()
def create_folder(
name: str,
parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
) -> str:
"""Create a new mail folder.
Args:
name: Display name for the new folder.
parent_folder_id: Parent folder to create under.
Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID.
Returns:
JSON object with the created folder's id and name.
"""
client = _get_client(ctx)
payload = {
"__type": "CreateFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "CreateFolderRequest:#Exchange",
"ParentFolderId": {
"__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(parent_folder_id),
},
"Folders": [
{
"__type": "Folder:#Exchange",
"DisplayName": name,
"FolderClass": "IPF.Note",
}
],
},
}
try:
data = client.request_header_payload("CreateFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"success": True,
"name": name,
"id": folder.get("FolderId", {}).get("Id", ""),
})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def rename_folder(
folder_id: str,
new_name: str,
ctx: Context = None,
) -> str:
"""Rename an existing mail folder.
Args:
folder_id: The Exchange folder ID to rename (from get_folders).
new_name: New display name for the folder.
Returns:
JSON object with success status and new folder id.
"""
client = _get_client(ctx)
payload = {
"__type": "UpdateFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "UpdateFolderRequest:#Exchange",
"FolderChanges": [
{
"__type": "FolderChange:#Exchange",
"FolderId": {
"__type": "FolderId:#Exchange",
"Id": folder_id,
},
"Updates": [
{
"__type": "SetFolderField:#Exchange",
"Path": {
"__type": "PropertyUri:#Exchange",
"FieldURI": "FolderDisplayName",
},
"Folder": {
"__type": "Folder:#Exchange",
"DisplayName": new_name,
},
}
],
}
],
},
}
try:
data = client.request_header_payload("UpdateFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"success": True,
"new_name": new_name,
"id": folder.get("FolderId", {}).get("Id", ""),
})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def empty_folder(
folder_id: str,
delete_sub_folders: bool = False,
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Empty all items from a mail folder.
Args:
folder_id: The Exchange folder ID to empty (from get_folders).
delete_sub_folders: If True, also delete sub-folders. Default False.
permanent: If True, permanently delete items (HardDelete).
Otherwise move to Deleted Items. Default False.
Returns:
JSON object with success status.
"""
client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
payload = {
"__type": "EmptyFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "EmptyFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"DeleteType": delete_type,
"DeleteSubFolders": delete_sub_folders,
"SuppressReadReceipt": True,
},
}
try:
data = client.request_header_payload("EmptyFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "folder_id": folder_id})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def delete_folder(
folder_id: str,
permanent: bool = False,
ctx: Context = None,
) -> str:
"""Delete a mail folder.
Args:
folder_id: The Exchange folder ID to delete (from get_folders).
permanent: If True, permanently delete (HardDelete).
Otherwise move to Deleted Items. Default False.
Returns:
JSON object with success status.
"""
client = _get_client(ctx)
delete_type = "HardDelete" if permanent else "MoveToDeletedItems"
payload = {
"__type": "DeleteFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "DeleteFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"DeleteType": delete_type,
},
}
try:
data = client.request_header_payload("DeleteFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if msg.get("ResponseClass") == "Success":
return json.dumps({"success": True, "folder_id": folder_id})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
@mcp.tool()
def move_folder(
folder_id: str,
target_parent_folder_id: str = "msgfolderroot",
ctx: Context = None,
) -> str:
"""Move a mail folder to a different parent folder.
Args:
folder_id: The Exchange folder ID to move (from get_folders).
target_parent_folder_id: Destination parent folder.
Defaults to "msgfolderroot" (top-level). Can be a
distinguished folder name or a raw folder ID.
Returns:
JSON object with success status and new folder ID.
"""
client = _get_client(ctx)
payload = {
"__type": "MoveFolderJsonRequest:#Exchange",
"Header": _HEADER_TZ,
"Body": {
"__type": "MoveFolderRequest:#Exchange",
"FolderIds": [
{"__type": "FolderId:#Exchange", "Id": folder_id}
],
"ToFolderId": {
"__type": "TargetFolderId:#Exchange",
"BaseFolderId": _folder_id_dict(target_parent_folder_id),
},
},
}
try:
data = client.request_header_payload("MoveFolder", payload)
except Exception as e:
return json.dumps({"error": str(e)})
for msg in client.extract_items(data):
if "Folders" in msg:
folder = msg["Folders"][0]
return json.dumps({
"success": True,
"folder_id": folder.get("FolderId", {}).get("Id", ""),
})
return json.dumps({"error": "Unexpected response", "raw": str(data)})
+119
View File
@@ -0,0 +1,119 @@
"""People / directory search tools for the Exchange MCP server.
Ports the find-person.py logic into an MCP tool using OWAClient.
"""
import json
from mcp.server.fastmcp import Context
from exchange_mcp.server import mcp, AppContext
from exchange_mcp.owa_client import OWAClient
from exchange_mcp.server_lifecycle import require_client
def _get_client(ctx: Context) -> OWAClient:
"""Extract the OWAClient from the MCP lifespan context."""
app_ctx: AppContext = ctx.request_context.lifespan_context
return require_client(app_ctx.client)
def _parse_person(resolution: dict) -> dict:
"""Parse person data from a ResolveNames resolution entry.
Preserves the exact logic from find-person.py parse_person().
"""
mailbox = resolution.get("Mailbox", {})
contact = resolution.get("Contact", {})
person = {
"name": mailbox.get("Name", contact.get("DisplayName", "")),
"email": mailbox.get("EmailAddress", ""),
"type": mailbox.get("MailboxType", ""),
"first_name": contact.get("GivenName", ""),
"last_name": contact.get("Surname", ""),
"job_title": contact.get("JobTitle", ""),
"department": contact.get("Department", ""),
"company": contact.get("CompanyName", ""),
"office": contact.get("OfficeLocation", ""),
"manager": "",
"manager_email": "",
"phones": {},
"address": {},
"direct_reports": [],
"alias": contact.get("Alias", ""),
}
# Phone numbers
for phone in contact.get("PhoneNumbers", []):
key = phone.get("Key", "")
number = phone.get("PhoneNumber", "")
if number:
person["phones"][key] = number
# Physical address
for addr in contact.get("PhysicalAddresses", []):
if addr.get("Key") == "Business":
parts = []
if addr.get("Street"):
parts.append(addr["Street"])
if addr.get("City"):
parts.append(addr["City"])
if addr.get("PostalCode"):
parts.append(addr["PostalCode"])
if addr.get("CountryOrRegion"):
parts.append(addr["CountryOrRegion"])
if parts:
person["address"] = {
"street": addr.get("Street", ""),
"city": addr.get("City", ""),
"postal_code": addr.get("PostalCode", ""),
"country": addr.get("CountryOrRegion", ""),
"full": ", ".join(parts),
}
# Manager
manager_data = contact.get("ManagerMailbox", {}).get("Mailbox", {})
if manager_data:
person["manager"] = manager_data.get("Name", "")
person["manager_email"] = manager_data.get("EmailAddress", "")
elif contact.get("Manager"):
person["manager"] = contact.get("Manager", "")
# Direct reports
for report in contact.get("DirectReports", []):
person["direct_reports"].append({
"name": report.get("Name", ""),
"email": report.get("EmailAddress", ""),
})
return person
@mcp.tool()
def find_person(query: str, ctx: Context) -> str:
"""Search for people in the corporate directory.
Looks up employees by name, email, department, or keyword using the
Exchange ResolveNames API against Active Directory.
Args:
query: Name, email address, or keyword to search for.
Returns:
JSON array of matching people with contact details (name, email,
job_title, department, company, office, phones, address, manager,
direct_reports, alias).
"""
client = _get_client(ctx)
try:
resolutions = client.resolve_names(query)
except Exception as e:
return json.dumps({"error": str(e)})
if not resolutions:
return json.dumps([])
people = [_parse_person(r) for r in resolutions]
return json.dumps(people, ensure_ascii=False)
+133
View File
@@ -0,0 +1,133 @@
"""Shared utility functions for the Exchange MCP server.
Extracts duplicated helpers from the standalone scripts:
html_to_text, date/time formatting and parsing.
"""
import html
import re
from datetime import datetime
def html_to_text(html_content: str) -> str:
"""Convert HTML to plain text.
Strips scripts, styles, converts <br>/<p>/<div> to newlines,
removes remaining tags, and unescapes HTML entities.
"""
if not html_content:
return ""
text = re.sub(
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(r"<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"<div[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text = re.sub(r"\n\s*\n", "\n\n", text)
text = re.sub(r"[ \t]+", " ", text)
return text.strip()
def extract_links_from_html(html_content: str) -> list[dict]:
"""Extract hyperlinks from HTML content.
Finds <a href="...">text</a> patterns, excludes mailto:, cid:,
javascript:, and fragment-only (#) links. Deduplicates by URL.
Returns list of {url, text} dicts.
"""
if not html_content:
return []
# Match <a ...href="URL"...>text</a>
pattern = re.compile(
r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
re.DOTALL | re.IGNORECASE,
)
seen: set[str] = set()
links: list[dict] = []
for url_raw, text_raw in pattern.findall(html_content):
url = html.unescape(url_raw).strip()
# Skip non-http links
if url.startswith(("mailto:", "cid:", "javascript:")) or url == "#":
continue
# Skip fragment-only links
if url.startswith("#"):
continue
if url in seen:
continue
seen.add(url)
# Clean link text: strip tags and whitespace
text = re.sub(r"<[^>]+>", "", text_raw)
text = html.unescape(text).strip()
links.append({"url": url, "text": text})
return links
def format_datetime(dt_str: str) -> str:
"""Format an ISO datetime string as 'YYYY-MM-DD HH:MM'.
Strips timezone suffixes (Z, +offset) for cleaner display.
"""
if not dt_str:
return ""
if "T" in dt_str:
date_part, time_part = dt_str.split("T", 1)
time_part = time_part.split("Z")[0].split("+")[0]
return f"{date_part} {time_part[:5]}"
return dt_str
def format_date(dt_str: str) -> str:
"""Extract the date portion from an ISO datetime string."""
if not dt_str:
return ""
if "T" in dt_str:
return dt_str.split("T")[0]
return dt_str
def parse_date(date_str: str) -> datetime:
"""Parse a date string in common formats.
Supports: YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY.
"""
formats = ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"Could not parse date: {date_str}")
def parse_iso_datetime(dt_str: str) -> datetime:
"""Parse an ISO datetime string to a naive datetime.
Handles both 'YYYY-MM-DDTHH:MM:SS' and 'YYYY-MM-DD' formats,
stripping any timezone suffix.
"""
if "T" in dt_str:
clean = dt_str.split("Z")[0].split("+")[0]
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S")
return datetime.strptime(dt_str, "%Y-%m-%d")
def format_attendee(name: str, email: str) -> str:
"""Format an attendee as 'Name <email>' or just the email."""
if name and email and not email.startswith("/O="):
return f"{name} <{email}>"
return name or email or ""
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "exchange-mcp-server"
version = "1.0.1"
description = "MCP server for Exchange / OWA - email, calendar, directory, availability"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
keywords = ["mcp", "exchange", "owa", "outlook", "email", "calendar"]
classifiers = [
"Programming Language :: Python :: 3",
]
dependencies = [
"mcp>=1.0.0",
"requests>=2.28.0",
"sse-starlette>=2.0.0",
]
[project.urls]
Homepage = "https://git.kosyrev.name/zoo/owa-mcp"
Repository = "https://git.kosyrev.name/zoo/owa-mcp"
[project.optional-dependencies]
http = ["uvicorn>=0.30.0"]
[project.scripts]
exchange-mcp-server = "exchange_mcp.server:main"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["exchange_mcp*"]