daknode-mcp 0.3.3__tar.gz → 0.3.5__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,25 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daknode-mcp
3
- Version: 0.3.3
3
+ Version: 0.3.5
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
- Project-URL: Documentation, https://daknode.com/developers
6
+ Project-URL: Documentation, https://daknode.com/integrations/mcp/
7
+ Project-URL: Security, https://daknode.com/security/reporting/
8
+ Keywords: daknode,mcp,email,calendar,developer-tools
7
9
  Requires-Python: >=3.10
8
10
  Description-Content-Type: text/markdown
9
- Requires-Dist: daknode<0.4,>=0.3.8
11
+ Requires-Dist: daknode<0.4,>=0.3.9
10
12
  Requires-Dist: mcp<3,>=2.2
11
13
 
12
14
  # DakNode MCP server
13
15
 
14
16
  <!-- mcp-name: io.github.Subodh103/daknode -->
15
17
 
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.
18
+ DakNode MCP 0.3.5 exposes 21 email and calendar API tools over **stdio**,
19
+ including availability, reliable booking, reminders and recurring-event edits.
20
+ An authenticated hosted endpoint is also available at
21
+ `https://api.daknode.com/mcp`; local installation does not connect to it or
22
+ authorize any action automatically.
19
23
 
20
24
  ## Install
21
25
 
@@ -43,8 +47,13 @@ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
43
47
 
44
48
  The key comes from the environment and **never from a command-line argument**:
45
49
  argv is visible to every process through `ps`, and shells record it in history.
46
- The server refuses to start without it, so a misconfiguration is obvious at
47
- launch rather than looking like a broken tool.
50
+ Version 0.3.5 supports
51
+ credential-free local `initialize`, `tools/list` and empty `prompts/list`, so a
52
+ client can inspect static schemas before setup. Every operation still requires credentials and fails with structured
53
+ `authentication_required` setup guidance if the key is missing; no API request
54
+ is sent. Configure the key privately, then restart/reconnect the client.
55
+ Hosted MCP authentication and permission-filtered discovery are unchanged:
56
+ this is not anonymous access to the remote endpoint or to customer data.
48
57
 
49
58
  ## Tools
50
59
 
@@ -53,9 +62,31 @@ launch rather than looking like a broken tool.
53
62
 
54
63
  Calendar: `get_calendar_event` · `create_calendar_event` · `update_calendar_event`
55
64
  · `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.
65
+ This release requires Python SDK **>=0.3.9,<0.4** and also exposes
66
+ `list_calendar_events`, `get_calendar_agenda`,
67
+ `get_calendar_free_busy`, `get_calendar_availability`, `book_calendar_event`,
68
+ `get_calendar_reminders`, `set_calendar_reminders`, and `edit_calendar_series`.
69
+ Each invokes exactly one corresponding generated SDK operation. No Calendar
70
+ deletion or unrelated management tools are added.
71
+
72
+ The list/agenda/free-busy/availability/reminder reads require `calendar.read`;
73
+ booking, reminder changes and series edits require `calendar.write`.
74
+ Free-busy takes `request: {start, end}`. Availability takes `request` with that
75
+ window plus IANA `timezone`, `working_hours: [{weekday, start, end}]` (Monday=0,
76
+ local HH:MM), `duration_minutes`, `step_minutes` and optional before/after buffer
77
+ minutes. Windows are explicit and at most 31 days. Slots are advisory and
78
+ inbox-only: external attendee calendars are unknown, never represented as free.
79
+ Booking takes `booking: {event, buffer_before_minutes?, buffer_after_minutes?}`
80
+ and a stable `idempotency_key`; only one non-recurring timed event is accepted.
81
+
82
+ Reminder writes take `revision` and `minutes_before_start`: up to five unique
83
+ integer offsets from 0 through 40320, or `[]` to clear. They configure developer
84
+ notifications, not attendee email. Series edits take `revision`, a stable retry
85
+ key, and `edit: {scope, recurrence_id?, cancelled?, changes?}` with explicit
86
+ `occurrence`, `series` or `following` scope. A following edit can return a new
87
+ future event ID; other edits return the original event. Optional patch fields
88
+ and explicit nulls retain their API meaning. No conflict triggers an automatic
89
+ revision refresh or retry.
59
90
 
60
91
  `get_calendar_event` requires `calendar.read`. The Calendar writes require
61
92
  `calendar.write`; invitation dispatch additionally requires `messages.send`.
@@ -224,7 +255,7 @@ Two distinct capabilities are implemented and wire-tested:
224
255
 
225
256
  The default verifier exposes `message.received` for `messages.read`, and
226
257
  `calendar.event.created`, `.updated`, `.cancelled`, `.responded`, `.starting`,
227
- `.ending` for `calendar.read`. Inbox-scoped credentials can subscribe only to
258
+ `.ending`, `.reminder` for `calendar.read` in this source checkout. Inbox-scoped credentials can subscribe only to
228
259
  their own inbox. Event payloads contain identifiers and calendar revision only;
229
260
  no bodies, subjects, event titles, attendees or email addresses. This is not a
230
261
  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.5 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
 
@@ -43,8 +34,13 @@ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
43
34
 
44
35
  The key comes from the environment and **never from a command-line argument**:
45
36
  argv is visible to every process through `ps`, and shells record it in history.
46
- The server refuses to start without it, so a misconfiguration is obvious at
47
- launch rather than looking like a broken tool.
37
+ Version 0.3.5 supports
38
+ credential-free local `initialize`, `tools/list` and empty `prompts/list`, so a
39
+ client can inspect static schemas before setup. Every operation still requires credentials and fails with structured
40
+ `authentication_required` setup guidance if the key is missing; no API request
41
+ is sent. Configure the key privately, then restart/reconnect the client.
42
+ Hosted MCP authentication and permission-filtered discovery are unchanged:
43
+ this is not anonymous access to the remote endpoint or to customer data.
48
44
 
49
45
  ## Tools
50
46
 
@@ -53,9 +49,31 @@ launch rather than looking like a broken tool.
53
49
 
54
50
  Calendar: `get_calendar_event` · `create_calendar_event` · `update_calendar_event`
55
51
  · `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.
52
+ This release requires Python SDK **>=0.3.9,<0.4** and also exposes
53
+ `list_calendar_events`, `get_calendar_agenda`,
54
+ `get_calendar_free_busy`, `get_calendar_availability`, `book_calendar_event`,
55
+ `get_calendar_reminders`, `set_calendar_reminders`, and `edit_calendar_series`.
56
+ Each invokes exactly one corresponding generated SDK operation. No Calendar
57
+ deletion or unrelated management tools are added.
58
+
59
+ The list/agenda/free-busy/availability/reminder reads require `calendar.read`;
60
+ booking, reminder changes and series edits require `calendar.write`.
61
+ Free-busy takes `request: {start, end}`. Availability takes `request` with that
62
+ window plus IANA `timezone`, `working_hours: [{weekday, start, end}]` (Monday=0,
63
+ local HH:MM), `duration_minutes`, `step_minutes` and optional before/after buffer
64
+ minutes. Windows are explicit and at most 31 days. Slots are advisory and
65
+ inbox-only: external attendee calendars are unknown, never represented as free.
66
+ Booking takes `booking: {event, buffer_before_minutes?, buffer_after_minutes?}`
67
+ and a stable `idempotency_key`; only one non-recurring timed event is accepted.
68
+
69
+ Reminder writes take `revision` and `minutes_before_start`: up to five unique
70
+ integer offsets from 0 through 40320, or `[]` to clear. They configure developer
71
+ notifications, not attendee email. Series edits take `revision`, a stable retry
72
+ key, and `edit: {scope, recurrence_id?, cancelled?, changes?}` with explicit
73
+ `occurrence`, `series` or `following` scope. A following edit can return a new
74
+ future event ID; other edits return the original event. Optional patch fields
75
+ and explicit nulls retain their API meaning. No conflict triggers an automatic
76
+ revision refresh or retry.
59
77
 
60
78
  `get_calendar_event` requires `calendar.read`. The Calendar writes require
61
79
  `calendar.write`; invitation dispatch additionally requires `messages.send`.
@@ -224,7 +242,7 @@ Two distinct capabilities are implemented and wire-tested:
224
242
 
225
243
  The default verifier exposes `message.received` for `messages.read`, and
226
244
  `calendar.event.created`, `.updated`, `.cancelled`, `.responded`, `.starting`,
227
- `.ending` for `calendar.read`. Inbox-scoped credentials can subscribe only to
245
+ `.ending`, `.reminder` for `calendar.read` in this source checkout. Inbox-scoped credentials can subscribe only to
228
246
  their own inbox. Event payloads contain identifiers and calendar revision only;
229
247
  no bodies, subjects, event titles, attendees or email addresses. This is not a
230
248
  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,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: daknode-mcp
3
+ Version: 0.3.5
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/integrations/mcp/
7
+ Project-URL: Security, https://daknode.com/security/reporting/
8
+ Keywords: daknode,mcp,email,calendar,developer-tools
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: daknode<0.4,>=0.3.9
12
+ Requires-Dist: mcp<3,>=2.2
13
+
1
14
  # DakNode MCP server
2
15
 
3
16
  <!-- mcp-name: io.github.Subodh103/daknode -->
4
17
 
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.
18
+ DakNode MCP 0.3.5 exposes 21 email and calendar API tools over **stdio**,
19
+ including availability, reliable booking, reminders and recurring-event edits.
20
+ An authenticated hosted endpoint is also available at
21
+ `https://api.daknode.com/mcp`; local installation does not connect to it or
22
+ authorize any action automatically.
8
23
 
9
24
  ## Install
10
25
 
@@ -32,8 +47,13 @@ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
32
47
 
33
48
  The key comes from the environment and **never from a command-line argument**:
34
49
  argv is visible to every process through `ps`, and shells record it in history.
35
- The server refuses to start without it, so a misconfiguration is obvious at
36
- launch rather than looking like a broken tool.
50
+ Version 0.3.5 supports
51
+ credential-free local `initialize`, `tools/list` and empty `prompts/list`, so a
52
+ client can inspect static schemas before setup. Every operation still requires credentials and fails with structured
53
+ `authentication_required` setup guidance if the key is missing; no API request
54
+ is sent. Configure the key privately, then restart/reconnect the client.
55
+ Hosted MCP authentication and permission-filtered discovery are unchanged:
56
+ this is not anonymous access to the remote endpoint or to customer data.
37
57
 
38
58
  ## Tools
39
59
 
@@ -42,9 +62,31 @@ launch rather than looking like a broken tool.
42
62
 
43
63
  Calendar: `get_calendar_event` · `create_calendar_event` · `update_calendar_event`
44
64
  · `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.
65
+ This release requires Python SDK **>=0.3.9,<0.4** and also exposes
66
+ `list_calendar_events`, `get_calendar_agenda`,
67
+ `get_calendar_free_busy`, `get_calendar_availability`, `book_calendar_event`,
68
+ `get_calendar_reminders`, `set_calendar_reminders`, and `edit_calendar_series`.
69
+ Each invokes exactly one corresponding generated SDK operation. No Calendar
70
+ deletion or unrelated management tools are added.
71
+
72
+ The list/agenda/free-busy/availability/reminder reads require `calendar.read`;
73
+ booking, reminder changes and series edits require `calendar.write`.
74
+ Free-busy takes `request: {start, end}`. Availability takes `request` with that
75
+ window plus IANA `timezone`, `working_hours: [{weekday, start, end}]` (Monday=0,
76
+ local HH:MM), `duration_minutes`, `step_minutes` and optional before/after buffer
77
+ minutes. Windows are explicit and at most 31 days. Slots are advisory and
78
+ inbox-only: external attendee calendars are unknown, never represented as free.
79
+ Booking takes `booking: {event, buffer_before_minutes?, buffer_after_minutes?}`
80
+ and a stable `idempotency_key`; only one non-recurring timed event is accepted.
81
+
82
+ Reminder writes take `revision` and `minutes_before_start`: up to five unique
83
+ integer offsets from 0 through 40320, or `[]` to clear. They configure developer
84
+ notifications, not attendee email. Series edits take `revision`, a stable retry
85
+ key, and `edit: {scope, recurrence_id?, cancelled?, changes?}` with explicit
86
+ `occurrence`, `series` or `following` scope. A following edit can return a new
87
+ future event ID; other edits return the original event. Optional patch fields
88
+ and explicit nulls retain their API meaning. No conflict triggers an automatic
89
+ revision refresh or retry.
48
90
 
49
91
  `get_calendar_event` requires `calendar.read`. The Calendar writes require
50
92
  `calendar.write`; invitation dispatch additionally requires `messages.send`.
@@ -213,7 +255,7 @@ Two distinct capabilities are implemented and wire-tested:
213
255
 
214
256
  The default verifier exposes `message.received` for `messages.read`, and
215
257
  `calendar.event.created`, `.updated`, `.cancelled`, `.responded`, `.starting`,
216
- `.ending` for `calendar.read`. Inbox-scoped credentials can subscribe only to
258
+ `.ending`, `.reminder` for `calendar.read` in this source checkout. Inbox-scoped credentials can subscribe only to
217
259
  their own inbox. Event payloads contain identifiers and calendar revision only;
218
260
  no bodies, subjects, event titles, attendees or email addresses. This is not a
219
261
  claim of AgentMail's exact spam/authentication filtering policy: retained mail
@@ -20,4 +20,6 @@ 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
24
+ tests/test_local_discovery.py
23
25
  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,18 +7,20 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "daknode-mcp"
10
- version = "0.3.3"
10
+ version = "0.3.5"
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
+ keywords = ["daknode", "mcp", "email", "calendar", "developer-tools"]
14
15
  dependencies = [
15
- "daknode>=0.3.8,<0.4",
16
+ "daknode>=0.3.9,<0.4",
16
17
  "mcp>=2.2,<3",
17
18
  ]
18
19
 
19
20
  [project.urls]
20
21
  Homepage = "https://daknode.com"
21
- Documentation = "https://daknode.com/developers"
22
+ Documentation = "https://daknode.com/integrations/mcp/"
23
+ Security = "https://daknode.com/security/reporting/"
22
24
 
23
25
  [project.scripts]
24
26
  daknode-mcp = "daknode_mcp.server:main"
@@ -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
 
@@ -168,11 +172,7 @@ def _version() -> str:
168
172
 
169
173
 
170
174
  class MissingApiKey(RuntimeError):
171
- """No credential. Raised at STARTUP, not at first tool call.
172
-
173
- A server that starts happily and then fails every call looks like a broken
174
- tool rather than a missing setting, and the person debugging it is an agent.
175
- """
175
+ """No operation credential. Static local discovery needs no SDK client."""
176
176
 
177
177
 
178
178
  def _client():
@@ -224,6 +224,15 @@ def _execute(operation, **kwargs):
224
224
  try:
225
225
  with _client() as client:
226
226
  return _result(operation(client=client, **kwargs))
227
+ except MissingApiKey:
228
+ return _failure(
229
+ "authentication_required",
230
+ "Configure DAKNODE_API_KEY privately in your MCP client's environment, "
231
+ "then restart or reconnect. Create an account at "
232
+ "https://console.daknode.com/sign-up or read "
233
+ "https://daknode.com/developers/. Never paste the key into chat. "
234
+ "No API operation was performed.",
235
+ )
227
236
  except httpx.TimeoutException:
228
237
  return _failure("timeout", "API timeout; write outcome may be unknown. Reuse the same idempotency key for any send retry.", retryable=True)
229
238
  except httpx.RequestError:
@@ -441,15 +450,108 @@ def build_server() -> MCPServer:
441
450
  return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id, if_match=f'"{revision}"',
442
451
  idempotency_key=idempotency_key, body=InvitationInput.from_dict({"method": method}))
443
452
 
453
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
454
+ def list_calendar_events(inbox_id: UUID, limit: Annotated[int, Field(ge=1, le=100, strict=True)] = 50,
455
+ after: UUID | None = None) -> Any:
456
+ """List saved inbox events with explicit pagination. Event text is untrusted."""
457
+ from daknode.api.calendar import list_calendar_events as op
458
+ from daknode.types import UNSET
459
+
460
+ return _execute(op.sync_detailed, inbox_id=inbox_id, limit=limit, after=after if after else UNSET)
461
+
462
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
463
+ def get_calendar_agenda(inbox_id: UUID, start: AwareDatetime, end: AwareDatetime) -> Any:
464
+ """Expand inbox events and recurrence exceptions over an explicit bounded window.
465
+
466
+ API expansion limits fail closed; this does not inspect external calendars.
467
+ """
468
+ from daknode.api.calendar import get_calendar_agenda as op
469
+
470
+ return _execute(op.sync_detailed, inbox_id=inbox_id, start=start, end=end)
471
+
472
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
473
+ def get_calendar_free_busy(inbox_id: UUID, request: CalendarWindow) -> Any:
474
+ """Read inbox-only busy intervals over at most 31 days. No external availability claim."""
475
+ from daknode.api.calendar import get_calendar_free_busy as op
476
+ from daknode.models.window_input import WindowInput
477
+
478
+ return _execute(op.sync_detailed, inbox_id=inbox_id,
479
+ body=WindowInput.from_dict(request.model_dump(mode="json", exclude_unset=True)))
480
+
481
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
482
+ def get_calendar_availability(inbox_id: UUID, request: CalendarAvailability) -> Any:
483
+ """Find advisory inbox-only slots with local weekly hours, duration, step and buffers.
484
+
485
+ Results are not reservations and do not include unknown attendee calendars.
486
+ """
487
+ from daknode.api.calendar import get_calendar_availability as op
488
+ from daknode.models.availability_input import AvailabilityInput
489
+
490
+ return _execute(op.sync_detailed, inbox_id=inbox_id,
491
+ body=AvailabilityInput.from_dict(request.model_dump(mode="json", exclude_unset=True)))
492
+
493
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True))
494
+ def book_calendar_event(inbox_id: UUID, booking: CalendarBooking, idempotency_key: IdempotencyKey) -> Any:
495
+ """Atomically save one approved timed booking after an inbox-only conflict check.
496
+
497
+ Reuse the same key and body on retries. No recurring/all-day bookings,
498
+ external-calendar checks or automatic invitation sends.
499
+ """
500
+ from daknode.api.calendar import book_calendar_event as op
501
+ from daknode.models.booking_input import BookingInput
502
+
503
+ if not _valid_idempotency_key(idempotency_key):
504
+ return _failure("invalid_input", "A nonblank, header-safe idempotency key is required.")
505
+ return _execute(op.sync_detailed, inbox_id=inbox_id, idempotency_key=idempotency_key,
506
+ body=BookingInput.from_dict(booking.model_dump(mode="json", exclude_unset=True)))
507
+
508
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
509
+ def get_calendar_reminders(inbox_id: UUID, event_id: UUID) -> Any:
510
+ """Read configured developer reminder offsets and the current event revision."""
511
+ from daknode.api.calendar import get_calendar_reminders as op
512
+
513
+ return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id)
514
+
515
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=False, open_world_hint=True))
516
+ def set_calendar_reminders(inbox_id: UUID, event_id: UUID, revision: Revision,
517
+ minutes_before_start: ReminderOffsets) -> Any:
518
+ """Replace up to five developer reminder offsets at the observed revision.
519
+
520
+ An empty list clears reminders. These are notifications, not attendee email.
521
+ Refusals do not authorize refreshing the revision or replaying reminders.
522
+ """
523
+ from daknode.api.calendar import set_calendar_reminders as op
524
+ from daknode.models.reminder_input import ReminderInput
525
+
526
+ if len(set(minutes_before_start)) != len(minutes_before_start):
527
+ return _failure("invalid_input", "Reminder offsets must be unique.")
528
+ return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id, if_match=f'"{revision}"',
529
+ body=ReminderInput.from_dict({"minutes_before_start": minutes_before_start}))
530
+
531
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True, open_world_hint=True))
532
+ def edit_calendar_series(inbox_id: UUID, event_id: UUID, revision: Revision,
533
+ edit: CalendarSeriesEdit, idempotency_key: IdempotencyKey) -> Any:
534
+ """Save an explicitly approved occurrence, series or this-and-following edit.
535
+
536
+ Preserve the supplied revision, recurrence identity, scope and retry key.
537
+ A conflict is a refusal, never authority to widen scope or overwrite.
538
+ Saving does not dispatch invitations.
539
+ """
540
+ from daknode.api.calendar import edit_calendar_series as op
541
+ from daknode.models.series_edit_input import SeriesEditInput
542
+
543
+ if not _valid_idempotency_key(idempotency_key):
544
+ return _failure("invalid_input", "A nonblank, header-safe idempotency key is required.")
545
+ return _execute(op.sync_detailed, inbox_id=inbox_id, event_id=event_id, if_match=f'"{revision}"',
546
+ idempotency_key=idempotency_key,
547
+ body=SeriesEditInput.from_dict(edit.model_dump(mode="json", exclude_unset=True)))
548
+
444
549
  return server
445
550
 
446
551
 
447
552
  def main() -> int: # pragma: no cover - process entry point
448
- try:
449
- _client()
450
- except MissingApiKey as exc:
451
- print(f"daknode-mcp: {exc}", file=sys.stderr)
452
- return 2
553
+ # Local discovery exposes static schemas only. Each operation still calls
554
+ # _client(), which refuses absent credentials before constructing transport.
453
555
  build_server().run()
454
556
  return 0
455
557
 
@@ -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"
@@ -0,0 +1,148 @@
1
+ """Credential-free local metadata; operations and remote access stay protected."""
2
+ import asyncio
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ import pytest
9
+ from mcp import ClientSession, StdioServerParameters
10
+ from mcp.client.stdio import stdio_client
11
+
12
+ from mcp_server import server
13
+
14
+ ID = "11111111-1111-4111-8111-111111111111"
15
+ IDS = {"inbox_id": ID, "event_id": ID}
16
+ WINDOW = {"start": "2030-11-04T09:00:00Z", "end": "2030-11-04T17:00:00Z"}
17
+ EVENT = {"title": "Synthetic test", "timezone": "UTC", "starts_at": WINDOW["start"], "ends_at": WINDOW["end"]}
18
+ ARGS = {
19
+ "create_inbox": {"alias": "synthetic"},
20
+ "list_threads": {"inbox_id": ID}, "list_messages": {"inbox_id": ID},
21
+ "get_thread": {"thread_id": ID}, "get_message": {"message_id": ID},
22
+ "search": {"query": "private-input-sentinel"},
23
+ "send": {"inbox_id": ID, "to": "synthetic@example.test", "subject": "Synthetic", "body_text": "No send", "idempotency_key": "synthetic-operation"},
24
+ "get_calendar_event": IDS,
25
+ "create_calendar_event": {"inbox_id": ID, "event": EVENT, "idempotency_key": "synthetic-operation"},
26
+ "update_calendar_event": {**IDS, "revision": 1, "patch": {"description": "Synthetic"}},
27
+ "cancel_calendar_event": {**IDS, "revision": 1},
28
+ "respond_calendar_event": {**IDS, "revision": 1, "response": "accepted"},
29
+ "send_calendar_invitations": {**IDS, "revision": 1, "idempotency_key": "synthetic-operation"},
30
+ "list_calendar_events": {"inbox_id": ID},
31
+ "get_calendar_agenda": {"inbox_id": ID, **WINDOW},
32
+ "get_calendar_free_busy": {"inbox_id": ID, "request": WINDOW},
33
+ "get_calendar_availability": {"inbox_id": ID, "request": {**WINDOW, "timezone": "UTC", "working_hours": [{"weekday": 0, "start": "09:00", "end": "17:00"}], "duration_minutes": 30, "step_minutes": 15}},
34
+ "book_calendar_event": {"inbox_id": ID, "booking": {"event": EVENT}, "idempotency_key": "synthetic-operation"},
35
+ "get_calendar_reminders": IDS,
36
+ "set_calendar_reminders": {**IDS, "revision": 1, "minutes_before_start": [15]},
37
+ "edit_calendar_series": {**IDS, "revision": 1, "edit": {"scope": "series", "changes": {"description": "Synthetic"}}, "idempotency_key": "synthetic-operation"},
38
+ }
39
+
40
+
41
+ def assert_setup_refusal(result):
42
+ assert result.is_error, result
43
+ payload = json.loads(result.content[0].text)
44
+ assert payload["error"]["code"] == "authentication_required"
45
+ assert "https://console.daknode.com/sign-up" in payload["error"]["message"]
46
+ assert "privately" in payload["error"]["message"]
47
+ assert "private-input-sentinel" not in result.content[0].text
48
+
49
+
50
+ def test_no_new_tool_or_fake_prompt_is_introduced(monkeypatch):
51
+ monkeypatch.delenv("DAKNODE_API_KEY", raising=False)
52
+ def forbidden(*args, **kwargs):
53
+ pytest.fail("Discovery constructed an authenticated client")
54
+ monkeypatch.setattr(server, "_client", forbidden)
55
+ async def run():
56
+ adapter = server.build_server()
57
+ assert {tool.name for tool in await adapter.list_tools()} == set(ARGS) == server.TOOL_NAMES
58
+ assert await adapter.list_prompts() == []
59
+ asyncio.run(run())
60
+
61
+
62
+ @pytest.mark.parametrize("name", sorted(ARGS))
63
+ @pytest.mark.parametrize("key", [None, "", " \t\n"])
64
+ def test_every_valid_operation_refuses_missing_credentials_before_transport(monkeypatch, name, key):
65
+ if key is None:
66
+ monkeypatch.delenv("DAKNODE_API_KEY", raising=False)
67
+ else:
68
+ monkeypatch.setenv("DAKNODE_API_KEY", key)
69
+ def forbidden(*args, **kwargs):
70
+ pytest.fail("No key must mean no HTTP transport")
71
+ monkeypatch.setattr(server, "BoundedTransport", forbidden)
72
+ result = asyncio.run(server.build_server().call_tool(name, ARGS[name]))
73
+ assert_setup_refusal(result)
74
+
75
+
76
+ def test_main_does_not_construct_a_client_or_write_protocol_noise(monkeypatch, capsys):
77
+ monkeypatch.delenv("DAKNODE_API_KEY", raising=False)
78
+ ran = []
79
+ class Adapter:
80
+ def run(self):
81
+ ran.append(True)
82
+ monkeypatch.setattr(server, "build_server", Adapter)
83
+ monkeypatch.setattr(server, "_client", lambda: pytest.fail("Startup must not build an operation client"))
84
+ assert server.main() == 0
85
+ assert ran == [True]
86
+ captured = capsys.readouterr()
87
+ assert captured.out == captured.err == ""
88
+
89
+
90
+ def test_actual_stdio_initialize_discovery_and_refusal_without_key(monkeypatch):
91
+ monkeypatch.delenv("DAKNODE_API_KEY", raising=False)
92
+ # A real child process, not the already-installed public0.3.4 entry point.
93
+ # Deny sockets so this can never hit a real API even if the guard regresses.
94
+ bootstrap = (
95
+ "import runpy,socket; "
96
+ "socket.socket.connect=lambda *a,**k: (_ for _ in ()).throw(RuntimeError('Network forbidden in discovery test')); "
97
+ "runpy.run_module('mcp_server.server',run_name='__main__')"
98
+ )
99
+ params = StdioServerParameters(command=sys.executable, args=["-c", bootstrap],
100
+ cwd=str(Path(__file__).resolve().parents[2]), env={"PATH": os.defpath})
101
+ async def run():
102
+ async with stdio_client(params) as (reader, writer):
103
+ async with ClientSession(reader, writer) as client:
104
+ await client.initialize()
105
+ tools = await client.list_tools()
106
+ assert {tool.name for tool in tools.tools} == server.TOOL_NAMES
107
+ assert (await client.list_prompts()).prompts == []
108
+ for name in ("list_messages", "send", "book_calendar_event"):
109
+ assert_setup_refusal(await client.call_tool(name, ARGS[name]))
110
+ assert len((await client.list_tools()).tools) == 21
111
+ asyncio.run(run())
112
+
113
+
114
+ @pytest.mark.parametrize("status", [200, 403])
115
+ def test_actual_stdio_first_read_uses_authenticated_sdk_and_preserves_refusal(status):
116
+ """Real MCP wire and generated client, synthetic HTTP only, no sockets."""
117
+ bootstrap = f'''
118
+ import httpx, socket
119
+ from mcp_server import server
120
+ socket.socket.connect = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("Network forbidden"))
121
+ def handle(request):
122
+ assert request.method == "GET"
123
+ assert str(request.url) == "https://api.daknode.com/v1/inboxes/{ID}/messages?limit=5"
124
+ assert request.headers["authorization"] == "Bearer synthetic-onboarding-only"
125
+ assert request.headers["accept-encoding"] == "identity"
126
+ return httpx.Response({status}, json={{"items": [], "has_more": False, "next_cursor": None}} if {status} == 200 else {{"error": {{"code": "forbidden"}}}})
127
+ server.BoundedTransport = lambda: httpx.MockTransport(handle)
128
+ server.main()
129
+ '''
130
+ params = StdioServerParameters(command=sys.executable, args=["-c", bootstrap],
131
+ cwd=str(Path(__file__).resolve().parents[2]),
132
+ env={"PATH": os.defpath, "DAKNODE_API_KEY": "synthetic-onboarding-only"})
133
+
134
+ async def run():
135
+ async with stdio_client(params) as (reader, writer):
136
+ async with ClientSession(reader, writer) as client:
137
+ await client.initialize()
138
+ assert "list_messages" in {tool.name for tool in (await client.list_tools()).tools}
139
+ result = await client.call_tool("list_messages", {"inbox_id": ID, "limit": 5})
140
+ text = " ".join(block.text for block in result.content if hasattr(block, "text"))
141
+ assert "synthetic-onboarding-only" not in text
142
+ if status == 200:
143
+ assert not result.is_error
144
+ assert json.loads(text)["items"] == []
145
+ else:
146
+ assert result.is_error
147
+ assert "403" in text
148
+ asyncio.run(run())
@@ -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