Compare commits
3 Commits
4207b38b16
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4698107d6a | |||
| 536309fa1a | |||
| fc976cd6ef |
+2
-9
@@ -2,20 +2,13 @@
|
|||||||
"excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"],
|
"excludes": ["**/.git", "**/.target", "**/vendor", "**/*.js"],
|
||||||
"includes": ["**/*.{md,json,py}"],
|
"includes": ["**/*.{md,json,py}"],
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"json": {
|
|
||||||
"deno": true
|
|
||||||
},
|
|
||||||
"lineWidth": 80,
|
"lineWidth": 80,
|
||||||
"markdown": {
|
"ruff": {
|
||||||
"deno": true
|
"lineLength": 80,
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"https://plugins.dprint.dev/json-0.22.0.wasm",
|
"https://plugins.dprint.dev/json-0.22.0.wasm",
|
||||||
"https://plugins.dprint.dev/markdown-0.22.1.wasm",
|
"https://plugins.dprint.dev/markdown-0.22.1.wasm",
|
||||||
"https://plugins.dprint.dev/ruff-0.7.20.wasm"
|
"https://plugins.dprint.dev/ruff-0.7.20.wasm"
|
||||||
],
|
],
|
||||||
"ruff": {
|
|
||||||
"lineLength": 88,
|
|
||||||
"targetVersion": "py310"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ __pycache__/
|
|||||||
.mcpregistry_*
|
.mcpregistry_*
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.salt
|
.salt
|
||||||
|
.venv
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.pyc
|
*.pyc
|
||||||
build
|
build
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ calendar, directory search, folder management, availability, and analytics.
|
|||||||
## 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 |
|
||||||
| `--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` |
|
||||||
@@ -19,26 +19,33 @@ calendar, directory search, folder management, availability, and analytics.
|
|||||||
## Tools (30)
|
## Tools (30)
|
||||||
|
|
||||||
### Email (10)
|
### Email (10)
|
||||||
|
|
||||||
`get_emails`, `get_email`, `send_email`, `reply_email`, `forward_email`,
|
`get_emails`, `get_email`, `send_email`, `reply_email`, `forward_email`,
|
||||||
`delete_email`, `move_email`, `mark_email_read`, `download_attachments`,
|
`delete_email`, `move_email`, `mark_email_read`, `download_attachments`,
|
||||||
`get_email_links`, `search_emails`
|
`get_email_links`, `search_emails`
|
||||||
|
|
||||||
### Calendar (7)
|
### Calendar (7)
|
||||||
|
|
||||||
`get_calendar_events`, `create_meeting`, `update_meeting`, `cancel_meeting`,
|
`get_calendar_events`, `create_meeting`, `update_meeting`, `cancel_meeting`,
|
||||||
`respond_to_meeting`, `download_event_attachments`, `get_event_links`
|
`respond_to_meeting`, `download_event_attachments`, `get_event_links`
|
||||||
|
|
||||||
### Directory (1)
|
### Directory (1)
|
||||||
|
|
||||||
`find_person`
|
`find_person`
|
||||||
|
|
||||||
### Folders (7)
|
### Folders (7)
|
||||||
|
|
||||||
`get_folders`, `create_folder`, `rename_folder`, `empty_folder`, `delete_folder`,
|
`get_folders`, `create_folder`, `rename_folder`, `empty_folder`, `delete_folder`,
|
||||||
`move_folder`, `check_session`
|
`move_folder`, `check_session`
|
||||||
|
|
||||||
### Availability (2)
|
### Availability (2)
|
||||||
|
|
||||||
`find_free_time`, `find_meeting_time`
|
`find_free_time`, `find_meeting_time`
|
||||||
|
|
||||||
### Analytics (2)
|
### Analytics (2)
|
||||||
|
|
||||||
`get_meeting_stats`, `get_meeting_contacts`
|
`get_meeting_stats`, `get_meeting_contacts`
|
||||||
|
|
||||||
### Auth (1)
|
### Auth (1)
|
||||||
|
|
||||||
`login`
|
`login`
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pip install --no-cache-dir -e ".[http]"
|
||||||
|
CMD ["owa-mcp"]
|
||||||
+15
-20
@@ -95,9 +95,7 @@ class OWAClient:
|
|||||||
"pragma": "no-cache",
|
"pragma": "no-cache",
|
||||||
"priority": "u=1, i",
|
"priority": "u=1, i",
|
||||||
"user-agent": (
|
"user-agent": (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/131.0.0.0 Safari/537.36"
|
|
||||||
),
|
),
|
||||||
# OWA-specific
|
# OWA-specific
|
||||||
"action": action,
|
"action": action,
|
||||||
@@ -128,7 +126,9 @@ class OWAClient:
|
|||||||
@property
|
@property
|
||||||
def _client_id(self) -> str:
|
def _client_id(self) -> str:
|
||||||
"""Extract ClientId from session cookies."""
|
"""Extract ClientId from session cookies."""
|
||||||
cid = next((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"
|
return cid if cid else "00000000000000000000000000000000"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -217,9 +217,7 @@ class OWAClient:
|
|||||||
f"{self.owa_url}/owa/",
|
f"{self.owa_url}/owa/",
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": (
|
"User-Agent": (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/131.0.0.0 Safari/537.36"
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
@@ -238,7 +236,9 @@ class OWAClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # non-critical
|
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.
|
"""Execute a single OWA API request.
|
||||||
|
|
||||||
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
|
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
|
||||||
@@ -271,15 +271,12 @@ class OWAClient:
|
|||||||
|
|
||||||
# Detect session expiry
|
# Detect session expiry
|
||||||
if resp.status_code in (401, 440):
|
if resp.status_code in (401, 440):
|
||||||
raise SessionExpiredError(
|
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
|
||||||
f"Session expired (HTTP {resp.status_code})."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "text/html" in resp.headers.get("Content-Type", ""):
|
if "text/html" in resp.headers.get("Content-Type", ""):
|
||||||
body_snippet = resp.text[:300] if resp.text else ""
|
body_snippet = resp.text[:300] if resp.text else ""
|
||||||
raise SessionExpiredError(
|
raise SessionExpiredError(
|
||||||
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
|
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). Snippet: {body_snippet}"
|
||||||
f"Snippet: {body_snippet}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
@@ -287,8 +284,7 @@ class OWAClient:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
||||||
raise SessionExpiredError(
|
raise SessionExpiredError(
|
||||||
f"Unexpected response (HTTP {resp.status_code}). "
|
f"Unexpected response (HTTP {resp.status_code}). Session may have expired."
|
||||||
"Session may have expired."
|
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Persist any Set-Cookie updates back to disk so the next request
|
# Persist any Set-Cookie updates back to disk so the next request
|
||||||
@@ -324,10 +320,7 @@ class OWAClient:
|
|||||||
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
url = (
|
url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
|
||||||
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
|
|
||||||
f"?id={quote(attachment_id)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self._session.get(url, timeout=timeout)
|
resp = self._session.get(url, timeout=timeout)
|
||||||
@@ -467,7 +460,9 @@ class OWAClient:
|
|||||||
# ResolveNames (directory search / attendee resolution)
|
# 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.
|
"""Call ResolveNames to search the directory.
|
||||||
|
|
||||||
Returns the list of Resolution dicts from the API, each containing
|
Returns the list of Resolution dicts from the API, each containing
|
||||||
|
|||||||
+24
-5
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -26,6 +27,7 @@ from dataclasses import dataclass
|
|||||||
from mcp.server.auth.provider import TokenVerifier
|
from mcp.server.auth.provider import TokenVerifier
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
|
||||||
from owa_mcp.owa_client import OWAClient
|
from owa_mcp.owa_client import OWAClient
|
||||||
from owa_mcp.server_lifecycle import (
|
from owa_mcp.server_lifecycle import (
|
||||||
@@ -74,7 +76,21 @@ _lifespan_cookie_file: str | None = None
|
|||||||
|
|
||||||
|
|
||||||
# ── Module-level MCP server (imported by all tool modules) ────────────────
|
# ── Module-level MCP server (imported by all tool modules) ────────────────
|
||||||
mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan)
|
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:
|
def _parse_args(argv=None) -> argparse.Namespace:
|
||||||
@@ -102,8 +118,7 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
|||||||
"--token-hash",
|
"--token-hash",
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"SHA-256 hash of the bearer token for HTTP transport auth. "
|
"SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum"
|
||||||
"Compute with: echo -n 'my-token' | sha256sum"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -120,12 +135,12 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--enabled-tools",
|
"--enabled-tools",
|
||||||
default=None,
|
default=None,
|
||||||
help="Comma-separated whitelist of tool names to expose",
|
help="Comma-separated whitelist of tool names or group aliases (read-only, write-only) to expose",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--disabled-tools",
|
"--disabled-tools",
|
||||||
default=None,
|
default=None,
|
||||||
help="Comma-separated blacklist of tool names to hide",
|
help="Comma-separated blacklist of tool names or group aliases (read-only, write-only) to hide",
|
||||||
)
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
@@ -208,6 +223,10 @@ def main(argv=None) -> None:
|
|||||||
mcp.settings.port = args.port
|
mcp.settings.port = args.port
|
||||||
|
|
||||||
# ── Run transport ─────────────────────────────────────────────────────
|
# ── 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:
|
match args.transport:
|
||||||
case "stdio":
|
case "stdio":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# ── Client factory ───────────────────────────────────────────────────────
|
# ── Client factory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
|
def create_owa_client(
|
||||||
|
owa_url: str, cookie_file: str | None = None
|
||||||
|
) -> OWAClient:
|
||||||
"""Create and return a fully configured :class:`OWAClient`.
|
"""Create and return a fully configured :class:`OWAClient`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -94,8 +96,7 @@ class _LazyClient:
|
|||||||
def resolved(self) -> OWAClient:
|
def resolved(self) -> OWAClient:
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"OWA client is not initialised. "
|
"OWA client is not initialised. Check --owa-url and --cookie-file arguments."
|
||||||
"Check --owa-url and --cookie-file arguments."
|
|
||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@@ -212,6 +213,59 @@ class SessionKeepalive:
|
|||||||
logger.debug("Keepalive ping failed (non-critical): %s", 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 ───────────────────────────────────────────────────────
|
# ── Tool filtering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -235,8 +289,24 @@ def apply_tool_filters(
|
|||||||
if not canonical:
|
if not canonical:
|
||||||
return
|
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)
|
allowed: set[str] = set(canonical)
|
||||||
|
|
||||||
|
# ── Resolve aliases ────────────────────────────────────────────────────
|
||||||
|
if enabled:
|
||||||
|
enabled = sorted(_resolve_aliases(enabled))
|
||||||
|
if disabled:
|
||||||
|
disabled = sorted(_resolve_aliases(disabled))
|
||||||
|
|
||||||
# ── Whitelist ──────────────────────────────────────────────────────────
|
# ── Whitelist ──────────────────────────────────────────────────────────
|
||||||
if enabled:
|
if enabled:
|
||||||
enabled_set = {n.strip() for n in enabled if n.strip()}
|
enabled_set = {n.strip() for n in enabled if n.strip()}
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ def get_meeting_stats(
|
|||||||
|
|
||||||
emails = [email for _, email in resolved if email]
|
emails = [email for _, email in resolved if email]
|
||||||
if not emails:
|
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
|
# Query availability
|
||||||
avail = _get_availability_events(client, emails, sd, ed)
|
avail = _get_availability_events(client, emails, sd, ed)
|
||||||
@@ -347,7 +349,9 @@ def get_meeting_contacts(
|
|||||||
if not items:
|
if not items:
|
||||||
break
|
break
|
||||||
folder_items = items[0].get("RootFolder", {}).get("Items", [])
|
folder_items = items[0].get("RootFolder", {}).get("Items", [])
|
||||||
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
is_last = (
|
||||||
|
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||||
|
)
|
||||||
if not folder_items:
|
if not folder_items:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -64,10 +64,16 @@ def _merge_busy_periods(all_busy: list) -> list[tuple]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_free_slots(
|
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]:
|
) -> list[tuple]:
|
||||||
"""Find free slots on a given date within working hours."""
|
"""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))
|
day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour))
|
||||||
|
|
||||||
# Filter busy periods to this day
|
# Filter busy periods to this day
|
||||||
@@ -320,7 +326,9 @@ def _get_calendar_events(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if there are more items
|
# Check if there are more items
|
||||||
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
is_last = (
|
||||||
|
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||||
|
)
|
||||||
if is_last:
|
if is_last:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -377,9 +385,7 @@ def find_free_time(
|
|||||||
folder_id = _get_calendar_folder_id(client)
|
folder_id = _get_calendar_folder_id(client)
|
||||||
if not folder_id:
|
if not folder_id:
|
||||||
return json.dumps(
|
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)
|
all_busy = _get_calendar_events(client, folder_id, sd, ed)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -582,9 +588,7 @@ def find_meeting_time(
|
|||||||
end_str = event.get("EndTime", "")
|
end_str = event.get("EndTime", "")
|
||||||
if start_str and end_str:
|
if start_str and end_str:
|
||||||
try:
|
try:
|
||||||
start = datetime.fromisoformat(
|
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
||||||
start_str.replace("Z", "+00:00")
|
|
||||||
)
|
|
||||||
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||||
all_busy.append((start, end))
|
all_busy.append((start, end))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+10
-19
@@ -287,21 +287,12 @@ def _resolve_attendee_list(client: OWAClient, emails: list[str]) -> list[dict]:
|
|||||||
|
|
||||||
def _build_html_body(description: str | None) -> str:
|
def _build_html_body(description: str | None) -> str:
|
||||||
"""Build the HTML body for a calendar item, matching create-meeting.py."""
|
"""Build the HTML body for a calendar item, matching create-meeting.py."""
|
||||||
body = (
|
body = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body dir="ltr">'
|
||||||
'<html><head><meta http-equiv="Content-Type" '
|
|
||||||
'content="text/html; charset=UTF-8"></head><body dir="ltr">'
|
|
||||||
)
|
|
||||||
if description:
|
if description:
|
||||||
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
|
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
|
||||||
body += (
|
body += f'<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
|
||||||
'<div style="font-size:12pt;color:#000000;'
|
|
||||||
f'font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
body += (
|
body += '<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
|
||||||
'<div style="font-size:12pt;color:#000000;'
|
|
||||||
'font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
|
|
||||||
)
|
|
||||||
body += "</body></html>"
|
body += "</body></html>"
|
||||||
return body
|
return body
|
||||||
|
|
||||||
@@ -385,9 +376,7 @@ def _get_expanded_events(
|
|||||||
subject = details.get("Subject", "") if details else ""
|
subject = details.get("Subject", "") if details else ""
|
||||||
location = details.get("Location", "") if details else ""
|
location = details.get("Location", "") if details else ""
|
||||||
is_meeting = details.get("IsMeeting", False) if details else False
|
is_meeting = details.get("IsMeeting", False) if details else False
|
||||||
is_recurring = (
|
is_recurring = details.get("IsRecurring", False) if details else False
|
||||||
details.get("IsRecurring", False) if details else False
|
|
||||||
)
|
|
||||||
|
|
||||||
expanded.append(
|
expanded.append(
|
||||||
{
|
{
|
||||||
@@ -807,9 +796,9 @@ def update_meeting(
|
|||||||
orig_start = datetime.fromisoformat(
|
orig_start = datetime.fromisoformat(
|
||||||
orig_start_str.replace("Z", "+00:00")
|
orig_start_str.replace("Z", "+00:00")
|
||||||
).replace(tzinfo=None)
|
).replace(tzinfo=None)
|
||||||
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
|
orig_end = datetime.fromisoformat(
|
||||||
tzinfo=None
|
orig_end_str.replace("Z", "+00:00")
|
||||||
)
|
).replace(tzinfo=None)
|
||||||
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
|
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
orig_start = None
|
orig_start = None
|
||||||
@@ -877,7 +866,9 @@ def update_meeting(
|
|||||||
return json.dumps({"error": f"Failed to cancel original meeting: {e}"})
|
return json.dumps({"error": f"Failed to cancel original meeting: {e}"})
|
||||||
|
|
||||||
# Step 4: Create the new meeting
|
# 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:
|
if description is not None:
|
||||||
new_body = _build_html_body(description)
|
new_body = _build_html_body(description)
|
||||||
|
|
||||||
|
|||||||
@@ -553,7 +553,9 @@ def reply_email(
|
|||||||
if not change_key:
|
if not change_key:
|
||||||
return json.dumps({"error": "Could not resolve item ChangeKey."})
|
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 = {
|
reply_item = {
|
||||||
"__type": item_type,
|
"__type": item_type,
|
||||||
@@ -1098,8 +1100,7 @@ def _build_search_restriction(query: str, scope: str) -> dict | None:
|
|||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="search_emails",
|
name="search_emails",
|
||||||
description=(
|
description=(
|
||||||
"Search emails by text across one or all folders. "
|
"Search emails by text across one or all folders. Supports searching by subject, body, sender, or all fields combined."
|
||||||
"Supports searching by subject, body, sender, or all fields combined."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def search_emails(
|
async def search_emails(
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ def check_session(ctx: Context = None) -> str:
|
|||||||
"__type": "FolderResponseShape:#Exchange",
|
"__type": "FolderResponseShape:#Exchange",
|
||||||
"BaseShape": "Default",
|
"BaseShape": "Default",
|
||||||
},
|
},
|
||||||
"FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}],
|
"FolderIds": [
|
||||||
|
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -18,9 +18,14 @@ def html_to_text(html_content: str) -> str:
|
|||||||
if not html_content:
|
if not html_content:
|
||||||
return ""
|
return ""
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE
|
r"<script[^>]*>.*?</script>",
|
||||||
|
"",
|
||||||
|
html_content,
|
||||||
|
flags=re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = re.sub(
|
||||||
|
r"<style[^>]*>.*?</style>", "", text, 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"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||||
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
|
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
|
||||||
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "owa-mcp-server"
|
name = "owa-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "MCP server for OWA — email, calendar, directory, availability"
|
description = "MCP server for OWA — email, calendar, directory, availability"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -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-server = "owa_mcp.server:main"
|
owa-mcp = "owa_mcp.server:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68.0"]
|
requires = ["setuptools>=68.0"]
|
||||||
|
|||||||
Reference in New Issue
Block a user