calendula 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miki Yutani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: calendula
3
+ Version: 0.1.0
4
+ Summary: MCP server that reads your Outlook (classic) calendar from WSL
5
+ Keywords: mcp,outlook,calendar,wsl,claude-code
6
+ Author: Miki Yutani
7
+ Author-email: Miki Yutani <mkyutani@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Office/Business :: Scheduling
20
+ Requires-Dist: mcp>=2.3,<3
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://github.com/mkyutani/calendula
23
+ Project-URL: Repository, https://github.com/mkyutani/calendula
24
+ Description-Content-Type: text/markdown
25
+
26
+ # calendula
27
+
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
29
+ [![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
30
+ [![PyPI version](https://badge.fury.io/py/calendula.svg)](https://pypi.org/project/calendula/)
31
+
32
+ An [MCP](https://modelcontextprotocol.io/) server that lets Claude Code (or any MCP client) read your **Outlook (classic)** calendar from **WSL**.
33
+
34
+ - Read-only: list the events of a day (or several), or look up the event at a given time with its attendees and body
35
+ - Talks to Outlook on Windows through PowerShell and COM: no Microsoft Graph, no admin consent, no tokens
36
+ - Keeps what it fetched in memory only; nothing is written to disk (see [Caching](#caching))
37
+
38
+ ## Requirements
39
+
40
+ - Windows with **Outlook (classic)** running. The new Outlook has no COM interface and does not work
41
+ - **WSL**: calendula runs in the Linux distribution and calls `wscript.exe` and `powershell.exe` through WSL interop
42
+ - Python 3.11 or later
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ uv tool install calendula # or: pipx install calendula
48
+ calendula list # prints today's events (checks that Outlook answers)
49
+ ```
50
+
51
+ Register it with Claude Code for all your projects:
52
+
53
+ ```bash
54
+ claude mcp add --scope user calendula -- calendula
55
+ ```
56
+
57
+ Other MCP clients: run `calendula` as a stdio server.
58
+
59
+ Then ask things like "What's on my calendar tomorrow afternoon?" or "Who is in the 14:00 meeting?".
60
+
61
+ To let Claude read the calendar without asking each time, allow the two read-only tools in `~/.claude/settings.json`:
62
+
63
+ ```json
64
+ {
65
+ "permissions": {
66
+ "allow": ["mcp__calendula__list_events", "mcp__calendula__get_event"]
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Tools
72
+
73
+ | Tool | Arguments | Returns |
74
+ | --- | --- | --- |
75
+ | `list_events` | `date` (YYYY-MM-DD, default today), `days` (1-31, default 1), `refresh` | Each day's events: time, subject, location |
76
+ | `get_event` | `time` (HH:MM), `date`, `refresh` | The event running at that time (or the one starting nearest to it, with a note): location, organizer, attendees, status, body |
77
+ | `restart_outlook` | none | Force-quits Outlook and starts it again, to recover a broken COM connection. Unsaved drafts are lost, so the model is told to ask you first |
78
+
79
+ Every answer starts with the day and the time it was fetched:
80
+
81
+ ```
82
+ 2026-11-05 (Thu) fetched 2026-11-05 10:32
83
+ 09:00-10:00 Weekly sync [Room A]
84
+ 13:30-14:30 Customer interview
85
+ ```
86
+
87
+ An event that spans several days (a conference, a meeting past midnight) appears on each day it covers. Bodies are kept whole except for blank lines and the Teams join block.
88
+
89
+ ## Caching
90
+
91
+ Fetching takes a few seconds (PowerShell starts, then Outlook answers over COM), so fetched days are kept in the server's memory:
92
+
93
+ - **Today and later** are reused for up to 1 hour, then fetched again
94
+ - **Past days** fetched after they ended are kept for the life of the server (one Claude Code session)
95
+ - `refresh: true` always fetches; ask for it when the calendar has just changed
96
+ - A request fetches only the days that are missing or stale, in one call
97
+ - When fetching fails, the error is returned as is; older copies are never shown instead
98
+
99
+ Nothing is written to disk. Set `CALENDULA_TTL_MINUTES` to change the 1 hour.
100
+
101
+ ## Command line
102
+
103
+ ```
104
+ calendula run the MCP server on stdio (same as `calendula serve`)
105
+ calendula list [DATE] [DAYS] print events, fetched from Outlook now
106
+ calendula show TIME [--date DATE] print the event at a time
107
+ calendula --version
108
+ ```
109
+
110
+ ## Troubleshooting
111
+
112
+ - **`CO_E_SERVER_EXEC_FAILURE` (0x80080005) and similar COM errors**: Outlook's COM connection often breaks after sleep or a network change. Restart Outlook (Claude will ask before calling `restart_outlook`)
113
+ - **`Outlook gave no answer`**: check that Outlook (classic) is running
114
+ - **`calendula needs WSL ...`**: calendula only works inside WSL on a Windows PC with Outlook
115
+
116
+ ## How it works
117
+
118
+ For each fetch, calendula writes `get_calendar.ps1` (as UTF-16 with a BOM, which Windows PowerShell 5.1 reads correctly) into a temporary directory under `/tmp`, and runs it hidden through `wscript.exe` and `run_hidden.vbs`, using the `\\wsl.localhost\...` path. The script reads the default calendar folder through `Outlook.Application`, including recurring events, and writes `events.json`, which calendula parses.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ uv sync
124
+ uv run pytest # needs neither Windows nor Outlook
125
+ CALENDULA_WINDOWS_TESTS=1 uv run pytest # on WSL: also the PowerShell side, still without Outlook
126
+ uv run ruff check && uv run ruff format --check
127
+ ```
128
+
129
+ What is tested where, and why, is in [docs/test-design.md](docs/test-design.md); the behavior it checks is specified in [docs/spec.md](docs/spec.md).
130
+
131
+ Releases: bump `version` in `pyproject.toml`, then push a tag `vX.Y.Z`. GitHub Actions tests, builds, publishes to TestPyPI, installs from there, publishes to PyPI (Trusted Publishing), and creates a GitHub release.
132
+
133
+ ## License
134
+
135
+ [MIT](LICENSE)
@@ -0,0 +1,110 @@
1
+ # calendula
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
5
+ [![PyPI version](https://badge.fury.io/py/calendula.svg)](https://pypi.org/project/calendula/)
6
+
7
+ An [MCP](https://modelcontextprotocol.io/) server that lets Claude Code (or any MCP client) read your **Outlook (classic)** calendar from **WSL**.
8
+
9
+ - Read-only: list the events of a day (or several), or look up the event at a given time with its attendees and body
10
+ - Talks to Outlook on Windows through PowerShell and COM: no Microsoft Graph, no admin consent, no tokens
11
+ - Keeps what it fetched in memory only; nothing is written to disk (see [Caching](#caching))
12
+
13
+ ## Requirements
14
+
15
+ - Windows with **Outlook (classic)** running. The new Outlook has no COM interface and does not work
16
+ - **WSL**: calendula runs in the Linux distribution and calls `wscript.exe` and `powershell.exe` through WSL interop
17
+ - Python 3.11 or later
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ uv tool install calendula # or: pipx install calendula
23
+ calendula list # prints today's events (checks that Outlook answers)
24
+ ```
25
+
26
+ Register it with Claude Code for all your projects:
27
+
28
+ ```bash
29
+ claude mcp add --scope user calendula -- calendula
30
+ ```
31
+
32
+ Other MCP clients: run `calendula` as a stdio server.
33
+
34
+ Then ask things like "What's on my calendar tomorrow afternoon?" or "Who is in the 14:00 meeting?".
35
+
36
+ To let Claude read the calendar without asking each time, allow the two read-only tools in `~/.claude/settings.json`:
37
+
38
+ ```json
39
+ {
40
+ "permissions": {
41
+ "allow": ["mcp__calendula__list_events", "mcp__calendula__get_event"]
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Tools
47
+
48
+ | Tool | Arguments | Returns |
49
+ | --- | --- | --- |
50
+ | `list_events` | `date` (YYYY-MM-DD, default today), `days` (1-31, default 1), `refresh` | Each day's events: time, subject, location |
51
+ | `get_event` | `time` (HH:MM), `date`, `refresh` | The event running at that time (or the one starting nearest to it, with a note): location, organizer, attendees, status, body |
52
+ | `restart_outlook` | none | Force-quits Outlook and starts it again, to recover a broken COM connection. Unsaved drafts are lost, so the model is told to ask you first |
53
+
54
+ Every answer starts with the day and the time it was fetched:
55
+
56
+ ```
57
+ 2026-11-05 (Thu) fetched 2026-11-05 10:32
58
+ 09:00-10:00 Weekly sync [Room A]
59
+ 13:30-14:30 Customer interview
60
+ ```
61
+
62
+ An event that spans several days (a conference, a meeting past midnight) appears on each day it covers. Bodies are kept whole except for blank lines and the Teams join block.
63
+
64
+ ## Caching
65
+
66
+ Fetching takes a few seconds (PowerShell starts, then Outlook answers over COM), so fetched days are kept in the server's memory:
67
+
68
+ - **Today and later** are reused for up to 1 hour, then fetched again
69
+ - **Past days** fetched after they ended are kept for the life of the server (one Claude Code session)
70
+ - `refresh: true` always fetches; ask for it when the calendar has just changed
71
+ - A request fetches only the days that are missing or stale, in one call
72
+ - When fetching fails, the error is returned as is; older copies are never shown instead
73
+
74
+ Nothing is written to disk. Set `CALENDULA_TTL_MINUTES` to change the 1 hour.
75
+
76
+ ## Command line
77
+
78
+ ```
79
+ calendula run the MCP server on stdio (same as `calendula serve`)
80
+ calendula list [DATE] [DAYS] print events, fetched from Outlook now
81
+ calendula show TIME [--date DATE] print the event at a time
82
+ calendula --version
83
+ ```
84
+
85
+ ## Troubleshooting
86
+
87
+ - **`CO_E_SERVER_EXEC_FAILURE` (0x80080005) and similar COM errors**: Outlook's COM connection often breaks after sleep or a network change. Restart Outlook (Claude will ask before calling `restart_outlook`)
88
+ - **`Outlook gave no answer`**: check that Outlook (classic) is running
89
+ - **`calendula needs WSL ...`**: calendula only works inside WSL on a Windows PC with Outlook
90
+
91
+ ## How it works
92
+
93
+ For each fetch, calendula writes `get_calendar.ps1` (as UTF-16 with a BOM, which Windows PowerShell 5.1 reads correctly) into a temporary directory under `/tmp`, and runs it hidden through `wscript.exe` and `run_hidden.vbs`, using the `\\wsl.localhost\...` path. The script reads the default calendar folder through `Outlook.Application`, including recurring events, and writes `events.json`, which calendula parses.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ uv sync
99
+ uv run pytest # needs neither Windows nor Outlook
100
+ CALENDULA_WINDOWS_TESTS=1 uv run pytest # on WSL: also the PowerShell side, still without Outlook
101
+ uv run ruff check && uv run ruff format --check
102
+ ```
103
+
104
+ What is tested where, and why, is in [docs/test-design.md](docs/test-design.md); the behavior it checks is specified in [docs/spec.md](docs/spec.md).
105
+
106
+ Releases: bump `version` in `pyproject.toml`, then push a tag `vX.Y.Z`. GitHub Actions tests, builds, publishes to TestPyPI, installs from there, publishes to PyPI (Trusted Publishing), and creates a GitHub release.
107
+
108
+ ## License
109
+
110
+ [MIT](LICENSE)
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "calendula"
3
+ version = "0.1.0"
4
+ description = "MCP server that reads your Outlook (classic) calendar from WSL"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.11"
9
+ keywords = [
10
+ "mcp",
11
+ "outlook",
12
+ "calendar",
13
+ "wsl",
14
+ "claude-code",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Office/Business :: Scheduling",
27
+ ]
28
+ dependencies = ["mcp>=2.3,<3"]
29
+
30
+ [[project.authors]]
31
+ name = "Miki Yutani"
32
+ email = "mkyutani@gmail.com"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/mkyutani/calendula"
36
+ Repository = "https://github.com/mkyutani/calendula"
37
+
38
+ [project.scripts]
39
+ calendula = "calendula.cli:main"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8.3.5",
44
+ "ruff>=0.16.10",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.13.7,<0.14"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.ruff.lint]
52
+ select = [
53
+ "E",
54
+ "F",
55
+ "W",
56
+ "I",
57
+ "N",
58
+ "UP",
59
+ "B",
60
+ "A",
61
+ "C4",
62
+ ]
63
+ ignore = ["E501"]
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "calendula"
3
+ version = "0.1.0"
4
+ description = "MCP server that reads your Outlook (classic) calendar from WSL"
5
+ authors = [{ name = "Miki Yutani", email = "mkyutani@gmail.com" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ keywords = ["mcp", "outlook", "calendar", "wsl", "claude-code"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Office/Business :: Scheduling",
22
+ ]
23
+ dependencies = ["mcp>=2.3,<3"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/mkyutani/calendula"
27
+ Repository = "https://github.com/mkyutani/calendula"
28
+
29
+ [project.scripts]
30
+ calendula = "calendula.cli:main"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest>=8.3.5",
35
+ "ruff>=0.16.10",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.13.7,<0.14"]
40
+ build-backend = "uv_build"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4"]
44
+ ignore = ["E501"] # Line too long (handled by ruff format)
@@ -0,0 +1,8 @@
1
+ """calendula: an MCP server that reads your Outlook (classic) calendar from WSL."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("calendula")
7
+ except PackageNotFoundError: # running from a source tree that is not installed
8
+ __version__ = "0.0.0"
@@ -0,0 +1,87 @@
1
+ """In-memory cache of fetched days. Nothing is written to disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import date, datetime, timedelta
10
+
11
+ from .events import Event
12
+
13
+ MAX_DAYS = 31
14
+ TTL_MINUTES = 60
15
+
16
+
17
+ def ttl_from_env(value: str | None) -> timedelta:
18
+ """CALENDULA_TTL_MINUTES as a timedelta; anything but a positive integer means the default."""
19
+ try:
20
+ minutes = int(value) if value is not None else TTL_MINUTES
21
+ except ValueError:
22
+ minutes = TTL_MINUTES
23
+ return timedelta(minutes=minutes if minutes > 0 else TTL_MINUTES)
24
+
25
+
26
+ DEFAULT_TTL = ttl_from_env(os.environ.get("CALENDULA_TTL_MINUTES"))
27
+
28
+ Fetcher = Callable[[date, int], dict[date, list[Event]]]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Day:
33
+ date: date
34
+ fetched: datetime
35
+ events: list[Event]
36
+
37
+
38
+ class Calendar:
39
+ """Days fetched from Outlook, kept for the life of the server process.
40
+
41
+ - A day that was over when it was fetched (yesterday or before) is kept and never fetched again.
42
+ - Any other day (today, the future, or a past day fetched before it ended) is fetched again
43
+ once it is older than `ttl`.
44
+ - `refresh=True` always fetches.
45
+ Missing or stale days in a request are fetched in one call covering them.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ fetch: Fetcher,
51
+ ttl: timedelta = DEFAULT_TTL,
52
+ now: Callable[[], datetime] = datetime.now,
53
+ ):
54
+ self._fetch = fetch
55
+ self._ttl = ttl
56
+ self._now = now
57
+ self._days: dict[date, Day] = {}
58
+ self._lock = threading.Lock()
59
+
60
+ def _stale(self, d: date, now: datetime) -> bool:
61
+ day = self._days.get(d)
62
+ if day is None:
63
+ return True
64
+ if day.fetched.date() > d:
65
+ return False
66
+ return now - day.fetched >= self._ttl
67
+
68
+ def days(self, start: date, count: int = 1, refresh: bool = False) -> list[Day]:
69
+ if not 1 <= count <= MAX_DAYS:
70
+ raise ValueError(f"days must be between 1 and {MAX_DAYS}")
71
+ wanted = [start + timedelta(days=i) for i in range(count)]
72
+ with self._lock:
73
+ now = self._now()
74
+ need = [d for d in wanted if refresh or self._stale(d, now)]
75
+ if need:
76
+ first, last = need[0], need[-1]
77
+ span = (last - first).days + 1
78
+ fetched = self._fetch(first, span)
79
+ at = self._now()
80
+ for i in range(span):
81
+ d = first + timedelta(days=i)
82
+ self._days[d] = Day(d, at, fetched.get(d, []))
83
+ return [self._days[d] for d in wanted]
84
+
85
+ def clear(self) -> None:
86
+ with self._lock:
87
+ self._days.clear()
@@ -0,0 +1,88 @@
1
+ """Command line: run the MCP server, or print events for a quick check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import datetime
8
+
9
+ from . import __version__, outlook
10
+ from .cache import MAX_DAYS
11
+ from .events import day_heading, parse_date, parse_time, pick
12
+
13
+
14
+ def _serve(_args: argparse.Namespace) -> int:
15
+ from .server import build_server
16
+
17
+ build_server().run("stdio")
18
+ return 0
19
+
20
+
21
+ def _list(args: argparse.Namespace) -> int:
22
+ start = parse_date(args.date, datetime.now().date())
23
+ if not 1 <= args.days <= MAX_DAYS:
24
+ raise ValueError(f"days must be between 1 and {MAX_DAYS}")
25
+ days = outlook.fetch(start, args.days)
26
+ fetched = datetime.now()
27
+ blocks = []
28
+ for d in sorted(days):
29
+ lines = [day_heading(d, fetched)] + (
30
+ [e.summary(d) for e in days[d]] or ["(no events)"]
31
+ )
32
+ blocks.append("\n".join(lines))
33
+ print("\n\n".join(blocks))
34
+ return 0
35
+
36
+
37
+ def _show(args: argparse.Namespace) -> int:
38
+ day = parse_date(args.date, datetime.now().date())
39
+ at = parse_time(args.time)
40
+ events = outlook.fetch(day, 1).get(day, [])
41
+ event, running = pick(events, datetime.combine(day, at))
42
+ print(day_heading(day, datetime.now()))
43
+ if event is None:
44
+ print("No timed events on this day.")
45
+ return 0
46
+ if not running:
47
+ print(
48
+ f"No event is running at {at:%H:%M}; this is the one starting nearest to it."
49
+ )
50
+ print(event.detail(day))
51
+ return 0
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ p = argparse.ArgumentParser(
56
+ prog="calendula",
57
+ description="MCP server that reads your Outlook (classic) calendar from WSL. "
58
+ "With no command, runs the server on stdio.",
59
+ )
60
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
61
+ sub = p.add_subparsers(dest="command")
62
+ sub.add_parser(
63
+ "serve", help="run the MCP server on stdio (the default)"
64
+ ).set_defaults(func=_serve)
65
+ pl = sub.add_parser("list", help="print events, fetched from Outlook now")
66
+ pl.add_argument("date", nargs="?", help="first day, YYYY-MM-DD (default: today)")
67
+ pl.add_argument(
68
+ "days", nargs="?", type=int, default=1, help="number of days (default: 1)"
69
+ )
70
+ pl.set_defaults(func=_list)
71
+ ps = sub.add_parser(
72
+ "show", help="print the event at a time, fetched from Outlook now"
73
+ )
74
+ ps.add_argument("time", help="HH:MM")
75
+ ps.add_argument("--date", help="YYYY-MM-DD (default: today)")
76
+ ps.set_defaults(func=_show)
77
+
78
+ args = p.parse_args(argv)
79
+ func = getattr(args, "func", _serve)
80
+ try:
81
+ return func(args)
82
+ except (ValueError, outlook.OutlookError) as e:
83
+ print(f"calendula: {e}", file=sys.stderr)
84
+ return 1
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
@@ -0,0 +1,153 @@
1
+ """Calendar events: parsing what the PowerShell script writes, and formatting them for the model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from datetime import date, datetime, time, timedelta
8
+
9
+ # Outlook OlBusyStatus
10
+ BUSY = {
11
+ 0: "Free",
12
+ 1: "Tentative",
13
+ 2: "Busy",
14
+ 3: "Out of office",
15
+ 4: "Working elsewhere",
16
+ }
17
+ DOW = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Event:
22
+ start: datetime
23
+ end: datetime
24
+ subject: str
25
+ all_day: bool = False
26
+ location: str = ""
27
+ organizer: str = ""
28
+ required: str = ""
29
+ optional: str = ""
30
+ categories: str = ""
31
+ busy: int | None = None
32
+ body: tuple[str, ...] = field(default_factory=tuple)
33
+
34
+ @classmethod
35
+ def from_json(cls, d: dict) -> Event:
36
+ def s(key: str) -> str:
37
+ v = d.get(key)
38
+ return "" if v is None else str(v).strip()
39
+
40
+ body = d.get("body") or []
41
+ if isinstance(body, str): # PowerShell may collapse a one-item array
42
+ body = [body]
43
+ busy = d.get("busy")
44
+ return cls(
45
+ start=datetime.fromisoformat(d["start"]),
46
+ end=datetime.fromisoformat(d["end"]),
47
+ subject=s("subject"),
48
+ all_day=bool(d.get("all_day")),
49
+ location=s("location"),
50
+ organizer=s("organizer"),
51
+ required=s("required"),
52
+ optional=s("optional"),
53
+ categories=s("categories"),
54
+ busy=int(busy) if busy is not None else None,
55
+ body=tuple(str(x) for x in body if str(x).strip()),
56
+ )
57
+
58
+ def span_on(self, day: date) -> str:
59
+ """The time span as shown on `day` (spec 3.1, "Time span on day D")."""
60
+ if self.all_day:
61
+ return "All day"
62
+ start = (
63
+ f"{self.start:%H:%M}"
64
+ if self.start.date() == day
65
+ else f"{self.start:%m-%d %H:%M}"
66
+ )
67
+ if self.end.date() == day:
68
+ end = f"{self.end:%H:%M}"
69
+ elif self.end == datetime.combine(day + timedelta(days=1), time()):
70
+ end = "24:00"
71
+ else:
72
+ end = f"{self.end:%m-%d %H:%M}"
73
+ return f"{start}-{end}"
74
+
75
+ def summary(self, day: date) -> str:
76
+ line = f"{self.span_on(day):<11} {self.subject}"
77
+ if self.location:
78
+ line += f" [{self.location}]"
79
+ return line
80
+
81
+ def detail(self, day: date) -> str:
82
+ lines = [f"{self.span_on(day)} {self.subject}"]
83
+ for label, value in (
84
+ ("Location", self.location),
85
+ ("Organizer", self.organizer),
86
+ ("Required attendees", self.required),
87
+ ("Optional attendees", self.optional),
88
+ ("Categories", self.categories),
89
+ ("Status", BUSY.get(self.busy, "") if self.busy is not None else ""),
90
+ ):
91
+ if value:
92
+ lines.append(f"{label}: {value}")
93
+ if self.body:
94
+ lines.append("")
95
+ lines.extend(self.body)
96
+ return "\n".join(lines)
97
+
98
+
99
+ def parse_days(data: dict) -> dict[date, list[Event]]:
100
+ """{date: [events sorted by start]} from the JSON the PowerShell script writes."""
101
+ out: dict[date, list[Event]] = {}
102
+ for day in data.get("days") or []:
103
+ d = date.fromisoformat(day["date"])
104
+ events = day.get("events") or []
105
+ if isinstance(events, dict): # PowerShell may collapse a one-item array
106
+ events = [events]
107
+ out[d] = sorted(
108
+ (Event.from_json(e) for e in events), key=lambda e: (not e.all_day, e.start)
109
+ )
110
+ return out
111
+
112
+
113
+ def pick(events: list[Event], at: datetime) -> tuple[Event | None, bool]:
114
+ """(event, running) at the date and time `at` (spec 2.4, B-PICK-1 to B-PICK-5).
115
+
116
+ Running: start <= at < end; of several, the latest start (ties: the first). Otherwise the event
117
+ whose start is nearest to `at` (ties: the earlier), with running False. All-day events are ignored.
118
+ """
119
+ timed = [e for e in events if not e.all_day]
120
+ running = [e for e in timed if e.start <= at < e.end]
121
+ if running:
122
+ return max(running, key=lambda e: e.start), True
123
+ if timed:
124
+ return min(timed, key=lambda e: abs(e.start - at)), False
125
+ return None, False
126
+
127
+
128
+ def day_heading(d: date, fetched: datetime) -> str:
129
+ return f"{d.isoformat()} ({DOW[d.weekday()]}) fetched {fetched:%Y-%m-%d %H:%M}"
130
+
131
+
132
+ DATE_RE = re.compile(r"^(?:(\d{4})[-/])?(\d{1,2})[-/](\d{1,2})$")
133
+ TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
134
+
135
+
136
+ def parse_date(text: str | None, today: date) -> date:
137
+ """YYYY-MM-DD, YYYY/MM/DD, or MM-DD / MM/DD (this year). Empty means today."""
138
+ if not text or not text.strip():
139
+ return today
140
+ m = DATE_RE.match(text.strip())
141
+ if not m:
142
+ raise ValueError(f"invalid date: {text!r} (use YYYY-MM-DD)")
143
+ try:
144
+ return date(int(m[1] or today.year), int(m[2]), int(m[3]))
145
+ except ValueError:
146
+ raise ValueError(f"invalid date: {text!r}") from None
147
+
148
+
149
+ def parse_time(text: str) -> time:
150
+ m = TIME_RE.match(text.strip())
151
+ if not m or int(m[1]) > 23 or int(m[2]) > 59:
152
+ raise ValueError(f"invalid time: {text!r} (use HH:MM)")
153
+ return time(int(m[1]), int(m[2]))
@@ -0,0 +1,100 @@
1
+ # Writes Outlook (classic) calendar events as JSON. Run by calendula (outlook.py) through run_hidden.vbs.
2
+ #
3
+ # Input (next to this script): start.txt first day, yyyyMMdd
4
+ # days.txt number of days
5
+ # Output: events.json {"days": [{"date": "yyyy-MM-dd", "events": [...]}]} (UTF-8, no BOM)
6
+ # error.txt "ERROR: <message>" when something fails (exit code 1)
7
+ #
8
+ # When dot-sourced (". .\get_calendar.ps1"), only the functions are defined; the tests use that.
9
+ # The contract is in docs/spec.md ("PowerShell script").
10
+
11
+ $inv = [Globalization.CultureInfo]::InvariantCulture
12
+
13
+ # Body lines: trimmed, blank lines dropped, and the Teams meeting block dropped
14
+ # (a line of 10+ underscores followed by "Microsoft Teams", up to the next such line or the end).
15
+ # An underscore line used for anything else (a quoted mail, say) is kept with what follows it.
16
+ function Get-BodyLines([string]$body) {
17
+ $raw = $body -split "`r?`n"
18
+ $out = [System.Collections.Generic.List[string]]::new()
19
+ $skip = $false
20
+ for ($j = 0; $j -lt $raw.Count; $j++) {
21
+ $t = $raw[$j].Trim()
22
+ if ($t -match '^_{10,}$') {
23
+ if ($skip) { $skip = $false; continue }
24
+ $k = $j + 1
25
+ while ($k -lt $raw.Count -and $raw[$k].Trim() -eq "") { $k++ }
26
+ if ($k -lt $raw.Count -and $raw[$k] -match 'Microsoft Teams') { $skip = $true; continue }
27
+ }
28
+ if ($skip -or $t -eq "") { continue }
29
+ $out.Add($t)
30
+ }
31
+ return ,$out
32
+ }
33
+
34
+ # One appointment as an ordered dictionary with the fields of events.json.
35
+ function ConvertTo-EventRecord($item) {
36
+ [ordered]@{
37
+ start = $item.Start.ToString("yyyy-MM-ddTHH:mm:ss", $inv)
38
+ end = $item.End.ToString("yyyy-MM-ddTHH:mm:ss", $inv)
39
+ all_day = [bool]$item.AllDayEvent
40
+ subject = [string]$item.Subject
41
+ location = [string]$item.Location
42
+ organizer = [string]$item.Organizer
43
+ required = [string]$item.RequiredAttendees
44
+ optional = [string]$item.OptionalAttendees
45
+ categories = [string]$item.Categories
46
+ busy = [int]$item.BusyStatus
47
+ body = (Get-BodyLines ($item.Body + "")).ToArray()
48
+ }
49
+ }
50
+
51
+ # The Restrict filter for the appointments that overlap $day: they start before the next day
52
+ # and end after this day began (an all-day event ends at 00:00 of the day after its last day).
53
+ function Get-DayFilter([datetime]$day) {
54
+ "[Start] < '" + $day.AddDays(1).ToString("g") + "' AND [End] > '" + $day.ToString("g") + "'"
55
+ }
56
+
57
+ # Records of the appointments in $folder (a calendar MAPIFolder) that overlap $day, sorted by start.
58
+ # Always an array, even with zero or one event.
59
+ function Get-DayEvents($folder, [datetime]$day) {
60
+ $items = $folder.Items
61
+ $items.IncludeRecurrences = $true
62
+ $items.Sort("[Start]")
63
+ $events = [System.Collections.Generic.List[object]]::new()
64
+ foreach ($item in ($items.Restrict((Get-DayFilter $day)) | Sort-Object Start)) {
65
+ $events.Add((ConvertTo-EventRecord $item))
66
+ }
67
+ return ,$events.ToArray()
68
+ }
69
+
70
+ # events.json text for a list of @{date; events}.
71
+ function ConvertTo-CalendarJson($days) {
72
+ ConvertTo-Json -InputObject ([ordered]@{ days = @($days) }) -Depth 6 -Compress
73
+ }
74
+
75
+ function Invoke-Main {
76
+ $outFile = "$PSScriptRoot\events.json"
77
+ $errorFile = "$PSScriptRoot\error.txt"
78
+ $utf8 = [System.Text.UTF8Encoding]::new($false)
79
+ Remove-Item $outFile -ErrorAction SilentlyContinue
80
+ Remove-Item $errorFile -ErrorAction SilentlyContinue
81
+ try {
82
+ $start = [DateTime]::ParseExact((Get-Content "$PSScriptRoot\start.txt" -Raw).Trim(), "yyyyMMdd", $inv)
83
+ $count = [int](Get-Content "$PSScriptRoot\days.txt" -Raw).Trim()
84
+
85
+ $ol = New-Object -ComObject Outlook.Application
86
+ $folder = $ol.GetNamespace("MAPI").GetDefaultFolder(9) # olFolderCalendar
87
+
88
+ $days = [System.Collections.Generic.List[object]]::new()
89
+ for ($i = 0; $i -lt $count; $i++) {
90
+ $day = $start.AddDays($i)
91
+ $days.Add([ordered]@{ date = $day.ToString("yyyy-MM-dd", $inv); events = (Get-DayEvents $folder $day) })
92
+ }
93
+ [System.IO.File]::WriteAllText($outFile, (ConvertTo-CalendarJson $days), $utf8)
94
+ } catch {
95
+ [System.IO.File]::WriteAllText($errorFile, "ERROR: " + $_.Exception.Message, $utf8)
96
+ exit 1
97
+ }
98
+ }
99
+
100
+ if ($MyInvocation.InvocationName -ne '.') { Invoke-Main }
@@ -0,0 +1,151 @@
1
+ """Bridge from WSL to Outlook (classic) on Windows through PowerShell and COM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ from .events import Event, parse_days
15
+
16
+ PACKAGE_DIR = Path(__file__).resolve().parent
17
+ FETCH_TIMEOUT = 180 # seconds
18
+ RESTART_WAIT = 120 # seconds to wait for OUTLOOK.EXE to appear
19
+ RESTART_SETTLE = 15 # seconds to wait after it appears, before COM answers
20
+ COM_ERROR_RE = re.compile(
21
+ r"CO_E_SERVER_EXEC_FAILURE|80080005|800706BA|800706BE|80010108|RPC_E_", re.I
22
+ )
23
+
24
+ RESTART_HINT = (
25
+ "Outlook's COM connection seems broken. Ask the user whether Outlook may be closed and restarted "
26
+ "(unsaved drafts are lost); if they agree, call restart_outlook and then retry once."
27
+ )
28
+
29
+
30
+ class OutlookError(Exception):
31
+ """Fetching from Outlook failed. `restart` is True when restarting Outlook is likely to help."""
32
+
33
+ def __init__(self, message: str, restart: bool = False):
34
+ super().__init__(message)
35
+ self.restart = restart
36
+
37
+ def __str__(self) -> str:
38
+ msg = super().__str__()
39
+ return f"{msg}\n{RESTART_HINT}" if self.restart else msg
40
+
41
+
42
+ def _require(*commands: str) -> None:
43
+ missing = [c for c in commands if shutil.which(c) is None]
44
+ if missing:
45
+ raise OutlookError(
46
+ "calendula needs WSL with Outlook (classic) on Windows; not found: "
47
+ + ", ".join(missing)
48
+ )
49
+
50
+
51
+ def _winpath(path: Path) -> str:
52
+ return subprocess.run(
53
+ ["wslpath", "-w", str(path)], check=True, capture_output=True, text=True
54
+ ).stdout.strip()
55
+
56
+
57
+ def _run_powershell(script: Path, workdir: Path) -> None:
58
+ """Run a PowerShell script hidden (no console window) through wscript and run_hidden.vbs."""
59
+ ps1 = workdir / script.name
60
+ # Windows PowerShell 5.1 reads a BOM-less script as the ANSI code page; UTF-16LE with a BOM is safe.
61
+ ps1.write_bytes(
62
+ b"\xff\xfe" + script.read_text(encoding="utf-8").encode("utf-16-le")
63
+ )
64
+ vbs = workdir / "run_hidden.vbs"
65
+ shutil.copyfile(PACKAGE_DIR / "run_hidden.vbs", vbs)
66
+ try:
67
+ subprocess.run(
68
+ ["wscript.exe", "//B", "//Nologo", _winpath(vbs), _winpath(ps1)],
69
+ check=False,
70
+ timeout=FETCH_TIMEOUT,
71
+ capture_output=True,
72
+ )
73
+ except subprocess.TimeoutExpired:
74
+ raise OutlookError(
75
+ f"Outlook did not answer within {FETCH_TIMEOUT} seconds.", restart=True
76
+ ) from None
77
+
78
+
79
+ def read_result(workdir: Path) -> dict[date, list[Event]]:
80
+ """Read events.json / error.txt that get_calendar.ps1 left in workdir."""
81
+ error = workdir / "error.txt"
82
+ if error.exists():
83
+ text = error.read_text(encoding="utf-8-sig", errors="replace").strip()
84
+ raise OutlookError(
85
+ text or "ERROR: unknown failure", restart=bool(COM_ERROR_RE.search(text))
86
+ )
87
+ out = workdir / "events.json"
88
+ if not out.exists():
89
+ raise OutlookError(
90
+ "Outlook gave no answer (is Outlook (classic) running?).", restart=True
91
+ )
92
+ try:
93
+ return parse_days(json.loads(out.read_text(encoding="utf-8-sig")))
94
+ except (ValueError, KeyError, TypeError) as e:
95
+ raise OutlookError(f"Unexpected output from Outlook: {e}") from None
96
+
97
+
98
+ def fetch(start: date, days: int) -> dict[date, list[Event]]:
99
+ """Events for `days` days from `start`, straight from Outlook."""
100
+ _require("wslpath", "wscript.exe")
101
+ with tempfile.TemporaryDirectory(prefix="calendula-") as tmp:
102
+ workdir = Path(tmp)
103
+ (workdir / "start.txt").write_text(f"{start:%Y%m%d}\n", encoding="ascii")
104
+ (workdir / "days.txt").write_text(f"{days}\n", encoding="ascii")
105
+ _run_powershell(PACKAGE_DIR / "get_calendar.ps1", workdir)
106
+ return read_result(workdir)
107
+
108
+
109
+ def _outlook_running() -> bool:
110
+ r = subprocess.run(
111
+ ["tasklist.exe", "/fi", "IMAGENAME eq OUTLOOK.EXE", "/fo", "csv", "/nh"],
112
+ capture_output=True,
113
+ text=True,
114
+ check=False,
115
+ )
116
+ return '"outlook.exe"' in r.stdout.lower()
117
+
118
+
119
+ def restart() -> str:
120
+ """Force-quit Outlook and start it again. Returns what happened."""
121
+ _require("tasklist.exe", "taskkill.exe", "powershell.exe")
122
+ notes = []
123
+ if _outlook_running():
124
+ subprocess.run(
125
+ ["taskkill.exe", "/f", "/im", "OUTLOOK.EXE"],
126
+ capture_output=True,
127
+ check=False,
128
+ )
129
+ notes.append("Outlook was closed.")
130
+ # Starting it before the old process is gone hands the start over to that process.
131
+ for _ in range(10):
132
+ if not _outlook_running():
133
+ break
134
+ time.sleep(1)
135
+ else:
136
+ notes.append("Outlook was not running.")
137
+ subprocess.run(
138
+ ["powershell.exe", "-NoProfile", "-Command", "Start-Process outlook.exe"],
139
+ capture_output=True,
140
+ check=False,
141
+ )
142
+ deadline = time.monotonic() + RESTART_WAIT
143
+ while not _outlook_running():
144
+ if time.monotonic() > deadline:
145
+ raise OutlookError(
146
+ f"OUTLOOK.EXE did not start within {RESTART_WAIT} seconds."
147
+ )
148
+ time.sleep(3)
149
+ time.sleep(RESTART_SETTLE)
150
+ notes.append("Outlook was started again.")
151
+ return " ".join(notes)
@@ -0,0 +1,2 @@
1
+ Set shell = CreateObject("WScript.Shell")
2
+ shell.Run "powershell.exe -ExecutionPolicy Bypass -NonInteractive -File """ & WScript.Arguments(0) & """", 0, True
@@ -0,0 +1,115 @@
1
+ """The MCP server: tools that read the Outlook calendar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from datetime import date, datetime
7
+ from typing import Annotated
8
+
9
+ from mcp.server import MCPServer
10
+ from mcp.server.mcpserver.exceptions import ToolError
11
+ from mcp.types import ToolAnnotations
12
+ from pydantic import Field
13
+
14
+ from . import __version__, outlook
15
+ from .cache import DEFAULT_TTL, MAX_DAYS, Calendar
16
+ from .events import day_heading, parse_date, parse_time, pick
17
+
18
+ INSTRUCTIONS = f"""\
19
+ Reads the user's Outlook (classic) calendar on Windows, from WSL. Read-only.
20
+
21
+ - Events for today and later are reused for up to {int(DEFAULT_TTL.total_seconds() // 60)} minutes \
22
+ after they were fetched; past days are fetched once per session. Every answer says when it was fetched.
23
+ Pass refresh=true when the user says the calendar has just changed, or when an exact, current answer matters.
24
+ - Dates are YYYY-MM-DD in the user's local time. Turn words like "tomorrow" or "next Monday" into dates first.
25
+ - If fetching fails, report the error. Never fill in events from memory or from older answers.
26
+ - If the error says Outlook's COM connection is broken, ask the user for permission before calling \
27
+ restart_outlook (it force-quits Outlook and unsaved drafts are lost), then retry once. Do not restart repeatedly.
28
+ """
29
+
30
+ DateArg = Annotated[
31
+ str | None,
32
+ Field(description="Day as YYYY-MM-DD (or MM-DD for this year). Default: today."),
33
+ ]
34
+ RefreshArg = Annotated[
35
+ bool, Field(description="Fetch from Outlook even if a recent copy is cached.")
36
+ ]
37
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)
38
+
39
+
40
+ def build_server(
41
+ calendar: Calendar | None = None, restart: Callable[[], str] = outlook.restart
42
+ ) -> MCPServer:
43
+ cal = calendar or Calendar(outlook.fetch)
44
+ server = MCPServer("calendula", version=__version__, instructions=INSTRUCTIONS)
45
+
46
+ def today() -> date:
47
+ return datetime.now().date()
48
+
49
+ @server.tool(annotations=READ_ONLY)
50
+ def list_events(
51
+ date: DateArg = None,
52
+ days: Annotated[
53
+ int, Field(ge=1, le=MAX_DAYS, description="Number of days from `date`.")
54
+ ] = 1,
55
+ refresh: RefreshArg = False,
56
+ ) -> str:
57
+ """List events (time, subject, location) day by day. Use get_event for attendees and the body."""
58
+ try:
59
+ fetched = cal.days(parse_date(date, today()), days, refresh)
60
+ except (ValueError, outlook.OutlookError) as e:
61
+ raise ToolError(str(e)) from None
62
+ blocks = []
63
+ for day in fetched:
64
+ lines = [day_heading(day.date, day.fetched)]
65
+ lines += [e.summary(day.date) for e in day.events] or ["(no events)"]
66
+ blocks.append("\n".join(lines))
67
+ return "\n\n".join(blocks)
68
+
69
+ @server.tool(annotations=READ_ONLY)
70
+ def get_event(
71
+ time: Annotated[str, Field(description="Time as HH:MM (24-hour).")],
72
+ date: DateArg = None,
73
+ refresh: RefreshArg = False,
74
+ ) -> str:
75
+ """Details of the event at a time: location, organizer, attendees, status, and the body.
76
+
77
+ If no event is running at that time, returns the timed event that starts nearest to it (and says so).
78
+ """
79
+ try:
80
+ at = parse_time(time)
81
+ (day,) = cal.days(parse_date(date, today()), 1, refresh)
82
+ except (ValueError, outlook.OutlookError) as e:
83
+ raise ToolError(str(e)) from None
84
+ event, running = pick(day.events, datetime.combine(day.date, at))
85
+ head = day_heading(day.date, day.fetched)
86
+ if event is None:
87
+ return f"{head}\nNo timed events on this day."
88
+ note = (
89
+ ""
90
+ if running
91
+ else f"No event is running at {at:%H:%M}; this is the one starting nearest to it.\n"
92
+ )
93
+ return f"{head}\n{note}{event.detail(day.date)}"
94
+
95
+ @server.tool(
96
+ annotations=ToolAnnotations(
97
+ readOnlyHint=False,
98
+ destructiveHint=True,
99
+ idempotentHint=False,
100
+ openWorldHint=False,
101
+ )
102
+ )
103
+ def restart_outlook() -> str:
104
+ """Force-quit Outlook (classic) and start it again, to recover a broken COM connection.
105
+
106
+ Unsaved drafts are lost. Call it only after the user has agreed, and only after a fetch failed with a COM error.
107
+ """
108
+ try:
109
+ result = restart()
110
+ except outlook.OutlookError as e:
111
+ raise ToolError(str(e)) from None
112
+ cal.clear()
113
+ return result
114
+
115
+ return server