|
2 | 2 | from __future__ import annotations |
3 | 3 |
|
4 | 4 | import json |
5 | | -from urllib.error import HTTPError |
| 5 | +from urllib.error import HTTPError, URLError |
6 | 6 |
|
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 | +) |
8 | 11 |
|
9 | 12 |
|
10 | 13 | class _Resp: |
11 | | - def __init__(self, payload: dict): |
| 14 | + def __init__(self, payload: dict | bytes): |
12 | 15 | self._payload = payload |
13 | 16 | def __enter__(self): |
14 | 17 | return self |
15 | 18 | def __exit__(self, exc_type, exc, tb): |
16 | 19 | 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] |
19 | 26 |
|
20 | 27 |
|
21 | 28 | def test_package_docs_uses_official_pypi_metadata_and_declared_urls(): |
@@ -67,3 +74,46 @@ def missing(url: str, timeout: float): |
67 | 74 | tool = create_server()._tool_manager._tools["lookup_package_docs"] |
68 | 75 | assert tool.annotations.readOnlyHint is True |
69 | 76 | 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