daknode-mcp 0.3.3__tar.gz → 0.3.4__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.
@@ -1,21 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daknode-mcp
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: DakNode's email and calendar API tools over local and authenticated remote MCP.
5
5
  Project-URL: Homepage, https://daknode.com
6
6
  Project-URL: Documentation, https://daknode.com/developers
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
- Requires-Dist: daknode<0.4,>=0.3.8
9
+ Requires-Dist: daknode<0.4,>=0.3.9
10
10
  Requires-Dist: mcp<3,>=2.2
11
11
 
12
12
  # DakNode MCP server
13
13
 
14
14
  <!-- mcp-name: io.github.Subodh103/daknode -->
15
15
 
16
- DakNode 0.3.3 exposes email and Calendar API tools over **stdio**, with a separate authenticated
17
- Streamable HTTP process prepared for hosting. No hosted deployment or OAuth
18
- availability is implied by this checkout.
16
+ DakNode MCP 0.3.4 exposes 21 email and calendar API tools over **stdio**,
17
+ including availability, reliable booking, reminders and recurring-event edits.
18
+ An authenticated hosted endpoint is also available at
19
+ `https://api.daknode.com/mcp`; local installation does not connect to it or
20
+ authorize any action automatically.
19
21
 
20
22
  ## Install
21
23
 
@@ -53,9 +55,31 @@ launch rather than looking like a broken tool.
53
55
 
54
56
  Calendar: `get_calendar_event` · `create_calendar_event` · `update_calendar_event`
55
57
  · `cancel_calendar_event` · `respond_calendar_event` · `send_calendar_invitations`.
56
- These six tools require generated Python SDK **>=0.3.8,<0.4** and each invokes
57
- exactly its published operation once. No Calendar deletion, exception, agenda,
58
- or unrelated management tools are added by this release.
58
+ This release requires Python SDK **>=0.3.9,<0.4** and also exposes
59
+ `list_calendar_events`, `get_calendar_agenda`,
60
+ `get_calendar_free_busy`, `get_calendar_availability`, `book_calendar_event`,
61
+ `get_calendar_reminders`, `set_calendar_reminders`, and `edit_calendar_series`.
62
+ Each invokes exactly one corresponding generated SDK operation. No Calendar
63
+ deletion or unrelated management tools are added.
64
+
65
+ The list/agenda/free-busy/availability/reminder reads require `calendar.read`;
66
+ booking, reminder changes and series edits require `calendar.write`.
67
+ Free-busy takes `request: {start, end}`. Availability takes `request` with that
68
+ window plus IANA `timezone`, `working_hours: [{weekday, start, end}]` (Monday=0,
69
+ local HH:MM), `duration_minutes`, `step_minutes` and optional before/after buffer
70
+ minutes. Windows are explicit and at most 31 days. Slots are advisory and
71
+ inbox-only: external attendee calendars are unknown, never represented as free.
72
+ Booking takes `booking: {event, buffer_before_minutes?, buffer_after_minutes?}`
73
+ and a stable `idempotency_key`; only one non-recurring timed event is accepted.
74
+
75
+ Reminder writes take `revision` and `minutes_before_start`: up to five unique
76
+ integer offsets from 0 through 40320, or `[]` to clear. They configure developer
77
+ notifications, not attendee email. Series edits take `revision`, a stable retry
78
+ key, and `edit: {scope, recurrence_id?, cancelled?, changes?}` with explicit
79
+ `occurrence`, `series` or `following` scope. A following edit can return a new
80
+ future event ID; other edits return the original event. Optional patch fields
81
+ and explicit nulls retain their API meaning. No conflict triggers an automatic
82
+ revision refresh or retry.
59
83
 
60
84
  `get_calendar_event` requires `calendar.read`. The Calendar writes require
61
85
  `calendar.write`; invitation dispatch additionally requires `messages.send`.
@@ -224,7 +248,7 @@ Two distinct capabilities are implemented and wire-tested:
224
248
 
225
249
  The default verifier exposes `message.received` for `messages.read`, and
226
250
  `calendar.event.created`, `.updated`, `.cancelled`, `.responded`, `.starting`,
227
- `.ending` for `calendar.read`. Inbox-scoped credentials can subscribe only to
251
+ `.ending`, `.reminder` for `calendar.read` in this source checkout. Inbox-scoped credentials can subscribe only to
228
252
  their own inbox. Event payloads contain identifiers and calendar revision only;
229
253
  no bodies, subjects, event titles, attendees or email addresses. This is not a
230
254
  claim of AgentMail's exact spam/authentication filtering policy: retained mail
@@ -1,21 +1,12 @@
1
- Metadata-Version: 2.4
2
- Name: daknode-mcp
3
- Version: 0.3.3
4
- Summary: DakNode's email and calendar API tools over local and authenticated remote MCP.
5
- Project-URL: Homepage, https://daknode.com
6
- Project-URL: Documentation, https://daknode.com/developers
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- Requires-Dist: daknode<0.4,>=0.3.8
10
- Requires-Dist: mcp<3,>=2.2
11
-
12
1
  # DakNode MCP server
13
2
 
14
3
  <!-- mcp-name: io.github.Subodh103/daknode -->
15
4
 
16
- DakNode 0.3.3 exposes email and Calendar API tools over **stdio**, with a separate authenticated
17
- Streamable HTTP process prepared for hosting. No hosted deployment or OAuth
18
- availability is implied by this checkout.
5
+ DakNode MCP 0.3.4 exposes 21 email and calendar API tools over **stdio**,
6
+ including availability, reliable booking, reminders and recurring-event edits.
7
+ An authenticated hosted endpoint is also available at
8
+ `https://api.daknode.com/mcp`; local installation does not connect to it or
9
+ authorize any action automatically.
19
10
 
20
11
  ## Install
21
12
 
@@ -53,9 +44,31 @@ launch rather than looking like a broken tool.
53
44
 
54
45
  Calendar: `get_calendar_event` · `create_calendar_event` · `update_calendar_event`
55
46
  · `cancel_calendar_event` · `respond_calendar_event` · `send_calendar_invitations`.
56
- These six tools require generated Python SDK **>=0.3.8,<0.4** and each invokes
57
- exactly its published operation once. No Calendar deletion, exception, agenda,
58
- or unrelated management tools are added by this release.
47
+ This release requires Python SDK **>=0.3.9,<0.4** and also exposes
48
+ `list_calendar_events`, `get_calendar_agenda`,
49
+ `get_calendar_free_busy`, `get_calendar_availability`, `book_calendar_event`,
50
+ `get_calendar_reminders`, `set_calendar_reminders`, and `edit_calendar_series`.
51
+ Each invokes exactly one corresponding generated SDK operation. No Calendar
52
+ deletion or unrelated management tools are added.
53
+
54
+ The list/agenda/free-busy/availability/reminder reads require `calendar.read`;
55
+ booking, reminder changes and series edits require `calendar.write`.
56
+ Free-busy takes `request: {start, end}`. Availability takes `request` with that
57
+ window plus IANA `timezone`, `working_hours: [{weekday, start, end}]` (Monday=0,
58
+ local HH:MM), `duration_minutes`, `step_minutes` and optional before/after buffer
59
+ minutes. Windows are explicit and at most 31 days. Slots are advisory and
60
+ inbox-only: external attendee calendars are unknown, never represented as free.
61
+ Booking takes `booking: {event, buffer_before_minutes?, buffer_after_minutes?}`
62
+ and a stable `idempotency_key`; only one non-recurring timed event is accepted.
63
+
64
+ Reminder writes take `revision` and `minutes_before_start`: up to five unique
65
+ integer offsets from 0 through 40320, or `[]` to clear. They configure developer
66
+ notifications, not attendee email. Series edits take `revision`, a stable retry
67
+ key, and `edit: {scope, recurrence_id?, cancelled?, changes?}` with explicit
68
+ `occurrence`, `series` or `following` scope. A following edit can return a new
69
+ future event ID; other edits return the original event. Optional patch fields
70
+ and explicit nulls retain their API meaning. No conflict triggers an automatic
71
+ revision refresh or retry.
59
72
 
60
73
  `get_calendar_event` requires `calendar.read`. The Calendar writes require
61
74
  `calendar.write`; invitation dispatch additionally requires `messages.send`.
@@ -224,7 +237,7 @@ Two distinct capabilities are implemented and wire-tested:
224
237
 
225
238
  The default verifier exposes `message.received` for `messages.read`, and
226
239
  `calendar.event.created`, `.updated`, `.cancelled`, `.responded`, `.starting`,
227
- `.ending` for `calendar.read`. Inbox-scoped credentials can subscribe only to
240
+ `.ending`, `.reminder` for `calendar.read` in this source checkout. Inbox-scoped credentials can subscribe only to
228
241
  their own inbox. Event payloads contain identifiers and calendar revision only;
229
242
  no bodies, subjects, event titles, attendees or email addresses. This is not a
230
243
  claim of AgentMail's exact spam/authentication filtering policy: retained mail
@@ -0,0 +1,110 @@
1
+ """MCP input schemas matching the published Calendar request bodies.
2
+
3
+ These are validation-only transport models. Generated SDK models still build
4
+ the API requests. Timezone, recurrence and organizer authority stay in the API.
5
+ Explicit nulls and omitted patch fields must remain different.
6
+ """
7
+ from datetime import date, datetime, timedelta
8
+ from typing import Annotated, Literal
9
+
10
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
11
+
12
+ Title = Annotated[str, Field(min_length=1, max_length=1000)]
13
+ Timezone = Annotated[str, Field(min_length=1, max_length=100)]
14
+ Description = Annotated[str, Field(max_length=50000)]
15
+ Location = Annotated[str, Field(max_length=2000)]
16
+ Recurrence = Annotated[str, Field(max_length=2000)]
17
+
18
+
19
+ class CalendarAttendee(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+ email: Annotated[str, Field(min_length=3, max_length=320)]
22
+ name: Annotated[str, Field(max_length=255)] | None = None
23
+
24
+
25
+ Attendees = Annotated[list[CalendarAttendee], Field(max_length=100)]
26
+
27
+
28
+ class CalendarCreate(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+ title: Title
31
+ timezone: Timezone
32
+ all_day: bool = False
33
+ attendees: Attendees = Field(default_factory=list)
34
+ description: Description | None = None
35
+ location: Location | None = None
36
+ rrule: Recurrence | None = None
37
+ starts_at: datetime | None = None
38
+ ends_at: datetime | None = None
39
+ start_date: date | None = None
40
+ end_date: date | None = None
41
+
42
+
43
+ class CalendarPatch(BaseModel):
44
+ model_config = ConfigDict(extra="forbid")
45
+ title: Title | None = None
46
+ timezone: Timezone | None = None
47
+ all_day: bool | None = None
48
+ attendees: Attendees | None = None
49
+ description: Description | None = None
50
+ location: Location | None = None
51
+ rrule: Recurrence | None = None
52
+ starts_at: datetime | None = None
53
+ ends_at: datetime | None = None
54
+ start_date: date | None = None
55
+ end_date: date | None = None
56
+
57
+
58
+ class CalendarWindow(BaseModel):
59
+ model_config = ConfigDict(extra="forbid")
60
+ start: AwareDatetime
61
+ end: AwareDatetime
62
+
63
+ @model_validator(mode="after")
64
+ def bounded_window(self):
65
+ if not timedelta(0) < self.end - self.start <= timedelta(days=31):
66
+ raise ValueError("Scheduling windows must be positive and at most 31 days")
67
+ return self
68
+
69
+
70
+ class CalendarWorkingHours(BaseModel):
71
+ model_config = ConfigDict(extra="forbid")
72
+ weekday: Annotated[int, Field(ge=0, le=6, strict=True)]
73
+ start: Annotated[str, Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")]
74
+ end: Annotated[str, Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")]
75
+
76
+
77
+ BufferMinutes = Annotated[int, Field(ge=0, le=1440, strict=True)]
78
+ PositiveMinutes = Annotated[int, Field(ge=1, le=1440, strict=True)]
79
+ ReminderOffsets = Annotated[list[Annotated[int, Field(ge=0, le=40320, strict=True)]], Field(max_length=5)]
80
+
81
+
82
+ class CalendarAvailability(CalendarWindow):
83
+ timezone: Timezone
84
+ working_hours: Annotated[list[CalendarWorkingHours], Field(min_length=1, max_length=70)]
85
+ duration_minutes: PositiveMinutes
86
+ step_minutes: PositiveMinutes
87
+ buffer_before_minutes: BufferMinutes = 0
88
+ buffer_after_minutes: BufferMinutes = 0
89
+
90
+
91
+ class CalendarBooking(BaseModel):
92
+ model_config = ConfigDict(extra="forbid")
93
+ event: CalendarCreate
94
+ buffer_before_minutes: BufferMinutes = 0
95
+ buffer_after_minutes: BufferMinutes = 0
96
+
97
+ @model_validator(mode="after")
98
+ def timed_booking(self):
99
+ if self.event.all_day or self.event.rrule is not None:
100
+ raise ValueError("Bookings require one non-recurring timed event")
101
+ CalendarWindow(start=self.event.starts_at, end=self.event.ends_at)
102
+ return self
103
+
104
+
105
+ class CalendarSeriesEdit(BaseModel):
106
+ model_config = ConfigDict(extra="forbid")
107
+ scope: Literal["occurrence", "following", "series"]
108
+ recurrence_id: Annotated[str, Field(min_length=1, max_length=40)] | None = None
109
+ cancelled: Annotated[bool, Field(strict=True)] = False
110
+ changes: CalendarPatch | None = None
@@ -1,10 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: daknode-mcp
3
+ Version: 0.3.4
4
+ Summary: DakNode's email and calendar API tools over local and authenticated remote MCP.
5
+ Project-URL: Homepage, https://daknode.com
6
+ Project-URL: Documentation, https://daknode.com/developers
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: daknode<0.4,>=0.3.9
10
+ Requires-Dist: mcp<3,>=2.2
11
+
1
12
  # DakNode MCP server
2
13
 
3
14
  <!-- mcp-name: io.github.Subodh103/daknode -->
4
15
 
5
- DakNode 0.3.3 exposes email and Calendar API tools over **stdio**, with a separate authenticated
6
- Streamable HTTP process prepared for hosting. No hosted deployment or OAuth
7
- availability is implied by this checkout.
16
+ DakNode MCP 0.3.4 exposes 21 email and calendar API tools over **stdio**,
17
+ including availability, reliable booking, reminders and recurring-event edits.
18
+ An authenticated hosted endpoint is also available at
19
+ `https://api.daknode.com/mcp`; local installation does not connect to it or
20
+ authorize any action automatically.
8
21
 
9
22
  ## Install
10
23
 
@@ -42,9 +55,31 @@ launch rather than looking like a broken tool.
42
55
 
43
56
  Calendar: `get_calendar_event` · `create_calendar_event` · `update_calendar_event`
44
57
  · `cancel_calendar_event` · `respond_calendar_event` · `send_calendar_invitations`.
45
- These six tools require generated Python SDK **>=0.3.8,<0.4** and each invokes
46
- exactly its published operation once. No Calendar deletion, exception, agenda,
47
- or unrelated management tools are added by this release.
58
+ This release requires Python SDK **>=0.3.9,<0.4** and also exposes
59
+ `list_calendar_events`, `get_calendar_agenda`,
60
+ `get_calendar_free_busy`, `get_calendar_availability`, `book_calendar_event`,
61
+ `get_calendar_reminders`, `set_calendar_reminders`, and `edit_calendar_series`.
62
+ Each invokes exactly one corresponding generated SDK operation. No Calendar
63
+ deletion or unrelated management tools are added.
64
+
65
+ The list/agenda/free-busy/availability/reminder reads require `calendar.read`;
66
+ booking, reminder changes and series edits require `calendar.write`.
67
+ Free-busy takes `request: {start, end}`. Availability takes `request` with that
68
+ window plus IANA `timezone`, `working_hours: [{weekday, start, end}]` (Monday=0,
69
+ local HH:MM), `duration_minutes`, `step_minutes` and optional before/after buffer
70
+ minutes. Windows are explicit and at most 31 days. Slots are advisory and
71
+ inbox-only: external attendee calendars are unknown, never represented as free.
72
+ Booking takes `booking: {event, buffer_before_minutes?, buffer_after_minutes?}`
73
+ and a stable `idempotency_key`; only one non-recurring timed event is accepted.
74
+
75
+ Reminder writes take `revision` and `minutes_before_start`: up to five unique
76
+ integer offsets from 0 through 40320, or `[]` to clear. They configure developer
77
+ notifications, not attendee email. Series edits take `revision`, a stable retry
78
+ key, and `edit: {scope, recurrence_id?, cancelled?, changes?}` with explicit
79
+ `occurrence`, `series` or `following` scope. A following edit can return a new
80
+ future event ID; other edits return the original event. Optional patch fields
81
+ and explicit nulls retain their API meaning. No conflict triggers an automatic
82
+ revision refresh or retry.
48
83
 
49
84
  `get_calendar_event` requires `calendar.read`. The Calendar writes require
50
85
  `calendar.write`; invitation dispatch additionally requires `messages.send`.
@@ -213,7 +248,7 @@ Two distinct capabilities are implemented and wire-tested:
213
248
 
214
249
  The default verifier exposes `message.received` for `messages.read`, and
215
250
  `calendar.event.created`, `.updated`, `.cancelled`, `.responded`, `.starting`,
216
- `.ending` for `calendar.read`. Inbox-scoped credentials can subscribe only to
251
+ `.ending`, `.reminder` for `calendar.read` in this source checkout. Inbox-scoped credentials can subscribe only to
217
252
  their own inbox. Event payloads contain identifiers and calendar revision only;
218
253
  no bodies, subjects, event titles, attendees or email addresses. This is not a
219
254
  claim of AgentMail's exact spam/authentication filtering policy: retained mail
@@ -20,4 +20,5 @@ daknode_mcp.egg-info/dependency_links.txt
20
20
  daknode_mcp.egg-info/entry_points.txt
21
21
  daknode_mcp.egg-info/requires.txt
22
22
  daknode_mcp.egg-info/top_level.txt
23
+ tests/test_calendar_scheduling.py
23
24
  tests/test_write_tools.py
@@ -0,0 +1,2 @@
1
+ daknode<0.4,>=0.3.9
2
+ mcp<3,>=2.2
@@ -16,7 +16,7 @@ import httpx
16
16
  from .server import Authority, DEFAULT_BASE_URL
17
17
 
18
18
  CALENDAR_NAMES = frozenset(f"calendar.event.{kind}" for kind in
19
- ("created", "updated", "cancelled", "responded", "starting", "ending"))
19
+ ("created", "updated", "cancelled", "responded", "starting", "ending", "reminder"))
20
20
  EVENT_NAMES = CALENDAR_NAMES | {"message.received"}
21
21
 
22
22
 
@@ -18,6 +18,14 @@ PERMISSIONS = {
18
18
  "search": frozenset({"search.read"}),
19
19
  "send": frozenset({"messages.send"}),
20
20
  "get_calendar_event": frozenset({"calendar.read"}),
21
+ "list_calendar_events": frozenset({"calendar.read"}),
22
+ "get_calendar_agenda": frozenset({"calendar.read"}),
23
+ "get_calendar_free_busy": frozenset({"calendar.read"}),
24
+ "get_calendar_availability": frozenset({"calendar.read"}),
25
+ "get_calendar_reminders": frozenset({"calendar.read"}),
26
+ "book_calendar_event": frozenset({"calendar.write"}),
27
+ "set_calendar_reminders": frozenset({"calendar.write"}),
28
+ "edit_calendar_series": frozenset({"calendar.write"}),
21
29
  "create_calendar_event": frozenset({"calendar.write"}),
22
30
  "update_calendar_event": frozenset({"calendar.write"}),
23
31
  "cancel_calendar_event": frozenset({"calendar.write"}),
@@ -7,12 +7,12 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "daknode-mcp"
10
- version = "0.3.3"
10
+ version = "0.3.4"
11
11
  description = "DakNode's email and calendar API tools over local and authenticated remote MCP."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
14
14
  dependencies = [
15
- "daknode>=0.3.8,<0.4",
15
+ "daknode>=0.3.9,<0.4",
16
16
  "mcp>=2.2,<3",
17
17
  ]
18
18
 
@@ -32,7 +32,7 @@ from typing import Any, Annotated, Literal
32
32
  from uuid import UUID
33
33
 
34
34
  import httpx
35
- from pydantic import Field
35
+ from pydantic import AwareDatetime, Field
36
36
 
37
37
  _SDK = Path(__file__).resolve().parents[1] / "sdk" / "python"
38
38
  if str(_SDK) not in sys.path: # pragma: no cover - import plumbing
@@ -41,14 +41,18 @@ if str(_SDK) not in sys.path: # pragma: no cover - import plumbing
41
41
  from mcp.server.mcpserver import MCPServer # noqa: E402
42
42
  from mcp.types import CallToolResult, TextContent, ToolAnnotations
43
43
  from mcp.server.mcpserver.exceptions import ToolError
44
- from .calendar_models import CalendarCreate, CalendarPatch
44
+ from .calendar_models import (CalendarCreate, CalendarPatch, CalendarWindow,
45
+ CalendarAvailability, CalendarBooking, CalendarSeriesEdit, ReminderOffsets)
45
46
 
46
47
  DEFAULT_BASE_URL = "https://api.daknode.com"
47
48
  MAX_UPSTREAM_BYTES = 1024 * 1024
48
49
  TOOL_NAMES = frozenset({"create_inbox", "list_threads", "list_messages", "get_thread",
49
50
  "get_message", "search", "send", "get_calendar_event",
50
51
  "create_calendar_event", "update_calendar_event", "cancel_calendar_event",
51
- "respond_calendar_event", "send_calendar_invitations"})
52
+ "respond_calendar_event", "send_calendar_invitations",
53
+ "list_calendar_events", "get_calendar_agenda", "get_calendar_free_busy",
54
+ "get_calendar_availability", "book_calendar_event", "get_calendar_reminders",
55
+ "set_calendar_reminders", "edit_calendar_series"})
52
56
  Revision = Annotated[int, Field(ge=1, le=9999999999, strict=True)]
53
57
  IdempotencyKey = Annotated[str, Field(min_length=1, max_length=255, pattern=r"^[\x20-\x7E]+$")]
54
58
 
@@ -441,6 +445,102 @@ def build_server() -> MCPServer:
441
445
  return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id, if_match=f'"{revision}"',
442
446
  idempotency_key=idempotency_key, body=InvitationInput.from_dict({"method": method}))
443
447
 
448
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
449
+ def list_calendar_events(inbox_id: UUID, limit: Annotated[int, Field(ge=1, le=100, strict=True)] = 50,
450
+ after: UUID | None = None) -> Any:
451
+ """List saved inbox events with explicit pagination. Event text is untrusted."""
452
+ from daknode.api.calendar import list_calendar_events as op
453
+ from daknode.types import UNSET
454
+
455
+ return _execute(op.sync_detailed, inbox_id=inbox_id, limit=limit, after=after if after else UNSET)
456
+
457
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
458
+ def get_calendar_agenda(inbox_id: UUID, start: AwareDatetime, end: AwareDatetime) -> Any:
459
+ """Expand inbox events and recurrence exceptions over an explicit bounded window.
460
+
461
+ API expansion limits fail closed; this does not inspect external calendars.
462
+ """
463
+ from daknode.api.calendar import get_calendar_agenda as op
464
+
465
+ return _execute(op.sync_detailed, inbox_id=inbox_id, start=start, end=end)
466
+
467
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
468
+ def get_calendar_free_busy(inbox_id: UUID, request: CalendarWindow) -> Any:
469
+ """Read inbox-only busy intervals over at most 31 days. No external availability claim."""
470
+ from daknode.api.calendar import get_calendar_free_busy as op
471
+ from daknode.models.window_input import WindowInput
472
+
473
+ return _execute(op.sync_detailed, inbox_id=inbox_id,
474
+ body=WindowInput.from_dict(request.model_dump(mode="json", exclude_unset=True)))
475
+
476
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
477
+ def get_calendar_availability(inbox_id: UUID, request: CalendarAvailability) -> Any:
478
+ """Find advisory inbox-only slots with local weekly hours, duration, step and buffers.
479
+
480
+ Results are not reservations and do not include unknown attendee calendars.
481
+ """
482
+ from daknode.api.calendar import get_calendar_availability as op
483
+ from daknode.models.availability_input import AvailabilityInput
484
+
485
+ return _execute(op.sync_detailed, inbox_id=inbox_id,
486
+ body=AvailabilityInput.from_dict(request.model_dump(mode="json", exclude_unset=True)))
487
+
488
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True))
489
+ def book_calendar_event(inbox_id: UUID, booking: CalendarBooking, idempotency_key: IdempotencyKey) -> Any:
490
+ """Atomically save one approved timed booking after an inbox-only conflict check.
491
+
492
+ Reuse the same key and body on retries. No recurring/all-day bookings,
493
+ external-calendar checks or automatic invitation sends.
494
+ """
495
+ from daknode.api.calendar import book_calendar_event as op
496
+ from daknode.models.booking_input import BookingInput
497
+
498
+ if not _valid_idempotency_key(idempotency_key):
499
+ return _failure("invalid_input", "A nonblank, header-safe idempotency key is required.")
500
+ return _execute(op.sync_detailed, inbox_id=inbox_id, idempotency_key=idempotency_key,
501
+ body=BookingInput.from_dict(booking.model_dump(mode="json", exclude_unset=True)))
502
+
503
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
504
+ def get_calendar_reminders(inbox_id: UUID, event_id: UUID) -> Any:
505
+ """Read configured developer reminder offsets and the current event revision."""
506
+ from daknode.api.calendar import get_calendar_reminders as op
507
+
508
+ return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id)
509
+
510
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=False, open_world_hint=True))
511
+ def set_calendar_reminders(inbox_id: UUID, event_id: UUID, revision: Revision,
512
+ minutes_before_start: ReminderOffsets) -> Any:
513
+ """Replace up to five developer reminder offsets at the observed revision.
514
+
515
+ An empty list clears reminders. These are notifications, not attendee email.
516
+ Refusals do not authorize refreshing the revision or replaying reminders.
517
+ """
518
+ from daknode.api.calendar import set_calendar_reminders as op
519
+ from daknode.models.reminder_input import ReminderInput
520
+
521
+ if len(set(minutes_before_start)) != len(minutes_before_start):
522
+ return _failure("invalid_input", "Reminder offsets must be unique.")
523
+ return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id, if_match=f'"{revision}"',
524
+ body=ReminderInput.from_dict({"minutes_before_start": minutes_before_start}))
525
+
526
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True, open_world_hint=True))
527
+ def edit_calendar_series(inbox_id: UUID, event_id: UUID, revision: Revision,
528
+ edit: CalendarSeriesEdit, idempotency_key: IdempotencyKey) -> Any:
529
+ """Save an explicitly approved occurrence, series or this-and-following edit.
530
+
531
+ Preserve the supplied revision, recurrence identity, scope and retry key.
532
+ A conflict is a refusal, never authority to widen scope or overwrite.
533
+ Saving does not dispatch invitations.
534
+ """
535
+ from daknode.api.calendar import edit_calendar_series as op
536
+ from daknode.models.series_edit_input import SeriesEditInput
537
+
538
+ if not _valid_idempotency_key(idempotency_key):
539
+ return _failure("invalid_input", "A nonblank, header-safe idempotency key is required.")
540
+ return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id, if_match=f'"{revision}"',
541
+ idempotency_key=idempotency_key,
542
+ body=SeriesEditInput.from_dict(edit.model_dump(mode="json", exclude_unset=True)))
543
+
444
544
  return server
445
545
 
446
546
 
@@ -0,0 +1,233 @@
1
+ """Calendar scheduling adapters: generated SDK + local and remote MCP wire.
2
+
3
+ Pure mocked HTTP; no API runtime imports, database, mail or external calendar.
4
+ """
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ import pytest
11
+ from daknode import AuthenticatedClient
12
+
13
+ from mcp_server import server
14
+ from mcp_server.calendar_models import (CalendarWindow, CalendarWorkingHours,
15
+ CalendarAvailability, CalendarBooking, CalendarSeriesEdit)
16
+ from mcp_server.event_source import CALENDAR_NAMES, JournalSource
17
+ from mcp_server.events import Events, ListParams
18
+ from mcp_server.identity import IdentityVerifier, PERMISSIONS
19
+ from mcp_server.remote import create_app
20
+
21
+ INBOX = "11111111-1111-4111-8111-111111111111"
22
+ EVENT = "22222222-2222-4222-8222-222222222222"
23
+ FUTURE = "33333333-3333-4333-8333-333333333333"
24
+ IDS = dict(inbox_id=INBOX, event_id=EVENT)
25
+ WINDOW = dict(start="2026-11-02T09:00:00Z", end="2026-11-02T13:00:00Z")
26
+ CREATE = dict(title="Approved meeting", timezone="UTC", starts_at="2026-11-02T10:00:00Z", ends_at="2026-11-02T11:00:00Z")
27
+ AVAILABILITY = dict(**WINDOW, timezone="UTC", working_hours=[dict(weekday=0, start="09:00", end="17:00")],
28
+ duration_minutes=30, step_minutes=15, buffer_before_minutes=5, buffer_after_minutes=10)
29
+ BOOKING = dict(event=CREATE, buffer_before_minutes=5, buffer_after_minutes=10)
30
+ EDIT = dict(scope="following", recurrence_id="2026-11-02T10:00:00+00:00", changes={"description": None})
31
+ EVENT_BODY = dict(**CREATE, id=EVENT, inbox_id=INBOX, uid="synthetic@daknode", source="local",
32
+ organizer="synthetic@example.test", all_day=False, description=None, location=None, start_date=None,
33
+ end_date=None, rrule=None, status="confirmed", revision=2, sequence=0, response="needs_action",
34
+ send_state="not_sent", attendees=[], created_at="2026-11-01T00:00:00Z", updated_at="2026-11-01T00:00:00Z")
35
+ READS = {"list_calendar_events", "get_calendar_agenda", "get_calendar_free_busy",
36
+ "get_calendar_availability", "get_calendar_reminders"}
37
+ WRITES = {"book_calendar_event", "set_calendar_reminders", "edit_calendar_series"}
38
+
39
+
40
+ async def wire(method, params, authority=None):
41
+ async def verify(key):
42
+ return authority or server.Authority(key, server.TOOL_NAMES, CALENDAR_NAMES, workspace_id=INBOX)
43
+ app = create_app(verify)
44
+ protocol = "2025-11-25"
45
+ if method.startswith("events/"):
46
+ protocol = "2026-07-28"
47
+ params = {**params, "_meta": {"io.modelcontextprotocol/protocolVersion": protocol,
48
+ "io.modelcontextprotocol/clientInfo": {"name": "scheduling-contract", "version": "1"},
49
+ "io.modelcontextprotocol/clientCapabilities": {"extensions": {"io.modelcontextprotocol/events": {}}}}}
50
+ async with app.app.router.lifespan_context(app.app):
51
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url=server.DEFAULT_BASE_URL) as client:
52
+ response = await client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
53
+ headers={"Authorization": "Bearer synthetic-calendar", "Accept": "application/json, text/event-stream",
54
+ "MCP-Protocol-Version": protocol, "MCP-Method": method})
55
+ assert response.status_code == 200, response.text
56
+ return response.json()["result"]
57
+
58
+
59
+ @pytest.fixture
60
+ def upstream(monkeypatch):
61
+ calls = []
62
+ def handle(request):
63
+ calls.append(request)
64
+ path = request.url.path
65
+ if path.endswith("/free-busy"):
66
+ return httpx.Response(200, json=dict(busy=[WINDOW], scope="inbox_only"))
67
+ if path.endswith("/availability"):
68
+ return httpx.Response(200, json=dict(slots=[WINDOW], scope="inbox_only", advisory=True))
69
+ if path.endswith("/reminders"):
70
+ return httpx.Response(200, json=dict(event_id=EVENT, revision=2, minutes_before_start=[0, 15, 40320]))
71
+ if path.endswith("/series-edit"):
72
+ return httpx.Response(200, json={**EVENT_BODY, "id": FUTURE})
73
+ if path.endswith("/events"):
74
+ return httpx.Response(200, json=dict(items=[EVENT_BODY], has_more=True, next_after=EVENT))
75
+ if path.endswith("/agenda"):
76
+ return httpx.Response(200, json=dict(items=[]))
77
+ return httpx.Response(201, json=EVENT_BODY)
78
+ def client():
79
+ authority = server.request_authority.get()
80
+ return AuthenticatedClient(base_url=server.DEFAULT_BASE_URL,
81
+ token=authority.api_key if authority else "synthetic-local",
82
+ httpx_args={"transport": httpx.MockTransport(handle)})
83
+ monkeypatch.setattr(server, "_client", client)
84
+ return calls
85
+
86
+
87
+ @pytest.mark.parametrize("remote", [False, True], ids=["local", "remote"])
88
+ @pytest.mark.parametrize("name,args,method,suffix,body", [
89
+ ("list_calendar_events", dict(inbox_id=INBOX, limit=7, after=EVENT), "GET", "/events", None),
90
+ ("get_calendar_agenda", dict(inbox_id=INBOX, **WINDOW), "GET", "/agenda", None),
91
+ ("get_calendar_free_busy", dict(inbox_id=INBOX, request=WINDOW), "POST", "/free-busy", WINDOW),
92
+ ("get_calendar_availability", dict(inbox_id=INBOX, request=AVAILABILITY), "POST", "/availability", AVAILABILITY),
93
+ ("book_calendar_event", dict(inbox_id=INBOX, booking=BOOKING, idempotency_key="book-once"), "POST", "/bookings", BOOKING),
94
+ ("get_calendar_reminders", IDS, "GET", f"/events/{EVENT}/reminders", None),
95
+ ("set_calendar_reminders", dict(**IDS, revision=2, minutes_before_start=[0, 15, 40320]), "PUT", f"/events/{EVENT}/reminders", dict(minutes_before_start=[0, 15, 40320])),
96
+ ("edit_calendar_series", dict(**IDS, revision=2, edit=EDIT, idempotency_key="split-once"), "POST", f"/events/{EVENT}/series-edit", EDIT),
97
+ ])
98
+ def test_one_generated_sdk_request_per_tool(upstream, remote, name, args, method, suffix, body):
99
+ if remote:
100
+ result = asyncio.run(wire("tools/call", {"name": name, "arguments": args}))
101
+ assert not result.get("isError"), result
102
+ else:
103
+ result = asyncio.run(server.build_server().call_tool(name, args))
104
+ assert not result.is_error, result
105
+ assert len(upstream) == 1
106
+ request = upstream[0]
107
+ assert request.method == method
108
+ assert request.url.path == f"/v1/inboxes/{INBOX}/calendar{suffix}"
109
+ assert request.headers["Authorization"] == ("Bearer synthetic-calendar" if remote else "Bearer synthetic-local")
110
+ if body is not None:
111
+ actual = json.loads(request.content)
112
+ # Generated datetime transport normalizes UTC Z to an explicit offset.
113
+ for k in ("start", "end", "starts_at", "ends_at"):
114
+ if k in actual:
115
+ actual[k] = actual[k].replace("+00:00", "Z")
116
+ if k in actual.get("event", {}):
117
+ actual["event"][k] = actual["event"][k].replace("+00:00", "Z")
118
+ expected = {**body}
119
+ if name == "book_calendar_event":
120
+ expected["event"] = {"attendees": [], **body["event"]}
121
+ assert actual == expected
122
+ if "revision" in args:
123
+ assert request.headers["If-Match"] == '"2"'
124
+ if "idempotency_key" in args:
125
+ assert request.headers["Idempotency-Key"] == args["idempotency_key"]
126
+ if name == "list_calendar_events":
127
+ assert dict(request.url.params) == {"limit": "7", "after": EVENT}
128
+ if name == "get_calendar_agenda":
129
+ assert request.url.params["start"].replace("+00:00", "Z") == WINDOW["start"]
130
+ if name == "edit_calendar_series":
131
+ assert FUTURE in str(result)
132
+
133
+
134
+ @pytest.mark.parametrize("permissions,expected", [
135
+ (["calendar.read"], READS), (["calendar.write"], WRITES), ([], set()), (None, READS | WRITES),
136
+ ])
137
+ def test_scoped_local_remote_discovery_and_reminder_events(permissions, expected):
138
+ async def run():
139
+ verifier = IdentityVerifier(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={
140
+ "resource": server.DEFAULT_BASE_URL, "workspace_id": INBOX, "inbox_id": INBOX,
141
+ "permissions": permissions, "expires_at": None})))
142
+ authority = await verifier("synthetic-calendar")
143
+ assert authority.inbox_id == INBOX
144
+ assert authority.tools & (READS | WRITES) == expected
145
+ token = server.request_authority.set(authority)
146
+ try:
147
+ local = {t.name for t in await server.build_server().list_tools()}
148
+ events = await Events(verifier).list_events(None, ListParams())
149
+ finally:
150
+ server.request_authority.reset(token)
151
+ remote = await wire("tools/list", {}, authority)
152
+ assert local == {t["name"] for t in remote["tools"]} == authority.tools
153
+ names = {e["name"] for e in events["events"]}
154
+ remote_events = await wire("events/list", {}, authority)
155
+ assert {e["name"] for e in remote_events["events"]} == names
156
+ assert ("calendar.event.reminder" in names) == (permissions is None or "calendar.read" in permissions)
157
+ assert "create_inbox" not in local
158
+ return authority
159
+ asyncio.run(run())
160
+
161
+
162
+ @pytest.mark.parametrize("name,args", [
163
+ ("get_calendar_free_busy", dict(inbox_id=INBOX, request={**WINDOW, "end": "2027-01-01T00:00:00Z"})),
164
+ ("get_calendar_free_busy", dict(inbox_id=INBOX, request={**WINDOW, "start": "2026-11-02T09:00:00"})),
165
+ ("get_calendar_availability", dict(inbox_id=INBOX, request={**AVAILABILITY, "step_minutes": True})),
166
+ ("book_calendar_event", dict(inbox_id=INBOX, booking=BOOKING)),
167
+ ("book_calendar_event", dict(inbox_id=INBOX, booking=BOOKING, idempotency_key=" ")),
168
+ ("book_calendar_event", dict(inbox_id=INBOX, booking={"event": {**CREATE, "rrule": "FREQ=DAILY"}}, idempotency_key="bad")),
169
+ ("set_calendar_reminders", dict(**IDS, revision=True, minutes_before_start=[15])),
170
+ ("set_calendar_reminders", dict(**IDS, revision=2, minutes_before_start=[40321])),
171
+ ("set_calendar_reminders", dict(**IDS, revision=2, minutes_before_start=[15] * 6)),
172
+ ("set_calendar_reminders", dict(**IDS, revision=2, minutes_before_start=[15, 15])),
173
+ ("edit_calendar_series", dict(**IDS, revision=2, edit={**EDIT, "scope": "auto"}, idempotency_key="bad")),
174
+ ("edit_calendar_series", dict(**IDS, revision=2, edit=EDIT, idempotency_key="bad\r\nheader")),
175
+ ])
176
+ def test_invalid_inputs_do_not_reach_http(upstream, name, args):
177
+ result = asyncio.run(wire("tools/call", {"name": name, "arguments": args}))
178
+ assert result["isError"] and not upstream
179
+
180
+
181
+ @pytest.mark.parametrize("name,args", [
182
+ ("get_calendar_free_busy", dict(inbox_id=INBOX, request=WINDOW)),
183
+ ("book_calendar_event", dict(inbox_id=INBOX, booking=BOOKING, idempotency_key="blocked")),
184
+ ("edit_calendar_series", dict(**IDS, revision=2, edit=EDIT, idempotency_key="blocked")),
185
+ ])
186
+ def test_permission_denied_tools_never_reach_http(upstream, name, args):
187
+ authority = server.Authority("synthetic-calendar", frozenset(), workspace_id=INBOX, inbox_id=INBOX)
188
+ result = asyncio.run(wire("tools/call", {"name": name, "arguments": args}, authority))
189
+ assert result["isError"] and not upstream
190
+
191
+
192
+ @pytest.mark.parametrize("name,args", [
193
+ ("book_calendar_event", dict(inbox_id=INBOX, booking=BOOKING, idempotency_key="retry-same")),
194
+ ("edit_calendar_series", dict(**IDS, revision=2, edit=EDIT, idempotency_key="retry-same")),
195
+ ("set_calendar_reminders", dict(**IDS, revision=2, minutes_before_start=[])),
196
+ ])
197
+ def test_conflict_does_not_retry_or_refresh(monkeypatch, name, args):
198
+ calls = []
199
+ def handle(request):
200
+ calls.append(request)
201
+ return httpx.Response(409, json={"error": {"code": "conflict"}})
202
+ monkeypatch.setattr(server, "_client", lambda: AuthenticatedClient(base_url=server.DEFAULT_BASE_URL,
203
+ token="synthetic", httpx_args={"transport": httpx.MockTransport(handle)}))
204
+ result = asyncio.run(wire("tools/call", {"name": name, "arguments": args}))
205
+ assert result["isError"] and len(calls) == 1
206
+
207
+
208
+ def test_new_schema_fields_match_public_contract():
209
+ spec = json.loads((Path(__file__).resolve().parents[2] / "sdk/openapi.public.json").read_text())
210
+ for model, schema in [(CalendarWindow, "WindowInput"), (CalendarWorkingHours, "WorkingHours"),
211
+ (CalendarAvailability, "AvailabilityInput"), (CalendarBooking, "BookingInput"),
212
+ (CalendarSeriesEdit, "SeriesEditInput")]:
213
+ actual, expected = model.model_json_schema(), spec["components"]["schemas"][schema]
214
+ assert set(actual["properties"]) == set(expected["properties"])
215
+ assert set(actual.get("required", [])) == set(expected.get("required", []))
216
+ assert actual["additionalProperties"] is False
217
+ assert server.TOOL_NAMES == frozenset(PERMISSIONS)
218
+
219
+
220
+ def test_reminder_journal_metadata_is_bounded_and_scoped():
221
+ from datetime import datetime, timezone
222
+ calls = []
223
+ def handle(request):
224
+ calls.append(request)
225
+ return httpx.Response(200, json={"items": [dict(id=FUTURE, event_id=EVENT, event_revision=2,
226
+ type="calendar.event.reminder", occurred_at=datetime.now(timezone.utc).isoformat())],
227
+ "next_cursor": "signed-cursor", "has_more": False})
228
+ authority = server.Authority("synthetic", frozenset(), CALENDAR_NAMES, workspace_id=INBOX, inbox_id=INBOX)
229
+ page = asyncio.run(JournalSource(transport=httpx.MockTransport(handle)).poll(authority,
230
+ "calendar.event.reminder", INBOX, None, 300000))
231
+ assert page.events[0]["data"] == dict(inboxId=INBOX, eventId=EVENT, revision=2)
232
+ assert calls[0].url.path.endswith(f"/{INBOX}/calendar/changes")
233
+ assert calls[0].url.params["limit"] == "1"
@@ -1,55 +0,0 @@
1
- """MCP input schemas matching the published Calendar request bodies.
2
-
3
- These are validation-only transport models. Generated SDK models still build
4
- the API requests. Timezone, recurrence and organizer authority stay in the API.
5
- Explicit nulls and omitted patch fields must remain different.
6
- """
7
- from datetime import date, datetime
8
- from typing import Annotated
9
-
10
- from pydantic import BaseModel, ConfigDict, Field
11
-
12
- Title = Annotated[str, Field(min_length=1, max_length=1000)]
13
- Timezone = Annotated[str, Field(min_length=1, max_length=100)]
14
- Description = Annotated[str, Field(max_length=50000)]
15
- Location = Annotated[str, Field(max_length=2000)]
16
- Recurrence = Annotated[str, Field(max_length=2000)]
17
-
18
-
19
- class CalendarAttendee(BaseModel):
20
- model_config = ConfigDict(extra="forbid")
21
- email: Annotated[str, Field(min_length=3, max_length=320)]
22
- name: Annotated[str, Field(max_length=255)] | None = None
23
-
24
-
25
- Attendees = Annotated[list[CalendarAttendee], Field(max_length=100)]
26
-
27
-
28
- class CalendarCreate(BaseModel):
29
- model_config = ConfigDict(extra="forbid")
30
- title: Title
31
- timezone: Timezone
32
- all_day: bool = False
33
- attendees: Attendees = Field(default_factory=list)
34
- description: Description | None = None
35
- location: Location | None = None
36
- rrule: Recurrence | None = None
37
- starts_at: datetime | None = None
38
- ends_at: datetime | None = None
39
- start_date: date | None = None
40
- end_date: date | None = None
41
-
42
-
43
- class CalendarPatch(BaseModel):
44
- model_config = ConfigDict(extra="forbid")
45
- title: Title | None = None
46
- timezone: Timezone | None = None
47
- all_day: bool | None = None
48
- attendees: Attendees | None = None
49
- description: Description | None = None
50
- location: Location | None = None
51
- rrule: Recurrence | None = None
52
- starts_at: datetime | None = None
53
- ends_at: datetime | None = None
54
- start_date: date | None = None
55
- end_date: date | None = None
@@ -1,2 +0,0 @@
1
- daknode<0.4,>=0.3.8
2
- mcp<3,>=2.2
File without changes
File without changes
File without changes
File without changes