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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ mailsocket-mcp = mailsocket_mcp.server:main
@@ -0,0 +1,5 @@
1
+ mailsocket
2
+ mcp<3.0,>=2.0
3
+
4
+ [dev]
5
+ pytest>=7
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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