python-docs-mcp-server 0.1.2__py3-none-any.whl
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.
- mcp_server_python_docs/__init__.py +17 -0
- mcp_server_python_docs/__main__.py +574 -0
- mcp_server_python_docs/app_context.py +33 -0
- mcp_server_python_docs/data/synonyms.yaml +194 -0
- mcp_server_python_docs/detection.py +82 -0
- mcp_server_python_docs/diagnostics.py +74 -0
- mcp_server_python_docs/errors.py +33 -0
- mcp_server_python_docs/ingestion/__init__.py +1 -0
- mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
- mcp_server_python_docs/ingestion/inventory.py +178 -0
- mcp_server_python_docs/ingestion/publish.py +504 -0
- mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
- mcp_server_python_docs/models.py +216 -0
- mcp_server_python_docs/py.typed +0 -0
- mcp_server_python_docs/retrieval/__init__.py +31 -0
- mcp_server_python_docs/retrieval/budget.py +63 -0
- mcp_server_python_docs/retrieval/query.py +183 -0
- mcp_server_python_docs/retrieval/ranker.py +369 -0
- mcp_server_python_docs/server.py +329 -0
- mcp_server_python_docs/services/__init__.py +7 -0
- mcp_server_python_docs/services/cache.py +111 -0
- mcp_server_python_docs/services/content.py +155 -0
- mcp_server_python_docs/services/observability.py +130 -0
- mcp_server_python_docs/services/package_docs.py +161 -0
- mcp_server_python_docs/services/persistent_cache.py +147 -0
- mcp_server_python_docs/services/search.py +111 -0
- mcp_server_python_docs/services/version.py +47 -0
- mcp_server_python_docs/services/version_resolution.py +79 -0
- mcp_server_python_docs/storage/__init__.py +1 -0
- mcp_server_python_docs/storage/db.py +146 -0
- mcp_server_python_docs/storage/schema.sql +136 -0
- python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
- python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
- python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
- python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
- python_docs_mcp_server-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""SQLite-backed cache for completed get_docs results across MCP restarts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from mcp_server_python_docs.models import GetDocsResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
_NO_ANCHOR_KEY = "\x00mcp-python-docs:no-anchor\x00"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CacheStats(NamedTuple):
|
|
20
|
+
hits: int = 0
|
|
21
|
+
misses: int = 0
|
|
22
|
+
writes: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PersistentDocsCache:
|
|
26
|
+
"""Persist get_docs results by index fingerprint, version, and request identity."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, cache_path: Path, index_path: Path) -> None:
|
|
29
|
+
self._cache_path = Path(cache_path)
|
|
30
|
+
# Set after fingerprint stat succeeds; stays "" if init fails so the
|
|
31
|
+
# cache disables cleanly without leaking partial state.
|
|
32
|
+
self._fingerprint = ""
|
|
33
|
+
self._hits = self._misses = self._writes = 0
|
|
34
|
+
# ``check_same_thread=False`` lets multiple threads share the connection,
|
|
35
|
+
# but per the Python sqlite3 docs writes must still be serialized by the
|
|
36
|
+
# application — this lock guards every execute()/commit() and the stats
|
|
37
|
+
# counters they update.
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
self._conn: sqlite3.Connection | None = None
|
|
40
|
+
try:
|
|
41
|
+
self._fingerprint = self._fingerprint_index(Path(index_path))
|
|
42
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
self._conn = sqlite3.connect(str(self._cache_path), check_same_thread=False)
|
|
44
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
45
|
+
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
46
|
+
self._conn.execute(
|
|
47
|
+
"CREATE TABLE IF NOT EXISTS retrieved_docs_cache ("
|
|
48
|
+
"index_fingerprint TEXT NOT NULL, version TEXT NOT NULL, slug TEXT NOT NULL, "
|
|
49
|
+
"anchor TEXT NOT NULL, max_chars INTEGER NOT NULL, start_index INTEGER NOT NULL, "
|
|
50
|
+
"result_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
51
|
+
"PRIMARY KEY (index_fingerprint, version, slug, anchor, max_chars, start_index))"
|
|
52
|
+
)
|
|
53
|
+
self._conn.execute(
|
|
54
|
+
"DELETE FROM retrieved_docs_cache WHERE index_fingerprint != ?",
|
|
55
|
+
(self._fingerprint,),
|
|
56
|
+
)
|
|
57
|
+
self._conn.commit()
|
|
58
|
+
except (OSError, sqlite3.Error) as e:
|
|
59
|
+
if self._conn is not None:
|
|
60
|
+
self._conn.close()
|
|
61
|
+
self._conn = None
|
|
62
|
+
logger.warning("Persistent docs cache disabled: %s", e)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def cache_path(self) -> Path:
|
|
66
|
+
return self._cache_path
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _fingerprint_index(index_path: Path) -> str:
|
|
70
|
+
stat = index_path.stat()
|
|
71
|
+
return f"{index_path.resolve()}:{stat.st_size}:{stat.st_mtime_ns}"
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _anchor_key(anchor: str | None) -> str:
|
|
75
|
+
return _NO_ANCHOR_KEY if anchor is None else anchor
|
|
76
|
+
|
|
77
|
+
def stats(self) -> CacheStats:
|
|
78
|
+
return CacheStats(self._hits, self._misses, self._writes)
|
|
79
|
+
|
|
80
|
+
def get(
|
|
81
|
+
self, *, version: str, slug: str, anchor: str | None, max_chars: int, start_index: int
|
|
82
|
+
) -> GetDocsResult | None:
|
|
83
|
+
if self._conn is None:
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._misses += 1
|
|
86
|
+
return None
|
|
87
|
+
with self._lock:
|
|
88
|
+
try:
|
|
89
|
+
row = self._conn.execute(
|
|
90
|
+
"SELECT result_json FROM retrieved_docs_cache WHERE index_fingerprint = ? "
|
|
91
|
+
"AND version = ? AND slug = ? AND anchor = ? AND max_chars = ? "
|
|
92
|
+
"AND start_index = ?",
|
|
93
|
+
(
|
|
94
|
+
self._fingerprint,
|
|
95
|
+
version,
|
|
96
|
+
slug,
|
|
97
|
+
self._anchor_key(anchor),
|
|
98
|
+
max_chars,
|
|
99
|
+
start_index,
|
|
100
|
+
),
|
|
101
|
+
).fetchone()
|
|
102
|
+
except sqlite3.Error as e:
|
|
103
|
+
self._misses += 1
|
|
104
|
+
logger.warning("Persistent docs cache read skipped: %s", e)
|
|
105
|
+
return None
|
|
106
|
+
if row is None:
|
|
107
|
+
self._misses += 1
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
result = GetDocsResult.model_validate_json(row[0])
|
|
111
|
+
except (ValidationError, ValueError) as e:
|
|
112
|
+
self._misses += 1
|
|
113
|
+
logger.warning("Persistent docs cache entry ignored: %s", e)
|
|
114
|
+
return None
|
|
115
|
+
self._hits += 1
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def put(self, *, result: GetDocsResult, max_chars: int, start_index: int) -> None:
|
|
119
|
+
if self._conn is None:
|
|
120
|
+
return
|
|
121
|
+
with self._lock:
|
|
122
|
+
try:
|
|
123
|
+
self._conn.execute(
|
|
124
|
+
"INSERT OR REPLACE INTO retrieved_docs_cache "
|
|
125
|
+
"(index_fingerprint, version, slug, anchor, max_chars, start_index, "
|
|
126
|
+
"result_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
127
|
+
(
|
|
128
|
+
self._fingerprint,
|
|
129
|
+
result.version,
|
|
130
|
+
result.slug,
|
|
131
|
+
self._anchor_key(result.anchor),
|
|
132
|
+
max_chars,
|
|
133
|
+
start_index,
|
|
134
|
+
result.model_dump_json(),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
self._conn.commit()
|
|
138
|
+
except (sqlite3.Error, ValueError) as e:
|
|
139
|
+
logger.warning("Persistent docs cache write skipped: %s", e)
|
|
140
|
+
return
|
|
141
|
+
self._writes += 1
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
with self._lock:
|
|
145
|
+
if self._conn is not None:
|
|
146
|
+
self._conn.close()
|
|
147
|
+
self._conn = None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Search service — handles search_docs for all kind values.
|
|
2
|
+
|
|
3
|
+
Extracts the search logic from server.py into a service class.
|
|
4
|
+
Receives sqlite3.Connection and synonyms dict via constructor.
|
|
5
|
+
No MCP types imported — dependency rule enforced.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from mcp_server_python_docs.models import SearchDocsResult
|
|
12
|
+
from mcp_server_python_docs.retrieval.query import (
|
|
13
|
+
build_match_expression,
|
|
14
|
+
classify_query,
|
|
15
|
+
expand_synonyms,
|
|
16
|
+
)
|
|
17
|
+
from mcp_server_python_docs.retrieval.ranker import (
|
|
18
|
+
lookup_symbols_exact,
|
|
19
|
+
search_examples,
|
|
20
|
+
search_sections,
|
|
21
|
+
search_symbols,
|
|
22
|
+
)
|
|
23
|
+
from mcp_server_python_docs.services.observability import log_tool_call
|
|
24
|
+
from mcp_server_python_docs.services.version_resolution import resolve_version_permissive
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SearchService:
|
|
28
|
+
"""Search service dispatching queries through classifier, synonym expansion,
|
|
29
|
+
and FTS5/symbol fast-path."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, db: sqlite3.Connection, synonyms: dict[str, list[str]]) -> None:
|
|
32
|
+
self._db = db
|
|
33
|
+
self._synonyms = synonyms
|
|
34
|
+
self._last_synonym_expanded: bool = False
|
|
35
|
+
self._last_resolution: str = "none"
|
|
36
|
+
# Note: _symbol_exists uses direct SQL (not the version-scoped symbol
|
|
37
|
+
# cache) because classify_query's callback has no version context.
|
|
38
|
+
|
|
39
|
+
def _resolve_version(self, version: str | None) -> str | None:
|
|
40
|
+
"""Resolve and validate version using shared resolution logic.
|
|
41
|
+
|
|
42
|
+
Returns None if version was None -- search is intentionally
|
|
43
|
+
cross-version so LLMs see results from all versions and can
|
|
44
|
+
compare (CR-01 documented design decision).
|
|
45
|
+
|
|
46
|
+
Raises VersionNotFoundError for unknown versions (MVER-03).
|
|
47
|
+
"""
|
|
48
|
+
return resolve_version_permissive(self._db, version)
|
|
49
|
+
|
|
50
|
+
def _symbol_exists(self, name: str) -> bool:
|
|
51
|
+
"""Check if a symbol name exists in the symbols table."""
|
|
52
|
+
row = self._db.execute(
|
|
53
|
+
"SELECT 1 FROM symbols WHERE qualified_name = ? LIMIT 1",
|
|
54
|
+
(name,),
|
|
55
|
+
).fetchone()
|
|
56
|
+
return row is not None
|
|
57
|
+
|
|
58
|
+
@log_tool_call("search_docs")
|
|
59
|
+
def search(
|
|
60
|
+
self,
|
|
61
|
+
query: str,
|
|
62
|
+
version: str | None = None,
|
|
63
|
+
kind: str = "auto",
|
|
64
|
+
max_results: int = 5,
|
|
65
|
+
) -> SearchDocsResult:
|
|
66
|
+
"""Execute a search query using the retrieval layer.
|
|
67
|
+
|
|
68
|
+
Routes queries through classifier -> synonym expansion -> FTS5 or
|
|
69
|
+
symbol fast-path. Returns SearchDocsResult with hits list.
|
|
70
|
+
"""
|
|
71
|
+
# Validate version if explicitly provided (MVER-03)
|
|
72
|
+
resolved_version = self._resolve_version(version)
|
|
73
|
+
|
|
74
|
+
# Track synonym expansion for observability (OPS-01)
|
|
75
|
+
original_tokens = set(query.lower().split()) if query.strip() else set()
|
|
76
|
+
expanded = expand_synonyms(query, self._synonyms)
|
|
77
|
+
self._last_synonym_expanded = expanded != original_tokens
|
|
78
|
+
|
|
79
|
+
# Classify query for routing (RETR-04)
|
|
80
|
+
query_type = classify_query(query, self._symbol_exists)
|
|
81
|
+
|
|
82
|
+
# Symbol fast-path: skip FTS5 entirely
|
|
83
|
+
if kind == "symbol" or (kind == "auto" and query_type == "symbol"):
|
|
84
|
+
hits = lookup_symbols_exact(self._db, query, resolved_version, max_results)
|
|
85
|
+
if hits:
|
|
86
|
+
self._last_resolution = "exact"
|
|
87
|
+
return SearchDocsResult(hits=hits)
|
|
88
|
+
# Fall through to FTS if symbol lookup found nothing and kind is auto
|
|
89
|
+
if kind == "symbol":
|
|
90
|
+
self._last_resolution = "exact"
|
|
91
|
+
return SearchDocsResult(hits=[], note=None)
|
|
92
|
+
|
|
93
|
+
# FTS5 path: build match expression with synonym expansion (RETR-05)
|
|
94
|
+
match_expr = build_match_expression(query, self._synonyms)
|
|
95
|
+
|
|
96
|
+
# Route to appropriate FTS5 table based on kind
|
|
97
|
+
if kind == "section":
|
|
98
|
+
hits = search_sections(self._db, match_expr, resolved_version, max_results)
|
|
99
|
+
elif kind == "example":
|
|
100
|
+
hits = search_examples(self._db, match_expr, resolved_version, max_results)
|
|
101
|
+
elif kind == "page":
|
|
102
|
+
# Page search uses sections with broader matching
|
|
103
|
+
hits = search_sections(self._db, match_expr, resolved_version, max_results)
|
|
104
|
+
else:
|
|
105
|
+
# kind == "auto": try sections first, fall back to symbols FTS
|
|
106
|
+
hits = search_sections(self._db, match_expr, resolved_version, max_results)
|
|
107
|
+
if not hits:
|
|
108
|
+
hits = search_symbols(self._db, match_expr, resolved_version, max_results)
|
|
109
|
+
|
|
110
|
+
self._last_resolution = "fts"
|
|
111
|
+
return SearchDocsResult(hits=hits)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Version service — lists available documentation versions.
|
|
2
|
+
|
|
3
|
+
Trivial service that queries doc_sets table. Kept as a class
|
|
4
|
+
for symmetry with SearchService and ContentService.
|
|
5
|
+
No MCP types imported — dependency rule enforced.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from mcp_server_python_docs.models import ListVersionsResult, VersionInfo
|
|
12
|
+
from mcp_server_python_docs.services.observability import log_tool_call
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VersionService:
|
|
16
|
+
"""Version listing service for list_versions tool.
|
|
17
|
+
|
|
18
|
+
Trivial service that queries doc_sets table. Kept as a class
|
|
19
|
+
for symmetry with SearchService and ContentService.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
23
|
+
self._db = db
|
|
24
|
+
|
|
25
|
+
@log_tool_call("list_versions")
|
|
26
|
+
def list_versions(self) -> ListVersionsResult:
|
|
27
|
+
"""List all available documentation versions from doc_sets table."""
|
|
28
|
+
rows = self._db.execute(
|
|
29
|
+
"""
|
|
30
|
+
SELECT version, language, label, is_default, built_at
|
|
31
|
+
FROM doc_sets
|
|
32
|
+
ORDER BY version DESC
|
|
33
|
+
"""
|
|
34
|
+
).fetchall()
|
|
35
|
+
|
|
36
|
+
versions = [
|
|
37
|
+
VersionInfo(
|
|
38
|
+
version=row["version"],
|
|
39
|
+
language=row["language"],
|
|
40
|
+
label=row["label"],
|
|
41
|
+
is_default=bool(row["is_default"]),
|
|
42
|
+
built_at=row["built_at"] or "",
|
|
43
|
+
)
|
|
44
|
+
for row in rows
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
return ListVersionsResult(versions=versions)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared version resolution logic for all services.
|
|
2
|
+
|
|
3
|
+
Centralizes version validation and default resolution to avoid
|
|
4
|
+
duplication across SearchService and ContentService (CR-01, WR-01).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
|
|
10
|
+
from mcp_server_python_docs.errors import VersionNotFoundError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_version(db: sqlite3.Connection, version: str) -> str:
|
|
14
|
+
"""Validate that a version exists in doc_sets. Returns the version string.
|
|
15
|
+
|
|
16
|
+
Raises VersionNotFoundError with available versions list if not found (MVER-03).
|
|
17
|
+
"""
|
|
18
|
+
row = db.execute(
|
|
19
|
+
"SELECT version FROM doc_sets WHERE version = ?",
|
|
20
|
+
(version,),
|
|
21
|
+
).fetchone()
|
|
22
|
+
if row is None:
|
|
23
|
+
available = [
|
|
24
|
+
r[0]
|
|
25
|
+
for r in db.execute(
|
|
26
|
+
"SELECT version FROM doc_sets ORDER BY version"
|
|
27
|
+
).fetchall()
|
|
28
|
+
]
|
|
29
|
+
raise VersionNotFoundError(
|
|
30
|
+
f"version {version!r} not found; available: {available}"
|
|
31
|
+
)
|
|
32
|
+
return version
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_default_version(db: sqlite3.Connection) -> str:
|
|
36
|
+
"""Resolve the default version from doc_sets.
|
|
37
|
+
|
|
38
|
+
Prefers is_default=1 row, falls back to highest version.
|
|
39
|
+
Raises VersionNotFoundError if no versions exist.
|
|
40
|
+
"""
|
|
41
|
+
row = db.execute(
|
|
42
|
+
"SELECT version FROM doc_sets WHERE is_default = 1 LIMIT 1"
|
|
43
|
+
).fetchone()
|
|
44
|
+
if row is None:
|
|
45
|
+
row = db.execute(
|
|
46
|
+
"SELECT version FROM doc_sets ORDER BY version DESC LIMIT 1"
|
|
47
|
+
).fetchone()
|
|
48
|
+
if row is None:
|
|
49
|
+
raise VersionNotFoundError("No versions available in index")
|
|
50
|
+
return row[0]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_version_strict(db: sqlite3.Connection, version: str | None) -> str:
|
|
54
|
+
"""Resolve version to a concrete version string. Never returns None.
|
|
55
|
+
|
|
56
|
+
Used by ContentService where a specific version is always needed
|
|
57
|
+
(e.g., to look up a document by slug + version).
|
|
58
|
+
|
|
59
|
+
- If version is provided, validates it exists.
|
|
60
|
+
- If version is None, resolves to default version.
|
|
61
|
+
"""
|
|
62
|
+
if version is not None:
|
|
63
|
+
return validate_version(db, version)
|
|
64
|
+
return resolve_default_version(db)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_version_permissive(db: sqlite3.Connection, version: str | None) -> str | None:
|
|
68
|
+
"""Resolve version, allowing None to mean 'all versions'.
|
|
69
|
+
|
|
70
|
+
Used by SearchService where None means cross-version search
|
|
71
|
+
(intentional design -- LLMs benefit from seeing results across
|
|
72
|
+
all versions so they can compare and pick the right one).
|
|
73
|
+
|
|
74
|
+
- If version is provided, validates it exists.
|
|
75
|
+
- If version is None, returns None (search all versions).
|
|
76
|
+
"""
|
|
77
|
+
if version is not None:
|
|
78
|
+
return validate_version(db, version)
|
|
79
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Storage layer: SQLite connection management and schema."""
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SQLite connection management with RO/RW split, WAL mode, and FTS5 check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.resources
|
|
5
|
+
import logging
|
|
6
|
+
import platform
|
|
7
|
+
import sqlite3
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import platformdirs
|
|
11
|
+
|
|
12
|
+
from mcp_server_python_docs.errors import FTS5UnavailableError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_cache_dir() -> Path:
|
|
18
|
+
"""Resolve cache directory via platformdirs (STOR-10).
|
|
19
|
+
|
|
20
|
+
Returns ~/.cache/mcp-python-docs/ on Linux,
|
|
21
|
+
~/Library/Caches/mcp-python-docs/ on macOS, etc.
|
|
22
|
+
"""
|
|
23
|
+
return Path(platformdirs.user_cache_dir("mcp-python-docs"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_index_path() -> Path:
|
|
27
|
+
"""Return the default index.db path."""
|
|
28
|
+
return get_cache_dir() / "index.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _set_common_pragmas(conn: sqlite3.Connection) -> None:
|
|
32
|
+
"""Set PRAGMAs that are safe for both read-only and read-write connections."""
|
|
33
|
+
conn.execute("PRAGMA synchronous = NORMAL")
|
|
34
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _set_readonly_pragmas(conn: sqlite3.Connection) -> None:
|
|
38
|
+
"""Set PRAGMAs for read-only serving connections (STOR-07)."""
|
|
39
|
+
_set_common_pragmas(conn)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _set_readwrite_pragmas(conn: sqlite3.Connection) -> None:
|
|
43
|
+
"""Set PRAGMAs for read-write ingestion connections (STOR-07)."""
|
|
44
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
45
|
+
_set_common_pragmas(conn)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_readonly_connection(path: str | Path) -> sqlite3.Connection:
|
|
49
|
+
"""Open a read-only connection for serving (STOR-06).
|
|
50
|
+
|
|
51
|
+
Uses SQLite URI mode with ?mode=ro to prevent accidental writes.
|
|
52
|
+
"""
|
|
53
|
+
path = Path(path)
|
|
54
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=False)
|
|
55
|
+
_set_readonly_pragmas(conn)
|
|
56
|
+
conn.row_factory = sqlite3.Row
|
|
57
|
+
return conn
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_readwrite_connection(path: str | Path) -> sqlite3.Connection:
|
|
61
|
+
"""Open a read-write connection for ingestion (STOR-06).
|
|
62
|
+
|
|
63
|
+
Creates parent directories if needed. Sets WAL mode for
|
|
64
|
+
concurrent reads during ingestion.
|
|
65
|
+
"""
|
|
66
|
+
path = Path(path)
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
conn = sqlite3.connect(str(path))
|
|
69
|
+
_set_readwrite_pragmas(conn)
|
|
70
|
+
conn.row_factory = sqlite3.Row
|
|
71
|
+
return conn
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assert_fts5_available(conn: sqlite3.Connection) -> None:
|
|
75
|
+
"""Check FTS5 availability with platform-aware error message (STOR-08).
|
|
76
|
+
|
|
77
|
+
Works on both read-only and read-write connections:
|
|
78
|
+
- RW: attempts CREATE/DROP of a temp FTS5 table (definitive check)
|
|
79
|
+
- RO: falls back to PRAGMA compile_options when CREATE fails due to
|
|
80
|
+
readonly mode (not a missing-FTS5 error)
|
|
81
|
+
|
|
82
|
+
Raises FTS5UnavailableError with actionable guidance:
|
|
83
|
+
- Linux x86-64: suggests pysqlite3-binary
|
|
84
|
+
- macOS/Windows/ARM: suggests uv python install or python.org
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
conn.execute("CREATE VIRTUAL TABLE temp._fts5_check USING fts5(x)")
|
|
88
|
+
conn.execute("DROP TABLE temp._fts5_check")
|
|
89
|
+
return # FTS5 confirmed via CREATE
|
|
90
|
+
except sqlite3.OperationalError as e:
|
|
91
|
+
error_msg = str(e).lower()
|
|
92
|
+
# If the error is about readonly (not missing FTS5), check compile_options
|
|
93
|
+
if "readonly" in error_msg or "read-only" in error_msg:
|
|
94
|
+
opts = [row[0] for row in conn.execute("PRAGMA compile_options")]
|
|
95
|
+
if "ENABLE_FTS5" in opts:
|
|
96
|
+
return # FTS5 is compiled in
|
|
97
|
+
# FTS5 genuinely unavailable -- build platform-specific hint
|
|
98
|
+
system = platform.system()
|
|
99
|
+
machine = platform.machine()
|
|
100
|
+
if system == "Linux" and machine == "x86_64":
|
|
101
|
+
hint = (
|
|
102
|
+
"Install the pysqlite3-binary fallback:\n"
|
|
103
|
+
" pip install 'python-docs-mcp-server[pysqlite3]'"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
hint = (
|
|
107
|
+
"Install a Python build with FTS5 support:\n"
|
|
108
|
+
" uv python install\n"
|
|
109
|
+
"Or install Python from python.org (includes FTS5)."
|
|
110
|
+
)
|
|
111
|
+
raise FTS5UnavailableError(
|
|
112
|
+
f"SQLite FTS5 extension is not available in this Python build.\n{hint}"
|
|
113
|
+
) from e
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def bootstrap_schema(conn: sqlite3.Connection) -> None:
|
|
117
|
+
"""Create all tables and FTS5 indexes from schema.sql (STOR-01, STOR-09).
|
|
118
|
+
|
|
119
|
+
Loads the complete DDL from storage/schema.sql via importlib.resources.
|
|
120
|
+
All CREATE statements use IF NOT EXISTS for idempotency -- running this
|
|
121
|
+
twice on the same database is a no-op.
|
|
122
|
+
|
|
123
|
+
FTS5 virtual tables are dropped and recreated on every bootstrap to ensure
|
|
124
|
+
the tokenizer configuration matches the current schema.sql. This is safe
|
|
125
|
+
because FTS5 external-content tables are derived data that can be rebuilt
|
|
126
|
+
from canonical tables via the 'rebuild' command.
|
|
127
|
+
|
|
128
|
+
Warning:
|
|
129
|
+
This function uses ``executescript()``, which issues an implicit
|
|
130
|
+
``COMMIT`` before executing the DDL. Do not call ``bootstrap_schema()``
|
|
131
|
+
while a transaction with uncommitted writes is in progress -- those
|
|
132
|
+
writes will be silently committed.
|
|
133
|
+
"""
|
|
134
|
+
# Drop FTS5 virtual tables first so they can be recreated with the
|
|
135
|
+
# correct tokenizer. IF NOT EXISTS would skip recreation if the table
|
|
136
|
+
# exists with a different tokenizer -- there is no ALTER for FTS5.
|
|
137
|
+
_FTS_TABLES = ("sections_fts", "symbols_fts", "examples_fts")
|
|
138
|
+
for fts_table in _FTS_TABLES:
|
|
139
|
+
assert fts_table.isidentifier(), f"Invalid table name: {fts_table}"
|
|
140
|
+
conn.execute(f"DROP TABLE IF EXISTS {fts_table}")
|
|
141
|
+
|
|
142
|
+
# Load and execute the full schema DDL
|
|
143
|
+
ref = importlib.resources.files("mcp_server_python_docs.storage") / "schema.sql"
|
|
144
|
+
with importlib.resources.as_file(ref) as schema_path:
|
|
145
|
+
schema_sql = schema_path.read_text()
|
|
146
|
+
conn.executescript(schema_sql)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
-- schema.sql — python-docs-mcp-server storage schema
|
|
2
|
+
-- Version: Phase 2 (2026-04-16)
|
|
3
|
+
--
|
|
4
|
+
-- Design decisions:
|
|
5
|
+
-- - FTS5 tokenizer: unicode61 remove_diacritics 2 tokenchars '._'
|
|
6
|
+
-- Porter stemming is deliberately NOT applied (STOR-02). Preserving
|
|
7
|
+
-- dots and underscores as token characters ensures Python identifiers
|
|
8
|
+
-- like asyncio.TaskGroup and json.dumps are indexed as single tokens.
|
|
9
|
+
-- - sections.uri has NO standalone UNIQUE constraint (STOR-04). Cross-version
|
|
10
|
+
-- URI overlap is safe; uniqueness is enforced only by UNIQUE(document_id, anchor).
|
|
11
|
+
-- - symbols uses UNIQUE(doc_set_id, qualified_name, symbol_type) (STOR-03)
|
|
12
|
+
-- instead of UNIQUE(doc_set_id, qualified_name), allowing the same name
|
|
13
|
+
-- to appear as both a function and a method.
|
|
14
|
+
-- - doc_sets.language defaults to 'en' (STOR-05), reserving space for
|
|
15
|
+
-- future i18n without a migration.
|
|
16
|
+
|
|
17
|
+
-- ────────────────────────────────────────────────────
|
|
18
|
+
-- Canonical tables (source of truth)
|
|
19
|
+
-- ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS doc_sets (
|
|
22
|
+
id INTEGER PRIMARY KEY,
|
|
23
|
+
source TEXT NOT NULL DEFAULT 'python-docs',
|
|
24
|
+
version TEXT NOT NULL,
|
|
25
|
+
language TEXT NOT NULL DEFAULT 'en',
|
|
26
|
+
label TEXT NOT NULL,
|
|
27
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
base_url TEXT,
|
|
29
|
+
built_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
30
|
+
UNIQUE(source, version, language)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
34
|
+
id INTEGER PRIMARY KEY,
|
|
35
|
+
doc_set_id INTEGER NOT NULL REFERENCES doc_sets(id) ON DELETE CASCADE,
|
|
36
|
+
uri TEXT NOT NULL,
|
|
37
|
+
slug TEXT NOT NULL,
|
|
38
|
+
title TEXT NOT NULL,
|
|
39
|
+
content_text TEXT NOT NULL,
|
|
40
|
+
char_count INTEGER NOT NULL,
|
|
41
|
+
UNIQUE(doc_set_id, slug)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS sections (
|
|
45
|
+
id INTEGER PRIMARY KEY,
|
|
46
|
+
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
47
|
+
uri TEXT NOT NULL,
|
|
48
|
+
anchor TEXT NOT NULL,
|
|
49
|
+
heading TEXT NOT NULL,
|
|
50
|
+
level INTEGER NOT NULL,
|
|
51
|
+
ordinal INTEGER NOT NULL,
|
|
52
|
+
content_text TEXT NOT NULL,
|
|
53
|
+
char_count INTEGER NOT NULL,
|
|
54
|
+
UNIQUE(document_id, anchor)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
58
|
+
id INTEGER PRIMARY KEY,
|
|
59
|
+
doc_set_id INTEGER NOT NULL REFERENCES doc_sets(id) ON DELETE CASCADE,
|
|
60
|
+
qualified_name TEXT NOT NULL,
|
|
61
|
+
normalized_name TEXT NOT NULL,
|
|
62
|
+
module TEXT,
|
|
63
|
+
symbol_type TEXT,
|
|
64
|
+
document_id INTEGER REFERENCES documents(id) ON DELETE SET NULL,
|
|
65
|
+
section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL,
|
|
66
|
+
uri TEXT NOT NULL,
|
|
67
|
+
anchor TEXT,
|
|
68
|
+
UNIQUE(doc_set_id, qualified_name, symbol_type)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS examples (
|
|
72
|
+
id INTEGER PRIMARY KEY,
|
|
73
|
+
section_id INTEGER REFERENCES sections(id) ON DELETE CASCADE,
|
|
74
|
+
code TEXT NOT NULL,
|
|
75
|
+
language TEXT NOT NULL DEFAULT 'python',
|
|
76
|
+
is_doctest INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
ordinal INTEGER NOT NULL DEFAULT 0
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS synonyms (
|
|
81
|
+
id INTEGER PRIMARY KEY,
|
|
82
|
+
concept TEXT NOT NULL,
|
|
83
|
+
expansion TEXT NOT NULL,
|
|
84
|
+
UNIQUE(concept)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS redirects (
|
|
88
|
+
id INTEGER PRIMARY KEY,
|
|
89
|
+
doc_set_id INTEGER NOT NULL REFERENCES doc_sets(id) ON DELETE CASCADE,
|
|
90
|
+
old_uri TEXT NOT NULL,
|
|
91
|
+
new_uri TEXT NOT NULL,
|
|
92
|
+
UNIQUE(doc_set_id, old_uri)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS ingestion_runs (
|
|
96
|
+
id INTEGER PRIMARY KEY,
|
|
97
|
+
source TEXT NOT NULL,
|
|
98
|
+
version TEXT NOT NULL,
|
|
99
|
+
status TEXT NOT NULL,
|
|
100
|
+
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
101
|
+
finished_at TEXT,
|
|
102
|
+
artifact_hash TEXT,
|
|
103
|
+
notes TEXT
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
-- ────────────────────────────────────────────────────
|
|
107
|
+
-- FTS5 virtual tables (retrieval aid, not source of truth)
|
|
108
|
+
--
|
|
109
|
+
-- All use external content mode: data lives in canonical tables,
|
|
110
|
+
-- FTS indexes are rebuilt via INSERT INTO <fts>(fts) VALUES('rebuild').
|
|
111
|
+
--
|
|
112
|
+
-- Tokenizer: unicode61 remove_diacritics 2 tokenchars '._'
|
|
113
|
+
-- - unicode61: Unicode-aware tokenization
|
|
114
|
+
-- - remove_diacritics 2: map diacritical chars to ASCII equivalents
|
|
115
|
+
-- - tokenchars '._': treat dots and underscores as token characters
|
|
116
|
+
-- (not separators), so asyncio.TaskGroup is one token
|
|
117
|
+
-- - NO porter stemming: preserves exact Python identifier search
|
|
118
|
+
-- ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS sections_fts USING fts5(
|
|
121
|
+
heading, content_text,
|
|
122
|
+
content='sections', content_rowid='id',
|
|
123
|
+
tokenize="unicode61 remove_diacritics 2 tokenchars '._'"
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
|
|
127
|
+
qualified_name, module,
|
|
128
|
+
content='symbols', content_rowid='id',
|
|
129
|
+
tokenize="unicode61 remove_diacritics 2 tokenchars '._'"
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS examples_fts USING fts5(
|
|
133
|
+
code,
|
|
134
|
+
content='examples', content_rowid='id',
|
|
135
|
+
tokenize="unicode61 remove_diacritics 2 tokenchars '._'"
|
|
136
|
+
);
|