ical-mcp 0.2.2__py3-none-any.whl
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.
- ical/__init__.py +1 -0
- ical/agent.py +108 -0
- ical/calendar.py +200 -0
- ical/cli.py +257 -0
- ical/mcp.py +63 -0
- ical_mcp-0.2.2.dist-info/METADATA +113 -0
- ical_mcp-0.2.2.dist-info/RECORD +10 -0
- ical_mcp-0.2.2.dist-info/WHEEL +4 -0
- ical_mcp-0.2.2.dist-info/entry_points.txt +3 -0
- ical_mcp-0.2.2.dist-info/licenses/LICENSE +21 -0
ical/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
ical/agent.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
def build_schema() -> dict:
|
|
4
|
+
return {
|
|
5
|
+
"name": "ical",
|
|
6
|
+
"description": "macOS Calendar.app CLI and MCP tool with bounded operations and verified invite creation",
|
|
7
|
+
"apple_script_timeout_seconds": 20,
|
|
8
|
+
"commands": {
|
|
9
|
+
"calendars": {
|
|
10
|
+
"description": "List available calendars",
|
|
11
|
+
"args": []
|
|
12
|
+
},
|
|
13
|
+
"list": {
|
|
14
|
+
"description": "List events in a calendar; Calendar automation errors are reported, never returned as an empty calendar",
|
|
15
|
+
"flags": {
|
|
16
|
+
"--calendar": "Calendar name (default: Calendar)",
|
|
17
|
+
"--json": "Output JSON"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"show": {
|
|
21
|
+
"description": "Read one event by Calendar ID, including invitee RSVP status; use to verify an uncertain write",
|
|
22
|
+
"flags": {
|
|
23
|
+
"--id": "Calendar event ID (required)",
|
|
24
|
+
"--calendar": "Calendar name containing the event (default: Calendar)",
|
|
25
|
+
"--json": "Return a machine-readable verified result"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"add": {
|
|
29
|
+
"description": "Create an event, check for conflicts, invite attendees, then read the event back by ID before reporting success",
|
|
30
|
+
"flags": {
|
|
31
|
+
"--title": "Event title (required)",
|
|
32
|
+
"--start": "Start time string (required)",
|
|
33
|
+
"--end": "End time string (required)",
|
|
34
|
+
"--calendar": "Calendar name",
|
|
35
|
+
"--attendee": "Email address of attendee (repeatable)",
|
|
36
|
+
"--description": "Event description",
|
|
37
|
+
"--location": "Event location",
|
|
38
|
+
"--no-check-conflict": "Disable conflict detection",
|
|
39
|
+
"--json": "Return a machine-readable verified result"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"mcp": {
|
|
43
|
+
"description": "Run FastMCP stdio server"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"add_result": {
|
|
47
|
+
"status": ["verified", "created_unverified", "write_outcome_unknown", "not_created"],
|
|
48
|
+
"event": {
|
|
49
|
+
"fields": ["id", "title", "start", "end", "calendar", "attendees"],
|
|
50
|
+
"start": "ISO 8601 string",
|
|
51
|
+
"end": "ISO 8601 string",
|
|
52
|
+
"attendees": {"fields": ["email", "status"], "status_values": ["accepted", "declined", "tentative", "unknown"]}
|
|
53
|
+
},
|
|
54
|
+
"unverified_fields": ["status", "id", "error"],
|
|
55
|
+
"write_outcome_unknown_fields": ["status", "error"],
|
|
56
|
+
"not_created_fields": ["status", "stage", "error"],
|
|
57
|
+
"show_result": {
|
|
58
|
+
"success": {"status": "verified", "event": "same event object as add"},
|
|
59
|
+
"failure": {"status": "verification_failed", "id": "requested event ID", "error": "reason"}
|
|
60
|
+
},
|
|
61
|
+
"note": "Calendar attendee status describes RSVP state. An unknown status means no response is recorded; it does not mean accepted. created_unverified means the write may have succeeded but read-back failed; preserve its ID and check with `ical show`. write_outcome_unknown means event creation timed out before its ID was returned; search the calendar with `ical list` before retrying."
|
|
62
|
+
},
|
|
63
|
+
"errors": {
|
|
64
|
+
"timeout": "Calendar automation exceeded its bounded timeout. The outcome of a write may be uncertain.",
|
|
65
|
+
"created_unverified": "The event may have been created, but read-back did not confirm it. Preserve the event ID and inspect Calendar before retrying.",
|
|
66
|
+
"write_outcome_unknown": "Event creation timed out before an ID was returned. The write may have succeeded. Search with `ical list`; do not retry until you confirm the event is absent.",
|
|
67
|
+
"not_created": "The conflict check timed out before the event write began. No event was created; retry after Calendar responds.",
|
|
68
|
+
"verification_failed": "Read-only event lookup failed. The CLI returns the event ID and error; check Calendar and retry the read, not the create.",
|
|
69
|
+
"conflict": "An overlapping event was found; no event was created.",
|
|
70
|
+
"calendar_error": "Calendar automation failed. The CLI returns a nonzero exit status and writes the error to stderr."
|
|
71
|
+
},
|
|
72
|
+
"retry_policy": "After write_outcome_unknown, use `ical list` to search for the event. After created_unverified, use `ical show --id ID --calendar CALENDAR`. Do not retry either write until confirming the event is absent. A not_created conflict-check timeout is safe to retry after Calendar responds."
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def build_guide() -> str:
|
|
76
|
+
return """# ical Agent Guide
|
|
77
|
+
|
|
78
|
+
`ical` is an LLM-native CLI tool for macOS Calendar.app.
|
|
79
|
+
|
|
80
|
+
## Common Operations:
|
|
81
|
+
- List calendars: `ical calendars`
|
|
82
|
+
- List events in calendar: `ical list -c you@example.com --json`
|
|
83
|
+
- Read one event by its Calendar ID: `ical show --id EVENT_ID --calendar you@example.com --json`
|
|
84
|
+
- Add and verify an event with conflict check & invites:
|
|
85
|
+
`ical add -t "Meeting" -s "Monday, August 10, 2026 at 1:00 PM" -e "Monday, August 10, 2026 at 2:00 PM" -c "you@example.com" -a "user@example.com"`
|
|
86
|
+
- Get a machine-readable verified result:
|
|
87
|
+
`ical add -t "Meeting" -s "Monday, August 10, 2026 at 1:00 PM" -e "Monday, August 10, 2026 at 2:00 PM" -c "you@example.com" -a "user@example.com" --json`
|
|
88
|
+
- Launch FastMCP stdio server for tool calling: `ical-mcp`
|
|
89
|
+
- Compatibility alias: `ical mcp`
|
|
90
|
+
|
|
91
|
+
`ical add` only reports verified success after reading the created event back
|
|
92
|
+
by its Calendar ID. The JSON success object has `status: "verified"` and an
|
|
93
|
+
`event` with `id`, `title`, ISO 8601 `start` and `end`, `calendar`, and
|
|
94
|
+
`attendees`; each attendee has an `email` and RSVP `status`. An `unknown` RSVP means no response
|
|
95
|
+
has been recorded, not that the invite was accepted.
|
|
96
|
+
|
|
97
|
+
Calendar automation calls have a 20-second timeout. If the conflict check times
|
|
98
|
+
out, `ical add` returns `not_created`; no event write was attempted, so retry
|
|
99
|
+
after Calendar responds. If the create write times out before
|
|
100
|
+
Calendar returns an event ID, JSON reports `status: "write_outcome_unknown"`
|
|
101
|
+
with an error. Search with `ical list` before retrying. If creation returns an
|
|
102
|
+
ID but read-back fails, the result is `created_unverified`; inspect it with
|
|
103
|
+
`ical show --id EVENT_ID --calendar CALENDAR`. **Do not retry either write
|
|
104
|
+
until confirming the event is absent**, to avoid a duplicate invite. `ical
|
|
105
|
+
show` is read-only and returns the same verified event envelope as `ical add`.
|
|
106
|
+
Calendar read failures are errors rather than empty event lists. See `ical
|
|
107
|
+
agent schema` for the machine-readable result and error contract.
|
|
108
|
+
"""
|
ical/calendar.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import datetime
|
|
3
|
+
from dateutil import parser
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
APPLE_SCRIPT_TIMEOUT_SECONDS = 20
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CalendarAutomationError(RuntimeError):
|
|
11
|
+
"""Calendar.app automation returned an error."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CalendarTimeoutError(CalendarAutomationError):
|
|
15
|
+
"""Calendar.app did not respond within the bounded operation time."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, write_may_have_succeeded: bool = False):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.write_may_have_succeeded = write_may_have_succeeded
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EventNotFoundError(CalendarAutomationError):
|
|
23
|
+
"""No event with the requested ID exists in the selected calendar."""
|
|
24
|
+
|
|
25
|
+
def _escape_applescript(s: str) -> str:
|
|
26
|
+
"""Escape backslashes and double quotes so a value can be safely embedded
|
|
27
|
+
inside a double-quoted AppleScript string literal."""
|
|
28
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
29
|
+
|
|
30
|
+
def run_applescript(
|
|
31
|
+
script: str,
|
|
32
|
+
operation: str = "Calendar operation",
|
|
33
|
+
*,
|
|
34
|
+
write_may_have_succeeded: bool = False,
|
|
35
|
+
) -> str:
|
|
36
|
+
try:
|
|
37
|
+
res = subprocess.run(
|
|
38
|
+
["osascript", "-e", script],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
timeout=APPLE_SCRIPT_TIMEOUT_SECONDS,
|
|
42
|
+
)
|
|
43
|
+
except subprocess.TimeoutExpired as err:
|
|
44
|
+
raise CalendarTimeoutError(
|
|
45
|
+
f"{operation} timed out after {APPLE_SCRIPT_TIMEOUT_SECONDS}s. "
|
|
46
|
+
"Calendar may still be processing; check the calendar before retrying.",
|
|
47
|
+
write_may_have_succeeded=write_may_have_succeeded,
|
|
48
|
+
) from err
|
|
49
|
+
if res.returncode != 0:
|
|
50
|
+
detail = res.stderr.strip() or "Calendar.app returned an unspecified AppleScript error"
|
|
51
|
+
raise CalendarAutomationError(f"{operation} failed: {detail}")
|
|
52
|
+
return res.stdout.strip()
|
|
53
|
+
|
|
54
|
+
def parse_date(date_str: str) -> datetime.datetime:
|
|
55
|
+
return parser.parse(date_str)
|
|
56
|
+
|
|
57
|
+
def get_events(calendar: str = "Calendar") -> List[Dict[str, Any]]:
|
|
58
|
+
# Bulk-fetch each property across all events in one Apple Event call each
|
|
59
|
+
# (summary of every event / start date of every event / ...) instead of
|
|
60
|
+
# looping "repeat with e in evts" and querying properties per-event.
|
|
61
|
+
# The per-event loop round-trips to Calendar.app 2-3x per event and is
|
|
62
|
+
# catastrophically slow (multi-second-per-event, effectively hangs) on
|
|
63
|
+
# any calendar with more than a handful of events - a well-known macOS
|
|
64
|
+
# Calendar AppleScript scripting-bridge performance bug.
|
|
65
|
+
script = f'''
|
|
66
|
+
tell application "Calendar"
|
|
67
|
+
tell calendar "{_escape_applescript(calendar)}"
|
|
68
|
+
set summaries to summary of every event
|
|
69
|
+
set starts to start date of every event
|
|
70
|
+
set ends to end date of every event
|
|
71
|
+
end tell
|
|
72
|
+
end tell
|
|
73
|
+
set res to ""
|
|
74
|
+
repeat with i from 1 to (count of summaries)
|
|
75
|
+
set res to res & (item i of summaries) & " | " & ((item i of starts) as string) & " | " & ((item i of ends) as string) & "\n"
|
|
76
|
+
end repeat
|
|
77
|
+
return res
|
|
78
|
+
'''
|
|
79
|
+
output = run_applescript(script, operation=f"Reading events from calendar {calendar!r}")
|
|
80
|
+
events = []
|
|
81
|
+
for line in output.splitlines():
|
|
82
|
+
if line.strip():
|
|
83
|
+
parts = line.split(" | ")
|
|
84
|
+
if len(parts) == 3:
|
|
85
|
+
try:
|
|
86
|
+
start_dt = parse_date(parts[1])
|
|
87
|
+
end_dt = parse_date(parts[2])
|
|
88
|
+
events.append({
|
|
89
|
+
"title": parts[0],
|
|
90
|
+
"start": start_dt,
|
|
91
|
+
"end": end_dt,
|
|
92
|
+
"start_str": parts[1],
|
|
93
|
+
"end_str": parts[2]
|
|
94
|
+
})
|
|
95
|
+
except (ValueError, OverflowError):
|
|
96
|
+
continue
|
|
97
|
+
return events
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_event_details(calendar: str, event_id: str) -> Dict[str, Any]:
|
|
101
|
+
"""Read one event by ID, including invitee participation when available."""
|
|
102
|
+
separator = "ASCII character 31"
|
|
103
|
+
script = f'''
|
|
104
|
+
tell application "Calendar"
|
|
105
|
+
tell calendar "{_escape_applescript(calendar)}"
|
|
106
|
+
set matchingEvents to every event whose id is "{_escape_applescript(event_id)}"
|
|
107
|
+
if (count of matchingEvents) is 0 then return ""
|
|
108
|
+
set targetEvent to item 1 of matchingEvents
|
|
109
|
+
set fields to {{id of targetEvent as text, summary of targetEvent as text, ¬
|
|
110
|
+
start date of targetEvent as text, end date of targetEvent as text}}
|
|
111
|
+
set resultText to ""
|
|
112
|
+
repeat with fieldValue in fields
|
|
113
|
+
if resultText is not "" then set resultText to resultText & linefeed
|
|
114
|
+
set resultText to resultText & (contents of fieldValue)
|
|
115
|
+
end repeat
|
|
116
|
+
repeat with guest in attendees of targetEvent
|
|
117
|
+
set resultText to resultText & linefeed & (email of guest as text) & {separator} & ¬
|
|
118
|
+
(participation status of guest as text)
|
|
119
|
+
end repeat
|
|
120
|
+
return resultText
|
|
121
|
+
end tell
|
|
122
|
+
end tell
|
|
123
|
+
'''
|
|
124
|
+
output = run_applescript(script, operation=f"Verifying event {event_id!r}")
|
|
125
|
+
lines = output.splitlines()
|
|
126
|
+
if len(lines) < 4:
|
|
127
|
+
raise EventNotFoundError(
|
|
128
|
+
f"Event {event_id!r} was not found in calendar {calendar!r}."
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
start_dt = parse_date(lines[2])
|
|
132
|
+
end_dt = parse_date(lines[3])
|
|
133
|
+
except (ValueError, OverflowError) as err:
|
|
134
|
+
raise CalendarAutomationError(
|
|
135
|
+
f"Calendar returned invalid dates while verifying event {event_id!r}."
|
|
136
|
+
) from err
|
|
137
|
+
attendees = []
|
|
138
|
+
for line in lines[4:]:
|
|
139
|
+
if chr(31) not in line:
|
|
140
|
+
continue
|
|
141
|
+
email, status = line.split(chr(31), 1)
|
|
142
|
+
attendees.append({"email": email, "status": status})
|
|
143
|
+
return {
|
|
144
|
+
"id": lines[0],
|
|
145
|
+
"title": lines[1],
|
|
146
|
+
"start": start_dt,
|
|
147
|
+
"end": end_dt,
|
|
148
|
+
"start_str": lines[2],
|
|
149
|
+
"end_str": lines[3],
|
|
150
|
+
"calendar": calendar,
|
|
151
|
+
"attendees": attendees,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def check_conflicts(calendar: str, start_dt: datetime.datetime, end_dt: datetime.datetime) -> List[Dict[str, Any]]:
|
|
155
|
+
existing = get_events(calendar)
|
|
156
|
+
conflicts = []
|
|
157
|
+
for evt in existing:
|
|
158
|
+
# Check overlap: start1 < end2 and start2 < end1
|
|
159
|
+
if evt["start"] < end_dt and start_dt < evt["end"]:
|
|
160
|
+
conflicts.append(evt)
|
|
161
|
+
return conflicts
|
|
162
|
+
|
|
163
|
+
def add_event(
|
|
164
|
+
title: str,
|
|
165
|
+
start: str,
|
|
166
|
+
end: str,
|
|
167
|
+
calendar: str = "Calendar",
|
|
168
|
+
attendee: Optional[List[str]] = None,
|
|
169
|
+
description: str = "",
|
|
170
|
+
location: str = "",
|
|
171
|
+
check_conflict: bool = True
|
|
172
|
+
) -> Dict[str, Any]:
|
|
173
|
+
start_dt = parse_date(start)
|
|
174
|
+
end_dt = parse_date(end)
|
|
175
|
+
|
|
176
|
+
if check_conflict:
|
|
177
|
+
conflicts = check_conflicts(calendar, start_dt, end_dt)
|
|
178
|
+
if conflicts:
|
|
179
|
+
conflict_titles = ", ".join([c["title"] for c in conflicts])
|
|
180
|
+
raise ValueError(f"Conflict detected with existing event(s): {conflict_titles}")
|
|
181
|
+
|
|
182
|
+
attendee_script = ""
|
|
183
|
+
if attendee:
|
|
184
|
+
for email in attendee:
|
|
185
|
+
attendee_script += f'\n\t\t\tmake new attendee at end of attendees of newEvt with properties {{email:"{_escape_applescript(email)}"}}'
|
|
186
|
+
|
|
187
|
+
script = f'''
|
|
188
|
+
tell application "Calendar"
|
|
189
|
+
tell calendar "{_escape_applescript(calendar)}"
|
|
190
|
+
set newEvt to make new event with properties {{summary:"{_escape_applescript(title)}", start date:date "{start}", end date:date "{end}", description:"{_escape_applescript(description)}", location:"{_escape_applescript(location)}"}}{attendee_script}
|
|
191
|
+
return id of newEvt
|
|
192
|
+
end tell
|
|
193
|
+
end tell
|
|
194
|
+
'''
|
|
195
|
+
event_id = run_applescript(
|
|
196
|
+
script,
|
|
197
|
+
operation=f"Creating event {title!r}",
|
|
198
|
+
write_may_have_succeeded=True,
|
|
199
|
+
)
|
|
200
|
+
return {"id": event_id, "title": title, "start": start, "end": end, "calendar": calendar}
|
ical/cli.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Command line interface for macOS Calendar.app."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import datetime
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from ical import __version__
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
Examples:
|
|
16
|
+
|
|
17
|
+
ical calendars
|
|
18
|
+
ical list --calendar "you@example.com" --json
|
|
19
|
+
ical add --title "Mock Interview" --start "Monday, Aug 10, 2026 at 1:00 PM" --end "Monday, Aug 10, 2026 at 2:00 PM" -a user@example.com
|
|
20
|
+
ical-mcp
|
|
21
|
+
|
|
22
|
+
`ical mcp` remains a compatibility alias for `ical-mcp`.
|
|
23
|
+
Agents: run `ical agent guide` for a playbook, `ical agent schema` for JSON contract.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="ical",
|
|
28
|
+
help="macOS Calendar.app CLI and MCP server.",
|
|
29
|
+
epilog=EPILOG,
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
add_completion=False,
|
|
32
|
+
rich_markup_mode="rich",
|
|
33
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
34
|
+
)
|
|
35
|
+
agent_app = typer.Typer(
|
|
36
|
+
name="agent",
|
|
37
|
+
help="Show the machine-readable schema and playbook.",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
add_completion=False,
|
|
40
|
+
rich_markup_mode="rich",
|
|
41
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
42
|
+
)
|
|
43
|
+
app.add_typer(agent_app, name="agent")
|
|
44
|
+
|
|
45
|
+
console = Console()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _event_json(event: dict) -> dict:
|
|
49
|
+
"""Return the stable, machine-readable fields shared by add and show."""
|
|
50
|
+
return {
|
|
51
|
+
key: value.isoformat() if isinstance(value, datetime.datetime) else value
|
|
52
|
+
for key, value in event.items()
|
|
53
|
+
if key in {"id", "title", "start", "end", "calendar", "attendees"}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_event(event: dict, label: str) -> None:
|
|
58
|
+
start_text = event.get("start_str", str(event["start"]))
|
|
59
|
+
end_text = event.get("end_str", str(event["end"]))
|
|
60
|
+
console.print(
|
|
61
|
+
f"[bold green]{label}.[/bold green] "
|
|
62
|
+
f"{event['title']} — {start_text} to {end_text} ([cyan]{event['calendar']}[/cyan])"
|
|
63
|
+
)
|
|
64
|
+
attendees = event.get("attendees", [])
|
|
65
|
+
if attendees:
|
|
66
|
+
for guest in attendees:
|
|
67
|
+
status = guest.get("status") or "status unavailable"
|
|
68
|
+
console.print(f" Invitee: {guest['email']} ({status})")
|
|
69
|
+
else:
|
|
70
|
+
console.print(" Invitees: none")
|
|
71
|
+
console.print(f" ID: {event['id']}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("calendars")
|
|
75
|
+
def list_calendars() -> None:
|
|
76
|
+
"""List available calendars in Calendar.app."""
|
|
77
|
+
from ical.calendar import run_applescript
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
calendars = run_applescript(
|
|
81
|
+
'tell application "Calendar" to get name of calendars'
|
|
82
|
+
).split(", ")
|
|
83
|
+
except Exception as err:
|
|
84
|
+
typer.echo(f"Error listing calendars: {err}", err=True)
|
|
85
|
+
raise typer.Exit(code=1) from err
|
|
86
|
+
|
|
87
|
+
table = Table(title="Calendars")
|
|
88
|
+
table.add_column("Name", style="cyan")
|
|
89
|
+
for calendar in calendars:
|
|
90
|
+
table.add_row(calendar)
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("add")
|
|
95
|
+
def add_event(
|
|
96
|
+
title: Annotated[str, typer.Option("--title", "-t", help="Event title")],
|
|
97
|
+
start: Annotated[str, typer.Option("--start", "-s", help="Start date and time")],
|
|
98
|
+
end: Annotated[str, typer.Option("--end", "-e", help="End date and time")],
|
|
99
|
+
calendar: Annotated[str, typer.Option("--calendar", "-c", help="Calendar name")] = "Calendar",
|
|
100
|
+
attendee: Annotated[list[str] | None, typer.Option("--attendee", "-a", help="Attendee email; repeat for multiple attendees")] = None,
|
|
101
|
+
description: Annotated[str, typer.Option("--description", "-d", help="Event description")] = "",
|
|
102
|
+
location: Annotated[str, typer.Option("--location", "-l", help="Event location")] = "",
|
|
103
|
+
check_conflict: Annotated[bool, typer.Option("--check-conflict/--no-check-conflict", help="Check for overlapping events")] = True,
|
|
104
|
+
as_json: Annotated[bool, typer.Option("--json", "-j", help="Emit verified event as JSON")] = False,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Create an event, invite attendees, and verify it was saved."""
|
|
107
|
+
from ical.calendar import CalendarTimeoutError, add_event as core_add_event, get_event_details
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = core_add_event(
|
|
111
|
+
title=title,
|
|
112
|
+
start=start,
|
|
113
|
+
end=end,
|
|
114
|
+
calendar=calendar,
|
|
115
|
+
attendee=attendee,
|
|
116
|
+
description=description,
|
|
117
|
+
location=location,
|
|
118
|
+
check_conflict=check_conflict,
|
|
119
|
+
)
|
|
120
|
+
except CalendarTimeoutError as err:
|
|
121
|
+
if err.write_may_have_succeeded:
|
|
122
|
+
status = "write_outcome_unknown"
|
|
123
|
+
details = {"status": status, "error": str(err)}
|
|
124
|
+
message = (
|
|
125
|
+
"Create request timed out; the write outcome is unknown. "
|
|
126
|
+
"Do not retry yet. Check Calendar.app for the event first. "
|
|
127
|
+
f"Details: {err}"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
status = "not_created"
|
|
131
|
+
details = {"status": status, "stage": "conflict_check", "error": str(err)}
|
|
132
|
+
message = (
|
|
133
|
+
"Calendar conflict check timed out. No event was created. "
|
|
134
|
+
"You can retry after Calendar responds. "
|
|
135
|
+
f"Details: {err}"
|
|
136
|
+
)
|
|
137
|
+
if as_json:
|
|
138
|
+
typer.echo(json.dumps(details))
|
|
139
|
+
else:
|
|
140
|
+
typer.echo(message, err=True)
|
|
141
|
+
raise typer.Exit(code=1) from err
|
|
142
|
+
except (ValueError, RuntimeError) as err:
|
|
143
|
+
typer.echo(f"Could not create event: {err}", err=True)
|
|
144
|
+
raise typer.Exit(code=1) from err
|
|
145
|
+
except Exception as err:
|
|
146
|
+
typer.echo(f"Unexpected error creating event: {err}", err=True)
|
|
147
|
+
raise typer.Exit(code=1) from err
|
|
148
|
+
|
|
149
|
+
# The create call may have succeeded even if Calendar.app stops responding
|
|
150
|
+
# during read-back. Never repeat the write automatically in that case.
|
|
151
|
+
try:
|
|
152
|
+
event = get_event_details(calendar, result["id"])
|
|
153
|
+
except Exception as err:
|
|
154
|
+
message = (
|
|
155
|
+
f"Event was created, but verification failed (ID: {result['id']}). "
|
|
156
|
+
f"Do not retry add; verify it with `ical show --id {result['id']} "
|
|
157
|
+
f"--calendar {calendar}`. Details: {err}"
|
|
158
|
+
)
|
|
159
|
+
if as_json:
|
|
160
|
+
typer.echo(json.dumps({"status": "created_unverified", "id": result["id"], "error": str(err)}))
|
|
161
|
+
else:
|
|
162
|
+
typer.echo(message, err=True)
|
|
163
|
+
raise typer.Exit(code=1) from err
|
|
164
|
+
|
|
165
|
+
if as_json:
|
|
166
|
+
typer.echo(json.dumps({"status": "verified", "event": _event_json(event)}, ensure_ascii=False))
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
_print_event(event, "Event created and verified")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("show")
|
|
173
|
+
def show_event(
|
|
174
|
+
event_id: Annotated[str, typer.Option("--id", help="Event ID returned by `ical add`")],
|
|
175
|
+
calendar: Annotated[str, typer.Option("--calendar", "-c", help="Calendar name")] = "Calendar",
|
|
176
|
+
as_json: Annotated[bool, typer.Option("--json", "-j", help="Emit verified event as JSON")] = False,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Read and verify one existing event by ID."""
|
|
179
|
+
from ical.calendar import get_event_details
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
event = get_event_details(calendar, event_id)
|
|
183
|
+
except Exception as err:
|
|
184
|
+
if as_json:
|
|
185
|
+
typer.echo(json.dumps({"status": "verification_failed", "id": event_id, "error": str(err)}))
|
|
186
|
+
else:
|
|
187
|
+
typer.echo(f"Could not verify event {event_id!r}: {err}", err=True)
|
|
188
|
+
raise typer.Exit(code=1) from err
|
|
189
|
+
|
|
190
|
+
if as_json:
|
|
191
|
+
typer.echo(json.dumps({"status": "verified", "event": _event_json(event)}, ensure_ascii=False))
|
|
192
|
+
return
|
|
193
|
+
_print_event(event, "Event verified")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command("list")
|
|
197
|
+
def list_events(
|
|
198
|
+
calendar: Annotated[str, typer.Option("--calendar", "-c", help="Calendar name")] = "Calendar",
|
|
199
|
+
as_json: Annotated[bool, typer.Option("--json", "-j", help="Emit JSON output")] = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""List events in a calendar."""
|
|
202
|
+
from ical.calendar import get_events
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
events = get_events(calendar)
|
|
206
|
+
json_events = [
|
|
207
|
+
{"title": event["title"], "start": event["start_str"], "end": event["end_str"]}
|
|
208
|
+
for event in events
|
|
209
|
+
]
|
|
210
|
+
if as_json:
|
|
211
|
+
typer.echo(json.dumps({"calendar": calendar, "events": json_events}, ensure_ascii=False))
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
table = Table(title=f"Events in {calendar}")
|
|
215
|
+
table.add_column("Title", style="bold white")
|
|
216
|
+
table.add_column("Start", style="green")
|
|
217
|
+
table.add_column("End", style="yellow")
|
|
218
|
+
for event in events:
|
|
219
|
+
table.add_row(event["title"], event["start_str"], event["end_str"])
|
|
220
|
+
console.print(table)
|
|
221
|
+
except Exception as err:
|
|
222
|
+
typer.echo(f"Error listing events: {err}", err=True)
|
|
223
|
+
raise typer.Exit(code=1) from err
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command("mcp")
|
|
227
|
+
def serve_mcp() -> None:
|
|
228
|
+
"""Run the FastMCP server over stdio."""
|
|
229
|
+
from ical.mcp import run_server
|
|
230
|
+
|
|
231
|
+
run_server()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@agent_app.command("schema")
|
|
235
|
+
def agent_schema_cmd() -> None:
|
|
236
|
+
"""Print the JSON command contract for agents."""
|
|
237
|
+
from ical.agent import build_schema
|
|
238
|
+
|
|
239
|
+
console.print_json(data=build_schema())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@agent_app.command("guide")
|
|
243
|
+
def agent_guide_cmd() -> None:
|
|
244
|
+
"""Print the Markdown agent playbook."""
|
|
245
|
+
from ical.agent import build_guide
|
|
246
|
+
|
|
247
|
+
sys.stdout.write(build_guide())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("version")
|
|
251
|
+
def version() -> None:
|
|
252
|
+
"""Print the installed version."""
|
|
253
|
+
typer.echo(f"ical {__version__}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
app()
|
ical/mcp.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
try:
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
except ImportError:
|
|
8
|
+
from mcp.server.mcpserver import MCPServer as FastMCP
|
|
9
|
+
from ical.calendar import add_event as core_add_event, get_events as core_get_events, run_applescript
|
|
10
|
+
|
|
11
|
+
mcp = FastMCP("ical")
|
|
12
|
+
|
|
13
|
+
@mcp.tool()
|
|
14
|
+
def list_calendars() -> List[str]:
|
|
15
|
+
"""List available calendars in macOS Calendar.app."""
|
|
16
|
+
script = 'tell application "Calendar" to get name of calendars'
|
|
17
|
+
return run_applescript(script).split(", ")
|
|
18
|
+
|
|
19
|
+
@mcp.tool()
|
|
20
|
+
def list_events(calendar: str = "Calendar") -> List[dict]:
|
|
21
|
+
"""List events in a calendar."""
|
|
22
|
+
events = core_get_events(calendar)
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
"title": e["title"],
|
|
26
|
+
"start": e["start_str"],
|
|
27
|
+
"end": e["end_str"]
|
|
28
|
+
} for e in events
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def add_calendar_event(
|
|
33
|
+
title: str,
|
|
34
|
+
start: str,
|
|
35
|
+
end: str,
|
|
36
|
+
calendar: str = "Calendar",
|
|
37
|
+
attendees: Optional[List[str]] = None,
|
|
38
|
+
description: str = "",
|
|
39
|
+
location: str = "",
|
|
40
|
+
check_conflict: bool = True
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""Add a calendar event with automatic conflict detection and attendee invites."""
|
|
43
|
+
try:
|
|
44
|
+
return core_add_event(
|
|
45
|
+
title=title,
|
|
46
|
+
start=start,
|
|
47
|
+
end=end,
|
|
48
|
+
calendar=calendar,
|
|
49
|
+
attendee=attendees,
|
|
50
|
+
description=description,
|
|
51
|
+
location=location,
|
|
52
|
+
check_conflict=check_conflict
|
|
53
|
+
)
|
|
54
|
+
except ValueError as err:
|
|
55
|
+
return {"error": str(err), "status": "conflict_detected"}
|
|
56
|
+
except Exception as err:
|
|
57
|
+
return {"error": str(err), "status": "failed"}
|
|
58
|
+
|
|
59
|
+
def run_server():
|
|
60
|
+
mcp.run(transport="stdio")
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
run_server()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ical-mcp
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: macOS Calendar.app CLI and MCP server — list calendars, schedule events, invite attendees
|
|
5
|
+
Project-URL: Homepage, https://github.com/ml-lubich/ical
|
|
6
|
+
Project-URL: Repository, https://github.com/ml-lubich/ical
|
|
7
|
+
Project-URL: Issues, https://github.com/ml-lubich/ical/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: applescript,calendar,cli,macos,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: mcp>=1.0.0
|
|
21
|
+
Requires-Dist: python-dateutil>=2.8.2
|
|
22
|
+
Requires-Dist: rich>=13.0.0
|
|
23
|
+
Requires-Dist: typer>=0.9.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# ical CLI
|
|
30
|
+
|
|
31
|
+
macOS Calendar.app CLI tool — schedule, invite, list, and verify calendar events.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install --python 3.14 ical-mcp
|
|
36
|
+
# or, with Python 3.10 or newer
|
|
37
|
+
pip install ical-mcp
|
|
38
|
+
# or
|
|
39
|
+
brew install ml-lubich/tap/ical
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
From a checkout:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install --editable .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The PyPI distribution is named `ical-mcp`. It installs the `ical` Typer CLI
|
|
49
|
+
and the standalone `ical-mcp` stdio server; `ical mcp` remains a compatibility
|
|
50
|
+
alias for the server command.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
```bash
|
|
54
|
+
ical -h
|
|
55
|
+
ical agent -h
|
|
56
|
+
ical calendars
|
|
57
|
+
ical add --title "Team Sync" --start "Monday, August 10, 2026 at 1:00:00 PM" --end "Monday, August 10, 2026 at 2:00:00 PM" -a guest@example.com -c you@example.com
|
|
58
|
+
ical add --title "Team Sync" --start "Monday, August 10, 2026 at 1:00:00 PM" --end "Monday, August 10, 2026 at 2:00:00 PM" -a guest@example.com -c you@example.com --json
|
|
59
|
+
ical show --id EVENT_ID --calendar you@example.com --json
|
|
60
|
+
ical list -c you@example.com
|
|
61
|
+
ical list -c you@example.com -j
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every command and command group accepts both `-h` and `--help`; generated
|
|
65
|
+
shell-completion flags are disabled so help lists the supported command
|
|
66
|
+
surface. `ical list` prints a human-readable table; `-j`/`--json` prints JSON
|
|
67
|
+
to stdout. `ical add` verifies the created event by reading it back from
|
|
68
|
+
Calendar before reporting success. Human output summarizes the verified event
|
|
69
|
+
and attendee RSVP status. With `--json`, it returns a stable object with
|
|
70
|
+
`status: "verified"` and an `event` containing the event ID, title, ISO 8601
|
|
71
|
+
start and end, calendar, and attendees (`email` and `status`). A post-write verification
|
|
72
|
+
failure reports `status: "created_unverified"`, along with the event ID and
|
|
73
|
+
error. `unknown` RSVP status means
|
|
74
|
+
Calendar has no recorded response from that attendee yet; it does not mean
|
|
75
|
+
they accepted. CLI errors are printed to stderr and return a nonzero exit
|
|
76
|
+
status.
|
|
77
|
+
|
|
78
|
+
Every Calendar AppleScript call has a 20-second timeout. Calendar failures,
|
|
79
|
+
timeouts, and failed event lookups are reported as errors; `ical list` does not
|
|
80
|
+
turn an automation failure into an empty calendar. If creation times out
|
|
81
|
+
before Calendar returns an ID, JSON reports `status: "write_outcome_unknown"`
|
|
82
|
+
and an error. Search for the event with `ical list` before retrying. If the
|
|
83
|
+
conflict check times out before creation starts, JSON reports
|
|
84
|
+
`status: "not_created"`; it is safe to retry after Calendar responds. If an ID
|
|
85
|
+
is available but read-back failed, use `ical show --id EVENT_ID --calendar
|
|
86
|
+
CALENDAR --json` to verify it. **Do not retry the add until confirming the
|
|
87
|
+
event is absent**, or you may send a duplicate invitation. `ical show` is
|
|
88
|
+
read-only and returns the same verified event envelope as `ical add`. A JSON
|
|
89
|
+
lookup failure returns `status: "verification_failed"`, the requested event
|
|
90
|
+
ID, and the error; retry the read after checking Calendar, never the add.
|
|
91
|
+
|
|
92
|
+
## MCP (for AI agents)
|
|
93
|
+
`ical-mcp` is the standalone FastMCP server command for stdio clients. It exposes `list_calendars`, `list_events`, and `add_calendar_event`. The backward-compatible `ical mcp` command runs the same server. Point any MCP-compatible client (Claude Desktop, Claude Code, etc.) at the standalone executable:
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {
|
|
97
|
+
"ical": {
|
|
98
|
+
"command": "ical-mcp"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
If `ical-mcp` isn't on `PATH`, use its absolute path from `uv tool dir --bin`. Existing MCP configs using `ical` with `args: ["mcp"]` continue to work.
|
|
104
|
+
|
|
105
|
+
macOS Calendar's AppleScript scripting bridge can be slow for calendars with
|
|
106
|
+
many recurring events. Calls now stop after the bounded timeout and return an
|
|
107
|
+
actionable error instead of appearing to hang indefinitely.
|
|
108
|
+
|
|
109
|
+
## Testing
|
|
110
|
+
```bash
|
|
111
|
+
uv run --extra dev pytest
|
|
112
|
+
```
|
|
113
|
+
The default test command reports coverage and enforces an 80% minimum. Tests mock only the OS boundary (`subprocess`/`osascript`); parsing, conflict detection, CLI dispatch, and MCP tool functions run for real.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ical/__init__.py,sha256=m6kyaNpwBcP1XYcqrelX2oS3PJuOnElOcRdBa9pEb8c,22
|
|
2
|
+
ical/agent.py,sha256=MIvtCxzp7YmUMa1JRvGOHUkkMvDXzHCRT8suLgB2wQY,6730
|
|
3
|
+
ical/calendar.py,sha256=54iudpsusZFYyPvfEjeq_ZuU0U7ekZ1eOxdR55KnYQQ,7817
|
|
4
|
+
ical/cli.py,sha256=V8B80MA8HWPG1HG_apy2KqFKwCgP_yxY50q1t-hLrSM,9122
|
|
5
|
+
ical/mcp.py,sha256=JUFpjQVV6aUu1fjrrmRa3O7nzt3SyptED2MirlM8sZY,1757
|
|
6
|
+
ical_mcp-0.2.2.dist-info/METADATA,sha256=s9-DaeRpq8VC8fnocjxxTQKOScgn-Up1gObdHGsCHiw,4970
|
|
7
|
+
ical_mcp-0.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
ical_mcp-0.2.2.dist-info/entry_points.txt,sha256=uwfz833NXq1wACEv8C_2JN0RBnwE5OaJl_B90tN3-dg,69
|
|
9
|
+
ical_mcp-0.2.2.dist-info/licenses/LICENSE,sha256=f2Jg7siIJugKjulMOIw4zz439sRWntgKzpceB40HKUs,1069
|
|
10
|
+
ical_mcp-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Misha Lubich
|
|
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.
|