mailsocket-mcp 0.1.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.1.0/LICENSE +21 -0
- mailsocket_mcp-0.1.0/PKG-INFO +101 -0
- mailsocket_mcp-0.1.0/README.md +78 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp/__init__.py +12 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp/server.py +267 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp.egg-info/PKG-INFO +101 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp.egg-info/SOURCES.txt +12 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp.egg-info/dependency_links.txt +1 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp.egg-info/entry_points.txt +2 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp.egg-info/requires.txt +5 -0
- mailsocket_mcp-0.1.0/mailsocket_mcp.egg-info/top_level.txt +1 -0
- mailsocket_mcp-0.1.0/pyproject.toml +40 -0
- mailsocket_mcp-0.1.0/setup.cfg +4 -0
- mailsocket_mcp-0.1.0/tests/test_server.py +327 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mailsocket
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsocket-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents.
|
|
5
|
+
Author: mailsocket
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: mailsocket,mcp,email,otp,inbox,magic-link
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: mailsocket
|
|
19
|
+
Requires-Dist: mcp<3.0,>=2.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# mailsocket — MCP server
|
|
25
|
+
|
|
26
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes
|
|
27
|
+
[mailsocket](https://mailsocket.app) to AI coding agents as native tools, so an
|
|
28
|
+
agent can create a throwaway inbox and **block on the OTP or magic link**
|
|
29
|
+
without writing any polling code. It wraps the
|
|
30
|
+
[Python SDK](../python/) — no second HTTP client, no re-implemented polling.
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
| Tool | What the agent gets |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `create_inbox(label?)` | a fresh inbox `id` + `address` |
|
|
37
|
+
| `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0)` | **the headline** — blocks (bounded, clamped to 120s) and returns the OTP string + confidence + subject/from |
|
|
38
|
+
| `wait_for_link(inbox_id, timeout?=60)` | the extracted magic link (returned, *not* followed) |
|
|
39
|
+
| `list_inboxes()` | every inbox owned by the key |
|
|
40
|
+
| `list_messages(inbox_id, has_otp?, subject_contains?, from?)` | messages, optionally filtered |
|
|
41
|
+
| `get_latest(inbox_id)` | the newest message, without blocking |
|
|
42
|
+
| `delete_inbox(inbox_id)` | remove an inbox when done |
|
|
43
|
+
|
|
44
|
+
Errors from the SDK (`AuthError` / `NotFound` / `RateLimited` / `WaitTimeout`)
|
|
45
|
+
are turned into clean MCP tool errors — no stack traces, and the API key is
|
|
46
|
+
never echoed back.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# from this directory, with the repo's backend venv (or your own):
|
|
52
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install mcp # official MCP SDK
|
|
53
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install -e ../python # local mailsocket SDK
|
|
54
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install -e . # this server (mailsocket-mcp)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Run
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export MAILSOCKET_API_KEY="ms_live_..."
|
|
61
|
+
# optional: export MAILSOCKET_BASE_URL="https://dash.mailsocket.app/api/v1"
|
|
62
|
+
mailsocket-mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It reads `MAILSOCKET_API_KEY` (and optional `MAILSOCKET_BASE_URL`) from the
|
|
66
|
+
environment. If the key is missing it exits with a clear message and status 2;
|
|
67
|
+
the key is never logged.
|
|
68
|
+
|
|
69
|
+
## Register with an MCP client
|
|
70
|
+
|
|
71
|
+
The server is a plain stdio MCP server, so it works with any MCP-compatible
|
|
72
|
+
client. Point your client's `mcpServers` config at the `mailsocket-mcp`
|
|
73
|
+
executable and pass the key through the environment:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"mailsocket": {
|
|
79
|
+
"command": "mailsocket-mcp",
|
|
80
|
+
"args": [],
|
|
81
|
+
"env": {
|
|
82
|
+
"MAILSOCKET_API_KEY": "ms_live_...",
|
|
83
|
+
"MAILSOCKET_BASE_URL": "https://dash.mailsocket.app/api/v1"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If `mailsocket-mcp` is not on the client's `PATH`, use the venv's full path for
|
|
91
|
+
`command`, e.g. `/Users/tuki/MailBridge/backend/.venv/bin/mailsocket-mcp`.
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd sdks/mcp
|
|
97
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pytest -q
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tests are fully offline — the SDK client is faked, so nothing touches the live
|
|
101
|
+
API.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# mailsocket — MCP server
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes
|
|
4
|
+
[mailsocket](https://mailsocket.app) to AI coding agents as native tools, so an
|
|
5
|
+
agent can create a throwaway inbox and **block on the OTP or magic link**
|
|
6
|
+
without writing any polling code. It wraps the
|
|
7
|
+
[Python SDK](../python/) — no second HTTP client, no re-implemented polling.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
| Tool | What the agent gets |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `create_inbox(label?)` | a fresh inbox `id` + `address` |
|
|
14
|
+
| `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0)` | **the headline** — blocks (bounded, clamped to 120s) and returns the OTP string + confidence + subject/from |
|
|
15
|
+
| `wait_for_link(inbox_id, timeout?=60)` | the extracted magic link (returned, *not* followed) |
|
|
16
|
+
| `list_inboxes()` | every inbox owned by the key |
|
|
17
|
+
| `list_messages(inbox_id, has_otp?, subject_contains?, from?)` | messages, optionally filtered |
|
|
18
|
+
| `get_latest(inbox_id)` | the newest message, without blocking |
|
|
19
|
+
| `delete_inbox(inbox_id)` | remove an inbox when done |
|
|
20
|
+
|
|
21
|
+
Errors from the SDK (`AuthError` / `NotFound` / `RateLimited` / `WaitTimeout`)
|
|
22
|
+
are turned into clean MCP tool errors — no stack traces, and the API key is
|
|
23
|
+
never echoed back.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# from this directory, with the repo's backend venv (or your own):
|
|
29
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install mcp # official MCP SDK
|
|
30
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install -e ../python # local mailsocket SDK
|
|
31
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install -e . # this server (mailsocket-mcp)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export MAILSOCKET_API_KEY="ms_live_..."
|
|
38
|
+
# optional: export MAILSOCKET_BASE_URL="https://dash.mailsocket.app/api/v1"
|
|
39
|
+
mailsocket-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It reads `MAILSOCKET_API_KEY` (and optional `MAILSOCKET_BASE_URL`) from the
|
|
43
|
+
environment. If the key is missing it exits with a clear message and status 2;
|
|
44
|
+
the key is never logged.
|
|
45
|
+
|
|
46
|
+
## Register with an MCP client
|
|
47
|
+
|
|
48
|
+
The server is a plain stdio MCP server, so it works with any MCP-compatible
|
|
49
|
+
client. Point your client's `mcpServers` config at the `mailsocket-mcp`
|
|
50
|
+
executable and pass the key through the environment:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"mailsocket": {
|
|
56
|
+
"command": "mailsocket-mcp",
|
|
57
|
+
"args": [],
|
|
58
|
+
"env": {
|
|
59
|
+
"MAILSOCKET_API_KEY": "ms_live_...",
|
|
60
|
+
"MAILSOCKET_BASE_URL": "https://dash.mailsocket.app/api/v1"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
If `mailsocket-mcp` is not on the client's `PATH`, use the venv's full path for
|
|
68
|
+
`command`, e.g. `/Users/tuki/MailBridge/backend/.venv/bin/mailsocket-mcp`.
|
|
69
|
+
|
|
70
|
+
## Develop
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cd sdks/mcp
|
|
74
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pytest -q
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Tests are fully offline — the SDK client is faked, so nothing touches the live
|
|
78
|
+
API.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""mailsocket MCP server — expose inboxes + the OTP wait to AI agents.
|
|
2
|
+
|
|
3
|
+
The actual server lives in :mod:`mailsocket_mcp.server` (imported as a
|
|
4
|
+
submodule, not re-exported under its own name, so ``mailsocket_mcp.server``
|
|
5
|
+
always resolves to the module for ``mailsocket_mcp.server:main``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .server import MAX_WAIT_TIMEOUT, MISSING_KEY_MESSAGE
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["MAX_WAIT_TIMEOUT", "MISSING_KEY_MESSAGE"]
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""mailsocket MCP server.
|
|
2
|
+
|
|
3
|
+
Wraps the official :mod:`mailsocket` Python SDK (sprint B1) as MCP tools so an
|
|
4
|
+
AI coding agent can create a throwaway inbox and *block* on the OTP / magic
|
|
5
|
+
link without writing any polling code.
|
|
6
|
+
|
|
7
|
+
The server speaks MCP over stdio (JSON-RPC). It reads ``MAILSOCKET_API_KEY``
|
|
8
|
+
(and optional ``MAILSOCKET_BASE_URL``) from the environment; the key is never
|
|
9
|
+
logged and never echoed back in a tool error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
import mcp.types as types
|
|
21
|
+
from mcp.server.mcpserver import MCPServer
|
|
22
|
+
|
|
23
|
+
from mailsocket import AuthError, Client, MailsocketError, NotFound, RateLimited, WaitTimeout
|
|
24
|
+
|
|
25
|
+
__all__ = ["server", "main", "MAX_WAIT_TIMEOUT", "MISSING_KEY_MESSAGE"]
|
|
26
|
+
|
|
27
|
+
# The headline wait is bounded: an agent can ask for up to 120s, never more.
|
|
28
|
+
DEFAULT_WAIT_TIMEOUT = 60.0
|
|
29
|
+
MAX_WAIT_TIMEOUT = 120.0
|
|
30
|
+
MIN_WAIT_TIMEOUT = 1.0
|
|
31
|
+
|
|
32
|
+
MISSING_KEY_MESSAGE = (
|
|
33
|
+
"MAILSOCKET_API_KEY is not set. Export it before starting the server, e.g. "
|
|
34
|
+
"`MAILSOCKET_API_KEY=ms_live_... mailsocket-mcp`."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
server = MCPServer(
|
|
38
|
+
name="mailsocket",
|
|
39
|
+
title="mailsocket MCP server",
|
|
40
|
+
description=(
|
|
41
|
+
"Ephemeral email inboxes for AI agents: create an inbox, hand the "
|
|
42
|
+
"address to any signup form, then block on the OTP or magic link."
|
|
43
|
+
),
|
|
44
|
+
version="0.1.0",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -- client construction -----------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _State:
|
|
52
|
+
client: Client | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_client_from_env() -> Client:
|
|
56
|
+
"""Build an SDK client from the environment, failing clearly without a key."""
|
|
57
|
+
api_key = os.environ.get("MAILSOCKET_API_KEY", "")
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise RuntimeError(MISSING_KEY_MESSAGE)
|
|
60
|
+
base_url = os.environ.get("MAILSOCKET_BASE_URL") or None
|
|
61
|
+
if base_url:
|
|
62
|
+
return Client(api_key, base_url=base_url)
|
|
63
|
+
return Client(api_key)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_client() -> Client:
|
|
67
|
+
if _State.client is None:
|
|
68
|
+
_State.client = _build_client_from_env()
|
|
69
|
+
return _State.client
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# -- error handling ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _safe(text: str) -> str:
|
|
76
|
+
"""Redact the API key from any text so it can never leak into a tool error.
|
|
77
|
+
|
|
78
|
+
Defense in depth against reformatted keys: redact the literal key, its
|
|
79
|
+
URL-encoded form, AND any token sharing the ``ms_live_`` credential prefix,
|
|
80
|
+
so an SDK/network error that url-encodes, wraps, or splits the key around
|
|
81
|
+
still cannot surface a usable secret to the agent.
|
|
82
|
+
"""
|
|
83
|
+
key = os.environ.get("MAILSOCKET_API_KEY", "")
|
|
84
|
+
if key:
|
|
85
|
+
text = text.replace(key, "***")
|
|
86
|
+
encoded = quote(key, safe="")
|
|
87
|
+
if encoded != key:
|
|
88
|
+
text = text.replace(encoded, "***")
|
|
89
|
+
# Belt and braces: scrub anything that looks like a live key, even if the
|
|
90
|
+
# env value was reformatted beyond a literal/url-encoded match.
|
|
91
|
+
text = re.sub(r"ms_live_[A-Za-z0-9_\-]{4,}", "***", text)
|
|
92
|
+
return text.strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _error_result(exc: BaseException) -> types.CallToolResult:
|
|
96
|
+
"""Turn an SDK exception into a clean MCP tool error (no traceback, no key)."""
|
|
97
|
+
if isinstance(exc, AuthError):
|
|
98
|
+
msg = "Authentication failed (HTTP 401): the API key was rejected. Check MAILSOCKET_API_KEY."
|
|
99
|
+
elif isinstance(exc, WaitTimeout):
|
|
100
|
+
msg = f"Timed out waiting for a matching message: {_safe(str(exc))}"
|
|
101
|
+
elif isinstance(exc, NotFound):
|
|
102
|
+
msg = f"Not found (HTTP 404): {_safe(str(exc))}"
|
|
103
|
+
elif isinstance(exc, RateLimited):
|
|
104
|
+
retry = f" Retry after {exc.retry_after:g}s." if exc.retry_after else ""
|
|
105
|
+
msg = f"Rate limited (HTTP 429): {_safe(str(exc))}.{retry}"
|
|
106
|
+
elif isinstance(exc, MailsocketError):
|
|
107
|
+
msg = _safe(str(exc)) or exc.__class__.__name__
|
|
108
|
+
else:
|
|
109
|
+
msg = _safe(str(exc)) or exc.__class__.__name__
|
|
110
|
+
return types.CallToolResult(
|
|
111
|
+
is_error=True,
|
|
112
|
+
content=[types.TextContent(type="text", text=msg)],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _guarded(fn: Callable) -> Callable:
|
|
117
|
+
"""Wrap a tool so SDK errors become clean MCP tool errors."""
|
|
118
|
+
|
|
119
|
+
@functools.wraps(fn)
|
|
120
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
121
|
+
try:
|
|
122
|
+
return fn(*args, **kwargs)
|
|
123
|
+
except Exception as exc: # noqa: BLE001 — any tool error must be scrubbed
|
|
124
|
+
# Route EVERY exception (incl. unexpected KeyError/TypeError/ValueError)
|
|
125
|
+
# through _error_result so the key is scrubbed and no raw traceback
|
|
126
|
+
# ever reaches the agent. _error_result handles known SDK types
|
|
127
|
+
# specially and falls back to a class-name-only message otherwise.
|
|
128
|
+
return _error_result(exc)
|
|
129
|
+
|
|
130
|
+
return wrapper
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clamp_timeout(timeout: float) -> float:
|
|
134
|
+
return max(MIN_WAIT_TIMEOUT, min(float(timeout), MAX_WAIT_TIMEOUT))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ctx(result, include: tuple[str, ...]) -> dict:
|
|
138
|
+
"""Pick subject/from context off a ``WaitResult`` message."""
|
|
139
|
+
message = getattr(result, "message", None) or {}
|
|
140
|
+
ctx: dict = {}
|
|
141
|
+
for field in include:
|
|
142
|
+
ctx[field] = message.get(field)
|
|
143
|
+
return ctx
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# -- tools -------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@server.tool()
|
|
150
|
+
@_guarded
|
|
151
|
+
def create_inbox(label: str | None = None) -> dict:
|
|
152
|
+
"""Create a new throwaway inbox and return its id and email address.
|
|
153
|
+
|
|
154
|
+
Hand the returned ``address`` to whatever signup form sends the code, then
|
|
155
|
+
call ``wait_for_otp`` (or ``wait_for_link``) with the returned ``id``.
|
|
156
|
+
"""
|
|
157
|
+
inbox = _get_client().create_inbox(label=label)
|
|
158
|
+
return {"id": inbox.get("id"), "address": inbox.get("address")}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@server.tool()
|
|
162
|
+
@_guarded
|
|
163
|
+
def wait_for_otp(
|
|
164
|
+
inbox_id: str,
|
|
165
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
166
|
+
min_confidence: float = 0.0,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Block (bounded) until an OTP arrives in ``inbox_id`` and return it.
|
|
169
|
+
|
|
170
|
+
This is the headline tool: no polling loop, no email parsing. ``timeout`` is
|
|
171
|
+
clamped to [1, 120] seconds. Returns the OTP string, a confidence score and
|
|
172
|
+
the subject/from of the message it came from. Raises a clean error if no
|
|
173
|
+
matching message arrives in time.
|
|
174
|
+
"""
|
|
175
|
+
result = _get_client().wait_for_otp(
|
|
176
|
+
inbox_id,
|
|
177
|
+
timeout=_clamp_timeout(timeout),
|
|
178
|
+
min_confidence=min_confidence,
|
|
179
|
+
)
|
|
180
|
+
return {
|
|
181
|
+
"otp": result.otp,
|
|
182
|
+
"confidence": result.confidence,
|
|
183
|
+
**_ctx(result, ("subject", "from")),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@server.tool()
|
|
188
|
+
@_guarded
|
|
189
|
+
def wait_for_link(
|
|
190
|
+
inbox_id: str,
|
|
191
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
192
|
+
) -> dict:
|
|
193
|
+
"""Block (bounded) until a magic link arrives in ``inbox_id`` and return it.
|
|
194
|
+
|
|
195
|
+
The link is returned, NOT followed — use it (e.g. in a headless browser or
|
|
196
|
+
``curl``) however you like. ``timeout`` is clamped to [1, 120] seconds.
|
|
197
|
+
"""
|
|
198
|
+
result = _get_client().wait_for_link(inbox_id, timeout=_clamp_timeout(timeout))
|
|
199
|
+
return {
|
|
200
|
+
"magic_link": result.magic_link,
|
|
201
|
+
**_ctx(result, ("subject", "from")),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@server.tool()
|
|
206
|
+
@_guarded
|
|
207
|
+
def list_inboxes() -> dict:
|
|
208
|
+
"""List all inboxes owned by the API key."""
|
|
209
|
+
page = _get_client().list_inboxes()
|
|
210
|
+
return {"inboxes": list(page.data)}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@server.tool()
|
|
214
|
+
@_guarded
|
|
215
|
+
def list_messages(
|
|
216
|
+
inbox_id: str,
|
|
217
|
+
has_otp: bool | None = None,
|
|
218
|
+
subject_contains: str | None = None,
|
|
219
|
+
sender: str | None = None,
|
|
220
|
+
) -> dict:
|
|
221
|
+
"""List messages in ``inbox_id``, optionally filtered.
|
|
222
|
+
|
|
223
|
+
``has_otp`` keeps only messages with a detected OTP, ``subject_contains``
|
|
224
|
+
does a case-insensitive substring match on the subject, and ``sender``
|
|
225
|
+
matches the ``from`` address (maps to the API's ``from`` filter).
|
|
226
|
+
"""
|
|
227
|
+
page = _get_client().list_messages(
|
|
228
|
+
inbox_id,
|
|
229
|
+
has_otp=has_otp,
|
|
230
|
+
subject_contains=subject_contains,
|
|
231
|
+
sender=sender,
|
|
232
|
+
)
|
|
233
|
+
return {"messages": list(page.data)}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@server.tool()
|
|
237
|
+
@_guarded
|
|
238
|
+
def get_latest(inbox_id: str) -> dict:
|
|
239
|
+
"""Return the newest message in ``inbox_id`` (without blocking)."""
|
|
240
|
+
return _get_client().get_latest(inbox_id)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@server.tool()
|
|
244
|
+
@_guarded
|
|
245
|
+
def delete_inbox(inbox_id: str) -> dict:
|
|
246
|
+
"""Delete ``inbox_id`` and everything in it. Use when you are done with it."""
|
|
247
|
+
_get_client().delete_inbox(inbox_id)
|
|
248
|
+
return {"deleted": inbox_id}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# -- entrypoint --------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main() -> None:
|
|
255
|
+
"""Console entry point: fail fast on a missing key, then serve MCP over stdio."""
|
|
256
|
+
import sys
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
_get_client()
|
|
260
|
+
except RuntimeError as exc: # missing MAILSOCKET_API_KEY
|
|
261
|
+
print(_safe(str(exc)), file=sys.stderr)
|
|
262
|
+
raise SystemExit(2)
|
|
263
|
+
server.run()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
main()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailsocket-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents.
|
|
5
|
+
Author: mailsocket
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: mailsocket,mcp,email,otp,inbox,magic-link
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: mailsocket
|
|
19
|
+
Requires-Dist: mcp<3.0,>=2.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# mailsocket — MCP server
|
|
25
|
+
|
|
26
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes
|
|
27
|
+
[mailsocket](https://mailsocket.app) to AI coding agents as native tools, so an
|
|
28
|
+
agent can create a throwaway inbox and **block on the OTP or magic link**
|
|
29
|
+
without writing any polling code. It wraps the
|
|
30
|
+
[Python SDK](../python/) — no second HTTP client, no re-implemented polling.
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
| Tool | What the agent gets |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `create_inbox(label?)` | a fresh inbox `id` + `address` |
|
|
37
|
+
| `wait_for_otp(inbox_id, timeout?=60, min_confidence?=0)` | **the headline** — blocks (bounded, clamped to 120s) and returns the OTP string + confidence + subject/from |
|
|
38
|
+
| `wait_for_link(inbox_id, timeout?=60)` | the extracted magic link (returned, *not* followed) |
|
|
39
|
+
| `list_inboxes()` | every inbox owned by the key |
|
|
40
|
+
| `list_messages(inbox_id, has_otp?, subject_contains?, from?)` | messages, optionally filtered |
|
|
41
|
+
| `get_latest(inbox_id)` | the newest message, without blocking |
|
|
42
|
+
| `delete_inbox(inbox_id)` | remove an inbox when done |
|
|
43
|
+
|
|
44
|
+
Errors from the SDK (`AuthError` / `NotFound` / `RateLimited` / `WaitTimeout`)
|
|
45
|
+
are turned into clean MCP tool errors — no stack traces, and the API key is
|
|
46
|
+
never echoed back.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# from this directory, with the repo's backend venv (or your own):
|
|
52
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install mcp # official MCP SDK
|
|
53
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install -e ../python # local mailsocket SDK
|
|
54
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pip install -e . # this server (mailsocket-mcp)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Run
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export MAILSOCKET_API_KEY="ms_live_..."
|
|
61
|
+
# optional: export MAILSOCKET_BASE_URL="https://dash.mailsocket.app/api/v1"
|
|
62
|
+
mailsocket-mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It reads `MAILSOCKET_API_KEY` (and optional `MAILSOCKET_BASE_URL`) from the
|
|
66
|
+
environment. If the key is missing it exits with a clear message and status 2;
|
|
67
|
+
the key is never logged.
|
|
68
|
+
|
|
69
|
+
## Register with an MCP client
|
|
70
|
+
|
|
71
|
+
The server is a plain stdio MCP server, so it works with any MCP-compatible
|
|
72
|
+
client. Point your client's `mcpServers` config at the `mailsocket-mcp`
|
|
73
|
+
executable and pass the key through the environment:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"mailsocket": {
|
|
79
|
+
"command": "mailsocket-mcp",
|
|
80
|
+
"args": [],
|
|
81
|
+
"env": {
|
|
82
|
+
"MAILSOCKET_API_KEY": "ms_live_...",
|
|
83
|
+
"MAILSOCKET_BASE_URL": "https://dash.mailsocket.app/api/v1"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If `mailsocket-mcp` is not on the client's `PATH`, use the venv's full path for
|
|
91
|
+
`command`, e.g. `/Users/tuki/MailBridge/backend/.venv/bin/mailsocket-mcp`.
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd sdks/mcp
|
|
97
|
+
/Users/tuki/MailBridge/backend/.venv/bin/python -m pytest -q
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tests are fully offline — the SDK client is faked, so nothing touches the live
|
|
101
|
+
API.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mailsocket_mcp/__init__.py
|
|
5
|
+
mailsocket_mcp/server.py
|
|
6
|
+
mailsocket_mcp.egg-info/PKG-INFO
|
|
7
|
+
mailsocket_mcp.egg-info/SOURCES.txt
|
|
8
|
+
mailsocket_mcp.egg-info/dependency_links.txt
|
|
9
|
+
mailsocket_mcp.egg-info/entry_points.txt
|
|
10
|
+
mailsocket_mcp.egg-info/requires.txt
|
|
11
|
+
mailsocket_mcp.egg-info/top_level.txt
|
|
12
|
+
tests/test_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mailsocket_mcp
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailsocket-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "mailsocket" }]
|
|
14
|
+
keywords = ["mailsocket", "mcp", "email", "otp", "inbox", "magic-link"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Communications :: Email",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"mailsocket", # local sibling SDK — `pip install -e ../python` first
|
|
26
|
+
"mcp>=2.0,<3.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
mailsocket-mcp = "mailsocket_mcp.server:main"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
packages = ["mailsocket_mcp"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = [".", "../python"]
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Offline unit tests for the mailsocket MCP server (fake SDK client, no network)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import mailsocket_mcp
|
|
8
|
+
from mailsocket import AuthError, MailsocketError, NotFound, RateLimited, WaitTimeout
|
|
9
|
+
from mailsocket_mcp.server import (
|
|
10
|
+
MAX_WAIT_TIMEOUT,
|
|
11
|
+
MISSING_KEY_MESSAGE,
|
|
12
|
+
_State,
|
|
13
|
+
_build_client_from_env,
|
|
14
|
+
server,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
API_KEY = "ms_live_test1234567890"
|
|
18
|
+
|
|
19
|
+
OTP_MESSAGE = {
|
|
20
|
+
"id": "msg_123",
|
|
21
|
+
"inbox_id": "inbox_abc",
|
|
22
|
+
"from": "Acme <no-reply@acme.test>",
|
|
23
|
+
"subject": "Your code",
|
|
24
|
+
"otp": "123456",
|
|
25
|
+
"otp_confidence": 0.95,
|
|
26
|
+
"magic_link": None,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
LINK_MESSAGE = {
|
|
30
|
+
**OTP_MESSAGE,
|
|
31
|
+
"id": "msg_456",
|
|
32
|
+
"otp": None,
|
|
33
|
+
"otp_confidence": None,
|
|
34
|
+
"magic_link": "https://example.test/go?token=abc",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FakeWaitResult:
|
|
39
|
+
def __init__(self, otp=None, confidence=None, magic_link=None, message=None):
|
|
40
|
+
self.otp = otp
|
|
41
|
+
self.confidence = confidence
|
|
42
|
+
self.magic_link = magic_link
|
|
43
|
+
self.message = message or {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FakeClient:
|
|
47
|
+
"""Scripted stand-in for the SDK Client, recording every call."""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.created = []
|
|
51
|
+
self.wait_otp_calls = []
|
|
52
|
+
self.wait_link_calls = []
|
|
53
|
+
self.list_messages_calls = []
|
|
54
|
+
self.latest_calls = []
|
|
55
|
+
self.deleted = []
|
|
56
|
+
self.wait_otp = None # (result | exception) scripted per-test
|
|
57
|
+
self.wait_link = None
|
|
58
|
+
|
|
59
|
+
def create_inbox(self, label=None):
|
|
60
|
+
self.created.append(label)
|
|
61
|
+
return {"id": "inbox_1", "address": "x@in.inboxpipe.net"}
|
|
62
|
+
|
|
63
|
+
def wait_for_otp(self, inbox_id, *, timeout=60.0, min_confidence=0.0, since=0):
|
|
64
|
+
self.wait_otp_calls.append((inbox_id, timeout, min_confidence))
|
|
65
|
+
if isinstance(self.wait_otp, Exception):
|
|
66
|
+
raise self.wait_otp
|
|
67
|
+
return self.wait_otp
|
|
68
|
+
|
|
69
|
+
def wait_for_link(self, inbox_id, *, timeout=60.0, since=0):
|
|
70
|
+
self.wait_link_calls.append((inbox_id, timeout))
|
|
71
|
+
if isinstance(self.wait_link, Exception):
|
|
72
|
+
raise self.wait_link
|
|
73
|
+
return self.wait_link
|
|
74
|
+
|
|
75
|
+
def list_inboxes(self, limit=None, cursor=None):
|
|
76
|
+
return SimplePage([{"id": "inbox_1", "address": "x@in.inboxpipe.net"}])
|
|
77
|
+
|
|
78
|
+
def list_messages(
|
|
79
|
+
self,
|
|
80
|
+
inbox_id,
|
|
81
|
+
has_otp=None,
|
|
82
|
+
subject_contains=None,
|
|
83
|
+
sender=None,
|
|
84
|
+
limit=None,
|
|
85
|
+
cursor=None,
|
|
86
|
+
):
|
|
87
|
+
self.list_messages_calls.append((inbox_id, has_otp, subject_contains, sender))
|
|
88
|
+
return SimplePage([OTP_MESSAGE])
|
|
89
|
+
|
|
90
|
+
def get_latest(self, inbox_id):
|
|
91
|
+
self.latest_calls.append(inbox_id)
|
|
92
|
+
return OTP_MESSAGE
|
|
93
|
+
|
|
94
|
+
def delete_inbox(self, inbox_id):
|
|
95
|
+
self.deleted.append(inbox_id)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SimplePage:
|
|
100
|
+
def __init__(self, data):
|
|
101
|
+
self.data = data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.fixture
|
|
105
|
+
def fake_client(monkeypatch):
|
|
106
|
+
"""Wire a FakeClient into the server and ensure a key is present."""
|
|
107
|
+
monkeypatch.setenv("MAILSOCKET_API_KEY", API_KEY)
|
|
108
|
+
monkeypatch.delenv("MAILSOCKET_BASE_URL", raising=False)
|
|
109
|
+
_State.client = None
|
|
110
|
+
fake = FakeClient()
|
|
111
|
+
_State.client = fake
|
|
112
|
+
yield fake
|
|
113
|
+
_State.client = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def call_tool(name, arguments):
|
|
117
|
+
return asyncio.run(server.call_tool(name, arguments))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# -- tools/list --------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_tools_list_returns_all_tools_with_valid_schemas():
|
|
124
|
+
tools = asyncio.run(server.list_tools())
|
|
125
|
+
names = {t.name for t in tools}
|
|
126
|
+
assert names == {
|
|
127
|
+
"create_inbox",
|
|
128
|
+
"wait_for_otp",
|
|
129
|
+
"wait_for_link",
|
|
130
|
+
"list_inboxes",
|
|
131
|
+
"list_messages",
|
|
132
|
+
"get_latest",
|
|
133
|
+
"delete_inbox",
|
|
134
|
+
}
|
|
135
|
+
by_name = {t.name: t for t in tools}
|
|
136
|
+
for name, tool in by_name.items():
|
|
137
|
+
assert tool.description, f"{name} missing a description"
|
|
138
|
+
schema = tool.input_schema
|
|
139
|
+
assert schema.get("type") == "object", f"{name} schema is not an object"
|
|
140
|
+
assert isinstance(schema.get("properties"), dict), f"{name} schema has no properties"
|
|
141
|
+
|
|
142
|
+
otp_schema = by_name["wait_for_otp"].input_schema
|
|
143
|
+
assert set(otp_schema["properties"]) >= {"inbox_id", "timeout", "min_confidence"}
|
|
144
|
+
assert "inbox_id" in otp_schema.get("required", [])
|
|
145
|
+
# min_confidence defaults to 0, timeout defaults to 60
|
|
146
|
+
assert otp_schema["properties"]["timeout"].get("default") == 60
|
|
147
|
+
|
|
148
|
+
create_schema = by_name["create_inbox"].input_schema
|
|
149
|
+
assert "label" in create_schema["properties"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# -- wait_for_otp ------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_wait_for_otp_returns_otp_and_confidence(fake_client):
|
|
156
|
+
fake_client.wait_otp = FakeWaitResult(
|
|
157
|
+
otp="123456", confidence=0.95, message={"subject": "Your code", "from": "Acme <no-reply@acme.test>"}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
result = call_tool("wait_for_otp", {"inbox_id": "inbox_abc"})
|
|
161
|
+
|
|
162
|
+
assert result.is_error is False
|
|
163
|
+
text = result.content[0].text
|
|
164
|
+
assert '"123456"' in text
|
|
165
|
+
inbox_id, timeout, min_conf = fake_client.wait_otp_calls[0]
|
|
166
|
+
assert inbox_id == "inbox_abc"
|
|
167
|
+
assert timeout == 60
|
|
168
|
+
assert min_conf == 0.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_wait_for_otp_timeout_is_clamped(fake_client):
|
|
172
|
+
fake_client.wait_otp = FakeWaitResult(otp="123456")
|
|
173
|
+
|
|
174
|
+
call_tool("wait_for_otp", {"inbox_id": "inbox_abc", "timeout": 9999})
|
|
175
|
+
|
|
176
|
+
_, timeout, _ = fake_client.wait_otp_calls[0]
|
|
177
|
+
assert timeout == MAX_WAIT_TIMEOUT
|
|
178
|
+
|
|
179
|
+
call_tool("wait_for_otp", {"inbox_id": "inbox_abc", "timeout": 0})
|
|
180
|
+
_, timeout, _ = fake_client.wait_otp_calls[1]
|
|
181
|
+
assert timeout == 1.0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_wait_for_otp_wait_timeout_is_clean_error(fake_client):
|
|
185
|
+
fake_client.wait_otp = WaitTimeout("No matching message arrived within 60s.")
|
|
186
|
+
|
|
187
|
+
result = call_tool("wait_for_otp", {"inbox_id": "inbox_abc"})
|
|
188
|
+
|
|
189
|
+
assert result.is_error is True
|
|
190
|
+
text = result.content[0].text
|
|
191
|
+
assert "Traceback" not in text
|
|
192
|
+
assert API_KEY not in text
|
|
193
|
+
assert "timed out" in text.lower() or "No matching message" in text
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# -- wait_for_link -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_wait_for_link_returns_magic_link(fake_client):
|
|
200
|
+
fake_client.wait_link = FakeWaitResult(magic_link="https://example.test/go?token=abc")
|
|
201
|
+
|
|
202
|
+
result = call_tool("wait_for_link", {"inbox_id": "inbox_abc"})
|
|
203
|
+
|
|
204
|
+
assert result.is_error is False
|
|
205
|
+
assert "https://example.test/go?token=abc" in result.content[0].text
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# -- create_inbox / list_messages / delete -----------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_create_inbox_returns_address_and_id(fake_client):
|
|
212
|
+
result = call_tool("create_inbox", {"label": "signup"})
|
|
213
|
+
|
|
214
|
+
assert result.is_error is False
|
|
215
|
+
text = result.content[0].text
|
|
216
|
+
assert '"inbox_1"' in text
|
|
217
|
+
assert "x@in.inboxpipe.net" in text
|
|
218
|
+
assert fake_client.created == ["signup"]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_list_messages_passes_filters_through(fake_client):
|
|
222
|
+
result = call_tool(
|
|
223
|
+
"list_messages",
|
|
224
|
+
{"inbox_id": "inbox_abc", "has_otp": True, "subject_contains": "code", "sender": "acme.test"},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
assert result.is_error is False
|
|
228
|
+
inbox_id, has_otp, subject, sender = fake_client.list_messages_calls[0]
|
|
229
|
+
assert inbox_id == "inbox_abc"
|
|
230
|
+
assert has_otp is True
|
|
231
|
+
assert subject == "code"
|
|
232
|
+
assert sender == "acme.test"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_get_latest_and_delete_inbox(fake_client):
|
|
236
|
+
call_tool("get_latest", {"inbox_id": "inbox_abc"})
|
|
237
|
+
assert fake_client.latest_calls == ["inbox_abc"]
|
|
238
|
+
|
|
239
|
+
result = call_tool("delete_inbox", {"inbox_id": "inbox_abc"})
|
|
240
|
+
assert result.is_error is False
|
|
241
|
+
assert fake_client.deleted == ["inbox_abc"]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# -- error mapping -----------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.parametrize(
|
|
248
|
+
"exc, expected_substring",
|
|
249
|
+
[
|
|
250
|
+
(AuthError("Authentication required.", code="authentication_required"), "Authentication failed"),
|
|
251
|
+
(NotFound("Resource not found.", code="not_found"), "Not found"),
|
|
252
|
+
(RateLimited("Rate limit exceeded.", retry_after=7.0), "Rate limited"),
|
|
253
|
+
],
|
|
254
|
+
)
|
|
255
|
+
def test_sdk_errors_map_to_clean_tool_errors(fake_client, exc, expected_substring):
|
|
256
|
+
fake_client.wait_otp = exc
|
|
257
|
+
|
|
258
|
+
result = call_tool("wait_for_otp", {"inbox_id": "inbox_abc"})
|
|
259
|
+
|
|
260
|
+
assert result.is_error is True
|
|
261
|
+
text = result.content[0].text
|
|
262
|
+
assert expected_substring in text
|
|
263
|
+
assert "Traceback" not in text
|
|
264
|
+
assert API_KEY not in text
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_api_key_never_appears_in_any_error(fake_client):
|
|
268
|
+
# The SDK error message itself embeds the key — the server must redact it.
|
|
269
|
+
fake_client.wait_otp = WaitTimeout(f"failed with key {API_KEY} on inbox")
|
|
270
|
+
|
|
271
|
+
result = call_tool("wait_for_otp", {"inbox_id": "inbox_abc"})
|
|
272
|
+
|
|
273
|
+
assert result.is_error is True
|
|
274
|
+
text = result.content[0].text
|
|
275
|
+
assert API_KEY not in text
|
|
276
|
+
assert "***" in text
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# -- startup / missing key ---------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_missing_api_key_raises_clear_startup_error(monkeypatch):
|
|
283
|
+
monkeypatch.delenv("MAILSOCKET_API_KEY", raising=False)
|
|
284
|
+
_State.client = None
|
|
285
|
+
|
|
286
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
287
|
+
_build_client_from_env()
|
|
288
|
+
|
|
289
|
+
assert MISSING_KEY_MESSAGE in str(exc_info.value)
|
|
290
|
+
assert "MAILSOCKET_API_KEY" in str(exc_info.value)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_version_exported():
|
|
294
|
+
assert hasattr(mailsocket_mcp, "__version__")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# -- ocr-found HIGH: reformatted-key redaction + broadened exception scrub ----
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_safe_redacts_url_encoded_and_prefixed_keys(fake_client):
|
|
301
|
+
from urllib.parse import quote
|
|
302
|
+
|
|
303
|
+
from mailsocket_mcp.server import _safe
|
|
304
|
+
|
|
305
|
+
# literal
|
|
306
|
+
assert API_KEY not in _safe(f"boom {API_KEY} boom")
|
|
307
|
+
# url-encoded form (e.g. key appearing inside a URL in an error)
|
|
308
|
+
enc = quote(API_KEY, safe="")
|
|
309
|
+
assert API_KEY not in _safe(f"GET /x?k={enc} failed")
|
|
310
|
+
assert enc not in _safe(f"GET /x?k={enc} failed")
|
|
311
|
+
# a DIFFERENT but ms_live_-shaped token is still scrubbed (defense in depth)
|
|
312
|
+
other = "ms_live_SOMEOTHERSECRET99"
|
|
313
|
+
assert other not in _safe(f"leaked {other} here")
|
|
314
|
+
assert "***" in _safe(f"leaked {other} here")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_unexpected_exception_is_scrubbed_not_raw_traceback(fake_client):
|
|
318
|
+
# An unexpected error type (not a MailsocketError/RuntimeError) that embeds
|
|
319
|
+
# the key must still become a clean, key-free tool error — never a raw raise.
|
|
320
|
+
fake_client.wait_otp = KeyError(f"weird {API_KEY} boom")
|
|
321
|
+
|
|
322
|
+
result = call_tool("wait_for_otp", {"inbox_id": "inbox_abc"})
|
|
323
|
+
|
|
324
|
+
assert result.is_error is True
|
|
325
|
+
text = result.content[0].text
|
|
326
|
+
assert API_KEY not in text
|
|
327
|
+
assert "Traceback" not in text
|