mcp-cache 0.2.0__tar.gz → 0.2.2__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.2.0 → mcp_cache-0.2.2}/CHANGELOG.md +54 -0
- mcp_cache-0.2.2/CLAUDE.md +65 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/PKG-INFO +16 -1
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/README.md +15 -0
- mcp_cache-0.2.2/docs/superpowers/plans/2026-07-05-list-cache-keys.md +350 -0
- mcp_cache-0.2.2/docs/superpowers/specs/2026-07-05-list-cache-keys-design.md +70 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/pyproject.toml +2 -5
- {mcp_cache-0.2.0 → mcp_cache-0.2.2/src}/mcp_cache/cache.py +44 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2/src}/mcp_cache/server.py +14 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/tests/test_cache.py +86 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/.github/workflows/ci.yml +0 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/.github/workflows/release.yml +0 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/.gitignore +0 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2/src}/mcp_cache/__init__.py +0 -0
- {mcp_cache-0.2.0 → mcp_cache-0.2.2}/tests/__init__.py +0 -0
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.2.2 (2026-07-06)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Wheel builds were shipping empty packages ([#3](https://github.com/cmaurer/mcp-cache/pull/3),
|
|
9
|
+
[`c35f270`](https://github.com/cmaurer/mcp-cache/commit/c35f270f6e43a592ecfd04017af5ee6a1f58dbe9))
|
|
10
|
+
|
|
11
|
+
python -m build builds the sdist first, then builds the wheel from that sdist. The top-level
|
|
12
|
+
[tool.hatch.build] sources = ["src"] rewrite strips the src/ prefix when creating the sdist, so by
|
|
13
|
+
the time the wheel target's packages = ["src/mcp_cache"] is evaluated against the sdist's layout,
|
|
14
|
+
that path no longer exists — producing a wheel with only dist-info and no package files. Every
|
|
15
|
+
published version (0.1.0 through 0.2.1) is affected.
|
|
16
|
+
|
|
17
|
+
Removing the redundant top-level sources config fixes it — verified the resulting wheel contains
|
|
18
|
+
__init__.py/cache.py/server.py and that MCPCache actually imports and works when installed from
|
|
19
|
+
it.
|
|
20
|
+
|
|
21
|
+
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## v0.2.1 (2026-07-06)
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
- Add cache key/series listing methods ([#2](https://github.com/cmaurer/mcp-cache/pull/2),
|
|
29
|
+
[`140bc30`](https://github.com/cmaurer/mcp-cache/commit/140bc30044f78f6ba36a9ac359234cd0fbb345d6))
|
|
30
|
+
|
|
31
|
+
* docs: add design spec for cache key/series listing methods
|
|
32
|
+
|
|
33
|
+
* docs: add implementation plan for cache key/series listing
|
|
34
|
+
|
|
35
|
+
* feat: add MCPCache.list_keys() to list TTL cache keys
|
|
36
|
+
|
|
37
|
+
Implement list_keys() method with optional prefix filtering and expired entry handling. Includes
|
|
38
|
+
_ttl_list_keys() helper and _like_prefix() escaping utility.
|
|
39
|
+
|
|
40
|
+
* feat: add MCPCache.list_series() to list time series IDs
|
|
41
|
+
|
|
42
|
+
Implement list_series() method with optional prefix filtering: - Public async method using
|
|
43
|
+
asyncio.to_thread pattern - Private sync helper _ts_list_series with SQL LIKE filtering - Reuses
|
|
44
|
+
_like_prefix helper from Task 1 - Results sorted alphabetically - Added 4 comprehensive tests
|
|
45
|
+
covering empty cache, deduplication, and prefix filtering
|
|
46
|
+
|
|
47
|
+
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|
48
|
+
|
|
49
|
+
* feat: expose cache_list_keys and timeseries_list_series MCP tools
|
|
50
|
+
|
|
51
|
+
* docs: add CLAUDE.md with architecture and command reference
|
|
52
|
+
|
|
53
|
+
---------
|
|
54
|
+
|
|
55
|
+
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
|
56
|
+
|
|
57
|
+
|
|
4
58
|
## v0.2.0 (2026-06-05)
|
|
5
59
|
|
|
6
60
|
### Features
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
`mcp-cache` is a reusable, zero-dependency (stdlib-only) SQLite-backed cache library for MCP (Model Context Protocol) servers. It ships as both an importable library (`from mcp_cache import MCPCache`) and a standalone MCP server (`mcp-cache` console script) for use directly in Claude Desktop. Requires Python 3.10+.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e ".[dev]" # install package + test deps
|
|
13
|
+
pytest # run full test suite
|
|
14
|
+
pytest tests/test_cache.py -v # verbose
|
|
15
|
+
pytest tests/test_cache.py -k test_name -v # run a single test
|
|
16
|
+
pytest tests/test_cache.py -k list_keys -v # run tests matching a substring
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
There is no linter/formatter configured in this repo — don't add one unprompted.
|
|
20
|
+
|
|
21
|
+
Tests use `pytest-asyncio` with `asyncio_mode = "auto"` (`pyproject.toml`), so async test functions need **no** `@pytest.mark.asyncio` decorator.
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
The library is two files under `src/mcp_cache/`:
|
|
26
|
+
|
|
27
|
+
- **`cache.py`** — `MCPCache`, the core SQLite-backed cache. No MCP/network dependency; pure stdlib (`sqlite3`, `asyncio`, `json`).
|
|
28
|
+
- **`server.py`** — a `FastMCP` server (`app = FastMCP("mcp-cache")`) that instantiates one module-level `MCPCache` and wraps each of its methods as an `@app.tool()`. This is what `pip install`'s `mcp-cache` console script runs.
|
|
29
|
+
|
|
30
|
+
New cache capability should almost always be added to `cache.py` first, then optionally exposed as a tool in `server.py` — not the other way around.
|
|
31
|
+
|
|
32
|
+
### Two caching strategies, one consistent pattern
|
|
33
|
+
|
|
34
|
+
`MCPCache` implements two independent strategies against one SQLite file, each with parallel method families:
|
|
35
|
+
|
|
36
|
+
- **TTL cache** (`ttl_cache` table) — arbitrary string keys → JSON blob + expiry. Methods: `get_or_fetch`, `get`, `set`, `invalidate`, `clear_expired`, `list_keys`.
|
|
37
|
+
- **Time series cache** (`ts_observations` + `ts_ranges` tables) — date-keyed observations per `series_id`, with gap-tracking so repeated queries only fetch missing date ranges. Methods: `get_timeseries`, `invalidate_series`, `list_series`.
|
|
38
|
+
|
|
39
|
+
Every public method follows the same shape: an `async def` method does `await asyncio.to_thread(self._sync_helper, ...)`, and a private sync helper (`_ttl_*` / `_ts_*` prefix) does the actual SQLite work via `self._connect()`. All SQLite connections go through `self._connect()`, which applies `busy_timeout`. Follow this pattern exactly when adding methods — don't call SQLite directly from an `async def`.
|
|
40
|
+
|
|
41
|
+
Any `LIKE`-based prefix filtering (`list_keys`, `list_series`) must go through the shared `_like_prefix()` helper (bottom of `cache.py`, escapes `%`/`_`/`\`) and bind the pattern as a parameter — never string-interpolate SQL.
|
|
42
|
+
|
|
43
|
+
### Time series gap detection
|
|
44
|
+
|
|
45
|
+
`get_timeseries` is the most complex piece: `_ts_find_gaps` merges overlapping/adjacent cached ranges from `ts_ranges` and computes the subset of `[start_date, end_date]` not yet covered, then only calls `fetch_fn` for those gaps before merging and returning the full requested range (sorted newest-first). When touching this logic, read `_ts_find_gaps` and its date-arithmetic helpers (`_prev_day`/`_next_day`) together — the gap math is easy to get off-by-one on.
|
|
46
|
+
|
|
47
|
+
### The `_MISSING` sentinel
|
|
48
|
+
|
|
49
|
+
TTL cache reads distinguish "no cached value" from "cached value is `None`" using the module-level `_MISSING` sentinel object (not `None`) as the miss marker internally. `get()` translates `_MISSING` back to `None` for the public API. Preserve this distinction in any code touching `_ttl_get`/`get_or_fetch`.
|
|
50
|
+
|
|
51
|
+
### Configuration
|
|
52
|
+
|
|
53
|
+
`server.py` reads three env vars at import time to configure the module-level cache instance: `MCP_CACHE_DB` (db file path, default `~/.cache/mcp_cache.db`), `MCP_CACHE_DEFAULT_TTL` (default `300`), `MCP_CACHE_BUSY_TIMEOUT` (default `5000` ms).
|
|
54
|
+
|
|
55
|
+
### Testing conventions
|
|
56
|
+
|
|
57
|
+
- Tests live in `tests/test_cache.py` only (no test file for `server.py`).
|
|
58
|
+
- Use the `tmp_path` fixture for a fresh SQLite file per test — never share a db across tests.
|
|
59
|
+
- To simulate expired/stale entries, insert rows directly via `sqlite3.connect(cache._db_path)` rather than sleeping.
|
|
60
|
+
- Use `unittest.mock.AsyncMock` for `fetch_fn`/`fetch` callables; assert with `.assert_awaited_once()` / `.await_count`.
|
|
61
|
+
- Tests hit a real SQLite file end-to-end — don't mock `MCPCache` internals.
|
|
62
|
+
|
|
63
|
+
### Release process
|
|
64
|
+
|
|
65
|
+
Versioning and `CHANGELOG.md` are fully automated by `python-semantic-release` on every push to `main` (`.github/workflows/release.yml`), driven by Conventional Commit prefixes (`feat:`, `fix:`, etc.) in commit messages. Do not hand-edit `CHANGELOG.md` or bump the version in `pyproject.toml` manually.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-cache
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Reusable SQLite-backed cache for MCP servers — TTL and incremental time series strategies
|
|
5
5
|
Project-URL: Homepage, https://github.com/cmaurer/mcp-cache
|
|
6
6
|
Project-URL: Issues, https://github.com/cmaurer/mcp-cache/issues
|
|
@@ -113,6 +113,13 @@ Remove a specific entry from the TTL cache. No-op if the key does not exist.
|
|
|
113
113
|
|
|
114
114
|
Delete all expired TTL entries. Returns the number of entries removed.
|
|
115
115
|
|
|
116
|
+
#### `await cache.list_keys(prefix=None, include_expired=False)`
|
|
117
|
+
|
|
118
|
+
Return TTL cache keys as a sorted list of strings.
|
|
119
|
+
|
|
120
|
+
- `prefix` — only return keys starting with this string; `None` (default) returns all keys
|
|
121
|
+
- `include_expired` — include expired entries; defaults to `False` (fresh keys only)
|
|
122
|
+
|
|
116
123
|
---
|
|
117
124
|
|
|
118
125
|
### Time series methods
|
|
@@ -132,6 +139,12 @@ Returns a list of `{date_key: str, value_key: float | None}` dicts sorted **newe
|
|
|
132
139
|
|
|
133
140
|
Remove all cached observations and range records for a series.
|
|
134
141
|
|
|
142
|
+
#### `await cache.list_series(prefix=None)`
|
|
143
|
+
|
|
144
|
+
Return distinct time series IDs as a sorted list of strings.
|
|
145
|
+
|
|
146
|
+
- `prefix` — only return series IDs starting with this string; `None` (default) returns all series
|
|
147
|
+
|
|
135
148
|
---
|
|
136
149
|
|
|
137
150
|
### Diagnostics
|
|
@@ -193,9 +206,11 @@ Restart Claude Desktop. The following tools will be available:
|
|
|
193
206
|
| `cache_set(key, value, ttl)` | Store a JSON value for `ttl` seconds (default 300) |
|
|
194
207
|
| `cache_invalidate(key)` | Remove a specific entry |
|
|
195
208
|
| `cache_clear_expired()` | Delete all expired entries |
|
|
209
|
+
| `cache_list_keys(prefix, include_expired)` | List TTL cache keys, optionally filtered by prefix |
|
|
196
210
|
| `timeseries_store(series_id, observations, range_start, range_end)` | Store date-keyed observations |
|
|
197
211
|
| `timeseries_get(series_id, start_date, end_date)` | Query cached observations |
|
|
198
212
|
| `timeseries_invalidate(series_id)` | Clear all data for a series |
|
|
213
|
+
| `timeseries_list_series(prefix)` | List time series IDs, optionally filtered by prefix |
|
|
199
214
|
| `cache_stats()` | Return entry counts and db path |
|
|
200
215
|
|
|
201
216
|
The cache database is stored at `~/.cache/mcp_cache.db`.
|
|
@@ -98,6 +98,13 @@ Remove a specific entry from the TTL cache. No-op if the key does not exist.
|
|
|
98
98
|
|
|
99
99
|
Delete all expired TTL entries. Returns the number of entries removed.
|
|
100
100
|
|
|
101
|
+
#### `await cache.list_keys(prefix=None, include_expired=False)`
|
|
102
|
+
|
|
103
|
+
Return TTL cache keys as a sorted list of strings.
|
|
104
|
+
|
|
105
|
+
- `prefix` — only return keys starting with this string; `None` (default) returns all keys
|
|
106
|
+
- `include_expired` — include expired entries; defaults to `False` (fresh keys only)
|
|
107
|
+
|
|
101
108
|
---
|
|
102
109
|
|
|
103
110
|
### Time series methods
|
|
@@ -117,6 +124,12 @@ Returns a list of `{date_key: str, value_key: float | None}` dicts sorted **newe
|
|
|
117
124
|
|
|
118
125
|
Remove all cached observations and range records for a series.
|
|
119
126
|
|
|
127
|
+
#### `await cache.list_series(prefix=None)`
|
|
128
|
+
|
|
129
|
+
Return distinct time series IDs as a sorted list of strings.
|
|
130
|
+
|
|
131
|
+
- `prefix` — only return series IDs starting with this string; `None` (default) returns all series
|
|
132
|
+
|
|
120
133
|
---
|
|
121
134
|
|
|
122
135
|
### Diagnostics
|
|
@@ -178,9 +191,11 @@ Restart Claude Desktop. The following tools will be available:
|
|
|
178
191
|
| `cache_set(key, value, ttl)` | Store a JSON value for `ttl` seconds (default 300) |
|
|
179
192
|
| `cache_invalidate(key)` | Remove a specific entry |
|
|
180
193
|
| `cache_clear_expired()` | Delete all expired entries |
|
|
194
|
+
| `cache_list_keys(prefix, include_expired)` | List TTL cache keys, optionally filtered by prefix |
|
|
181
195
|
| `timeseries_store(series_id, observations, range_start, range_end)` | Store date-keyed observations |
|
|
182
196
|
| `timeseries_get(series_id, start_date, end_date)` | Query cached observations |
|
|
183
197
|
| `timeseries_invalidate(series_id)` | Clear all data for a series |
|
|
198
|
+
| `timeseries_list_series(prefix)` | List time series IDs, optionally filtered by prefix |
|
|
184
199
|
| `cache_stats()` | Return entry counts and db path |
|
|
185
200
|
|
|
186
201
|
The cache database is stored at `~/.cache/mcp_cache.db`.
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# List Cache Keys Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add the ability to list what's currently cached — TTL cache keys and time series IDs — with optional prefix filtering.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two new async methods on `MCPCache` (`list_keys`, `list_series`), each following the existing sync-helper + `asyncio.to_thread` pattern already used by every other method in `cache.py`. Two new MCP tools in `server.py` expose them to Claude Desktop, mirroring the existing tool style. README gets matching API reference and tool table entries.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Python 3.10+, sqlite3 (stdlib), pytest + pytest-asyncio (`asyncio_mode = "auto"`, no decorator needed).
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Follow the existing `cache.py` pattern: a public `async def` method that does `await asyncio.to_thread(self._sync_helper, ...)`, plus a private sync helper that opens a connection via `self._connect()`.
|
|
14
|
+
- `prefix` filtering uses a bound SQL parameter (`LIKE ? `), never string interpolation.
|
|
15
|
+
- `list_keys` excludes expired entries by default; `include_expired=True` includes them.
|
|
16
|
+
- Both methods return results sorted alphabetically.
|
|
17
|
+
- Tests go in the existing `tests/test_cache.py`, using the same style as neighboring tests (`tmp_path` fixture, direct `sqlite3.connect` for pre-seeding rows, no custom fixtures/classes).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
### Task 1: `MCPCache.list_keys()` — TTL cache key listing
|
|
22
|
+
|
|
23
|
+
**Files:**
|
|
24
|
+
- Modify: `src/mcp_cache/cache.py` (add method under the `# --- TTL cache ---` section, after `clear_expired`/`_ttl_clear_expired`, i.e. after line 153)
|
|
25
|
+
- Test: `tests/test_cache.py` (add tests under the `# ── TTL cache` section, after `test_upsert_updates_on_re_store`, i.e. after line 167)
|
|
26
|
+
|
|
27
|
+
**Interfaces:**
|
|
28
|
+
- Produces: `async def list_keys(self, prefix: str | None = None, include_expired: bool = False) -> list[str]`
|
|
29
|
+
|
|
30
|
+
- [ ] **Step 1: Write the failing tests**
|
|
31
|
+
|
|
32
|
+
Add to `tests/test_cache.py` after `test_upsert_updates_on_re_store` (line 167):
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
async def test_list_keys_empty_cache(tmp_path):
|
|
36
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
37
|
+
assert await cache.list_keys() == []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_list_keys_returns_fresh_keys_sorted(tmp_path):
|
|
41
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
42
|
+
await cache.set("zebra", "v")
|
|
43
|
+
await cache.set("apple", "v")
|
|
44
|
+
assert await cache.list_keys() == ["apple", "zebra"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_list_keys_excludes_expired_by_default(tmp_path):
|
|
48
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
49
|
+
old = time.time() - 9999
|
|
50
|
+
with sqlite3.connect(cache._db_path) as conn:
|
|
51
|
+
conn.executemany(
|
|
52
|
+
"INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
|
|
53
|
+
[
|
|
54
|
+
("stale", '"a"', old, 300),
|
|
55
|
+
("fresh", '"b"', time.time(), 3600),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
assert await cache.list_keys() == ["fresh"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_list_keys_include_expired_true(tmp_path):
|
|
62
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
63
|
+
old = time.time() - 9999
|
|
64
|
+
with sqlite3.connect(cache._db_path) as conn:
|
|
65
|
+
conn.executemany(
|
|
66
|
+
"INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
|
|
67
|
+
[
|
|
68
|
+
("stale", '"a"', old, 300),
|
|
69
|
+
("fresh", '"b"', time.time(), 3600),
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
assert await cache.list_keys(include_expired=True) == ["fresh", "stale"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_list_keys_prefix_filter(tmp_path):
|
|
76
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
77
|
+
await cache.set("fred:T10YIE", "v")
|
|
78
|
+
await cache.set("fred:DGS10", "v")
|
|
79
|
+
await cache.set("other:key", "v")
|
|
80
|
+
assert await cache.list_keys(prefix="fred:") == ["fred:DGS10", "fred:T10YIE"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_list_keys_prefix_no_match(tmp_path):
|
|
84
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
85
|
+
await cache.set("fred:T10YIE", "v")
|
|
86
|
+
assert await cache.list_keys(prefix="nomatch:") == []
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
90
|
+
|
|
91
|
+
Run: `pytest tests/test_cache.py -k list_keys -v`
|
|
92
|
+
Expected: FAIL with `AttributeError: 'MCPCache' object has no attribute 'list_keys'`
|
|
93
|
+
|
|
94
|
+
- [ ] **Step 3: Implement `list_keys` and `_ttl_list_keys`**
|
|
95
|
+
|
|
96
|
+
In `src/mcp_cache/cache.py`, add after `_ttl_clear_expired` (after line 153, before the `# --- Time series cache ---` comment on line 155):
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
async def list_keys(self, prefix: str | None = None, include_expired: bool = False) -> list[str]:
|
|
100
|
+
"""Return TTL cache keys, optionally filtered by prefix.
|
|
101
|
+
|
|
102
|
+
Excludes expired entries unless include_expired=True.
|
|
103
|
+
"""
|
|
104
|
+
return await asyncio.to_thread(self._ttl_list_keys, prefix, include_expired)
|
|
105
|
+
|
|
106
|
+
def _ttl_list_keys(self, prefix: str | None, include_expired: bool) -> list[str]:
|
|
107
|
+
query = "SELECT key FROM ttl_cache"
|
|
108
|
+
conditions = []
|
|
109
|
+
params: list[Any] = []
|
|
110
|
+
if not include_expired:
|
|
111
|
+
conditions.append("(? - cached_at) <= ttl")
|
|
112
|
+
params.insert(0, time.time())
|
|
113
|
+
if prefix:
|
|
114
|
+
conditions.append("key LIKE ? ESCAPE '\\'")
|
|
115
|
+
params.append(_like_prefix(prefix))
|
|
116
|
+
if conditions:
|
|
117
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
118
|
+
query += " ORDER BY key"
|
|
119
|
+
with self._connect() as conn:
|
|
120
|
+
rows = conn.execute(query, params).fetchall()
|
|
121
|
+
return [row[0] for row in rows]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Add the `_like_prefix` helper near the other module-level helpers (`_prev_day`/`_next_day` at the bottom of the file, after line 288):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
def _like_prefix(prefix: str) -> str:
|
|
128
|
+
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
129
|
+
return escaped + "%"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
133
|
+
|
|
134
|
+
Run: `pytest tests/test_cache.py -k list_keys -v`
|
|
135
|
+
Expected: PASS (all 5 tests)
|
|
136
|
+
|
|
137
|
+
- [ ] **Step 5: Run the full test suite**
|
|
138
|
+
|
|
139
|
+
Run: `pytest tests/test_cache.py -v`
|
|
140
|
+
Expected: PASS (all tests, no regressions)
|
|
141
|
+
|
|
142
|
+
- [ ] **Step 6: Commit**
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
git add src/mcp_cache/cache.py tests/test_cache.py
|
|
146
|
+
git commit -m "feat: add MCPCache.list_keys() to list TTL cache keys"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### Task 2: `MCPCache.list_series()` — time series ID listing
|
|
152
|
+
|
|
153
|
+
**Files:**
|
|
154
|
+
- Modify: `src/mcp_cache/cache.py` (add method under `# --- Time series cache ---`, after `_ts_delete_series`, i.e. after what will now be line ~275 once Task 1's addition shifts line numbers — insert directly after the `_ts_delete_series` method and before the `# --- Diagnostics ---` comment)
|
|
155
|
+
- Test: `tests/test_cache.py` (add tests under the `# ── time series` section, after `test_timeseries_empty_fetch_result`)
|
|
156
|
+
|
|
157
|
+
**Interfaces:**
|
|
158
|
+
- Consumes: none from Task 1 (independent method, same file)
|
|
159
|
+
- Produces: `async def list_series(self, prefix: str | None = None) -> list[str]`
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 1: Write the failing tests**
|
|
162
|
+
|
|
163
|
+
Add to `tests/test_cache.py` after `test_timeseries_empty_fetch_result` (originally line 281, before the `# ── constructor parameters` section):
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
async def test_list_series_empty_cache(tmp_path):
|
|
167
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
168
|
+
assert await cache.list_series() == []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def test_list_series_returns_distinct_sorted(tmp_path):
|
|
172
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
173
|
+
f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
174
|
+
f2 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
175
|
+
await cache.get_timeseries("zebra", "2024-01-01", "2024-01-01", f1)
|
|
176
|
+
await cache.get_timeseries("apple", "2024-01-01", "2024-01-01", f2)
|
|
177
|
+
assert await cache.list_series() == ["apple", "zebra"]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def test_list_series_dedups_across_ranges(tmp_path):
|
|
181
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
182
|
+
f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
183
|
+
f2 = AsyncMock(return_value=_obs(["2024-02-01"]))
|
|
184
|
+
await cache.get_timeseries("S1", "2024-01-01", "2024-01-01", f1)
|
|
185
|
+
await cache.get_timeseries("S1", "2024-02-01", "2024-02-01", f2)
|
|
186
|
+
assert await cache.list_series() == ["S1"]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def test_list_series_prefix_filter(tmp_path):
|
|
190
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
191
|
+
f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
192
|
+
f2 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
193
|
+
await cache.get_timeseries("fred:T10YIE", "2024-01-01", "2024-01-01", f1)
|
|
194
|
+
await cache.get_timeseries("other:S1", "2024-01-01", "2024-01-01", f2)
|
|
195
|
+
assert await cache.list_series(prefix="fred:") == ["fred:T10YIE"]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
199
|
+
|
|
200
|
+
Run: `pytest tests/test_cache.py -k list_series -v`
|
|
201
|
+
Expected: FAIL with `AttributeError: 'MCPCache' object has no attribute 'list_series'`
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 3: Implement `list_series` and `_ts_list_series`**
|
|
204
|
+
|
|
205
|
+
In `src/mcp_cache/cache.py`, add after `_ts_delete_series` (right before the `# --- Diagnostics ---` comment):
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
async def list_series(self, prefix: str | None = None) -> list[str]:
|
|
209
|
+
"""Return distinct time series IDs, optionally filtered by prefix."""
|
|
210
|
+
return await asyncio.to_thread(self._ts_list_series, prefix)
|
|
211
|
+
|
|
212
|
+
def _ts_list_series(self, prefix: str | None) -> list[str]:
|
|
213
|
+
query = "SELECT DISTINCT series_id FROM ts_observations"
|
|
214
|
+
params: list[Any] = []
|
|
215
|
+
if prefix:
|
|
216
|
+
query += " WHERE series_id LIKE ? ESCAPE '\\'"
|
|
217
|
+
params.append(_like_prefix(prefix))
|
|
218
|
+
query += " ORDER BY series_id"
|
|
219
|
+
with self._connect() as conn:
|
|
220
|
+
rows = conn.execute(query, params).fetchall()
|
|
221
|
+
return [row[0] for row in rows]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
(`_like_prefix` was already added in Task 1 — reuse it, don't redefine it.)
|
|
225
|
+
|
|
226
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
227
|
+
|
|
228
|
+
Run: `pytest tests/test_cache.py -k list_series -v`
|
|
229
|
+
Expected: PASS (all 4 tests)
|
|
230
|
+
|
|
231
|
+
- [ ] **Step 5: Run the full test suite**
|
|
232
|
+
|
|
233
|
+
Run: `pytest tests/test_cache.py -v`
|
|
234
|
+
Expected: PASS (all tests, no regressions)
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 6: Commit**
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git add src/mcp_cache/cache.py tests/test_cache.py
|
|
240
|
+
git commit -m "feat: add MCPCache.list_series() to list time series IDs"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### Task 3: Expose MCP tools and update README
|
|
246
|
+
|
|
247
|
+
**Files:**
|
|
248
|
+
- Modify: `src/mcp_cache/server.py` (add two `@app.tool()` functions after `cache_clear_expired`, i.e. after line 48, and after `timeseries_invalidate`, i.e. after line 95)
|
|
249
|
+
- Modify: `README.md` (add API reference entries and tool table rows)
|
|
250
|
+
|
|
251
|
+
**Interfaces:**
|
|
252
|
+
- Consumes: `MCPCache.list_keys(prefix, include_expired)` from Task 1, `MCPCache.list_series(prefix)` from Task 2
|
|
253
|
+
- Produces: MCP tools `cache_list_keys`, `timeseries_list_series` (no other task depends on these)
|
|
254
|
+
|
|
255
|
+
- [ ] **Step 1: Add `cache_list_keys` tool**
|
|
256
|
+
|
|
257
|
+
In `src/mcp_cache/server.py`, add after `cache_clear_expired` (after line 48, before the `timeseries_store` tool):
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
@app.tool()
|
|
261
|
+
async def cache_list_keys(prefix: str = "", include_expired: bool = False) -> str:
|
|
262
|
+
"""List TTL cache keys, optionally filtered by prefix."""
|
|
263
|
+
keys = await _cache.list_keys(prefix or None, include_expired)
|
|
264
|
+
return json.dumps(keys)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 2: Add `timeseries_list_series` tool**
|
|
268
|
+
|
|
269
|
+
In `src/mcp_cache/server.py`, add after `timeseries_invalidate` (after line 95, before the `cache_stats` tool):
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
@app.tool()
|
|
273
|
+
async def timeseries_list_series(prefix: str = "") -> str:
|
|
274
|
+
"""List time series IDs, optionally filtered by prefix."""
|
|
275
|
+
series = await _cache.list_series(prefix or None)
|
|
276
|
+
return json.dumps(series)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- [ ] **Step 3: Verify the server imports and tools register**
|
|
280
|
+
|
|
281
|
+
Run: `python -c "from mcp_cache.server import app; print(sorted(t for t in __import__('asyncio').run(app.list_tools())))" 2>&1 | tail -5`
|
|
282
|
+
|
|
283
|
+
If that's awkward to run directly, instead run:
|
|
284
|
+
|
|
285
|
+
Run: `python -c "import mcp_cache.server"`
|
|
286
|
+
Expected: no import errors (exit code 0)
|
|
287
|
+
|
|
288
|
+
- [ ] **Step 4: Update README API reference**
|
|
289
|
+
|
|
290
|
+
In `README.md`, add after the `#### await cache.clear_expired()` section (after line 99, before the `---` on line 101):
|
|
291
|
+
|
|
292
|
+
```markdown
|
|
293
|
+
|
|
294
|
+
#### `await cache.list_keys(prefix=None, include_expired=False)`
|
|
295
|
+
|
|
296
|
+
Return TTL cache keys as a sorted list of strings.
|
|
297
|
+
|
|
298
|
+
- `prefix` — only return keys starting with this string; `None` (default) returns all keys
|
|
299
|
+
- `include_expired` — include expired entries; defaults to `False` (fresh keys only)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Add after the `#### await cache.invalidate_series(series_id)` section (after line 118, before the `---` on line 120):
|
|
303
|
+
|
|
304
|
+
```markdown
|
|
305
|
+
|
|
306
|
+
#### `await cache.list_series(prefix=None)`
|
|
307
|
+
|
|
308
|
+
Return distinct time series IDs as a sorted list of strings.
|
|
309
|
+
|
|
310
|
+
- `prefix` — only return series IDs starting with this string; `None` (default) returns all series
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- [ ] **Step 5: Update README MCP tool table**
|
|
314
|
+
|
|
315
|
+
In `README.md`, update the tool table (around line 176-184) to add two rows — after `cache_clear_expired()` and after `timeseries_invalidate(series_id)`:
|
|
316
|
+
|
|
317
|
+
```markdown
|
|
318
|
+
| `cache_get(key)` | Return a cached value, or `null` if missing/expired |
|
|
319
|
+
| `cache_set(key, value, ttl)` | Store a JSON value for `ttl` seconds (default 300) |
|
|
320
|
+
| `cache_invalidate(key)` | Remove a specific entry |
|
|
321
|
+
| `cache_clear_expired()` | Delete all expired entries |
|
|
322
|
+
| `cache_list_keys(prefix, include_expired)` | List TTL cache keys, optionally filtered by prefix |
|
|
323
|
+
| `timeseries_store(series_id, observations, range_start, range_end)` | Store date-keyed observations |
|
|
324
|
+
| `timeseries_get(series_id, start_date, end_date)` | Query cached observations |
|
|
325
|
+
| `timeseries_invalidate(series_id)` | Clear all data for a series |
|
|
326
|
+
| `timeseries_list_series(prefix)` | List time series IDs, optionally filtered by prefix |
|
|
327
|
+
| `cache_stats()` | Return entry counts and db path |
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
This replaces the existing 8-row table with the 10-row version above (same rows, two new ones inserted in place).
|
|
331
|
+
|
|
332
|
+
- [ ] **Step 6: Run the full test suite**
|
|
333
|
+
|
|
334
|
+
Run: `pytest -v`
|
|
335
|
+
Expected: PASS (all tests, no regressions)
|
|
336
|
+
|
|
337
|
+
- [ ] **Step 7: Commit**
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
git add src/mcp_cache/server.py README.md
|
|
341
|
+
git commit -m "feat: expose cache_list_keys and timeseries_list_series MCP tools"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Self-Review Notes
|
|
347
|
+
|
|
348
|
+
- **Spec coverage:** `list_keys` (Task 1) ✓, `list_series` (Task 2) ✓, prefix filtering both (Tasks 1 & 2) ✓, include_expired (Task 1) ✓, MCP tool exposure (Task 3) ✓, tests per spec's testing plan (Tasks 1 & 2) ✓. Spec's "out of scope" items (glob matching, pagination, combined method) are correctly omitted.
|
|
349
|
+
- **Type consistency:** `list_keys(prefix: str | None, include_expired: bool) -> list[str]` and `list_series(prefix: str | None) -> list[str]` match between the spec, task interfaces, and implementation code in every task.
|
|
350
|
+
- **Shared helper:** `_like_prefix` is defined once in Task 1 and reused (not redefined) in Task 2 — flagged explicitly in Task 2 to avoid a duplicate-definition mistake.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# List Cache Keys — Design
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
`MCPCache` has no way to enumerate what's currently cached. Callers can `get`/`set` individual keys or `get_timeseries` individual series, but there's no introspection method to list what keys/series exist — useful for debugging, cache management tooling, and the MCP server's diagnostic surface (alongside `stats()`).
|
|
6
|
+
|
|
7
|
+
## Approach
|
|
8
|
+
|
|
9
|
+
Add two separate listing methods, one per existing store, following the codebase's existing convention of splitting TTL-cache methods from time-series methods (`get`/`set`/`invalidate`/`clear_expired` vs. `get_timeseries`/`invalidate_series`). A single overloaded method with a `store` selector was considered but rejected — it would need different parameters per store (`include_expired` only makes sense for TTL) and existing code doesn't unify these stores elsewhere.
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
async def list_keys(self, prefix: str | None = None, include_expired: bool = False) -> list[str]:
|
|
15
|
+
"""Return TTL cache keys, optionally filtered by prefix. Fresh only unless include_expired=True."""
|
|
16
|
+
|
|
17
|
+
async def list_series(self, prefix: str | None = None) -> list[str]:
|
|
18
|
+
"""Return distinct time series series_ids, optionally filtered by prefix."""
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `list_keys` queries `ttl_cache`, excluding expired rows (`(now - cached_at) > ttl`) unless `include_expired=True`.
|
|
22
|
+
- `list_series` queries `SELECT DISTINCT series_id FROM ts_observations`.
|
|
23
|
+
- Both accept `prefix`: when set, filter with a bound `LIKE 'prefix%'` parameter (plain prefix match, no glob/regex). `prefix=None` (or omitted) returns everything.
|
|
24
|
+
- Both follow the existing sync-helper + `asyncio.to_thread` pattern used throughout `cache.py` (e.g. `_ttl_get`, `_ts_query`).
|
|
25
|
+
- Results are returned sorted alphabetically for deterministic output.
|
|
26
|
+
|
|
27
|
+
## Edge cases
|
|
28
|
+
|
|
29
|
+
- Empty cache/store → return `[]`, not an error.
|
|
30
|
+
- `prefix=""` is treated the same as `prefix=None` (no filtering) since `LIKE '%'` matches everything anyway — no special-casing needed.
|
|
31
|
+
- Prefix is passed as a bound SQL parameter, not string-interpolated — no injection risk.
|
|
32
|
+
|
|
33
|
+
## MCP server tool exposure
|
|
34
|
+
|
|
35
|
+
Add two tools in `server.py`, mirroring the existing `cache_stats` tool:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
@app.tool()
|
|
39
|
+
async def cache_list_keys(prefix: str = "", include_expired: bool = False) -> str:
|
|
40
|
+
"""List TTL cache keys, optionally filtered by prefix."""
|
|
41
|
+
keys = await _cache.list_keys(prefix or None, include_expired)
|
|
42
|
+
return json.dumps(keys)
|
|
43
|
+
|
|
44
|
+
@app.tool()
|
|
45
|
+
async def timeseries_list_series(prefix: str = "") -> str:
|
|
46
|
+
"""List time series IDs, optionally filtered by prefix."""
|
|
47
|
+
series = await _cache.list_series(prefix or None)
|
|
48
|
+
return json.dumps(series)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Testing
|
|
52
|
+
|
|
53
|
+
Add to `tests/test_cache.py`, following existing conventions (real SQLite via `MCPCache`, `pytest.mark.asyncio`):
|
|
54
|
+
|
|
55
|
+
- `list_keys` returns `[]` on empty cache
|
|
56
|
+
- `list_keys` returns all fresh keys, alphabetically sorted
|
|
57
|
+
- `list_keys` excludes expired keys by default
|
|
58
|
+
- `list_keys(include_expired=True)` includes expired keys
|
|
59
|
+
- `list_keys(prefix=...)` filters correctly (matching and non-matching cases)
|
|
60
|
+
- `list_series` returns `[]` when no series cached
|
|
61
|
+
- `list_series` returns distinct series_ids (dedup across multiple date ranges for the same series)
|
|
62
|
+
- `list_series(prefix=...)` filters correctly
|
|
63
|
+
|
|
64
|
+
No test file exists for `server.py` currently, so no server-level tests are added — consistent with existing coverage.
|
|
65
|
+
|
|
66
|
+
## Out of scope
|
|
67
|
+
|
|
68
|
+
- Glob/regex pattern matching (prefix-only, per YAGNI)
|
|
69
|
+
- Pagination (no evidence of large enough key counts to need it)
|
|
70
|
+
- Combined "list everything" convenience method (caller can call both if needed)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mcp-cache"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Reusable SQLite-backed cache for MCP servers — TTL and incremental time series strategies"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -40,7 +40,4 @@ branch = "main"
|
|
|
40
40
|
build_command = "pip install build && python -m build"
|
|
41
41
|
|
|
42
42
|
[tool.hatch.build.targets.wheel]
|
|
43
|
-
packages = ["src/mcp_cache"]
|
|
44
|
-
|
|
45
|
-
[tool.hatch.build]
|
|
46
|
-
sources = ["src"]
|
|
43
|
+
packages = ["src/mcp_cache"]
|
|
@@ -152,6 +152,30 @@ class MCPCache:
|
|
|
152
152
|
)
|
|
153
153
|
return cur.rowcount
|
|
154
154
|
|
|
155
|
+
async def list_keys(self, prefix: str | None = None, include_expired: bool = False) -> list[str]:
|
|
156
|
+
"""Return TTL cache keys, optionally filtered by prefix.
|
|
157
|
+
|
|
158
|
+
Excludes expired entries unless include_expired=True.
|
|
159
|
+
"""
|
|
160
|
+
return await asyncio.to_thread(self._ttl_list_keys, prefix, include_expired)
|
|
161
|
+
|
|
162
|
+
def _ttl_list_keys(self, prefix: str | None, include_expired: bool) -> list[str]:
|
|
163
|
+
query = "SELECT key FROM ttl_cache"
|
|
164
|
+
conditions = []
|
|
165
|
+
params: list[Any] = []
|
|
166
|
+
if not include_expired:
|
|
167
|
+
conditions.append("(? - cached_at) <= ttl")
|
|
168
|
+
params.insert(0, time.time())
|
|
169
|
+
if prefix:
|
|
170
|
+
conditions.append("key LIKE ? ESCAPE '\\'")
|
|
171
|
+
params.append(_like_prefix(prefix))
|
|
172
|
+
if conditions:
|
|
173
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
174
|
+
query += " ORDER BY key"
|
|
175
|
+
with self._connect() as conn:
|
|
176
|
+
rows = conn.execute(query, params).fetchall()
|
|
177
|
+
return [row[0] for row in rows]
|
|
178
|
+
|
|
155
179
|
# --- Time series cache ---
|
|
156
180
|
|
|
157
181
|
async def get_timeseries(
|
|
@@ -254,6 +278,21 @@ class MCPCache:
|
|
|
254
278
|
conn.execute("DELETE FROM ts_observations WHERE series_id = ?", (series_id,))
|
|
255
279
|
conn.execute("DELETE FROM ts_ranges WHERE series_id = ?", (series_id,))
|
|
256
280
|
|
|
281
|
+
async def list_series(self, prefix: str | None = None) -> list[str]:
|
|
282
|
+
"""Return distinct time series IDs, optionally filtered by prefix."""
|
|
283
|
+
return await asyncio.to_thread(self._ts_list_series, prefix)
|
|
284
|
+
|
|
285
|
+
def _ts_list_series(self, prefix: str | None) -> list[str]:
|
|
286
|
+
query = "SELECT DISTINCT series_id FROM ts_observations"
|
|
287
|
+
params: list[Any] = []
|
|
288
|
+
if prefix:
|
|
289
|
+
query += " WHERE series_id LIKE ? ESCAPE '\\'"
|
|
290
|
+
params.append(_like_prefix(prefix))
|
|
291
|
+
query += " ORDER BY series_id"
|
|
292
|
+
with self._connect() as conn:
|
|
293
|
+
rows = conn.execute(query, params).fetchall()
|
|
294
|
+
return [row[0] for row in rows]
|
|
295
|
+
|
|
257
296
|
# --- Diagnostics ---
|
|
258
297
|
|
|
259
298
|
async def stats(self) -> dict:
|
|
@@ -285,3 +324,8 @@ def _prev_day(iso: str) -> str:
|
|
|
285
324
|
|
|
286
325
|
def _next_day(iso: str) -> str:
|
|
287
326
|
return (date.fromisoformat(iso) + timedelta(days=1)).isoformat()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _like_prefix(prefix: str) -> str:
|
|
330
|
+
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
331
|
+
return escaped + "%"
|
|
@@ -48,6 +48,13 @@ async def cache_clear_expired() -> str:
|
|
|
48
48
|
return f"removed {count} expired entries"
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
@app.tool()
|
|
52
|
+
async def cache_list_keys(prefix: str = "", include_expired: bool = False) -> str:
|
|
53
|
+
"""List TTL cache keys, optionally filtered by prefix."""
|
|
54
|
+
keys = await _cache.list_keys(prefix or None, include_expired)
|
|
55
|
+
return json.dumps(keys)
|
|
56
|
+
|
|
57
|
+
|
|
51
58
|
@app.tool()
|
|
52
59
|
async def timeseries_store(
|
|
53
60
|
series_id: str,
|
|
@@ -95,6 +102,13 @@ async def timeseries_invalidate(series_id: str) -> str:
|
|
|
95
102
|
return f"invalidated series '{series_id}'"
|
|
96
103
|
|
|
97
104
|
|
|
105
|
+
@app.tool()
|
|
106
|
+
async def timeseries_list_series(prefix: str = "") -> str:
|
|
107
|
+
"""List time series IDs, optionally filtered by prefix."""
|
|
108
|
+
series = await _cache.list_series(prefix or None)
|
|
109
|
+
return json.dumps(series)
|
|
110
|
+
|
|
111
|
+
|
|
98
112
|
@app.tool()
|
|
99
113
|
async def cache_stats() -> str:
|
|
100
114
|
"""Return cache statistics (entry counts, series counts, db path) as JSON."""
|
|
@@ -167,6 +167,60 @@ async def test_upsert_updates_on_re_store(tmp_path):
|
|
|
167
167
|
assert result == "v2"
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
async def test_list_keys_empty_cache(tmp_path):
|
|
171
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
172
|
+
assert await cache.list_keys() == []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_list_keys_returns_fresh_keys_sorted(tmp_path):
|
|
176
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
177
|
+
await cache.set("zebra", "v")
|
|
178
|
+
await cache.set("apple", "v")
|
|
179
|
+
assert await cache.list_keys() == ["apple", "zebra"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def test_list_keys_excludes_expired_by_default(tmp_path):
|
|
183
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
184
|
+
old = time.time() - 9999
|
|
185
|
+
with sqlite3.connect(cache._db_path) as conn:
|
|
186
|
+
conn.executemany(
|
|
187
|
+
"INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
|
|
188
|
+
[
|
|
189
|
+
("stale", '"a"', old, 300),
|
|
190
|
+
("fresh", '"b"', time.time(), 3600),
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
assert await cache.list_keys() == ["fresh"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def test_list_keys_include_expired_true(tmp_path):
|
|
197
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
198
|
+
old = time.time() - 9999
|
|
199
|
+
with sqlite3.connect(cache._db_path) as conn:
|
|
200
|
+
conn.executemany(
|
|
201
|
+
"INSERT INTO ttl_cache (key, value_json, cached_at, ttl) VALUES (?, ?, ?, ?)",
|
|
202
|
+
[
|
|
203
|
+
("stale", '"a"', old, 300),
|
|
204
|
+
("fresh", '"b"', time.time(), 3600),
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
assert await cache.list_keys(include_expired=True) == ["fresh", "stale"]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_list_keys_prefix_filter(tmp_path):
|
|
211
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
212
|
+
await cache.set("fred:T10YIE", "v")
|
|
213
|
+
await cache.set("fred:DGS10", "v")
|
|
214
|
+
await cache.set("other:key", "v")
|
|
215
|
+
assert await cache.list_keys(prefix="fred:") == ["fred:DGS10", "fred:T10YIE"]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def test_list_keys_prefix_no_match(tmp_path):
|
|
219
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
220
|
+
await cache.set("fred:T10YIE", "v")
|
|
221
|
+
assert await cache.list_keys(prefix="nomatch:") == []
|
|
222
|
+
|
|
223
|
+
|
|
170
224
|
# ── time series ─────────────────────────────────────────────────────────────
|
|
171
225
|
|
|
172
226
|
|
|
@@ -281,6 +335,38 @@ async def test_timeseries_empty_fetch_result(tmp_path):
|
|
|
281
335
|
fetch.assert_awaited_once()
|
|
282
336
|
|
|
283
337
|
|
|
338
|
+
async def test_list_series_empty_cache(tmp_path):
|
|
339
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
340
|
+
assert await cache.list_series() == []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def test_list_series_returns_distinct_sorted(tmp_path):
|
|
344
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
345
|
+
f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
346
|
+
f2 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
347
|
+
await cache.get_timeseries("zebra", "2024-01-01", "2024-01-01", f1)
|
|
348
|
+
await cache.get_timeseries("apple", "2024-01-01", "2024-01-01", f2)
|
|
349
|
+
assert await cache.list_series() == ["apple", "zebra"]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def test_list_series_dedups_across_ranges(tmp_path):
|
|
353
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
354
|
+
f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
355
|
+
f2 = AsyncMock(return_value=_obs(["2024-02-01"]))
|
|
356
|
+
await cache.get_timeseries("S1", "2024-01-01", "2024-01-01", f1)
|
|
357
|
+
await cache.get_timeseries("S1", "2024-02-01", "2024-02-01", f2)
|
|
358
|
+
assert await cache.list_series() == ["S1"]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def test_list_series_prefix_filter(tmp_path):
|
|
362
|
+
cache = MCPCache(tmp_path / "test.db")
|
|
363
|
+
f1 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
364
|
+
f2 = AsyncMock(return_value=_obs(["2024-01-01"]))
|
|
365
|
+
await cache.get_timeseries("fred:T10YIE", "2024-01-01", "2024-01-01", f1)
|
|
366
|
+
await cache.get_timeseries("other:S1", "2024-01-01", "2024-01-01", f2)
|
|
367
|
+
assert await cache.list_series(prefix="fred:") == ["fred:T10YIE"]
|
|
368
|
+
|
|
369
|
+
|
|
284
370
|
# ── constructor parameters ──────────────────────────────────────────────────
|
|
285
371
|
|
|
286
372
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|