daknode-mcp 0.3.1__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.
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/PKG-INFO +12 -1
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/README.md +11 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/daknode_mcp.egg-info/PKG-INFO +12 -1
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/daknode_mcp.egg-info/SOURCES.txt +2 -1
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/pyproject.toml +1 -1
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/server.py +12 -3
- daknode_mcp-0.3.2/tests/test_write_tools.py +95 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/__init__.py +0 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/daknode_mcp.egg-info/dependency_links.txt +0 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/daknode_mcp.egg-info/entry_points.txt +0 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/daknode_mcp.egg-info/requires.txt +0 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/daknode_mcp.egg-info/top_level.txt +0 -0
- {daknode_mcp-0.3.1 → daknode_mcp-0.3.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: daknode-mcp
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -50,6 +50,17 @@ launch rather than looking like a broken tool.
|
|
|
50
50
|
`create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
|
|
51
51
|
· `search` · `send`
|
|
52
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
|
+
|
|
53
64
|
**Every tool is 1:1 with a published API operation.** That is `architecture.md`
|
|
54
65
|
§0 consequence 4, and it is the boundary this server exists inside: DakNode
|
|
55
66
|
exposes operations, the agent decides which to call. A tool like
|
|
@@ -39,6 +39,17 @@ launch rather than looking like a broken tool.
|
|
|
39
39
|
`create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
|
|
40
40
|
· `search` · `send`
|
|
41
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
|
+
|
|
42
53
|
**Every tool is 1:1 with a published API operation.** That is `architecture.md`
|
|
43
54
|
§0 consequence 4, and it is the boundary this server exists inside: DakNode
|
|
44
55
|
exposes operations, the agent decides which to call. A tool like
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: daknode-mcp
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -50,6 +50,17 @@ launch rather than looking like a broken tool.
|
|
|
50
50
|
`create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
|
|
51
51
|
· `search` · `send`
|
|
52
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
|
+
|
|
53
64
|
**Every tool is 1:1 with a published API operation.** That is `architecture.md`
|
|
54
65
|
§0 consequence 4, and it is the boundary this server exists inside: DakNode
|
|
55
66
|
exposes operations, the agent decides which to call. A tool like
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|