1313from collections .abc import AsyncIterator
1414from contextlib import asynccontextmanager
1515from pathlib import Path
16- from typing import Literal
16+ from typing import Annotated , Literal
1717
1818import platformdirs
1919import yaml
2020from mcp .server .fastmcp import Context , FastMCP
2121from mcp .server .fastmcp .exceptions import ToolError
2222from mcp .types import ToolAnnotations
23+ from pydantic import Field
2324
2425from mcp_server_python_docs .app_context import AppContext
2526from mcp_server_python_docs .detection import detect_python_version , match_to_indexed
@@ -81,50 +82,51 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
8182 # Open read-only connection (STOR-06, STOR-07)
8283 db = get_readonly_connection (index_path )
8384
84- # Check FTS5 (STOR-08)
85- _assert_fts5 (db )
86-
87- # Construct service instances (Phase 5 — service layer wiring)
88- search_svc = SearchService (db , synonyms )
89- content_svc = ContentService (db )
90- version_svc = VersionService (db )
91-
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-
10785 try :
108- yield AppContext (
109- db = db ,
110- index_path = index_path ,
111- synonyms = synonyms ,
112- search_service = search_svc ,
113- content_service = content_svc ,
114- version_service = version_svc ,
115- detected_python_version = matched ,
116- detected_python_source = detected_src ,
117- )
118- except Exception :
119- # HYGN-05: log lifespan errors, write last-error.log, re-raise original
120- error_msg = traceback .format_exc ()
121- logger .error ("Lifespan error: %s" , error_msg )
86+ # Check FTS5 (STOR-08)
87+ _assert_fts5 (db )
88+
89+ # Construct service instances (Phase 5 — service layer wiring)
90+ search_svc = SearchService (db , synonyms )
91+ content_svc = ContentService (db )
92+ version_svc = VersionService (db )
93+
94+ # Detect user's Python version and match to indexed versions
95+ detected_ver , detected_src = detect_python_version ()
96+ indexed_versions = [
97+ r [0 ] for r in db .execute ("SELECT version FROM doc_sets ORDER BY version" ).fetchall ()
98+ ]
99+ matched = match_to_indexed (detected_ver , indexed_versions )
100+ if matched :
101+ logger .info ("User Python %s matches indexed version — using as default" , matched )
102+ else :
103+ logger .info (
104+ "User Python %s not in index %s — using normal default" ,
105+ detected_ver ,
106+ indexed_versions ,
107+ )
108+
122109 try :
123- error_log = cache_dir / "last-error.log"
124- error_log .write_text (error_msg )
110+ yield AppContext (
111+ db = db ,
112+ index_path = index_path ,
113+ synonyms = synonyms ,
114+ search_service = search_svc ,
115+ content_service = content_svc ,
116+ version_service = version_svc ,
117+ detected_python_version = matched ,
118+ detected_python_source = detected_src ,
119+ )
125120 except Exception :
126- pass
127- raise
121+ # HYGN-05: log lifespan errors, write last-error.log, re-raise original
122+ error_msg = traceback .format_exc ()
123+ logger .error ("Lifespan error: %s" , error_msg )
124+ try :
125+ error_log = cache_dir / "last-error.log"
126+ error_log .write_text (error_msg )
127+ except Exception :
128+ pass
129+ raise
128130 finally :
129131 db .close ()
130132
@@ -137,6 +139,47 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
137139 openWorldHint = False ,
138140)
139141
142+ SearchQueryParam = Annotated [
143+ str ,
144+ Field (
145+ max_length = 500 ,
146+ description = "Search query - Python symbol (asyncio.TaskGroup) or concept (parse json)" ,
147+ ),
148+ ]
149+ VersionParam = Annotated [
150+ str | None ,
151+ Field (description = "Python version (e.g. '3.13'). Defaults to latest." ),
152+ ]
153+ SearchKindParam = Annotated [
154+ Literal ["auto" , "page" , "symbol" , "section" , "example" ],
155+ Field (
156+ description = (
157+ "Search type. Use 'symbol' for API lookups, "
158+ "'example' for code samples, 'auto' otherwise."
159+ )
160+ ),
161+ ]
162+ MaxResultsParam = Annotated [
163+ int ,
164+ Field (ge = 1 , le = 20 , description = "Maximum number of results to return." ),
165+ ]
166+ SlugParam = Annotated [
167+ str ,
168+ Field (max_length = 500 , description = "Page slug (e.g. 'library/asyncio-task.html')" ),
169+ ]
170+ AnchorParam = Annotated [
171+ str | None ,
172+ Field (description = "Section anchor for section-only retrieval" ),
173+ ]
174+ MaxCharsParam = Annotated [
175+ int ,
176+ Field (ge = 100 , le = 50000 , description = "Maximum characters to return" ),
177+ ]
178+ StartIndexParam = Annotated [
179+ int ,
180+ Field (ge = 0 , description = "Start position for pagination" ),
181+ ]
182+
140183
141184def create_server () -> FastMCP :
142185 """Create and configure the FastMCP server."""
@@ -147,10 +190,10 @@ def create_server() -> FastMCP:
147190
148191 @mcp .tool (annotations = _TOOL_ANNOTATIONS )
149192 def search_docs (
150- query : str ,
151- version : str | None = None ,
152- kind : Literal [ "auto" , "page" , "symbol" , "section" , "example" ] = "auto" ,
153- max_results : int = 5 ,
193+ query : SearchQueryParam ,
194+ version : VersionParam = None ,
195+ kind : SearchKindParam = "auto" ,
196+ max_results : MaxResultsParam = 5 ,
154197 ctx : Context = None , # type: ignore[assignment]
155198 ) -> SearchDocsResult :
156199 """Search Python documentation. Use kind='symbol' for API lookups
@@ -168,11 +211,11 @@ def search_docs(
168211
169212 @mcp .tool (annotations = _TOOL_ANNOTATIONS )
170213 def get_docs (
171- slug : str ,
172- version : str | None = None ,
173- anchor : str | None = None ,
174- max_chars : int = 8000 ,
175- start_index : int = 0 ,
214+ slug : SlugParam ,
215+ version : VersionParam = None ,
216+ anchor : AnchorParam = None ,
217+ max_chars : MaxCharsParam = 8000 ,
218+ start_index : StartIndexParam = 0 ,
176219 ctx : Context = None , # type: ignore[assignment]
177220 ) -> GetDocsResult :
178221 """Retrieve a documentation page or specific section. Provide anchor for
@@ -214,7 +257,6 @@ def detect_python_version(
214257 matches an indexed documentation set."""
215258 app_ctx : AppContext = ctx .request_context .lifespan_context
216259 detected_ver = app_ctx .detected_python_version
217- detected_src = app_ctx .detected_python_source or "unknown"
218260
219261 # Re-run detection to get the raw version even if it didn't match
220262 from mcp_server_python_docs .detection import detect_python_version as _detect
0 commit comments