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.
- mcp_cache-0.1.1/.github/workflows/ci.yml +54 -0
- mcp_cache-0.1.1/.github/workflows/release.yml +43 -0
- mcp_cache-0.1.1/.gitignore +7 -0
- mcp_cache-0.1.1/CHANGELOG.md +64 -0
- mcp_cache-0.1.1/PKG-INFO +212 -0
- mcp_cache-0.1.1/README.md +197 -0
- mcp_cache-0.1.1/mcp_cache/__init__.py +11 -0
- mcp_cache-0.1.1/mcp_cache/cache.py +277 -0
- mcp_cache-0.1.1/mcp_cache/server.py +105 -0
- mcp_cache-0.1.1/pyproject.toml +45 -0
- mcp_cache-0.1.1/tests/__init__.py +0 -0
- mcp_cache-0.1.1/tests/test_cache.py +325 -0
|
@@ -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,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>
|
mcp_cache-0.1.1/PKG-INFO
ADDED
|
@@ -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
|