11"""SQLite-backed cache for completed get_docs results across MCP restarts."""
2+
23from __future__ import annotations
34
5+ import logging
46import sqlite3
57from pathlib import Path
68from typing import NamedTuple
79
10+ from pydantic import ValidationError
11+
812from mcp_server_python_docs .models import GetDocsResult
913
14+ logger = logging .getLogger (__name__ )
15+ _NO_ANCHOR_KEY = "\x00 mcp-python-docs:no-anchor\x00 "
16+
1017
1118class CacheStats (NamedTuple ):
1219 hits : int = 0
@@ -21,17 +28,24 @@ def __init__(self, cache_path: Path, index_path: Path) -> None:
2128 self ._cache_path = Path (cache_path )
2229 self ._fingerprint = self ._fingerprint_index (Path (index_path ))
2330 self ._hits = self ._misses = self ._writes = 0
24- self ._cache_path .parent .mkdir (parents = True , exist_ok = True )
25- self ._conn = sqlite3 .connect (str (self ._cache_path ), check_same_thread = False )
26- self ._conn .execute ("PRAGMA synchronous = NORMAL" )
27- self ._conn .execute (
28- "CREATE TABLE IF NOT EXISTS retrieved_docs_cache ("
29- "index_fingerprint TEXT NOT NULL, version TEXT NOT NULL, slug TEXT NOT NULL, "
30- "anchor TEXT NOT NULL, max_chars INTEGER NOT NULL, start_index INTEGER NOT NULL, "
31- "result_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "
32- "PRIMARY KEY (index_fingerprint, version, slug, anchor, max_chars, start_index))"
33- )
34- self ._conn .commit ()
31+ self ._conn : sqlite3 .Connection | None = None
32+ try :
33+ self ._cache_path .parent .mkdir (parents = True , exist_ok = True )
34+ self ._conn = sqlite3 .connect (str (self ._cache_path ), check_same_thread = False )
35+ self ._conn .execute ("PRAGMA synchronous = NORMAL" )
36+ self ._conn .execute (
37+ "CREATE TABLE IF NOT EXISTS retrieved_docs_cache ("
38+ "index_fingerprint TEXT NOT NULL, version TEXT NOT NULL, slug TEXT NOT NULL, "
39+ "anchor TEXT NOT NULL, max_chars INTEGER NOT NULL, start_index INTEGER NOT NULL, "
40+ "result_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "
41+ "PRIMARY KEY (index_fingerprint, version, slug, anchor, max_chars, start_index))"
42+ )
43+ self ._conn .commit ()
44+ except (OSError , sqlite3 .Error ) as e :
45+ if self ._conn is not None :
46+ self ._conn .close ()
47+ self ._conn = None
48+ logger .warning ("Persistent docs cache disabled: %s" , e )
3549
3650 @property
3751 def cache_path (self ) -> Path :
@@ -42,40 +56,73 @@ def _fingerprint_index(index_path: Path) -> str:
4256 stat = index_path .stat ()
4357 return f"{ index_path .resolve ()} :{ stat .st_size } :{ stat .st_mtime_ns } "
4458
59+ @staticmethod
60+ def _anchor_key (anchor : str | None ) -> str :
61+ return _NO_ANCHOR_KEY if anchor is None else anchor
62+
4563 def stats (self ) -> CacheStats :
4664 return CacheStats (self ._hits , self ._misses , self ._writes )
4765
4866 def get (
4967 self , * , version : str , slug : str , anchor : str | None , max_chars : int , start_index : int
5068 ) -> GetDocsResult | None :
51- row = self ._conn .execute (
52- "SELECT result_json FROM retrieved_docs_cache WHERE index_fingerprint = ? "
53- "AND version = ? AND slug = ? AND anchor = ? AND max_chars = ? AND start_index = ?" ,
54- (self ._fingerprint , version , slug , anchor or "" , max_chars , start_index ),
55- ).fetchone ()
69+ if self ._conn is None :
70+ self ._misses += 1
71+ return None
72+ try :
73+ row = self ._conn .execute (
74+ "SELECT result_json FROM retrieved_docs_cache WHERE index_fingerprint = ? "
75+ "AND version = ? AND slug = ? AND anchor = ? AND max_chars = ? AND start_index = ?" ,
76+ (
77+ self ._fingerprint ,
78+ version ,
79+ slug ,
80+ self ._anchor_key (anchor ),
81+ max_chars ,
82+ start_index ,
83+ ),
84+ ).fetchone ()
85+ except sqlite3 .Error as e :
86+ self ._misses += 1
87+ logger .warning ("Persistent docs cache read skipped: %s" , e )
88+ return None
5689 if row is None :
5790 self ._misses += 1
5891 return None
92+ try :
93+ result = GetDocsResult .model_validate_json (row [0 ])
94+ except (ValidationError , ValueError ) as e :
95+ self ._misses += 1
96+ logger .warning ("Persistent docs cache entry ignored: %s" , e )
97+ return None
5998 self ._hits += 1
60- return GetDocsResult . model_validate_json ( row [ 0 ])
99+ return result
61100
62101 def put (self , * , result : GetDocsResult , max_chars : int , start_index : int ) -> None :
63- self ._conn .execute (
64- "INSERT OR REPLACE INTO retrieved_docs_cache "
65- "(index_fingerprint, version, slug, anchor, max_chars, start_index, result_json) "
66- "VALUES (?, ?, ?, ?, ?, ?, ?)" ,
67- (
68- self ._fingerprint ,
69- result .version ,
70- result .slug ,
71- result .anchor or "" ,
72- max_chars ,
73- start_index ,
74- result .model_dump_json (),
75- ),
76- )
77- self ._conn .commit ()
102+ if self ._conn is None :
103+ return
104+ try :
105+ self ._conn .execute (
106+ "INSERT OR REPLACE INTO retrieved_docs_cache "
107+ "(index_fingerprint, version, slug, anchor, max_chars, start_index, result_json) "
108+ "VALUES (?, ?, ?, ?, ?, ?, ?)" ,
109+ (
110+ self ._fingerprint ,
111+ result .version ,
112+ result .slug ,
113+ self ._anchor_key (result .anchor ),
114+ max_chars ,
115+ start_index ,
116+ result .model_dump_json (),
117+ ),
118+ )
119+ self ._conn .commit ()
120+ except (sqlite3 .Error , ValueError ) as e :
121+ logger .warning ("Persistent docs cache write skipped: %s" , e )
122+ return
78123 self ._writes += 1
79124
80125 def close (self ) -> None :
81- self ._conn .close ()
126+ if self ._conn is not None :
127+ self ._conn .close ()
128+ self ._conn = None
0 commit comments