daknode-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """DakNode MCP server. Spec: `.agent/specs/17_agent_toolkit.md#mcp`."""
daknode_mcp/server.py ADDED
@@ -0,0 +1,229 @@
1
+ """DakNode MCP server — DakNode's API as tools, over stdio.
2
+
3
+ Spec: `.agent/specs/17_agent_toolkit.md#mcp`.
4
+ Boundary: `.agent/architecture.md` §0, "Where MCP, SDKs and a CLI sit".
5
+
6
+ **Every tool is 1:1 with a published API operation and composes no decision.**
7
+ That is `architecture.md` §0 consequence 4, and it is the whole boundary here.
8
+ `create_inbox`, `list_threads`, `send` are inside the line. A tool called
9
+ `reply_to_latest` or `auto_respond` would be outside it — DakNode would be
10
+ choosing which message to answer, **while looking like a convenience**. That is
11
+ how this boundary erodes: not by someone adding an agent framework, which the
12
+ existing rule catches loudly, but by someone adding a helpful-sounding tool.
13
+
14
+ If a tool cannot be named after an `operationId`, it does not belong here.
15
+ `tests/test_mcp_server.py` enforces that rather than leaving it to review.
16
+
17
+ This wraps the **generated SDK** rather than building requests itself: a second
18
+ HTTP client would drift from the first, and the SDK is already regenerated from
19
+ the spec and guarded against drift.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ _SDK = Path(__file__).resolve().parents[1] / "sdk" / "python"
30
+ if str(_SDK) not in sys.path: # pragma: no cover - import plumbing
31
+ sys.path.insert(0, str(_SDK))
32
+
33
+ from mcp.server.mcpserver import MCPServer # noqa: E402
34
+
35
+ DEFAULT_BASE_URL = "https://api.daknode.com"
36
+
37
+
38
+ def _version() -> str:
39
+ """The installed `daknode-mcp` version, so a client can report what it runs.
40
+
41
+ Read from package metadata rather than written here a second time; a copy
42
+ run from the repository, not installed, reports itself as such.
43
+ """
44
+ from importlib.metadata import PackageNotFoundError, version
45
+
46
+ try:
47
+ return version("daknode-mcp")
48
+ except PackageNotFoundError:
49
+ return "0.0.0+checkout"
50
+
51
+
52
+ class MissingApiKey(RuntimeError):
53
+ """No credential. Raised at STARTUP, not at first tool call.
54
+
55
+ A server that starts happily and then fails every call looks like a broken
56
+ tool rather than a missing setting, and the person debugging it is an agent.
57
+ """
58
+
59
+
60
+ def _client():
61
+ """Build the SDK client from the environment.
62
+
63
+ The key comes from `DAKNODE_API_KEY` and **never from argv**: command-line
64
+ arguments are visible to every process on the machine through `ps`, and
65
+ shells record them in history.
66
+ """
67
+ key = os.environ.get("DAKNODE_API_KEY", "").strip()
68
+ if not key:
69
+ raise MissingApiKey(
70
+ "DAKNODE_API_KEY is not set. Put it in the MCP client's environment "
71
+ "configuration for this server."
72
+ )
73
+ from daknode import AuthenticatedClient
74
+
75
+ return AuthenticatedClient(
76
+ base_url=os.environ.get("DAKNODE_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
77
+ token=key,
78
+ )
79
+
80
+
81
+ def _result(response) -> Any:
82
+ """Return the parsed body, or a readable failure.
83
+
84
+ Never raises to the agent: an MCP tool that throws gives the model a stack
85
+ trace, which it cannot act on and may repeat back to a user.
86
+ """
87
+ parsed = getattr(response, "parsed", None)
88
+ if parsed is not None:
89
+ return parsed.to_dict() if hasattr(parsed, "to_dict") else parsed
90
+ return {
91
+ "status": getattr(response, "status_code", None),
92
+ "error": (getattr(response, "content", b"") or b"").decode("utf-8", "replace")[:500],
93
+ }
94
+
95
+
96
+ def build_server() -> MCPServer:
97
+ """Register one tool per published operation.
98
+
99
+ **Deliberately NOT exposed:** erasure. It is irreversible, and an agent that
100
+ can erase a data subject through a tool call is a liability no convenience
101
+ justifies (spec §5). It stays an explicit API call a human integrates.
102
+ Console, operator and provider-callback surfaces are absent for the same
103
+ reason the SDK omits them.
104
+ """
105
+ server = MCPServer(
106
+ name="daknode",
107
+ title="DakNode",
108
+ version=_version(),
109
+ website_url="https://daknode.com",
110
+ description=(
111
+ "Email for software agents: provision an inbox, read threads, "
112
+ "search, and send."
113
+ ),
114
+ )
115
+
116
+ @server.tool()
117
+ def create_inbox() -> Any:
118
+ """Create a new agent inbox and return its id and address.
119
+
120
+ The same address both sends and receives. Inbound mail arrives by
121
+ webhook; use `list_threads` and `get_thread` to read history.
122
+ """
123
+ from daknode.api.inboxes import create_inbox as op
124
+ from daknode.models import InboxCreateRequest
125
+
126
+ with _client() as c:
127
+ return _result(op.sync_detailed(client=c, body=InboxCreateRequest()))
128
+
129
+ @server.tool()
130
+ def list_threads(inbox_id: str, limit: int = 25, cursor: str | None = None) -> Any:
131
+ """List conversations in an inbox, most recently active first."""
132
+ from daknode.api.messages import list_threads as op
133
+ from daknode.types import UNSET
134
+
135
+ with _client() as c:
136
+ return _result(op.sync_detailed(
137
+ inbox_id=inbox_id, client=c, limit=limit,
138
+ cursor=cursor if cursor else UNSET,
139
+ ))
140
+
141
+ @server.tool()
142
+ def list_messages(inbox_id: str, limit: int = 25, cursor: str | None = None) -> Any:
143
+ """List messages in an inbox, newest first."""
144
+ from daknode.api.messages import list_messages as op
145
+ from daknode.types import UNSET
146
+
147
+ with _client() as c:
148
+ return _result(op.sync_detailed(
149
+ inbox_id=inbox_id, client=c, limit=limit,
150
+ cursor=cursor if cursor else UNSET,
151
+ ))
152
+
153
+ @server.tool()
154
+ def get_thread(thread_id: str) -> Any:
155
+ """Fetch one conversation with both sides, oldest first."""
156
+ from daknode.api.messages import get_thread as op
157
+
158
+ with _client() as c:
159
+ return _result(op.sync_detailed(thread_id=thread_id, client=c))
160
+
161
+ @server.tool()
162
+ def get_message(message_id: str) -> Any:
163
+ """Fetch one message by id.
164
+
165
+ `body_text` is already cleaned: quoted history and signatures are
166
+ stripped, so there is no reply chain to parse out of it. Check
167
+ `risk_level` before feeding the content to a model — `high` means
168
+ prompt-injection patterns were detected in mail from the internet.
169
+ """
170
+ from daknode.api.messages import get_message as op
171
+
172
+ with _client() as c:
173
+ return _result(op.sync_detailed(message_id=message_id, client=c))
174
+
175
+ @server.tool()
176
+ def search(query: str, top_k: int = 10) -> Any:
177
+ """Semantic search over this caller's own mail.
178
+
179
+ Returns identifiers and scores, not content — resolve them with
180
+ `get_message` or `get_thread`.
181
+ """
182
+ from daknode.api.search import search as op
183
+ from daknode.models import SearchRequest
184
+
185
+ with _client() as c:
186
+ return _result(op.sync_detailed(
187
+ client=c, body=SearchRequest(query=query, top_k=top_k)
188
+ ))
189
+
190
+ @server.tool()
191
+ def send(
192
+ inbox_id: str,
193
+ to: str,
194
+ subject: str,
195
+ body_text: str,
196
+ thread_id: str | None = None,
197
+ ) -> Any:
198
+ """Send a message.
199
+
200
+ Pass `thread_id` to reply into a conversation: the server derives
201
+ `In-Reply-To` and `References` from that thread's latest received
202
+ message. Without it the message opens a NEW conversation in the
203
+ recipient's client — it does not continue one.
204
+ """
205
+ from daknode.api.send import send as op
206
+ from daknode.models import SendRequest
207
+ from daknode.types import UNSET
208
+
209
+ with _client() as c:
210
+ return _result(op.sync_detailed(client=c, body=SendRequest(
211
+ inbox_id=inbox_id, to=to, subject=subject, body_text=body_text,
212
+ thread_id=thread_id if thread_id else UNSET,
213
+ )))
214
+
215
+ return server
216
+
217
+
218
+ def main() -> int: # pragma: no cover - process entry point
219
+ try:
220
+ _client()
221
+ except MissingApiKey as exc:
222
+ print(f"daknode-mcp: {exc}", file=sys.stderr)
223
+ return 2
224
+ build_server().run()
225
+ return 0
226
+
227
+
228
+ if __name__ == "__main__": # pragma: no cover
229
+ raise SystemExit(main())
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: daknode-mcp
3
+ Version: 0.1.0
4
+ Summary: DakNode's email API as MCP tools for AI agents, over stdio.
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.2,>=0.1
10
+ Requires-Dist: mcp<3,>=2.2
11
+
12
+ # DakNode MCP server
13
+
14
+ DakNode's API as MCP tools, over **stdio**. The client launches it as a
15
+ subprocess; there is no hosted endpoint and no infrastructure.
16
+
17
+ ## Install
18
+
19
+ Python 3.10+. With [uv](https://docs.astral.sh/uv/), nothing needs installing
20
+ up front — the client starts it with `uvx`. Otherwise `pipx install daknode-mcp`.
21
+
22
+ Get an API key with the two-request signup at <https://daknode.com/developers>.
23
+
24
+ ## Configure
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "daknode": {
30
+ "command": "uvx",
31
+ "args": ["daknode-mcp"],
32
+ "env": { "DAKNODE_API_KEY": "…" }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ With `pipx`, use `"command": "daknode-mcp"` and no `args`. From a repository
39
+ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
40
+
41
+ The key comes from the environment and **never from a command-line argument**:
42
+ argv is visible to every process through `ps`, and shells record it in history.
43
+ The server refuses to start without it, so a misconfiguration is obvious at
44
+ launch rather than looking like a broken tool.
45
+
46
+ ## Tools
47
+
48
+ `create_inbox` · `list_threads` · `list_messages` · `get_thread` · `get_message`
49
+ · `search` · `send`
50
+
51
+ **Every tool is 1:1 with a published API operation.** That is `architecture.md`
52
+ §0 consequence 4, and it is the boundary this server exists inside: DakNode
53
+ exposes operations, the agent decides which to call. A tool like
54
+ `reply_to_latest` or `auto_respond` would cross that line — DakNode would be
55
+ choosing which message to answer — **while looking like a convenience**.
56
+ `tests/test_mcp_server.py` enforces it rather than leaving it to review.
57
+
58
+ ## What is deliberately absent
59
+
60
+ **Erasure.** It is irreversible, and an agent that can erase a data subject
61
+ through a tool call is a liability no convenience justifies. It stays an
62
+ explicit API call a human integrates.
63
+
64
+ **Console and operator APIs.** Not a product surface.
65
+
66
+ ## Notes for whoever uses this
67
+
68
+ `send` without `thread_id` opens a **new** conversation in the recipient's
69
+ client. Pass `thread_id` to reply into an existing one — the server then derives
70
+ `In-Reply-To` and `References` from that thread's latest received message.
71
+
72
+ `search` returns identifiers and scores, **not content**. Resolve them with
73
+ `get_message` or `get_thread`.
74
+
75
+ `body_text` is already cleaned: quoted history and signatures are stripped.
76
+ Check `risk_level` before feeding content to a model — `high` means
77
+ prompt-injection patterns were detected in mail from the internet.
@@ -0,0 +1,7 @@
1
+ daknode_mcp/__init__.py,sha256=aStm9Q8imB2PI4r10dRlsqnGqzFk9RxVQrGBK-rAcmo,72
2
+ daknode_mcp/server.py,sha256=gph8wVTzdZrLvpIK9VG_eaHbfKlkcJWyFRNpAZTUUV0,8188
3
+ daknode_mcp-0.1.0.dist-info/METADATA,sha256=LjNHwKEUp8EByuqNHia0fQR50e45VGM_3K_sDntj3ss,2799
4
+ daknode_mcp-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ daknode_mcp-0.1.0.dist-info/entry_points.txt,sha256=M48Ha35idxlU9DveXX4tXQFNGIDeNtcJAPiNg5ZP4tk,56
6
+ daknode_mcp-0.1.0.dist-info/top_level.txt,sha256=-Ag_P5pqnxaZXjvkbfQ16tTYG4ch4oSTpHG78xkXFCA,12
7
+ daknode_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ daknode-mcp = daknode_mcp.server:main
@@ -0,0 +1 @@
1
+ daknode_mcp