feat: publish lightweight Hermes Nextcloud calendar MCP
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 22:53:46 +03:00
commit 91823006e2
44 changed files with 7546 additions and 0 deletions

183
tests/test_stdio.py Normal file
View File

@@ -0,0 +1,183 @@
import asyncio
import copy
import json
import sys
from pathlib import Path
import pytest
from mcp import Client, StdioServerParameters
FIXTURE = Path(__file__).parent / "stdio_fixture_server.py"
async def test_real_sdk_stdio_discovers_only_five_typed_tools():
params = StdioServerParameters(command=sys.executable, args=[str(FIXTURE)])
async with Client(params, read_timeout_seconds=5) as client:
tools = (await client.list_tools()).tools
assert {t.name for t in tools} == {
"list_calendars",
"list_events",
"get_event",
"create_event",
"update_event",
}
assert client.server_capabilities.resources is None
assert client.server_capabilities.prompts is None
create = next(t for t in tools if t.name == "create_event")
assert create.input_schema["additionalProperties"] is False
assert create.input_schema["$defs"]["Fields"]["additionalProperties"] is False
assert create.annotations.read_only_hint is False
listed = await client.call_tool("list_calendars", {})
envelope = json.loads(listed.content[0].text)
assert envelope["data"][0]["calendar_id"] == "calendar-1"
events = await client.call_tool(
"list_events",
{
"calendar_id": "calendar-1",
"start": "2025-03-01T00:00:00Z",
"end": "2025-04-01T00:00:00Z",
},
)
envelope = json.loads(events.content[0].text)
identifier = envelope["data"][0]["event_id"]
assert "description" not in envelope["data"][0]
details = await client.call_tool(
"get_event", {"calendar_id": "calendar-1", "event_id": identifier}
)
envelope = json.loads(details.content[0].text)
assert envelope["data"]["revision"] == '"old"'
assert envelope["data"]["events"][0]["description"].startswith("Untrusted text")
created = await client.call_tool(
"create_event",
{
"calendar_id": "calendar-1",
"fields": {
"title": "Created",
"start": "2025-03-29",
"end": "2025-03-30",
"all_day": True,
},
},
)
assert json.loads(created.content[0].text)["data"]["created"]
edited = await client.call_tool(
"update_event",
{
"calendar_id": "calendar-1",
"event_id": identifier,
"expected_revision": '"old"',
"patch": {"title": "Edited"},
},
)
assert json.loads(edited.content[0].text)["data"]["updated"]
bad = await client.call_tool(
"create_event",
{
"calendar_id": "calendar-1",
"fields": {
"title": "Bad",
"start": "x",
"end": "x",
"password": "synthetic-password",
},
},
)
assert json.loads(bad.content[0].text)["error"]["code"] == "INVALID_INPUT"
assert "synthetic-password" not in bad.content[0].text
@pytest.mark.parametrize(
"timezone,all_day,start,end",
[
(
"Europe/Berlin",
False,
"2025-07-01T10:00:00+02:00",
"2025-07-01T10:15:00+02:00",
),
("UTC", False, "2025-07-01T08:00:00+00:00", "2025-07-01T08:15:00+00:00"),
(None, True, "2025-07-01", "2025-07-02"),
],
)
async def test_published_examples_create_then_edit_without_changing_times(
timezone, all_day, start, end
):
params = StdioServerParameters(command=sys.executable, args=[str(FIXTURE)])
async with Client(params, read_timeout_seconds=5) as client:
tools = {t.name: t for t in (await client.list_tools()).tools}
examples = tools["create_event"].input_schema.get("examples", [])
example = next(
(
e
for e in examples
if e["fields"].get("all_day", False) == all_day
and e["fields"].get("timezone") == timezone
),
None,
)
assert example is not None, "Discovery must supply usable creation examples"
args = copy.deepcopy(example)
args["calendar_id"] = "calendar-1"
created = await client.call_tool("create_event", args)
result = json.loads(created.content[0].text)
assert result["ok"], result
identifier = result["data"]["event_id"]
read_args = {"calendar_id": "calendar-1", "event_id": identifier}
read = await client.call_tool("get_event", read_args)
original = json.loads(read.content[0].text)["data"]
assert original["events"][0]["title"] == "Example event"
assert original["events"][0]["start"] == start
assert original["events"][0]["end"] == end
assert original["events"][0]["all_day"] is all_day
edits = tools["update_event"].input_schema.get("examples", [])
assert edits, "Discovery must supply a revision-based title-edit example"
args = copy.deepcopy(edits[0])
args.update(read_args, expected_revision=original["revision"])
updated = await client.call_tool("update_event", args)
result = json.loads(updated.content[0].text)
assert result["ok"], result
read = await client.call_tool("get_event", read_args)
edited = json.loads(read.content[0].text)["data"]["events"][0]
assert edited["title"] == "Updated example"
assert edited["start"] == start
assert edited["end"] == end
assert edited["all_day"] is all_day
async def test_legacy_hermes_handshake_and_clean_stdout():
process = await asyncio.create_subprocess_exec(
sys.executable,
str(FIXTURE),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
request = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "synthetic-hermes-client", "version": "1"},
},
}
process.stdin.write((json.dumps(request) + "\n").encode())
await process.stdin.drain()
response = json.loads(await asyncio.wait_for(process.stdout.readline(), 5))
assert response["result"]["protocolVersion"] == "2025-03-26"
assert response["result"]["capabilities"]["tools"] is not None
assert "resources" not in response["result"]["capabilities"]
process.stdin.write(b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
process.stdin.write(
b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n'
)
await process.stdin.drain()
listed = json.loads(await asyncio.wait_for(process.stdout.readline(), 5))
assert len(listed["result"]["tools"]) == 5
finally:
process.stdin.close()
await asyncio.wait_for(process.wait(), 5)
assert await process.stderr.read() == b""