Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/INTEGRATION-TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Release-specific sign-off still lives in [`.github/RELEASE.md`](RELEASE.md).
- `uv run pyright src/`
- `uv run pytest --tb=short -q`
- Local index build completed:
- `uv run mcp-server-python-docs build-index --versions 3.12,3.13`
- `uv run mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14`
- Doctor passes:
- `uv run mcp-server-python-docs doctor`
- Slow E2E workflow passes when preparing a release:
Expand Down Expand Up @@ -113,7 +113,7 @@ locked.

- [ ] `uvx mcp-server-python-docs --version`
- Expected: prints the current package version
- [ ] `uvx mcp-server-python-docs build-index --versions 3.12,3.13`
- [ ] `uvx mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14`
- Expected: index build completes successfully
- [ ] `uvx mcp-server-python-docs doctor`
- Expected: all required checks pass
Expand All @@ -131,8 +131,8 @@ or supported Python versions.
- Expected: both Python 3.13 and Python 3.14 jobs start
- [ ] Confirm each job installs the built wheel into a clean virtual environment
- Expected: the command path is the installed `mcp-server-python-docs`, not editable source
- [ ] Confirm `build-index --versions 3.12,3.13` passes
- Expected: both versions produce content, not symbol-only fallback
- [ ] Confirm `build-index --versions 3.10,3.11,3.12,3.13,3.14` passes
- Expected: all five versions produce content, not symbol-only fallback
- [ ] Confirm `doctor` and `validate-corpus` pass
- Expected: corpus smoke checks include requested versions and the default version
- [ ] Inspect uploaded logs if a job fails
Expand Down
21 changes: 13 additions & 8 deletions .github/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@ Before the first release, configure PyPI Trusted Publishing:

## Notes

**Python version coverage:** The release workflow builds and tests against Python 3.13 only.
Python 3.12 is covered by the CI workflow (`ci.yml`) which runs a 2x2 matrix (3.12/3.13 x
ubuntu/macos) on every push to `main`. Since tags are created from commits that have already
passed CI, 3.12 compatibility is verified before the release workflow runs. This is an accepted
trade-off to keep the release artifact pipeline simple (single Python version produces the wheel).
**Runtime coverage:** The release workflow builds and tests against Python 3.13 only.
Python 3.12 is covered by the CI workflow (`ci.yml`) which runs a 2x2 matrix
(3.12/3.13 x ubuntu/macos) on every push to `main`. Since tags are created
from commits that have already passed CI, 3.12 compatibility is verified before
the release workflow runs. This is an accepted trade-off to keep the release
artifact pipeline simple (single Python version produces the wheel).

**Documentation coverage:** The full docs index target is Python documentation
versions 3.10 through 3.14.

## Creating a Release

Expand Down Expand Up @@ -106,7 +110,7 @@ Complete these steps in order. Each step has a checkbox -- do not skip ahead.
First public release of mcp-server-python-docs.

A read-only, version-aware MCP retrieval server over Python
standard library documentation (3.12 + 3.13).
standard library documentation (3.10 through 3.14).

Installable via: uvx mcp-server-python-docs"
```
Expand Down Expand Up @@ -137,7 +141,7 @@ Complete these steps in order. Each step has a checkbox -- do not skip ahead.
# Should print 0.1.0

# Step 2: Build index
uvx mcp-server-python-docs build-index --versions 3.12,3.13
uvx mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
# Should complete successfully

# Step 3: Doctor check
Expand All @@ -148,7 +152,8 @@ Complete these steps in order. Each step has a checkbox -- do not skip ahead.
- Run GitHub Actions workflow `Slow E2E`
- Confirm Python 3.13 and Python 3.14 jobs both pass
- Confirm each job installs the built wheel, runs
`build-index --versions 3.12,3.13`, `doctor`, and `validate-corpus`
`build-index --versions 3.10,3.11,3.12,3.13,3.14`, `doctor`, and
`validate-corpus`
- [ ] Claude Desktop test with published package:
Configure `mcpServers` with `uvx mcp-server-python-docs` and verify
"what is asyncio.TaskGroup" returns a correct hit
Expand Down
11 changes: 5 additions & 6 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,13 @@ jobs:
installed-build-index:
name: Installed build-index (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 120

strategy:
fail-fast: false
matrix:
python-version: ["3.13", "3.14"]

env:
HOME: ${{ runner.temp }}/mcp-python-docs-home
XDG_CACHE_HOME: ${{ runner.temp }}/mcp-python-docs-cache

steps:
- uses: actions/checkout@v4

Expand All @@ -40,9 +36,12 @@ jobs:
.e2e-venv/bin/mcp-server-python-docs --version

- name: Build and validate full docs index
env:
HOME: ${{ runner.temp }}/mcp-python-docs-home
XDG_CACHE_HOME: ${{ runner.temp }}/mcp-python-docs-cache
run: |
set -o pipefail
.e2e-venv/bin/mcp-server-python-docs build-index --versions 3.12,3.13 \
.e2e-venv/bin/mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14 \
2>&1 | tee "${RUNNER_TEMP}/build-index-${{ matrix.python-version }}.log"
.e2e-venv/bin/mcp-server-python-docs doctor
.e2e-venv/bin/mcp-server-python-docs validate-corpus
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ uv run pytest tests/test_retrieval_regression.py -q
The server needs a local SQLite index before runtime validation:

```bash
uv run mcp-server-python-docs build-index --versions 3.12,3.13
uv run mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
uv run mcp-server-python-docs doctor
uv run mcp-server-python-docs validate-corpus
```
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ shell or use `python -m uv ...` as a fallback for local contributor commands.
Build the local documentation index:

```bash
uvx mcp-server-python-docs build-index --versions 3.12,3.13
uvx mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
```

If you installed the package persistently, you can drop the `uvx` prefix:

```bash
mcp-server-python-docs build-index --versions 3.12,3.13
mcp-server-python-docs build-index --versions 3.10,3.11,3.12,3.13,3.14
```

This downloads Python's `objects.inv` files, clones CPython docs sources, runs
Expand Down Expand Up @@ -178,7 +178,7 @@ The server currently exposes four MCP tools:
Use this server when you need:

- exact Python stdlib symbol resolution
- consistent version-aware answers across Python 3.12 and 3.13
- consistent version-aware answers across Python 3.10 through 3.14
- token-efficient section retrieval from official docs
- a local, read-only MCP server with a simple operational story

Expand Down Expand Up @@ -292,7 +292,7 @@ For contributor setup and verification:
Tested on macOS and Linux. Windows should work, but it is not verified on
every release.

Python 3.12 and 3.13 are currently supported.
Python documentation versions 3.10 through 3.14 are currently supported.

## License

Expand Down
48 changes: 35 additions & 13 deletions src/mcp_server_python_docs/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ def _consume_saved_stdout_fd() -> int:
# === Now safe to import everything else ===
import click # noqa: E402

from mcp_server_python_docs.ingestion.cpython_versions import ( # noqa: E402
SUPPORTED_DOC_VERSIONS_CSV,
)


@click.group(invoke_without_command=True)
@click.option("--version", "show_version", is_flag=True, help="Show version and exit.")
Expand Down Expand Up @@ -105,7 +109,7 @@ def serve() -> None:
@click.option(
"--versions",
required=True,
help="Comma-separated Python versions (e.g., 3.12,3.13)",
help=f"Comma-separated Python versions (e.g., {SUPPORTED_DOC_VERSIONS_CSV})",
)
@click.option(
"--skip-content",
Expand All @@ -120,6 +124,9 @@ def build_index(versions: str, skip_content: bool) -> None:
import venv
from pathlib import Path

from mcp_server_python_docs.ingestion.cpython_versions import (
CPYTHON_DOCS_BUILD_CONFIG,
)
from mcp_server_python_docs.ingestion.inventory import ingest_inventory
from mcp_server_python_docs.ingestion.publish import (
_version_sort_key,
Expand All @@ -128,6 +135,7 @@ def build_index(versions: str, skip_content: bool) -> None:
publish_index,
)
from mcp_server_python_docs.ingestion.sphinx_json import (
build_sphinx_bootstrap_requirements,
build_sphinx_json_command,
ingest_sphinx_json_dir,
make_sphinx_json_env,
Expand All @@ -142,15 +150,12 @@ def build_index(versions: str, skip_content: bool) -> None:
get_readwrite_connection,
)

# Version tag mapping: CPython git tag and Sphinx constraints (INGR-C-02)
VERSION_CONFIG: dict[str, dict[str, str]] = {
"3.12": {"tag": "v3.12.13", "sphinx_pin": "sphinx~=8.2.0"},
"3.13": {"tag": "v3.13.12", "sphinx_pin": "sphinx<9.0.0"},
}

version_list = parse_expected_versions(versions)
if not version_list:
logger.error("No valid versions specified. Example: --versions 3.13")
logger.error(
"No valid versions specified. Example: --versions %s",
SUPPORTED_DOC_VERSIONS_CSV,
)
raise SystemExit(1)

# Validate version format before sorting (CR-03, WR-04)
Expand Down Expand Up @@ -188,7 +193,7 @@ def build_index(versions: str, skip_content: bool) -> None:
continue

# === Content ingestion (INGR-C-01 through INGR-C-03) ===
config = VERSION_CONFIG.get(version)
config = CPYTHON_DOCS_BUILD_CONFIG.get(version)
if not config:
logger.warning(
"No CPython build config for %s, skipping content ingestion",
Expand Down Expand Up @@ -226,9 +231,22 @@ def build_index(versions: str, skip_content: bool) -> None:
)
pip_path = os.path.join(scripts_dir, "pip")

# Install Sphinx with the version pin for this CPython branch
# Install Sphinx with the version pin for this CPython branch.
bootstrap_requirements = build_sphinx_bootstrap_requirements(
config["sphinx_pin"]
)
if len(bootstrap_requirements) > 1:
logger.info(
"Installing Sphinx bootstrap packages for Python %s: %s",
version,
", ".join(bootstrap_requirements[:-1]),
)
subprocess.run(
[pip_path, "install", config["sphinx_pin"]],
[
pip_path,
"install",
*bootstrap_requirements,
],
check=True,
capture_output=True,
text=True,
Expand Down Expand Up @@ -381,7 +399,10 @@ def validate_corpus(db_path: str | None) -> None:

if not target.exists():
logger.error("Index not found at %s", target)
logger.error("Run: mcp-server-python-docs build-index --versions 3.13")
logger.error(
"Run: mcp-server-python-docs build-index --versions %s",
SUPPORTED_DOC_VERSIONS_CSV,
)
raise SystemExit(1)

logger.info("Validating corpus at %s", target)
Expand Down Expand Up @@ -506,7 +527,8 @@ def doctor() -> None:
index_detail = str(index_path)
if not index_exists:
index_detail += (
" (not found -- run: mcp-server-python-docs build-index --versions 3.13)"
f" (not found -- run: mcp-server-python-docs build-index --versions "
f"{SUPPORTED_DOC_VERSIONS_CSV})"
)
else:
size_mb = index_path.stat().st_size / (1024 * 1024)
Expand Down
4 changes: 2 additions & 2 deletions src/mcp_server_python_docs/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ def match_to_indexed(
"""Match a detected version to the closest indexed version.

Returns the detected version if it's in the index, otherwise None.
We don't guess if 3.11 is detected but only 3.12/3.13 are indexed,
return None and let the normal default resolution handle it.
We don't guess -- if a detected version is not indexed, return None and
let the normal default resolution handle it.
"""
if detected in indexed_versions:
return detected
Expand Down
32 changes: 32 additions & 0 deletions src/mcp_server_python_docs/ingestion/cpython_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Pinned CPython documentation build targets."""
from __future__ import annotations

from typing import Final, TypedDict


class CPythonDocsBuildConfig(TypedDict):
"""Build settings for one CPython documentation release."""

tag: str
sphinx_pin: str


SUPPORTED_DOC_VERSIONS: Final[tuple[str, ...]] = (
"3.10",
"3.11",
"3.12",
"3.13",
"3.14",
)

SUPPORTED_DOC_VERSIONS_CSV: Final[str] = ",".join(SUPPORTED_DOC_VERSIONS)

# CPython git tags are pinned so content builds are reproducible and do not
# drift when a maintenance branch receives new commits.
CPYTHON_DOCS_BUILD_CONFIG: Final[dict[str, CPythonDocsBuildConfig]] = {
"3.10": {"tag": "v3.10.20", "sphinx_pin": "sphinx==3.4.3"},
"3.11": {"tag": "v3.11.15", "sphinx_pin": "sphinx~=7.2.0"},
"3.12": {"tag": "v3.12.13", "sphinx_pin": "sphinx~=8.2.0"},
"3.13": {"tag": "v3.13.13", "sphinx_pin": "sphinx<9.0.0"},
"3.14": {"tag": "v3.14.4", "sphinx_pin": "sphinx<9.0.0"},
}
15 changes: 10 additions & 5 deletions src/mcp_server_python_docs/ingestion/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from collections.abc import Iterable
from datetime import datetime
from pathlib import Path
from typing import Final

from mcp_server_python_docs.storage.db import (
get_cache_dir,
Expand All @@ -23,6 +24,8 @@

logger = logging.getLogger(__name__)

SMOKE_SENTINEL_SYMBOL: Final[str] = "asyncio.run"


def _version_sort_key(version: str) -> tuple[int, ...]:
"""Sort dotted Python versions numerically."""
Expand Down Expand Up @@ -83,7 +86,7 @@ def record_ingestion_run(
Args:
conn: Read-write SQLite connection.
source: Source identifier (e.g., 'python-docs').
version: Version string (e.g., '3.13' or '3.12,3.13').
version: Version string (e.g., '3.13' or '3.10,3.11,3.12,3.13,3.14').
status: Run status ('building', 'smoke_testing', 'published', 'failed').
artifact_hash: SHA256 hash of the build artifact.
notes: Optional notes about the run.
Expand Down Expand Up @@ -221,16 +224,18 @@ def run_smoke_tests(
"SELECT 1 FROM symbols "
"JOIN doc_sets ON doc_sets.id = symbols.doc_set_id "
"WHERE doc_sets.version = ? "
"AND symbols.qualified_name = 'asyncio.TaskGroup' LIMIT 1",
(version,),
"AND symbols.qualified_name = ? LIMIT 1",
(version, SMOKE_SENTINEL_SYMBOL),
).fetchone()
if row:
messages.append(
f"OK: sentinel: asyncio.TaskGroup symbol found for version {version}"
f"OK: sentinel: {SMOKE_SENTINEL_SYMBOL} symbol found "
f"for version {version}"
)
else:
messages.append(
f"FAIL: sentinel: asyncio.TaskGroup symbol missing for version {version}"
f"FAIL: sentinel: {SMOKE_SENTINEL_SYMBOL} symbol missing "
f"for version {version}"
)
passed = False

Expand Down
Loading
Loading