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.
Files changed (36) hide show
  1. mcp_server_python_docs/__init__.py +17 -0
  2. mcp_server_python_docs/__main__.py +574 -0
  3. mcp_server_python_docs/app_context.py +33 -0
  4. mcp_server_python_docs/data/synonyms.yaml +194 -0
  5. mcp_server_python_docs/detection.py +82 -0
  6. mcp_server_python_docs/diagnostics.py +74 -0
  7. mcp_server_python_docs/errors.py +33 -0
  8. mcp_server_python_docs/ingestion/__init__.py +1 -0
  9. mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
  10. mcp_server_python_docs/ingestion/inventory.py +178 -0
  11. mcp_server_python_docs/ingestion/publish.py +504 -0
  12. mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
  13. mcp_server_python_docs/models.py +216 -0
  14. mcp_server_python_docs/py.typed +0 -0
  15. mcp_server_python_docs/retrieval/__init__.py +31 -0
  16. mcp_server_python_docs/retrieval/budget.py +63 -0
  17. mcp_server_python_docs/retrieval/query.py +183 -0
  18. mcp_server_python_docs/retrieval/ranker.py +369 -0
  19. mcp_server_python_docs/server.py +329 -0
  20. mcp_server_python_docs/services/__init__.py +7 -0
  21. mcp_server_python_docs/services/cache.py +111 -0
  22. mcp_server_python_docs/services/content.py +155 -0
  23. mcp_server_python_docs/services/observability.py +130 -0
  24. mcp_server_python_docs/services/package_docs.py +161 -0
  25. mcp_server_python_docs/services/persistent_cache.py +147 -0
  26. mcp_server_python_docs/services/search.py +111 -0
  27. mcp_server_python_docs/services/version.py +47 -0
  28. mcp_server_python_docs/services/version_resolution.py +79 -0
  29. mcp_server_python_docs/storage/__init__.py +1 -0
  30. mcp_server_python_docs/storage/db.py +146 -0
  31. mcp_server_python_docs/storage/schema.sql +136 -0
  32. python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
  33. python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
  34. python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
  35. python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
  36. python_docs_mcp_server-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,369 @@
1
+ """BM25 ranking with column weights and FTS5 snippets.
2
+
3
+ Search functions that execute FTS5 queries with BM25 column weighting
4
+ and snippet() excerpts. All functions return list[SymbolHit] with the
5
+ locked hit shape (RETR-09).
6
+
7
+ Receives sqlite3.Connection as parameter -- does not import storage.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import sqlite3
13
+
14
+ from mcp_server_python_docs.models import SymbolHit
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _page_uri(uri: str) -> str:
20
+ """Return the page part of an objects.inv URI."""
21
+ return uri.split("#", 1)[0]
22
+
23
+
24
+ def _document_candidates(uri: str) -> tuple[str, ...]:
25
+ """Return possible document slugs for a symbol URI.
26
+
27
+ ``objects.inv`` entries use HTML paths such as ``library/json.html``,
28
+ while Sphinx JSON content is ingested with extensionless slugs such as
29
+ ``library/json``. Prefer the exact URI first, then the extensionless form.
30
+ """
31
+ page_uri = _page_uri(uri)
32
+ if page_uri.endswith(".html"):
33
+ return (page_uri, page_uri[:-5])
34
+ return (page_uri,)
35
+
36
+
37
+ def _resolve_symbol_location(
38
+ conn: sqlite3.Connection,
39
+ row: sqlite3.Row,
40
+ ) -> tuple[str, str | None]:
41
+ """Resolve a symbol row to a get_docs-compatible slug and anchor.
42
+
43
+ Returns ``(slug, None)`` when the hit is page-level. ``get_docs()`` treats
44
+ any non-None anchor as a section lookup, so blank anchors must never leak.
45
+ """
46
+ uri = str(row["uri"])
47
+ fallback_slug = _page_uri(uri)
48
+ fallback_anchor = str(row["anchor"] or "")
49
+
50
+ section_id = row["section_id"]
51
+ if section_id is not None:
52
+ section_row = conn.execute(
53
+ """
54
+ SELECT d.slug, s.anchor
55
+ FROM sections s
56
+ JOIN documents d ON s.document_id = d.id
57
+ WHERE s.id = ?
58
+ LIMIT 1
59
+ """,
60
+ (section_id,),
61
+ ).fetchone()
62
+ if section_row is not None:
63
+ return section_row["slug"], (section_row["anchor"] or None)
64
+
65
+ doc_row = None
66
+ document_id = row["document_id"]
67
+ if document_id is not None:
68
+ doc_row = conn.execute(
69
+ "SELECT id, slug FROM documents WHERE id = ? LIMIT 1",
70
+ (document_id,),
71
+ ).fetchone()
72
+
73
+ if doc_row is None:
74
+ for candidate in _document_candidates(uri):
75
+ doc_row = conn.execute(
76
+ """
77
+ SELECT id, slug
78
+ FROM documents
79
+ WHERE doc_set_id = ? AND (slug = ? OR uri = ?)
80
+ LIMIT 1
81
+ """,
82
+ (row["doc_set_id"], candidate, candidate),
83
+ ).fetchone()
84
+ if doc_row is not None:
85
+ break
86
+
87
+ if doc_row is None:
88
+ return fallback_slug, (fallback_anchor or None)
89
+
90
+ if fallback_anchor:
91
+ section_row = conn.execute(
92
+ """
93
+ SELECT 1
94
+ FROM sections
95
+ WHERE document_id = ? AND anchor = ?
96
+ LIMIT 1
97
+ """,
98
+ (doc_row["id"], fallback_anchor),
99
+ ).fetchone()
100
+ if section_row is not None:
101
+ return doc_row["slug"], fallback_anchor
102
+
103
+ return doc_row["slug"], None
104
+
105
+
106
+ def _normalize_scores(hits: list[SymbolHit]) -> list[SymbolHit]:
107
+ """Normalize BM25 scores to [0.1, 1.0] range.
108
+
109
+ BM25 returns negative scores (lower = better). This normalizes
110
+ to a positive range where 1.0 is best match and 0.1 is worst
111
+ in the batch.
112
+ """
113
+ if not hits:
114
+ return hits
115
+ if len(hits) == 1:
116
+ # Single hit always gets score 1.0
117
+ return [hits[0].model_copy(update={"score": 1.0})]
118
+
119
+ raw_scores = [h.score for h in hits]
120
+ min_score = min(raw_scores)
121
+ max_score = max(raw_scores)
122
+
123
+ if max_score == min_score:
124
+ return [h.model_copy(update={"score": 1.0}) for h in hits]
125
+
126
+ normalized = []
127
+ for hit in hits:
128
+ # BM25 scores are negative; lower (more negative) = better
129
+ # Normalize so the best (most negative) gets 1.0
130
+ norm = 0.1 + 0.9 * (max_score - hit.score) / (max_score - min_score)
131
+ normalized.append(hit.model_copy(update={"score": round(norm, 4)}))
132
+
133
+ return normalized
134
+
135
+
136
+ def search_sections(
137
+ conn: sqlite3.Connection,
138
+ match_expr: str,
139
+ version: str | None,
140
+ max_results: int,
141
+ ) -> list[SymbolHit]:
142
+ """Search sections via FTS5 with BM25 column weights (RETR-06, RETR-07).
143
+
144
+ Column weights: heading (10.0) > content_text (1.0).
145
+ Returns ~200-char FTS5 snippet() excerpts.
146
+
147
+ Args:
148
+ conn: Read-only SQLite connection.
149
+ match_expr: FTS5-safe MATCH expression (from fts5_escape or
150
+ build_match_expression).
151
+ version: Python version filter, or None for all versions.
152
+ max_results: Maximum number of hits.
153
+
154
+ Returns:
155
+ List of SymbolHit with kind="section" and FTS5 snippets.
156
+ """
157
+ try:
158
+ cursor = conn.execute(
159
+ """
160
+ SELECT s.id, s.heading, s.uri, s.anchor,
161
+ d.version, doc.slug,
162
+ bm25(sections_fts, 10.0, 1.0) as score,
163
+ snippet(sections_fts, 1, '**', '**', '...', 32) as snippet_text
164
+ FROM sections_fts
165
+ JOIN sections s ON sections_fts.rowid = s.id
166
+ JOIN documents doc ON s.document_id = doc.id
167
+ JOIN doc_sets d ON doc.doc_set_id = d.id
168
+ WHERE sections_fts MATCH ?
169
+ AND (? IS NULL OR d.version = ?)
170
+ ORDER BY bm25(sections_fts, 10.0, 1.0)
171
+ LIMIT ?
172
+ """,
173
+ (match_expr, version, version, max_results),
174
+ )
175
+ rows = cursor.fetchall()
176
+ except sqlite3.OperationalError:
177
+ logger.warning("FTS5 query failed for sections: %r", match_expr)
178
+ return []
179
+
180
+ hits = [
181
+ SymbolHit(
182
+ uri=row["uri"],
183
+ title=row["heading"],
184
+ kind="section",
185
+ snippet=row["snippet_text"] or "",
186
+ score=row["score"],
187
+ version=row["version"],
188
+ slug=row["slug"],
189
+ anchor=row["anchor"] or None,
190
+ )
191
+ for row in rows
192
+ ]
193
+
194
+ return _normalize_scores(hits)
195
+
196
+
197
+ def search_symbols(
198
+ conn: sqlite3.Connection,
199
+ match_expr: str,
200
+ version: str | None,
201
+ max_results: int,
202
+ ) -> list[SymbolHit]:
203
+ """Search symbols via FTS5 with BM25 column weights (RETR-06).
204
+
205
+ Column weights: qualified_name (10.0) > module (1.0).
206
+
207
+ Args:
208
+ conn: Read-only SQLite connection.
209
+ match_expr: FTS5-safe MATCH expression.
210
+ version: Python version filter, or None for all versions.
211
+ max_results: Maximum number of hits.
212
+
213
+ Returns:
214
+ List of SymbolHit with kind from symbol_type.
215
+ """
216
+ try:
217
+ cursor = conn.execute(
218
+ """
219
+ SELECT sym.id, sym.doc_set_id, sym.document_id, sym.section_id,
220
+ sym.qualified_name, sym.symbol_type, sym.uri,
221
+ sym.anchor, sym.module, d.version,
222
+ bm25(symbols_fts, 10.0, 1.0) as score,
223
+ snippet(symbols_fts, 0, '**', '**', '...', 32) as snippet_text
224
+ FROM symbols_fts
225
+ JOIN symbols sym ON symbols_fts.rowid = sym.id
226
+ JOIN doc_sets d ON sym.doc_set_id = d.id
227
+ WHERE symbols_fts MATCH ?
228
+ AND (? IS NULL OR d.version = ?)
229
+ ORDER BY bm25(symbols_fts, 10.0, 1.0)
230
+ LIMIT ?
231
+ """,
232
+ (match_expr, version, version, max_results),
233
+ )
234
+ rows = cursor.fetchall()
235
+ except sqlite3.OperationalError:
236
+ logger.warning("FTS5 query failed for symbols: %r", match_expr)
237
+ return []
238
+
239
+ hits = []
240
+ for row in rows:
241
+ slug, anchor = _resolve_symbol_location(conn, row)
242
+ hits.append(SymbolHit(
243
+ uri=row["uri"],
244
+ title=row["qualified_name"],
245
+ kind=row["symbol_type"] or "symbol",
246
+ snippet=row["snippet_text"] or "",
247
+ score=row["score"],
248
+ version=row["version"],
249
+ slug=slug,
250
+ anchor=anchor,
251
+ ))
252
+
253
+ return _normalize_scores(hits)
254
+
255
+
256
+ def search_examples(
257
+ conn: sqlite3.Connection,
258
+ match_expr: str,
259
+ version: str | None,
260
+ max_results: int,
261
+ ) -> list[SymbolHit]:
262
+ """Search examples via FTS5 (RETR-06).
263
+
264
+ Args:
265
+ conn: Read-only SQLite connection.
266
+ match_expr: FTS5-safe MATCH expression.
267
+ version: Python version filter, or None for all versions.
268
+ max_results: Maximum number of hits.
269
+
270
+ Returns:
271
+ List of SymbolHit with kind="example" or "doctest".
272
+ """
273
+ try:
274
+ cursor = conn.execute(
275
+ """
276
+ SELECT e.id, e.code, e.is_doctest,
277
+ s.heading, s.uri as section_uri, s.anchor,
278
+ d.version, doc.slug,
279
+ bm25(examples_fts) as score,
280
+ snippet(examples_fts, 0, '**', '**', '...', 32) as snippet_text
281
+ FROM examples_fts
282
+ JOIN examples e ON examples_fts.rowid = e.id
283
+ JOIN sections s ON e.section_id = s.id
284
+ JOIN documents doc ON s.document_id = doc.id
285
+ JOIN doc_sets d ON doc.doc_set_id = d.id
286
+ WHERE examples_fts MATCH ?
287
+ AND (? IS NULL OR d.version = ?)
288
+ ORDER BY bm25(examples_fts)
289
+ LIMIT ?
290
+ """,
291
+ (match_expr, version, version, max_results),
292
+ )
293
+ rows = cursor.fetchall()
294
+ except sqlite3.OperationalError:
295
+ logger.warning("FTS5 query failed for examples: %r", match_expr)
296
+ return []
297
+
298
+ hits = [
299
+ SymbolHit(
300
+ uri=row["section_uri"],
301
+ title=row["heading"],
302
+ kind="doctest" if row["is_doctest"] else "example",
303
+ snippet=row["snippet_text"] or "",
304
+ score=row["score"],
305
+ version=row["version"],
306
+ slug=row["slug"],
307
+ anchor=row["anchor"] or None,
308
+ )
309
+ for row in rows
310
+ ]
311
+
312
+ return _normalize_scores(hits)
313
+
314
+
315
+ def lookup_symbols_exact(
316
+ conn: sqlite3.Connection,
317
+ query: str,
318
+ version: str | None,
319
+ max_results: int,
320
+ ) -> list[SymbolHit]:
321
+ """Direct symbol table lookup for the symbol fast-path (RETR-04).
322
+
323
+ Bypasses FTS5 entirely. Uses exact match first, then LIKE prefix
324
+ match. Scores: exact match = 1.0, prefix match = 0.8.
325
+
326
+ Args:
327
+ conn: Read-only SQLite connection.
328
+ query: Symbol name to look up.
329
+ version: Python version filter, or None for all versions.
330
+ max_results: Maximum number of hits.
331
+
332
+ Returns:
333
+ List of SymbolHit with kind from symbol_type.
334
+ """
335
+ # Escape LIKE wildcards in user input
336
+ escaped_query = (
337
+ query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
338
+ )
339
+
340
+ cursor = conn.execute(
341
+ """
342
+ SELECT s.doc_set_id, s.document_id, s.section_id,
343
+ s.qualified_name, s.symbol_type, s.uri, s.anchor,
344
+ s.module, d.version
345
+ FROM symbols s
346
+ JOIN doc_sets d ON s.doc_set_id = d.id
347
+ WHERE (s.qualified_name = ? OR s.qualified_name LIKE ? ESCAPE '\\')
348
+ AND (? IS NULL OR d.version = ?)
349
+ ORDER BY CASE WHEN s.qualified_name = ? THEN 0 ELSE 1 END
350
+ LIMIT ?
351
+ """,
352
+ (query, f"%{escaped_query}%", version, version, query, max_results),
353
+ )
354
+ rows = cursor.fetchall()
355
+
356
+ hits = []
357
+ for row in rows:
358
+ slug, anchor = _resolve_symbol_location(conn, row)
359
+ hits.append(SymbolHit(
360
+ uri=row["uri"],
361
+ title=row["qualified_name"],
362
+ kind=row["symbol_type"] or "symbol",
363
+ snippet="",
364
+ score=1.0 if row["qualified_name"] == query else 0.8,
365
+ version=row["version"],
366
+ slug=slug,
367
+ anchor=anchor,
368
+ ))
369
+ return hits
@@ -0,0 +1,329 @@
1
+ """FastMCP server with lifespan DI and tool registration.
2
+
3
+ Thin server layer — delegates all tool logic to services.
4
+ Dependency rule: server -> services -> retrieval/storage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import importlib.resources
11
+ import logging
12
+ import sqlite3
13
+ import sys
14
+ import traceback
15
+ from collections.abc import AsyncIterator
16
+ from contextlib import asynccontextmanager
17
+ from typing import Annotated, Literal
18
+
19
+ import yaml
20
+ from mcp.server.fastmcp import Context, FastMCP
21
+ from mcp.server.fastmcp.exceptions import ToolError
22
+ from mcp.types import ToolAnnotations
23
+ from pydantic import Field
24
+
25
+ from mcp_server_python_docs.app_context import AppContext
26
+ from mcp_server_python_docs.detection import detect_python_version, match_to_indexed
27
+ from mcp_server_python_docs.errors import DocsServerError
28
+ from mcp_server_python_docs.models import (
29
+ DetectPythonVersionResult,
30
+ GetDocsResult,
31
+ ListVersionsResult,
32
+ PackageDocsResult,
33
+ SearchDocsResult,
34
+ )
35
+ from mcp_server_python_docs.services.content import ContentService
36
+ from mcp_server_python_docs.services.package_docs import PackageDocsService
37
+ from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
38
+ from mcp_server_python_docs.services.search import SearchService
39
+ from mcp_server_python_docs.services.version import VersionService
40
+ from mcp_server_python_docs.storage.db import (
41
+ get_cache_dir,
42
+ get_index_path,
43
+ get_readonly_connection,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def _load_synonyms() -> dict[str, list[str]]:
50
+ """Load synonyms.yaml from package data via importlib.resources (SRVR-12)."""
51
+ ref = importlib.resources.files("mcp_server_python_docs") / "data" / "synonyms.yaml"
52
+ with importlib.resources.as_file(ref) as path:
53
+ data = yaml.safe_load(path.read_text())
54
+ return {k: v for k, v in data.items() if isinstance(v, list)}
55
+
56
+
57
+ def _assert_fts5(conn: sqlite3.Connection) -> None:
58
+ """Check FTS5 availability with platform-aware error (STOR-08)."""
59
+ from mcp_server_python_docs.storage.db import assert_fts5_available
60
+
61
+ assert_fts5_available(conn)
62
+
63
+
64
+ @asynccontextmanager
65
+ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
66
+ """Manage application lifecycle with typed context (SRVR-01).
67
+
68
+ Loads synonyms eagerly (SRVR-11), opens read-only DB handle (STOR-06),
69
+ constructs service instances, and fails fast on missing index or
70
+ unavailable FTS5.
71
+ """
72
+ cache_dir = get_cache_dir()
73
+ index_path = get_index_path()
74
+
75
+ # Fail fast on missing index (SRVR-10)
76
+ if not index_path.exists():
77
+ from mcp_server_python_docs.ingestion.cpython_versions import (
78
+ SUPPORTED_DOC_VERSIONS_CSV,
79
+ )
80
+
81
+ msg = (
82
+ f"No index found at {index_path}\n"
83
+ f"Run: python-docs-mcp-server build-index --versions "
84
+ f"{SUPPORTED_DOC_VERSIONS_CSV}"
85
+ )
86
+ logger.error(msg)
87
+ print(msg, file=sys.stderr)
88
+ raise SystemExit(1)
89
+
90
+ # Load synonyms from package data (SRVR-11, SRVR-12)
91
+ synonyms = _load_synonyms()
92
+ logger.info("Loaded %d synonym entries", len(synonyms))
93
+
94
+ # Open read-only connection (STOR-06, STOR-07)
95
+ db = get_readonly_connection(index_path)
96
+ persistent_docs_cache: PersistentDocsCache | None = None
97
+
98
+ try:
99
+ # Check FTS5 (STOR-08)
100
+ _assert_fts5(db)
101
+
102
+ # Construct service instances (Phase 5 — service layer wiring)
103
+ persistent_docs_cache = PersistentDocsCache(
104
+ cache_path=cache_dir / "retrieved-docs-cache.sqlite3",
105
+ index_path=index_path,
106
+ )
107
+ search_svc = SearchService(db, synonyms)
108
+ content_svc = ContentService(db, persistent_cache=persistent_docs_cache)
109
+ version_svc = VersionService(db)
110
+ package_docs_svc = PackageDocsService()
111
+
112
+ # Detect user's Python version and match to indexed versions
113
+ detected_ver, detected_src = detect_python_version()
114
+ indexed_versions = [
115
+ r[0] for r in db.execute("SELECT version FROM doc_sets ORDER BY version").fetchall()
116
+ ]
117
+ matched = match_to_indexed(detected_ver, indexed_versions)
118
+ if matched:
119
+ logger.info("User Python %s matches indexed version — using as default", matched)
120
+ else:
121
+ logger.info(
122
+ "User Python %s not in index %s — using normal default",
123
+ detected_ver,
124
+ indexed_versions,
125
+ )
126
+
127
+ try:
128
+ yield AppContext(
129
+ db=db,
130
+ index_path=index_path,
131
+ synonyms=synonyms,
132
+ search_service=search_svc,
133
+ content_service=content_svc,
134
+ version_service=version_svc,
135
+ package_docs_service=package_docs_svc,
136
+ persistent_docs_cache=persistent_docs_cache,
137
+ detected_python_version=matched,
138
+ detected_python_source=detected_src,
139
+ )
140
+ except Exception:
141
+ # HYGN-05: log lifespan errors, write last-error.log, re-raise original
142
+ error_msg = traceback.format_exc()
143
+ logger.error("Lifespan error: %s", error_msg)
144
+ try:
145
+ error_log = cache_dir / "last-error.log"
146
+ error_log.write_text(error_msg)
147
+ except Exception:
148
+ pass
149
+ raise
150
+ finally:
151
+ if persistent_docs_cache is not None:
152
+ try:
153
+ persistent_docs_cache.close()
154
+ except Exception:
155
+ pass
156
+ db.close()
157
+
158
+
159
+ # Shared tool annotations — all tools are read-only (SRVR-02, SRVR-03, SRVR-04)
160
+ _TOOL_ANNOTATIONS = ToolAnnotations(
161
+ readOnlyHint=True,
162
+ destructiveHint=False,
163
+ idempotentHint=True,
164
+ openWorldHint=False,
165
+ )
166
+ _PYPI_TOOL_ANNOTATIONS = ToolAnnotations(
167
+ readOnlyHint=True,
168
+ destructiveHint=False,
169
+ idempotentHint=True,
170
+ openWorldHint=True,
171
+ )
172
+
173
+ SearchQueryParam = Annotated[
174
+ str,
175
+ Field(
176
+ max_length=500,
177
+ description="Search query - Python symbol (asyncio.TaskGroup) or concept (parse json)",
178
+ ),
179
+ ]
180
+ VersionParam = Annotated[
181
+ str | None,
182
+ Field(description="Python version (e.g. '3.13'). Defaults to latest."),
183
+ ]
184
+ SearchKindParam = Annotated[
185
+ Literal["auto", "page", "symbol", "section", "example"],
186
+ Field(
187
+ description=(
188
+ "Search type. Use 'symbol' for API lookups, "
189
+ "'example' for code samples, 'auto' otherwise."
190
+ )
191
+ ),
192
+ ]
193
+ MaxResultsParam = Annotated[
194
+ int,
195
+ Field(ge=1, le=20, description="Maximum number of results to return."),
196
+ ]
197
+ SlugParam = Annotated[
198
+ str,
199
+ Field(max_length=500, description="Page slug (e.g. 'library/asyncio-task.html')"),
200
+ ]
201
+ AnchorParam = Annotated[
202
+ str | None,
203
+ Field(description="Section anchor for section-only retrieval"),
204
+ ]
205
+ MaxCharsParam = Annotated[
206
+ int,
207
+ Field(ge=100, le=50000, description="Maximum characters to return"),
208
+ ]
209
+ StartIndexParam = Annotated[
210
+ int,
211
+ Field(ge=0, description="Start position for pagination"),
212
+ ]
213
+ PackageParam = Annotated[
214
+ str,
215
+ Field(min_length=1, max_length=214, description="PyPI package/project name"),
216
+ ]
217
+
218
+
219
+ def create_server() -> FastMCP:
220
+ """Create and configure the FastMCP server."""
221
+ mcp = FastMCP(
222
+ "python-docs-mcp-server",
223
+ lifespan=app_lifespan,
224
+ )
225
+
226
+ @mcp.tool(annotations=_TOOL_ANNOTATIONS)
227
+ def search_docs(
228
+ query: SearchQueryParam,
229
+ version: VersionParam = None,
230
+ kind: SearchKindParam = "auto",
231
+ max_results: MaxResultsParam = 5,
232
+ ctx: Context = None, # type: ignore[assignment]
233
+ ) -> SearchDocsResult:
234
+ """Search Python documentation. Use kind='symbol' for API lookups
235
+ (asyncio.TaskGroup), kind='example' for code samples, kind='auto' otherwise.
236
+ When version is omitted, searches across all versions. Pass the version
237
+ from each hit's version field to get_docs for consistent results."""
238
+ app_ctx: AppContext = ctx.request_context.lifespan_context
239
+ try:
240
+ return app_ctx.search_service.search(query, version, kind, max_results)
241
+ except DocsServerError as e:
242
+ raise ToolError(str(e))
243
+ except Exception as e:
244
+ logger.exception("Unexpected error in search_docs")
245
+ raise ToolError(f"Internal error: {type(e).__name__}")
246
+
247
+ @mcp.tool(annotations=_TOOL_ANNOTATIONS)
248
+ def get_docs(
249
+ slug: SlugParam,
250
+ version: VersionParam = None,
251
+ anchor: AnchorParam = None,
252
+ max_chars: MaxCharsParam = 8000,
253
+ start_index: StartIndexParam = 0,
254
+ ctx: Context = None, # type: ignore[assignment]
255
+ ) -> GetDocsResult:
256
+ """Retrieve a documentation page or specific section. Provide anchor for
257
+ section-only retrieval (much cheaper). Pagination via start_index."""
258
+ app_ctx: AppContext = ctx.request_context.lifespan_context
259
+ # Auto-default to detected Python version when no version specified
260
+ if version is None and app_ctx.detected_python_version:
261
+ version = app_ctx.detected_python_version
262
+ try:
263
+ return app_ctx.content_service.get_docs(slug, version, anchor, max_chars, start_index)
264
+ except DocsServerError as e:
265
+ raise ToolError(str(e))
266
+ except Exception as e:
267
+ logger.exception("Unexpected error in get_docs")
268
+ raise ToolError(f"Internal error: {type(e).__name__}")
269
+
270
+ @mcp.tool(annotations=_PYPI_TOOL_ANNOTATIONS)
271
+ async def lookup_package_docs(
272
+ package: PackageParam,
273
+ ctx: Context = None, # type: ignore[assignment]
274
+ ) -> PackageDocsResult:
275
+ """Look up package-declared docs/homepage/source URLs via official PyPI metadata.
276
+
277
+ This is not generic web search: it only queries PyPI's JSON API and
278
+ returns official PyPI metadata plus package-declared project URLs.
279
+ """
280
+ app_ctx: AppContext = ctx.request_context.lifespan_context
281
+ try:
282
+ return await asyncio.to_thread(app_ctx.package_docs_service.lookup, package)
283
+ except Exception as e:
284
+ logger.exception("Unexpected error in lookup_package_docs")
285
+ raise ToolError(f"Internal error: {type(e).__name__}")
286
+
287
+ @mcp.tool(annotations=_TOOL_ANNOTATIONS)
288
+ def list_versions(
289
+ ctx: Context = None, # type: ignore[assignment]
290
+ ) -> ListVersionsResult:
291
+ """List Python documentation versions available in this index."""
292
+ app_ctx: AppContext = ctx.request_context.lifespan_context
293
+ try:
294
+ return app_ctx.version_service.list_versions()
295
+ except DocsServerError as e:
296
+ raise ToolError(str(e))
297
+ except Exception as e:
298
+ logger.exception("Unexpected error in list_versions")
299
+ raise ToolError(f"Internal error: {type(e).__name__}")
300
+
301
+ @mcp.tool(annotations=_TOOL_ANNOTATIONS)
302
+ def detect_python_version(
303
+ ctx: Context = None, # type: ignore[assignment]
304
+ ) -> DetectPythonVersionResult:
305
+ """Detect the Python version in the user's environment.
306
+ Returns the detected version, how it was found, and whether it
307
+ matches an indexed documentation set."""
308
+ app_ctx: AppContext = ctx.request_context.lifespan_context
309
+ detected_ver = app_ctx.detected_python_version
310
+
311
+ # Re-run detection to get the raw version even if it didn't match
312
+ from mcp_server_python_docs.detection import detect_python_version as _detect
313
+
314
+ raw_ver, raw_src = _detect()
315
+
316
+ return DetectPythonVersionResult(
317
+ detected_version=raw_ver,
318
+ source=raw_src,
319
+ matched_index_version=detected_ver,
320
+ is_default=detected_ver is not None,
321
+ )
322
+
323
+ # SRVR-07: _meta hint for get_docs tool.
324
+ # FastMCP 1.27 does not expose a public API for setting _meta on tool
325
+ # definitions. Deferred until the mcp SDK adds _meta support to the
326
+ # decorator API or tool manager. The hint is advisory — clients work
327
+ # correctly without it.
328
+
329
+ return mcp
@@ -0,0 +1,7 @@
1
+ """Service layer — SearchService, ContentService, VersionService.
2
+
3
+ Services sit between FastMCP tool handlers and the retrieval/storage layers.
4
+ Dependency rule: server -> services -> retrieval/storage.
5
+ Services receive sqlite3.Connection via constructor and execute queries directly.
6
+ No service imports MCP types.
7
+ """