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.
Files changed (36) hide show
  1. mcp_server_python_docs/__init__.py +17 -0
  2. mcp_server_python_docs/__main__.py +574 -0
  3. mcp_server_python_docs/app_context.py +33 -0
  4. mcp_server_python_docs/data/synonyms.yaml +194 -0
  5. mcp_server_python_docs/detection.py +82 -0
  6. mcp_server_python_docs/diagnostics.py +74 -0
  7. mcp_server_python_docs/errors.py +33 -0
  8. mcp_server_python_docs/ingestion/__init__.py +1 -0
  9. mcp_server_python_docs/ingestion/cpython_versions.py +32 -0
  10. mcp_server_python_docs/ingestion/inventory.py +178 -0
  11. mcp_server_python_docs/ingestion/publish.py +504 -0
  12. mcp_server_python_docs/ingestion/sphinx_json.py +617 -0
  13. mcp_server_python_docs/models.py +216 -0
  14. mcp_server_python_docs/py.typed +0 -0
  15. mcp_server_python_docs/retrieval/__init__.py +31 -0
  16. mcp_server_python_docs/retrieval/budget.py +63 -0
  17. mcp_server_python_docs/retrieval/query.py +183 -0
  18. mcp_server_python_docs/retrieval/ranker.py +369 -0
  19. mcp_server_python_docs/server.py +329 -0
  20. mcp_server_python_docs/services/__init__.py +7 -0
  21. mcp_server_python_docs/services/cache.py +111 -0
  22. mcp_server_python_docs/services/content.py +155 -0
  23. mcp_server_python_docs/services/observability.py +130 -0
  24. mcp_server_python_docs/services/package_docs.py +161 -0
  25. mcp_server_python_docs/services/persistent_cache.py +147 -0
  26. mcp_server_python_docs/services/search.py +111 -0
  27. mcp_server_python_docs/services/version.py +47 -0
  28. mcp_server_python_docs/services/version_resolution.py +79 -0
  29. mcp_server_python_docs/storage/__init__.py +1 -0
  30. mcp_server_python_docs/storage/db.py +146 -0
  31. mcp_server_python_docs/storage/schema.sql +136 -0
  32. python_docs_mcp_server-0.1.2.dist-info/METADATA +528 -0
  33. python_docs_mcp_server-0.1.2.dist-info/RECORD +36 -0
  34. python_docs_mcp_server-0.1.2.dist-info/WHEEL +4 -0
  35. python_docs_mcp_server-0.1.2.dist-info/entry_points.txt +3 -0
  36. 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
+ );