mailsocket-mcp 0.2.3__tar.gz → 0.4.0__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket-mcp
3
- Version: 0.2.3
3
+ Version: 0.4.0
4
4
  Summary: MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Topic :: Communications :: Email
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: mailsocket>=0.1.3
22
+ Requires-Dist: mailsocket>=0.3.0
23
23
  Requires-Dist: mcp<3.0,>=2.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7; extra == "dev"
@@ -39,11 +39,12 @@ no second HTTP client, no re-implemented polling.
39
39
  | Tool | What the agent gets |
40
40
  | --- | --- |
41
41
  | `create_inbox(label?)` | a fresh inbox `id` + `address` |
42
- | `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0, since?)` | **the headline** — blocks (bounded: requested deadline clamped to 120s locally, 55s on the remote server) and returns the OTP string + confidence + subject/from/id/received_at |
43
- | `wait_for_link(inbox_id, timeout?=60, since?)` | the extracted magic link (returned, *not* followed) + subject/from/id/received_at |
42
+ | `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0, since?)` | **the headline** — blocks (bounded: requested deadline clamped to 120s locally, 55s on the remote server) and returns the OTP string + confidence + subject/from/id/received_at. Default `since` omitted: only messages arriving after this call are matched. |
43
+ | `wait_for_link(inbox_id, timeout?=60, since?)` | the extracted magic link (returned, *not* followed) + subject/from/id/received_at. Default `since` omitted: only messages arriving after this call are matched. |
44
44
  | `list_inboxes(limit?, cursor?)` | one page of inboxes owned by the key (server default page size 25) + `next_cursor`/`has_more` |
45
45
  | `list_messages(inbox_id, has_otp?, subject_contains?, sender?, limit?, cursor?)` | one page of messages, optionally filtered (server default page size 25) + `next_cursor`/`has_more` |
46
46
  | `get_latest(inbox_id)` | the newest message, without blocking |
47
+ | `send_test_code(inbox_id)` | puts one sample OTP email straight into the inbox (no mail server) and returns its `message_id` + `sent_at`, so you can check the wait loop without real email. 5 per inbox / 20 per account per hour. |
47
48
  | `delete_inbox(inbox_id)` | remove an inbox when done |
48
49
 
49
50
  Errors from the SDK (`AuthError` / `NotFound` / `RateLimited` / `WaitTimeout`)
@@ -53,14 +54,26 @@ never echoed back.
53
54
  ### Reusing an inbox
54
55
 
55
56
  `create_inbox` needs no `since` — a fresh inbox only ever has new messages.
56
- But if you reuse an existing inbox (e.g. to request a second OTP/link), pass
57
- `since` to `wait_for_otp`/`wait_for_link`: otherwise the wait can match the
58
- OLDEST retained message instead of the new one you just triggered. Pass
59
- either the time right before you triggered the new email (ISO8601 or unix
60
- seconds), or the `id` of the last message you saw (`msg_...`, meaning "only
61
- messages after that one") — both tools return `id`/`received_at` on the
62
- matched message so you can chain `since=<id>` into the next call on that same
63
- inbox.
57
+ By default, `wait_for_otp`/`wait_for_link` omit `since` entirely, so the
58
+ server matches only messages arriving AFTER the call (the request start
59
+ time). But if you reuse an existing inbox (e.g. to request a second
60
+ OTP/link), pass `since` explicitly: otherwise a call with no `since` will
61
+ correctly ignore anything already sitting in the inbox, which is right for
62
+ most reuse, but if you actually WANT to catch a code that already arrived
63
+ before you called the tool, pass either the time right before you triggered
64
+ the new email (ISO8601 or unix seconds), or the `id` of the last message you
65
+ saw (`msg_...`, meaning "only messages after that one") — both tools return
66
+ `id`/`received_at` on the matched message so you can chain `since=<id>` into
67
+ the next call on that same inbox.
68
+
69
+ ### Trying the loop without real email
70
+
71
+ `send_test_code` does not go through the mail server, so the code is
72
+ already in the inbox when the tool returns. Take a cutoff first: the `id`
73
+ from `get_latest` (or `since=0` if the inbox is empty), or the current time.
74
+ Then call `send_test_code(inbox_id)`, then
75
+ `wait_for_otp(inbox_id, since=<that cutoff>)`. Without `since` the wait only
76
+ sees mail that arrives during the wait, so it would miss the test code.
64
77
 
65
78
  ## Requirements
66
79
 
@@ -195,6 +208,20 @@ remote-transport tests talk to a local fake upstream on `127.0.0.1`.
195
208
 
196
209
  ## Changelog
197
210
 
211
+ - **0.4.0** — new `send_test_code(inbox_id)` tool (stdio and the hosted
212
+ remote server, under the same per-key caps as the other non-wait tools):
213
+ puts one sample OTP email into your own inbox without going through the
214
+ mail server. Requires `mailsocket>=0.3.0` (`Client.send_test_code`).
215
+ - **0.3.0** — BEHAVIOUR CHANGE (via `mailsocket>=0.2.0`): `wait_for_otp` /
216
+ `wait_for_link` now omit `since` by default instead of forwarding `0`
217
+ ("any message already in the inbox"). The old default could return a
218
+ STALE OTP/link already sitting in a reused inbox; the new default matches
219
+ only messages arriving after the call. Pass `since=0` explicitly to keep
220
+ the old behaviour.
221
+ Hosted server (mcp.mailsocket.app, deployed from the repo, no new PyPI
222
+ release): per-key concurrent waits raised 3 → 10 (= the Pro plan cap; the
223
+ API still enforces each plan's own cap) and the shared wait pool 16 → 32,
224
+ following the backend's raised wait capacity (global 64).
198
225
  - **0.2.3** — `wait_for_otp` / `wait_for_link` accept an optional `since` (ISO8601, unix seconds or a message id) so a reused inbox returns the NEW code; results include the message `id` and `received_at`.
199
226
  - **0.2.2** — depends on `mailsocket>=0.1.3` (stricter `_seg` id validation in the underlying Python SDK).
200
227
 
@@ -12,11 +12,12 @@ no second HTTP client, no re-implemented polling.
12
12
  | Tool | What the agent gets |
13
13
  | --- | --- |
14
14
  | `create_inbox(label?)` | a fresh inbox `id` + `address` |
15
- | `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0, since?)` | **the headline** — blocks (bounded: requested deadline clamped to 120s locally, 55s on the remote server) and returns the OTP string + confidence + subject/from/id/received_at |
16
- | `wait_for_link(inbox_id, timeout?=60, since?)` | the extracted magic link (returned, *not* followed) + subject/from/id/received_at |
15
+ | `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0, since?)` | **the headline** — blocks (bounded: requested deadline clamped to 120s locally, 55s on the remote server) and returns the OTP string + confidence + subject/from/id/received_at. Default `since` omitted: only messages arriving after this call are matched. |
16
+ | `wait_for_link(inbox_id, timeout?=60, since?)` | the extracted magic link (returned, *not* followed) + subject/from/id/received_at. Default `since` omitted: only messages arriving after this call are matched. |
17
17
  | `list_inboxes(limit?, cursor?)` | one page of inboxes owned by the key (server default page size 25) + `next_cursor`/`has_more` |
18
18
  | `list_messages(inbox_id, has_otp?, subject_contains?, sender?, limit?, cursor?)` | one page of messages, optionally filtered (server default page size 25) + `next_cursor`/`has_more` |
19
19
  | `get_latest(inbox_id)` | the newest message, without blocking |
20
+ | `send_test_code(inbox_id)` | puts one sample OTP email straight into the inbox (no mail server) and returns its `message_id` + `sent_at`, so you can check the wait loop without real email. 5 per inbox / 20 per account per hour. |
20
21
  | `delete_inbox(inbox_id)` | remove an inbox when done |
21
22
 
22
23
  Errors from the SDK (`AuthError` / `NotFound` / `RateLimited` / `WaitTimeout`)
@@ -26,14 +27,26 @@ never echoed back.
26
27
  ### Reusing an inbox
27
28
 
28
29
  `create_inbox` needs no `since` — a fresh inbox only ever has new messages.
29
- But if you reuse an existing inbox (e.g. to request a second OTP/link), pass
30
- `since` to `wait_for_otp`/`wait_for_link`: otherwise the wait can match the
31
- OLDEST retained message instead of the new one you just triggered. Pass
32
- either the time right before you triggered the new email (ISO8601 or unix
33
- seconds), or the `id` of the last message you saw (`msg_...`, meaning "only
34
- messages after that one") — both tools return `id`/`received_at` on the
35
- matched message so you can chain `since=<id>` into the next call on that same
36
- inbox.
30
+ By default, `wait_for_otp`/`wait_for_link` omit `since` entirely, so the
31
+ server matches only messages arriving AFTER the call (the request start
32
+ time). But if you reuse an existing inbox (e.g. to request a second
33
+ OTP/link), pass `since` explicitly: otherwise a call with no `since` will
34
+ correctly ignore anything already sitting in the inbox, which is right for
35
+ most reuse, but if you actually WANT to catch a code that already arrived
36
+ before you called the tool, pass either the time right before you triggered
37
+ the new email (ISO8601 or unix seconds), or the `id` of the last message you
38
+ saw (`msg_...`, meaning "only messages after that one") — both tools return
39
+ `id`/`received_at` on the matched message so you can chain `since=<id>` into
40
+ the next call on that same inbox.
41
+
42
+ ### Trying the loop without real email
43
+
44
+ `send_test_code` does not go through the mail server, so the code is
45
+ already in the inbox when the tool returns. Take a cutoff first: the `id`
46
+ from `get_latest` (or `since=0` if the inbox is empty), or the current time.
47
+ Then call `send_test_code(inbox_id)`, then
48
+ `wait_for_otp(inbox_id, since=<that cutoff>)`. Without `since` the wait only
49
+ sees mail that arrives during the wait, so it would miss the test code.
37
50
 
38
51
  ## Requirements
39
52
 
@@ -168,6 +181,20 @@ remote-transport tests talk to a local fake upstream on `127.0.0.1`.
168
181
 
169
182
  ## Changelog
170
183
 
184
+ - **0.4.0** — new `send_test_code(inbox_id)` tool (stdio and the hosted
185
+ remote server, under the same per-key caps as the other non-wait tools):
186
+ puts one sample OTP email into your own inbox without going through the
187
+ mail server. Requires `mailsocket>=0.3.0` (`Client.send_test_code`).
188
+ - **0.3.0** — BEHAVIOUR CHANGE (via `mailsocket>=0.2.0`): `wait_for_otp` /
189
+ `wait_for_link` now omit `since` by default instead of forwarding `0`
190
+ ("any message already in the inbox"). The old default could return a
191
+ STALE OTP/link already sitting in a reused inbox; the new default matches
192
+ only messages arriving after the call. Pass `since=0` explicitly to keep
193
+ the old behaviour.
194
+ Hosted server (mcp.mailsocket.app, deployed from the repo, no new PyPI
195
+ release): per-key concurrent waits raised 3 → 10 (= the Pro plan cap; the
196
+ API still enforces each plan's own cap) and the shared wait pool 16 → 32,
197
+ following the backend's raised wait capacity (global 64).
171
198
  - **0.2.3** — `wait_for_otp` / `wait_for_link` accept an optional `since` (ISO8601, unix seconds or a message id) so a reused inbox returns the NEW code; results include the message `id` and `received_at`.
172
199
  - **0.2.2** — depends on `mailsocket>=0.1.3` (stricter `_seg` id validation in the underlying Python SDK).
173
200
 
@@ -52,7 +52,7 @@ __all__ = [
52
52
  # Canonical version, re-exported as ``mailsocket_mcp.__version__`` (see
53
53
  # __init__.py). Defined here, not in __init__.py, because __init__.py imports
54
54
  # this module at package-load time and a circular self-import would break.
55
- __version__ = "0.2.3"
55
+ __version__ = "0.4.0"
56
56
 
57
57
  # The headline wait is bounded: an agent can request up to the transport's
58
58
  # ceiling as the overall deadline (clamped), though transport overhead can push
@@ -66,16 +66,23 @@ MIN_WAIT_TIMEOUT = 1.0
66
66
 
67
67
  # Bounded worker-thread concurrency for the (sync, urllib) SDK calls. Waits and
68
68
  # everything else get SEPARATE pools so long waits can never starve
69
- # list/create calls. WAIT matches the backend's global WaitCapacity (16). A
69
+ # list/create calls. WAIT is half the backend's global WaitCapacity (64): the
70
+ # hosted MCP is ONE client of that pool, so it must stay below it and can
71
+ # never fill the backend's wait slots on its own (direct API callers keep the
72
+ # other half). WAIT + OTHER (48) must stay <= uvicorn --limit-concurrency (64)
73
+ # in docker-compose.yml / deploy/mcp/Dockerfile — pinned by test_remote. A
70
74
  # caller that cannot get a slot within LIMITER_QUEUE_TIMEOUT gets a clean
71
75
  # "busy, retry" tool error instead of queueing unboundedly.
72
- WAIT_CONCURRENCY = 16
76
+ WAIT_CONCURRENCY = 32
73
77
  OTHER_CONCURRENCY = 16
74
78
  LIMITER_QUEUE_TIMEOUT = 10.0
75
79
  # Per-key fairness (remote mode only): one key may hold at most this many
76
80
  # in-flight calls of each kind, so it can never monopolise the shared pools
77
- # above. Over the cap → an immediate clean tool error (no queueing).
78
- PER_KEY_WAIT_CONCURRENCY = 3
81
+ # above. Over the cap → an immediate clean tool error (no queueing). The wait
82
+ # cap equals the Pro plan's backend cap (10) so the hosted MCP never limits a
83
+ # paying key below its plan; the backend still enforces each plan's own cap
84
+ # (Free 2, Pro 10, ...) and answers 429 too_many_wait_requests above it.
85
+ PER_KEY_WAIT_CONCURRENCY = 10
79
86
  PER_KEY_OTHER_CONCURRENCY = 8
80
87
 
81
88
  MISSING_KEY_MESSAGE = (
@@ -277,7 +284,7 @@ def _error_result(exc: BaseException) -> types.CallToolResult:
277
284
  msg = f"Not found (HTTP 404): {_safe(str(exc), key)}"
278
285
  elif isinstance(exc, RateLimited):
279
286
  retry = f" Retry after {exc.retry_after:g}s." if exc.retry_after else ""
280
- msg = f"Rate limited (HTTP 429): {_safe(str(exc), key)}.{retry}"
287
+ msg = f"Rate limited (HTTP 429): {_safe(str(exc), key).rstrip('.')}.{retry}"
281
288
  elif isinstance(exc, _Busy):
282
289
  msg = str(exc)
283
290
  elif isinstance(exc, MailsocketError):
@@ -481,12 +488,13 @@ def wait_for_otp(
481
488
  ``since`` (optional) only matches messages that arrived after this point:
482
489
  an ISO8601 timestamp, unix seconds, or a message id (``msg_...``, meaning
483
490
  "only messages after that one"; an unknown id falls back to the oldest
484
- message, so prefer a timestamp if unsure). A fresh inbox straight from
485
- ``create_inbox`` needs nothing here — the default already matches
486
- anything in it. When REUSING an inbox for a second code, pass either the
487
- time just before you triggered this email, or the ``id`` of the last
488
- message you saw, so you get the NEW code instead of the oldest one still
489
- retained in the inbox.
491
+ message, so prefer a timestamp if unsure). Omitted (the default), the
492
+ server matches only messages that arrive during this call — the right
493
+ choice for a fresh inbox from ``create_inbox``. When REUSING an inbox for
494
+ a second code and you want to catch one that already arrived before this
495
+ call, pass either the time just before you triggered this email, or the
496
+ ``id`` of the last message you saw, so you get the NEW code instead of
497
+ missing it.
490
498
 
491
499
  Side effects: none beyond the long-poll HTTP call(s) needed to satisfy the
492
500
  wait (``openWorldHint`` — it talks to the live mailsocket API and blocks
@@ -537,12 +545,13 @@ def wait_for_link(
537
545
  ``since`` (optional) only matches messages that arrived after this point:
538
546
  an ISO8601 timestamp, unix seconds, or a message id (``msg_...``, meaning
539
547
  "only messages after that one"; an unknown id falls back to the oldest
540
- message, so prefer a timestamp if unsure). A fresh inbox straight from
541
- ``create_inbox`` needs nothing here — the default already matches
542
- anything in it. When REUSING an inbox for a second link, pass either the
543
- time just before you triggered this email, or the ``id`` of the last
544
- message you saw, so you get the NEW link instead of the oldest one still
545
- retained in the inbox.
548
+ message, so prefer a timestamp if unsure). Omitted (the default), the
549
+ server matches only messages that arrive during this call — the right
550
+ choice for a fresh inbox from ``create_inbox``. When REUSING an inbox for
551
+ a second link and you want to catch one that already arrived before this
552
+ call, pass either the time just before you triggered this email, or the
553
+ ``id`` of the last message you saw, so you get the NEW link instead of
554
+ missing it.
546
555
 
547
556
  Side effects: none beyond the long-poll HTTP call(s) needed to satisfy the
548
557
  wait (``openWorldHint`` — it talks to the live mailsocket API and blocks
@@ -652,6 +661,46 @@ def get_latest(inbox_id: str) -> dict:
652
661
  return _get_client().get_latest(inbox_id)
653
662
 
654
663
 
664
+ @server.tool(
665
+ annotations=types.ToolAnnotations(
666
+ readOnlyHint=False,
667
+ destructiveHint=False,
668
+ idempotentHint=False,
669
+ openWorldHint=True,
670
+ ),
671
+ )
672
+ @_guarded()
673
+ def send_test_code(inbox_id: str) -> dict:
674
+ """Put one sample OTP email (a random 6-digit code) into ``inbox_id``.
675
+
676
+ Use this to check the wait loop end to end when you have no real email
677
+ to trigger. It does NOT go through the mail server: the message is
678
+ stored and parsed exactly like real inbound mail, so ``wait_for_otp``
679
+ returns it.
680
+
681
+ How to pair it with the wait (one call at a time, no concurrency):
682
+ BEFORE calling this tool, take a cutoff, either the ``id`` of the newest
683
+ message (``get_latest``; use ``since=0`` if the inbox is empty) or the
684
+ current time (ISO8601 or unix seconds). Then call ``send_test_code``,
685
+ then ``wait_for_otp(inbox_id, since=<that cutoff>)``. Without ``since``,
686
+ the wait only matches mail arriving during the wait itself and would
687
+ miss this code. Do not pass the returned ``message_id`` as ``since``:
688
+ that means "after this message" and skips it.
689
+
690
+ Limits: 5 per inbox and 20 per account per hour (shared with the
691
+ dashboard "Send a test code" button); over that, a clean rate-limit
692
+ error says when to retry. A disabled inbox, or an unknown/deleted/
693
+ foreign inbox id, returns a clean tool error and nothing is charged.
694
+
695
+ Side effects: creates one message in the inbox (counts toward monthly
696
+ message usage and fires the inbox's webhook, like real mail). Makes one
697
+ network call to the mailsocket API (``openWorldHint``). Returns
698
+ ``message_id`` and ``sent_at`` of the new message.
699
+ """
700
+ data = _get_client().send_test_code(inbox_id)
701
+ return {"message_id": data.get("message_id"), "sent_at": data.get("sent_at")}
702
+
703
+
655
704
  @server.tool(
656
705
  annotations=types.ToolAnnotations(destructiveHint=True, openWorldHint=True),
657
706
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket-mcp
3
- Version: 0.2.3
3
+ Version: 0.4.0
4
4
  Summary: MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Topic :: Communications :: Email
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: mailsocket>=0.1.3
22
+ Requires-Dist: mailsocket>=0.3.0
23
23
  Requires-Dist: mcp<3.0,>=2.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7; extra == "dev"
@@ -39,11 +39,12 @@ no second HTTP client, no re-implemented polling.
39
39
  | Tool | What the agent gets |
40
40
  | --- | --- |
41
41
  | `create_inbox(label?)` | a fresh inbox `id` + `address` |
42
- | `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0, since?)` | **the headline** — blocks (bounded: requested deadline clamped to 120s locally, 55s on the remote server) and returns the OTP string + confidence + subject/from/id/received_at |
43
- | `wait_for_link(inbox_id, timeout?=60, since?)` | the extracted magic link (returned, *not* followed) + subject/from/id/received_at |
42
+ | `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0, since?)` | **the headline** — blocks (bounded: requested deadline clamped to 120s locally, 55s on the remote server) and returns the OTP string + confidence + subject/from/id/received_at. Default `since` omitted: only messages arriving after this call are matched. |
43
+ | `wait_for_link(inbox_id, timeout?=60, since?)` | the extracted magic link (returned, *not* followed) + subject/from/id/received_at. Default `since` omitted: only messages arriving after this call are matched. |
44
44
  | `list_inboxes(limit?, cursor?)` | one page of inboxes owned by the key (server default page size 25) + `next_cursor`/`has_more` |
45
45
  | `list_messages(inbox_id, has_otp?, subject_contains?, sender?, limit?, cursor?)` | one page of messages, optionally filtered (server default page size 25) + `next_cursor`/`has_more` |
46
46
  | `get_latest(inbox_id)` | the newest message, without blocking |
47
+ | `send_test_code(inbox_id)` | puts one sample OTP email straight into the inbox (no mail server) and returns its `message_id` + `sent_at`, so you can check the wait loop without real email. 5 per inbox / 20 per account per hour. |
47
48
  | `delete_inbox(inbox_id)` | remove an inbox when done |
48
49
 
49
50
  Errors from the SDK (`AuthError` / `NotFound` / `RateLimited` / `WaitTimeout`)
@@ -53,14 +54,26 @@ never echoed back.
53
54
  ### Reusing an inbox
54
55
 
55
56
  `create_inbox` needs no `since` — a fresh inbox only ever has new messages.
56
- But if you reuse an existing inbox (e.g. to request a second OTP/link), pass
57
- `since` to `wait_for_otp`/`wait_for_link`: otherwise the wait can match the
58
- OLDEST retained message instead of the new one you just triggered. Pass
59
- either the time right before you triggered the new email (ISO8601 or unix
60
- seconds), or the `id` of the last message you saw (`msg_...`, meaning "only
61
- messages after that one") — both tools return `id`/`received_at` on the
62
- matched message so you can chain `since=<id>` into the next call on that same
63
- inbox.
57
+ By default, `wait_for_otp`/`wait_for_link` omit `since` entirely, so the
58
+ server matches only messages arriving AFTER the call (the request start
59
+ time). But if you reuse an existing inbox (e.g. to request a second
60
+ OTP/link), pass `since` explicitly: otherwise a call with no `since` will
61
+ correctly ignore anything already sitting in the inbox, which is right for
62
+ most reuse, but if you actually WANT to catch a code that already arrived
63
+ before you called the tool, pass either the time right before you triggered
64
+ the new email (ISO8601 or unix seconds), or the `id` of the last message you
65
+ saw (`msg_...`, meaning "only messages after that one") — both tools return
66
+ `id`/`received_at` on the matched message so you can chain `since=<id>` into
67
+ the next call on that same inbox.
68
+
69
+ ### Trying the loop without real email
70
+
71
+ `send_test_code` does not go through the mail server, so the code is
72
+ already in the inbox when the tool returns. Take a cutoff first: the `id`
73
+ from `get_latest` (or `since=0` if the inbox is empty), or the current time.
74
+ Then call `send_test_code(inbox_id)`, then
75
+ `wait_for_otp(inbox_id, since=<that cutoff>)`. Without `since` the wait only
76
+ sees mail that arrives during the wait, so it would miss the test code.
64
77
 
65
78
  ## Requirements
66
79
 
@@ -195,6 +208,20 @@ remote-transport tests talk to a local fake upstream on `127.0.0.1`.
195
208
 
196
209
  ## Changelog
197
210
 
211
+ - **0.4.0** — new `send_test_code(inbox_id)` tool (stdio and the hosted
212
+ remote server, under the same per-key caps as the other non-wait tools):
213
+ puts one sample OTP email into your own inbox without going through the
214
+ mail server. Requires `mailsocket>=0.3.0` (`Client.send_test_code`).
215
+ - **0.3.0** — BEHAVIOUR CHANGE (via `mailsocket>=0.2.0`): `wait_for_otp` /
216
+ `wait_for_link` now omit `since` by default instead of forwarding `0`
217
+ ("any message already in the inbox"). The old default could return a
218
+ STALE OTP/link already sitting in a reused inbox; the new default matches
219
+ only messages arriving after the call. Pass `since=0` explicitly to keep
220
+ the old behaviour.
221
+ Hosted server (mcp.mailsocket.app, deployed from the repo, no new PyPI
222
+ release): per-key concurrent waits raised 3 → 10 (= the Pro plan cap; the
223
+ API still enforces each plan's own cap) and the shared wait pool 16 → 32,
224
+ following the backend's raised wait capacity (global 64).
198
225
  - **0.2.3** — `wait_for_otp` / `wait_for_link` accept an optional `since` (ISO8601, unix seconds or a message id) so a reused inbox returns the NEW code; results include the message `id` and `received_at`.
199
226
  - **0.2.2** — depends on `mailsocket>=0.1.3` (stricter `_seg` id validation in the underlying Python SDK).
200
227
 
@@ -1,4 +1,4 @@
1
- mailsocket>=0.1.3
1
+ mailsocket>=0.3.0
2
2
  mcp<3.0,>=2.0
3
3
 
4
4
  [dev]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket-mcp"
7
- version = "0.2.3"
7
+ version = "0.4.0"
8
8
  description = "MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Topic :: Communications :: Email",
23
23
  ]
24
24
  dependencies = [
25
- "mailsocket>=0.1.3", # published on PyPI as `mailsocket` (0.1.3 adds strict _seg validation)
25
+ "mailsocket>=0.3.0", # published on PyPI as `mailsocket` (0.3.0: Client.send_test_code; 0.2.0: since defaults to omitted — see CHANGELOG)
26
26
  "mcp>=2.0,<3.0",
27
27
  ]
28
28
 
@@ -81,6 +81,8 @@ class Upstream:
81
81
  )
82
82
  if self.command == "DELETE":
83
83
  return self._send(204, None)
84
+ if path.endswith("/test-code") and self.command == "POST":
85
+ return self._send(201, {"data": {"message_id": f"msg_{tenant}", "sent_at": "2026-10-08T12:00:00Z"}})
84
86
  if path.endswith("/messages/latest"):
85
87
  return self._send(200, {"data": {"id": "msg_1", "subject": f"hello {tenant} {path}"}})
86
88
  if path.endswith("/messages/wait"):
@@ -391,6 +393,7 @@ ALL_TOOL_CALLS = [
391
393
  ("list_messages", {"inbox_id": "i"}),
392
394
  ("get_latest", {"inbox_id": "i"}),
393
395
  ("delete_inbox", {"inbox_id": "i"}),
396
+ ("send_test_code", {"inbox_id": "i"}),
394
397
  ]
395
398
 
396
399
 
@@ -757,7 +760,7 @@ def test_stateless_no_session_id_needed(upstream):
757
760
 
758
761
  r1, r2 = run(go)
759
762
  assert r1.status_code == 200 and "mcp-session-id" not in r1.headers
760
- assert len(r1.json()["result"]["tools"]) == 7
763
+ assert len(r1.json()["result"]["tools"]) == 8
761
764
  assert r2.status_code == 200 and r2.json()["result"]["isError"] is False
762
765
  assert "tenant-B" in r2.json()["result"]["content"][0]["text"]
763
766
 
@@ -828,6 +831,12 @@ class FakeClient:
828
831
 
829
832
  return R()
830
833
 
834
+ def send_test_code(self, inbox_id):
835
+ self.calls.append(("send_test_code", inbox_id))
836
+ if FakeClient.block_keys is not None and self.key in FakeClient.block_keys:
837
+ self._block()
838
+ return {"message_id": "msg_t", "sent_at": "2026-10-08T12:00:00Z"}
839
+
831
840
  def list_inboxes(self, limit=None, cursor=None):
832
841
  if FakeClient.block_keys is not None and self.key in FakeClient.block_keys:
833
842
  self._block()
@@ -874,9 +883,14 @@ def test_wait_capacity_429_is_clean_error_with_retry_hint(upstream, fake_client_
874
883
  assert "Traceback" not in text
875
884
 
876
885
 
877
- def test_limiter_caps_waits_at_16_and_other_tools_still_served(upstream, fake_client_cls, monkeypatch):
878
- """20 concurrent waits: at most 16 in flight, the rest get a clean busy
879
- error after the queue timeout, and list_inboxes is served meanwhile."""
886
+ def test_limiter_caps_waits_at_wait_concurrency_and_other_tools_still_served(
887
+ upstream, fake_client_cls, monkeypatch
888
+ ):
889
+ """WAIT_CONCURRENCY + 4 concurrent waits: at most WAIT_CONCURRENCY in
890
+ flight, the 4 extra get a clean busy error after the queue timeout, and
891
+ list_inboxes is served meanwhile."""
892
+ cap = srv.WAIT_CONCURRENCY
893
+ excess = 4
880
894
  monkeypatch.setattr(srv, "LIMITER_QUEUE_TIMEOUT", 0.5)
881
895
  fake_client_cls.gate = threading.Event()
882
896
  app = make_app(upstream)
@@ -896,42 +910,58 @@ def test_limiter_caps_waits_at_16_and_other_tools_still_served(upstream, fake_cl
896
910
  waits.append(r.json()["result"])
897
911
 
898
912
  async with anyio.create_task_group() as tg:
899
- for i in range(20):
913
+ for i in range(cap + excess):
900
914
  tg.start_soon(wait_one, i)
901
915
  # Let the waits saturate their pool, then prove "other" is free.
902
916
  with anyio.fail_after(5):
903
- while FakeClient.inflight < 16:
917
+ while FakeClient.inflight < cap:
904
918
  await anyio.sleep(0.01)
905
919
  other = await client.post(
906
920
  "/mcp", headers=bearer(KEY_A), json=tool_call("list_inboxes", {}, id_=99)
907
921
  )
908
922
  other_inflight = FakeClient.inflight
909
- # Wait until the 4 excess callers have been refused, then release.
923
+ # Wait until the excess callers have been refused, then release.
910
924
  with anyio.fail_after(5):
911
- while len(waits) < 4:
925
+ while len(waits) < excess:
912
926
  await anyio.sleep(0.01)
913
927
  FakeClient.gate.set()
914
928
  return waits, other, other_inflight
915
929
 
916
930
  waits, other, other_inflight = run(go)
917
- assert FakeClient.max_inflight == 16
931
+ assert FakeClient.max_inflight == cap
918
932
  assert other.json()["result"]["isError"] is False
919
- assert other_inflight == 16 # served while all 16 wait slots were busy
933
+ assert other_inflight == cap # served while every wait slot was busy
920
934
  busy = [w for w in waits if w["isError"]]
921
935
  ok = [w for w in waits if not w["isError"]]
922
- assert len(busy) == 4 and len(ok) == 16
936
+ assert len(busy) == excess and len(ok) == cap
923
937
  assert all("Retry in a few seconds" in w["content"][0]["text"] for w in busy)
924
938
 
925
939
 
940
+ @pytest.mark.parametrize("path", ["docker-compose.yml", "deploy/mcp/Dockerfile"])
941
+ def test_uvicorn_limit_concurrency_covers_both_pools(path):
942
+ """uvicorn answers 503 above --limit-concurrency before our limiters ever
943
+ see the request, so it must admit every wait + other slot (and the wait
944
+ pool must stay below the backend's global 64 so MCP alone cannot fill it)."""
945
+ import re
946
+ from pathlib import Path
947
+
948
+ text = (Path(__file__).resolve().parents[3] / path).read_text()
949
+ m = re.search(r'"--limit-concurrency",\s*"(\d+)"', text)
950
+ assert m, path
951
+ assert int(m.group(1)) >= srv.WAIT_CONCURRENCY + srv.OTHER_CONCURRENCY
952
+ assert srv.WAIT_CONCURRENCY < 64
953
+
954
+
926
955
  # -- per-key fairness (opus P2-1) --------------------------------------------
927
956
 
928
957
 
929
958
  def test_p2_one_key_cannot_starve_another_real_upstream_429(upstream, monkeypatch):
930
- """opus's exact scenario on the REAL SDK + wire: 16 concurrent waits from key
931
- A against an upstream answering 429 too_many_wait_requests (Retry-After 1).
959
+ """opus's exact scenario on the REAL SDK + wire: PER_KEY_WAIT_CONCURRENCY + 6
960
+ concurrent waits from key A against an upstream answering 429 too_many_wait_requests (Retry-After 1).
932
961
  Key B's wait must still be served (and reach the upstream) promptly; A's
933
962
  calls come back as clean tool errors without sleeping/retrying in a slot."""
934
963
  monkeypatch.setattr(srv, "LIMITER_QUEUE_TIMEOUT", 1.5)
964
+ n_attack = srv.PER_KEY_WAIT_CONCURRENCY + 6
935
965
  upstream.wait_429_keys = {KEY_A}
936
966
  app = make_app(upstream)
937
967
 
@@ -948,7 +978,7 @@ def test_p2_one_key_cannot_starve_another_real_upstream_429(upstream, monkeypatc
948
978
  a_results.append(r.json()["result"])
949
979
 
950
980
  async with anyio.create_task_group() as tg:
951
- for i in range(16):
981
+ for i in range(n_attack):
952
982
  tg.start_soon(attacker, i)
953
983
  await anyio.sleep(0.05) # let A's calls grab whatever they can
954
984
  started = time.monotonic()
@@ -970,11 +1000,11 @@ def test_p2_one_key_cannot_starve_another_real_upstream_429(upstream, monkeypatc
970
1000
  assert len(b_calls) == 1
971
1001
  # A: every call is a clean, immediate error — either the per-key cap or
972
1002
  # the upstream 429 surfaced as-is. No Retry-After sleeping inside a slot.
973
- assert len(a_results) == 16 and all(r["isError"] for r in a_results)
1003
+ assert len(a_results) == n_attack and all(r["isError"] for r in a_results)
974
1004
  texts = [r["content"][0]["text"] for r in a_results]
975
1005
  capped = [t for t in texts if "Too many concurrent wait calls for this API key" in t]
976
1006
  limited = [t for t in texts if t.startswith("Rate limited (HTTP 429)")]
977
- assert len(capped) + len(limited) == 16
1007
+ assert len(capped) + len(limited) == n_attack
978
1008
  assert len(limited) <= srv.PER_KEY_WAIT_CONCURRENCY
979
1009
  a_calls = [c for c in upstream.calls if c["headers"]["authorization"] == f"Bearer {KEY_A}"]
980
1010
  assert len(a_calls) == len(limited) # exactly one upstream hit each, no retries
@@ -982,9 +1012,11 @@ def test_p2_one_key_cannot_starve_another_real_upstream_429(upstream, monkeypatc
982
1012
  assert srv._PerKeyInflight.counts == {} # entries removed at 0
983
1013
 
984
1014
 
985
- def test_p2_per_key_wait_cap_is_3_and_other_key_still_served(upstream, fake_client_cls):
986
- """Per-key cap alone: A's 16 waits → only 3 in flight, 13 immediate clean
987
- errors; B's wait is served while A's 3 are still held."""
1015
+ def test_p2_per_key_wait_cap_and_other_key_still_served(upstream, fake_client_cls):
1016
+ """Per-key cap alone: A's cap + 6 waits → only PER_KEY_WAIT_CONCURRENCY in
1017
+ flight, 6 immediate clean errors; B's wait is served while A's are held."""
1018
+ cap = srv.PER_KEY_WAIT_CONCURRENCY
1019
+ n = cap + 6
988
1020
  fake_client_cls.gate = threading.Event()
989
1021
  fake_client_cls.block_keys = {KEY_A}
990
1022
  app = make_app(upstream)
@@ -1001,10 +1033,10 @@ def test_p2_per_key_wait_cap_is_3_and_other_key_still_served(upstream, fake_clie
1001
1033
  results.append(r.json()["result"])
1002
1034
 
1003
1035
  async with anyio.create_task_group() as tg:
1004
- for i in range(16):
1036
+ for i in range(n):
1005
1037
  tg.start_soon(a, i)
1006
1038
  with anyio.fail_after(5):
1007
- while len(results) < 13 or FakeClient.inflight < 3:
1039
+ while len(results) < n - cap or FakeClient.inflight < cap:
1008
1040
  await anyio.sleep(0.01)
1009
1041
  held = FakeClient.inflight
1010
1042
  counts_while_held = dict(srv._PerKeyInflight.counts)
@@ -1016,10 +1048,10 @@ def test_p2_per_key_wait_cap_is_3_and_other_key_still_served(upstream, fake_clie
1016
1048
  return results, b.json()["result"], held, counts_while_held
1017
1049
 
1018
1050
  results, b, held, counts = run(go)
1019
- assert held == 3 and FakeClient.max_inflight == 3
1051
+ assert held == cap and FakeClient.max_inflight == cap
1020
1052
  busy = [r for r in results if r["isError"]]
1021
- assert len(busy) == 13 and len(results) == 16
1022
- assert all("max 3 in flight" in r["content"][0]["text"] for r in busy)
1053
+ assert len(busy) == n - cap and len(results) == n
1054
+ assert all(f"max {cap} in flight" in r["content"][0]["text"] for r in busy)
1023
1055
  assert b["isError"] is False
1024
1056
  # keyed by sha256 digest, never by the key itself
1025
1057
  assert all(KEY_A not in k[0] and len(k[0]) == 64 for k in counts)
@@ -1060,6 +1092,59 @@ def test_p2_per_key_other_cap_is_8(upstream, fake_client_cls):
1060
1092
  assert srv._PerKeyInflight.counts == {}
1061
1093
 
1062
1094
 
1095
+ def test_send_test_code_reaches_upstream_as_tenant_post(upstream):
1096
+ """Remote: the tool POSTs /inboxes/{id}/test-code upstream with the
1097
+ CALLER's key and returns that tenant's result."""
1098
+ app = make_app(upstream)
1099
+ r = _post(app, json=tool_call("send_test_code", {"inbox_id": "inbox_x"}))
1100
+ result = r.json()["result"]
1101
+ assert result["isError"] is False
1102
+ assert json.loads(result["content"][0]["text"]) == {
1103
+ "message_id": "msg_tenant-A",
1104
+ "sent_at": "2026-10-08T12:00:00Z",
1105
+ }
1106
+ assert [(c["method"], c["path"]) for c in upstream.calls] == [("POST", "/api/v1/inboxes/inbox_x/test-code")]
1107
+ assert upstream.calls[0]["headers"]["authorization"] == f"Bearer {KEY_A}"
1108
+
1109
+
1110
+ def test_send_test_code_shares_the_per_key_non_wait_cap(upstream, fake_client_cls):
1111
+ """send_test_code is a non-wait tool: the per-key cap of 8 in-flight
1112
+ non-wait calls applies to it, and another key is still served."""
1113
+ fake_client_cls.gate = threading.Event()
1114
+ fake_client_cls.block_keys = {KEY_A}
1115
+ app = make_app(upstream)
1116
+
1117
+ async def go():
1118
+ results = []
1119
+ async with serving(app) as client:
1120
+
1121
+ async def a(i):
1122
+ r = await client.post(
1123
+ "/mcp", headers=bearer(KEY_A), json=tool_call("send_test_code", {"inbox_id": "i"}, id_=i)
1124
+ )
1125
+ results.append(r.json()["result"])
1126
+
1127
+ async with anyio.create_task_group() as tg:
1128
+ for i in range(12):
1129
+ tg.start_soon(a, i)
1130
+ with anyio.fail_after(5):
1131
+ while len(results) < 4 or FakeClient.inflight < srv.PER_KEY_OTHER_CONCURRENCY:
1132
+ await anyio.sleep(0.01)
1133
+ b = await client.post(
1134
+ "/mcp", headers=bearer(KEY_B), json=tool_call("send_test_code", {"inbox_id": "i"})
1135
+ )
1136
+ FakeClient.gate.set()
1137
+ return results, b.json()["result"]
1138
+
1139
+ results, b = run(go)
1140
+ assert FakeClient.max_inflight == srv.PER_KEY_OTHER_CONCURRENCY
1141
+ busy = [r for r in results if r["isError"]]
1142
+ assert len(busy) == 12 - srv.PER_KEY_OTHER_CONCURRENCY
1143
+ assert all("non-wait calls for this API key (max 8" in r["content"][0]["text"] for r in busy)
1144
+ assert b["isError"] is False
1145
+ assert srv._PerKeyInflight.counts == {}
1146
+
1147
+
1063
1148
  def test_p2_remote_wait_429_returns_immediately_no_sleep(upstream, monkeypatch):
1064
1149
  """Remote mode: an upstream 429 on a wait is surfaced at once (one upstream
1065
1150
  hit), never slept on inside the slot."""
@@ -1085,7 +1170,7 @@ def test_p2_per_key_counter_released_on_error(upstream, fake_client_cls):
1085
1170
  async def go():
1086
1171
  texts = []
1087
1172
  async with serving(app) as client:
1088
- for i in range(5): # would hit the cap of 3 if slots leaked
1173
+ for i in range(srv.PER_KEY_WAIT_CONCURRENCY + 2): # would hit the cap if slots leaked
1089
1174
  r = await client.post(
1090
1175
  "/mcp", headers=bearer(KEY_A),
1091
1176
  json=tool_call("wait_for_otp", {"inbox_id": "i"}, id_=i),
@@ -1094,7 +1179,7 @@ def test_p2_per_key_counter_released_on_error(upstream, fake_client_cls):
1094
1179
  return texts
1095
1180
 
1096
1181
  texts = run(go)
1097
- assert len(texts) == 5
1182
+ assert len(texts) == srv.PER_KEY_WAIT_CONCURRENCY + 2
1098
1183
  assert all("Too many concurrent" not in t for t in texts)
1099
1184
  assert all(t == "boom" for t in texts)
1100
1185
  assert srv._PerKeyInflight.counts == {}
@@ -1113,6 +1198,8 @@ def test_stdio_binding_has_no_per_key_cap_and_sdk_still_retries():
1113
1198
  inflight = 0
1114
1199
  peak = 0
1115
1200
 
1201
+ n = srv.PER_KEY_WAIT_CONCURRENCY + 3
1202
+
1116
1203
  async def go():
1117
1204
  ev = anyio.Event()
1118
1205
 
@@ -1126,12 +1213,13 @@ def test_stdio_binding_has_no_per_key_cap_and_sdk_still_retries():
1126
1213
 
1127
1214
  with srv.bind_client(object(), KEY_A):
1128
1215
  async with anyio.create_task_group() as tg:
1129
- for _ in range(6):
1216
+ for _ in range(n):
1130
1217
  tg.start_soon(body)
1131
1218
  with anyio.fail_after(5):
1132
- while Gate.inflight < 6:
1219
+ while Gate.inflight < n:
1133
1220
  await anyio.sleep(0.01)
1134
1221
  ev.set()
1135
1222
 
1136
1223
  run(go)
1137
- assert Gate.peak == 6 # > per-key cap of 3: stdio is single-tenant, uncapped
1224
+ # > the remote per-key wait cap: stdio is single-tenant, uncapped
1225
+ assert Gate.peak == n > srv.PER_KEY_WAIT_CONCURRENCY
@@ -56,18 +56,20 @@ class FakeClient:
56
56
  self.deleted = []
57
57
  self.wait_otp = None # (result | exception) scripted per-test
58
58
  self.wait_link = None
59
+ self.test_code_calls = []
60
+ self.test_code = {"message_id": "msg_test1", "sent_at": "2026-10-08T12:00:00Z"}
59
61
 
60
62
  def create_inbox(self, label=None):
61
63
  self.created.append(label)
62
64
  return {"id": "inbox_1", "address": "x@in.inboxpipe.net"}
63
65
 
64
- def wait_for_otp(self, inbox_id, *, timeout=60.0, min_confidence=0.0, since=0):
66
+ def wait_for_otp(self, inbox_id, *, timeout=60.0, min_confidence=0.0, since=None):
65
67
  self.wait_otp_calls.append((inbox_id, timeout, min_confidence, since))
66
68
  if isinstance(self.wait_otp, Exception):
67
69
  raise self.wait_otp
68
70
  return self.wait_otp
69
71
 
70
- def wait_for_link(self, inbox_id, *, timeout=60.0, since=0):
72
+ def wait_for_link(self, inbox_id, *, timeout=60.0, since=None):
71
73
  self.wait_link_calls.append((inbox_id, timeout, since))
72
74
  if isinstance(self.wait_link, Exception):
73
75
  raise self.wait_link
@@ -96,6 +98,12 @@ class FakeClient:
96
98
  self.deleted.append(inbox_id)
97
99
  return None
98
100
 
101
+ def send_test_code(self, inbox_id):
102
+ self.test_code_calls.append(inbox_id)
103
+ if isinstance(self.test_code, Exception):
104
+ raise self.test_code
105
+ return self.test_code
106
+
99
107
 
100
108
  class SimplePage:
101
109
  def __init__(self, data):
@@ -132,6 +140,7 @@ def test_tools_list_returns_all_tools_with_valid_schemas():
132
140
  "list_messages",
133
141
  "get_latest",
134
142
  "delete_inbox",
143
+ "send_test_code",
135
144
  }
136
145
  by_name = {t.name: t for t in tools}
137
146
  for name, tool in by_name.items():
@@ -213,6 +222,12 @@ def test_tools_list_annotation_values():
213
222
  "idempotentHint": None,
214
223
  "openWorldHint": True,
215
224
  },
225
+ "send_test_code": {
226
+ "readOnlyHint": False,
227
+ "destructiveHint": False,
228
+ "idempotentHint": False,
229
+ "openWorldHint": True,
230
+ },
216
231
  }
217
232
 
218
233
  assert set(expected) == set(by_name)
@@ -237,7 +252,7 @@ def test_wait_for_otp_returns_otp_and_confidence(fake_client):
237
252
  assert inbox_id == "inbox_abc"
238
253
  assert timeout == 60
239
254
  assert min_conf == 0.0
240
- assert since == 0 # absent -> SDK default unchanged
255
+ assert since is None # absent -> SDK omits since (server uses request start time)
241
256
 
242
257
 
243
258
  def test_wait_for_otp_timeout_is_clamped(fake_client):
@@ -263,6 +278,17 @@ def test_wait_for_otp_since_passed_through(fake_client):
263
278
  assert since == "msg_111"
264
279
 
265
280
 
281
+ def test_wait_for_otp_explicit_since_zero_passed_through(fake_client):
282
+ """since=0 (include messages already in the inbox) must reach the SDK as 0,
283
+ not be dropped as falsy and turned into the new request-start default."""
284
+ fake_client.wait_otp = FakeWaitResult(otp="123456")
285
+
286
+ call_tool("wait_for_otp", {"inbox_id": "inbox_abc", "since": 0})
287
+
288
+ _, _, _, since = fake_client.wait_otp_calls[0]
289
+ assert since == 0
290
+
291
+
266
292
  def test_wait_for_otp_result_carries_id_and_received_at(fake_client):
267
293
  fake_client.wait_otp = FakeWaitResult(
268
294
  otp="123456",
@@ -307,7 +333,7 @@ def test_wait_for_link_returns_magic_link(fake_client):
307
333
  assert "https://example.test/go?token=abc" in result.content[0].text
308
334
  inbox_id, timeout, since = fake_client.wait_link_calls[0]
309
335
  assert inbox_id == "inbox_abc"
310
- assert since == 0 # absent -> SDK default unchanged
336
+ assert since is None # absent -> SDK omits since (server uses request start time)
311
337
 
312
338
 
313
339
  def test_wait_for_link_since_passed_through(fake_client):
@@ -375,6 +401,68 @@ def test_get_latest_and_delete_inbox(fake_client):
375
401
  assert fake_client.deleted == ["inbox_abc"]
376
402
 
377
403
 
404
+ # -- send_test_code ----------------------------------------------------------
405
+
406
+
407
+ def test_send_test_code_returns_message_id_and_sent_at(fake_client):
408
+ result = call_tool("send_test_code", {"inbox_id": "inbox_abc"})
409
+
410
+ assert result.is_error is False
411
+ payload = json.loads(result.content[0].text)
412
+ assert payload == {"message_id": "msg_test1", "sent_at": "2026-10-08T12:00:00Z"}
413
+ assert fake_client.test_code_calls == ["inbox_abc"]
414
+
415
+
416
+ def test_send_test_code_description_explains_pairing_with_wait():
417
+ tools = {t.name: t for t in asyncio.run(server.list_tools())}
418
+ desc = tools["send_test_code"].description
419
+ assert "does NOT go through the mail server" in desc
420
+ assert "since" in desc and "wait_for_otp" in desc
421
+ assert "BEFORE calling this tool" in desc
422
+ schema = tools["send_test_code"].input_schema
423
+ assert set(schema["properties"]) == {"inbox_id"}
424
+ assert schema.get("required") == ["inbox_id"]
425
+
426
+
427
+ @pytest.mark.parametrize(
428
+ "exc, expected_substring",
429
+ [
430
+ (NotFound("Resource not found.", code="not_found", status=404), "Not found (HTTP 404)"),
431
+ (
432
+ RateLimited("Too many test codes. Try again at 13:00 UTC.", code="rate_limited", retry_after=1800.0),
433
+ "Retry after 1800s",
434
+ ),
435
+ (
436
+ MailsocketError(
437
+ "This inbox is disabled. Enable it to receive a test code.", code="inbox_disabled", status=409
438
+ ),
439
+ "This inbox is disabled",
440
+ ),
441
+ (AuthError("Authentication required.", code="authentication_required"), "Authentication failed"),
442
+ ],
443
+ )
444
+ def test_send_test_code_errors_are_clean_tool_errors(fake_client, exc, expected_substring):
445
+ fake_client.test_code = exc
446
+
447
+ result = call_tool("send_test_code", {"inbox_id": "inbox_abc"})
448
+
449
+ assert result.is_error is True
450
+ text = result.content[0].text
451
+ assert expected_substring in text
452
+ assert "Traceback" not in text
453
+
454
+
455
+ def test_send_test_code_error_never_leaks_the_key(fake_client):
456
+ fake_client.test_code = MailsocketError(f"boom for {API_KEY} and ms_live_otherkey123")
457
+
458
+ result = call_tool("send_test_code", {"inbox_id": "inbox_abc"})
459
+
460
+ assert result.is_error is True
461
+ text = result.content[0].text
462
+ assert API_KEY not in text and "ms_live_" not in text
463
+ assert "***" in text
464
+
465
+
378
466
  # -- error mapping -----------------------------------------------------------
379
467
 
380
468
 
@@ -428,14 +516,14 @@ def test_version_exported():
428
516
 
429
517
 
430
518
  def test_metadata_consistency_across_files():
431
- """Versions match everywhere: MCP 0.2.3 (pyproject, server.json, __version__)
432
- and its Python SDK sibling 0.1.3 (pyproject, __version__, user agent)."""
519
+ """Versions match everywhere: MCP 0.4.0 (pyproject, server.json, __version__)
520
+ and its Python SDK sibling 0.3.0 (pyproject, __version__, user agent)."""
433
521
  import tomllib
434
522
  from pathlib import Path
435
523
 
436
524
  root = Path(__file__).resolve().parents[1]
437
- expected = "0.2.3"
438
- expected_sdk = "0.1.3"
525
+ expected = "0.4.0"
526
+ expected_sdk = "0.3.0"
439
527
 
440
528
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
441
529
  assert pyproject["project"]["version"] == expected
File without changes
File without changes