Compare commits
1 Commits
main
..
a9497625b1
| Author | SHA1 | Date | |
|---|---|---|---|
| a9497625b1 |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"],
|
|
||||||
"includes": ["**/*.{md,json,py}"],
|
|
||||||
"indentWidth": 2,
|
|
||||||
"lineWidth": 80,
|
|
||||||
"ruff": {
|
|
||||||
"lineLength": 80,
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ __pycache__/
|
|||||||
.mcpregistry_*
|
.mcpregistry_*
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.salt
|
.salt
|
||||||
.venv
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.pyc
|
*.pyc
|
||||||
build
|
build
|
||||||
|
|||||||
@@ -1,51 +1,287 @@
|
|||||||
# OWA MCP Server
|
# OWA MCP Server
|
||||||
|
|
||||||
MCP server for any Microsoft Exchange / OWA deployment. 30 tools for email,
|
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.
|
||||||
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 |
|
| `--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 |
|
| `--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` | — | HTTP only | SHA-256 hash of the bearer token |
|
| `--token-hash` | — | For HTTP | 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` | Authenticate to OWA using browser session cookies |
|
||||||
|
|
||||||
`login`
|
## 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,5 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
RUN pip install --no-cache-dir -e ".[http]"
|
|
||||||
CMD ["owa-mcp"]
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Exchange MCP Server - access OWA email, calendar, directory via MCP."""
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
@@ -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()
|
||||||
@@ -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)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""MCP tool modules for Exchange."""
|
||||||
@@ -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)
|
||||||
@@ -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.",
|
||||||
|
})
|
||||||
@@ -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
@@ -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)})
|
||||||
@@ -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)
|
||||||
@@ -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 ""
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""OWA MCP Server — access OWA email, calendar, directory via MCP."""
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
"""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 time
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote, urlparse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
class SessionExpiredError(Exception):
|
|
||||||
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# 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 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(f"Session expired (HTTP {resp.status_code}).")
|
|
||||||
|
|
||||||
if "text/html" in resp.headers.get("Content-Type", ""):
|
|
||||||
body_snippet = resp.text[:300] if resp.text else ""
|
|
||||||
raise SessionExpiredError(
|
|
||||||
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). 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?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 []
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"""OWA 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
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 TokenVerifier
|
|
||||||
from mcp.server.auth.settings import AuthSettings
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
|
||||||
|
|
||||||
from owa_mcp.owa_client import OWAClient
|
|
||||||
from owa_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:`~owa_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(
|
|
||||||
"owa",
|
|
||||||
lifespan=app_lifespan,
|
|
||||||
transport_security=TransportSecuritySettings(
|
|
||||||
enable_dns_rebinding_protection=False,
|
|
||||||
allowed_hosts=[],
|
|
||||||
allowed_origins=[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Force-disable DNS rebinding protection even if FastMCP auto-config
|
|
||||||
# overwrites it (e.g. when --host is a non-localhost address).
|
|
||||||
mcp.settings.transport_security = TransportSecuritySettings(
|
|
||||||
enable_dns_rebinding_protection=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args(argv=None) -> argparse.Namespace:
|
|
||||||
"""Parse command-line arguments."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="OWA 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 or group aliases (read-only, write-only) to expose",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--disabled-tools",
|
|
||||||
default=None,
|
|
||||||
help="Comma-separated blacklist of tool names or group aliases (read-only, write-only) 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 owa_mcp.tools.analytics # noqa: E402, F401
|
|
||||||
import owa_mcp.tools.auth # noqa: E402, F401
|
|
||||||
import owa_mcp.tools.availability # noqa: E402, F401
|
|
||||||
import owa_mcp.tools.calendar # noqa: E402, F401
|
|
||||||
import owa_mcp.tools.email # noqa: E402, F401
|
|
||||||
import owa_mcp.tools.folders # noqa: E402, F401
|
|
||||||
import owa_mcp.tools.people # noqa: E402, F401
|
|
||||||
|
|
||||||
# ── Auth for HTTP transports ───────────────────────────────────────────
|
|
||||||
_configure_auth(mcp, args.token_hash)
|
|
||||||
|
|
||||||
# ── Tool filtering ─────────────────────────────────────────────────────
|
|
||||||
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()]
|
|
||||||
|
|
||||||
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()]
|
|
||||||
|
|
||||||
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 ─────────────────────────────────────────────────────
|
|
||||||
# Allow all X-Forwarded-* headers (needed when behind a proxy or
|
|
||||||
# when clients connect with a non-default Host header).
|
|
||||||
os.environ["FORWARDED_ALLOW_IPS"] = "*"
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
"""Shared lifecycle utilities for the OWA 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 mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
||||||
|
|
||||||
from owa_mcp.owa_client import OWAClient, SessionExpiredError
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
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 groups ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
TOOL_GROUPS: dict[str, set[str]] = {
|
|
||||||
"read-only": {
|
|
||||||
"get_emails",
|
|
||||||
"get_email",
|
|
||||||
"search_emails",
|
|
||||||
"get_calendar_events",
|
|
||||||
"get_event_links",
|
|
||||||
"find_person",
|
|
||||||
"get_folders",
|
|
||||||
"check_session",
|
|
||||||
"find_free_time",
|
|
||||||
"find_meeting_time",
|
|
||||||
"get_meeting_stats",
|
|
||||||
"get_meeting_contacts",
|
|
||||||
"download_event_attachments",
|
|
||||||
"get_email_links",
|
|
||||||
"download_attachments",
|
|
||||||
"login",
|
|
||||||
},
|
|
||||||
"write-only": {
|
|
||||||
"send_email",
|
|
||||||
"reply_email",
|
|
||||||
"forward_email",
|
|
||||||
"delete_email",
|
|
||||||
"move_email",
|
|
||||||
"mark_email_read",
|
|
||||||
"create_meeting",
|
|
||||||
"update_meeting",
|
|
||||||
"cancel_meeting",
|
|
||||||
"respond_to_meeting",
|
|
||||||
"create_folder",
|
|
||||||
"rename_folder",
|
|
||||||
"empty_folder",
|
|
||||||
"delete_folder",
|
|
||||||
"move_folder",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Alias resolution ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_aliases(names: list[str]) -> set[str]:
|
|
||||||
resolved: set[str] = set()
|
|
||||||
for name in names:
|
|
||||||
if name in TOOL_GROUPS:
|
|
||||||
resolved |= TOOL_GROUPS[name]
|
|
||||||
else:
|
|
||||||
resolved.add(name)
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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
|
|
||||||
|
|
||||||
# ── Group integrity check ───────────────────────────────────────────────
|
|
||||||
all_grouped: set[str] = set().union(*TOOL_GROUPS.values())
|
|
||||||
missing = canonical - all_grouped
|
|
||||||
assert not missing, f"Tools not in any group: {sorted(missing)}"
|
|
||||||
overlaps: set[str] = set()
|
|
||||||
for i, (g1, s1) in enumerate(TOOL_GROUPS.items()):
|
|
||||||
for g2, s2 in list(TOOL_GROUPS.items())[i + 1 :]:
|
|
||||||
overlaps |= s1 & s2
|
|
||||||
assert not overlaps, f"Tools in multiple groups: {sorted(overlaps)}"
|
|
||||||
|
|
||||||
allowed: set[str] = set(canonical)
|
|
||||||
|
|
||||||
# ── Resolve aliases ────────────────────────────────────────────────────
|
|
||||||
if enabled:
|
|
||||||
enabled = sorted(_resolve_aliases(enabled))
|
|
||||||
if disabled:
|
|
||||||
disabled = sorted(_resolve_aliases(disabled))
|
|
||||||
|
|
||||||
# ── 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 +0,0 @@
|
|||||||
"""MCP tool modules for OWA."""
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
"""Analytics tools for the OWA MCP server.
|
|
||||||
|
|
||||||
Provides meeting statistics and connection matrix analysis
|
|
||||||
using GetUserAvailability and GetItem APIs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from collections import Counter
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
|
||||||
|
|
||||||
from owa_mcp.owa_client import OWAClient
|
|
||||||
from owa_mcp.server import AppContext, mcp
|
|
||||||
from owa_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,
|
|
||||||
)
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""Authentication tool for the OWA 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 owa_mcp.owa_client import OWAClient, SessionExpiredError
|
|
||||||
from owa_mcp.server import AppContext, mcp
|
|
||||||
from owa_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.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,638 +0,0 @@
|
|||||||
"""Availability / free-time tools for the OWA 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 owa_mcp.owa_client import OWAClient
|
|
||||||
from owa_mcp.server import AppContext, mcp
|
|
||||||
from owa_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
@@ -1,448 +0,0 @@
|
|||||||
"""Folder tools for the OWA MCP server.
|
|
||||||
|
|
||||||
Ports the get-folders.py logic into an MCP tool using OWAClient.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
|
||||||
|
|
||||||
from owa_mcp.owa_client import OWAClient
|
|
||||||
from owa_mcp.server import AppContext, mcp
|
|
||||||
from owa_mcp.server_lifecycle import require_client
|
|
||||||
|
|
||||||
_DISTINGUISHED_NAMES = {
|
|
||||||
"msgfolderroot",
|
|
||||||
"inbox",
|
|
||||||
"sentitems",
|
|
||||||
"drafts",
|
|
||||||
"deleteditems",
|
|
||||||
"junkemail",
|
|
||||||
"outbox",
|
|
||||||
"calendar",
|
|
||||||
"contacts",
|
|
||||||
"tasks",
|
|
||||||
"notes",
|
|
||||||
"journal",
|
|
||||||
"searchfolders",
|
|
||||||
}
|
|
||||||
|
|
||||||
_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)})
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""People / directory search tools for the OWA MCP server.
|
|
||||||
|
|
||||||
Ports the find-person.py logic into an MCP tool using OWAClient.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
|
||||||
|
|
||||||
from owa_mcp.owa_client import OWAClient
|
|
||||||
from owa_mcp.server import AppContext, mcp
|
|
||||||
from owa_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)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""Shared utility functions for the OWA 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 ""
|
|
||||||
+6
-14
@@ -1,11 +1,11 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "owa-mcp"
|
name = "exchange-mcp-server"
|
||||||
version = "0.1.0"
|
version = "1.0.1"
|
||||||
description = "MCP server for OWA — email, calendar, directory, availability"
|
description = "MCP server for Exchange / 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", "owa", "outlook", "exchange", "email", "calendar"]
|
keywords = ["mcp", "exchange", "owa", "outlook", "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]
|
||||||
owa-mcp = "owa_mcp.server:main"
|
exchange-mcp-server = "exchange_mcp.server:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68.0"]
|
requires = ["setuptools>=68.0"]
|
||||||
@@ -31,12 +31,4 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["owa_mcp*"]
|
include = ["exchange_mcp*"]
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 88
|
|
||||||
target-version = "py310"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "W", "I", "UP"]
|
|
||||||
ignore = ["E501"]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user