Compare commits
3 Commits
main
..
4207b38b16
| Author | SHA1 | Date | |
|---|---|---|---|
| 4207b38b16 | |||
| 40dea9a2c2 | |||
| a9497625b1 |
+9
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ __pycache__/
|
||||
.mcpregistry_*
|
||||
.playwright-mcp/
|
||||
.salt
|
||||
.venv
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
build
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
"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
@@ -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()
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user