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.
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/PKG-INFO +39 -12
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/README.md +37 -10
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp/server.py +67 -18
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp.egg-info/PKG-INFO +39 -12
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp.egg-info/requires.txt +1 -1
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/pyproject.toml +2 -2
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/tests/test_remote.py +117 -29
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/tests/test_server.py +96 -8
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/LICENSE +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp/__init__.py +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp/remote.py +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp.egg-info/SOURCES.txt +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp.egg-info/dependency_links.txt +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp.egg-info/entry_points.txt +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/mailsocket_mcp.egg-info/top_level.txt +0 -0
- {mailsocket_mcp-0.2.3 → mailsocket_mcp-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsocket-mcp
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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).
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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).
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mailsocket-mcp"
|
|
7
|
-
version = "0.
|
|
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.
|
|
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"]) ==
|
|
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
|
|
878
|
-
|
|
879
|
-
|
|
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(
|
|
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 <
|
|
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
|
|
923
|
+
# Wait until the excess callers have been refused, then release.
|
|
910
924
|
with anyio.fail_after(5):
|
|
911
|
-
while len(waits) <
|
|
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 ==
|
|
931
|
+
assert FakeClient.max_inflight == cap
|
|
918
932
|
assert other.json()["result"]["isError"] is False
|
|
919
|
-
assert other_inflight ==
|
|
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) ==
|
|
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:
|
|
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(
|
|
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) ==
|
|
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) ==
|
|
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
|
|
986
|
-
"""Per-key cap alone: A's
|
|
987
|
-
errors; B's wait is served while A's
|
|
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(
|
|
1036
|
+
for i in range(n):
|
|
1005
1037
|
tg.start_soon(a, i)
|
|
1006
1038
|
with anyio.fail_after(5):
|
|
1007
|
-
while len(results) <
|
|
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 ==
|
|
1051
|
+
assert held == cap and FakeClient.max_inflight == cap
|
|
1020
1052
|
busy = [r for r in results if r["isError"]]
|
|
1021
|
-
assert len(busy) ==
|
|
1022
|
-
assert all("max
|
|
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(
|
|
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) ==
|
|
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(
|
|
1216
|
+
for _ in range(n):
|
|
1130
1217
|
tg.start_soon(body)
|
|
1131
1218
|
with anyio.fail_after(5):
|
|
1132
|
-
while Gate.inflight <
|
|
1219
|
+
while Gate.inflight < n:
|
|
1133
1220
|
await anyio.sleep(0.01)
|
|
1134
1221
|
ev.set()
|
|
1135
1222
|
|
|
1136
1223
|
run(go)
|
|
1137
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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.
|
|
432
|
-
and its Python SDK sibling 0.
|
|
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.
|
|
438
|
-
expected_sdk = "0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|