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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ical = ical.cli:app
3
+ ical-mcp = ical.mcp:run_server
@@ -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.