daknode-mcp 0.3.0__tar.gz → 0.3.2__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,16 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daknode-mcp
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: DakNode's email API as MCP tools for AI agents, over stdio.
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.7
10
10
  Requires-Dist: mcp<3,>=2.2
11
11
 
12
12
  # DakNode MCP server
13
13
 
14
+ <!-- mcp-name: io.github.Subodh103/daknode -->
15
+
14
16
  DakNode's API as MCP tools, over **stdio**. The client launches it as a
15
17
  subprocess; there is no hosted endpoint and no infrastructure.
16
18
 
@@ -48,6 +50,17 @@ launch rather than looking like a broken tool.
48
50
  `create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
49
51
  · `search` · `send`
50
52
 
53
+ `create_inbox(alias)` requires the chosen address alias. `send` requires a
54
+ caller-supplied `idempotency_key`: keep it stable for retries of the same approved
55
+ message. Blank, control-character/non-ASCII or over-255-character keys are
56
+ refused before HTTP. A distinct intentional send needs a distinct key. Tool
57
+ discovery alone does not verify these write contracts; isolated operation tests
58
+ exercise the generated SDK request and response with a mocked HTTP transport.
59
+
60
+ Codex plugin, Cursor install link and canonical multi-platform skill:
61
+ https://github.com/Subodh103/daknode-integrations. These launch the same local
62
+ server; no hosted ChatGPT endpoint or public marketplace acceptance is implied.
63
+
51
64
  **Every tool is 1:1 with a published API operation.** That is `architecture.md`
52
65
  §0 consequence 4, and it is the boundary this server exists inside: DakNode
53
66
  exposes operations, the agent decides which to call. A tool like
@@ -1,5 +1,7 @@
1
1
  # DakNode MCP server
2
2
 
3
+ <!-- mcp-name: io.github.Subodh103/daknode -->
4
+
3
5
  DakNode's API as MCP tools, over **stdio**. The client launches it as a
4
6
  subprocess; there is no hosted endpoint and no infrastructure.
5
7
 
@@ -37,6 +39,17 @@ launch rather than looking like a broken tool.
37
39
  `create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
38
40
  · `search` · `send`
39
41
 
42
+ `create_inbox(alias)` requires the chosen address alias. `send` requires a
43
+ caller-supplied `idempotency_key`: keep it stable for retries of the same approved
44
+ message. Blank, control-character/non-ASCII or over-255-character keys are
45
+ refused before HTTP. A distinct intentional send needs a distinct key. Tool
46
+ discovery alone does not verify these write contracts; isolated operation tests
47
+ exercise the generated SDK request and response with a mocked HTTP transport.
48
+
49
+ Codex plugin, Cursor install link and canonical multi-platform skill:
50
+ https://github.com/Subodh103/daknode-integrations. These launch the same local
51
+ server; no hosted ChatGPT endpoint or public marketplace acceptance is implied.
52
+
40
53
  **Every tool is 1:1 with a published API operation.** That is `architecture.md`
41
54
  §0 consequence 4, and it is the boundary this server exists inside: DakNode
42
55
  exposes operations, the agent decides which to call. A tool like
@@ -1,16 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daknode-mcp
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: DakNode's email API as MCP tools for AI agents, over stdio.
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.7
10
10
  Requires-Dist: mcp<3,>=2.2
11
11
 
12
12
  # DakNode MCP server
13
13
 
14
+ <!-- mcp-name: io.github.Subodh103/daknode -->
15
+
14
16
  DakNode's API as MCP tools, over **stdio**. The client launches it as a
15
17
  subprocess; there is no hosted endpoint and no infrastructure.
16
18
 
@@ -48,6 +50,17 @@ launch rather than looking like a broken tool.
48
50
  `create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
49
51
  · `search` · `send`
50
52
 
53
+ `create_inbox(alias)` requires the chosen address alias. `send` requires a
54
+ caller-supplied `idempotency_key`: keep it stable for retries of the same approved
55
+ message. Blank, control-character/non-ASCII or over-255-character keys are
56
+ refused before HTTP. A distinct intentional send needs a distinct key. Tool
57
+ discovery alone does not verify these write contracts; isolated operation tests
58
+ exercise the generated SDK request and response with a mocked HTTP transport.
59
+
60
+ Codex plugin, Cursor install link and canonical multi-platform skill:
61
+ https://github.com/Subodh103/daknode-integrations. These launch the same local
62
+ server; no hosted ChatGPT endpoint or public marketplace acceptance is implied.
63
+
51
64
  **Every tool is 1:1 with a published API operation.** That is `architecture.md`
52
65
  §0 consequence 4, and it is the boundary this server exists inside: DakNode
53
66
  exposes operations, the agent decides which to call. A tool like
@@ -9,4 +9,5 @@ daknode_mcp.egg-info/SOURCES.txt
9
9
  daknode_mcp.egg-info/dependency_links.txt
10
10
  daknode_mcp.egg-info/entry_points.txt
11
11
  daknode_mcp.egg-info/requires.txt
12
- daknode_mcp.egg-info/top_level.txt
12
+ daknode_mcp.egg-info/top_level.txt
13
+ tests/test_write_tools.py
@@ -0,0 +1,2 @@
1
+ daknode<0.4,>=0.3.7
2
+ mcp<3,>=2.2
@@ -7,12 +7,12 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "daknode-mcp"
10
- version = "0.3.0"
10
+ version = "0.3.2"
11
11
  description = "DakNode's email API as MCP tools for AI agents, over stdio."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
14
14
  dependencies = [
15
- "daknode>=0.3,<0.4",
15
+ "daknode>=0.3.7,<0.4",
16
16
  "mcp>=2.2,<3",
17
17
  ]
18
18
 
@@ -114,17 +114,20 @@ def build_server() -> MCPServer:
114
114
  )
115
115
 
116
116
  @server.tool()
117
- def create_inbox() -> Any:
117
+ def create_inbox(alias: str) -> Any:
118
118
  """Create a new agent inbox and return its id and address.
119
119
 
120
+ Supply the user's chosen alias (the local part of the address).
120
121
  The same address both sends and receives. Inbound mail arrives by
121
122
  webhook; use `list_threads` and `get_thread` to read history.
122
123
  """
123
124
  from daknode.api.inboxes import create_inbox as op
124
125
  from daknode.models import InboxCreateRequest
125
126
 
127
+ if not alias.strip():
128
+ return {"error": "A nonempty inbox alias is required."}
126
129
  with _client() as c:
127
- return _result(op.sync_detailed(client=c, body=InboxCreateRequest()))
130
+ return _result(op.sync_detailed(client=c, body=InboxCreateRequest(alias=alias)))
128
131
 
129
132
  @server.tool()
130
133
  def list_threads(inbox_id: str, limit: int = 25, cursor: str | None = None) -> Any:
@@ -193,10 +196,13 @@ def build_server() -> MCPServer:
193
196
  to: str,
194
197
  subject: str,
195
198
  body_text: str,
199
+ idempotency_key: str,
196
200
  thread_id: str | None = None,
197
201
  ) -> Any:
198
202
  """Send a message.
199
203
 
204
+ Supply a stable `idempotency_key` for this approved send; reuse it on
205
+ retries, and use a different key only for a new intentional send.
200
206
  Pass `thread_id` to reply into a conversation: the server derives
201
207
  `In-Reply-To` and `References` from that thread's latest received
202
208
  message. Without it the message opens a NEW conversation in the
@@ -206,8 +212,11 @@ def build_server() -> MCPServer:
206
212
  from daknode.models import SendRequest
207
213
  from daknode.types import UNSET
208
214
 
215
+ if (not idempotency_key.strip() or len(idempotency_key) > 255
216
+ or any(ord(char) < 32 or ord(char) > 126 for char in idempotency_key)):
217
+ return {"error": "A nonempty, header-safe idempotency key of at most 255 characters is required."}
209
218
  with _client() as c:
210
- return _result(op.sync_detailed(client=c, body=SendRequest(
219
+ return _result(op.sync_detailed(client=c, idempotency_key=idempotency_key, body=SendRequest(
211
220
  inbox_id=inbox_id, to=to, subject=subject, body_text=body_text,
212
221
  thread_id=thread_id if thread_id else UNSET,
213
222
  )))
@@ -0,0 +1,95 @@
1
+ """Operation-level MCP tests: real generated SDK, mocked HTTP, no live mail."""
2
+ import asyncio
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+ from daknode import AuthenticatedClient
8
+
9
+ from mcp_server import server
10
+
11
+
12
+ @pytest.fixture
13
+ def calls(monkeypatch):
14
+ requests = []
15
+
16
+ def handle(request):
17
+ requests.append(request)
18
+ if request.url.path == "/v1/inboxes":
19
+ return httpx.Response(201, json={"inbox_id": "11111111-1111-4111-8111-111111111111",
20
+ "address": "support@agents.daknode.com", "channel": "email",
21
+ "display_name": None, "webhook_url": None})
22
+ return httpx.Response(200, json={"send_id": "22222222-2222-4222-8222-222222222222", "status": "queued"})
23
+
24
+ monkeypatch.setattr(server, "_client", lambda: AuthenticatedClient(
25
+ base_url="https://api.daknode.com", token="synthetic-not-a-key",
26
+ httpx_args={"transport": httpx.MockTransport(handle)}))
27
+ return requests
28
+
29
+
30
+ def invoke(name, args):
31
+ return asyncio.run(server.build_server().call_tool(name, args))
32
+
33
+
34
+ def test_create_requires_and_transmits_alias(calls):
35
+ result = invoke("create_inbox", {"alias": "support"})
36
+ assert len(calls) == 1
37
+ assert json.loads(calls[0].content) == {"alias": "support"}
38
+ assert calls[0].method == "POST"
39
+ assert "support@agents.daknode.com" in str(result)
40
+
41
+
42
+ def test_send_preserves_idempotency_and_thread(calls):
43
+ args = {"inbox_id": "11111111-1111-4111-8111-111111111111", "to": "test@example.com",
44
+ "subject": "Test", "body_text": "Hello", "idempotency_key": "stable-operation-1",
45
+ "thread_id": "33333333-3333-4333-8333-333333333333"}
46
+ for _ in range(2):
47
+ result = invoke("send", args)
48
+ assert "queued" in str(result) and not result.is_error
49
+ assert len(calls) == 2
50
+ for request in calls:
51
+ assert request.headers["Idempotency-Key"] == "stable-operation-1"
52
+ assert json.loads(request.content)["thread_id"] == args["thread_id"]
53
+ assert request.url.path == "/v1/send"
54
+
55
+
56
+ @pytest.mark.parametrize("name,args", [
57
+ ("create_inbox", {}), ("create_inbox", {"alias": ""}),
58
+ ("send", {"inbox_id": "x", "to": "a@example.com", "subject": "s", "body_text": "b"}),
59
+ *[("send", {"inbox_id": "x", "to": "a@example.com", "subject": "s", "body_text": "b", "idempotency_key": value})
60
+ for value in ("", " ", "x" * 256, "unsafe\r\nheader")],
61
+ ])
62
+ def test_invalid_write_never_reaches_http(calls, name, args):
63
+ try:
64
+ result = invoke(name, args)
65
+ except Exception:
66
+ pass # MCP versions may report schema validation as an exception.
67
+ else:
68
+ assert "error" in str(result).lower()
69
+ assert calls == []
70
+
71
+
72
+ def test_write_arguments_are_required_in_protocol_schema():
73
+ tools = {tool.name: tool for tool in asyncio.run(server.build_server().list_tools())}
74
+ assert "alias" in tools["create_inbox"].input_schema["required"]
75
+ assert "idempotency_key" in tools["send"].input_schema["required"]
76
+
77
+
78
+ @pytest.mark.parametrize("status", [401, 403, 429])
79
+ def test_api_refusals_are_not_reported_as_accepted(monkeypatch, status):
80
+ def refuse(request):
81
+ return httpx.Response(status, json={"error": {"code": "refused"}})
82
+ monkeypatch.setattr(server, "_client", lambda: AuthenticatedClient(
83
+ base_url="https://api.daknode.com", token="synthetic-not-a-key",
84
+ httpx_args={"transport": httpx.MockTransport(refuse)}))
85
+ result = invoke("send", {"inbox_id": "11111111-1111-4111-8111-111111111111",
86
+ "to": "test@example.com", "subject": "Test", "body_text": "Hello",
87
+ "idempotency_key": "stable-refused-operation"})
88
+ assert str(status) in str(result) and "queued" not in str(result)
89
+
90
+
91
+ def test_new_conversation_omits_thread(calls):
92
+ invoke("send", {"inbox_id": "11111111-1111-4111-8111-111111111111",
93
+ "to": "test@example.com", "subject": "Test", "body_text": "Hello",
94
+ "idempotency_key": "new-conversation"})
95
+ assert "thread_id" not in json.loads(calls[0].content)
@@ -1,2 +0,0 @@
1
- daknode<0.4,>=0.3
2
- mcp<3,>=2.2
File without changes
File without changes