python-docs-mcp-server 0.3.1__py3-none-any.whl → 0.4.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.
@@ -16,6 +16,7 @@ from mcp_server_python_docs.services.package_docs import PackageDocsService
16
16
  from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
17
17
  from mcp_server_python_docs.services.search import SearchService
18
18
  from mcp_server_python_docs.services.version import VersionService
19
+ from mcp_server_python_docs.services.whatsnew import WhatsNewService
19
20
 
20
21
 
21
22
  @dataclass
@@ -28,6 +29,7 @@ class AppContext:
28
29
  content_service: ContentService
29
30
  compare_service: CompareService
30
31
  version_service: VersionService
32
+ whatsnew_service: WhatsNewService
31
33
  package_docs_service: PackageDocsService = field(default_factory=PackageDocsService)
32
34
  persistent_docs_cache: PersistentDocsCache | None = None
33
35
  synonyms: dict[str, list[str]] = field(default_factory=dict)
@@ -287,9 +287,16 @@ def extract_sections(body_html: str, doc_uri: str) -> list[dict]:
287
287
  return []
288
288
 
289
289
  soup = BeautifulSoup(body_html, "html.parser")
290
+ # Modern Sphinx puts the ID on a section; only its first direct heading names it.
291
+ for section in soup.find_all(["section", "div"], id=True):
292
+ if section.name != "section" and "section" not in (section.get("class") or []):
293
+ continue
294
+ heading = section.find(re.compile(r"^h[1-6]$"), recursive=False)
295
+ if isinstance(heading, Tag) and not heading.get("id"):
296
+ heading["id"] = section["id"]
290
297
  heading_tags = soup.find_all(re.compile(r"^h[1-6]$"), id=True)
291
298
 
292
- if not heading_tags:
299
+ if not heading_tags and not soup.find("dt", id=True):
293
300
  # No headings with id — create a single section covering the whole body
294
301
  content = html_to_markdown(body_html)
295
302
  if not content:
@@ -346,6 +353,24 @@ def extract_sections(body_html: str, doc_uri: str) -> list[dict]:
346
353
  }
347
354
  )
348
355
 
356
+ # Sphinx API signatures carry independent anchors inside definition lists.
357
+ anchors = {section["anchor"] for section in sections}
358
+ for signature in soup.find_all("dt", id=True):
359
+ anchor = str(signature["id"])
360
+ if anchor in anchors:
361
+ continue
362
+ anchors.add(anchor)
363
+ description = signature.find_next_sibling("dd")
364
+ content = html_to_markdown(str(signature) + (str(description) if description else ""))
365
+ sections.append({
366
+ "anchor": anchor,
367
+ "heading": signature.get_text(" ", strip=True).rstrip("¶").strip(),
368
+ "level": 3,
369
+ "ordinal": len(sections),
370
+ "content_text": content,
371
+ "char_count": len(content),
372
+ "uri": f"{doc_uri}#{anchor}",
373
+ })
349
374
  return sections
350
375
 
351
376
 
@@ -301,3 +301,29 @@ class CompareVersionsResult(BaseModel):
301
301
  "therefore based on symbol presence alone."
302
302
  ),
303
303
  )
304
+
305
+
306
+ # --- whatsnew_for_version models ---
307
+
308
+ WhatsNewKind = Literal[
309
+ "new_module", "new_feature", "deprecation", "removal",
310
+ "performance", "syntax", "other",
311
+ ]
312
+
313
+
314
+ class WhatsNewSection(BaseModel):
315
+ """A nonempty, bounded section from an indexed official release page."""
316
+
317
+ title: str = Field(description="Official section heading")
318
+ anchor: str = Field(description="Stable section anchor for get_docs follow-up")
319
+ body: str = Field(
320
+ description="Bounded section text; truncated sections include a get_docs hint"
321
+ )
322
+ kind: WhatsNewKind = Field(description="Conservative heading-hierarchy category")
323
+
324
+
325
+ class WhatsNewResult(BaseModel):
326
+ """Paginated sections from What's New in one Python version."""
327
+
328
+ sections: list[WhatsNewSection] = Field(default_factory=list)
329
+ next_start_index: int | None = Field(default=None)
@@ -9,7 +9,9 @@ Receives sqlite3.Connection as parameter -- does not import storage.
9
9
  from __future__ import annotations
10
10
 
11
11
  import logging
12
+ import re
12
13
  import sqlite3
14
+ from difflib import SequenceMatcher
13
15
 
14
16
  from mcp_server_python_docs.models import SymbolHit
15
17
 
@@ -133,6 +135,32 @@ def _normalize_scores(hits: list[SymbolHit]) -> list[SymbolHit]:
133
135
  return normalized
134
136
 
135
137
 
138
+ def _section_excerpt_overlaps(first: sqlite3.Row, second: sqlite3.Row) -> bool:
139
+ """Only collapse nested sections when their *matched excerpts* are redundant.
140
+
141
+ A shared page is not enough: an overview can independently answer a broad
142
+ query while a nearby API section answers a precise one. The content and the
143
+ FTS snippet must both overlap substantially before they compete for a slot.
144
+ """
145
+ if first["version"] != second["version"] or first["slug"] != second["slug"]:
146
+ return False
147
+ first_content = " ".join(first["content_text"].casefold().split())
148
+ second_content = " ".join(second["content_text"].casefold().split())
149
+ if not first_content or not second_content:
150
+ return False
151
+ if first_content not in second_content and second_content not in first_content:
152
+ return False
153
+
154
+ first_words = re.findall(r"\w+", first["snippet_text"].casefold())
155
+ second_words = re.findall(r"\w+", second["snippet_text"].casefold())
156
+ shorter = min(len(first_words), len(second_words))
157
+ if shorter < 8:
158
+ return False
159
+ shared = SequenceMatcher(None, first_words, second_words, autojunk=False)
160
+ match = shared.find_longest_match(0, len(first_words), 0, len(second_words))
161
+ return match.size >= max(8, (7 * shorter + 9) // 10)
162
+
163
+
136
164
  def search_sections(
137
165
  conn: sqlite3.Connection,
138
166
  match_expr: str,
@@ -154,10 +182,15 @@ def search_sections(
154
182
  Returns:
155
183
  List of SymbolHit with kind="section" and FTS5 snippets.
156
184
  """
185
+ # Inspect a fixed, ranked window even after the result slots fill: a later
186
+ # narrow API anchor can replace an earlier enclosing overview. The server
187
+ # caps max_results at 20, so this examines at most 160 candidates and does
188
+ # not paginate through an unbounded run of overlapping sections.
189
+ candidate_limit = max(40, max_results * 8)
157
190
  try:
158
- cursor = conn.execute(
191
+ candidates = conn.execute(
159
192
  """
160
- SELECT s.id, s.heading, s.uri, s.anchor,
193
+ SELECT s.id, s.heading, s.uri, s.anchor, s.content_text,
161
194
  d.version, doc.slug,
162
195
  bm25(sections_fts, 10.0, 1.0) as score,
163
196
  snippet(sections_fts, 1, '**', '**', '...', 32) as snippet_text
@@ -167,16 +200,33 @@ def search_sections(
167
200
  JOIN doc_sets d ON doc.doc_set_id = d.id
168
201
  WHERE sections_fts MATCH ?
169
202
  AND (? IS NULL OR d.version = ?)
170
- ORDER BY bm25(sections_fts, 10.0, 1.0)
203
+ ORDER BY bm25(sections_fts, 10.0, 1.0), s.id
171
204
  LIMIT ?
172
205
  """,
173
- (match_expr, version, version, max_results),
174
- )
175
- rows = cursor.fetchall()
206
+ (match_expr, version, version, candidate_limit),
207
+ ).fetchall()
176
208
  except sqlite3.OperationalError:
177
209
  logger.warning("FTS5 query failed for sections: %r", match_expr)
178
210
  return []
179
211
 
212
+ selected: list[sqlite3.Row] = []
213
+ for row in candidates:
214
+ duplicate = next(
215
+ (i for i, prior in enumerate(selected)
216
+ if _section_excerpt_overlaps(prior, row)),
217
+ None,
218
+ )
219
+ if duplicate is None:
220
+ selected.append(row)
221
+ elif len(row["content_text"]) < len(selected[duplicate]["content_text"]):
222
+ # The narrower section gives a more precise canonical anchor
223
+ # for the same matched text. The overview stays available
224
+ # through its anchor and whole-page get_docs retrieval.
225
+ selected[duplicate] = row
226
+
227
+ selected.sort(key=lambda row: (row["score"], row["id"]))
228
+ rows = selected[:max_results]
229
+
180
230
  hits = [
181
231
  SymbolHit(
182
232
  uri=row["uri"],
@@ -34,6 +34,8 @@ from mcp_server_python_docs.models import (
34
34
  ListVersionsResult,
35
35
  PackageDocsResult,
36
36
  SearchDocsResult,
37
+ WhatsNewKind,
38
+ WhatsNewResult,
37
39
  )
38
40
  from mcp_server_python_docs.services.compare import CompareService
39
41
  from mcp_server_python_docs.services.content import ContentService
@@ -41,6 +43,7 @@ from mcp_server_python_docs.services.package_docs import PackageDocsService
41
43
  from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
42
44
  from mcp_server_python_docs.services.search import SearchService
43
45
  from mcp_server_python_docs.services.version import VersionService
46
+ from mcp_server_python_docs.services.whatsnew import WhatsNewService
44
47
  from mcp_server_python_docs.storage.db import (
45
48
  get_cache_dir,
46
49
  get_index_path,
@@ -162,6 +165,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
162
165
  content_svc = ContentService(db, persistent_cache=persistent_docs_cache)
163
166
  compare_svc = CompareService(db, content_svc)
164
167
  version_svc = VersionService(db)
168
+ whatsnew_svc = WhatsNewService(db)
165
169
  package_docs_svc = PackageDocsService()
166
170
 
167
171
  # Detect user's Python version and match to indexed versions
@@ -188,6 +192,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
188
192
  content_service=content_svc,
189
193
  compare_service=compare_svc,
190
194
  version_service=version_svc,
195
+ whatsnew_service=whatsnew_svc,
191
196
  package_docs_service=package_docs_svc,
192
197
  persistent_docs_cache=persistent_docs_cache,
193
198
  detected_python_version=matched,
@@ -411,6 +416,29 @@ def create_server() -> FastMCP:
411
416
  logger.exception("Unexpected error in compare_versions")
412
417
  raise ToolError(f"Internal error: {type(e).__name__}")
413
418
 
419
+ @mcp.tool(annotations=_TOOL_ANNOTATIONS)
420
+ def whatsnew_for_version(
421
+ version: CompareVersionParam,
422
+ kind: WhatsNewKind | None = None,
423
+ start_index: StartIndexParam = 0,
424
+ max_sections: MaxResultsParam = 20,
425
+ ctx: Context = None, # type: ignore[assignment]
426
+ ) -> WhatsNewResult:
427
+ """Browse indexed official What's New sections in document order.
428
+
429
+ Use kind to narrow the release topics. Sections are bounded; for full
430
+ text use get_docs(slug='whatsnew/X.Y', version='X.Y', anchor=section.anchor).
431
+ Pagination start_index counts nonempty sections after kind filtering.
432
+ """
433
+ app_ctx: AppContext = ctx.request_context.lifespan_context
434
+ try:
435
+ return app_ctx.whatsnew_service.get(version, kind, start_index, max_sections)
436
+ except DocsServerError as e:
437
+ raise ToolError(str(e))
438
+ except Exception as e:
439
+ logger.exception("Unexpected error in whatsnew_for_version")
440
+ raise ToolError(f"Internal error: {type(e).__name__}")
441
+
414
442
  # SRVR-07: _meta hint for get_docs tool.
415
443
  # FastMCP 1.27 does not expose a public API for setting _meta on tool
416
444
  # definitions. Deferred until the mcp SDK adds _meta support to the
@@ -67,7 +67,7 @@ class ContentService:
67
67
  # Find the document
68
68
  doc_row = self._db.execute(
69
69
  """
70
- SELECT d.id, d.title, d.slug
70
+ SELECT d.id, d.title, d.slug, d.content_text
71
71
  FROM documents d
72
72
  JOIN doc_sets ds ON d.doc_set_id = ds.id
73
73
  WHERE d.slug = ? AND ds.version = ?
@@ -105,29 +105,8 @@ class ContentService:
105
105
  f"Section {anchor!r} not found in {slug!r} v{resolved_version}"
106
106
  )
107
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)
108
+ # The stored page is canonical; nested anchor excerpts can overlap.
109
+ full_text = doc_row["content_text"] or ""
131
110
 
132
111
  title = doc_title
133
112
 
@@ -6,9 +6,11 @@ No MCP types imported — dependency rule enforced.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ import re
9
10
  import sqlite3
11
+ from urllib.parse import urlsplit
10
12
 
11
- from mcp_server_python_docs.models import SearchDocsResult
13
+ from mcp_server_python_docs.models import SearchDocsResult, SymbolHit
12
14
  from mcp_server_python_docs.retrieval.query import (
13
15
  build_match_expression,
14
16
  classify_query,
@@ -23,6 +25,8 @@ from mcp_server_python_docs.retrieval.ranker import (
23
25
  from mcp_server_python_docs.services.observability import log_tool_call
24
26
  from mcp_server_python_docs.services.version_resolution import resolve_version_permissive
25
27
 
28
+ _DOTTED_IDENTIFIER = re.compile(r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+\b")
29
+
26
30
 
27
31
  class SearchService:
28
32
  """Search service dispatching queries through classifier, synonym expansion,
@@ -55,6 +59,47 @@ class SearchService:
55
59
  ).fetchone()
56
60
  return row is not None
57
61
 
62
+ def _known_identifiers(self, query: str, version: str) -> set[str]:
63
+ """Count exact prompt identifiers only in the requested inventory."""
64
+ return {
65
+ name
66
+ for name in set(_DOTTED_IDENTIFIER.findall(query))
67
+ if self._db.execute(
68
+ "SELECT 1 FROM symbols s JOIN doc_sets d ON d.id = s.doc_set_id "
69
+ "WHERE s.qualified_name = ? AND d.version = ? LIMIT 1",
70
+ (name, version),
71
+ ).fetchone() is not None
72
+ }
73
+
74
+ def _append_canonical(
75
+ self, original: SearchDocsResult, query: str, version: str | None,
76
+ kind: str, max_results: int,
77
+ ) -> SearchDocsResult:
78
+ """Fill at most one spare slot without altering semantic evidence."""
79
+ if kind != "auto" or version is None or not original.hits:
80
+ return original
81
+ if len(original.hits) >= max_results:
82
+ return original
83
+ known = self._known_identifiers(query, version)
84
+ if len(known) != 1:
85
+ return original
86
+ name = known.pop()
87
+ candidates = lookup_symbols_exact(self._db, name, version, 1)
88
+ if not candidates or candidates[0].title != name:
89
+ return original
90
+ candidate = candidates[0]
91
+
92
+ def location(hit: SymbolHit) -> tuple[str, str, str]:
93
+ uri = urlsplit(hit.uri)
94
+ return (hit.version, (hit.slug or uri.path).removesuffix(".html"),
95
+ hit.anchor or uri.fragment)
96
+
97
+ if any(location(hit) == location(candidate) for hit in original.hits):
98
+ return original
99
+ proposed = original.model_copy(update={"hits": [*original.hits, candidate]})
100
+ # Admission only: never cap, replace or truncate the original response.
101
+ return proposed if len(proposed.model_dump_json().encode("utf-8")) <= 8000 else original
102
+
58
103
  @log_tool_call("search_docs")
59
104
  def search(
60
105
  self,
@@ -84,7 +129,9 @@ class SearchService:
84
129
  hits = lookup_symbols_exact(self._db, query, resolved_version, max_results)
85
130
  if hits:
86
131
  self._last_resolution = "exact"
87
- return SearchDocsResult(hits=hits)
132
+ return self._append_canonical(
133
+ SearchDocsResult(hits=hits), query, resolved_version, kind, max_results,
134
+ )
88
135
  # Fall through to FTS if symbol lookup found nothing and kind is auto
89
136
  if kind == "symbol":
90
137
  self._last_resolution = "exact"
@@ -108,4 +155,18 @@ class SearchService:
108
155
  hits = search_symbols(self._db, match_expr, resolved_version, max_results)
109
156
 
110
157
  self._last_resolution = "fts"
111
- return SearchDocsResult(hits=hits)
158
+ if not hits and kind == "auto" and resolved_version is not None:
159
+ # Prompt-only candidates; ambiguity is decided in the requested inventory.
160
+ known = self._known_identifiers(query, resolved_version)
161
+ if len(known) == 1:
162
+ name = known.pop()
163
+ hits = lookup_symbols_exact(self._db, name, resolved_version, max_results)
164
+ if hits:
165
+ self._last_resolution = "exact"
166
+ return SearchDocsResult(
167
+ hits=hits,
168
+ note=f"No full-query results; showing symbol matches for {name!r}.",
169
+ )
170
+ return self._append_canonical(
171
+ SearchDocsResult(hits=hits), query, resolved_version, kind, max_results,
172
+ )
@@ -0,0 +1,172 @@
1
+ """Offline, version-scoped discovery of sections on the official What's New page."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+
6
+ from mcp_server_python_docs.errors import DocsServerError, PageNotFoundError
7
+ from mcp_server_python_docs.models import WhatsNewKind, WhatsNewResult, WhatsNewSection
8
+ from mcp_server_python_docs.retrieval.budget import apply_budget
9
+ from mcp_server_python_docs.services.version_resolution import validate_version
10
+
11
+ # Cap the whole structured response, including metadata and continuation hints.
12
+ _RESULT_CHARS = 20_000
13
+ _BODY_CHARS = 7800
14
+
15
+
16
+ def _body_cost(section: WhatsNewSection, body: str) -> int:
17
+ return (
18
+ len(section.model_copy(update={"body": body}).model_dump_json())
19
+ - len(section.model_copy(update={"body": ""}).model_dump_json())
20
+ )
21
+
22
+
23
+ def _fit_body(section: WhatsNewSection, body: str, hint: str, share: int) -> str:
24
+ # Full text has no hint: its cost is outside the monotonic truncated search.
25
+ if len(body) <= _BODY_CHARS and _body_cost(section, body) <= share:
26
+ return body
27
+ low, high = 1, min(_BODY_CHARS, len(body) - 1)
28
+ chosen = section.body
29
+ while low <= high:
30
+ middle = (low + high) // 2
31
+ excerpt, truncated, _ = apply_budget(body, middle)
32
+ candidate = excerpt + hint if truncated else excerpt
33
+ if _body_cost(section, candidate) <= share:
34
+ chosen = candidate
35
+ low = middle + 1
36
+ else:
37
+ high = middle - 1
38
+ return chosen
39
+
40
+
41
+ def _classify(headings: tuple[str, ...]) -> WhatsNewKind:
42
+ """Classify only when an explicit heading or its ancestry supports it.
43
+
44
+ A leaf such as ``asyncio`` or an opaque ``#id6`` cannot classify itself.
45
+ Nearest relevant ancestor wins, so a future pending removal inside a
46
+ deprecations chapter remains a deprecation, not a removal in this release.
47
+ """
48
+ for heading in reversed(headings):
49
+ title = heading.casefold().strip()
50
+ if title.startswith("pending removal"):
51
+ return "deprecation"
52
+ if title in {"deprecated", "new deprecations", "deprecated c apis"}:
53
+ return "deprecation"
54
+ if title in {"removed", "removed modules and apis", "removed c apis"}:
55
+ return "removal"
56
+ if title in {"new modules"}:
57
+ return "new_module"
58
+ if title in {"optimizations", "performance"}:
59
+ return "performance"
60
+ if "syntax" in title or title == "syntax changes":
61
+ return "syntax"
62
+ if title in {"new features", "new features related to type hints", "improved modules"}:
63
+ return "new_feature"
64
+ return "other"
65
+
66
+
67
+ class WhatsNewService:
68
+ """Read the already-indexed release page; never fetch at query time."""
69
+
70
+ def __init__(self, db: sqlite3.Connection) -> None:
71
+ self._db = db
72
+
73
+ def get(
74
+ self,
75
+ version: str,
76
+ kind: WhatsNewKind | None = None,
77
+ start_index: int = 0,
78
+ max_sections: int = 20,
79
+ ) -> WhatsNewResult:
80
+ validate_version(self._db, version)
81
+ if start_index < 0 or not 1 <= max_sections <= 20:
82
+ raise ValueError("start_index must be nonnegative and max_sections must be 1..20")
83
+ slug = f"whatsnew/{version}"
84
+ document = self._db.execute(
85
+ "SELECT d.id FROM documents d JOIN doc_sets ds ON ds.id = d.doc_set_id "
86
+ "WHERE ds.version = ? AND ds.source = 'python-docs' "
87
+ "AND ds.language = 'en' AND d.slug = ?",
88
+ (version, slug),
89
+ ).fetchone()
90
+ if document is None:
91
+ raise PageNotFoundError(
92
+ f"official What's New page {slug!r} is not indexed for Python {version}; "
93
+ "rebuild the full documentation index (without --skip-content)"
94
+ )
95
+
96
+ rows = self._db.execute(
97
+ "SELECT anchor, heading, level, content_text FROM sections "
98
+ "WHERE document_id = ? ORDER BY ordinal, id",
99
+ (document["id"],),
100
+ ).fetchall()
101
+ ancestors: list[tuple[int, str]] = []
102
+ matches: list[tuple[sqlite3.Row, WhatsNewKind]] = []
103
+ for row in rows:
104
+ level = max(1, int(row["level"]))
105
+ # Sphinx levels may skip; compare actual levels, not stack depth.
106
+ while ancestors and ancestors[-1][0] >= level:
107
+ ancestors.pop()
108
+ ancestors.append((level, row["heading"]))
109
+ body = row["content_text"].strip()
110
+ if not body:
111
+ continue
112
+ category = _classify(tuple(title for _, title in ancestors))
113
+ if kind is not None and category != kind:
114
+ continue
115
+ matches.append((row, category))
116
+ # Reserve indexed text and any required hint before admitting each section.
117
+ # Stop at the first non-fitting section; the next offset must never skip it.
118
+ page: list[tuple[str, str]] = []
119
+ sections: list[WhatsNewSection] = []
120
+ minimum_costs: list[int] = []
121
+ result = WhatsNewResult()
122
+ for row, category in matches[start_index : start_index + max_sections]:
123
+ section = WhatsNewSection(
124
+ title=row["heading"], anchor=row["anchor"], body="", kind=category
125
+ )
126
+ next_index = start_index + len(sections) + 1
127
+ candidate = WhatsNewResult(
128
+ sections=[*sections, section],
129
+ next_start_index=next_index if next_index < len(matches) else None,
130
+ )
131
+ if not sections and len(candidate.model_dump_json()) > _RESULT_CHARS:
132
+ raise DocsServerError(
133
+ f"What's New section at start_index={start_index} has metadata "
134
+ "larger than the 20,000-character response limit"
135
+ )
136
+ body = row["content_text"].strip()
137
+ hint = (
138
+ "\n\n[Section truncated. Continue with "
139
+ f"get_docs(slug={slug!r}, version={version!r}, "
140
+ f"anchor={row['anchor']!r}).]"
141
+ )
142
+ excerpt, truncated, _ = apply_budget(body, 1)
143
+ minimum = excerpt + hint if truncated else excerpt
144
+ if len(body) <= _BODY_CHARS and (
145
+ _body_cost(section, body) <= _body_cost(section, minimum)
146
+ ):
147
+ minimum = body
148
+ section.body = minimum
149
+ if len(candidate.model_dump_json()) > _RESULT_CHARS:
150
+ if not sections:
151
+ raise DocsServerError(
152
+ f"What's New section at start_index={start_index} cannot fit "
153
+ "nonempty indexed text and its required continuation within "
154
+ "the 20,000-character response limit"
155
+ )
156
+ break
157
+ page.append((body, hint))
158
+ minimum_costs.append(_body_cost(section, minimum))
159
+ sections.append(section)
160
+ result = candidate
161
+ remaining = _RESULT_CHARS - len(result.model_dump_json()) + sum(minimum_costs)
162
+ reserved = sum(minimum_costs)
163
+ for index, (body, hint) in enumerate(page):
164
+ section = sections[index]
165
+ reserved -= minimum_costs[index]
166
+ share = min(
167
+ max(remaining // (len(page) - index), minimum_costs[index]),
168
+ remaining - reserved,
169
+ )
170
+ section.body = _fit_body(section, body, hint, share)
171
+ remaining -= _body_cost(section, section.body)
172
+ return result
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-docs-mcp-server
3
- Version: 0.3.1
3
+ Version: 0.4.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
@@ -17,7 +17,7 @@ Requires-Python: >=3.12
17
17
  Requires-Dist: beautifulsoup4<5.0,>=4.12
18
18
  Requires-Dist: click<9.0,>=8.1.7
19
19
  Requires-Dist: markdownify<2.0,>=0.14
20
- Requires-Dist: mcp<2.0.0,>=1.27.0
20
+ Requires-Dist: mcp<2.0.0,>=1.30.0
21
21
  Requires-Dist: platformdirs>=4.0
22
22
  Requires-Dist: pydantic<3.0,>=2.0.0
23
23
  Requires-Dist: pyyaml<7.0,>=6.0
@@ -79,7 +79,7 @@ tools for lookup and section retrieval.
79
79
  - page and section retrieval with truncation and pagination
80
80
  - a local SQLite + FTS5 index; no runtime web scraping
81
81
  - results for each Python version you index
82
- - six read-only MCP tools
82
+ - seven read-only MCP tools
83
83
 
84
84
  ## Quick example
85
85
 
@@ -127,6 +127,20 @@ pipx install python-docs-mcp-server
127
127
  If `uv` is installed but the `uv` command is not on your `PATH`, reopen your
128
128
  shell or use `python -m uv ...` as a fallback for local contributor commands.
129
129
 
130
+ ## Search result behavior
131
+
132
+ For `kind="auto"` with an explicit version, a nonempty search with spare result
133
+ slots may append **one** exact inventory target when the prompt contains one
134
+ known qualified identifier in that version. Original hits, snippets, scores,
135
+ order and notes are preserved. Full results, ambiguous identifiers, other kinds
136
+ and versionless queries are unchanged; the existing empty-result fallback is
137
+ unchanged. Canonical locations are deduplicated, including inventory aliases.
138
+
139
+ The append is admitted only when the **complete proposed response** serialized
140
+ by Pydantic's compact `model_dump_json()` is at most **8000 UTF-8 bytes**. This is
141
+ not a global search cap: oversized original responses remain unchanged. It is
142
+ separate from `get_docs(max_chars=8000)`, which limits content **characters**.
143
+
130
144
  ## First run
131
145
 
132
146
  Build the local documentation index:
@@ -233,7 +247,7 @@ Contributor commands and validation steps live in
233
247
 
234
248
  ## Tools
235
249
 
236
- The server currently exposes six MCP tools:
250
+ The server currently exposes seven MCP tools:
237
251
 
238
252
  | Tool | Description |
239
253
  |------|-------------|
@@ -243,6 +257,17 @@ The server currently exposes six MCP tools:
243
257
  | `list_versions` | List all indexed Python versions with metadata. |
244
258
  | `detect_python_version` | Detect the user's local Python version and report whether that version has been indexed. |
245
259
  | `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. |
260
+ | `whatsnew_for_version` | Browse nonempty sections of the already-indexed official What's New page for one Python version. Optional `kind` filters by conservative heading-hierarchy category. `start_index` and `max_sections` paginate the filtered results; the structured response is capped at 20,000 characters, and truncated excerpts include a stable anchor and `get_docs` follow-up hint. Canonical titles and anchors are unchanged; if one section cannot fit its metadata plus nonempty indexed text and any required continuation hint, an explicit bounded error is returned. Page size may be reduced to reserve usable excerpts. The cap counts Unicode characters in the model JSON, excluding the MCP envelope. Querying is offline. |
261
+
262
+ For `search_docs` with `kind="auto"` and an explicit indexed `version`, the
263
+ original lookup and full-text search run first. If both return no hits, the
264
+ server looks for dotted Python identifiers in the prompt. Exactly one distinct
265
+ identifier registered in that version enables a symbol-lookup fallback; repeated
266
+ mentions count once and unknown identifiers do not count. Multiple known
267
+ identifiers leave the result empty. A result note identifies fallback matches,
268
+ which are symbol documentation, not an answer to the complete prompt. Explicit
269
+ kinds, searches without a version, nonempty results, and result budgets are
270
+ unchanged.
246
271
 
247
272
  ## Why not Context7 or generic docs retrieval?
248
273
 
@@ -1,12 +1,12 @@
1
1
  mcp_server_python_docs/__init__.py,sha256=sUtQT81momSAPMywIkgnR7BZxJHn53rpcjSE3CTAUws,499
2
2
  mcp_server_python_docs/__main__.py,sha256=d6ZyRgkmOW7XUUgoGjOMEzCVoHL-aHuqmFKdxxkucvk,22165
3
- mcp_server_python_docs/app_context.py,sha256=5kK4JcTiKgJGOV_gFE8aVktYdtCpeDbZunIrlt4SxyA,1403
3
+ mcp_server_python_docs/app_context.py,sha256=DDUiDFMdxAElaQaiZ9mMQAU6H6D9Vq946NrNlmbtIN4,1510
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=A9XtcJPJFDa6jn7pHa4yDS1TPlnpIc3A7WEeKu4qqOw,10247
7
+ mcp_server_python_docs/models.py,sha256=89fe4foe7wyw_f8tpMDPZqBJ5Nd8bolhkwm65eQdW6o,11099
8
8
  mcp_server_python_docs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- mcp_server_python_docs/server.py,sha256=8F27o2hTyi1rwSNoMo8lGYXCvhwEHS7cVHI7n9p5Y2M,15469
9
+ mcp_server_python_docs/server.py,sha256=faiUpXnnXulwcIUKBptc9ZFgB4-owJH3CTgrUL_8sKU,16733
10
10
  mcp_server_python_docs/cache/__init__.py,sha256=Eba8m5Xi3LYQKz5ictK9whVi_SZJWr5vZE1AtKDb26U,31
11
11
  mcp_server_python_docs/cache/codec.py,sha256=tOEuHDqa6ziF2T05JubtDg_QnqvYRuheZijZUTX9lpE,3653
12
12
  mcp_server_python_docs/data/synonyms.yaml,sha256=LgJxamZ9QT6Pykva7M1uM8juSd9ybHuv3vUOKrUFMg4,10696
@@ -14,26 +14,27 @@ mcp_server_python_docs/ingestion/__init__.py,sha256=Yd7Jk7hD7XG3eoBVQpYiG94EwJGf
14
14
  mcp_server_python_docs/ingestion/cpython_versions.py,sha256=_28TX26eGEiiEV_6NthaPg8vwvwjuus2yVLn7ktvcyo,1426
15
15
  mcp_server_python_docs/ingestion/inventory.py,sha256=5gQCWOo1tBiQgv-e_A9KR9n2xfP4YAnJrSmb0pFbzwY,5672
16
16
  mcp_server_python_docs/ingestion/publish.py,sha256=gra87NgH1i4T92nSwf3sqp4AnKuuzQfrcE7ySnDgcSQ,17247
17
- mcp_server_python_docs/ingestion/sphinx_json.py,sha256=OXrZ8LMeLFn2har2TVnWVNwMY9b5z1VCMioQdIE5gJE,20270
17
+ mcp_server_python_docs/ingestion/sphinx_json.py,sha256=ZSiTK30-9hhra7oFelUL8OP3eB_dB8ddmFBV4hWUL3g,21524
18
18
  mcp_server_python_docs/retrieval/__init__.py,sha256=xSH2dWrIun_Q980ElcoXWBIobTqz0am66-AkEvRo_ck,779
19
19
  mcp_server_python_docs/retrieval/budget.py,sha256=kORd1Pt6IxLH95vRE7WBwdwezxvk98deYBgeNLEmyOw,2098
20
20
  mcp_server_python_docs/retrieval/query.py,sha256=9rWBvWQG3KwVjCqEqCYKMFgKqswU-O4a1SyJiTzQrjk,6116
21
- mcp_server_python_docs/retrieval/ranker.py,sha256=GSssXzfaJKEWrCgF_UuTm9-lSzTbYe3cSalsqgq0nUI,11712
21
+ mcp_server_python_docs/retrieval/ranker.py,sha256=ct9uIir0oK38v0eI-lKPq9m_EsN8Fsf3NBajbYi2fAs,14122
22
22
  mcp_server_python_docs/services/__init__.py,sha256=UKlaRQYo_cnLOUVf_QieXnHlcnH9ejEa3pYgHhJiG1E,320
23
23
  mcp_server_python_docs/services/cache.py,sha256=ADqg89johOJRYZysbes1bEs6nLFrmzS9L5c-Njt1mrU,3199
24
24
  mcp_server_python_docs/services/compare.py,sha256=wWsgD8w9fl3L7gr-mcdd6ntY_V1pGtZ-ues1iyyNRPg,12743
25
- mcp_server_python_docs/services/content.py,sha256=7O5XGbR9QWICMxYAd-cp_pPF86tNLcYAMIwyCP_i8dk,5342
25
+ mcp_server_python_docs/services/content.py,sha256=5_FPgyOYbax7qNH6OXEePnyfmyjBdgh-QkK7CvEMCCU,4654
26
26
  mcp_server_python_docs/services/observability.py,sha256=rwnkIDelNkclHzuwsktGUoU2fzYCuiR1niVRmOwJHJg,4839
27
27
  mcp_server_python_docs/services/package_docs.py,sha256=0Qq8PtRL_KdiTFUtllaj4KFHz6u9L903zcORVpYmR1U,5605
28
28
  mcp_server_python_docs/services/persistent_cache.py,sha256=p1uhTkwmXRP-v79jaIPDTEv9Z0G0iqCUnUDKmFQA6GA,6956
29
- mcp_server_python_docs/services/search.py,sha256=Pjt4IDfnogWTGYKFJ_4ODbq1xbUZ0ybceD6yj7Ep6lw,4545
29
+ mcp_server_python_docs/services/search.py,sha256=xioiLrgTPTU9LHg2t0dG8mHxaKwYSKI-haGZq6J0kYs,7384
30
30
  mcp_server_python_docs/services/version.py,sha256=nPkWhksZo7B4QIp-knlSEvVXFmKIcVUq6tWRTJAe5aA,1467
31
31
  mcp_server_python_docs/services/version_resolution.py,sha256=rULBoqq97t0ZfSWSy-ecI3wALx5t_3OWuok1hZK8w0w,2643
32
+ mcp_server_python_docs/services/whatsnew.py,sha256=vLNbUaPoCEMBP-PvPiu7Wp1QV28OQJpFUg_umFwWAx0,7476
32
33
  mcp_server_python_docs/storage/__init__.py,sha256=YNeNr7FW9Vgvo79oXgKXE3eGP0WwWDqRgTZ7lg7MFW4,62
33
34
  mcp_server_python_docs/storage/db.py,sha256=Lys8HN3IvgmNpr_qyocUQghcZnwHf2lz9eq_BFmwi54,5634
34
35
  mcp_server_python_docs/storage/schema.sql,sha256=4cXPktPQSLfbjkSjirpf0dA2CphlO9UCIKUxnLiIm_g,5637
35
- python_docs_mcp_server-0.3.1.dist-info/METADATA,sha256=OGvwbikJSXf2KSdeA5grj0PwDRhjDECRtmW_Wk6N_MA,14808
36
- python_docs_mcp_server-0.3.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
37
- python_docs_mcp_server-0.3.1.dist-info/entry_points.txt,sha256=-1arbtjZhF5mtnJNAh_90UBr1f4AqJxT3Tmfy9p7NMI,142
38
- python_docs_mcp_server-0.3.1.dist-info/licenses/LICENSE,sha256=-AHGWRwcvoeP4bkY1W2ok_qaI1Mg0Zx2qYgR1k-3CHY,1071
39
- python_docs_mcp_server-0.3.1.dist-info/RECORD,,
36
+ python_docs_mcp_server-0.4.0.dist-info/METADATA,sha256=qBymOswxf7xctE7iOhiuRO0aQHxYuod0Fy3ib6Tq6Dw,16992
37
+ python_docs_mcp_server-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
38
+ python_docs_mcp_server-0.4.0.dist-info/entry_points.txt,sha256=-1arbtjZhF5mtnJNAh_90UBr1f4AqJxT3Tmfy9p7NMI,142
39
+ python_docs_mcp_server-0.4.0.dist-info/licenses/LICENSE,sha256=-AHGWRwcvoeP4bkY1W2ok_qaI1Mg0Zx2qYgR1k-3CHY,1071
40
+ python_docs_mcp_server-0.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any