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.
- {daknode_mcp-0.3.3/daknode_mcp.egg-info → daknode_mcp-0.3.4}/PKG-INFO +33 -9
- daknode_mcp-0.3.3/PKG-INFO → daknode_mcp-0.3.4/README.md +31 -18
- daknode_mcp-0.3.4/calendar_models.py +110 -0
- daknode_mcp-0.3.3/README.md → daknode_mcp-0.3.4/daknode_mcp.egg-info/PKG-INFO +42 -7
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/daknode_mcp.egg-info/SOURCES.txt +1 -0
- daknode_mcp-0.3.4/daknode_mcp.egg-info/requires.txt +2 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/event_source.py +1 -1
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/identity.py +8 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/pyproject.toml +2 -2
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/server.py +103 -3
- daknode_mcp-0.3.4/tests/test_calendar_scheduling.py +233 -0
- daknode_mcp-0.3.3/calendar_models.py +0 -55
- daknode_mcp-0.3.3/daknode_mcp.egg-info/requires.txt +0 -2
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/__init__.py +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/daknode_mcp.egg-info/dependency_links.txt +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/daknode_mcp.egg-info/entry_points.txt +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/daknode_mcp.egg-info/top_level.txt +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/events.py +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/remote.py +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/setup.cfg +0 -0
- {daknode_mcp-0.3.3 → daknode_mcp-0.3.4}/tests/test_write_tools.py +0 -0
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: daknode-mcp
|
|
3
|
-
Version: 0.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.
|
|
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.
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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.
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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.
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|