daknode-mcp 0.3.4__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,9 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daknode-mcp
3
- Version: 0.3.4
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
11
  Requires-Dist: daknode<0.4,>=0.3.9
@@ -13,7 +15,7 @@ Requires-Dist: mcp<3,>=2.2
13
15
 
14
16
  <!-- mcp-name: io.github.Subodh103/daknode -->
15
17
 
16
- DakNode MCP 0.3.4 exposes 21 email and calendar API tools over **stdio**,
18
+ DakNode MCP 0.3.5 exposes 21 email and calendar API tools over **stdio**,
17
19
  including availability, reliable booking, reminders and recurring-event edits.
18
20
  An authenticated hosted endpoint is also available at
19
21
  `https://api.daknode.com/mcp`; local installation does not connect to it or
@@ -45,8 +47,13 @@ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
45
47
 
46
48
  The key comes from the environment and **never from a command-line argument**:
47
49
  argv is visible to every process through `ps`, and shells record it in history.
48
- The server refuses to start without it, so a misconfiguration is obvious at
49
- 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.
50
57
 
51
58
  ## Tools
52
59
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- mcp-name: io.github.Subodh103/daknode -->
4
4
 
5
- DakNode MCP 0.3.4 exposes 21 email and calendar API tools over **stdio**,
5
+ DakNode MCP 0.3.5 exposes 21 email and calendar API tools over **stdio**,
6
6
  including availability, reliable booking, reminders and recurring-event edits.
7
7
  An authenticated hosted endpoint is also available at
8
8
  `https://api.daknode.com/mcp`; local installation does not connect to it or
@@ -34,8 +34,13 @@ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
34
34
 
35
35
  The key comes from the environment and **never from a command-line argument**:
36
36
  argv is visible to every process through `ps`, and shells record it in history.
37
- The server refuses to start without it, so a misconfiguration is obvious at
38
- 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.
39
44
 
40
45
  ## Tools
41
46
 
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: daknode-mcp
3
- Version: 0.3.4
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
11
  Requires-Dist: daknode<0.4,>=0.3.9
@@ -13,7 +15,7 @@ Requires-Dist: mcp<3,>=2.2
13
15
 
14
16
  <!-- mcp-name: io.github.Subodh103/daknode -->
15
17
 
16
- DakNode MCP 0.3.4 exposes 21 email and calendar API tools over **stdio**,
18
+ DakNode MCP 0.3.5 exposes 21 email and calendar API tools over **stdio**,
17
19
  including availability, reliable booking, reminders and recurring-event edits.
18
20
  An authenticated hosted endpoint is also available at
19
21
  `https://api.daknode.com/mcp`; local installation does not connect to it or
@@ -45,8 +47,13 @@ checkout, `"command": "python", "args": ["-m", "mcp_server.server"]`.
45
47
 
46
48
  The key comes from the environment and **never from a command-line argument**:
47
49
  argv is visible to every process through `ps`, and shells record it in history.
48
- The server refuses to start without it, so a misconfiguration is obvious at
49
- 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.
50
57
 
51
58
  ## Tools
52
59
 
@@ -21,4 +21,5 @@ 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
23
  tests/test_calendar_scheduling.py
24
+ tests/test_local_discovery.py
24
25
  tests/test_write_tools.py
@@ -7,10 +7,11 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "daknode-mcp"
10
- version = "0.3.4"
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
16
  "daknode>=0.3.9,<0.4",
16
17
  "mcp>=2.2,<3",
@@ -18,7 +19,8 @@ dependencies = [
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"
@@ -172,11 +172,7 @@ def _version() -> str:
172
172
 
173
173
 
174
174
  class MissingApiKey(RuntimeError):
175
- """No credential. Raised at STARTUP, not at first tool call.
176
-
177
- A server that starts happily and then fails every call looks like a broken
178
- tool rather than a missing setting, and the person debugging it is an agent.
179
- """
175
+ """No operation credential. Static local discovery needs no SDK client."""
180
176
 
181
177
 
182
178
  def _client():
@@ -228,6 +224,15 @@ def _execute(operation, **kwargs):
228
224
  try:
229
225
  with _client() as client:
230
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
+ )
231
236
  except httpx.TimeoutException:
232
237
  return _failure("timeout", "API timeout; write outcome may be unknown. Reuse the same idempotency key for any send retry.", retryable=True)
233
238
  except httpx.RequestError:
@@ -545,11 +550,8 @@ def build_server() -> MCPServer:
545
550
 
546
551
 
547
552
  def main() -> int: # pragma: no cover - process entry point
548
- try:
549
- _client()
550
- except MissingApiKey as exc:
551
- print(f"daknode-mcp: {exc}", file=sys.stderr)
552
- return 2
553
+ # Local discovery exposes static schemas only. Each operation still calls
554
+ # _client(), which refuses absent credentials before constructing transport.
553
555
  build_server().run()
554
556
  return 0
555
557
 
@@ -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())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes