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.
- webcal_mcp-0.1.1/LICENSE.md +19 -0
- webcal_mcp-0.1.1/MANIFEST.in +8 -0
- webcal_mcp-0.1.1/PKG-INFO +101 -0
- webcal_mcp-0.1.1/README.md +71 -0
- webcal_mcp-0.1.1/pyproject.toml +67 -0
- webcal_mcp-0.1.1/setup.cfg +4 -0
- webcal_mcp-0.1.1/tests/test_config.py +83 -0
- webcal_mcp-0.1.1/tests/test_fetcher.py +131 -0
- webcal_mcp-0.1.1/tests/test_parser.py +82 -0
- webcal_mcp-0.1.1/tests/test_server.py +123 -0
- webcal_mcp-0.1.1/webcal_mcp/__init__.py +23 -0
- webcal_mcp-0.1.1/webcal_mcp/__main__.py +4 -0
- webcal_mcp-0.1.1/webcal_mcp/config.py +96 -0
- webcal_mcp-0.1.1/webcal_mcp/fetcher.py +85 -0
- webcal_mcp-0.1.1/webcal_mcp/parser.py +187 -0
- webcal_mcp-0.1.1/webcal_mcp/py.typed +0 -0
- webcal_mcp-0.1.1/webcal_mcp/server.py +229 -0
- webcal_mcp-0.1.1/webcal_mcp/source.py +41 -0
- webcal_mcp-0.1.1/webcal_mcp.egg-info/SOURCES.txt +16 -0
|
@@ -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,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,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()
|