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"], "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"
}
} }
-1
View File
@@ -4,7 +4,6 @@ __pycache__/
.mcpregistry_* .mcpregistry_*
.playwright-mcp/ .playwright-mcp/
.salt .salt
.venv
*.egg-info/ *.egg-info/
*.pyc *.pyc
build build
+1 -8
View File
@@ -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`
-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", "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
View File
@@ -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()
+3 -73
View File
@@ -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()}
+2 -6
View File
@@ -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
+9 -13
View File
@@ -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
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: 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)
+3 -4
View File
@@ -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(
+1 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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"]