mailsocket-mcp 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/PKG-INFO +1 -1
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp/remote.py +33 -4
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp/server.py +1 -1
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp.egg-info/PKG-INFO +1 -1
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/pyproject.toml +1 -1
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/tests/test_remote.py +57 -4
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/tests/test_server.py +2 -2
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/LICENSE +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/README.md +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp/__init__.py +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp.egg-info/SOURCES.txt +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp.egg-info/dependency_links.txt +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp.egg-info/entry_points.txt +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp.egg-info/requires.txt +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/mailsocket_mcp.egg-info/top_level.txt +0 -0
- {mailsocket_mcp-0.2.0 → mailsocket_mcp-0.2.1}/setup.cfg +0 -0
|
@@ -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
|
|
257
|
-
#
|
|
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
|
-
|
|
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
|
|
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.
|
|
55
|
+
__version__ = "0.2.1"
|
|
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
|
|
@@ -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]
|
|
541
|
+
assert "ms_live_" not in access[0]
|
|
542
|
+
assert access[0].startswith("POST /mcp/<redacted> 400 ")
|
|
542
543
|
|
|
543
544
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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,13 +365,13 @@ def test_version_exported():
|
|
|
365
365
|
|
|
366
366
|
|
|
367
367
|
def test_metadata_consistency_across_files():
|
|
368
|
-
"""Versions match everywhere: MCP 0.2.
|
|
368
|
+
"""Versions match everywhere: MCP 0.2.1 (pyproject, server.json, __version__)
|
|
369
369
|
and its Python SDK sibling 0.1.2 (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.
|
|
374
|
+
expected = "0.2.1"
|
|
375
375
|
expected_sdk = "0.1.2"
|
|
376
376
|
|
|
377
377
|
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|