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