feat: publish Hermes Nextcloud calendar MCP under AGPL-3.0
Some checks failed
Offline checks / test (3.11) (push) Has been cancelled
Offline checks / test (3.13) (push) Has been cancelled

This commit is contained in:
xeno
2026-09-30 20:27:25 +00:00
commit 93b9c9f913
44 changed files with 8193 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
"""Constrain every authenticated request before constructing its headers."""
import base64
import re
import ssl
from urllib.parse import quote, unquote, urlsplit
import httpx
from .types import MAX_DAV_BYTES, ErrorCode, HTTPResult, SafeError
def path(value: str) -> str:
if (
not isinstance(value, str)
or not value.startswith("/")
or value.startswith("//")
):
raise SafeError(ErrorCode.INVALID_INPUT)
if re.search(r"[\x00-\x1f\x7f\\?#]", value) or re.search(
r"%(?![0-9a-fA-F]{2})", value
):
raise SafeError(ErrorCode.INVALID_INPUT)
segments = value.split("/")[1:]
normalized = []
for i, segment in enumerate(segments):
try:
decoded = unquote(segment, encoding="utf-8", errors="strict")
except UnicodeError:
raise SafeError(ErrorCode.INVALID_INPUT) from None
if decoded in (".", "..") or re.search(r"[%/\\\x00-\x1f\x7f]", decoded):
raise SafeError(ErrorCode.INVALID_INPUT)
if not decoded and i != len(segments) - 1:
raise SafeError(ErrorCode.INVALID_INPUT)
normalized.append(quote(decoded, safe="-._~"))
return "/" + "/".join(normalized)
def strong_etag(value: str | None) -> bool:
return (
isinstance(value, str)
and len(value) <= 256
and bool(re.fullmatch(r'"[\x21\x23-\x7e]*"', value))
)
def event_id(calendar, href: str) -> str:
resource = path(href)
root = path(calendar.href)
if not root.endswith("/") or not resource.startswith(root):
raise SafeError(ErrorCode.INVALID_INPUT)
filename = resource[len(root) :]
if not filename or "/" in filename:
raise SafeError(ErrorCode.INVALID_INPUT)
return base64.urlsafe_b64encode(filename.encode()).decode().rstrip("=")
def event_href(calendar, identifier: str) -> str:
if not isinstance(identifier, str) or not re.fullmatch(
r"[A-Za-z0-9_-]{1,2048}", identifier
):
raise SafeError(ErrorCode.INVALID_INPUT)
try:
filename = base64.b64decode(
identifier + "=" * (-len(identifier) % 4), altchars=b"-_", validate=True
).decode()
href = path(calendar.href) + filename
if event_id(calendar, href) != identifier:
raise ValueError("noncanonical")
return href
except (ValueError, UnicodeError):
raise SafeError(ErrorCode.INVALID_INPUT) from None
def endpoint(value: str):
if not isinstance(value, str) or re.search(r"[\x00-\x20\x7f\\]", value):
raise SafeError(ErrorCode.INVALID_INPUT)
try:
parts = urlsplit(value)
if (
parts.scheme != "https"
or not parts.hostname
or parts.username is not None
or parts.password is not None
or parts.query
or parts.fragment
or "?" in value
or "#" in value
):
raise ValueError("url")
host = parts.hostname.encode("idna").decode().lower()
if "%" in host:
raise ValueError("host")
port = parts.port or 443
if not 1 <= port <= 65535:
raise ValueError("port")
host_url = f"[{host}]" if ":" in host else host
origin = f"https://{host_url}" + (f":{port}" if port != 443 else "")
return origin, path(parts.path or "/"), (host, port)
except (ValueError, UnicodeError):
raise SafeError(ErrorCode.INVALID_INPUT) from None
class CalDAVClient:
def __init__(self, profile, *, transport=None, discovery=False):
self.write_started = False
self.profile = profile
self.discovery = discovery
self.origin, base, self.authority = endpoint(profile.base_url)
self.home = path(profile.calendar_home)
if not self.home.startswith(
base.rstrip("/") + "/remote.php/dav/calendars/"
) or not self.home.endswith("/"):
raise SafeError(ErrorCode.INVALID_INPUT)
self.roots = tuple(path(c.href) for c in profile.calendars)
if any(
not root.startswith(self.home)
or not root.endswith("/")
or "/" in root[len(self.home) : -1]
or root == self.home
for root in self.roots
):
raise SafeError(ErrorCode.INVALID_INPUT)
if (
not profile.username
or ":" in profile.username
or any(ord(c) < 32 for c in profile.username)
):
raise SafeError(ErrorCode.INVALID_INPUT)
try:
self.ssl_context = ssl.create_default_context(cafile=profile.ca_bundle)
except (OSError, ssl.SSLError):
raise SafeError(ErrorCode.NETWORK_ERROR) from None
self.http = httpx.AsyncClient(
verify=self.ssl_context,
trust_env=False,
follow_redirects=False,
transport=transport,
timeout=httpx.Timeout(20, connect=10),
limits=httpx.Limits(max_connections=2, max_keepalive_connections=0),
)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
await self.http.aclose()
def validated_href(self, href: str, *, discovered=False) -> str:
if href.startswith("https://"):
origin, resource, authority = endpoint(href)
if authority != self.authority or origin != self.origin:
raise SafeError(ErrorCode.INVALID_INPUT)
else:
resource = path(href)
if discovered:
if resource == self.home:
return resource
if (
resource.startswith(self.home)
and resource.endswith("/")
and "/" not in resource[len(self.home) : -1]
and resource != self.home
):
return resource
for root in self.roots:
if resource == root or (
resource.startswith(root)
and resource[len(root) :]
and "/" not in resource[len(root) :]
):
return resource
raise SafeError(ErrorCode.INVALID_INPUT)
async def request(
self,
method: str,
href: str,
*,
body=None,
expected_revision=None,
create=False,
max_bytes=MAX_DAV_BYTES,
) -> HTTPResult:
if (
method not in {"PROPFIND", "REPORT", "GET", "PUT"}
or not 1 <= max_bytes <= MAX_DAV_BYTES
):
raise SafeError(ErrorCode.INVALID_INPUT)
resource = self.validated_href(href, discovered=self.discovery)
if self.discovery:
if method != "PROPFIND" or resource != self.home:
raise SafeError(ErrorCode.INVALID_INPUT)
elif (method in {"REPORT", "PROPFIND"}) != resource.endswith("/"):
raise SafeError(ErrorCode.INVALID_INPUT)
if method != "PUT" and (create or expected_revision is not None):
raise SafeError(ErrorCode.INVALID_INPUT)
headers = {"Accept-Encoding": "identity"}
if method == "PROPFIND":
headers.update(
{
"Depth": "1" if self.discovery else "0",
"Content-Type": "application/xml; charset=utf-8",
}
)
elif method == "REPORT":
headers.update(
{"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}
)
elif method == "PUT":
if create == (expected_revision is not None) or (
expected_revision is not None and not strong_etag(expected_revision)
):
raise SafeError(ErrorCode.INVALID_INPUT)
headers["Content-Type"] = "text/calendar; charset=utf-8"
headers["If-None-Match" if create else "If-Match"] = (
"*" if create else expected_revision
)
try:
if method == "PUT":
self.write_started = True
async with self.http.stream(
method,
self.origin + resource,
headers=headers,
content=body,
auth=httpx.BasicAuth(self.profile.username, self.profile.password),
) as response:
status = response.status_code
failures = {
401: ErrorCode.AUTH_FAILED,
403: ErrorCode.PERMISSION_DENIED,
404: ErrorCode.NOT_FOUND,
412: ErrorCode.CONFLICT,
}
if status in failures:
raise SafeError(failures[status])
allowed = {
"PROPFIND": {207},
"REPORT": {207},
"GET": {200},
"PUT": {200, 201, 204},
}
if status not in allowed[method]:
if method == "PUT" and status >= 500:
raise SafeError(ErrorCode.UNKNOWN_WRITE_OUTCOME)
raise SafeError(ErrorCode.BAD_RESPONSE)
etag = response.headers.get("ETag")
if method == "PUT":
# Status and ETag suffice. Consuming an unused response body
# could turn an acknowledged write into a false failure.
return HTTPResult(status, b"", etag if strong_etag(etag) else None)
# Reject compressed responses to bound decompression memory as well as bytes.
if (
response.headers.get("Content-Encoding", "identity").lower()
!= "identity"
):
raise SafeError(ErrorCode.BAD_RESPONSE)
length = response.headers.get("Content-Length")
if length is not None:
try:
if int(length) < 0:
raise ValueError("length")
if int(length) > max_bytes:
raise SafeError(ErrorCode.LIMIT_EXCEEDED)
except ValueError:
raise SafeError(ErrorCode.BAD_RESPONSE) from None
data = bytearray()
async for chunk in response.aiter_bytes():
if len(data) + len(chunk) > max_bytes:
raise SafeError(ErrorCode.LIMIT_EXCEEDED)
data.extend(chunk)
etag = response.headers.get("ETag")
return HTTPResult(
status, bytes(data), etag if strong_etag(etag) else None
)
except httpx.HTTPError:
raise SafeError(
ErrorCode.UNKNOWN_WRITE_OUTCOME
if method == "PUT"
else ErrorCode.NETWORK_ERROR
) from None