mcp-cache 0.1.1__tar.gz

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.
@@ -0,0 +1,54 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ test:
11
+ name: "Python ${{ matrix.python-version }}"
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ cache: pip
25
+
26
+ - name: Install package and dev dependencies
27
+ run: pip install -e ".[dev]"
28
+
29
+ - name: Run tests
30
+ run: pytest --tb=short -q
31
+
32
+ build:
33
+ name: Build distribution
34
+ runs-on: ubuntu-latest
35
+ needs: test
36
+
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: "3.12"
43
+ cache: pip
44
+
45
+ - name: Install build tools
46
+ run: pip install build
47
+
48
+ - name: Build wheel and sdist
49
+ run: python -m build
50
+
51
+ - uses: actions/upload-artifact@v4
52
+ with:
53
+ name: dist
54
+ path: dist/
@@ -0,0 +1,43 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ concurrency:
9
+ group: release-${{ github.ref }}
10
+ cancel-in-progress: false
11
+
12
+ jobs:
13
+ release:
14
+ name: Semantic release + PyPI publish
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: write
18
+ id-token: write
19
+ steps:
20
+ - name: Checkout (full history for semantic-release)
21
+ uses: actions/checkout@v4
22
+ with:
23
+ fetch-depth: 0
24
+ token: ${{ secrets.GITHUB_TOKEN }}
25
+ persist-credentials: true
26
+
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+
31
+ - name: Python Semantic Release
32
+ id: release
33
+ uses: python-semantic-release/python-semantic-release@v9
34
+ with:
35
+ github_token: ${{ secrets.GITHUB_TOKEN }}
36
+
37
+ - name: Publish to PyPI
38
+ if: steps.release.outputs.released == 'true'
39
+ uses: pypa/gh-action-pypi-publish@release/v1
40
+ with:
41
+ password: ${{ secrets.PYPI_API_TOKEN }}
42
+ packages-dir: dist
43
+ skip-existing: true
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ *.db
@@ -0,0 +1,64 @@
1
+ # CHANGELOG
2
+
3
+
4
+ ## v0.1.1 (2026-06-04)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Cache None values correctly in TTL cache
9
+ ([`885b19f`](https://github.com/cmaurer/mcp-cache/commit/885b19f7667755d5c103381dc69713d592a75164))
10
+
11
+ get_or_fetch and get() both returned None for a missing key and for a legitimately cached None
12
+ value, causing a false cache miss on the second call. Introduced a _MISSING sentinel so the two
13
+ cases are distinguished.
14
+
15
+ Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
16
+
17
+
18
+ ## v0.1.0 (2026-06-04)
19
+
20
+ ### Chores
21
+
22
+ - Adding missed files
23
+ ([`85840c4`](https://github.com/cmaurer/mcp-cache/commit/85840c404426d3cbdbc1a86a2938d8f1ad6963ed))
24
+
25
+ ### Continuous Integration
26
+
27
+ - Add PyPI publish workflow on GitHub release
28
+ ([`954a8f3`](https://github.com/cmaurer/mcp-cache/commit/954a8f3f50fb2c8c288ce2a3d63d87092a75d993))
29
+
30
+ Uses OIDC trusted publishing — no API token secret required.
31
+
32
+ Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
33
+
34
+ - Replace publish.yml with semantic-release workflow
35
+ ([`6368f0f`](https://github.com/cmaurer/mcp-cache/commit/6368f0f2cd69531d6e79c86e70b34e5164150d98))
36
+
37
+ - release.yml: runs python-semantic-release on every push to main, bumps version in pyproject.toml,
38
+ creates GitHub release, and publishes to PyPI (requires PYPI_API_TOKEN secret) - pyproject.toml:
39
+ add [tool.semantic_release] config pointing at project.version for automated version bumps -
40
+ remove publish.yml (release.yml now owns PyPI publishing)
41
+
42
+ Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
43
+
44
+ ### Features
45
+
46
+ - Add MCP server for Claude Desktop
47
+ ([`91f2fe6`](https://github.com/cmaurer/mcp-cache/commit/91f2fe6116762067f0d149a21a1eeddb3b42d5da))
48
+
49
+ - server.py: FastMCP server exposing 8 tools (cache_get/set/invalidate, cache_clear_expired,
50
+ timeseries_store/get/invalidate, cache_stats) - MCPCache: add public get() and set() methods -
51
+ pyproject.toml: mcp>=1.0 dependency + mcp-cache console script - README: Claude Desktop config and
52
+ tool reference table - tests: 4 new tests for get/set public methods (33 total)
53
+
54
+ Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
55
+
56
+ - Add unit tests, README, and GitHub Actions CI
57
+ ([`64857e1`](https://github.com/cmaurer/mcp-cache/commit/64857e1bad8b2af67f604cafae3eedc459214cc9))
58
+
59
+ - 29 pytest-asyncio tests covering TTL cache, time series gap detection, invalidation, diagnostics,
60
+ and edge cases - README with installation, quick-start, and full API reference - CI workflow: test
61
+ matrix across Python 3.10–3.13 + build artifact job - pyproject.toml: dev extras (pytest,
62
+ pytest-asyncio) and pytest config
63
+
64
+ Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-cache
3
+ Version: 0.1.1
4
+ Summary: Reusable SQLite-backed cache for MCP servers — TTL and incremental time series strategies
5
+ Project-URL: Homepage, https://github.com/cmaurer/mcp-cache
6
+ Project-URL: Issues, https://github.com/cmaurer/mcp-cache/issues
7
+ License: MIT
8
+ Keywords: cache,llm,mcp,sqlite,time-series
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp>=1.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # mcp-cache
17
+
18
+ A reusable SQLite-backed cache for MCP (Model Context Protocol) servers. Zero dependencies — pure Python stdlib.
19
+
20
+ Two caching strategies:
21
+
22
+ - **TTL cache** — store any JSON-serializable value for N seconds
23
+ - **Time series cache** — store date-keyed observations and automatically fetch only missing date ranges
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install mcp-cache
29
+ ```
30
+
31
+ Or from source:
32
+
33
+ ```bash
34
+ git clone https://github.com/cmaurer/mcp-cache
35
+ cd mcp-cache
36
+ pip install -e .
37
+ ```
38
+
39
+ Requires Python 3.10+.
40
+
41
+ ## Quick start
42
+
43
+ ### TTL cache
44
+
45
+ ```python
46
+ from mcp_cache import MCPCache
47
+
48
+ cache = MCPCache("~/.cache/myserver.db", default_ttl=300)
49
+
50
+ async def get_quote(symbol: str) -> dict:
51
+ return await cache.get_or_fetch(
52
+ key=f"quote:{symbol}",
53
+ fetch_fn=lambda: api.fetch_quote(symbol),
54
+ ttl=60, # override default; omit to use default_ttl
55
+ )
56
+ ```
57
+
58
+ `fetch_fn` is called only on a cache miss or after the TTL expires. The result is stored as JSON and returned on subsequent calls within the TTL window.
59
+
60
+ ### Time series cache
61
+
62
+ ```python
63
+ observations = await cache.get_timeseries(
64
+ series_id="T10YIE",
65
+ start_date="2020-01-01",
66
+ end_date="2024-12-31",
67
+ fetch_fn=lambda s, e: fred_api.get_series("T10YIE", s, e),
68
+ )
69
+ # Returns list of {"date": "YYYY-MM-DD", "value": float} dicts, newest first
70
+ ```
71
+
72
+ On the first call the full range is fetched. On subsequent calls only gaps are fetched — requesting a wider range re-uses the already-cached portion and fetches only the missing edges.
73
+
74
+ Custom key names are supported for APIs that don't use `"date"` / `"value"`:
75
+
76
+ ```python
77
+ observations = await cache.get_timeseries(
78
+ series_id="prices",
79
+ start_date="2024-01-01",
80
+ end_date="2024-06-30",
81
+ fetch_fn=my_fetch,
82
+ date_key="timestamp",
83
+ value_key="close",
84
+ )
85
+ ```
86
+
87
+ ## API reference
88
+
89
+ ### `MCPCache(db_path, default_ttl)`
90
+
91
+ | Parameter | Type | Default | Description |
92
+ |-----------|------|---------|-------------|
93
+ | `db_path` | `str \| Path` | `"mcp_cache.db"` | Path to SQLite file. `~` is expanded. Parent dirs are created automatically. |
94
+ | `default_ttl` | `int` | `300` | Default TTL in seconds for `get_or_fetch`. |
95
+
96
+ ---
97
+
98
+ ### TTL cache methods
99
+
100
+ #### `await cache.get_or_fetch(key, fetch_fn, ttl=None)`
101
+
102
+ Return the cached value for `key` if it exists and is still fresh. Otherwise call `fetch_fn()`, store the result, and return it.
103
+
104
+ - `key` — cache key string
105
+ - `fetch_fn` — async callable that returns a JSON-serializable value
106
+ - `ttl` — per-call TTL override in seconds; uses `default_ttl` if omitted
107
+
108
+ #### `await cache.invalidate(key)`
109
+
110
+ Remove a specific entry from the TTL cache. No-op if the key does not exist.
111
+
112
+ #### `await cache.clear_expired()`
113
+
114
+ Delete all expired TTL entries. Returns the number of entries removed.
115
+
116
+ ---
117
+
118
+ ### Time series methods
119
+
120
+ #### `await cache.get_timeseries(series_id, start_date, end_date, fetch_fn, date_key="date", value_key="value")`
121
+
122
+ Return cached observations for `series_id` in `[start_date, end_date]`.
123
+
124
+ - `series_id` — identifier for the series
125
+ - `start_date`, `end_date` — `date` objects or ISO strings (`"YYYY-MM-DD"`)
126
+ - `fetch_fn(start, end)` — async callable receiving ISO date strings; must return a list of dicts containing `date_key` and optionally `value_key`
127
+ - `date_key`, `value_key` — key names in the returned dicts (default `"date"`, `"value"`)
128
+
129
+ Returns a list of `{date_key: str, value_key: float | None}` dicts sorted **newest first**. `None` values are preserved — they represent real data points (e.g. non-trading days).
130
+
131
+ #### `await cache.invalidate_series(series_id)`
132
+
133
+ Remove all cached observations and range records for a series.
134
+
135
+ ---
136
+
137
+ ### Diagnostics
138
+
139
+ #### `await cache.stats()`
140
+
141
+ Returns a dict with cache counts:
142
+
143
+ ```python
144
+ {
145
+ "ttl_cache": {"total": 12, "fresh": 10, "expired": 2},
146
+ "timeseries": {"series": 3, "observations": 1500, "fetched_ranges": 6},
147
+ "db_path": "/home/user/.cache/myserver.db",
148
+ }
149
+ ```
150
+
151
+ ## Using with an MCP server
152
+
153
+ ```python
154
+ from mcp_cache import MCPCache
155
+
156
+ _cache = MCPCache("~/.cache/myserver.db")
157
+
158
+ @server.tool()
159
+ async def get_price_history(symbol: str, start: str, end: str) -> list[dict]:
160
+ return await _cache.get_timeseries(
161
+ series_id=symbol,
162
+ start_date=start,
163
+ end_date=end,
164
+ fetch_fn=lambda s, e: data_provider.fetch(symbol, s, e),
165
+ )
166
+ ```
167
+
168
+ ## Using with Claude Desktop
169
+
170
+ Install the package:
171
+
172
+ ```bash
173
+ pip install mcp-cache
174
+ ```
175
+
176
+ Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json`:
177
+
178
+ ```json
179
+ {
180
+ "mcpServers": {
181
+ "cache": {
182
+ "command": "mcp-cache"
183
+ }
184
+ }
185
+ }
186
+ ```
187
+
188
+ Restart Claude Desktop. The following tools will be available:
189
+
190
+ | Tool | Description |
191
+ |------|-------------|
192
+ | `cache_get(key)` | Return a cached value, or `null` if missing/expired |
193
+ | `cache_set(key, value, ttl)` | Store a JSON value for `ttl` seconds (default 300) |
194
+ | `cache_invalidate(key)` | Remove a specific entry |
195
+ | `cache_clear_expired()` | Delete all expired entries |
196
+ | `timeseries_store(series_id, observations, range_start, range_end)` | Store date-keyed observations |
197
+ | `timeseries_get(series_id, start_date, end_date)` | Query cached observations |
198
+ | `timeseries_invalidate(series_id)` | Clear all data for a series |
199
+ | `cache_stats()` | Return entry counts and db path |
200
+
201
+ The cache database is stored at `~/.cache/mcp_cache.db`.
202
+
203
+ ## Development
204
+
205
+ ```bash
206
+ pip install -e ".[dev]"
207
+ pytest
208
+ ```
209
+
210
+ ## License
211
+
212
+ MIT
@@ -0,0 +1,197 @@
1
+ # mcp-cache
2
+
3
+ A reusable SQLite-backed cache for MCP (Model Context Protocol) servers. Zero dependencies — pure Python stdlib.
4
+
5
+ Two caching strategies:
6
+
7
+ - **TTL cache** — store any JSON-serializable value for N seconds
8
+ - **Time series cache** — store date-keyed observations and automatically fetch only missing date ranges
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install mcp-cache
14
+ ```
15
+
16
+ Or from source:
17
+
18
+ ```bash
19
+ git clone https://github.com/cmaurer/mcp-cache
20
+ cd mcp-cache
21
+ pip install -e .
22
+ ```
23
+
24
+ Requires Python 3.10+.
25
+
26
+ ## Quick start
27
+
28
+ ### TTL cache
29
+
30
+ ```python
31
+ from mcp_cache import MCPCache
32
+
33
+ cache = MCPCache("~/.cache/myserver.db", default_ttl=300)
34
+
35
+ async def get_quote(symbol: str) -> dict:
36
+ return await cache.get_or_fetch(
37
+ key=f"quote:{symbol}",
38
+ fetch_fn=lambda: api.fetch_quote(symbol),
39
+ ttl=60, # override default; omit to use default_ttl
40
+ )
41
+ ```
42
+
43
+ `fetch_fn` is called only on a cache miss or after the TTL expires. The result is stored as JSON and returned on subsequent calls within the TTL window.
44
+
45
+ ### Time series cache
46
+
47
+ ```python
48
+ observations = await cache.get_timeseries(
49
+ series_id="T10YIE",
50
+ start_date="2020-01-01",
51
+ end_date="2024-12-31",
52
+ fetch_fn=lambda s, e: fred_api.get_series("T10YIE", s, e),
53
+ )
54
+ # Returns list of {"date": "YYYY-MM-DD", "value": float} dicts, newest first
55
+ ```
56
+
57
+ On the first call the full range is fetched. On subsequent calls only gaps are fetched — requesting a wider range re-uses the already-cached portion and fetches only the missing edges.
58
+
59
+ Custom key names are supported for APIs that don't use `"date"` / `"value"`:
60
+
61
+ ```python
62
+ observations = await cache.get_timeseries(
63
+ series_id="prices",
64
+ start_date="2024-01-01",
65
+ end_date="2024-06-30",
66
+ fetch_fn=my_fetch,
67
+ date_key="timestamp",
68
+ value_key="close",
69
+ )
70
+ ```
71
+
72
+ ## API reference
73
+
74
+ ### `MCPCache(db_path, default_ttl)`
75
+
76
+ | Parameter | Type | Default | Description |
77
+ |-----------|------|---------|-------------|
78
+ | `db_path` | `str \| Path` | `"mcp_cache.db"` | Path to SQLite file. `~` is expanded. Parent dirs are created automatically. |
79
+ | `default_ttl` | `int` | `300` | Default TTL in seconds for `get_or_fetch`. |
80
+
81
+ ---
82
+
83
+ ### TTL cache methods
84
+
85
+ #### `await cache.get_or_fetch(key, fetch_fn, ttl=None)`
86
+
87
+ Return the cached value for `key` if it exists and is still fresh. Otherwise call `fetch_fn()`, store the result, and return it.
88
+
89
+ - `key` — cache key string
90
+ - `fetch_fn` — async callable that returns a JSON-serializable value
91
+ - `ttl` — per-call TTL override in seconds; uses `default_ttl` if omitted
92
+
93
+ #### `await cache.invalidate(key)`
94
+
95
+ Remove a specific entry from the TTL cache. No-op if the key does not exist.
96
+
97
+ #### `await cache.clear_expired()`
98
+
99
+ Delete all expired TTL entries. Returns the number of entries removed.
100
+
101
+ ---
102
+
103
+ ### Time series methods
104
+
105
+ #### `await cache.get_timeseries(series_id, start_date, end_date, fetch_fn, date_key="date", value_key="value")`
106
+
107
+ Return cached observations for `series_id` in `[start_date, end_date]`.
108
+
109
+ - `series_id` — identifier for the series
110
+ - `start_date`, `end_date` — `date` objects or ISO strings (`"YYYY-MM-DD"`)
111
+ - `fetch_fn(start, end)` — async callable receiving ISO date strings; must return a list of dicts containing `date_key` and optionally `value_key`
112
+ - `date_key`, `value_key` — key names in the returned dicts (default `"date"`, `"value"`)
113
+
114
+ Returns a list of `{date_key: str, value_key: float | None}` dicts sorted **newest first**. `None` values are preserved — they represent real data points (e.g. non-trading days).
115
+
116
+ #### `await cache.invalidate_series(series_id)`
117
+
118
+ Remove all cached observations and range records for a series.
119
+
120
+ ---
121
+
122
+ ### Diagnostics
123
+
124
+ #### `await cache.stats()`
125
+
126
+ Returns a dict with cache counts:
127
+
128
+ ```python
129
+ {
130
+ "ttl_cache": {"total": 12, "fresh": 10, "expired": 2},
131
+ "timeseries": {"series": 3, "observations": 1500, "fetched_ranges": 6},
132
+ "db_path": "/home/user/.cache/myserver.db",
133
+ }
134
+ ```
135
+
136
+ ## Using with an MCP server
137
+
138
+ ```python
139
+ from mcp_cache import MCPCache
140
+
141
+ _cache = MCPCache("~/.cache/myserver.db")
142
+
143
+ @server.tool()
144
+ async def get_price_history(symbol: str, start: str, end: str) -> list[dict]:
145
+ return await _cache.get_timeseries(
146
+ series_id=symbol,
147
+ start_date=start,
148
+ end_date=end,
149
+ fetch_fn=lambda s, e: data_provider.fetch(symbol, s, e),
150
+ )
151
+ ```
152
+
153
+ ## Using with Claude Desktop
154
+
155
+ Install the package:
156
+
157
+ ```bash
158
+ pip install mcp-cache
159
+ ```
160
+
161
+ Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json`:
162
+
163
+ ```json
164
+ {
165
+ "mcpServers": {
166
+ "cache": {
167
+ "command": "mcp-cache"
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ Restart Claude Desktop. The following tools will be available:
174
+
175
+ | Tool | Description |
176
+ |------|-------------|
177
+ | `cache_get(key)` | Return a cached value, or `null` if missing/expired |
178
+ | `cache_set(key, value, ttl)` | Store a JSON value for `ttl` seconds (default 300) |
179
+ | `cache_invalidate(key)` | Remove a specific entry |
180
+ | `cache_clear_expired()` | Delete all expired entries |
181
+ | `timeseries_store(series_id, observations, range_start, range_end)` | Store date-keyed observations |
182
+ | `timeseries_get(series_id, start_date, end_date)` | Query cached observations |
183
+ | `timeseries_invalidate(series_id)` | Clear all data for a series |
184
+ | `cache_stats()` | Return entry counts and db path |
185
+
186
+ The cache database is stored at `~/.cache/mcp_cache.db`.
187
+
188
+ ## Development
189
+
190
+ ```bash
191
+ pip install -e ".[dev]"
192
+ pytest
193
+ ```
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,11 @@
1
+ """
2
+ mcp_cache — reusable SQLite-backed cache for MCP servers.
3
+
4
+ Two strategies:
5
+ - TTL cache : cache any JSON-serializable value for N seconds
6
+ - Time series : store date-keyed observations and fetch only missing ranges
7
+ """
8
+
9
+ from .cache import MCPCache
10
+
11
+ __all__ = ["MCPCache"]
@@ -0,0 +1,277 @@
1
+ """SQLite-backed cache with TTL and time series strategies."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sqlite3
6
+ import time
7
+ from datetime import date, timedelta
8
+ from pathlib import Path
9
+ from typing import Any, Awaitable, Callable
10
+
11
+
12
+ _MISSING = object() # sentinel distinguishing a cached None from a cache miss
13
+
14
+
15
+ def _to_date_str(d: date | str) -> str:
16
+ if isinstance(d, str):
17
+ return d
18
+ return d.isoformat()
19
+
20
+
21
+ class MCPCache:
22
+ """
23
+ Persistent SQLite cache for MCP server API responses.
24
+
25
+ Usage — TTL cache (any API response):
26
+ cache = MCPCache("~/.cache/myserver.db")
27
+ data = await cache.get_or_fetch("fred:T10YIE", fetch_fn, ttl=3600)
28
+
29
+ Usage — time series (date-keyed observations):
30
+ observations = await cache.get_timeseries(
31
+ series_id="T10YIE",
32
+ start_date="2020-01-01",
33
+ end_date="2024-12-31",
34
+ fetch_fn=lambda s, e: api.get_series("T10YIE", s, e),
35
+ )
36
+ """
37
+
38
+ def __init__(self, db_path: str | Path = "mcp_cache.db", default_ttl: int = 300):
39
+ self._db_path = str(Path(db_path).expanduser().resolve())
40
+ self._default_ttl = default_ttl
41
+ self._lock = asyncio.Lock()
42
+ self._init_db()
43
+
44
+ def _init_db(self) -> None:
45
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
46
+ with sqlite3.connect(self._db_path) as conn:
47
+ conn.executescript("""
48
+ CREATE TABLE IF NOT EXISTS ttl_cache (
49
+ key TEXT PRIMARY KEY,
50
+ value_json TEXT NOT NULL,
51
+ cached_at REAL NOT NULL,
52
+ ttl INTEGER NOT NULL
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS ts_observations (
56
+ series_id TEXT NOT NULL,
57
+ obs_date TEXT NOT NULL,
58
+ value REAL,
59
+ PRIMARY KEY (series_id, obs_date)
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS ts_ranges (
63
+ series_id TEXT NOT NULL,
64
+ start_date TEXT NOT NULL,
65
+ end_date TEXT NOT NULL,
66
+ fetched_at REAL NOT NULL
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_ts_ranges_series
70
+ ON ts_ranges (series_id, start_date, end_date);
71
+ """)
72
+
73
+ # --- TTL cache ---
74
+
75
+ async def get_or_fetch(
76
+ self,
77
+ key: str,
78
+ fetch_fn: Callable[[], Awaitable[Any]],
79
+ ttl: int | None = None,
80
+ ) -> Any:
81
+ """Return cached value if fresh; otherwise call fetch_fn, cache, and return."""
82
+ effective_ttl = ttl if ttl is not None else self._default_ttl
83
+ cached = await asyncio.to_thread(self._ttl_get, key)
84
+ if cached is not _MISSING:
85
+ return cached
86
+ value = await fetch_fn()
87
+ await asyncio.to_thread(self._ttl_set, key, value, effective_ttl)
88
+ return value
89
+
90
+ def _ttl_get(self, key: str) -> Any:
91
+ with sqlite3.connect(self._db_path) as conn:
92
+ row = conn.execute(
93
+ "SELECT value_json, cached_at, ttl FROM ttl_cache WHERE key = ?", (key,)
94
+ ).fetchone()
95
+ if row is None:
96
+ return _MISSING
97
+ value_json, cached_at, ttl = row
98
+ if time.time() - cached_at > ttl:
99
+ return _MISSING
100
+ return json.loads(value_json)
101
+
102
+ def _ttl_set(self, key: str, value: Any, ttl: int) -> None:
103
+ with sqlite3.connect(self._db_path) as conn:
104
+ conn.execute(
105
+ """
106
+ INSERT INTO ttl_cache (key, value_json, cached_at, ttl)
107
+ VALUES (?, ?, ?, ?)
108
+ ON CONFLICT(key) DO UPDATE SET
109
+ value_json = excluded.value_json,
110
+ cached_at = excluded.cached_at,
111
+ ttl = excluded.ttl
112
+ """,
113
+ (key, json.dumps(value), time.time(), ttl),
114
+ )
115
+
116
+ async def get(self, key: str) -> Any | None:
117
+ """Return the cached value for key, or None if missing or expired."""
118
+ result = await asyncio.to_thread(self._ttl_get, key)
119
+ return None if result is _MISSING else result
120
+
121
+ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
122
+ """Store value under key. Uses default_ttl if ttl is not specified."""
123
+ effective_ttl = ttl if ttl is not None else self._default_ttl
124
+ await asyncio.to_thread(self._ttl_set, key, value, effective_ttl)
125
+
126
+ async def invalidate(self, key: str) -> None:
127
+ """Remove a specific TTL cache entry."""
128
+ await asyncio.to_thread(self._ttl_delete, key)
129
+
130
+ def _ttl_delete(self, key: str) -> None:
131
+ with sqlite3.connect(self._db_path) as conn:
132
+ conn.execute("DELETE FROM ttl_cache WHERE key = ?", (key,))
133
+
134
+ async def clear_expired(self) -> int:
135
+ """Delete all expired TTL entries. Returns the number removed."""
136
+ return await asyncio.to_thread(self._ttl_clear_expired)
137
+
138
+ def _ttl_clear_expired(self) -> int:
139
+ with sqlite3.connect(self._db_path) as conn:
140
+ cur = conn.execute(
141
+ "DELETE FROM ttl_cache WHERE (? - cached_at) > ttl", (time.time(),)
142
+ )
143
+ return cur.rowcount
144
+
145
+ # --- Time series cache ---
146
+
147
+ async def get_timeseries(
148
+ self,
149
+ series_id: str,
150
+ start_date: date | str,
151
+ end_date: date | str,
152
+ fetch_fn: Callable[[str, str], Awaitable[list[dict]]],
153
+ date_key: str = "date",
154
+ value_key: str = "value",
155
+ ) -> list[dict]:
156
+ """
157
+ Return observations for series_id in [start_date, end_date].
158
+
159
+ Computes which sub-ranges are not yet cached, calls fetch_fn(start, end)
160
+ for each gap (ISO date strings), stores new observations, and returns
161
+ the merged result sorted descending by date (newest first).
162
+
163
+ fetch_fn must return a list of dicts with date_key and value_key.
164
+ Null/None values are stored — they represent real data points.
165
+ """
166
+ start = _to_date_str(start_date)
167
+ end = _to_date_str(end_date)
168
+ gaps = await asyncio.to_thread(self._ts_find_gaps, series_id, start, end)
169
+ for gap_start, gap_end in gaps:
170
+ new_obs = await fetch_fn(gap_start, gap_end)
171
+ await asyncio.to_thread(
172
+ self._ts_store, series_id, new_obs, gap_start, gap_end, date_key, value_key
173
+ )
174
+ return await asyncio.to_thread(self._ts_query, series_id, start, end, date_key, value_key)
175
+
176
+ def _ts_find_gaps(self, series_id: str, start: str, end: str) -> list[tuple[str, str]]:
177
+ with sqlite3.connect(self._db_path) as conn:
178
+ rows = conn.execute(
179
+ """
180
+ SELECT start_date, end_date FROM ts_ranges
181
+ WHERE series_id = ? AND start_date <= ? AND end_date >= ?
182
+ ORDER BY start_date
183
+ """,
184
+ (series_id, end, start),
185
+ ).fetchall()
186
+
187
+ if not rows:
188
+ return [(start, end)]
189
+
190
+ merged: list[tuple[str, str]] = []
191
+ for r_start, r_end in rows:
192
+ if merged and r_start <= merged[-1][1]:
193
+ merged[-1] = (merged[-1][0], max(merged[-1][1], r_end))
194
+ else:
195
+ merged.append((r_start, r_end))
196
+
197
+ gaps: list[tuple[str, str]] = []
198
+ cursor = start
199
+ for covered_start, covered_end in merged:
200
+ if cursor < covered_start:
201
+ gaps.append((cursor, _prev_day(covered_start)))
202
+ cursor = max(cursor, _next_day(covered_end))
203
+ if cursor <= end:
204
+ gaps.append((cursor, end))
205
+ return gaps
206
+
207
+ def _ts_store(self, series_id, observations, range_start, range_end, date_key, value_key):
208
+ with sqlite3.connect(self._db_path) as conn:
209
+ conn.executemany(
210
+ """
211
+ INSERT INTO ts_observations (series_id, obs_date, value)
212
+ VALUES (?, ?, ?)
213
+ ON CONFLICT(series_id, obs_date) DO UPDATE SET value = excluded.value
214
+ """,
215
+ [
216
+ (series_id, str(obs[date_key]), obs.get(value_key))
217
+ for obs in observations
218
+ if date_key in obs
219
+ ],
220
+ )
221
+ conn.execute(
222
+ "INSERT INTO ts_ranges (series_id, start_date, end_date, fetched_at) VALUES (?, ?, ?, ?)",
223
+ (series_id, range_start, range_end, time.time()),
224
+ )
225
+
226
+ def _ts_query(self, series_id, start, end, date_key, value_key):
227
+ with sqlite3.connect(self._db_path) as conn:
228
+ rows = conn.execute(
229
+ """
230
+ SELECT obs_date, value FROM ts_observations
231
+ WHERE series_id = ? AND obs_date >= ? AND obs_date <= ?
232
+ ORDER BY obs_date DESC
233
+ """,
234
+ (series_id, start, end),
235
+ ).fetchall()
236
+ return [{date_key: row[0], value_key: row[1]} for row in rows]
237
+
238
+ async def invalidate_series(self, series_id: str) -> None:
239
+ """Remove all cached observations and range records for a series."""
240
+ await asyncio.to_thread(self._ts_delete_series, series_id)
241
+
242
+ def _ts_delete_series(self, series_id: str) -> None:
243
+ with sqlite3.connect(self._db_path) as conn:
244
+ conn.execute("DELETE FROM ts_observations WHERE series_id = ?", (series_id,))
245
+ conn.execute("DELETE FROM ts_ranges WHERE series_id = ?", (series_id,))
246
+
247
+ # --- Diagnostics ---
248
+
249
+ async def stats(self) -> dict:
250
+ """Return counts and basic info about the cache contents."""
251
+ return await asyncio.to_thread(self._stats)
252
+
253
+ def _stats(self) -> dict:
254
+ now = time.time()
255
+ with sqlite3.connect(self._db_path) as conn:
256
+ ttl_total = conn.execute("SELECT COUNT(*) FROM ttl_cache").fetchone()[0]
257
+ ttl_fresh = conn.execute(
258
+ "SELECT COUNT(*) FROM ttl_cache WHERE (? - cached_at) <= ttl", (now,)
259
+ ).fetchone()[0]
260
+ ts_series = conn.execute(
261
+ "SELECT COUNT(DISTINCT series_id) FROM ts_observations"
262
+ ).fetchone()[0]
263
+ ts_obs = conn.execute("SELECT COUNT(*) FROM ts_observations").fetchone()[0]
264
+ ts_ranges = conn.execute("SELECT COUNT(*) FROM ts_ranges").fetchone()[0]
265
+ return {
266
+ "ttl_cache": {"total": ttl_total, "fresh": ttl_fresh, "expired": ttl_total - ttl_fresh},
267
+ "timeseries": {"series": ts_series, "observations": ts_obs, "fetched_ranges": ts_ranges},
268
+ "db_path": self._db_path,
269
+ }
270
+
271
+
272
+ def _prev_day(iso: str) -> str:
273
+ return (date.fromisoformat(iso) - timedelta(days=1)).isoformat()
274
+
275
+
276
+ def _next_day(iso: str) -> str:
277
+ return (date.fromisoformat(iso) + timedelta(days=1)).isoformat()
@@ -0,0 +1,105 @@
1
+ """Standalone MCP server exposing MCPCache as tools for Claude Desktop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+ from .cache import MCPCache
12
+
13
+ _DEFAULT_DB = Path.home() / ".cache" / "mcp_cache.db"
14
+
15
+ app = FastMCP("mcp-cache")
16
+ _cache = MCPCache(_DEFAULT_DB)
17
+
18
+
19
+ @app.tool()
20
+ async def cache_get(key: str) -> str:
21
+ """Return the cached JSON value for key, or null if missing or expired."""
22
+ value = await _cache.get(key)
23
+ return json.dumps(value)
24
+
25
+
26
+ @app.tool()
27
+ async def cache_set(key: str, value: str, ttl: int = 300) -> str:
28
+ """Store value (a JSON string) under key for ttl seconds."""
29
+ await _cache.set(key, json.loads(value), ttl)
30
+ return f"cached '{key}' for {ttl}s"
31
+
32
+
33
+ @app.tool()
34
+ async def cache_invalidate(key: str) -> str:
35
+ """Remove a specific TTL cache entry."""
36
+ await _cache.invalidate(key)
37
+ return f"invalidated '{key}'"
38
+
39
+
40
+ @app.tool()
41
+ async def cache_clear_expired() -> str:
42
+ """Delete all expired TTL entries and return the count removed."""
43
+ count = await _cache.clear_expired()
44
+ return f"removed {count} expired entries"
45
+
46
+
47
+ @app.tool()
48
+ async def timeseries_store(
49
+ series_id: str,
50
+ observations: str,
51
+ range_start: str,
52
+ range_end: str,
53
+ date_key: str = "date",
54
+ value_key: str = "value",
55
+ ) -> str:
56
+ """Store time series observations.
57
+
58
+ observations — JSON array of objects each containing date_key and value_key.
59
+ range_start / range_end — YYYY-MM-DD boundaries that were fetched (used for gap tracking).
60
+ """
61
+ obs = json.loads(observations)
62
+ await asyncio.to_thread(
63
+ _cache._ts_store, series_id, obs, range_start, range_end, date_key, value_key
64
+ )
65
+ return f"stored {len(obs)} observations for '{series_id}' [{range_start} – {range_end}]"
66
+
67
+
68
+ @app.tool()
69
+ async def timeseries_get(
70
+ series_id: str,
71
+ start_date: str,
72
+ end_date: str,
73
+ date_key: str = "date",
74
+ value_key: str = "value",
75
+ ) -> str:
76
+ """Return cached observations for series_id between start_date and end_date (YYYY-MM-DD).
77
+
78
+ Only returns already-cached data — does not call any external fetch.
79
+ Results are sorted newest-first.
80
+ """
81
+ rows = await asyncio.to_thread(
82
+ _cache._ts_query, series_id, start_date, end_date, date_key, value_key
83
+ )
84
+ return json.dumps(rows)
85
+
86
+
87
+ @app.tool()
88
+ async def timeseries_invalidate(series_id: str) -> str:
89
+ """Remove all cached observations and range records for series_id."""
90
+ await _cache.invalidate_series(series_id)
91
+ return f"invalidated series '{series_id}'"
92
+
93
+
94
+ @app.tool()
95
+ async def cache_stats() -> str:
96
+ """Return cache statistics (entry counts, series counts, db path) as JSON."""
97
+ return json.dumps(await _cache.stats(), indent=2)
98
+
99
+
100
+ def main() -> None:
101
+ app.run()
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-cache"
7
+ version = "0.1.1"
8
+ description = "Reusable SQLite-backed cache for MCP servers — TTL and incremental time series strategies"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ keywords = ["mcp", "cache", "sqlite", "time-series", "llm"]
13
+ dependencies = [
14
+ "mcp>=1.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/cmaurer/mcp-cache"
19
+ Issues = "https://github.com/cmaurer/mcp-cache/issues"
20
+
21
+ [project.scripts]
22
+ mcp-cache = "mcp_cache.server:main"
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "pytest-asyncio>=0.23",
28
+ ]
29
+
30
+
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
34
+ testpaths = ["tests"]
35
+
36
+ [tool.semantic_release]
37
+ version_toml = ["pyproject.toml:project.version"]
38
+ branch = "main"
39
+ build_command = "pip install build && python -m build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/mcp_cache"]
43
+
44
+ [tool.hatch.build]
45
+ sources = ["src"]
File without changes
@@ -0,0 +1,325 @@
1
+ """Unit tests for MCPCache — TTL cache, time series, and diagnostics."""
2
+
3
+ import sqlite3
4
+ import time
5
+ from datetime import date
6
+ from unittest.mock import AsyncMock
7
+
8
+ import pytest
9
+
10
+ from mcp_cache import MCPCache
11
+ from mcp_cache.cache import _next_day, _prev_day, _to_date_str
12
+
13
+
14
+ # ── helper functions ────────────────────────────────────────────────────────
15
+
16
+
17
+ def test_to_date_str_passthrough():
18
+ assert _to_date_str("2024-06-01") == "2024-06-01"
19
+
20
+
21
+ def test_to_date_str_from_date_object():
22
+ assert _to_date_str(date(2024, 6, 1)) == "2024-06-01"
23
+
24
+
25
+ def test_prev_day_mid_month():
26
+ assert _prev_day("2024-06-15") == "2024-06-14"
27
+
28
+
29
+ def test_prev_day_month_boundary():
30
+ assert _prev_day("2024-06-01") == "2024-05-31"
31
+
32
+
33
+ def test_next_day_mid_month():
34
+ assert _next_day("2024-06-15") == "2024-06-16"
35
+
36
+
37
+ def test_next_day_year_boundary():
38
+ assert _next_day("2024-12-31") == "2025-01-01"
39
+
40
+
41
+ # ── TTL cache ───────────────────────────────────────────────────────────────
42
+
43
+
44
+ async def test_cache_miss_calls_fetch(tmp_path):
45
+ cache = MCPCache(tmp_path / "test.db")
46
+ fetch = AsyncMock(return_value={"v": 1})
47
+ result = await cache.get_or_fetch("k", fetch)
48
+ assert result == {"v": 1}
49
+ fetch.assert_awaited_once()
50
+
51
+
52
+ async def test_cache_hit_skips_fetch(tmp_path):
53
+ cache = MCPCache(tmp_path / "test.db")
54
+ fetch = AsyncMock(return_value={"v": 1})
55
+ await cache.get_or_fetch("k", fetch)
56
+ result = await cache.get_or_fetch("k", fetch)
57
+ assert result == {"v": 1}
58
+ fetch.assert_awaited_once()
59
+
60
+
61
+ async def test_cache_expired_entry_refetches(tmp_path):
62
+ cache = MCPCache(tmp_path / "test.db")
63
+ # Insert a stale entry directly so we don't need to sleep
64
+ with sqlite3.connect(cache._db_path) as conn:
65
+ conn.execute(
66
+ "INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
67
+ ("k", '"old"', time.time() - 9999, 300),
68
+ )
69
+ fetch = AsyncMock(return_value="fresh")
70
+ result = await cache.get_or_fetch("k", fetch)
71
+ assert result == "fresh"
72
+ fetch.assert_awaited_once()
73
+
74
+
75
+ async def test_per_call_ttl_overrides_default(tmp_path):
76
+ cache = MCPCache(tmp_path / "test.db", default_ttl=3600)
77
+ # Insert an entry that is stale under ttl=1 but fresh under default 3600
78
+ with sqlite3.connect(cache._db_path) as conn:
79
+ conn.execute(
80
+ "INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
81
+ ("k", '"old"', time.time() - 5, 1),
82
+ )
83
+ fetch = AsyncMock(return_value="new")
84
+ result = await cache.get_or_fetch("k", fetch, ttl=1)
85
+ assert result == "new"
86
+ fetch.assert_awaited_once()
87
+
88
+
89
+ async def test_invalidate_removes_entry(tmp_path):
90
+ cache = MCPCache(tmp_path / "test.db")
91
+ fetch = AsyncMock(return_value="data")
92
+ await cache.get_or_fetch("k", fetch)
93
+ await cache.invalidate("k")
94
+ await cache.get_or_fetch("k", fetch)
95
+ assert fetch.await_count == 2
96
+
97
+
98
+ async def test_invalidate_nonexistent_key_is_noop(tmp_path):
99
+ cache = MCPCache(tmp_path / "test.db")
100
+ await cache.invalidate("no-such-key") # must not raise
101
+
102
+
103
+ async def test_clear_expired_removes_stale_entries(tmp_path):
104
+ cache = MCPCache(tmp_path / "test.db")
105
+ old = time.time() - 9999
106
+ with sqlite3.connect(cache._db_path) as conn:
107
+ conn.executemany(
108
+ "INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
109
+ [
110
+ ("stale1", '"a"', old, 300),
111
+ ("stale2", '"b"', old, 300),
112
+ ("fresh", '"c"', time.time(), 3600),
113
+ ],
114
+ )
115
+ removed = await cache.clear_expired()
116
+ assert removed == 2
117
+
118
+
119
+ async def test_ttl_cache_stores_various_json_types(tmp_path):
120
+ cache = MCPCache(tmp_path / "test.db")
121
+ cases = [("s", "hello"), ("i", 42), ("f", 3.14), ("l", [1, 2]), ("d", {"a": 1}), ("b", True)]
122
+ for key, value in cases:
123
+ result = await cache.get_or_fetch(key, AsyncMock(return_value=value))
124
+ assert result == value
125
+
126
+
127
+ async def test_cached_none_is_not_treated_as_miss(tmp_path):
128
+ cache = MCPCache(tmp_path / "test.db")
129
+ fetch = AsyncMock(return_value=None)
130
+ await cache.get_or_fetch("k", fetch)
131
+ await cache.get_or_fetch("k", fetch)
132
+ fetch.assert_awaited_once() # second call must hit cache, not re-fetch
133
+
134
+
135
+ async def test_get_returns_none_on_miss(tmp_path):
136
+ cache = MCPCache(tmp_path / "test.db")
137
+ assert await cache.get("missing") is None
138
+
139
+
140
+ async def test_set_then_get_roundtrip(tmp_path):
141
+ cache = MCPCache(tmp_path / "test.db")
142
+ await cache.set("k", {"x": 1})
143
+ assert await cache.get("k") == {"x": 1}
144
+
145
+
146
+ async def test_set_uses_default_ttl(tmp_path):
147
+ cache = MCPCache(tmp_path / "test.db", default_ttl=3600)
148
+ await cache.set("k", "v")
149
+ with sqlite3.connect(cache._db_path) as conn:
150
+ ttl = conn.execute("SELECT ttl FROM ttl_cache WHERE key = 'k'").fetchone()[0]
151
+ assert ttl == 3600
152
+
153
+
154
+ async def test_set_explicit_ttl_overrides_default(tmp_path):
155
+ cache = MCPCache(tmp_path / "test.db", default_ttl=3600)
156
+ await cache.set("k", "v", ttl=60)
157
+ with sqlite3.connect(cache._db_path) as conn:
158
+ ttl = conn.execute("SELECT ttl FROM ttl_cache WHERE key = 'k'").fetchone()[0]
159
+ assert ttl == 60
160
+
161
+
162
+ async def test_upsert_updates_on_re_store(tmp_path):
163
+ cache = MCPCache(tmp_path / "test.db")
164
+ await cache.get_or_fetch("k", AsyncMock(return_value="v1"))
165
+ await cache.invalidate("k")
166
+ result = await cache.get_or_fetch("k", AsyncMock(return_value="v2"))
167
+ assert result == "v2"
168
+
169
+
170
+ # ── time series ─────────────────────────────────────────────────────────────
171
+
172
+
173
+ def _obs(dates, value=1.0):
174
+ return [{"date": d, "value": value} for d in dates]
175
+
176
+
177
+ async def test_timeseries_full_miss_calls_fetch(tmp_path):
178
+ cache = MCPCache(tmp_path / "test.db")
179
+ fetch = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02", "2024-01-03"]))
180
+ result = await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", fetch)
181
+ fetch.assert_awaited_once_with("2024-01-01", "2024-01-03")
182
+ assert len(result) == 3
183
+
184
+
185
+ async def test_timeseries_full_hit_skips_fetch(tmp_path):
186
+ cache = MCPCache(tmp_path / "test.db")
187
+ fetch = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02", "2024-01-03"]))
188
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", fetch)
189
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", fetch)
190
+ fetch.assert_awaited_once()
191
+
192
+
193
+ async def test_timeseries_partial_gap_fetches_only_missing(tmp_path):
194
+ cache = MCPCache(tmp_path / "test.db")
195
+ f1 = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02", "2024-01-03"]))
196
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", f1)
197
+
198
+ f2 = AsyncMock(return_value=_obs(["2024-01-04", "2024-01-05"]))
199
+ result = await cache.get_timeseries("S1", "2024-01-01", "2024-01-05", f2)
200
+ f2.assert_awaited_once_with("2024-01-04", "2024-01-05")
201
+ assert len(result) == 5
202
+
203
+
204
+ async def test_timeseries_gap_in_middle(tmp_path):
205
+ """Cached [Jan1-3] and [Jan7-9]; requesting [Jan1-9] should only fetch [Jan4-6]."""
206
+ cache = MCPCache(tmp_path / "test.db")
207
+ f1 = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02", "2024-01-03"]))
208
+ f2 = AsyncMock(return_value=_obs(["2024-01-07", "2024-01-08", "2024-01-09"]))
209
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", f1)
210
+ await cache.get_timeseries("S1", "2024-01-07", "2024-01-09", f2)
211
+
212
+ f3 = AsyncMock(return_value=_obs(["2024-01-04", "2024-01-05", "2024-01-06"]))
213
+ result = await cache.get_timeseries("S1", "2024-01-01", "2024-01-09", f3)
214
+ f3.assert_awaited_once_with("2024-01-04", "2024-01-06")
215
+ assert len(result) == 9
216
+
217
+
218
+ async def test_timeseries_result_sorted_descending(tmp_path):
219
+ cache = MCPCache(tmp_path / "test.db")
220
+ fetch = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02", "2024-01-03"]))
221
+ result = await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", fetch)
222
+ dates = [r["date"] for r in result]
223
+ assert dates == sorted(dates, reverse=True)
224
+
225
+
226
+ async def test_timeseries_accepts_date_objects(tmp_path):
227
+ cache = MCPCache(tmp_path / "test.db")
228
+ fetch = AsyncMock(return_value=_obs(["2024-06-01"]))
229
+ result = await cache.get_timeseries("S1", date(2024, 6, 1), date(2024, 6, 1), fetch)
230
+ assert len(result) == 1
231
+
232
+
233
+ async def test_timeseries_custom_date_value_keys(tmp_path):
234
+ cache = MCPCache(tmp_path / "test.db")
235
+ obs = [{"t": "2024-01-01", "price": 99.5}]
236
+ fetch = AsyncMock(return_value=obs)
237
+ result = await cache.get_timeseries(
238
+ "S1", "2024-01-01", "2024-01-01", fetch, date_key="t", value_key="price"
239
+ )
240
+ assert result == [{"t": "2024-01-01", "price": 99.5}]
241
+
242
+
243
+ async def test_timeseries_null_values_stored(tmp_path):
244
+ cache = MCPCache(tmp_path / "test.db")
245
+ obs = [{"date": "2024-01-01", "value": None}]
246
+ fetch = AsyncMock(return_value=obs)
247
+ result = await cache.get_timeseries("S1", "2024-01-01", "2024-01-01", fetch)
248
+ assert result[0]["value"] is None
249
+
250
+
251
+ async def test_invalidate_series_clears_all_data(tmp_path):
252
+ cache = MCPCache(tmp_path / "test.db")
253
+ f1 = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02"]))
254
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-02", f1)
255
+ await cache.invalidate_series("S1")
256
+
257
+ f2 = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02"]))
258
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-02", f2)
259
+ f2.assert_awaited_once()
260
+
261
+
262
+ async def test_invalidate_series_does_not_affect_other_series(tmp_path):
263
+ cache = MCPCache(tmp_path / "test.db")
264
+ f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
265
+ f2 = AsyncMock(return_value=_obs(["2024-01-01"]))
266
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-01", f1)
267
+ await cache.get_timeseries("S2", "2024-01-01", "2024-01-01", f2)
268
+ await cache.invalidate_series("S1")
269
+
270
+ f2_again = AsyncMock(return_value=_obs(["2024-01-01"]))
271
+ await cache.get_timeseries("S2", "2024-01-01", "2024-01-01", f2_again)
272
+ f2_again.assert_not_awaited()
273
+
274
+
275
+ async def test_timeseries_empty_fetch_result(tmp_path):
276
+ """fetch_fn returning [] should store the range so it isn't re-fetched."""
277
+ cache = MCPCache(tmp_path / "test.db")
278
+ fetch = AsyncMock(return_value=[])
279
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", fetch)
280
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-03", fetch)
281
+ fetch.assert_awaited_once()
282
+
283
+
284
+ # ── stats ───────────────────────────────────────────────────────────────────
285
+
286
+
287
+ async def test_stats_empty_cache(tmp_path):
288
+ cache = MCPCache(tmp_path / "test.db")
289
+ s = await cache.stats()
290
+ assert s["ttl_cache"] == {"total": 0, "fresh": 0, "expired": 0}
291
+ assert s["timeseries"] == {"series": 0, "observations": 0, "fetched_ranges": 0}
292
+ assert s["db_path"] == cache._db_path
293
+
294
+
295
+ async def test_stats_reflects_cache_state(tmp_path):
296
+ cache = MCPCache(tmp_path / "test.db")
297
+ await cache.get_or_fetch("k1", AsyncMock(return_value=1))
298
+ await cache.get_or_fetch("k2", AsyncMock(return_value=2))
299
+ fetch = AsyncMock(return_value=_obs(["2024-01-01", "2024-01-02"]))
300
+ await cache.get_timeseries("S1", "2024-01-01", "2024-01-02", fetch)
301
+
302
+ s = await cache.stats()
303
+ assert s["ttl_cache"]["total"] == 2
304
+ assert s["ttl_cache"]["fresh"] == 2
305
+ assert s["ttl_cache"]["expired"] == 0
306
+ assert s["timeseries"]["observations"] == 2
307
+ assert s["timeseries"]["series"] == 1
308
+ assert s["timeseries"]["fetched_ranges"] == 1
309
+
310
+
311
+ async def test_stats_counts_expired_correctly(tmp_path):
312
+ cache = MCPCache(tmp_path / "test.db")
313
+ old = time.time() - 9999
314
+ with sqlite3.connect(cache._db_path) as conn:
315
+ conn.executemany(
316
+ "INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
317
+ [
318
+ ("stale", '"x"', old, 300),
319
+ ("fresh", '"y"', time.time(), 3600),
320
+ ],
321
+ )
322
+ s = await cache.stats()
323
+ assert s["ttl_cache"]["total"] == 2
324
+ assert s["ttl_cache"]["fresh"] == 1
325
+ assert s["ttl_cache"]["expired"] == 1