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,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
|
+
"""
|