Skip to content

Commit faf6a78

Browse files
ayhammoudaclaude
andcommitted
feat: add detect_python_version tool and auto-default get_docs to user's Python
Detects the user's Python version at startup by probing .python-version, python3 in PATH, then server runtime. When the detected version matches an indexed doc set, get_docs defaults to it instead of the arbitrary highest version. search_docs remains cross-version by design (CR-01). New detect_python_version tool lets LLMs explicitly query the detection result including source and whether it matched an indexed version. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9206c09 commit faf6a78

5 files changed

Lines changed: 157 additions & 3 deletions

File tree

src/mcp_server_python_docs/app_context.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ class AppContext:
2525
content_service: ContentService
2626
version_service: VersionService
2727
synonyms: dict[str, list[str]] = field(default_factory=dict)
28+
detected_python_version: str | None = None
29+
detected_python_source: str | None = None
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Python version detection from the user's environment.
2+
3+
Probes multiple sources to determine which Python version the user
4+
is working with, so the server can default to the right documentation.
5+
6+
Detection order:
7+
1. .python-version file in cwd (pyenv, mise, rtx convention)
8+
2. ``python3 --version`` in PATH (user's active interpreter)
9+
3. ``sys.version_info`` (server's own runtime — last resort)
10+
"""
11+
from __future__ import annotations
12+
13+
import logging
14+
import re
15+
import subprocess
16+
import sys
17+
from pathlib import Path
18+
19+
logger = logging.getLogger(__name__)
20+
21+
_VERSION_RE = re.compile(r"(\d+\.\d+)")
22+
23+
24+
def _parse_major_minor(raw: str) -> str | None:
25+
"""Extract 'X.Y' from a version string like '3.13.2', 'Python 3.13.2', 'cpython-3.13'."""
26+
m = _VERSION_RE.search(raw)
27+
return m.group(1) if m else None
28+
29+
30+
def detect_python_version() -> tuple[str, str]:
31+
"""Detect the user's Python version.
32+
33+
Returns:
34+
Tuple of (major_minor, source) where major_minor is like '3.13'
35+
and source describes how it was detected.
36+
"""
37+
# 1. .python-version file (pyenv / mise / rtx)
38+
pv_file = Path.cwd() / ".python-version"
39+
if pv_file.is_file():
40+
try:
41+
first_line = pv_file.read_text().strip().splitlines()[0].strip()
42+
version = _parse_major_minor(first_line)
43+
if version:
44+
logger.info("Detected Python %s from .python-version", version)
45+
return version, ".python-version file"
46+
except Exception:
47+
pass
48+
49+
# 2. python3 --version in PATH
50+
try:
51+
result = subprocess.run(
52+
["python3", "--version"],
53+
capture_output=True,
54+
text=True,
55+
timeout=5,
56+
)
57+
if result.returncode == 0:
58+
version = _parse_major_minor(result.stdout.strip())
59+
if version:
60+
logger.info("Detected Python %s from python3 in PATH", version)
61+
return version, "python3 in PATH"
62+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
63+
pass
64+
65+
# 3. Server's own runtime
66+
version = f"{sys.version_info.major}.{sys.version_info.minor}"
67+
logger.info("Using server runtime Python %s as fallback", version)
68+
return version, "server runtime"
69+
70+
71+
def match_to_indexed(
72+
detected: str, indexed_versions: list[str]
73+
) -> str | None:
74+
"""Match a detected version to the closest indexed version.
75+
76+
Returns the detected version if it's in the index, otherwise None.
77+
We don't guess — if 3.11 is detected but only 3.12/3.13 are indexed,
78+
return None and let the normal default resolution handle it.
79+
"""
80+
if detected in indexed_versions:
81+
return detected
82+
return None

src/mcp_server_python_docs/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,24 @@ class ListVersionsResult(BaseModel):
136136
versions: list[VersionInfo] = Field(
137137
description="Available documentation versions",
138138
)
139+
140+
141+
# --- detect_python_version models ---
142+
143+
144+
class DetectPythonVersionResult(BaseModel):
145+
"""Output from detect_python_version tool."""
146+
147+
detected_version: str = Field(
148+
description="Python major.minor detected from the user's environment (e.g. '3.13')"
149+
)
150+
source: str = Field(
151+
description="How the version was detected: '.python-version file', 'python3 in PATH', or 'server runtime'"
152+
)
153+
matched_index_version: str | None = Field(
154+
default=None,
155+
description="The detected version if it matches an indexed doc set, otherwise null",
156+
)
157+
is_default: bool = Field(
158+
description="Whether this detected version is being used as the default for get_docs"
159+
)

src/mcp_server_python_docs/server.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@
2222
from mcp.types import ToolAnnotations
2323

2424
from mcp_server_python_docs.app_context import AppContext
25+
from mcp_server_python_docs.detection import detect_python_version, match_to_indexed
2526
from mcp_server_python_docs.errors import DocsServerError
26-
from mcp_server_python_docs.models import GetDocsResult, ListVersionsResult, SearchDocsResult
27+
from mcp_server_python_docs.models import (
28+
DetectPythonVersionResult,
29+
GetDocsResult,
30+
ListVersionsResult,
31+
SearchDocsResult,
32+
)
2733
from mcp_server_python_docs.services.content import ContentService
2834
from mcp_server_python_docs.services.search import SearchService
2935
from mcp_server_python_docs.services.version import VersionService
@@ -83,6 +89,21 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
8389
content_svc = ContentService(db)
8490
version_svc = VersionService(db)
8591

92+
# Detect user's Python version and match to indexed versions
93+
detected_ver, detected_src = detect_python_version()
94+
indexed_versions = [
95+
r[0] for r in db.execute("SELECT version FROM doc_sets ORDER BY version").fetchall()
96+
]
97+
matched = match_to_indexed(detected_ver, indexed_versions)
98+
if matched:
99+
logger.info("User Python %s matches indexed version — using as default", matched)
100+
else:
101+
logger.info(
102+
"User Python %s not in index %s — using normal default",
103+
detected_ver,
104+
indexed_versions,
105+
)
106+
86107
try:
87108
yield AppContext(
88109
db=db,
@@ -91,6 +112,8 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
91112
search_service=search_svc,
92113
content_service=content_svc,
93114
version_service=version_svc,
115+
detected_python_version=matched,
116+
detected_python_source=detected_src,
94117
)
95118
except Exception:
96119
# HYGN-05: log lifespan errors, write last-error.log, re-raise original
@@ -155,6 +178,9 @@ def get_docs(
155178
"""Retrieve a documentation page or specific section. Provide anchor for
156179
section-only retrieval (much cheaper). Pagination via start_index."""
157180
app_ctx: AppContext = ctx.request_context.lifespan_context
181+
# Auto-default to detected Python version when no version specified
182+
if version is None and app_ctx.detected_python_version:
183+
version = app_ctx.detected_python_version
158184
try:
159185
return app_ctx.content_service.get_docs(
160186
slug, version, anchor, max_chars, start_index
@@ -179,6 +205,29 @@ def list_versions(
179205
logger.exception("Unexpected error in list_versions")
180206
raise ToolError(f"Internal error: {type(e).__name__}")
181207

208+
@mcp.tool(annotations=_TOOL_ANNOTATIONS)
209+
def detect_python_version(
210+
ctx: Context = None, # type: ignore[assignment]
211+
) -> DetectPythonVersionResult:
212+
"""Detect the Python version in the user's environment.
213+
Returns the detected version, how it was found, and whether it
214+
matches an indexed documentation set."""
215+
app_ctx: AppContext = ctx.request_context.lifespan_context
216+
detected_ver = app_ctx.detected_python_version
217+
detected_src = app_ctx.detected_python_source or "unknown"
218+
219+
# Re-run detection to get the raw version even if it didn't match
220+
from mcp_server_python_docs.detection import detect_python_version as _detect
221+
222+
raw_ver, raw_src = _detect()
223+
224+
return DetectPythonVersionResult(
225+
detected_version=raw_ver,
226+
source=raw_src,
227+
matched_index_version=detected_ver,
228+
is_default=detected_ver is not None,
229+
)
230+
182231
# SRVR-07: _meta hint for get_docs tool.
183232
# FastMCP 1.27 does not expose a public API for setting _meta on tool
184233
# definitions. Deferred until the mcp SDK adds _meta support to the

tests/test_services.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,12 +387,12 @@ def test_all_tools_have_annotations(self):
387387
annotations.openWorldHint is False
388388
), f"{name} openWorldHint should be False"
389389

390-
def test_three_tools_registered(self):
390+
def test_four_tools_registered(self):
391391
from mcp_server_python_docs.server import create_server
392392

393393
server = create_server()
394394
tools = server._tool_manager._tools
395-
assert len(tools) == 3
395+
assert len(tools) == 4
396396

397397

398398
# === validate-corpus Tests ===

0 commit comments

Comments
 (0)