python-docs-mcp-server 0.1.2__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.
- mcp_server_python_docs/__init__.py +17 -0
- mcp_server_python_docs/__main__.py +574 -0
- mcp_server_python_docs/app_context.py +33 -0
- mcp_server_python_docs/data/synonyms.yaml +194 -0
- mcp_server_python_docs/detection.py +82 -0
- mcp_server_python_docs/diagnostics.py +74 -0
- mcp_server_python_docs/errors.py +33 -0
- mcp_server_python_docs/ingestion/__init__.py +1 -0
- mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
- mcp_server_python_docs/ingestion/inventory.py +178 -0
- mcp_server_python_docs/ingestion/publish.py +504 -0
- mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
- mcp_server_python_docs/models.py +216 -0
- mcp_server_python_docs/py.typed +0 -0
- mcp_server_python_docs/retrieval/__init__.py +31 -0
- mcp_server_python_docs/retrieval/budget.py +63 -0
- mcp_server_python_docs/retrieval/query.py +183 -0
- mcp_server_python_docs/retrieval/ranker.py +369 -0
- mcp_server_python_docs/server.py +329 -0
- mcp_server_python_docs/services/__init__.py +7 -0
- mcp_server_python_docs/services/cache.py +111 -0
- mcp_server_python_docs/services/content.py +155 -0
- mcp_server_python_docs/services/observability.py +130 -0
- mcp_server_python_docs/services/package_docs.py +161 -0
- mcp_server_python_docs/services/persistent_cache.py +147 -0
- mcp_server_python_docs/services/search.py +111 -0
- mcp_server_python_docs/services/version.py +47 -0
- mcp_server_python_docs/services/version_resolution.py +79 -0
- mcp_server_python_docs/storage/__init__.py +1 -0
- mcp_server_python_docs/storage/db.py +146 -0
- mcp_server_python_docs/storage/schema.sql +136 -0
- python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
- python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
- python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
- python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
- python_docs_mcp_server-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""LRU cache wrappers for hot read paths (OPS-04, OPS-05).
|
|
2
|
+
|
|
3
|
+
Process-lifetime-scoped caches with no TTL and no invalidation.
|
|
4
|
+
Users restart the server on rebuild (documented in PUBL-05).
|
|
5
|
+
|
|
6
|
+
The cached functions are created as closures that capture the
|
|
7
|
+
sqlite3.Connection at construction time. This is necessary because
|
|
8
|
+
lru_cache requires hashable arguments, and sqlite3.Connection is
|
|
9
|
+
not hashable.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CachedSection(NamedTuple):
|
|
20
|
+
"""Cached section data -- lightweight container for LRU cache."""
|
|
21
|
+
|
|
22
|
+
heading: str
|
|
23
|
+
content_text: str
|
|
24
|
+
anchor: str
|
|
25
|
+
uri: str
|
|
26
|
+
document_id: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CachedSymbol(NamedTuple):
|
|
30
|
+
"""Cached symbol resolution result."""
|
|
31
|
+
|
|
32
|
+
qualified_name: str
|
|
33
|
+
symbol_type: str
|
|
34
|
+
uri: str
|
|
35
|
+
anchor: str
|
|
36
|
+
module: str
|
|
37
|
+
version: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_section_cache(db: sqlite3.Connection) -> Callable[[int], CachedSection | None]:
|
|
41
|
+
"""Create an LRU-cached section lookup function (OPS-04).
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
db: Read-only SQLite connection (captured by closure).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A function ``get_section(section_id: int) -> CachedSection | None``
|
|
48
|
+
with maxsize=512.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@lru_cache(maxsize=512)
|
|
52
|
+
def get_section_cached(section_id: int) -> CachedSection | None:
|
|
53
|
+
row = db.execute(
|
|
54
|
+
"""
|
|
55
|
+
SELECT heading, content_text, anchor, uri, document_id
|
|
56
|
+
FROM sections
|
|
57
|
+
WHERE id = ?
|
|
58
|
+
""",
|
|
59
|
+
(section_id,),
|
|
60
|
+
).fetchone()
|
|
61
|
+
if row is None:
|
|
62
|
+
return None
|
|
63
|
+
return CachedSection(
|
|
64
|
+
heading=row["heading"] or "",
|
|
65
|
+
content_text=row["content_text"] or "",
|
|
66
|
+
anchor=row["anchor"] or "",
|
|
67
|
+
uri=row["uri"] or "",
|
|
68
|
+
document_id=row["document_id"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return get_section_cached
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_symbol_cache(db: sqlite3.Connection) -> Callable[[str, str], CachedSymbol | None]:
|
|
75
|
+
"""Create an LRU-cached symbol resolution function (OPS-04).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
db: Read-only SQLite connection (captured by closure).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A function ``resolve_symbol(qualified_name: str, version: str) -> CachedSymbol | None``
|
|
82
|
+
with maxsize=128.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
@lru_cache(maxsize=128)
|
|
86
|
+
def resolve_symbol_cached(
|
|
87
|
+
qualified_name: str, version: str
|
|
88
|
+
) -> CachedSymbol | None:
|
|
89
|
+
row = db.execute(
|
|
90
|
+
"""
|
|
91
|
+
SELECT s.qualified_name, s.symbol_type, s.uri, s.anchor,
|
|
92
|
+
s.module, d.version
|
|
93
|
+
FROM symbols s
|
|
94
|
+
JOIN doc_sets d ON s.doc_set_id = d.id
|
|
95
|
+
WHERE s.qualified_name = ? AND d.version = ?
|
|
96
|
+
LIMIT 1
|
|
97
|
+
""",
|
|
98
|
+
(qualified_name, version),
|
|
99
|
+
).fetchone()
|
|
100
|
+
if row is None:
|
|
101
|
+
return None
|
|
102
|
+
return CachedSymbol(
|
|
103
|
+
qualified_name=row["qualified_name"],
|
|
104
|
+
symbol_type=row["symbol_type"] or "symbol",
|
|
105
|
+
uri=row["uri"],
|
|
106
|
+
anchor=row["anchor"] or "",
|
|
107
|
+
module=row["module"] or "",
|
|
108
|
+
version=row["version"],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return resolve_symbol_cached
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Content service — handles get_docs for page and section retrieval.
|
|
2
|
+
|
|
3
|
+
When anchor is provided, returns just that section.
|
|
4
|
+
When omitted, returns the full page with truncation/pagination.
|
|
5
|
+
No MCP types imported — dependency rule enforced.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from mcp_server_python_docs.errors import PageNotFoundError
|
|
12
|
+
from mcp_server_python_docs.models import GetDocsResult
|
|
13
|
+
from mcp_server_python_docs.retrieval.budget import apply_budget
|
|
14
|
+
from mcp_server_python_docs.services.cache import create_section_cache
|
|
15
|
+
from mcp_server_python_docs.services.observability import log_tool_call
|
|
16
|
+
from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
|
|
17
|
+
from mcp_server_python_docs.services.version_resolution import resolve_version_strict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContentService:
|
|
21
|
+
"""Content retrieval service for get_docs tool.
|
|
22
|
+
|
|
23
|
+
When anchor is provided, returns just that section.
|
|
24
|
+
When omitted, returns the full page with truncation/pagination.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
db: sqlite3.Connection,
|
|
30
|
+
persistent_cache: PersistentDocsCache | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._db = db
|
|
33
|
+
self._get_section = create_section_cache(db)
|
|
34
|
+
self._persistent_cache = persistent_cache
|
|
35
|
+
|
|
36
|
+
def _resolve_version(self, version: str | None) -> str:
|
|
37
|
+
"""Resolve version to a concrete version string using shared resolution logic.
|
|
38
|
+
|
|
39
|
+
Defaults to the is_default=1 version when None is passed.
|
|
40
|
+
Raises VersionNotFoundError for unknown versions (MVER-03).
|
|
41
|
+
"""
|
|
42
|
+
return resolve_version_strict(self._db, version)
|
|
43
|
+
|
|
44
|
+
@log_tool_call("get_docs")
|
|
45
|
+
def get_docs(
|
|
46
|
+
self,
|
|
47
|
+
slug: str,
|
|
48
|
+
version: str | None = None,
|
|
49
|
+
anchor: str | None = None,
|
|
50
|
+
max_chars: int = 8000,
|
|
51
|
+
start_index: int = 0,
|
|
52
|
+
) -> GetDocsResult:
|
|
53
|
+
"""Retrieve documentation content by slug, optionally narrowed to a section by anchor."""
|
|
54
|
+
resolved_version = self._resolve_version(version)
|
|
55
|
+
|
|
56
|
+
if self._persistent_cache is not None:
|
|
57
|
+
cached = self._persistent_cache.get(
|
|
58
|
+
version=resolved_version,
|
|
59
|
+
slug=slug,
|
|
60
|
+
anchor=anchor,
|
|
61
|
+
max_chars=max_chars,
|
|
62
|
+
start_index=start_index,
|
|
63
|
+
)
|
|
64
|
+
if cached is not None:
|
|
65
|
+
return cached
|
|
66
|
+
|
|
67
|
+
# Find the document
|
|
68
|
+
doc_row = self._db.execute(
|
|
69
|
+
"""
|
|
70
|
+
SELECT d.id, d.title, d.slug
|
|
71
|
+
FROM documents d
|
|
72
|
+
JOIN doc_sets ds ON d.doc_set_id = ds.id
|
|
73
|
+
WHERE d.slug = ? AND ds.version = ?
|
|
74
|
+
LIMIT 1
|
|
75
|
+
""",
|
|
76
|
+
(slug, resolved_version),
|
|
77
|
+
).fetchone()
|
|
78
|
+
|
|
79
|
+
if doc_row is None:
|
|
80
|
+
raise PageNotFoundError(
|
|
81
|
+
f"Page {slug!r} not found for version {resolved_version}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
doc_id = doc_row["id"]
|
|
85
|
+
doc_title = doc_row["title"]
|
|
86
|
+
|
|
87
|
+
if anchor is not None:
|
|
88
|
+
# Section-level retrieval — use cache for repeat reads (OPS-04)
|
|
89
|
+
id_row = self._db.execute(
|
|
90
|
+
"SELECT id FROM sections WHERE document_id = ? AND anchor = ? LIMIT 1",
|
|
91
|
+
(doc_id, anchor),
|
|
92
|
+
).fetchone()
|
|
93
|
+
|
|
94
|
+
if id_row is None:
|
|
95
|
+
raise PageNotFoundError(
|
|
96
|
+
f"Section {anchor!r} not found in {slug!r} v{resolved_version}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
cached = self._get_section(id_row["id"])
|
|
100
|
+
if cached is not None:
|
|
101
|
+
full_text = cached.content_text
|
|
102
|
+
title = cached.heading or doc_title
|
|
103
|
+
else:
|
|
104
|
+
raise PageNotFoundError(
|
|
105
|
+
f"Section {anchor!r} not found in {slug!r} v{resolved_version}"
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
# Page-level retrieval: concatenate all sections in ordinal order
|
|
109
|
+
section_rows = self._db.execute(
|
|
110
|
+
"""
|
|
111
|
+
SELECT heading, content_text
|
|
112
|
+
FROM sections
|
|
113
|
+
WHERE document_id = ?
|
|
114
|
+
ORDER BY ordinal
|
|
115
|
+
""",
|
|
116
|
+
(doc_id,),
|
|
117
|
+
).fetchall()
|
|
118
|
+
|
|
119
|
+
if not section_rows:
|
|
120
|
+
full_text = ""
|
|
121
|
+
else:
|
|
122
|
+
parts = []
|
|
123
|
+
for row in section_rows:
|
|
124
|
+
heading = row["heading"] or ""
|
|
125
|
+
content = row["content_text"] or ""
|
|
126
|
+
if heading:
|
|
127
|
+
parts.append(f"## {heading}\n\n{content}")
|
|
128
|
+
else:
|
|
129
|
+
parts.append(content)
|
|
130
|
+
full_text = "\n\n".join(parts)
|
|
131
|
+
|
|
132
|
+
title = doc_title
|
|
133
|
+
|
|
134
|
+
# Apply budget enforcement (RETR-08)
|
|
135
|
+
truncated_text, is_truncated, next_idx = apply_budget(
|
|
136
|
+
full_text, max_chars, start_index
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
result = GetDocsResult(
|
|
140
|
+
content=truncated_text,
|
|
141
|
+
slug=slug,
|
|
142
|
+
title=title,
|
|
143
|
+
version=resolved_version,
|
|
144
|
+
anchor=anchor,
|
|
145
|
+
char_count=len(full_text),
|
|
146
|
+
truncated=is_truncated,
|
|
147
|
+
next_start_index=next_idx,
|
|
148
|
+
)
|
|
149
|
+
if self._persistent_cache is not None:
|
|
150
|
+
self._persistent_cache.put(
|
|
151
|
+
result=result,
|
|
152
|
+
max_chars=max_chars,
|
|
153
|
+
start_index=start_index,
|
|
154
|
+
)
|
|
155
|
+
return result
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Structured logging for service method calls (OPS-01, OPS-02, OPS-03).
|
|
2
|
+
|
|
3
|
+
Implements per-service-method decorators that log structured key=value (logfmt)
|
|
4
|
+
lines to stderr. NOT FastMCP middleware — the MCP SDK middleware surface is
|
|
5
|
+
unstable, so we instrument at the service layer.
|
|
6
|
+
|
|
7
|
+
Log format (logfmt — D-10 from Phase 1):
|
|
8
|
+
tool=search_docs version=3.13 latency_ms=12 result_count=5
|
|
9
|
+
truncated=false resolution=fts synonym_expansion=yes
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import inspect
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format_logfmt(**fields: Any) -> str:
|
|
23
|
+
"""Format fields as logfmt key=value pairs.
|
|
24
|
+
|
|
25
|
+
Handles None (omitted), bool (lowercase), and strings with spaces (quoted).
|
|
26
|
+
"""
|
|
27
|
+
parts = []
|
|
28
|
+
for key, value in fields.items():
|
|
29
|
+
if value is None:
|
|
30
|
+
continue
|
|
31
|
+
if isinstance(value, bool):
|
|
32
|
+
parts.append(f"{key}={str(value).lower()}")
|
|
33
|
+
elif isinstance(value, float):
|
|
34
|
+
parts.append(f"{key}={value:.1f}")
|
|
35
|
+
elif isinstance(value, str) and " " in value:
|
|
36
|
+
escaped = str(value).replace('"', '\\"')
|
|
37
|
+
parts.append(f'{key}="{escaped}"')
|
|
38
|
+
else:
|
|
39
|
+
parts.append(f"{key}={value}")
|
|
40
|
+
return " ".join(parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def log_tool_call(tool_name: str) -> Callable:
|
|
44
|
+
"""Decorator that logs structured info for every service method call.
|
|
45
|
+
|
|
46
|
+
Extracts version, result_count, truncated, resolution path, and
|
|
47
|
+
synonym_expansion from the method arguments and return value.
|
|
48
|
+
|
|
49
|
+
Note: This decorator only works with synchronous service methods.
|
|
50
|
+
If a method becomes async, the wrapper must be updated to detect
|
|
51
|
+
coroutines and await the result, otherwise timing will be wrong.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
tool_name: Name of the MCP tool (search_docs, get_docs, list_versions).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def decorator(fn: Callable) -> Callable:
|
|
58
|
+
@functools.wraps(fn)
|
|
59
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
60
|
+
start = time.monotonic()
|
|
61
|
+
result: Any = None
|
|
62
|
+
error: Exception | None = None
|
|
63
|
+
error_tb: TracebackType | None = None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = fn(self, *args, **kwargs)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
error = exc
|
|
69
|
+
error_tb = exc.__traceback__
|
|
70
|
+
|
|
71
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
72
|
+
|
|
73
|
+
# Extract structured fields from args/kwargs and result
|
|
74
|
+
fields: dict[str, Any] = {
|
|
75
|
+
"tool": tool_name,
|
|
76
|
+
"latency_ms": round(elapsed_ms, 1),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Extract version by name via inspect (WR-05: avoids fragile positional indexing)
|
|
80
|
+
try:
|
|
81
|
+
sig = inspect.signature(fn)
|
|
82
|
+
bound = sig.bind(self, *args, **kwargs)
|
|
83
|
+
bound.apply_defaults()
|
|
84
|
+
version_val = bound.arguments.get("version")
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
version_val = kwargs.get("version")
|
|
87
|
+
fields["version"] = version_val or "default"
|
|
88
|
+
|
|
89
|
+
if error is None:
|
|
90
|
+
# Extract result-specific fields
|
|
91
|
+
if hasattr(result, "hits"):
|
|
92
|
+
# SearchDocsResult
|
|
93
|
+
fields["result_count"] = len(result.hits)
|
|
94
|
+
# Resolution path from service state
|
|
95
|
+
if hasattr(self, "_last_resolution"):
|
|
96
|
+
fields["resolution"] = self._last_resolution
|
|
97
|
+
else:
|
|
98
|
+
fields["resolution"] = "fts"
|
|
99
|
+
fields["truncated"] = False
|
|
100
|
+
elif hasattr(result, "truncated"):
|
|
101
|
+
# GetDocsResult
|
|
102
|
+
fields["result_count"] = 1 if result.content else 0
|
|
103
|
+
fields["truncated"] = result.truncated
|
|
104
|
+
fields["resolution"] = "exact"
|
|
105
|
+
elif hasattr(result, "versions"):
|
|
106
|
+
# ListVersionsResult
|
|
107
|
+
fields["result_count"] = len(result.versions)
|
|
108
|
+
fields["truncated"] = False
|
|
109
|
+
fields["resolution"] = "exact"
|
|
110
|
+
|
|
111
|
+
# Synonym expansion detection from service state
|
|
112
|
+
if hasattr(self, "_last_synonym_expanded"):
|
|
113
|
+
fields["synonym_expansion"] = (
|
|
114
|
+
"yes" if self._last_synonym_expanded else "no"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
fields["error"] = type(error).__name__
|
|
118
|
+
|
|
119
|
+
# Write logfmt line to stderr (HYGN-01 safe — stderr only)
|
|
120
|
+
log_line = _format_logfmt(**fields)
|
|
121
|
+
print(log_line, file=sys.stderr)
|
|
122
|
+
|
|
123
|
+
if error is not None:
|
|
124
|
+
raise error.with_traceback(error_tb)
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
return wrapper
|
|
129
|
+
|
|
130
|
+
return decorator
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Controlled package docs lookup using official PyPI JSON metadata.
|
|
2
|
+
|
|
3
|
+
Source: https://docs.pypi.org/api/json/ documents GET /pypi/<project>/json.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Protocol, cast
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import quote, urlparse
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from mcp_server_python_docs.models import (
|
|
17
|
+
PackageDocsResult,
|
|
18
|
+
PackageDocsSource,
|
|
19
|
+
PackageKind,
|
|
20
|
+
)
|
|
21
|
+
from mcp_server_python_docs.services.observability import log_tool_call
|
|
22
|
+
|
|
23
|
+
_ALLOWED = {
|
|
24
|
+
"documentation",
|
|
25
|
+
"docs",
|
|
26
|
+
"homepage",
|
|
27
|
+
"home page",
|
|
28
|
+
"source",
|
|
29
|
+
"source code",
|
|
30
|
+
"repository",
|
|
31
|
+
"repo",
|
|
32
|
+
}
|
|
33
|
+
_PYPI_METADATA_MAX_BYTES = 5 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _HTTPResponse(Protocol):
|
|
37
|
+
def read(self, size: int = -1) -> bytes: ...
|
|
38
|
+
def __enter__(self) -> "_HTTPResponse": ...
|
|
39
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool | None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
Fetcher = Callable[[str, float], _HTTPResponse]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_fetcher(url: str, timeout: float) -> _HTTPResponse:
|
|
46
|
+
req = Request(
|
|
47
|
+
url,
|
|
48
|
+
headers={"Accept": "application/json", "User-Agent": "python-docs-mcp-server"},
|
|
49
|
+
)
|
|
50
|
+
return urlopen(req, timeout=timeout)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize(name: str) -> str:
|
|
54
|
+
return quote(re.sub(r"[-_.]+", "-", name.strip().lower()), safe="-")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _http_url(url: object) -> str | None:
|
|
58
|
+
if not isinstance(url, str):
|
|
59
|
+
return None
|
|
60
|
+
parsed = urlparse(url.strip())
|
|
61
|
+
return url.strip() if parsed.scheme in {"http", "https"} and parsed.netloc else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _source(label: str, url: object, kind: PackageKind) -> PackageDocsSource | None:
|
|
65
|
+
valid = _http_url(url)
|
|
66
|
+
if valid is None:
|
|
67
|
+
return None
|
|
68
|
+
return PackageDocsSource(label=label, url=valid, kind=kind, declared_by="PyPI project metadata")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_limited(response: _HTTPResponse) -> bytes | None:
|
|
72
|
+
data = response.read(_PYPI_METADATA_MAX_BYTES + 1)
|
|
73
|
+
if len(data) > _PYPI_METADATA_MAX_BYTES:
|
|
74
|
+
return None
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _empty_result(package: str, metadata_source: str, note: str) -> PackageDocsResult:
|
|
79
|
+
return PackageDocsResult(
|
|
80
|
+
package=package,
|
|
81
|
+
version="",
|
|
82
|
+
metadata_source=metadata_source,
|
|
83
|
+
sources=[],
|
|
84
|
+
note=note,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PackageDocsService:
|
|
89
|
+
"""Return package-declared docs/homepage/source URLs from PyPI metadata only."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, fetcher: Fetcher = _default_fetcher, timeout: float = 10.0) -> None:
|
|
92
|
+
self._fetcher = fetcher
|
|
93
|
+
self._timeout = timeout
|
|
94
|
+
|
|
95
|
+
@log_tool_call("lookup_package_docs")
|
|
96
|
+
def lookup(self, package: str) -> PackageDocsResult:
|
|
97
|
+
project = _normalize(package)
|
|
98
|
+
metadata_source = f"https://pypi.org/pypi/{project}/json"
|
|
99
|
+
try:
|
|
100
|
+
with self._fetcher(metadata_source, self._timeout) as response:
|
|
101
|
+
data = _read_limited(response)
|
|
102
|
+
if data is None:
|
|
103
|
+
return _empty_result(
|
|
104
|
+
package, metadata_source, "PyPI metadata exceeded size limit."
|
|
105
|
+
)
|
|
106
|
+
payload = json.loads(data.decode("utf-8"))
|
|
107
|
+
except HTTPError as e:
|
|
108
|
+
note = (
|
|
109
|
+
"Package not found on PyPI." if e.code == 404 else f"PyPI returned HTTP {e.code}."
|
|
110
|
+
)
|
|
111
|
+
return _empty_result(package, metadata_source, note)
|
|
112
|
+
except (URLError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
113
|
+
return _empty_result(
|
|
114
|
+
package,
|
|
115
|
+
metadata_source,
|
|
116
|
+
f"Unable to retrieve PyPI metadata: {type(e).__name__}.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
info = payload.get("info") if isinstance(payload, dict) else {}
|
|
120
|
+
info = info if isinstance(info, dict) else {}
|
|
121
|
+
sources = [
|
|
122
|
+
s
|
|
123
|
+
for s in (
|
|
124
|
+
_source(
|
|
125
|
+
"PyPI project",
|
|
126
|
+
info.get("project_url") or f"https://pypi.org/project/{project}/",
|
|
127
|
+
"pypi",
|
|
128
|
+
),
|
|
129
|
+
_source("Documentation", info.get("docs_url"), "docs"),
|
|
130
|
+
_source("Homepage", info.get("home_page"), "homepage"),
|
|
131
|
+
)
|
|
132
|
+
if s is not None
|
|
133
|
+
]
|
|
134
|
+
skipped: list[str] = []
|
|
135
|
+
project_urls = info.get("project_urls")
|
|
136
|
+
if isinstance(project_urls, dict):
|
|
137
|
+
for label, url in project_urls.items():
|
|
138
|
+
lowered = str(label).strip().lower()
|
|
139
|
+
if lowered in _ALLOWED:
|
|
140
|
+
# Runtime-safe: members of `_ALLOWED` (with spaces → underscores)
|
|
141
|
+
# are exactly the non-pypi entries in the PackageKind Literal.
|
|
142
|
+
derived_kind = cast(PackageKind, lowered.replace(" ", "_"))
|
|
143
|
+
found = _source(str(label), url, derived_kind)
|
|
144
|
+
if found is not None and found not in sources:
|
|
145
|
+
sources.append(found)
|
|
146
|
+
else:
|
|
147
|
+
skipped.append(str(label))
|
|
148
|
+
note = None
|
|
149
|
+
if skipped:
|
|
150
|
+
note = (
|
|
151
|
+
"Ignored project URL labels outside the controlled allowlist: "
|
|
152
|
+
f"{', '.join(sorted(skipped))}."
|
|
153
|
+
)
|
|
154
|
+
return PackageDocsResult(
|
|
155
|
+
package=str(info.get("name") or package),
|
|
156
|
+
version=str(info.get("version") or ""),
|
|
157
|
+
summary=str(info.get("summary") or ""),
|
|
158
|
+
metadata_source=metadata_source,
|
|
159
|
+
sources=sources,
|
|
160
|
+
note=note,
|
|
161
|
+
)
|