python-docs-mcp-server 0.2.0__tar.gz → 0.2.1__tar.gz

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 (105) hide show
  1. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/CHANGELOG.md +12 -0
  2. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/PKG-INFO +1 -1
  3. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/pyproject.toml +1 -1
  4. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/server.json +2 -2
  5. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/ingestion/sphinx_json.py +21 -1
  6. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_ingestion.py +13 -1
  7. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_mcp_get_docs_cache_smoke.py +5 -8
  8. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_stdio_smoke.py +82 -68
  9. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/uv.lock +1 -1
  10. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.cursorignore +0 -0
  11. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/INTEGRATION-TEST.md +0 -0
  12. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/RELEASE.md +0 -0
  13. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/TEST-STRATEGY.md +0 -0
  14. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/dependabot.yml +0 -0
  15. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/workflows/ci.yml +0 -0
  16. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/workflows/codeql.yml +0 -0
  17. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/workflows/e2e.yml +0 -0
  18. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/workflows/release.yml +0 -0
  19. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/workflows/scorecard.yml +0 -0
  20. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.github/workflows/security.yml +0 -0
  21. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.gitignore +0 -0
  22. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/POSITIONING.md +0 -0
  23. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/ROADMAP.md +0 -0
  24. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-01-data-shape-spike-PLAN.md +0 -0
  25. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-01-data-shape-spike-SUMMARY.md +0 -0
  26. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-02-result-models-PLAN.md +0 -0
  27. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-02-result-models-SUMMARY.md +0 -0
  28. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-03-compare-service-PLAN.md +0 -0
  29. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-03-compare-service-SUMMARY.md +0 -0
  30. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-04-mcp-tool-wiring-PLAN.md +0 -0
  31. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-04-mcp-tool-wiring-SUMMARY.md +0 -0
  32. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-05-user-facing-docs-PLAN.md +0 -0
  33. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-05-user-facing-docs-SUMMARY.md +0 -0
  34. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-CONTEXT.md +0 -0
  35. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-REVIEW.md +0 -0
  36. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/09-compare-versions/09-VERIFICATION.md +0 -0
  37. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/10-whatsnew/10-CONTEXT.md +0 -0
  38. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/.planning/phases/11-detect-venv/11-CONTEXT.md +0 -0
  39. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/AGENTS.md +0 -0
  40. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/CLAUDE.md +0 -0
  41. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/CONTRIBUTING.md +0 -0
  42. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/LICENSE +0 -0
  43. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/README.md +0 -0
  44. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/SECURITY.md +0 -0
  45. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/glama.json +0 -0
  46. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/__init__.py +0 -0
  47. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/__main__.py +0 -0
  48. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/app_context.py +0 -0
  49. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/data/synonyms.yaml +0 -0
  50. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/detection.py +0 -0
  51. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/diagnostics.py +0 -0
  52. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/errors.py +0 -0
  53. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/ingestion/__init__.py +0 -0
  54. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/ingestion/cpython_versions.py +0 -0
  55. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/ingestion/inventory.py +0 -0
  56. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/ingestion/publish.py +0 -0
  57. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/models.py +0 -0
  58. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/py.typed +0 -0
  59. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/retrieval/__init__.py +0 -0
  60. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/retrieval/budget.py +0 -0
  61. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/retrieval/query.py +0 -0
  62. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/retrieval/ranker.py +0 -0
  63. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/server.py +0 -0
  64. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/__init__.py +0 -0
  65. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/cache.py +0 -0
  66. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/compare.py +0 -0
  67. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/content.py +0 -0
  68. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/observability.py +0 -0
  69. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/package_docs.py +0 -0
  70. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/persistent_cache.py +0 -0
  71. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/search.py +0 -0
  72. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/version.py +0 -0
  73. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/services/version_resolution.py +0 -0
  74. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/storage/__init__.py +0 -0
  75. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/storage/db.py +0 -0
  76. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/src/mcp_server_python_docs/storage/schema.sql +0 -0
  77. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/__init__.py +0 -0
  78. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/conftest.py +0 -0
  79. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/retrieval_regression_cases.json +0 -0
  80. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/sample_broken.fjson +0 -0
  81. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/sample_library.fjson +0 -0
  82. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/schema-get_docs-input.json +0 -0
  83. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/schema-get_docs-output.json +0 -0
  84. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/schema-list_versions-output.json +0 -0
  85. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/schema-search_docs-input.json +0 -0
  86. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/fixtures/schema-search_docs-output.json +0 -0
  87. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_ci_workflows.py +0 -0
  88. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_compare_versions.py +0 -0
  89. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_compare_versions_spike.py +0 -0
  90. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_detection.py +0 -0
  91. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_doctor.py +0 -0
  92. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_multi_version.py +0 -0
  93. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_package_docs.py +0 -0
  94. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_packaging.py +0 -0
  95. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_persistent_docs_cache.py +0 -0
  96. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_phase1_integration.py +0 -0
  97. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_publish.py +0 -0
  98. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_retrieval.py +0 -0
  99. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_retrieval_regression.py +0 -0
  100. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_schema.py +0 -0
  101. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_schema_snapshot.py +0 -0
  102. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_services.py +0 -0
  103. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_stability.py +0 -0
  104. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_stdio_hygiene.py +0 -0
  105. {python_docs_mcp_server-0.2.0 → python_docs_mcp_server-0.2.1}/tests/test_synonyms.py +0 -0
@@ -4,6 +4,18 @@ All notable changes to `python-docs-mcp-server` are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
5
  this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.1] — 2026-05-29
8
+
9
+ ### Fixed
10
+
11
+ - **`build-index` on Python 3.14** — Sphinx's parallel JSON build (`-j auto`)
12
+ raised `_pickle.PicklingError` on Python 3.14, which flipped the default
13
+ `multiprocessing` start method on POSIX from `fork` to `forkserver`
14
+ (`forkserver` must pickle worker tasks, but Sphinx's `ParallelTasks` holds an
15
+ unpicklable local closure). The builder now falls back to a serial build
16
+ (`-j 1`) on 3.14+ while keeping the parallel path on `fork`-default
17
+ interpreters (3.13 and earlier). Server runtime behavior is unchanged.
18
+
7
19
  ## [0.2.0] — 2026-05-29
8
20
 
9
21
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-docs-mcp-server
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: The canonical Python stdlib oracle for AI coding agents — exact symbols, exact sections, exact versions, offline, always free, always MIT, token-frugal.
5
5
  Author-email: Aymen Hammouda <hammouda.aymen@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-docs-mcp-server"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "The canonical Python stdlib oracle for AI coding agents — exact symbols, exact sections, exact versions, offline, always free, always MIT, token-frugal."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,13 +8,13 @@
8
8
  "url": "https://github.com/ayhammouda/python-docs-mcp-server",
9
9
  "source": "github"
10
10
  },
11
- "version": "0.2.0",
11
+ "version": "0.2.1",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "pypi",
15
15
  "registryBaseUrl": "https://pypi.org",
16
16
  "identifier": "python-docs-mcp-server",
17
- "version": "0.2.0",
17
+ "version": "0.2.1",
18
18
  "runtimeHint": "uvx",
19
19
  "transport": {
20
20
  "type": "stdio"
@@ -13,6 +13,7 @@ import logging
13
13
  import os
14
14
  import re
15
15
  import sqlite3
16
+ import sys
16
17
  from collections.abc import Mapping
17
18
  from pathlib import Path
18
19
 
@@ -166,6 +167,25 @@ def make_sphinx_json_env(
166
167
  return env
167
168
 
168
169
 
170
+ def sphinx_parallel_jobs(
171
+ version_info: tuple[int, ...] = (sys.version_info.major, sys.version_info.minor),
172
+ ) -> str:
173
+ """Return the Sphinx ``-j`` value for the build interpreter.
174
+
175
+ The Sphinx venv is created from the current interpreter (``venv.create``),
176
+ so ``sys.version_info`` reflects the Python that will run ``sphinx-build``.
177
+
178
+ Python 3.14 changed the default ``multiprocessing`` start method on
179
+ POSIX from ``fork`` to ``forkserver``. ``forkserver`` pickles the work
180
+ handed to child processes, but Sphinx's parallel build passes a
181
+ ``ParallelTasks`` object holding a local closure
182
+ (``Builder._read_parallel.<locals>.merge``) that cannot be pickled, so
183
+ ``-j auto`` raises ``_pickle.PicklingError`` on 3.14+. Fall back to a
184
+ serial build there; ``fork``-default interpreters keep the parallel path.
185
+ """
186
+ return "1" if version_info >= (3, 14) else "auto"
187
+
188
+
169
189
  def build_sphinx_json_command(
170
190
  sphinx_build: Path | str,
171
191
  doc_dir: Path | str,
@@ -179,7 +199,7 @@ def build_sphinx_json_command(
179
199
  "-D",
180
200
  "html_theme=classic",
181
201
  "-j",
182
- "auto",
202
+ sphinx_parallel_jobs(),
183
203
  str(doc_dir),
184
204
  str(json_out),
185
205
  ]
@@ -34,6 +34,7 @@ from mcp_server_python_docs.ingestion.sphinx_json import (
34
34
  parse_fjson,
35
35
  populate_synonyms,
36
36
  rebuild_fts_indexes,
37
+ sphinx_parallel_jobs,
37
38
  write_json_build_requirements,
38
39
  write_sphinx_json_sitecustomize,
39
40
  )
@@ -227,11 +228,22 @@ class TestSphinxJsonCommand:
227
228
  "-D",
228
229
  "html_theme=classic",
229
230
  "-j",
230
- "auto",
231
+ sphinx_parallel_jobs(),
231
232
  str(doc_dir),
232
233
  str(json_out),
233
234
  ]
234
235
 
236
+ def test_sphinx_parallel_jobs_auto_before_314(self):
237
+ """Pre-3.14 interpreters keep the parallel 'auto' build (fork default)."""
238
+ assert sphinx_parallel_jobs((3, 10, 0)) == "auto"
239
+ assert sphinx_parallel_jobs((3, 13, 5)) == "auto"
240
+
241
+ def test_sphinx_parallel_jobs_serial_on_314_plus(self):
242
+ """3.14+ flipped multiprocessing to forkserver, which can't pickle
243
+ Sphinx's parallel ParallelTasks closures -> force a serial build."""
244
+ assert sphinx_parallel_jobs((3, 14, 0)) == "1"
245
+ assert sphinx_parallel_jobs((3, 15, 1)) == "1"
246
+
235
247
 
236
248
  # ── fjson parsing tests (INGR-C-04) ──
237
249
 
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
 
4
4
  import sqlite3
5
5
  import subprocess
6
- import sys
7
6
  from pathlib import Path
8
7
 
9
8
  from mcp_server_python_docs.services.persistent_cache import _NO_ANCHOR_KEY
@@ -14,6 +13,7 @@ from tests.test_stdio_smoke import (
14
13
  _isolated_cache_env,
15
14
  _make_notification,
16
15
  _make_request,
16
+ _run_server_until_responses,
17
17
  )
18
18
 
19
19
 
@@ -80,13 +80,10 @@ def _create_contentful_json_index(cache_dir: Path) -> Path:
80
80
 
81
81
 
82
82
  def _run_server(stdin_data: bytes, env: dict[str, str]) -> subprocess.CompletedProcess:
83
- return subprocess.run(
84
- [sys.executable, "-m", "mcp_server_python_docs", "serve"],
85
- input=stdin_data,
86
- capture_output=True,
87
- timeout=15,
88
- env=env,
89
- )
83
+ # Use the polling runner (waits for the tools/call reply before closing
84
+ # stdin) instead of subprocess.run(input=...), which races the server's
85
+ # EOF-driven shutdown and flakes on cold CI runners.
86
+ return _run_server_until_responses(stdin_data, env)
90
87
 
91
88
 
92
89
  def _initialized_tool_call(name: str, arguments: dict, req_id: int = 2) -> bytes:
@@ -184,6 +184,87 @@ def _request_ids(stdin_data: bytes) -> list[int]:
184
184
  return request_ids
185
185
 
186
186
 
187
+ def _run_server_until_responses(
188
+ stdin_data: bytes, env: dict[str, str], timeout: int = 15,
189
+ ) -> subprocess.CompletedProcess:
190
+ """Run the MCP server, stream stdin, and wait for every expected response.
191
+
192
+ Unlike a fire-and-forget ``subprocess.run(input=...)``, this keeps stdin
193
+ open until each request id in ``stdin_data`` has a matching response on
194
+ stdout (or the timeout/exit fires). That avoids a race where the server's
195
+ read loop shuts down on stdin EOF before the final ``tools/call`` handler
196
+ flushes its reply — the source of CI flakiness on cold runners.
197
+ """
198
+ proc = subprocess.Popen(
199
+ [sys.executable, "-m", "mcp_server_python_docs", "serve"],
200
+ stdin=subprocess.PIPE,
201
+ stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE,
203
+ env=env,
204
+ )
205
+ assert proc.stdin is not None
206
+ assert proc.stdout is not None
207
+ assert proc.stderr is not None
208
+
209
+ stdout_lines: list[bytes] = []
210
+ stderr_lines: list[bytes] = []
211
+ output_lock = threading.Lock()
212
+
213
+ def read_stream(stream, sink: list[bytes]) -> None:
214
+ for line in iter(stream.readline, b""):
215
+ with output_lock:
216
+ sink.append(line)
217
+
218
+ stdout_thread = threading.Thread(
219
+ target=read_stream,
220
+ args=(proc.stdout, stdout_lines),
221
+ daemon=True,
222
+ )
223
+ stderr_thread = threading.Thread(
224
+ target=read_stream,
225
+ args=(proc.stderr, stderr_lines),
226
+ daemon=True,
227
+ )
228
+ stdout_thread.start()
229
+ stderr_thread.start()
230
+
231
+ expected_ids = _request_ids(stdin_data)
232
+ deadline = time.monotonic() + timeout
233
+ try:
234
+ for line in stdin_data.splitlines(keepends=True):
235
+ proc.stdin.write(line)
236
+ proc.stdin.flush()
237
+
238
+ while time.monotonic() < deadline:
239
+ with output_lock:
240
+ responses = _read_responses(b"".join(stdout_lines))
241
+ if all(_find_response(responses, req_id) is not None for req_id in expected_ids):
242
+ break
243
+ if proc.poll() is not None:
244
+ break
245
+ time.sleep(0.02)
246
+ finally:
247
+ try:
248
+ proc.stdin.close()
249
+ except BrokenPipeError:
250
+ # The subprocess may have already closed stdin after handling the requests.
251
+ pass
252
+ proc.stdin = None
253
+
254
+ remaining = max(0.1, deadline - time.monotonic())
255
+ try:
256
+ proc.wait(timeout=remaining)
257
+ except subprocess.TimeoutExpired:
258
+ proc.kill()
259
+ proc.wait(timeout=5)
260
+
261
+ stdout_thread.join(timeout=1)
262
+ stderr_thread.join(timeout=1)
263
+ stdout = b"".join(stdout_lines)
264
+ stderr = b"".join(stderr_lines)
265
+ return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr)
266
+
267
+
187
268
  class TestStdioSmoke:
188
269
  """Spawn the MCP server as a subprocess and verify protocol compliance."""
189
270
 
@@ -198,74 +279,7 @@ class TestStdioSmoke:
198
279
  self, stdin_data: bytes, timeout: int = 15,
199
280
  ) -> subprocess.CompletedProcess:
200
281
  """Run the server subprocess with line-paced stdin and return the result."""
201
- proc = subprocess.Popen(
202
- [sys.executable, "-m", "mcp_server_python_docs", "serve"],
203
- stdin=subprocess.PIPE,
204
- stdout=subprocess.PIPE,
205
- stderr=subprocess.PIPE,
206
- env=self.env,
207
- )
208
- assert proc.stdin is not None
209
- assert proc.stdout is not None
210
- assert proc.stderr is not None
211
-
212
- stdout_lines: list[bytes] = []
213
- stderr_lines: list[bytes] = []
214
- output_lock = threading.Lock()
215
-
216
- def read_stream(stream, sink: list[bytes]) -> None:
217
- for line in iter(stream.readline, b""):
218
- with output_lock:
219
- sink.append(line)
220
-
221
- stdout_thread = threading.Thread(
222
- target=read_stream,
223
- args=(proc.stdout, stdout_lines),
224
- daemon=True,
225
- )
226
- stderr_thread = threading.Thread(
227
- target=read_stream,
228
- args=(proc.stderr, stderr_lines),
229
- daemon=True,
230
- )
231
- stdout_thread.start()
232
- stderr_thread.start()
233
-
234
- expected_ids = _request_ids(stdin_data)
235
- deadline = time.monotonic() + timeout
236
- try:
237
- for line in stdin_data.splitlines(keepends=True):
238
- proc.stdin.write(line)
239
- proc.stdin.flush()
240
-
241
- while time.monotonic() < deadline:
242
- with output_lock:
243
- responses = _read_responses(b"".join(stdout_lines))
244
- if all(_find_response(responses, req_id) is not None for req_id in expected_ids):
245
- break
246
- if proc.poll() is not None:
247
- break
248
- time.sleep(0.02)
249
- finally:
250
- try:
251
- proc.stdin.close()
252
- except BrokenPipeError:
253
- # The subprocess may have already closed stdin after handling the requests.
254
- pass
255
- proc.stdin = None
256
-
257
- remaining = max(0.1, deadline - time.monotonic())
258
- try:
259
- proc.wait(timeout=remaining)
260
- except subprocess.TimeoutExpired:
261
- proc.kill()
262
- proc.wait(timeout=5)
263
-
264
- stdout_thread.join(timeout=1)
265
- stderr_thread.join(timeout=1)
266
- stdout = b"".join(stdout_lines)
267
- stderr = b"".join(stderr_lines)
268
- return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr)
282
+ return _run_server_until_responses(stdin_data, self.env, timeout)
269
283
 
270
284
  def test_server_lists_tools_no_stdout_pollution(self):
271
285
  """Server returns tool list and stdout has no non-JSON-RPC bytes."""
@@ -541,7 +541,7 @@ wheels = [
541
541
 
542
542
  [[package]]
543
543
  name = "python-docs-mcp-server"
544
- version = "0.2.0"
544
+ version = "0.2.1"
545
545
  source = { editable = "." }
546
546
  dependencies = [
547
547
  { name = "beautifulsoup4" },