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,17 @@
|
|
|
1
|
+
"""MCP server for Python standard library documentation."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _read_version() -> str:
|
|
9
|
+
try:
|
|
10
|
+
return version("python-docs-mcp-server")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
13
|
+
with pyproject_path.open("rb") as fh:
|
|
14
|
+
return tomllib.load(fh)["project"]["version"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__version__ = _read_version()
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""MCP server for Python standard library documentation.
|
|
2
|
+
|
|
3
|
+
Entry point with stdio hygiene. The order of operations is load-bearing:
|
|
4
|
+
1. Save real stdout fd, redirect fd 1 to stderr (HYGN-01, B3 blocker)
|
|
5
|
+
2. Install SIGPIPE handler (HYGN-03)
|
|
6
|
+
3. Configure logging to stderr (HYGN-02)
|
|
7
|
+
4. Import everything else
|
|
8
|
+
"""
|
|
9
|
+
# === STDIO HYGIENE (HYGN-01, B3 blocker) ===
|
|
10
|
+
# These MUST be the first imports and operations, before anything
|
|
11
|
+
# that might write to stdout.
|
|
12
|
+
import atexit
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
# Save the real stdout fd for the MCP framer, then redirect fd 1 to stderr.
|
|
18
|
+
# After this, any print() or write to fd 1 goes to stderr, not the MCP pipe.
|
|
19
|
+
_saved_stdout_fd: int | None = os.dup(1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _close_saved_stdout_fd() -> None:
|
|
23
|
+
"""Close the saved stdout fd when the CLI exits without serving."""
|
|
24
|
+
global _saved_stdout_fd
|
|
25
|
+
if _saved_stdout_fd is None:
|
|
26
|
+
return
|
|
27
|
+
try:
|
|
28
|
+
os.close(_saved_stdout_fd)
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
finally:
|
|
32
|
+
_saved_stdout_fd = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _consume_saved_stdout_fd() -> int:
|
|
36
|
+
"""Hand off the saved stdout fd to the stdio MCP transport."""
|
|
37
|
+
global _saved_stdout_fd
|
|
38
|
+
if _saved_stdout_fd is None:
|
|
39
|
+
raise RuntimeError("Saved stdout fd is not available")
|
|
40
|
+
fd = _saved_stdout_fd
|
|
41
|
+
_saved_stdout_fd = None
|
|
42
|
+
return fd
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
atexit.register(_close_saved_stdout_fd)
|
|
46
|
+
os.dup2(2, 1)
|
|
47
|
+
sys.stdout = sys.stderr
|
|
48
|
+
|
|
49
|
+
# === SIGPIPE HANDLER (HYGN-03) ===
|
|
50
|
+
# Ignore SIGPIPE so client disconnect doesn't crash with BrokenPipeError.
|
|
51
|
+
# Windows does not expose SIGPIPE.
|
|
52
|
+
sigpipe = getattr(signal, "SIGPIPE", None)
|
|
53
|
+
if sigpipe is not None:
|
|
54
|
+
signal.signal(sigpipe, signal.SIG_IGN)
|
|
55
|
+
|
|
56
|
+
# === LOGGING TO STDERR (HYGN-02) ===
|
|
57
|
+
import logging # noqa: E402
|
|
58
|
+
|
|
59
|
+
logging.basicConfig(
|
|
60
|
+
stream=sys.stderr,
|
|
61
|
+
level=logging.INFO,
|
|
62
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
63
|
+
)
|
|
64
|
+
logger = logging.getLogger("mcp_server_python_docs")
|
|
65
|
+
|
|
66
|
+
# === Now safe to import everything else ===
|
|
67
|
+
import click # noqa: E402
|
|
68
|
+
|
|
69
|
+
from mcp_server_python_docs.ingestion.cpython_versions import ( # noqa: E402
|
|
70
|
+
SUPPORTED_DOC_VERSIONS_CSV,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@click.group(invoke_without_command=True)
|
|
75
|
+
@click.option("--version", "show_version", is_flag=True, help="Show version and exit.")
|
|
76
|
+
@click.pass_context
|
|
77
|
+
def main(ctx: click.Context, show_version: bool) -> None:
|
|
78
|
+
"""MCP server for Python standard library documentation."""
|
|
79
|
+
if show_version:
|
|
80
|
+
from mcp_server_python_docs import __version__
|
|
81
|
+
|
|
82
|
+
click.echo(f"python-docs-mcp-server {__version__}", err=True)
|
|
83
|
+
ctx.exit(0)
|
|
84
|
+
if ctx.invoked_subcommand is None:
|
|
85
|
+
ctx.invoke(serve)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@main.command()
|
|
89
|
+
def serve() -> None:
|
|
90
|
+
"""Start the MCP server (default command)."""
|
|
91
|
+
from mcp_server_python_docs.server import create_server
|
|
92
|
+
|
|
93
|
+
mcp_server = create_server()
|
|
94
|
+
saved_stdout_fd = _consume_saved_stdout_fd()
|
|
95
|
+
|
|
96
|
+
# Restore the real stdout fd for MCP protocol framing.
|
|
97
|
+
# By this point all imports are done — no third-party code will
|
|
98
|
+
# print to stdout during MCP communication.
|
|
99
|
+
os.dup2(saved_stdout_fd, 1)
|
|
100
|
+
os.close(saved_stdout_fd)
|
|
101
|
+
# FastMCP writes JSON-RPC frames through Python's stdout object.
|
|
102
|
+
# Restore it as well as fd 1, otherwise protocol frames are emitted
|
|
103
|
+
# on stderr because module import redirected sys.stdout there.
|
|
104
|
+
sys.stdout = sys.__stdout__
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
mcp_server.run(transport="stdio")
|
|
108
|
+
except BrokenPipeError:
|
|
109
|
+
pass # Client disconnected (HYGN-03)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@main.command("build-index")
|
|
113
|
+
@click.option(
|
|
114
|
+
"--versions",
|
|
115
|
+
required=True,
|
|
116
|
+
help=f"Comma-separated Python versions (e.g., {SUPPORTED_DOC_VERSIONS_CSV})",
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--skip-content",
|
|
120
|
+
is_flag=True,
|
|
121
|
+
help="Skip Sphinx JSON build and publish a symbol-only index (search_docs only).",
|
|
122
|
+
)
|
|
123
|
+
def build_index(versions: str, skip_content: bool) -> None:
|
|
124
|
+
"""Build the documentation index from objects.inv and Sphinx JSON."""
|
|
125
|
+
import shutil
|
|
126
|
+
import subprocess
|
|
127
|
+
import tempfile
|
|
128
|
+
import venv
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
|
|
131
|
+
from mcp_server_python_docs.ingestion.cpython_versions import (
|
|
132
|
+
CPYTHON_DOCS_BUILD_CONFIG,
|
|
133
|
+
)
|
|
134
|
+
from mcp_server_python_docs.ingestion.inventory import ingest_inventory
|
|
135
|
+
from mcp_server_python_docs.ingestion.publish import (
|
|
136
|
+
_version_sort_key,
|
|
137
|
+
generate_build_path,
|
|
138
|
+
parse_expected_versions,
|
|
139
|
+
publish_index,
|
|
140
|
+
)
|
|
141
|
+
from mcp_server_python_docs.ingestion.sphinx_json import (
|
|
142
|
+
build_sphinx_bootstrap_requirements,
|
|
143
|
+
build_sphinx_json_command,
|
|
144
|
+
ingest_sphinx_json_dir,
|
|
145
|
+
make_sphinx_json_env,
|
|
146
|
+
populate_synonyms,
|
|
147
|
+
rebuild_fts_indexes,
|
|
148
|
+
write_json_build_requirements,
|
|
149
|
+
write_sphinx_json_sitecustomize,
|
|
150
|
+
)
|
|
151
|
+
from mcp_server_python_docs.storage.db import (
|
|
152
|
+
assert_fts5_available,
|
|
153
|
+
bootstrap_schema,
|
|
154
|
+
get_readwrite_connection,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
version_list = parse_expected_versions(versions)
|
|
158
|
+
if not version_list:
|
|
159
|
+
logger.error(
|
|
160
|
+
"No valid versions specified. Example: --versions %s",
|
|
161
|
+
SUPPORTED_DOC_VERSIONS_CSV,
|
|
162
|
+
)
|
|
163
|
+
raise SystemExit(1)
|
|
164
|
+
|
|
165
|
+
# Validate version format before sorting (CR-03, WR-04)
|
|
166
|
+
for v in version_list:
|
|
167
|
+
parts = v.split(".")
|
|
168
|
+
if len(parts) != 2 or not all(p.isdigit() for p in parts):
|
|
169
|
+
logger.error(
|
|
170
|
+
"Invalid version format %r. Expected 'X.Y' (e.g., 3.13)", v
|
|
171
|
+
)
|
|
172
|
+
raise SystemExit(1)
|
|
173
|
+
|
|
174
|
+
# Determine default version: highest version number (MVER-02)
|
|
175
|
+
default_version = max(version_list, key=_version_sort_key)
|
|
176
|
+
|
|
177
|
+
# Build into a timestamped artifact, not directly to index.db (PUBL-01)
|
|
178
|
+
build_db_path = generate_build_path()
|
|
179
|
+
logger.info("Building index at %s", build_db_path)
|
|
180
|
+
|
|
181
|
+
conn = get_readwrite_connection(build_db_path)
|
|
182
|
+
try:
|
|
183
|
+
bootstrap_schema(conn)
|
|
184
|
+
assert_fts5_available(conn)
|
|
185
|
+
|
|
186
|
+
any_version_succeeded = False
|
|
187
|
+
|
|
188
|
+
for version in version_list:
|
|
189
|
+
try:
|
|
190
|
+
# === Objects.inv ingestion (existing — INGR-I-*) ===
|
|
191
|
+
logger.info("Ingesting objects.inv for Python %s...", version)
|
|
192
|
+
count = ingest_inventory(conn, version, is_default=(version == default_version))
|
|
193
|
+
logger.info("Ingested %d symbols for Python %s", count, version)
|
|
194
|
+
|
|
195
|
+
if skip_content:
|
|
196
|
+
any_version_succeeded = True
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# === Content ingestion (INGR-C-01 through INGR-C-03) ===
|
|
200
|
+
config = CPYTHON_DOCS_BUILD_CONFIG.get(version)
|
|
201
|
+
if not config:
|
|
202
|
+
logger.warning(
|
|
203
|
+
"No CPython build config for %s, skipping content ingestion",
|
|
204
|
+
version,
|
|
205
|
+
)
|
|
206
|
+
any_version_succeeded = True
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Clone CPython source at pinned tag (INGR-C-01)
|
|
210
|
+
clone_dir = tempfile.mkdtemp(prefix=f"cpython-{version}-")
|
|
211
|
+
try:
|
|
212
|
+
logger.info(
|
|
213
|
+
"Cloning CPython %s into %s...", config["tag"], clone_dir
|
|
214
|
+
)
|
|
215
|
+
subprocess.run(
|
|
216
|
+
[
|
|
217
|
+
"git", "clone", "--depth", "1",
|
|
218
|
+
"--branch", config["tag"],
|
|
219
|
+
"https://github.com/python/cpython.git",
|
|
220
|
+
clone_dir,
|
|
221
|
+
],
|
|
222
|
+
check=True,
|
|
223
|
+
capture_output=True,
|
|
224
|
+
text=True,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Create dedicated Sphinx venv (INGR-C-02)
|
|
228
|
+
venv_dir = os.path.join(clone_dir, "_sphinx_venv")
|
|
229
|
+
logger.info("Creating Sphinx venv at %s...", venv_dir)
|
|
230
|
+
venv.create(venv_dir, with_pip=True)
|
|
231
|
+
# Use Scripts/ on Windows, bin/ elsewhere
|
|
232
|
+
scripts_dir = os.path.join(
|
|
233
|
+
venv_dir,
|
|
234
|
+
"Scripts" if sys.platform == "win32" else "bin",
|
|
235
|
+
)
|
|
236
|
+
pip_path = os.path.join(scripts_dir, "pip")
|
|
237
|
+
|
|
238
|
+
# Install Sphinx with the version pin for this CPython branch.
|
|
239
|
+
bootstrap_requirements = build_sphinx_bootstrap_requirements(
|
|
240
|
+
config["sphinx_pin"]
|
|
241
|
+
)
|
|
242
|
+
if len(bootstrap_requirements) > 1:
|
|
243
|
+
logger.info(
|
|
244
|
+
"Installing Sphinx bootstrap packages for Python %s: %s",
|
|
245
|
+
version,
|
|
246
|
+
", ".join(bootstrap_requirements[:-1]),
|
|
247
|
+
)
|
|
248
|
+
subprocess.run(
|
|
249
|
+
[
|
|
250
|
+
pip_path,
|
|
251
|
+
"install",
|
|
252
|
+
*bootstrap_requirements,
|
|
253
|
+
],
|
|
254
|
+
check=True,
|
|
255
|
+
capture_output=True,
|
|
256
|
+
text=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Install remaining Doc/requirements.txt deps
|
|
260
|
+
doc_reqs = Path(clone_dir) / "Doc" / "requirements.txt"
|
|
261
|
+
if doc_reqs.exists():
|
|
262
|
+
json_doc_reqs = doc_reqs.with_name(
|
|
263
|
+
"_json-build-requirements.txt"
|
|
264
|
+
)
|
|
265
|
+
omitted_reqs = write_json_build_requirements(
|
|
266
|
+
doc_reqs, json_doc_reqs
|
|
267
|
+
)
|
|
268
|
+
if omitted_reqs:
|
|
269
|
+
logger.info(
|
|
270
|
+
"Omitted HTML-only Sphinx extensions for JSON build: %s",
|
|
271
|
+
", ".join(omitted_reqs),
|
|
272
|
+
)
|
|
273
|
+
subprocess.run(
|
|
274
|
+
[pip_path, "install", "-r", str(json_doc_reqs)],
|
|
275
|
+
check=True,
|
|
276
|
+
capture_output=True,
|
|
277
|
+
text=True,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Run sphinx-build -b json directly (INGR-C-03)
|
|
281
|
+
# Never use 'make json' — that target does not exist
|
|
282
|
+
sphinx_build = os.path.join(scripts_dir, "sphinx-build")
|
|
283
|
+
doc_dir = os.path.join(clone_dir, "Doc")
|
|
284
|
+
json_out = os.path.join(doc_dir, "build", "json")
|
|
285
|
+
sphinx_compat_dir = Path(clone_dir) / "_sphinx_json_compat"
|
|
286
|
+
write_sphinx_json_sitecustomize(sphinx_compat_dir)
|
|
287
|
+
sphinx_env = make_sphinx_json_env(sphinx_compat_dir)
|
|
288
|
+
|
|
289
|
+
logger.info(
|
|
290
|
+
"Running sphinx-build -b json for Python %s "
|
|
291
|
+
"(this may take 3-8 minutes)...",
|
|
292
|
+
version,
|
|
293
|
+
)
|
|
294
|
+
result = subprocess.run(
|
|
295
|
+
build_sphinx_json_command(sphinx_build, doc_dir, json_out),
|
|
296
|
+
capture_output=True,
|
|
297
|
+
text=True,
|
|
298
|
+
cwd=doc_dir,
|
|
299
|
+
env=sphinx_env,
|
|
300
|
+
)
|
|
301
|
+
if result.returncode != 0:
|
|
302
|
+
logger.error(
|
|
303
|
+
"sphinx-build failed for %s:\n%s",
|
|
304
|
+
version,
|
|
305
|
+
result.stderr[-2000:] if result.stderr else "(no output)",
|
|
306
|
+
)
|
|
307
|
+
logger.warning(
|
|
308
|
+
"Version %s has SYMBOLS ONLY (sphinx-build failed). "
|
|
309
|
+
"search_docs will work but get_docs will fail until content "
|
|
310
|
+
"ingestion succeeds.",
|
|
311
|
+
version,
|
|
312
|
+
)
|
|
313
|
+
any_version_succeeded = True
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
logger.info("sphinx-build complete for Python %s", version)
|
|
317
|
+
|
|
318
|
+
# Get doc_set_id for this version
|
|
319
|
+
row = conn.execute(
|
|
320
|
+
"SELECT id FROM doc_sets "
|
|
321
|
+
"WHERE source='python-docs' AND version=? AND language='en'",
|
|
322
|
+
(version,),
|
|
323
|
+
).fetchone()
|
|
324
|
+
if row is None:
|
|
325
|
+
logger.error("No doc_set found for version %s", version)
|
|
326
|
+
continue
|
|
327
|
+
doc_set_id = row[0]
|
|
328
|
+
|
|
329
|
+
# Ingest fjson files (INGR-C-04 through INGR-C-07)
|
|
330
|
+
success, failures = ingest_sphinx_json_dir(
|
|
331
|
+
conn, Path(json_out), doc_set_id
|
|
332
|
+
)
|
|
333
|
+
logger.info(
|
|
334
|
+
"Ingested %d documents (%d failures) for Python %s",
|
|
335
|
+
success, failures, version,
|
|
336
|
+
)
|
|
337
|
+
any_version_succeeded = True
|
|
338
|
+
|
|
339
|
+
except subprocess.CalledProcessError as e:
|
|
340
|
+
logger.error(
|
|
341
|
+
"Subprocess failed for %s: %s\n%s",
|
|
342
|
+
version, e, e.stderr[:2000] if e.stderr else "",
|
|
343
|
+
)
|
|
344
|
+
any_version_succeeded = True # symbols still ingested
|
|
345
|
+
finally:
|
|
346
|
+
# Cleanup clone directory
|
|
347
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
348
|
+
logger.info("Cleaned up %s", clone_dir)
|
|
349
|
+
|
|
350
|
+
except Exception as e:
|
|
351
|
+
logger.error("Error processing version %s: %s", version, e)
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
if not any_version_succeeded:
|
|
355
|
+
logger.error("No versions were successfully ingested")
|
|
356
|
+
# Clean up the failed build artifact
|
|
357
|
+
if build_db_path.exists():
|
|
358
|
+
build_db_path.unlink()
|
|
359
|
+
raise SystemExit(1)
|
|
360
|
+
|
|
361
|
+
# Populate synonyms from synonyms.yaml (INGR-C-09)
|
|
362
|
+
synonym_count = populate_synonyms(conn)
|
|
363
|
+
logger.info("Populated %d synonyms", synonym_count)
|
|
364
|
+
|
|
365
|
+
# Rebuild FTS indexes (INGR-C-08)
|
|
366
|
+
rebuild_fts_indexes(conn)
|
|
367
|
+
finally:
|
|
368
|
+
conn.close()
|
|
369
|
+
|
|
370
|
+
# Publish: smoke test + atomic swap (PUBL-01 through PUBL-05)
|
|
371
|
+
versions_str = ",".join(version_list)
|
|
372
|
+
success = publish_index(build_db_path, versions_str, require_content=not skip_content)
|
|
373
|
+
if not success:
|
|
374
|
+
logger.error("Publishing failed — smoke tests did not pass")
|
|
375
|
+
raise SystemExit(1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@main.command("validate-corpus")
|
|
379
|
+
@click.option(
|
|
380
|
+
"--db-path",
|
|
381
|
+
default=None,
|
|
382
|
+
type=click.Path(),
|
|
383
|
+
help="Path to index database. Defaults to the standard cache location.",
|
|
384
|
+
)
|
|
385
|
+
def validate_corpus(db_path: str | None) -> None:
|
|
386
|
+
"""Validate the current index by running smoke tests.
|
|
387
|
+
|
|
388
|
+
Runs the same smoke-test suite used during build-index publishing.
|
|
389
|
+
Exits 0 if all checks pass, non-zero if any fail.
|
|
390
|
+
"""
|
|
391
|
+
from pathlib import Path
|
|
392
|
+
|
|
393
|
+
from mcp_server_python_docs.ingestion.publish import (
|
|
394
|
+
parse_expected_versions,
|
|
395
|
+
run_smoke_tests,
|
|
396
|
+
)
|
|
397
|
+
from mcp_server_python_docs.storage.db import get_index_path, get_readonly_connection
|
|
398
|
+
|
|
399
|
+
if db_path is not None:
|
|
400
|
+
target = Path(db_path)
|
|
401
|
+
else:
|
|
402
|
+
target = get_index_path()
|
|
403
|
+
|
|
404
|
+
if not target.exists():
|
|
405
|
+
logger.error("Index not found at %s", target)
|
|
406
|
+
logger.error(
|
|
407
|
+
"Run: python-docs-mcp-server build-index --versions %s",
|
|
408
|
+
SUPPORTED_DOC_VERSIONS_CSV,
|
|
409
|
+
)
|
|
410
|
+
raise SystemExit(1)
|
|
411
|
+
|
|
412
|
+
logger.info("Validating corpus at %s", target)
|
|
413
|
+
|
|
414
|
+
# Auto-detect symbol-only builds from the last published ingestion run
|
|
415
|
+
require_content = True
|
|
416
|
+
expected_versions: list[str] | None = None
|
|
417
|
+
try:
|
|
418
|
+
ro_conn = get_readonly_connection(target)
|
|
419
|
+
row = ro_conn.execute(
|
|
420
|
+
"SELECT version, notes FROM ingestion_runs "
|
|
421
|
+
"WHERE status = 'published' ORDER BY id DESC LIMIT 1"
|
|
422
|
+
).fetchone()
|
|
423
|
+
ro_conn.close()
|
|
424
|
+
if row and row[0]:
|
|
425
|
+
expected_versions = parse_expected_versions(row[0])
|
|
426
|
+
if row and row[1] and "build_mode=symbol_only" in row[1]:
|
|
427
|
+
require_content = False
|
|
428
|
+
logger.info("Detected symbol-only build — skipping content checks")
|
|
429
|
+
except Exception as e:
|
|
430
|
+
# If we can't read the metadata, default to full validation
|
|
431
|
+
logger.debug("Could not read ingestion_runs metadata: %s", e)
|
|
432
|
+
|
|
433
|
+
passed, messages = run_smoke_tests(
|
|
434
|
+
target,
|
|
435
|
+
require_content=require_content,
|
|
436
|
+
expected_versions=expected_versions,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for msg in messages:
|
|
440
|
+
if msg.startswith("OK:"):
|
|
441
|
+
logger.info(" %s", msg)
|
|
442
|
+
elif msg.startswith("WARN:"):
|
|
443
|
+
logger.warning(" %s", msg)
|
|
444
|
+
else:
|
|
445
|
+
logger.error(" %s", msg)
|
|
446
|
+
|
|
447
|
+
if passed:
|
|
448
|
+
logger.info("Corpus validation PASSED")
|
|
449
|
+
# Return normally -- Click exits with code 0 by default
|
|
450
|
+
else:
|
|
451
|
+
logger.error("Corpus validation FAILED")
|
|
452
|
+
raise SystemExit(1)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@main.command()
|
|
456
|
+
def doctor() -> None:
|
|
457
|
+
"""Check environment health and report issues (CLI-02)."""
|
|
458
|
+
import shutil
|
|
459
|
+
import sqlite3
|
|
460
|
+
from pathlib import Path
|
|
461
|
+
|
|
462
|
+
from mcp_server_python_docs.diagnostics import check_build_venv_support
|
|
463
|
+
from mcp_server_python_docs.storage.db import get_cache_dir, get_index_path
|
|
464
|
+
|
|
465
|
+
results: list[tuple[str, bool, str]] = [] # (probe_name, passed, detail)
|
|
466
|
+
|
|
467
|
+
# 1. Python version
|
|
468
|
+
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
469
|
+
py_ok = sys.version_info >= (3, 12)
|
|
470
|
+
results.append((
|
|
471
|
+
"Python version",
|
|
472
|
+
py_ok,
|
|
473
|
+
f"{py_ver}" + ("" if py_ok else " (requires >= 3.12)"),
|
|
474
|
+
))
|
|
475
|
+
|
|
476
|
+
# 2. SQLite FTS5 availability
|
|
477
|
+
fts5_ok = False
|
|
478
|
+
fts5_detail = ""
|
|
479
|
+
try:
|
|
480
|
+
mem_conn = sqlite3.connect(":memory:")
|
|
481
|
+
mem_conn.execute("CREATE VIRTUAL TABLE _fts5_check USING fts5(x)")
|
|
482
|
+
mem_conn.execute("DROP TABLE _fts5_check")
|
|
483
|
+
mem_conn.close()
|
|
484
|
+
fts5_ok = True
|
|
485
|
+
fts5_detail = f"SQLite {sqlite3.sqlite_version}"
|
|
486
|
+
except sqlite3.OperationalError:
|
|
487
|
+
import platform as plat
|
|
488
|
+
|
|
489
|
+
if plat.system() == "Linux" and plat.machine() == "x86_64":
|
|
490
|
+
fts5_detail = (
|
|
491
|
+
"FTS5 unavailable -- pip install 'python-docs-mcp-server[pysqlite3]'"
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
fts5_detail = (
|
|
495
|
+
"FTS5 unavailable -- install Python from python.org or uv python install"
|
|
496
|
+
)
|
|
497
|
+
results.append(("SQLite FTS5", fts5_ok, fts5_detail))
|
|
498
|
+
|
|
499
|
+
# 3. Build-index Sphinx venv support
|
|
500
|
+
build_venv_result = check_build_venv_support()
|
|
501
|
+
results.append((
|
|
502
|
+
"Build venv support",
|
|
503
|
+
build_venv_result.passed,
|
|
504
|
+
build_venv_result.detail,
|
|
505
|
+
))
|
|
506
|
+
|
|
507
|
+
# 4. Cache directory
|
|
508
|
+
cache_dir = get_cache_dir()
|
|
509
|
+
cache_exists = cache_dir.exists()
|
|
510
|
+
cache_writable = False
|
|
511
|
+
if cache_exists:
|
|
512
|
+
try:
|
|
513
|
+
test_file = cache_dir / ".doctor-write-test"
|
|
514
|
+
test_file.touch()
|
|
515
|
+
test_file.unlink()
|
|
516
|
+
cache_writable = True
|
|
517
|
+
except OSError:
|
|
518
|
+
pass
|
|
519
|
+
cache_ok = True # Not existing yet is OK (will be created on first build)
|
|
520
|
+
cache_detail = str(cache_dir)
|
|
521
|
+
if not cache_exists:
|
|
522
|
+
cache_detail += " (does not exist -- will be created on first build-index)"
|
|
523
|
+
elif not cache_writable:
|
|
524
|
+
cache_detail += " (not writable)"
|
|
525
|
+
cache_ok = False
|
|
526
|
+
results.append(("Cache directory", cache_ok, cache_detail))
|
|
527
|
+
|
|
528
|
+
# 5. Index database presence
|
|
529
|
+
index_path = get_index_path()
|
|
530
|
+
index_exists = index_path.exists()
|
|
531
|
+
index_detail = str(index_path)
|
|
532
|
+
if not index_exists:
|
|
533
|
+
index_detail += (
|
|
534
|
+
f" (not found -- run: python-docs-mcp-server build-index --versions "
|
|
535
|
+
f"{SUPPORTED_DOC_VERSIONS_CSV})"
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
size_mb = index_path.stat().st_size / (1024 * 1024)
|
|
539
|
+
index_detail += f" ({size_mb:.1f} MB)"
|
|
540
|
+
results.append(("Index database", index_exists, index_detail))
|
|
541
|
+
|
|
542
|
+
# 6. Free disk space
|
|
543
|
+
check_path = cache_dir if cache_exists else cache_dir.parent
|
|
544
|
+
# Ensure path exists for disk_usage
|
|
545
|
+
if not check_path.exists():
|
|
546
|
+
check_path = Path.home()
|
|
547
|
+
disk_usage = shutil.disk_usage(check_path)
|
|
548
|
+
free_gb = disk_usage.free / (1024**3)
|
|
549
|
+
disk_ok = free_gb >= 1.0
|
|
550
|
+
disk_detail = f"{free_gb:.1f} GB free"
|
|
551
|
+
if not disk_ok:
|
|
552
|
+
disk_detail += " (recommend >= 1 GB for index builds)"
|
|
553
|
+
results.append(("Disk space", disk_ok, disk_detail))
|
|
554
|
+
|
|
555
|
+
# Print report (all to stderr for stdio hygiene)
|
|
556
|
+
click.echo("\npython-docs-mcp-server doctor\n", err=True)
|
|
557
|
+
all_passed = True
|
|
558
|
+
for probe_name, passed, detail in results:
|
|
559
|
+
status = "PASS" if passed else "FAIL"
|
|
560
|
+
marker = "+" if passed else "x"
|
|
561
|
+
click.echo(f" {marker} {status}: {probe_name} -- {detail}", err=True)
|
|
562
|
+
if not passed:
|
|
563
|
+
all_passed = False
|
|
564
|
+
|
|
565
|
+
click.echo("", err=True)
|
|
566
|
+
if all_passed:
|
|
567
|
+
click.echo("All checks passed.", err=True)
|
|
568
|
+
else:
|
|
569
|
+
click.echo("Some checks failed. See details above.", err=True)
|
|
570
|
+
raise SystemExit(1)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
if __name__ == "__main__":
|
|
574
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Typed application context for FastMCP lifespan DI.
|
|
2
|
+
|
|
3
|
+
The AppContext dataclass is yielded by app_lifespan and accessed in tool
|
|
4
|
+
handlers via ctx.request_context.lifespan_context. This replaces module-level
|
|
5
|
+
globals with typed dependency injection (SRVR-01).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mcp_server_python_docs.services.content import ContentService
|
|
14
|
+
from mcp_server_python_docs.services.package_docs import PackageDocsService
|
|
15
|
+
from mcp_server_python_docs.services.persistent_cache import PersistentDocsCache
|
|
16
|
+
from mcp_server_python_docs.services.search import SearchService
|
|
17
|
+
from mcp_server_python_docs.services.version import VersionService
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AppContext:
|
|
22
|
+
"""Application context with typed dependencies for lifespan DI."""
|
|
23
|
+
|
|
24
|
+
db: sqlite3.Connection
|
|
25
|
+
index_path: Path
|
|
26
|
+
search_service: SearchService
|
|
27
|
+
content_service: ContentService
|
|
28
|
+
version_service: VersionService
|
|
29
|
+
package_docs_service: PackageDocsService = field(default_factory=PackageDocsService)
|
|
30
|
+
persistent_docs_cache: PersistentDocsCache | None = None
|
|
31
|
+
synonyms: dict[str, list[str]] = field(default_factory=dict)
|
|
32
|
+
detected_python_version: str | None = None
|
|
33
|
+
detected_python_source: str | None = None
|