mcp-cache 0.2.0__tar.gz → 0.2.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.
@@ -1,6 +1,40 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.2.1 (2026-07-06)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Add cache key/series listing methods ([#2](https://github.com/cmaurer/mcp-cache/pull/2),
9
+ [`140bc30`](https://github.com/cmaurer/mcp-cache/commit/140bc30044f78f6ba36a9ac359234cd0fbb345d6))
10
+
11
+ * docs: add design spec for cache key/series listing methods
12
+
13
+ * docs: add implementation plan for cache key/series listing
14
+
15
+ * feat: add MCPCache.list_keys() to list TTL cache keys
16
+
17
+ Implement list_keys() method with optional prefix filtering and expired entry handling. Includes
18
+ _ttl_list_keys() helper and _like_prefix() escaping utility.
19
+
20
+ * feat: add MCPCache.list_series() to list time series IDs
21
+
22
+ Implement list_series() method with optional prefix filtering: - Public async method using
23
+ asyncio.to_thread pattern - Private sync helper _ts_list_series with SQL LIKE filtering - Reuses
24
+ _like_prefix helper from Task 1 - Results sorted alphabetically - Added 4 comprehensive tests
25
+ covering empty cache, deduplication, and prefix filtering
26
+
27
+ Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
28
+
29
+ * feat: expose cache_list_keys and timeseries_list_series MCP tools
30
+
31
+ * docs: add CLAUDE.md with architecture and command reference
32
+
33
+ ---------
34
+
35
+ Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
36
+
37
+
4
38
  ## v0.2.0 (2026-06-05)
5
39
 
6
40
  ### 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.0
3
+ Version: 0.2.1
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)
@@ -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."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcp-cache"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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"
@@ -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