python-docs-mcp-server 0.1.6__py3-none-any.whl → 0.2.0__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/app_context.py +2 -0
- mcp_server_python_docs/models.py +87 -0
- mcp_server_python_docs/server.py +37 -0
- mcp_server_python_docs/services/compare.py +303 -0
- {python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/METADATA +4 -3
- {python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/RECORD +9 -8
- {python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/WHEEL +0 -0
- {python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/entry_points.txt +0 -0
- {python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -10,6 +10,7 @@ import sqlite3
|
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
+
from mcp_server_python_docs.services.compare import CompareService
|
|
13
14
|
from mcp_server_python_docs.services.content import ContentService
|
|
14
15
|
from mcp_server_python_docs.services.package_docs import PackageDocsService
|
|
15
16
|
from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
|
|
@@ -25,6 +26,7 @@ class AppContext:
|
|
|
25
26
|
index_path: Path
|
|
26
27
|
search_service: SearchService
|
|
27
28
|
content_service: ContentService
|
|
29
|
+
compare_service: CompareService
|
|
28
30
|
version_service: VersionService
|
|
29
31
|
package_docs_service: PackageDocsService = field(default_factory=PackageDocsService)
|
|
30
32
|
persistent_docs_cache: PersistentDocsCache | None = None
|
mcp_server_python_docs/models.py
CHANGED
|
@@ -214,3 +214,90 @@ class PackageDocsResult(BaseModel):
|
|
|
214
214
|
default=None,
|
|
215
215
|
description="Controlled-scope note, for example skipped labels or not-found details",
|
|
216
216
|
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- compare_versions models ---
|
|
220
|
+
|
|
221
|
+
ChangeKind = Literal["added", "removed", "changed", "unchanged"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class CompareVersionsResult(BaseModel):
|
|
225
|
+
"""Output from compare_versions tool (CMPR-01, CMPR-03).
|
|
226
|
+
|
|
227
|
+
Typed contract returned by CompareService.compare and the source FastMCP
|
|
228
|
+
auto-derives the compare_versions outputSchema from. The four ``change``
|
|
229
|
+
cases (added / removed / changed / unchanged) all share this shape;
|
|
230
|
+
optional delta fields are ``None`` when they do not apply to the case,
|
|
231
|
+
keeping the JSON shape predictable and token-frugal (no full re-printing
|
|
232
|
+
of unchanged content, per CMPR-03).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
symbol: str = Field(description="Qualified symbol name being compared")
|
|
236
|
+
v1: str = Field(description="Source Python version")
|
|
237
|
+
v2: str = Field(description="Target Python version")
|
|
238
|
+
change: ChangeKind = Field(
|
|
239
|
+
description=(
|
|
240
|
+
"Diff discriminator: 'added' (symbol new in v2), 'removed' "
|
|
241
|
+
"(symbol absent in v2), 'changed' (symbol present in both with a "
|
|
242
|
+
"detected delta), or 'unchanged' (symbol present in both, no delta)"
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
new_in: str | None = Field(
|
|
247
|
+
default=None,
|
|
248
|
+
description=(
|
|
249
|
+
"Version string extracted from the v2 section text; populated when "
|
|
250
|
+
"change == 'added', and may also be set when change == 'changed' if "
|
|
251
|
+
"the v2 section carries a versionadded marker for a sub-feature"
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
removed_in: str | None = Field(
|
|
255
|
+
default=None,
|
|
256
|
+
description=(
|
|
257
|
+
"Version where the symbol is first absent; populated when "
|
|
258
|
+
"change == 'removed' (equals v2)"
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
changed_in: str | None = Field(
|
|
262
|
+
default=None,
|
|
263
|
+
description=(
|
|
264
|
+
"Version extracted from the v2 section; populated when "
|
|
265
|
+
"change == 'changed'"
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
deprecated_in: str | None = Field(
|
|
269
|
+
default=None,
|
|
270
|
+
description="Version extracted from a deprecation marker in the v2 section",
|
|
271
|
+
)
|
|
272
|
+
signature_delta: str | None = Field(
|
|
273
|
+
default=None,
|
|
274
|
+
description=(
|
|
275
|
+
"Best-effort heuristic: first non-empty diff line between v1 and v2 "
|
|
276
|
+
"section text. MAY be a docstring change or prose change rather than "
|
|
277
|
+
"a true signature change — treat as advisory, not authoritative."
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
see_also_added: list[str] = Field(
|
|
281
|
+
default_factory=list,
|
|
282
|
+
description="See-also link labels present in v2 but not v1",
|
|
283
|
+
)
|
|
284
|
+
see_also_removed: list[str] = Field(
|
|
285
|
+
default_factory=list,
|
|
286
|
+
description="See-also link labels present in v1 but not v2",
|
|
287
|
+
)
|
|
288
|
+
section_diff: str | None = Field(
|
|
289
|
+
default=None,
|
|
290
|
+
description=(
|
|
291
|
+
"Short unified-diff snippet (difflib.unified_diff), truncated to "
|
|
292
|
+
"honor the token budget; populated only when change == 'changed' and "
|
|
293
|
+
"the diff is non-trivially short"
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
note: str | None = Field(
|
|
297
|
+
default=None,
|
|
298
|
+
description=(
|
|
299
|
+
"Optional advisory note about result completeness, e.g. when docs "
|
|
300
|
+
"pages could not be fetched for one or both versions and the diff is "
|
|
301
|
+
"therefore based on symbol presence alone."
|
|
302
|
+
),
|
|
303
|
+
)
|
mcp_server_python_docs/server.py
CHANGED
|
@@ -28,12 +28,14 @@ from mcp_server_python_docs.app_context import AppContext
|
|
|
28
28
|
from mcp_server_python_docs.detection import detect_python_version, match_to_indexed
|
|
29
29
|
from mcp_server_python_docs.errors import DocsServerError
|
|
30
30
|
from mcp_server_python_docs.models import (
|
|
31
|
+
CompareVersionsResult,
|
|
31
32
|
DetectPythonVersionResult,
|
|
32
33
|
GetDocsResult,
|
|
33
34
|
ListVersionsResult,
|
|
34
35
|
PackageDocsResult,
|
|
35
36
|
SearchDocsResult,
|
|
36
37
|
)
|
|
38
|
+
from mcp_server_python_docs.services.compare import CompareService
|
|
37
39
|
from mcp_server_python_docs.services.content import ContentService
|
|
38
40
|
from mcp_server_python_docs.services.package_docs import PackageDocsService
|
|
39
41
|
from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
|
|
@@ -158,6 +160,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
158
160
|
)
|
|
159
161
|
search_svc = SearchService(db, synonyms)
|
|
160
162
|
content_svc = ContentService(db, persistent_cache=persistent_docs_cache)
|
|
163
|
+
compare_svc = CompareService(db, content_svc)
|
|
161
164
|
version_svc = VersionService(db)
|
|
162
165
|
package_docs_svc = PackageDocsService()
|
|
163
166
|
|
|
@@ -183,6 +186,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
183
186
|
synonyms=synonyms,
|
|
184
187
|
search_service=search_svc,
|
|
185
188
|
content_service=content_svc,
|
|
189
|
+
compare_service=compare_svc,
|
|
186
190
|
version_service=version_svc,
|
|
187
191
|
package_docs_service=package_docs_svc,
|
|
188
192
|
persistent_docs_cache=persistent_docs_cache,
|
|
@@ -268,6 +272,18 @@ PackageParam = Annotated[
|
|
|
268
272
|
str,
|
|
269
273
|
Field(min_length=1, max_length=214, description="PyPI package/project name"),
|
|
270
274
|
]
|
|
275
|
+
SymbolParam = Annotated[
|
|
276
|
+
str,
|
|
277
|
+
Field(
|
|
278
|
+
min_length=1,
|
|
279
|
+
max_length=200,
|
|
280
|
+
description="Qualified Python symbol name, e.g. 'asyncio.TaskGroup'",
|
|
281
|
+
),
|
|
282
|
+
]
|
|
283
|
+
CompareVersionParam = Annotated[
|
|
284
|
+
str,
|
|
285
|
+
Field(description="Python version string, e.g. '3.11'"),
|
|
286
|
+
]
|
|
271
287
|
|
|
272
288
|
|
|
273
289
|
def create_server() -> FastMCP:
|
|
@@ -374,6 +390,27 @@ def create_server() -> FastMCP:
|
|
|
374
390
|
is_default=detected_ver is not None,
|
|
375
391
|
)
|
|
376
392
|
|
|
393
|
+
@mcp.tool(annotations=_TOOL_ANNOTATIONS)
|
|
394
|
+
def compare_versions(
|
|
395
|
+
symbol: SymbolParam,
|
|
396
|
+
v1: CompareVersionParam,
|
|
397
|
+
v2: CompareVersionParam,
|
|
398
|
+
ctx: Context = None, # type: ignore[assignment]
|
|
399
|
+
) -> CompareVersionsResult:
|
|
400
|
+
"""Diff a Python stdlib symbol between two indexed versions. Returns
|
|
401
|
+
change=added|removed|changed|unchanged with optional new_in, changed_in,
|
|
402
|
+
deprecated_in, signature_delta (advisory), see_also_added/removed,
|
|
403
|
+
section_diff, and note fields. Both versions must be indexed; otherwise
|
|
404
|
+
an actionable error names the available versions."""
|
|
405
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
406
|
+
try:
|
|
407
|
+
return app_ctx.compare_service.compare(symbol, v1, v2)
|
|
408
|
+
except DocsServerError as e:
|
|
409
|
+
raise ToolError(str(e))
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.exception("Unexpected error in compare_versions")
|
|
412
|
+
raise ToolError(f"Internal error: {type(e).__name__}")
|
|
413
|
+
|
|
377
414
|
# SRVR-07: _meta hint for get_docs tool.
|
|
378
415
|
# FastMCP 1.27 does not expose a public API for setting _meta on tool
|
|
379
416
|
# definitions. Deferred until the mcp SDK adds _meta support to the
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Version-diff service for the compare_versions MCP tool (CMPR-01/02/03).
|
|
2
|
+
|
|
3
|
+
Phase 09. Implements ``CompareService.compare(symbol, v1, v2)`` returning a
|
|
4
|
+
``CompareVersionsResult``: the four diff branches (added / removed / changed /
|
|
5
|
+
unchanged), the version/symbol error paths, and the see-also / deprecation /
|
|
6
|
+
signature-delta heuristics.
|
|
7
|
+
|
|
8
|
+
No structured signature/deprecation/see-also metadata is stored in the index,
|
|
9
|
+
so this service derives those signals from section *text* using the regex
|
|
10
|
+
literals locked by the Plan 09-01 data-shape spike, plus symbol presence/absence
|
|
11
|
+
across the two doc_sets (via ``create_symbol_cache``).
|
|
12
|
+
|
|
13
|
+
Algorithm ordering is FIXED per cross-AI review H2:
|
|
14
|
+
1. validate BOTH versions (validate_version raises the unknown-version error
|
|
15
|
+
from version_resolution; compare.py never names that exception type — H4)
|
|
16
|
+
2. resolve the symbol in BOTH versions
|
|
17
|
+
3. if missing in BOTH -> SymbolNotFoundError
|
|
18
|
+
4. ONLY THEN handle the v1 == v2 identical-versions case as 'unchanged'
|
|
19
|
+
|
|
20
|
+
This ordering ensures ``compare('does.not.exist', '3.11', '3.11')`` raises
|
|
21
|
+
SymbolNotFoundError rather than falsely returning 'unchanged'.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import difflib
|
|
26
|
+
import re
|
|
27
|
+
import sqlite3
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from mcp_server_python_docs.errors import PageNotFoundError, SymbolNotFoundError
|
|
31
|
+
from mcp_server_python_docs.models import CompareVersionsResult
|
|
32
|
+
from mcp_server_python_docs.services.cache import create_symbol_cache
|
|
33
|
+
from mcp_server_python_docs.services.observability import log_tool_call
|
|
34
|
+
from mcp_server_python_docs.services.version_resolution import validate_version
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from mcp_server_python_docs.services.content import ContentService
|
|
38
|
+
|
|
39
|
+
# --- Locked extractor regexes (verbatim from 09-01-data-shape-spike-SUMMARY) ---
|
|
40
|
+
# All four HOLD against the spike fixture (A1/A2/sibling probes). Scalar
|
|
41
|
+
# extractors return None on no-match; _extract_see_also returns [].
|
|
42
|
+
_NEW_IN_RE = re.compile(r"New in version\s+(\d+\.\d+)")
|
|
43
|
+
_CHANGED_IN_RE = re.compile(r"Changed in version\s+(\d+\.\d+)")
|
|
44
|
+
_DEPRECATED_IN_RE = re.compile(r"Deprecated since version\s+(\d+\.\d+)")
|
|
45
|
+
# Markdown link label extractor; MUST be applied only within a "See also" window
|
|
46
|
+
# (locate case-insensitive "see also", read forward to next ATX heading / window
|
|
47
|
+
# end), not against the whole section, or it captures unrelated body links.
|
|
48
|
+
_SEE_ALSO_LINK_RE = re.compile(r"\[([^\]]+)\]\(")
|
|
49
|
+
|
|
50
|
+
# M2 note text emitted when a docs page is unfetchable in the both-present branch.
|
|
51
|
+
_PAGE_UNAVAILABLE_NOTE = "docs page not available for one or both versions"
|
|
52
|
+
|
|
53
|
+
# section_diff truncation ceiling (token-frugality, CMPR-03).
|
|
54
|
+
_SECTION_DIFF_MAX_CHARS = 600
|
|
55
|
+
# signature_delta heuristic: first-line snippet length cap.
|
|
56
|
+
_SIGNATURE_LINE_MAX = 80
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_version(pattern: re.Pattern[str], text: str) -> str | None:
|
|
60
|
+
"""Return the captured version from the first match of ``pattern``, or None.
|
|
61
|
+
|
|
62
|
+
Shared by the New-in / Changed-in / Deprecated-since extractors, which differ
|
|
63
|
+
only by their (spike-locked) pattern literal (``_NEW_IN_RE`` etc.).
|
|
64
|
+
"""
|
|
65
|
+
match = pattern.search(text)
|
|
66
|
+
return match.group(1) if match else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_see_also(text: str) -> list[str]:
|
|
70
|
+
"""Extract see-also link labels from the 'See also' window only.
|
|
71
|
+
|
|
72
|
+
Locates the first case-insensitive 'see also' line, then reads forward to the
|
|
73
|
+
next ATX heading ('#') or up to 20 lines, and matches markdown link labels in
|
|
74
|
+
that window. Returns [] when there is no 'See also' section (fallback policy).
|
|
75
|
+
"""
|
|
76
|
+
lines = text.splitlines()
|
|
77
|
+
start = None
|
|
78
|
+
for idx, line in enumerate(lines):
|
|
79
|
+
if "see also" in line.lower():
|
|
80
|
+
start = idx
|
|
81
|
+
break
|
|
82
|
+
if start is None:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
# WR-01: a Sphinx "See also" admonition is one contiguous block. markdownify
|
|
86
|
+
# rarely emits an ATX heading after it, so an ATX-only break over-captures
|
|
87
|
+
# unrelated body links. Skip leading blanks, then read the block until the
|
|
88
|
+
# first blank line AFTER content has started (or an ATX heading, or the cap).
|
|
89
|
+
window: list[str] = []
|
|
90
|
+
started = False
|
|
91
|
+
for line in lines[start + 1 : start + 1 + 20]:
|
|
92
|
+
if line.lstrip().startswith("#"):
|
|
93
|
+
break
|
|
94
|
+
if line.strip():
|
|
95
|
+
started = True
|
|
96
|
+
window.append(line)
|
|
97
|
+
elif started:
|
|
98
|
+
break # blank line ends the admonition block
|
|
99
|
+
|
|
100
|
+
return _SEE_ALSO_LINK_RE.findall("\n".join(window))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _first_nonempty_line(text: str) -> str:
|
|
104
|
+
"""Return the first non-blank line of a section text, or '' if none."""
|
|
105
|
+
for line in text.splitlines():
|
|
106
|
+
if line.strip():
|
|
107
|
+
return line.strip()
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CompareService:
|
|
112
|
+
"""Compute a structured version diff for a stdlib symbol (CMPR-01).
|
|
113
|
+
|
|
114
|
+
Composes existing primitives: ``validate_version`` (version existence +
|
|
115
|
+
actionable error), ``create_symbol_cache`` (version-scoped symbol
|
|
116
|
+
resolution), and ``ContentService.get_docs`` (section text retrieval).
|
|
117
|
+
The method is intentionally SYNC — SQLite reads on the open connection are
|
|
118
|
+
non-blocking; no asyncio.to_thread is used.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
db: sqlite3.Connection,
|
|
124
|
+
content_service: ContentService,
|
|
125
|
+
) -> None:
|
|
126
|
+
self._db = db
|
|
127
|
+
self._content = content_service
|
|
128
|
+
# Private per-service LRU symbol resolver (own cache, RESEARCH §Q7).
|
|
129
|
+
self._resolve = create_symbol_cache(db)
|
|
130
|
+
|
|
131
|
+
def _section_text(self, uri: str, anchor: str, version: str) -> str:
|
|
132
|
+
"""Fetch the section text for a symbol via ContentService.
|
|
133
|
+
|
|
134
|
+
Derives the slug from the symbol URI (everything before '#') and uses the
|
|
135
|
+
symbol anchor — never joins symbols.section_id directly (RESEARCH Pitfall 2).
|
|
136
|
+
|
|
137
|
+
CR-01: symbol URIs carry ".html" (``library/json.html#...``) but real
|
|
138
|
+
Sphinx ingestion stores ``documents.slug`` EXTENSIONLESS
|
|
139
|
+
(``library/json``), and ``get_docs`` matches ``documents.slug`` exactly.
|
|
140
|
+
Try the extensionless form first, then the raw ".html" page, mirroring
|
|
141
|
+
``retrieval.ranker._document_candidates`` so resolution holds on a real
|
|
142
|
+
index. Raises PageNotFoundError only when every candidate misses.
|
|
143
|
+
"""
|
|
144
|
+
page = uri.split("#", 1)[0]
|
|
145
|
+
candidates = (page[:-5], page) if page.endswith(".html") else (page,)
|
|
146
|
+
last_exc: PageNotFoundError | None = None
|
|
147
|
+
for slug in candidates:
|
|
148
|
+
try:
|
|
149
|
+
result = self._content.get_docs(
|
|
150
|
+
slug=slug, version=version, anchor=anchor
|
|
151
|
+
)
|
|
152
|
+
except PageNotFoundError as exc:
|
|
153
|
+
last_exc = exc
|
|
154
|
+
continue
|
|
155
|
+
return result.content
|
|
156
|
+
# candidates is non-empty, so last_exc is always set when we reach here.
|
|
157
|
+
assert last_exc is not None
|
|
158
|
+
raise last_exc
|
|
159
|
+
|
|
160
|
+
@log_tool_call("compare_versions")
|
|
161
|
+
def compare(self, symbol: str, v1: str, v2: str) -> CompareVersionsResult:
|
|
162
|
+
"""Compare a symbol's documentation between two Python versions.
|
|
163
|
+
|
|
164
|
+
See module docstring for the FIXED branch ordering (H2). Returns one of
|
|
165
|
+
the four ``change`` cases; validate_version raises the unknown-version
|
|
166
|
+
error from its own module, or raises SymbolNotFoundError when the symbol
|
|
167
|
+
is absent in both versions.
|
|
168
|
+
"""
|
|
169
|
+
# 1. Validate BOTH versions first. validate_version raises the
|
|
170
|
+
# unknown-version error (with the indexed-version list) from its own
|
|
171
|
+
# module — compare.py never references that exception type (H4).
|
|
172
|
+
validate_version(self._db, v1)
|
|
173
|
+
validate_version(self._db, v2)
|
|
174
|
+
|
|
175
|
+
# 2. Resolve the symbol in BOTH versions.
|
|
176
|
+
sym_v1 = self._resolve(symbol, v1)
|
|
177
|
+
sym_v2 = self._resolve(symbol, v2)
|
|
178
|
+
|
|
179
|
+
# 3. Missing in BOTH versions -> SymbolNotFoundError. This fires BEFORE
|
|
180
|
+
# the v1 == v2 check so identical-versions with a non-existent symbol
|
|
181
|
+
# raises rather than returning 'unchanged' (H2 fix).
|
|
182
|
+
if sym_v1 is None and sym_v2 is None:
|
|
183
|
+
raise SymbolNotFoundError(
|
|
184
|
+
f"symbol {symbol!r} not found in v{v1} or v{v2}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# 4. Identical versions, symbol known to exist -> 'unchanged'.
|
|
188
|
+
if v1 == v2:
|
|
189
|
+
return CompareVersionsResult(
|
|
190
|
+
symbol=symbol, v1=v1, v2=v2, change="unchanged"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# 5. Branch on symbol presence across the two versions.
|
|
194
|
+
if sym_v1 is None and sym_v2 is not None:
|
|
195
|
+
# Added in v2. Best-effort new_in extraction; swallow
|
|
196
|
+
# PageNotFoundError (structural 'added' is sound without the section).
|
|
197
|
+
new_in: str | None = None
|
|
198
|
+
try:
|
|
199
|
+
text_v2 = self._section_text(sym_v2.uri, sym_v2.anchor, v2)
|
|
200
|
+
new_in = _extract_version(_NEW_IN_RE, text_v2)
|
|
201
|
+
except PageNotFoundError:
|
|
202
|
+
new_in = None
|
|
203
|
+
return CompareVersionsResult(
|
|
204
|
+
symbol=symbol, v1=v1, v2=v2, change="added", new_in=new_in
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if sym_v1 is not None and sym_v2 is None:
|
|
208
|
+
# Removed in v2 — no section fetch needed.
|
|
209
|
+
return CompareVersionsResult(
|
|
210
|
+
symbol=symbol, v1=v1, v2=v2, change="removed", removed_in=v2
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Both present. Narrow for the type checker.
|
|
214
|
+
assert sym_v1 is not None and sym_v2 is not None
|
|
215
|
+
|
|
216
|
+
# M2: if either section is unfetchable, report 'changed' + note rather
|
|
217
|
+
# than the previous false-negative 'unchanged'.
|
|
218
|
+
try:
|
|
219
|
+
text_v1 = self._section_text(sym_v1.uri, sym_v1.anchor, v1)
|
|
220
|
+
text_v2 = self._section_text(sym_v2.uri, sym_v2.anchor, v2)
|
|
221
|
+
except PageNotFoundError:
|
|
222
|
+
return CompareVersionsResult(
|
|
223
|
+
symbol=symbol,
|
|
224
|
+
v1=v1,
|
|
225
|
+
v2=v2,
|
|
226
|
+
change="changed",
|
|
227
|
+
section_diff=None,
|
|
228
|
+
note=_PAGE_UNAVAILABLE_NOTE,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
changed_in = _extract_version(_CHANGED_IN_RE, text_v2)
|
|
232
|
+
deprecated_in = _extract_version(_DEPRECATED_IN_RE, text_v2)
|
|
233
|
+
|
|
234
|
+
# signature_delta (M1, advisory): first non-empty line comparison.
|
|
235
|
+
first_v1 = _first_nonempty_line(text_v1)
|
|
236
|
+
first_v2 = _first_nonempty_line(text_v2)
|
|
237
|
+
signature_delta: str | None = None
|
|
238
|
+
if first_v1 != first_v2:
|
|
239
|
+
signature_delta = (
|
|
240
|
+
f"line 1 differs (v{v1}: {first_v1[:_SIGNATURE_LINE_MAX]} "
|
|
241
|
+
f"-> v{v2}: {first_v2[:_SIGNATURE_LINE_MAX]})"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# See-also delta (set difference of link labels in each "See also" window).
|
|
245
|
+
see_v1 = set(_extract_see_also(text_v1))
|
|
246
|
+
see_v2 = set(_extract_see_also(text_v2))
|
|
247
|
+
see_also_added = sorted(see_v2 - see_v1)
|
|
248
|
+
see_also_removed = sorted(see_v1 - see_v2)
|
|
249
|
+
|
|
250
|
+
# Unified diff of the two section texts, truncated for token frugality.
|
|
251
|
+
diff_lines = list(
|
|
252
|
+
difflib.unified_diff(
|
|
253
|
+
text_v1.splitlines(),
|
|
254
|
+
text_v2.splitlines(),
|
|
255
|
+
lineterm="",
|
|
256
|
+
n=2,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
# WR-02: truncate on LINE boundaries (not mid-line) with an explicit
|
|
260
|
+
# marker, so the emitted diff stays a parseable unified diff.
|
|
261
|
+
section_diff: str | None = None
|
|
262
|
+
if diff_lines:
|
|
263
|
+
kept: list[str] = []
|
|
264
|
+
used = 0
|
|
265
|
+
truncated = False
|
|
266
|
+
for line in diff_lines:
|
|
267
|
+
projected = used + len(line) + (1 if kept else 0)
|
|
268
|
+
if projected > _SECTION_DIFF_MAX_CHARS:
|
|
269
|
+
truncated = True
|
|
270
|
+
break
|
|
271
|
+
kept.append(line)
|
|
272
|
+
used = projected
|
|
273
|
+
section_diff = "\n".join(kept)
|
|
274
|
+
if truncated:
|
|
275
|
+
marker = "... (diff truncated)"
|
|
276
|
+
section_diff = f"{section_diff}\n{marker}" if section_diff else marker
|
|
277
|
+
|
|
278
|
+
# If nothing changed at all, the symbol is genuinely 'unchanged'.
|
|
279
|
+
has_delta = (
|
|
280
|
+
section_diff is not None
|
|
281
|
+
or changed_in is not None
|
|
282
|
+
or deprecated_in is not None
|
|
283
|
+
or signature_delta is not None
|
|
284
|
+
or bool(see_also_added)
|
|
285
|
+
or bool(see_also_removed)
|
|
286
|
+
)
|
|
287
|
+
if not has_delta:
|
|
288
|
+
return CompareVersionsResult(
|
|
289
|
+
symbol=symbol, v1=v1, v2=v2, change="unchanged"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return CompareVersionsResult(
|
|
293
|
+
symbol=symbol,
|
|
294
|
+
v1=v1,
|
|
295
|
+
v2=v2,
|
|
296
|
+
change="changed",
|
|
297
|
+
changed_in=changed_in,
|
|
298
|
+
deprecated_in=deprecated_in,
|
|
299
|
+
signature_delta=signature_delta,
|
|
300
|
+
see_also_added=see_also_added,
|
|
301
|
+
see_also_removed=see_also_removed,
|
|
302
|
+
section_diff=section_diff,
|
|
303
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-docs-mcp-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: The canonical Python stdlib oracle for AI coding agents — exact symbols, exact sections, exact versions, offline, always free, always MIT, token-frugal.
|
|
5
5
|
Author-email: Aymen Hammouda <hammouda.aymen@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -71,7 +71,7 @@ tools for lookup and section retrieval.
|
|
|
71
71
|
- page and section retrieval with truncation and pagination
|
|
72
72
|
- a local SQLite + FTS5 index; no runtime web scraping
|
|
73
73
|
- results for each Python version you index
|
|
74
|
-
-
|
|
74
|
+
- six read-only MCP tools
|
|
75
75
|
|
|
76
76
|
## Quick example
|
|
77
77
|
|
|
@@ -205,7 +205,7 @@ Contributor commands and validation steps live in
|
|
|
205
205
|
|
|
206
206
|
## Tools
|
|
207
207
|
|
|
208
|
-
The server currently exposes
|
|
208
|
+
The server currently exposes six MCP tools:
|
|
209
209
|
|
|
210
210
|
| Tool | Description |
|
|
211
211
|
|------|-------------|
|
|
@@ -214,6 +214,7 @@ The server currently exposes five MCP tools:
|
|
|
214
214
|
| `lookup_package_docs` | Look up official PyPI package metadata and return package-declared documentation/homepage/source URLs. This is a controlled PyPI metadata lookup, not generic web search. |
|
|
215
215
|
| `list_versions` | List all indexed Python versions with metadata. |
|
|
216
216
|
| `detect_python_version` | Detect the user's local Python version and report whether that version has been indexed. |
|
|
217
|
+
| `compare_versions` | Diff a Python stdlib symbol between two indexed versions. Returns `change=added|removed|changed|unchanged` with optional `new_in`, `changed_in`, `deprecated_in`, `signature_delta` (advisory heuristic), `see_also_added/removed`, `section_diff`, and `note` deltas. Token-frugal — emits only changed fields, not full content. |
|
|
217
218
|
|
|
218
219
|
## Why not Context7 or generic docs retrieval?
|
|
219
220
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
mcp_server_python_docs/__init__.py,sha256=sUtQT81momSAPMywIkgnR7BZxJHn53rpcjSE3CTAUws,499
|
|
2
2
|
mcp_server_python_docs/__main__.py,sha256=_LmCa1p3O1gSnOcOC7UXoNbqmIo2LBha3FDsfUUkJ_8,21196
|
|
3
|
-
mcp_server_python_docs/app_context.py,sha256=
|
|
3
|
+
mcp_server_python_docs/app_context.py,sha256=5kK4JcTiKgJGOV_gFE8aVktYdtCpeDbZunIrlt4SxyA,1403
|
|
4
4
|
mcp_server_python_docs/detection.py,sha256=bYYSfpMymHk9wehNW66oItX6yFUoCC2JXDRZLYpM0Vw,2860
|
|
5
5
|
mcp_server_python_docs/diagnostics.py,sha256=FyC6QDFLvozARpYuMnaKfJwaN6BCzVycME_SkNs2YIs,2370
|
|
6
6
|
mcp_server_python_docs/errors.py,sha256=XTOm1raJfxS2adaOPZdwj0SuhpMAXAdjBsF2-zTv_1E,827
|
|
7
|
-
mcp_server_python_docs/models.py,sha256=
|
|
7
|
+
mcp_server_python_docs/models.py,sha256=A9XtcJPJFDa6jn7pHa4yDS1TPlnpIc3A7WEeKu4qqOw,10247
|
|
8
8
|
mcp_server_python_docs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
mcp_server_python_docs/server.py,sha256=
|
|
9
|
+
mcp_server_python_docs/server.py,sha256=8F27o2hTyi1rwSNoMo8lGYXCvhwEHS7cVHI7n9p5Y2M,15469
|
|
10
10
|
mcp_server_python_docs/data/synonyms.yaml,sha256=LgJxamZ9QT6Pykva7M1uM8juSd9ybHuv3vUOKrUFMg4,10696
|
|
11
11
|
mcp_server_python_docs/ingestion/__init__.py,sha256=Yd7Jk7hD7XG3eoBVQpYiG94EwJGfI3qXxVCpA8JszYg,66
|
|
12
12
|
mcp_server_python_docs/ingestion/cpython_versions.py,sha256=mEhjuT7q8Bo1aZ-zj6PSqmbq2Bz64g4xj50ttqA4QUU,977
|
|
@@ -19,6 +19,7 @@ mcp_server_python_docs/retrieval/query.py,sha256=9rWBvWQG3KwVjCqEqCYKMFgKqswU-O4
|
|
|
19
19
|
mcp_server_python_docs/retrieval/ranker.py,sha256=GSssXzfaJKEWrCgF_UuTm9-lSzTbYe3cSalsqgq0nUI,11712
|
|
20
20
|
mcp_server_python_docs/services/__init__.py,sha256=UKlaRQYo_cnLOUVf_QieXnHlcnH9ejEa3pYgHhJiG1E,320
|
|
21
21
|
mcp_server_python_docs/services/cache.py,sha256=ADqg89johOJRYZysbes1bEs6nLFrmzS9L5c-Njt1mrU,3199
|
|
22
|
+
mcp_server_python_docs/services/compare.py,sha256=wWsgD8w9fl3L7gr-mcdd6ntY_V1pGtZ-ues1iyyNRPg,12743
|
|
22
23
|
mcp_server_python_docs/services/content.py,sha256=7O5XGbR9QWICMxYAd-cp_pPF86tNLcYAMIwyCP_i8dk,5342
|
|
23
24
|
mcp_server_python_docs/services/observability.py,sha256=rwnkIDelNkclHzuwsktGUoU2fzYCuiR1niVRmOwJHJg,4839
|
|
24
25
|
mcp_server_python_docs/services/package_docs.py,sha256=0Qq8PtRL_KdiTFUtllaj4KFHz6u9L903zcORVpYmR1U,5605
|
|
@@ -29,8 +30,8 @@ mcp_server_python_docs/services/version_resolution.py,sha256=rULBoqq97t0ZfSWSy-e
|
|
|
29
30
|
mcp_server_python_docs/storage/__init__.py,sha256=YNeNr7FW9Vgvo79oXgKXE3eGP0WwWDqRgTZ7lg7MFW4,62
|
|
30
31
|
mcp_server_python_docs/storage/db.py,sha256=Lys8HN3IvgmNpr_qyocUQghcZnwHf2lz9eq_BFmwi54,5634
|
|
31
32
|
mcp_server_python_docs/storage/schema.sql,sha256=4cXPktPQSLfbjkSjirpf0dA2CphlO9UCIKUxnLiIm_g,5637
|
|
32
|
-
python_docs_mcp_server-0.
|
|
33
|
-
python_docs_mcp_server-0.
|
|
34
|
-
python_docs_mcp_server-0.
|
|
35
|
-
python_docs_mcp_server-0.
|
|
36
|
-
python_docs_mcp_server-0.
|
|
33
|
+
python_docs_mcp_server-0.2.0.dist-info/METADATA,sha256=Yb2uC9uMwyceWUJiUxa-Da1TRIKLkpqhu6TXCHJHKWM,13880
|
|
34
|
+
python_docs_mcp_server-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
35
|
+
python_docs_mcp_server-0.2.0.dist-info/entry_points.txt,sha256=-1arbtjZhF5mtnJNAh_90UBr1f4AqJxT3Tmfy9p7NMI,142
|
|
36
|
+
python_docs_mcp_server-0.2.0.dist-info/licenses/LICENSE,sha256=-AHGWRwcvoeP4bkY1W2ok_qaI1Mg0Zx2qYgR1k-3CHY,1071
|
|
37
|
+
python_docs_mcp_server-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
{python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{python_docs_mcp_server-0.1.6.dist-info → python_docs_mcp_server-0.2.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|