Compare commits

..

3 Commits

Author SHA1 Message Date
albnnc 4207b38b16 w 2026-07-06 02:05:16 +03:00
albnnc 40dea9a2c2 w 2026-07-06 01:58:46 +03:00
albnnc a9497625b1 w 2026-07-06 01:46:14 +03:00
17 changed files with 4424 additions and 4521 deletions
+9 -2
View File
@@ -2,13 +2,20 @@
"excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"],
"includes": ["**/*.{md,json,py}"],
"indentWidth": 2,
"json": {
"deno": true
},
"lineWidth": 80,
"ruff": {
"lineLength": 80,
"markdown": {
"deno": true
},
"plugins": [
"https://plugins.dprint.dev/json-0.22.0.wasm",
"https://plugins.dprint.dev/markdown-0.22.1.wasm",
"https://plugins.dprint.dev/ruff-0.7.20.wasm"
],
"ruff": {
"lineLength": 88,
"targetVersion": "py310"
}
}
-1
View File
@@ -4,7 +4,6 @@ __pycache__/
.mcpregistry_*
.playwright-mcp/
.salt
.venv
*.egg-info/
*.pyc
build
+1 -8
View File
@@ -6,7 +6,7 @@ calendar, directory search, folder management, availability, and analytics.
## CLI Arguments
| Argument | Default | Required | Description |
| ------------------ | --------------------- | --------- | --------------------------------------- |
|---|---|---|---|
| `--owa-url` | — | Yes | Base URL of the OWA instance |
| `--cookie-file` | `session-cookies.txt` | No | Path to session cookies file |
| `--transport` | `stdio` | — | `stdio`, `sse`, or `streamable-http` |
@@ -19,33 +19,26 @@ calendar, directory search, folder management, availability, and analytics.
## Tools (30)
### Email (10)
`get_emails`, `get_email`, `send_email`, `reply_email`, `forward_email`,
`delete_email`, `move_email`, `mark_email_read`, `download_attachments`,
`get_email_links`, `search_emails`
### Calendar (7)
`get_calendar_events`, `create_meeting`, `update_meeting`, `cancel_meeting`,
`respond_to_meeting`, `download_event_attachments`, `get_event_links`
### Directory (1)
`find_person`
### Folders (7)
`get_folders`, `create_folder`, `rename_folder`, `empty_folder`, `delete_folder`,
`move_folder`, `check_session`
### Availability (2)
`find_free_time`, `find_meeting_time`
### Analytics (2)
`get_meeting_stats`, `get_meeting_contacts`
### Auth (1)
`login`
-5
View File
@@ -1,5 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -e ".[http]"
CMD ["owa-mcp"]
+20 -15
View File
@@ -95,7 +95,9 @@ class OWAClient:
"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"
"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,
@@ -126,9 +128,7 @@ class OWAClient:
@property
def _client_id(self) -> str:
"""Extract ClientId from session cookies."""
cid = next(
(c.value for c in self._session.cookies if c.name == "ClientId"), ""
)
cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "")
return cid if cid else "00000000000000000000000000000000"
# ------------------------------------------------------------------
@@ -217,7 +217,9 @@ class OWAClient:
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"
"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,
@@ -236,9 +238,7 @@ class OWAClient:
except Exception:
pass # non-critical
def _do_request(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
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
@@ -271,12 +271,15 @@ class OWAClient:
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
raise SessionExpiredError(
f"Session expired (HTTP {resp.status_code})."
)
if "text/html" in resp.headers.get("Content-Type", ""):
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}"
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
f"Snippet: {body_snippet}"
)
# Parse JSON
@@ -284,7 +287,8 @@ class OWAClient:
data = resp.json()
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
raise SessionExpiredError(
f"Unexpected response (HTTP {resp.status_code}). Session may have expired."
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
@@ -320,7 +324,10 @@ class OWAClient:
from urllib.parse import quote
url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
url = (
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
f"?id={quote(attachment_id)}"
)
try:
resp = self._session.get(url, timeout=timeout)
@@ -460,9 +467,7 @@ class OWAClient:
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
def resolve_names(
self, query: str, *, full_contact: bool = True
) -> list[dict]:
def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]:
"""Call ResolveNames to search the directory.
Returns the list of Resolution dicts from the API, each containing
+5 -24
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import argparse
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -27,7 +26,6 @@ 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 (
@@ -76,21 +74,7 @@ _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,
)
mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan)
def _parse_args(argv=None) -> argparse.Namespace:
@@ -118,7 +102,8 @@ def _parse_args(argv=None) -> argparse.Namespace:
"--token-hash",
default=None,
help=(
"SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum"
"SHA-256 hash of the bearer token for HTTP transport auth. "
"Compute with: echo -n 'my-token' | sha256sum"
),
)
parser.add_argument(
@@ -135,12 +120,12 @@ def _parse_args(argv=None) -> argparse.Namespace:
parser.add_argument(
"--enabled-tools",
default=None,
help="Comma-separated whitelist of tool names or group aliases (read-only, write-only) to expose",
help="Comma-separated whitelist of tool names 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",
help="Comma-separated blacklist of tool names to hide",
)
return parser.parse_args(argv)
@@ -223,10 +208,6 @@ def main(argv=None) -> None:
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()
+3 -73
View File
@@ -30,9 +30,7 @@ logger = logging.getLogger(__name__)
# ── Client factory ───────────────────────────────────────────────────────
def create_owa_client(
owa_url: str, cookie_file: str | None = None
) -> OWAClient:
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
"""Create and return a fully configured :class:`OWAClient`.
Args:
@@ -96,7 +94,8 @@ class _LazyClient:
def resolved(self) -> OWAClient:
if self._client is None:
raise RuntimeError(
"OWA client is not initialised. Check --owa-url and --cookie-file arguments."
"OWA client is not initialised. "
"Check --owa-url and --cookie-file arguments."
)
return self._client
@@ -213,59 +212,6 @@ class SessionKeepalive:
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 ───────────────────────────────────────────────────────
@@ -289,24 +235,8 @@ def apply_tool_filters(
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()}
+2 -6
View File
@@ -191,9 +191,7 @@ def get_meeting_stats(
emails = [email for _, email in resolved if email]
if not emails:
return json.dumps(
{"error": "Could not resolve any names to email addresses."}
)
return json.dumps({"error": "Could not resolve any names to email addresses."})
# Query availability
avail = _get_availability_events(client, emails, sd, ed)
@@ -349,9 +347,7 @@ def get_meeting_contacts(
if not items:
break
folder_items = items[0].get("RootFolder", {}).get("Items", [])
is_last = (
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
)
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if not folder_items:
break
+9 -13
View File
@@ -64,16 +64,10 @@ def _merge_busy_periods(all_busy: list) -> list[tuple]:
def _find_free_slots(
busy_periods: list,
date,
start_hour: int,
end_hour: int,
duration_minutes: int,
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_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
@@ -326,9 +320,7 @@ def _get_calendar_events(
continue
# Check if there are more items
is_last = (
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
)
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
if is_last:
break
@@ -385,7 +377,9 @@ def find_free_time(
folder_id = _get_calendar_folder_id(client)
if not folder_id:
return json.dumps(
{"error": "Could not find calendar folder. Session may have expired."}
{
"error": "Could not find calendar folder. Session may have expired."
}
)
all_busy = _get_calendar_events(client, folder_id, sd, ed)
except Exception as e:
@@ -588,7 +582,9 @@ def find_meeting_time(
end_str = event.get("EndTime", "")
if start_str and end_str:
try:
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
start = datetime.fromisoformat(
start_str.replace("Z", "+00:00")
)
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
all_busy.append((start, end))
except Exception:
+19 -10
View File
@@ -287,12 +287,21 @@ def _resolve_attendee_list(client: OWAClient, emails: list[str]) -> list[dict]:
def _build_html_body(description: str | None) -> str:
"""Build the HTML body for a calendar item, matching create-meeting.py."""
body = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body dir="ltr">'
body = (
'<html><head><meta http-equiv="Content-Type" '
'content="text/html; charset=UTF-8"></head><body dir="ltr">'
)
if description:
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
body += f'<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
body += (
'<div style="font-size:12pt;color:#000000;'
f'font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
)
else:
body += '<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
body += (
'<div style="font-size:12pt;color:#000000;'
'font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
)
body += "</body></html>"
return body
@@ -376,7 +385,9 @@ def _get_expanded_events(
subject = details.get("Subject", "") if details else ""
location = details.get("Location", "") if details else ""
is_meeting = details.get("IsMeeting", False) if details else False
is_recurring = details.get("IsRecurring", False) if details else False
is_recurring = (
details.get("IsRecurring", False) if details else False
)
expanded.append(
{
@@ -796,9 +807,9 @@ def update_meeting(
orig_start = datetime.fromisoformat(
orig_start_str.replace("Z", "+00:00")
).replace(tzinfo=None)
orig_end = datetime.fromisoformat(
orig_end_str.replace("Z", "+00:00")
).replace(tzinfo=None)
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
except (ValueError, AttributeError):
orig_start = None
@@ -866,9 +877,7 @@ def update_meeting(
return json.dumps({"error": f"Failed to cancel original meeting: {e}"})
# Step 4: Create the new meeting
new_body = (
description if description is not None else orig.get("body_html", "")
)
new_body = description if description is not None else orig.get("body_html", "")
if description is not None:
new_body = _build_html_body(description)
+3 -4
View File
@@ -553,9 +553,7 @@ def reply_email(
if not change_key:
return json.dumps({"error": "Could not resolve item ChangeKey."})
item_type = (
"ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
)
item_type = "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
reply_item = {
"__type": item_type,
@@ -1100,7 +1098,8 @@ def _build_search_restriction(query: str, scope: str) -> dict | None:
@mcp.tool(
name="search_emails",
description=(
"Search emails by text across one or all folders. Supports searching by subject, body, sender, or all fields combined."
"Search emails by text across one or all folders. "
"Supports searching by subject, body, sender, or all fields combined."
),
)
async def search_emails(
+1 -3
View File
@@ -77,9 +77,7 @@ def check_session(ctx: Context = None) -> str:
"__type": "FolderResponseShape:#Exchange",
"BaseShape": "Default",
},
"FolderIds": [
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
],
"FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}],
},
}
+2 -7
View File
@@ -18,14 +18,9 @@ def html_to_text(html_content: str) -> str:
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
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)
+2 -2
View File
@@ -1,5 +1,5 @@
[project]
name = "owa-mcp"
name = "owa-mcp-server"
version = "0.1.0"
description = "MCP server for OWA — email, calendar, directory, availability"
readme = "README.md"
@@ -23,7 +23,7 @@ Repository = "https://git.kosyrev.name/zoo/owa-mcp"
http = ["uvicorn>=0.30.0"]
[project.scripts]
owa-mcp = "owa_mcp.server:main"
owa-mcp-server = "owa_mcp.server:main"
[build-system]
requires = ["setuptools>=68.0"]