webcal-mcp 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Copyright (c) Mark Nottingham
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -0,0 +1,8 @@
1
+ global-include py.typed
2
+ prune build
3
+ prune dist
4
+ prune test
5
+ prune tools
6
+ prune src
7
+ prune *.egg-info
8
+ exclude .gitignore .editorconfig .dockerignore .coveragerc
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: webcal-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server exposing a remote iCalendar (webcal) feed as queryable read-only tools
5
+ Author-email: Mark Nottingham <mnot@mnot.net>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://github.com/mnot/webcal-mcp
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Development Status :: 4 - Beta
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.md
13
+ Requires-Dist: mcp>=1.0
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: icalendar>=5.0
16
+ Requires-Dist: recurring-ical-events>=2.2
17
+ Requires-Dist: python-dateutil>=2.8
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy; extra == "dev"
20
+ Requires-Dist: black; extra == "dev"
21
+ Requires-Dist: isort; extra == "dev"
22
+ Requires-Dist: pylint; extra == "dev"
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Requires-Dist: pytest-asyncio; extra == "dev"
25
+ Requires-Dist: pytest-md; extra == "dev"
26
+ Requires-Dist: validate-pyproject; extra == "dev"
27
+ Requires-Dist: build; extra == "dev"
28
+ Requires-Dist: types-python-dateutil; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # webcal-mcp
32
+
33
+ A small read-only [Model Context Protocol](https://modelcontextprotocol.io/) server
34
+ that exposes one or more remote iCalendar feeds (the `webcal://` or `.ics` kind)
35
+ as queryable tools for an LLM agent.
36
+
37
+ ## Install
38
+
39
+ ```
40
+ pipx install webcal-mcp
41
+ ```
42
+
43
+ Or from a checkout:
44
+
45
+ ```
46
+ pipx install .
47
+ ```
48
+
49
+ This puts a `webcal-mcp` script on your `PATH`. Python 3.10 or later is required.
50
+
51
+ ## Configure
52
+
53
+ Create `~/.config/webcal-mcp/config.toml` (or point `WEBCAL_MCP_CONFIG` at any
54
+ other path). See `config.example.toml`:
55
+
56
+ ```toml
57
+ default_ttl_seconds = 900
58
+
59
+ [calendars.personal]
60
+ url = "webcal://example.com/personal.ics"
61
+ description = "Personal calendar"
62
+
63
+ [calendars.work]
64
+ url = "https://example.com/work.ics"
65
+ description = "Work calendar"
66
+ ```
67
+
68
+ `webcal://` and `webcals://` URLs are normalized to `https://`.
69
+
70
+ ## Run
71
+
72
+ ```
73
+ webcal-mcp
74
+ ```
75
+
76
+ The server speaks MCP over stdio. Wire it into an MCP-aware client (Claude
77
+ Desktop, Claude Code, etc.) by pointing at the `webcal-mcp` script. For
78
+ example, for Claude Code:
79
+
80
+ ```
81
+ claude mcp add webcal -- webcal-mcp
82
+ ```
83
+
84
+ ## Tools
85
+
86
+ | Tool | Purpose |
87
+ | --- | --- |
88
+ | `list_calendars` | Names, descriptions, capability flags for the configured calendars. |
89
+ | `list_events` | Events in a date range, with optional `query`, `categories`, `location` filters and `brief` / `full` / `markdown` detail modes. Either bound may be omitted for open-ended windows. |
90
+ | `events_on` | All events occurring on a given date. |
91
+ | `get_event` | Full record for a single UID. |
92
+
93
+ Recurring events are expanded within the requested window. Responses are
94
+ cached in memory with a per-calendar TTL; revalidation uses HTTP `ETag` and
95
+ `Last-Modified`.
96
+
97
+ ## Read-only
98
+
99
+ Calendars are read-only today. The `CalendarSource` abstraction reserves a
100
+ `writable` capability flag so future write-capable backends can be added
101
+ without changing the tool surface.
@@ -0,0 +1,71 @@
1
+ # webcal-mcp
2
+
3
+ A small read-only [Model Context Protocol](https://modelcontextprotocol.io/) server
4
+ that exposes one or more remote iCalendar feeds (the `webcal://` or `.ics` kind)
5
+ as queryable tools for an LLM agent.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pipx install webcal-mcp
11
+ ```
12
+
13
+ Or from a checkout:
14
+
15
+ ```
16
+ pipx install .
17
+ ```
18
+
19
+ This puts a `webcal-mcp` script on your `PATH`. Python 3.10 or later is required.
20
+
21
+ ## Configure
22
+
23
+ Create `~/.config/webcal-mcp/config.toml` (or point `WEBCAL_MCP_CONFIG` at any
24
+ other path). See `config.example.toml`:
25
+
26
+ ```toml
27
+ default_ttl_seconds = 900
28
+
29
+ [calendars.personal]
30
+ url = "webcal://example.com/personal.ics"
31
+ description = "Personal calendar"
32
+
33
+ [calendars.work]
34
+ url = "https://example.com/work.ics"
35
+ description = "Work calendar"
36
+ ```
37
+
38
+ `webcal://` and `webcals://` URLs are normalized to `https://`.
39
+
40
+ ## Run
41
+
42
+ ```
43
+ webcal-mcp
44
+ ```
45
+
46
+ The server speaks MCP over stdio. Wire it into an MCP-aware client (Claude
47
+ Desktop, Claude Code, etc.) by pointing at the `webcal-mcp` script. For
48
+ example, for Claude Code:
49
+
50
+ ```
51
+ claude mcp add webcal -- webcal-mcp
52
+ ```
53
+
54
+ ## Tools
55
+
56
+ | Tool | Purpose |
57
+ | --- | --- |
58
+ | `list_calendars` | Names, descriptions, capability flags for the configured calendars. |
59
+ | `list_events` | Events in a date range, with optional `query`, `categories`, `location` filters and `brief` / `full` / `markdown` detail modes. Either bound may be omitted for open-ended windows. |
60
+ | `events_on` | All events occurring on a given date. |
61
+ | `get_event` | Full record for a single UID. |
62
+
63
+ Recurring events are expanded within the requested window. Responses are
64
+ cached in memory with a per-calendar TTL; revalidation uses HTTP `ETag` and
65
+ `Last-Modified`.
66
+
67
+ ## Read-only
68
+
69
+ Calendars are read-only today. The `CalendarSource` abstraction reserves a
70
+ `writable` capability flag so future write-capable backends can be added
71
+ without changing the tool surface.
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "webcal-mcp"
3
+ dynamic = ["version"]
4
+ authors = [
5
+ {name="Mark Nottingham", email="mnot@mnot.net"}
6
+ ]
7
+ description = "MCP server exposing a remote iCalendar (webcal) feed as queryable read-only tools"
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ license-files = ["LICENSE.md"]
12
+ classifiers = [
13
+ "Operating System :: OS Independent",
14
+ "Development Status :: 4 - Beta"
15
+ ]
16
+ dependencies = [
17
+ "mcp>=1.0",
18
+ "httpx>=0.27",
19
+ "icalendar>=5.0",
20
+ "recurring-ical-events>=2.2",
21
+ "python-dateutil>=2.8",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["mypy", "black", "isort", "pylint", "pytest", "pytest-asyncio", "pytest-md", "validate-pyproject", "build", "types-python-dateutil"]
26
+
27
+ [project.urls]
28
+ homepage = "https://github.com/mnot/webcal-mcp"
29
+
30
+ [project.scripts]
31
+ webcal-mcp = "webcal_mcp.server:main"
32
+
33
+
34
+ [build-system]
35
+ requires = [
36
+ "setuptools>=82.0.1",
37
+ "wheel"
38
+ ]
39
+ build-backend = "setuptools.build_meta"
40
+
41
+ [tool.setuptools.dynamic]
42
+ version = {attr = "webcal_mcp.__version__"}
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["."]
46
+ include = ["webcal_mcp", "webcal_mcp.*"]
47
+ exclude = ["build*", "dist*"]
48
+ namespaces = false
49
+
50
+ [tool.setuptools]
51
+ include-package-data = true
52
+
53
+ [tool.setuptools.package-data]
54
+ webcal_mcp = ["py.typed"]
55
+
56
+ # Most tool configuration lives in dedicated files (managed by the template):
57
+ # mypy → mypy.ini
58
+ # pylint → .pylintrc
59
+ # isort → .isort.cfg
60
+ # Black is the exception: it only reads pyproject.toml, so its config
61
+ # stays here and is project-owned.
62
+
63
+ [tool.black]
64
+ line-length = 100
65
+
66
+ [tool.pytest.ini_options]
67
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,83 @@
1
+ """Tests for config loading and URL normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+
8
+ import pytest
9
+
10
+ from webcal_mcp.config import CalendarConfig, load_config
11
+
12
+
13
+ def _write(tmp_path: Path, body: str) -> Path:
14
+ path = tmp_path / "config.toml"
15
+ path.write_text(dedent(body))
16
+ return path
17
+
18
+
19
+ def test_loads_multiple_calendars(tmp_path: Path) -> None:
20
+ cfg = load_config(
21
+ _write(
22
+ tmp_path,
23
+ """\
24
+ default_ttl_seconds = 600
25
+
26
+ [calendars.personal]
27
+ url = "https://example.com/p.ics"
28
+ description = "Personal"
29
+
30
+ [calendars.work]
31
+ url = "webcal://example.com/w.ics"
32
+ ttl_seconds = 1800
33
+ """,
34
+ )
35
+ )
36
+ assert set(cfg.calendars) == {"personal", "work"}
37
+ assert cfg.calendars["personal"].ttl_seconds == 600 # inherits default
38
+ assert cfg.calendars["work"].ttl_seconds == 1800
39
+ assert cfg.default_calendar is None # ambiguous with two calendars
40
+
41
+
42
+ def test_single_calendar_is_default(tmp_path: Path) -> None:
43
+ cfg = load_config(
44
+ _write(
45
+ tmp_path,
46
+ """\
47
+ [calendars.only]
48
+ url = "https://example.com/o.ics"
49
+ """,
50
+ )
51
+ )
52
+ assert cfg.default_calendar == "only"
53
+
54
+
55
+ def test_missing_url_rejected(tmp_path: Path) -> None:
56
+ with pytest.raises(ValueError, match="missing 'url'"):
57
+ load_config(
58
+ _write(
59
+ tmp_path,
60
+ """\
61
+ [calendars.bad]
62
+ description = "no url"
63
+ """,
64
+ )
65
+ )
66
+
67
+
68
+ def test_no_calendars_rejected(tmp_path: Path) -> None:
69
+ with pytest.raises(ValueError, match="No \\[calendars"):
70
+ load_config(_write(tmp_path, "default_ttl_seconds = 60\n"))
71
+
72
+
73
+ @pytest.mark.parametrize(
74
+ "url,expected",
75
+ [
76
+ ("webcal://example.com/x.ics", "https://example.com/x.ics"),
77
+ ("webcals://example.com/x.ics", "https://example.com/x.ics"),
78
+ ("https://example.com/x.ics", "https://example.com/x.ics"),
79
+ ("http://example.com/x.ics", "http://example.com/x.ics"),
80
+ ],
81
+ )
82
+ def test_http_url_normalization(url: str, expected: str) -> None:
83
+ assert CalendarConfig(name="c", url=url).http_url == expected
@@ -0,0 +1,131 @@
1
+ """Tests for IcsHttpSource caching behaviour using httpx MockTransport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from webcal_mcp.config import CalendarConfig
11
+ from webcal_mcp.fetcher import IcsHttpSource
12
+
13
+
14
+ def _make_source(
15
+ handler: httpx.MockTransport, *, ttl_seconds: int = 900
16
+ ) -> tuple[IcsHttpSource, httpx.AsyncClient]:
17
+ client = httpx.AsyncClient(transport=handler)
18
+ cfg = CalendarConfig(name="c", url="https://example.com/c.ics", ttl_seconds=ttl_seconds)
19
+ return IcsHttpSource(cfg, client), client
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_cached_within_ttl(simple_ics: bytes) -> None:
24
+ calls: list[httpx.Request] = []
25
+
26
+ def handler(request: httpx.Request) -> httpx.Response:
27
+ calls.append(request)
28
+ return httpx.Response(200, content=simple_ics, headers={"etag": "v1"})
29
+
30
+ source, client = _make_source(httpx.MockTransport(handler))
31
+ try:
32
+ window = (
33
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
34
+ datetime(2026, 6, 10, tzinfo=timezone.utc),
35
+ )
36
+ await source.events(*window)
37
+ await source.events(*window)
38
+ finally:
39
+ await client.aclose()
40
+ assert len(calls) == 1 # second call served from cache
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_etag_revalidation(simple_ics: bytes) -> None:
45
+ calls: list[httpx.Request] = []
46
+
47
+ def handler(request: httpx.Request) -> httpx.Response:
48
+ calls.append(request)
49
+ if "If-None-Match" in request.headers:
50
+ return httpx.Response(304)
51
+ return httpx.Response(200, content=simple_ics, headers={"etag": "v1"})
52
+
53
+ source, client = _make_source(httpx.MockTransport(handler), ttl_seconds=0)
54
+ try:
55
+ window = (
56
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
57
+ datetime(2026, 6, 10, tzinfo=timezone.utc),
58
+ )
59
+ first = await source.events(*window)
60
+ second = await source.events(*window)
61
+ finally:
62
+ await client.aclose()
63
+
64
+ assert len(calls) == 2
65
+ assert calls[1].headers.get("If-None-Match") == "v1"
66
+ assert [e.uid for e in first] == [e.uid for e in second]
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_get_event_uses_cache(simple_ics: bytes) -> None:
71
+ calls: list[httpx.Request] = []
72
+
73
+ def handler(request: httpx.Request) -> httpx.Response:
74
+ calls.append(request)
75
+ return httpx.Response(200, content=simple_ics)
76
+
77
+ source, client = _make_source(httpx.MockTransport(handler))
78
+ try:
79
+ event = await source.get_event("evt-2@test")
80
+ finally:
81
+ await client.aclose()
82
+
83
+ assert event is not None
84
+ assert event.summary == "Lunch with Alice"
85
+ assert len(calls) == 1
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_refresh_bypasses_ttl(simple_ics: bytes) -> None:
90
+ calls: list[httpx.Request] = []
91
+
92
+ def handler(request: httpx.Request) -> httpx.Response:
93
+ calls.append(request)
94
+ if "If-None-Match" in request.headers:
95
+ return httpx.Response(304)
96
+ return httpx.Response(200, content=simple_ics, headers={"etag": "v1"})
97
+
98
+ # TTL is large enough that a normal call would be served from cache.
99
+ source, client = _make_source(httpx.MockTransport(handler), ttl_seconds=3600)
100
+ try:
101
+ window = (
102
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
103
+ datetime(2026, 6, 10, tzinfo=timezone.utc),
104
+ )
105
+ await source.events(*window)
106
+ await source.events(*window) # cached, no HTTP call
107
+ await source.events(*window, refresh=True) # forces revalidation
108
+ await source.get_event("evt-1@test", refresh=True) # forces revalidation
109
+ finally:
110
+ await client.aclose()
111
+
112
+ # Initial fetch + two refresh-triggered conditional GETs.
113
+ assert len(calls) == 3
114
+ assert calls[1].headers.get("If-None-Match") == "v1"
115
+ assert calls[2].headers.get("If-None-Match") == "v1"
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_http_error_propagates() -> None:
120
+ def handler(_: httpx.Request) -> httpx.Response:
121
+ return httpx.Response(500, text="boom")
122
+
123
+ source, client = _make_source(httpx.MockTransport(handler))
124
+ try:
125
+ with pytest.raises(httpx.HTTPStatusError):
126
+ await source.events(
127
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
128
+ datetime(2026, 6, 2, tzinfo=timezone.utc),
129
+ )
130
+ finally:
131
+ await client.aclose()
@@ -0,0 +1,82 @@
1
+ """Tests for ICS parsing, normalization, recurrence expansion, master lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from webcal_mcp.parser import expand_events, find_master, parse_calendar
8
+
9
+
10
+ def test_normalizes_events(simple_ics: bytes) -> None:
11
+ cal = parse_calendar(simple_ics)
12
+ events = expand_events(
13
+ cal,
14
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
15
+ datetime(2026, 6, 10, tzinfo=timezone.utc),
16
+ )
17
+ assert [e.uid for e in events] == ["evt-1@test", "evt-2@test", "evt-3@test"]
18
+
19
+ standup = events[0]
20
+ assert standup.summary == "Standup"
21
+ assert standup.location == "Zoom"
22
+ assert standup.all_day is False
23
+ assert standup.categories == ("work", "meetings")
24
+ assert standup.start.tzinfo is not None
25
+
26
+
27
+ def test_all_day_event_flagged(simple_ics: bytes) -> None:
28
+ cal = parse_calendar(simple_ics)
29
+ events = expand_events(
30
+ cal,
31
+ datetime(2026, 6, 4, tzinfo=timezone.utc),
32
+ datetime(2026, 6, 7, tzinfo=timezone.utc),
33
+ )
34
+ [conf] = events
35
+ assert conf.uid == "evt-3@test"
36
+ assert conf.all_day is True
37
+
38
+
39
+ def test_window_filters_events(simple_ics: bytes) -> None:
40
+ cal = parse_calendar(simple_ics)
41
+ events = expand_events(
42
+ cal,
43
+ datetime(2026, 6, 2, tzinfo=timezone.utc),
44
+ datetime(2026, 6, 3, tzinfo=timezone.utc),
45
+ )
46
+ assert [e.uid for e in events] == ["evt-2@test"]
47
+
48
+
49
+ def test_recurrence_expansion(recurring_ics: bytes) -> None:
50
+ cal = parse_calendar(recurring_ics)
51
+ events = expand_events(
52
+ cal,
53
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
54
+ datetime(2026, 7, 1, tzinfo=timezone.utc),
55
+ )
56
+ assert len(events) == 4
57
+ assert all(e.summary == "Weekly sync" for e in events)
58
+ # Each occurrence is exactly one week apart.
59
+ gaps = {(b.start - a.start).days for a, b in zip(events, events[1:])}
60
+ assert gaps == {7}
61
+
62
+
63
+ def test_find_master(simple_ics: bytes) -> None:
64
+ cal = parse_calendar(simple_ics)
65
+ master = find_master(cal, "evt-2@test")
66
+ assert master is not None
67
+ assert master.summary == "Lunch with Alice"
68
+ assert find_master(cal, "nope") is None
69
+
70
+
71
+ def test_as_markdown_includes_key_fields(simple_ics: bytes) -> None:
72
+ cal = parse_calendar(simple_ics)
73
+ [standup, *_] = expand_events(
74
+ cal,
75
+ datetime(2026, 6, 1, tzinfo=timezone.utc),
76
+ datetime(2026, 6, 2, tzinfo=timezone.utc),
77
+ )
78
+ md = standup.as_markdown()
79
+ assert "## Standup" in md
80
+ assert "Zoom" in md
81
+ assert "evt-1@test" in md
82
+ assert "Daily sync" in md
@@ -0,0 +1,123 @@
1
+ """Tests for server helpers and tool registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ import pytest
8
+
9
+ from webcal_mcp.config import CalendarConfig, Config
10
+ from webcal_mcp.parser import Event
11
+ from webcal_mcp.server import (
12
+ DEFAULT_WINDOW_DAYS,
13
+ CalendarRegistry,
14
+ _filter,
15
+ _resolve_window,
16
+ build_server,
17
+ )
18
+
19
+
20
+ def _ev(uid: str, **kw: object) -> Event:
21
+ base = {
22
+ "uid": uid,
23
+ "summary": "",
24
+ "start": datetime(2026, 6, 1, tzinfo=timezone.utc),
25
+ "end": datetime(2026, 6, 1, 1, tzinfo=timezone.utc),
26
+ "all_day": False,
27
+ }
28
+ base.update(kw)
29
+ return Event(**base) # type: ignore[arg-type]
30
+
31
+
32
+ def test_resolve_window_defaults_when_empty() -> None:
33
+ start, end = _resolve_window(None, None)
34
+ assert (end - start) == timedelta(days=DEFAULT_WINDOW_DAYS)
35
+
36
+
37
+ def test_resolve_window_open_start() -> None:
38
+ start, end = _resolve_window(None, "2026-06-30")
39
+ assert (end - start).days >= DEFAULT_WINDOW_DAYS - 1
40
+
41
+
42
+ def test_resolve_window_open_end() -> None:
43
+ start, end = _resolve_window("2026-06-01", None)
44
+ assert (end - start) == timedelta(days=DEFAULT_WINDOW_DAYS)
45
+
46
+
47
+ def test_resolve_window_pushes_end_to_end_of_day() -> None:
48
+ _, end = _resolve_window("2026-06-01", "2026-06-02")
49
+ assert end.hour == 23 and end.minute == 59
50
+
51
+
52
+ def test_filter_query_matches_summary_and_description() -> None:
53
+ events = [
54
+ _ev("a", summary="Standup"),
55
+ _ev("b", summary="Lunch", description="with Alice"),
56
+ _ev("c", summary="Other"),
57
+ ]
58
+ assert {e.uid for e in _filter(events, query="alice", categories=None, location=None)} == {"b"}
59
+ assert {e.uid for e in _filter(events, query="stand", categories=None, location=None)} == {"a"}
60
+
61
+
62
+ def test_filter_categories_intersection() -> None:
63
+ events = [
64
+ _ev("a", categories=("work",)),
65
+ _ev("b", categories=("personal",)),
66
+ _ev("c", categories=("work", "urgent")),
67
+ ]
68
+ got = _filter(events, query=None, categories=["WORK"], location=None)
69
+ assert {e.uid for e in got} == {"a", "c"}
70
+
71
+
72
+ def test_filter_location_substring() -> None:
73
+ events = [
74
+ _ev("a", location="Cafe Roma"),
75
+ _ev("b", location="Office"),
76
+ ]
77
+ got = _filter(events, query=None, categories=None, location="cafe")
78
+ assert {e.uid for e in got} == {"a"}
79
+
80
+
81
+ def _registry(*names: str) -> CalendarRegistry:
82
+ cals = {n: CalendarConfig(name=n, url=f"https://x/{n}.ics") for n in names}
83
+ return CalendarRegistry(Config(calendars=cals))
84
+
85
+
86
+ def test_resolve_requires_name_when_multiple() -> None:
87
+ reg = _registry("a", "b")
88
+ try:
89
+ with pytest.raises(ValueError, match="Multiple calendars"):
90
+ reg.resolve(None)
91
+ with pytest.raises(ValueError, match="Unknown calendar"):
92
+ reg.resolve("c")
93
+ assert reg.resolve("a").name == "a"
94
+ finally:
95
+ import asyncio
96
+
97
+ asyncio.run(reg.aclose())
98
+
99
+
100
+ def test_resolve_defaults_to_only_calendar() -> None:
101
+ reg = _registry("solo")
102
+ try:
103
+ assert reg.resolve(None).name == "solo"
104
+ finally:
105
+ import asyncio
106
+
107
+ asyncio.run(reg.aclose())
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_build_server_registers_expected_tools() -> None:
112
+ reg = _registry("solo")
113
+ try:
114
+ server = build_server(reg)
115
+ tools = await server.list_tools()
116
+ assert {t.name for t in tools} == {
117
+ "list_calendars",
118
+ "list_events",
119
+ "events_on",
120
+ "get_event",
121
+ }
122
+ finally:
123
+ await reg.aclose()