Compare commits
3 Commits
main
..
4207b38b16
| Author | SHA1 | Date | |
|---|---|---|---|
| 4207b38b16 | |||
| 40dea9a2c2 | |||
| a9497625b1 |
+9
-2
@@ -2,13 +2,20 @@
|
|||||||
"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,
|
||||||
"ruff": {
|
"markdown": {
|
||||||
"lineLength": 80,
|
"deno": true
|
||||||
},
|
},
|
||||||
"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,7 +4,6 @@ __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,33 +19,26 @@ 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`
|
||||||
|
|||||||
@@ -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
@@ -95,7 +95,9 @@ 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) 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
|
# OWA-specific
|
||||||
"action": action,
|
"action": action,
|
||||||
@@ -126,9 +128,7 @@ class OWAClient:
|
|||||||
@property
|
@property
|
||||||
def _client_id(self) -> str:
|
def _client_id(self) -> str:
|
||||||
"""Extract ClientId from session cookies."""
|
"""Extract ClientId from session cookies."""
|
||||||
cid = next(
|
cid = next((c.value for c in self._session.cookies if c.name == "ClientId"), "")
|
||||||
(c.value for c in self._session.cookies if c.name == "ClientId"), ""
|
|
||||||
)
|
|
||||||
return cid if cid else "00000000000000000000000000000000"
|
return cid if cid else "00000000000000000000000000000000"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -217,7 +217,9 @@ 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) 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,
|
timeout=10,
|
||||||
@@ -236,9 +238,7 @@ class OWAClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # non-critical
|
pass # non-critical
|
||||||
|
|
||||||
def _do_request(
|
def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
|
||||||
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,12 +271,15 @@ class OWAClient:
|
|||||||
|
|
||||||
# Detect session expiry
|
# Detect session expiry
|
||||||
if resp.status_code in (401, 440):
|
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", ""):
|
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}). Snippet: {body_snippet}"
|
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
|
||||||
|
f"Snippet: {body_snippet}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
@@ -284,7 +287,8 @@ 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}). Session may have expired."
|
f"Unexpected response (HTTP {resp.status_code}). "
|
||||||
|
"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
|
||||||
@@ -320,7 +324,10 @@ class OWAClient:
|
|||||||
|
|
||||||
from urllib.parse import quote
|
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:
|
try:
|
||||||
resp = self._session.get(url, timeout=timeout)
|
resp = self._session.get(url, timeout=timeout)
|
||||||
@@ -460,9 +467,7 @@ class OWAClient:
|
|||||||
# ResolveNames (directory search / attendee resolution)
|
# ResolveNames (directory search / attendee resolution)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def resolve_names(
|
def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]:
|
||||||
self, query: str, *, full_contact: bool = True
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Call ResolveNames to search the directory.
|
"""Call ResolveNames to search the directory.
|
||||||
|
|
||||||
Returns the list of Resolution dicts from the API, each containing
|
Returns the list of Resolution dicts from the API, each containing
|
||||||
|
|||||||
+5
-24
@@ -19,7 +19,6 @@ 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
|
||||||
@@ -27,7 +26,6 @@ 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 (
|
||||||
@@ -76,21 +74,7 @@ _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(
|
mcp: FastMCP = FastMCP("owa", lifespan=app_lifespan)
|
||||||
"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:
|
||||||
@@ -118,7 +102,8 @@ 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. 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(
|
parser.add_argument(
|
||||||
@@ -135,12 +120,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 or group aliases (read-only, write-only) to expose",
|
help="Comma-separated whitelist of tool names to expose",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--disabled-tools",
|
"--disabled-tools",
|
||||||
default=None,
|
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)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
@@ -223,10 +208,6 @@ 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,9 +30,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# ── Client factory ───────────────────────────────────────────────────────
|
# ── Client factory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def create_owa_client(
|
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
|
||||||
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:
|
||||||
@@ -96,7 +94,8 @@ 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. Check --owa-url and --cookie-file arguments."
|
"OWA client is not initialised. "
|
||||||
|
"Check --owa-url and --cookie-file arguments."
|
||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@@ -213,59 +212,6 @@ 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 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -289,24 +235,8 @@ 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,9 +191,7 @@ 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(
|
return json.dumps({"error": "Could not resolve any names to email addresses."})
|
||||||
{"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)
|
||||||
@@ -349,9 +347,7 @@ 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 = (
|
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||||
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
|
||||||
)
|
|
||||||
if not folder_items:
|
if not folder_items:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -64,16 +64,10 @@ def _merge_busy_periods(all_busy: list) -> list[tuple]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_free_slots(
|
def _find_free_slots(
|
||||||
busy_periods: list,
|
busy_periods: list, date, start_hour: int, end_hour: int, duration_minutes: int
|
||||||
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(
|
day_start = datetime.combine(date, datetime.min.time().replace(hour=start_hour))
|
||||||
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
|
||||||
@@ -326,9 +320,7 @@ def _get_calendar_events(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if there are more items
|
# Check if there are more items
|
||||||
is_last = (
|
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||||
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
|
||||||
)
|
|
||||||
if is_last:
|
if is_last:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -385,7 +377,9 @@ 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:
|
||||||
@@ -588,7 +582,9 @@ 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_str.replace("Z", "+00:00"))
|
start = datetime.fromisoformat(
|
||||||
|
start_str.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||||
all_busy.append((start, end))
|
all_busy.append((start, end))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+19
-10
@@ -287,12 +287,21 @@ 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 = '<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:
|
if description:
|
||||||
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
|
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:
|
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>"
|
body += "</body></html>"
|
||||||
return body
|
return body
|
||||||
|
|
||||||
@@ -376,7 +385,9 @@ 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 = details.get("IsRecurring", False) if details else False
|
is_recurring = (
|
||||||
|
details.get("IsRecurring", False) if details else False
|
||||||
|
)
|
||||||
|
|
||||||
expanded.append(
|
expanded.append(
|
||||||
{
|
{
|
||||||
@@ -796,9 +807,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 = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
|
||||||
orig_end_str.replace("Z", "+00:00")
|
tzinfo=None
|
||||||
).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
|
||||||
@@ -866,9 +877,7 @@ 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 = (
|
new_body = description if description is not None else orig.get("body_html", "")
|
||||||
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,9 +553,7 @@ 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 = (
|
item_type = "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
|
||||||
"ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
|
|
||||||
)
|
|
||||||
|
|
||||||
reply_item = {
|
reply_item = {
|
||||||
"__type": item_type,
|
"__type": item_type,
|
||||||
@@ -1100,7 +1098,8 @@ 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. 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(
|
async def search_emails(
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ def check_session(ctx: Context = None) -> str:
|
|||||||
"__type": "FolderResponseShape:#Exchange",
|
"__type": "FolderResponseShape:#Exchange",
|
||||||
"BaseShape": "Default",
|
"BaseShape": "Default",
|
||||||
},
|
},
|
||||||
"FolderIds": [
|
"FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}],
|
||||||
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-7
@@ -18,14 +18,9 @@ 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>",
|
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE
|
||||||
"",
|
|
||||||
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"
|
name = "owa-mcp-server"
|
||||||
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 = "owa_mcp.server:main"
|
owa-mcp-server = "owa_mcp.server:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68.0"]
|
requires = ["setuptools>=68.0"]
|
||||||
|
|||||||
Reference in New Issue
Block a user