Skip to content

Commit 6a7439d

Browse files
ayhammoudaclaude
andcommitted
fix: apply deep code review findings (3 critical, 5 warning)
CR-01: Lifespan error handler re-raises instead of SystemExit(1) CR-02: Use INSERT OR IGNORE for symbols (matches DELETE+re-insert pattern) CR-03: Validate version format before sorting (prevents ValueError crash) WR-02: Add DocsServerError catch to list_versions for consistent isError:true WR-03: Escape double quotes in logfmt values to prevent injection WR-04: Validate version format in ingest_inventory before URL construction WR-05: Add defensive assertion for FTS table name identifiers WR-07: Validate YAML structure in populate_synonyms WR-08: Add clear warning when sphinx-build fails but symbols ingested IN-05: Replace dead _meta code block with deferred comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a2e1564 commit 6a7439d

6 files changed

Lines changed: 45 additions & 22 deletions

File tree

src/mcp_server_python_docs/__main__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ def build_index(versions: str, skip_content: bool) -> None:
118118
logger.error("No valid versions specified. Example: --versions 3.13")
119119
raise SystemExit(1)
120120

121+
# Validate version format before sorting (CR-03, WR-04)
122+
for v in version_list:
123+
parts = v.split(".")
124+
if len(parts) != 2 or not all(p.isdigit() for p in parts):
125+
logger.error(
126+
"Invalid version format %r. Expected 'X.Y' (e.g., 3.13)", v
127+
)
128+
raise SystemExit(1)
129+
121130
# Determine default version: highest version number (MVER-02)
122131
sorted_versions = sorted(version_list, key=lambda v: [int(x) for x in v.split(".")])
123132
default_version = sorted_versions[-1]
@@ -228,7 +237,12 @@ def build_index(versions: str, skip_content: bool) -> None:
228237
version,
229238
result.stderr[-2000:] if result.stderr else "(no output)",
230239
)
231-
any_version_succeeded = True # symbols still ingested
240+
logger.warning(
241+
"Symbols were ingested for %s but sections/examples "
242+
"will be missing. Smoke tests may fail.",
243+
version,
244+
)
245+
any_version_succeeded = True
232246
continue
233247

234248
logger.info("sphinx-build complete for Python %s", version)

src/mcp_server_python_docs/ingestion/inventory.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ def ingest_inventory(
111111
# Clear existing symbols for this doc_set (re-ingestion support)
112112
conn.execute("DELETE FROM symbols WHERE doc_set_id = ?", (doc_set_id,))
113113

114+
# Validate version format before URL construction (WR-04)
115+
import re
116+
117+
if not re.match(r"^\d+\.\d+$", version):
118+
from mcp_server_python_docs.errors import IngestionError
119+
120+
raise IngestionError(f"Invalid version format: {version!r}")
121+
114122
# Download and parse objects.inv (INGR-I-01)
115123
url = f"https://docs.python.org/{version}/objects.inv"
116124
logger.info(f"Downloading {url}...")
@@ -141,7 +149,7 @@ def ingest_inventory(
141149
anchor_part = uri.split("#", 1)[1] if "#" in uri else None
142150

143151
conn.execute(
144-
"INSERT OR REPLACE INTO symbols "
152+
"INSERT OR IGNORE INTO symbols "
145153
"(doc_set_id, qualified_name, normalized_name, module, "
146154
"symbol_type, uri, anchor) "
147155
"VALUES (?, ?, ?, ?, ?, ?, ?)",

src/mcp_server_python_docs/ingestion/sphinx_json.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,13 @@ def populate_synonyms(conn: sqlite3.Connection) -> int:
398398
with importlib.resources.as_file(ref) as path:
399399
data = yaml.safe_load(path.read_text())
400400

401+
if not isinstance(data, dict):
402+
from mcp_server_python_docs.errors import IngestionError
403+
404+
raise IngestionError(
405+
f"synonyms.yaml must be a YAML mapping, got {type(data).__name__}"
406+
)
407+
401408
count = 0
402409
for concept, expansion in data.items():
403410
if isinstance(expansion, list):

src/mcp_server_python_docs/server.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
9696
version_service=version_svc,
9797
)
9898
except Exception:
99-
# HYGN-05: log lifespan errors, write last-error.log
99+
# HYGN-05: log lifespan errors, write last-error.log, re-raise original
100100
error_msg = traceback.format_exc()
101101
logger.error("Lifespan error: %s", error_msg)
102102
try:
103103
error_log = cache_dir / "last-error.log"
104104
error_log.write_text(error_msg)
105105
except Exception:
106106
pass
107-
raise SystemExit(1)
107+
raise
108108
finally:
109109
db.close()
110110

@@ -167,24 +167,15 @@ def list_versions(
167167
) -> ListVersionsResult:
168168
"""List Python documentation versions available in this index."""
169169
app_ctx: AppContext = ctx.request_context.lifespan_context
170-
return app_ctx.version_service.list_versions()
170+
try:
171+
return app_ctx.version_service.list_versions()
172+
except DocsServerError as e:
173+
raise ToolError(str(e))
171174

172175
# SRVR-07: _meta hint for get_docs tool.
173176
# FastMCP 1.27 does not expose a public API for setting _meta on tool
174-
# definitions via the decorator. The _meta is documented as a client hint
175-
# for expected max response size. We set it by accessing the tool manager's
176-
# internal tool registry. This may need updating on mcp SDK version bumps.
177-
try:
178-
tool_mgr = mcp._tool_manager
179-
if hasattr(tool_mgr, "_tools") and "get_docs" in tool_mgr._tools:
180-
tool_def = tool_mgr._tools["get_docs"]
181-
if not hasattr(tool_def, "_meta") or tool_def._meta is None:
182-
# Store as attribute for now — the MCP protocol layer reads
183-
# _meta from the Tool definition when building tools/list response
184-
pass
185-
# The _meta hint is advisory and may not be supported by all SDK
186-
# versions. Log but don't fail if we can't set it.
187-
except Exception:
188-
logger.debug("Could not set _meta on get_docs tool — advisory hint only")
177+
# definitions. Deferred until the mcp SDK adds _meta support to the
178+
# decorator API or tool manager. The hint is advisory — clients work
179+
# correctly without it.
189180

190181
return mcp

src/mcp_server_python_docs/services/observability.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ def _format_logfmt(**fields: Any) -> str:
3030
elif isinstance(value, float):
3131
parts.append(f"{key}={value:.1f}")
3232
elif isinstance(value, str) and " " in value:
33-
parts.append(f'{key}="{value}"')
33+
escaped = str(value).replace('"', '\\"')
34+
parts.append(f'{key}="{escaped}"')
3435
else:
3536
parts.append(f"{key}={value}")
3637
return " ".join(parts)

src/mcp_server_python_docs/storage/db.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ def bootstrap_schema(conn: sqlite3.Connection) -> None:
124124
# Drop FTS5 virtual tables first so they can be recreated with the
125125
# correct tokenizer. IF NOT EXISTS would skip recreation if the table
126126
# exists with a different tokenizer -- there is no ALTER for FTS5.
127-
for fts_table in ("sections_fts", "symbols_fts", "examples_fts"):
127+
_FTS_TABLES = ("sections_fts", "symbols_fts", "examples_fts")
128+
for fts_table in _FTS_TABLES:
129+
assert fts_table.isidentifier(), f"Invalid table name: {fts_table}"
128130
conn.execute(f"DROP TABLE IF EXISTS {fts_table}")
129131

130132
# Load and execute the full schema DDL

0 commit comments

Comments
 (0)