mailsocket-mcp 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Topic :: Communications :: Email
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: mailsocket>=0.1.2
22
+ Requires-Dist: mailsocket>=0.1.3
23
23
  Requires-Dist: mcp<3.0,>=2.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7; extra == "dev"
@@ -178,7 +178,11 @@ pip install -e ".[dev]"
178
178
  pytest
179
179
  ```
180
180
 
181
- Tests are fully offline — the SDK client is faked, so nothing touches the live
182
- API.
181
+ Tests make no external or live-API requests. The SDK client is faked, and the
182
+ remote-transport tests talk to a local fake upstream on `127.0.0.1`.
183
+
184
+ ## Changelog
185
+
186
+ - **0.2.2** — depends on `mailsocket>=0.1.3` (stricter `_seg` id validation in the underlying Python SDK).
183
187
 
184
188
  <!-- mcp-name: app.mailsocket/mailsocket-mcp -->
@@ -151,7 +151,11 @@ pip install -e ".[dev]"
151
151
  pytest
152
152
  ```
153
153
 
154
- Tests are fully offline — the SDK client is faked, so nothing touches the live
155
- API.
154
+ Tests make no external or live-API requests. The SDK client is faked, and the
155
+ remote-transport tests talk to a local fake upstream on `127.0.0.1`.
156
+
157
+ ## Changelog
158
+
159
+ - **0.2.2** — depends on `mailsocket>=0.1.3` (stricter `_seg` id validation in the underlying Python SDK).
156
160
 
157
161
  <!-- mcp-name: app.mailsocket/mailsocket-mcp -->
@@ -68,6 +68,10 @@ KEY_RE = re.compile(r"^ms_live_[A-Za-z0-9_\-]{8,200}$")
68
68
  # Any query parameter that looks like a credential is refused outright.
69
69
  _CREDENTIAL_PARAMS = {"api_key", "apikey", "key", "token", "access_token", "authorization"}
70
70
  _KEY_LIKE = re.compile(r"ms_live_", re.IGNORECASE)
71
+ # Access-log placeholder for any path that carries (or was refused for) a key.
72
+ REDACTED_PATH = "/mcp/<redacted>"
73
+ # How many extra percent-decoding rounds a path gets before key detection.
74
+ _MAX_PATH_DECODES = 3
71
75
  WWW_AUTHENTICATE = 'Bearer realm="mailsocket"'
72
76
  KEY_HINT = "Send a valid key as `Authorization: Bearer ms_live_...`."
73
77
  DEFAULT_ALLOWED_HOST = "mcp.mailsocket.app"
@@ -144,6 +148,30 @@ def _bearer_key(headers: Headers) -> tuple[str | None, str | None]:
144
148
  return token, None
145
149
 
146
150
 
151
+ def _path_has_key(path: str) -> bool:
152
+ """True if ``ms_live_`` (any case) appears in the path as given OR after
153
+ up to ``_MAX_PATH_DECODES`` rounds of percent-decoding, so
154
+ ``/mcp/MS_LIVE_…`` and double-encoded ``/mcp/%256Ds_live_…`` are caught."""
155
+ candidate = path
156
+ for _ in range(_MAX_PATH_DECODES + 1):
157
+ if _KEY_LIKE.search(candidate):
158
+ return True
159
+ decoded = unquote(candidate)
160
+ if decoded == candidate:
161
+ return False
162
+ candidate = decoded
163
+ return False
164
+
165
+
166
+ def _log_path(path: str) -> str:
167
+ """Path for the access log: a fixed placeholder whenever the path carries
168
+ a key (the credential_in_url refusal uses the same test), else the path
169
+ with any ``ms_live_`` token scrubbed."""
170
+ if _path_has_key(path):
171
+ return REDACTED_PATH
172
+ return _redact(path, None)
173
+
174
+
147
175
  def _query_has_credential(query_string: bytes) -> bool:
148
176
  try:
149
177
  pairs = parse_qsl(query_string.decode("latin-1"), keep_blank_values=True)
@@ -253,12 +281,13 @@ class RemoteMCPApp:
253
281
  await self._dispatch(scope, receive, send_logged)
254
282
  finally:
255
283
  # Access log: method, path (NO query string), status, duration.
256
- # Headers are never logged, so neither is the key; the path is
257
- # scrubbed of any ms_live_ token in case a client put one there.
284
+ # Headers are never logged, so neither is the key. A path with a
285
+ # key in it (any case, up to 3x percent-encoded) is replaced by a
286
+ # fixed placeholder — never partially scrubbed.
258
287
  access_logger.info(
259
288
  "%s %s %s %.0fms",
260
289
  scope.get("method", "-"),
261
- _redact(str(scope.get("path", "-")), None),
290
+ _log_path(str(scope.get("path", "-"))),
262
291
  status_holder["status"],
263
292
  (time.monotonic() - started) * 1000,
264
293
  )
@@ -278,7 +307,7 @@ class RemoteMCPApp:
278
307
  await _error(421, "invalid_host", "Invalid Host header.")(scope, receive, send)
279
308
  return
280
309
 
281
- if _KEY_LIKE.search(unquote(path)):
310
+ if _path_has_key(path):
282
311
  # e.g. POST /mcp/ms_live_… — refuse it (the logged path is
283
312
  # redacted too, but the request must not proceed either).
284
313
  await _error(
@@ -52,7 +52,7 @@ __all__ = [
52
52
  # Canonical version, re-exported as ``mailsocket_mcp.__version__`` (see
53
53
  # __init__.py). Defined here, not in __init__.py, because __init__.py imports
54
54
  # this module at package-load time and a circular self-import would break.
55
- __version__ = "0.2.0"
55
+ __version__ = "0.2.2"
56
56
 
57
57
  # The headline wait is bounded: an agent can request up to the transport's
58
58
  # ceiling as the overall deadline (clamped), though transport overhead can push
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsocket-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents.
5
5
  Author: mailsocket
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Topic :: Communications :: Email
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: mailsocket>=0.1.2
22
+ Requires-Dist: mailsocket>=0.1.3
23
23
  Requires-Dist: mcp<3.0,>=2.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7; extra == "dev"
@@ -178,7 +178,11 @@ pip install -e ".[dev]"
178
178
  pytest
179
179
  ```
180
180
 
181
- Tests are fully offline — the SDK client is faked, so nothing touches the live
182
- API.
181
+ Tests make no external or live-API requests. The SDK client is faked, and the
182
+ remote-transport tests talk to a local fake upstream on `127.0.0.1`.
183
+
184
+ ## Changelog
185
+
186
+ - **0.2.2** — depends on `mailsocket>=0.1.3` (stricter `_seg` id validation in the underlying Python SDK).
183
187
 
184
188
  <!-- mcp-name: app.mailsocket/mailsocket-mcp -->
@@ -1,4 +1,4 @@
1
- mailsocket>=0.1.2
1
+ mailsocket>=0.1.3
2
2
  mcp<3.0,>=2.0
3
3
 
4
4
  [dev]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mailsocket-mcp"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "MCP server exposing mailsocket inboxes + wait-for-OTP to AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Topic :: Communications :: Email",
23
23
  ]
24
24
  dependencies = [
25
- "mailsocket>=0.1.2", # published on PyPI as `mailsocket` (0.1.2 adds extra_headers)
25
+ "mailsocket>=0.1.3", # published on PyPI as `mailsocket` (0.1.3 adds strict _seg validation)
26
26
  "mcp>=2.0,<3.0",
27
27
  ]
28
28
 
@@ -538,12 +538,65 @@ def test_key_in_url_path_is_400_and_never_logged(upstream, caplog, path):
538
538
  assert _leaks(caplog.text, KEY_REAL) == []
539
539
  access = [rec.getMessage() for rec in caplog.records if rec.name == "mailsocket_mcp.access"]
540
540
  assert len(access) == 1 and " 400 " in access[0]
541
- assert "ms_live_" not in access[0] and "***" in access[0]
541
+ assert "ms_live_" not in access[0]
542
+ assert access[0].startswith("POST /mcp/<redacted> 400 ")
542
543
 
543
544
 
544
- def test_access_log_path_redaction_even_if_request_not_refused(caplog):
545
- """Belt and braces: the access-log path itself is scrubbed."""
546
- assert remote._redact(f"/mcp/{KEY_REAL}", None) == "/mcp/***"
545
+ PATH_BODY = "F1PathBodySecret0123456789"
546
+
547
+
548
+ @pytest.mark.parametrize(
549
+ "path",
550
+ [
551
+ f"/mcp/MS_LIVE_{PATH_BODY}", # upper case
552
+ f"/mcp/Ms_LiVe_{PATH_BODY}", # mixed case
553
+ f"/mcp/%256Ds_live_{PATH_BODY}", # double-encoded "m"
554
+ f"/mcp/%25256D%2573_live_{PATH_BODY}", # triple-encoded "m", double "s"
555
+ f"/mcp/%6D%53_LIVE_{PATH_BODY}", # single-encoded, upper case
556
+ ],
557
+ )
558
+ def test_f1_case_and_multi_encoded_key_in_path_is_400_and_log_is_placeholder(upstream, caplog, path):
559
+ """F1 #3: POST /mcp/MS_LIVE_<body> and /mcp/%256Ds_live_<body> are refused
560
+ (credential_in_url) and the access log carries only /mcp/<redacted> —
561
+ the case-sensitive single-pass scrub used to log <body> verbatim."""
562
+ caplog.set_level(logging.DEBUG)
563
+ app = make_app(upstream)
564
+
565
+ async def go():
566
+ async with serving(app) as client:
567
+ return await client.post(path, headers=bearer(KEY_REAL), json=rpc("tools/list"))
568
+
569
+ r = run(go)
570
+ assert r.status_code == 400
571
+ assert r.json()["error"]["code"] == "credential_in_url"
572
+ assert upstream.calls == []
573
+ assert PATH_BODY not in r.text
574
+ # every SERVER-side record (the in-process test client's own "httpx2"
575
+ # request log is excluded — it is not part of the deployed server)
576
+ server_side = [rec.getMessage() for rec in caplog.records if not rec.name.startswith("httpx")]
577
+ assert server_side, "expected server-side log records"
578
+ assert [line for line in server_side if PATH_BODY in line] == []
579
+ access = [rec.getMessage() for rec in caplog.records if rec.name == "mailsocket_mcp.access"]
580
+ assert len(access) == 1
581
+ assert access[0].startswith("POST /mcp/<redacted> 400 ")
582
+
583
+
584
+ @pytest.mark.parametrize(
585
+ "path,expected",
586
+ [
587
+ (f"/mcp/{KEY_REAL}", "/mcp/<redacted>"),
588
+ (f"/mcp/MS_LIVE_{PATH_BODY}", "/mcp/<redacted>"),
589
+ (f"/x/%256Ds_live_{PATH_BODY}", "/mcp/<redacted>"), # 2 decodes
590
+ (f"/x/%25256Ds_live_{PATH_BODY}", "/mcp/<redacted>"), # 3 decodes
591
+ (f"/x/%2525256Ds_live_{PATH_BODY}", f"/x/%2525256Ds_live_{PATH_BODY}"), # 4: beyond the cap
592
+ ("/mcp", "/mcp"),
593
+ ("/healthz", "/healthz"),
594
+ ],
595
+ )
596
+ def test_access_log_path_is_placeholder_whenever_path_has_a_key(path, expected):
597
+ """Belt and braces: the access-log path helper itself (case-insensitive,
598
+ up to 3 extra percent-decoding rounds)."""
599
+ assert remote._log_path(path) == expected
547
600
 
548
601
 
549
602
  def test_access_log_has_no_headers_or_query(upstream, caplog):
@@ -365,14 +365,14 @@ def test_version_exported():
365
365
 
366
366
 
367
367
  def test_metadata_consistency_across_files():
368
- """Versions match everywhere: MCP 0.2.0 (pyproject, server.json, __version__)
369
- and its Python SDK sibling 0.1.2 (pyproject, __version__, user agent)."""
368
+ """Versions match everywhere: MCP 0.2.2 (pyproject, server.json, __version__)
369
+ and its Python SDK sibling 0.1.3 (pyproject, __version__, user agent)."""
370
370
  import tomllib
371
371
  from pathlib import Path
372
372
 
373
373
  root = Path(__file__).resolve().parents[1]
374
- expected = "0.2.0"
375
- expected_sdk = "0.1.2"
374
+ expected = "0.2.2"
375
+ expected_sdk = "0.1.3"
376
376
 
377
377
  pyproject = tomllib.loads((root / "pyproject.toml").read_text())
378
378
  assert pyproject["project"]["version"] == expected
File without changes
File without changes