mailsocket-mcp 0.1.0__py3-none-any.whl

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,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,8 @@
1
+ mailsocket_mcp/__init__.py,sha256=NoCQmyrJZC4ESoXpZ65uzonNv6SXLSNJYPSHzbu5k-w,429
2
+ mailsocket_mcp/server.py,sha256=wZim3PwUbwl9mtw2q9GRrz71puTZyX9iXvAXalOPYbc,8616
3
+ mailsocket_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=Eyot-wc-_WRgJlHzEP7XSrVlFqlwaDvM9BYqHpDJVY0,1067
4
+ mailsocket_mcp-0.1.0.dist-info/METADATA,sha256=FUQ3x8rekaiPZTl6JfVTuv18I1-51rGebPbgYe2sH8U,3561
5
+ mailsocket_mcp-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ mailsocket_mcp-0.1.0.dist-info/entry_points.txt,sha256=2Jv4dW-IGegpnKX_PJqaSPbncebu3yoSV89-SGQZR2I,62
7
+ mailsocket_mcp-0.1.0.dist-info/top_level.txt,sha256=wzXAJ1o-ulXKdF7Xi3GLmhXKtu5SwTaqfqjA0tBEaCM,15
8
+ mailsocket_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mailsocket-mcp = mailsocket_mcp.server:main
@@ -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 @@
1
+ mailsocket_mcp