Skip to content

Commit 15b2bc5

Browse files
fix: bound PyPI metadata response reads
1 parent 61e0994 commit 15b2bc5

2 files changed

Lines changed: 74 additions & 7 deletions

File tree

src/mcp_server_python_docs/services/package_docs.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
"repository", "repo", "bug tracker", "issues", "changelog", "release notes",
2121
}
2222
_BLOCKED = ("mirror", "community", "unofficial", "tutorial", "example")
23+
_PYPI_METADATA_MAX_BYTES = 5 * 1024 * 1024
2324

2425

2526
class _HTTPResponse(Protocol):
26-
def read(self) -> bytes: ...
27+
def read(self, size: int = -1) -> bytes: ...
2728
def __enter__(self) -> "_HTTPResponse": ...
2829
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool | None: ...
2930

@@ -57,6 +58,13 @@ def _source(label: str, url: object, kind: str) -> PackageDocsSource | None:
5758
return PackageDocsSource(label=label, url=valid, kind=kind, declared_by="PyPI project metadata")
5859

5960

61+
def _read_limited(response: _HTTPResponse) -> bytes | None:
62+
data = response.read(_PYPI_METADATA_MAX_BYTES + 1)
63+
if len(data) > _PYPI_METADATA_MAX_BYTES:
64+
return None
65+
return data
66+
67+
6068
class PackageDocsService:
6169
"""Return package-declared docs/homepage/source URLs from PyPI metadata only."""
6270

@@ -70,7 +78,16 @@ def lookup(self, package: str) -> PackageDocsResult:
7078
metadata_source = f"https://pypi.org/pypi/{project}/json"
7179
try:
7280
with self._fetcher(metadata_source, self._timeout) as response:
73-
payload = json.loads(response.read().decode("utf-8"))
81+
data = _read_limited(response)
82+
if data is None:
83+
return PackageDocsResult(
84+
package=package,
85+
version="",
86+
metadata_source=metadata_source,
87+
sources=[],
88+
note="PyPI metadata exceeded size limit.",
89+
)
90+
payload = json.loads(data.decode("utf-8"))
7491
except HTTPError as e:
7592
if e.code == 404:
7693
return PackageDocsResult(

tests/test_package_docs.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,27 @@
22
from __future__ import annotations
33

44
import json
5-
from urllib.error import HTTPError
5+
from urllib.error import HTTPError, URLError
66

7-
from mcp_server_python_docs.services.package_docs import PackageDocsService
7+
from mcp_server_python_docs.services.package_docs import (
8+
_PYPI_METADATA_MAX_BYTES,
9+
PackageDocsService,
10+
)
811

912

1013
class _Resp:
11-
def __init__(self, payload: dict):
14+
def __init__(self, payload: dict | bytes):
1215
self._payload = payload
1316
def __enter__(self):
1417
return self
1518
def __exit__(self, exc_type, exc, tb):
1619
return False
17-
def read(self) -> bytes:
18-
return json.dumps(self._payload).encode()
20+
def read(self, size: int = -1) -> bytes:
21+
if isinstance(self._payload, bytes):
22+
data = self._payload
23+
else:
24+
data = json.dumps(self._payload).encode()
25+
return data if size < 0 else data[:size]
1926

2027

2128
def test_package_docs_uses_official_pypi_metadata_and_declared_urls():
@@ -67,3 +74,46 @@ def missing(url: str, timeout: float):
6774
tool = create_server()._tool_manager._tools["lookup_package_docs"]
6875
assert tool.annotations.readOnlyHint is True
6976
assert tool.annotations.openWorldHint is True
77+
78+
79+
def test_package_docs_rejects_oversized_pypi_metadata_without_unbounded_read():
80+
class LargeResp:
81+
requested_size: int | None = None
82+
83+
def __enter__(self):
84+
return self
85+
86+
def __exit__(self, exc_type, exc, tb):
87+
return False
88+
89+
def read(self, size: int = -1) -> bytes:
90+
self.requested_size = size
91+
assert size == _PYPI_METADATA_MAX_BYTES + 1
92+
return b"x" * size
93+
94+
response = LargeResp()
95+
96+
def fetch(url: str, timeout: float):
97+
return response
98+
99+
result = PackageDocsService(fetcher=fetch).lookup("huge-package")
100+
101+
assert response.requested_size == _PYPI_METADATA_MAX_BYTES + 1
102+
assert result.sources == []
103+
assert result.note == "PyPI metadata exceeded size limit."
104+
105+
106+
def test_package_docs_reports_retrieval_and_json_errors():
107+
def unreachable(url: str, timeout: float):
108+
raise URLError("network down")
109+
110+
network_result = PackageDocsService(fetcher=unreachable).lookup("demo")
111+
assert network_result.sources == []
112+
assert network_result.note == "Unable to retrieve PyPI metadata: URLError."
113+
114+
def invalid_json(url: str, timeout: float):
115+
return _Resp(b"not json")
116+
117+
json_result = PackageDocsService(fetcher=invalid_json).lookup("demo")
118+
assert json_result.sources == []
119+
assert json_result.note == "Unable to retrieve PyPI metadata: JSONDecodeError."

0 commit comments

Comments
 (0)