From 9a76c4718b24e3150a255e412d909e86bed3099f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:50:54 -0400 Subject: [PATCH] feat(mcp): event CRUD tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new tools (events weren't in fable-mcp before). Split start_date + start_time inputs combine into a naive datetime that services/events.py interprets in the user's local timezone. Sentinels for update: - empty strings → leave unchanged - duration_minutes=-1 → leave unchanged - duration_minutes=0 → set to point event (NULL duration) - start_date/start_time must BOTH be set to move the event Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/mcp/tools/__init__.py | 3 +- src/fabledassistant/mcp/tools/events.py | 149 ++++++++++++++++++++ tests/test_mcp_tool_events.py | 158 ++++++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/fabledassistant/mcp/tools/events.py create mode 100644 tests/test_mcp_tool_events.py diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index a885f96..6dead51 100644 --- a/src/fabledassistant/mcp/tools/__init__.py +++ b/src/fabledassistant/mcp/tools/__init__.py @@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called from `mcp.server.build_mcp_server`. """ from fabledassistant.mcp.tools import ( - milestones, notes, projects, search, tasks, + events, milestones, notes, projects, search, tasks, ) @@ -16,3 +16,4 @@ def register_all(mcp) -> None: tasks.register(mcp) projects.register(mcp) milestones.register(mcp) + events.register(mcp) diff --git a/src/fabledassistant/mcp/tools/events.py b/src/fabledassistant/mcp/tools/events.py new file mode 100644 index 0000000..5f355d9 --- /dev/null +++ b/src/fabledassistant/mcp/tools/events.py @@ -0,0 +1,149 @@ +"""Calendar event MCP tools — new in Phase 3. + +Events were previously only an internal-LLM tool; the MCP surface didn't have +them. Wraps services/events.py. + +Date/time inputs are split: start_date (YYYY-MM-DD) + start_time (HH:MM) get +combined into a naive datetime; the service layer interprets it in the user's +local timezone. duration_minutes=0 ⇒ point event (NULL duration). The +LLM-era expected_weekday verification check is intentionally not replicated — +Claude doesn't need it. + +For update, sentinels: + - title="" / location="" / description="" → leave unchanged + - start_date="" / start_time="" → leave unchanged (both must be provided to + move the event) + - duration_minutes=-1 → leave unchanged; 0 means "set to point event" +""" +from __future__ import annotations + +from datetime import datetime + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services import events as events_svc + + +def _combine(start_date: str, start_time: str) -> datetime: + """Combine YYYY-MM-DD + HH:MM into a naive datetime.""" + t = start_time or "00:00" + return datetime.fromisoformat(f"{start_date}T{t}:00") + + +def _event_dict(event) -> dict: + """Render an Event model to a dict, handling list_events (already dicts).""" + return event if isinstance(event, dict) else event.to_dict() + + +async def fable_list_events(date_from: str, date_to: str) -> dict: + """List events between date_from and date_to (YYYY-MM-DD, both inclusive at + the day level — `date_to` is interpreted as end-of-that-day). + + Recurring events are expanded into individual occurrences within the range. + """ + uid = current_user_id() + start = datetime.fromisoformat(date_from) + end = datetime.fromisoformat(date_to) + rows = await events_svc.list_events(uid, start, end) + return {"events": [_event_dict(e) for e in rows], "total": len(rows)} + + +async def fable_create_event( + title: str, + start_date: str, + start_time: str = "00:00", + duration_minutes: int = 0, + all_day: bool = False, + location: str = "", + description: str = "", +) -> dict: + """Create a calendar event. + + Args: + title: Event title (required). + start_date: YYYY-MM-DD. + start_time: HH:MM (24-hour). Ignored when all_day=True. + duration_minutes: 0 for a point event (no duration); otherwise minutes. + all_day: True to make this an all-day event. + location: Optional location string. + description: Optional longer description. + """ + uid = current_user_id() + event = await events_svc.create_event( + uid, + title=title, + start_dt=_combine(start_date, start_time), + duration_minutes=duration_minutes or None, + all_day=all_day, + location=location, + description=description, + ) + return event.to_dict() + + +async def fable_get_event(event_id: int) -> dict: + """Fetch a single event by ID.""" + uid = current_user_id() + event = await events_svc.get_event(uid, event_id) + if event is None: + raise ValueError(f"event {event_id} not found") + return event.to_dict() + + +async def fable_update_event( + event_id: int, + title: str = "", + start_date: str = "", + start_time: str = "", + duration_minutes: int = -1, + location: str = "", + description: str = "", +) -> dict: + """Update an event. Only explicitly provided fields are changed. + + Args: + event_id: ID of the event to update. + title: New title; omit to leave unchanged. + start_date / start_time: BOTH must be set to move the event. Omit either + to leave the start_dt unchanged. + duration_minutes: -1 leaves unchanged; 0 sets to point event; any + positive value sets the duration. + location / description: omit to leave unchanged. + """ + uid = current_user_id() + fields: dict = {} + if title: + fields["title"] = title + if location: + fields["location"] = location + if description: + fields["description"] = description + if start_date and start_time: + fields["start_dt"] = _combine(start_date, start_time) + if duration_minutes >= 0: + # 0 means point event (NULL); positive sets a real duration. + fields["duration_minutes"] = duration_minutes or None + event = await events_svc.update_event(uid, event_id, **fields) + if event is None: + raise ValueError(f"event {event_id} not found") + return event.to_dict() + + +async def fable_delete_event(event_id: int) -> str: + """Permanently delete a calendar event by ID. Cannot be undone.""" + uid = current_user_id() + existing = await events_svc.get_event(uid, event_id) + if existing is None: + raise ValueError(f"event {event_id} not found") + await events_svc.delete_event(uid, event_id) + return f"Event {event_id} deleted." + + +def register(mcp) -> None: + for fn in ( + fable_list_events, + fable_create_event, + fable_get_event, + fable_update_event, + fable_delete_event, + ): + mcp.tool(name=fn.__name__)(fn) diff --git a/tests/test_mcp_tool_events.py b/tests/test_mcp_tool_events.py new file mode 100644 index 0000000..b459df7 --- /dev/null +++ b/tests/test_mcp_tool_events.py @@ -0,0 +1,158 @@ +"""Tests for fable_*_event tools.""" +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx +from fabledassistant.mcp.tools.events import ( + fable_list_events, fable_create_event, fable_get_event, + fable_update_event, fable_delete_event, +) + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def _fake_event(**overrides) -> MagicMock: + e = MagicMock() + base = { + "id": 1, "title": "ev", "start_dt": "2026-06-01T10:00:00", + "duration_minutes": 30, "all_day": False, + "location": "", "description": "", + } + base.update(overrides) + e.to_dict.return_value = base + return e + + +@pytest.mark.asyncio +async def test_list_events_passes_datetime_range(): + mock = AsyncMock(return_value=[ + {"id": 1, "title": "morning standup"}, + ]) + with patch("fabledassistant.mcp.tools.events.events_svc.list_events", mock): + out = await fable_list_events(date_from="2026-06-01", date_to="2026-06-08") + args, _ = mock.call_args + assert args[0] == 7 # user_id + assert args[1] == datetime(2026, 6, 1) + assert args[2] == datetime(2026, 6, 8) + assert out["total"] == 1 + + +@pytest.mark.asyncio +async def test_create_event_combines_date_and_time(): + e = _fake_event() + mock = AsyncMock(return_value=e) + with patch("fabledassistant.mcp.tools.events.events_svc.create_event", mock): + await fable_create_event( + title="standup", start_date="2026-06-01", start_time="09:30", + duration_minutes=15, + ) + kwargs = mock.call_args.kwargs + assert kwargs["start_dt"] == datetime(2026, 6, 1, 9, 30) + assert kwargs["duration_minutes"] == 15 + + +@pytest.mark.asyncio +async def test_create_event_zero_duration_means_point_event(): + """duration_minutes=0 must map to None at the service layer (NULL = point).""" + e = _fake_event() + mock = AsyncMock(return_value=e) + with patch("fabledassistant.mcp.tools.events.events_svc.create_event", mock): + await fable_create_event(title="x", start_date="2026-06-01") + assert mock.call_args.kwargs["duration_minutes"] is None + + +@pytest.mark.asyncio +async def test_get_event_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.events.events_svc.get_event", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="event 999 not found"): + await fable_get_event(event_id=999) + + +@pytest.mark.asyncio +async def test_update_event_only_sends_non_default_fields(): + e = _fake_event() + mock = AsyncMock(return_value=e) + with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): + await fable_update_event(event_id=1, title="new title") + args, kwargs = mock.call_args + assert args == (7, 1) + assert kwargs == {"title": "new title"} + + +@pytest.mark.asyncio +async def test_update_event_duration_minus_one_means_unchanged(): + e = _fake_event() + mock = AsyncMock(return_value=e) + with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): + await fable_update_event(event_id=1, duration_minutes=-1) + assert "duration_minutes" not in mock.call_args.kwargs + + +@pytest.mark.asyncio +async def test_update_event_duration_zero_clears_to_point(): + """duration_minutes=0 means "set to point event" (NULL).""" + e = _fake_event() + mock = AsyncMock(return_value=e) + with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): + await fable_update_event(event_id=1, duration_minutes=0) + assert mock.call_args.kwargs["duration_minutes"] is None + + +@pytest.mark.asyncio +async def test_update_event_requires_both_date_and_time_to_move(): + e = _fake_event() + mock = AsyncMock(return_value=e) + with patch("fabledassistant.mcp.tools.events.events_svc.update_event", mock): + await fable_update_event(event_id=1, start_date="2026-06-02") + # Only start_date, no start_time → start_dt NOT in fields + assert "start_dt" not in mock.call_args.kwargs + + mock.reset_mock() + await fable_update_event( + event_id=1, start_date="2026-06-02", start_time="11:00", + ) + assert mock.call_args.kwargs["start_dt"] == datetime(2026, 6, 2, 11) + + +@pytest.mark.asyncio +async def test_update_event_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.events.events_svc.update_event", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="event 999 not found"): + await fable_update_event(event_id=999, title="x") + + +@pytest.mark.asyncio +async def test_delete_event_checks_existence_then_returns_confirmation(): + fake = _fake_event() + with patch( + "fabledassistant.mcp.tools.events.events_svc.get_event", + AsyncMock(return_value=fake), + ), patch( + "fabledassistant.mcp.tools.events.events_svc.delete_event", + AsyncMock(return_value=None), + ): + result = await fable_delete_event(event_id=7) + assert result == "Event 7 deleted." + + +@pytest.mark.asyncio +async def test_delete_event_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.events.events_svc.get_event", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="event 999 not found"): + await fable_delete_event(event_id=999)