Skip to content

Commit 4799f06

Browse files
author
Nicola Spieser
committed
feat: tool result caching (MCPCache.h, per-tool TTL, bounded eviction) — 29 new tests, fix version sync — bump to v0.36.0
1 parent 2077dea commit 4799f06

13 files changed

Lines changed: 842 additions & 11 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.36.0] — 2026-02-22
6+
7+
### Added
8+
- **Tool Result Caching** (`MCPCache.h`): Per-tool result caching with configurable TTL, bounded memory, and automatic expiration. Ideal for MCU sensor tools where hardware reads are expensive (e.g., DHT sensor needs 2s between reads, I2C scans are slow). Cache keys are computed from tool name + argument hash, so identical calls return cached results within the TTL window.
9+
- `ToolResultCache`: Standalone cache with `setToolTTL()`, `get()`, `put()`, `invalidateTool()`, `invalidate()`, `clear()`, `statsJson()`.
10+
- `enableCache()` / `isCacheEnabled()` on Server: opt-in caching (disabled by default).
11+
- `cache()` accessor on Server for per-tool TTL configuration and stats.
12+
- Bounded size with `setMaxEntries()` (default 32) — evicts expired then oldest entries.
13+
- Cache-aware tool dispatch: cache hits skip handler execution entirely, cache misses store results automatically.
14+
- Works with both simple and rich tool handlers.
15+
- After-call hooks still fire on cache hits for consistent metrics/logging.
16+
- Programmatic invalidation from within handlers (e.g., a "reset" tool invalidating a "read" tool's cache).
17+
- **29 new tests**: 20 ToolResultCache unit tests and 9 Server cache integration tests. **Total: 1252 tests**.
18+
19+
### Fixed
20+
- Version sync: `library.properties` and `library.json` now match `MCPD_VERSION` (previously stuck at 0.31.0).
21+
522
## [0.35.0] — 2026-02-22
623

724
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
|---|:---:|:---:|:---:|
3434
| Runs on the MCU ||| ❌ CLI tool |
3535
| MCP spec compliant | ✅ 2025-11-25 | ❌ custom WS ||
36-
| Actually compiles |1261 tests | ❌ self-described | N/A |
36+
| Actually compiles |1252 tests | ❌ self-described | N/A |
3737
| Streamable HTTP + SSE ||||
3838
| WebSocket transport ||||
3939
| Claude Desktop bridge ||||

docs/API.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,42 @@ If a tool's JSON output violates its declared `outputSchema` (wrong types, missi
513513
This catches handler bugs during development before invalid data reaches the client. Non-JSON output from tools with `outputSchema` gracefully skips validation (no `structuredContent` is generated).
514514

515515
Disabled by default. Enable with `mcp.enableOutputValidation()`.
516+
517+
### Tool Result Caching
518+
519+
Cache tool results with per-tool TTL to avoid expensive hardware reads:
520+
521+
```cpp
522+
// Configure TTL per tool (only explicitly configured tools are cached)
523+
mcp.cache().setToolTTL("temperature_read", 2000); // Cache for 2 seconds
524+
mcp.cache().setToolTTL("i2c_scan", 10000); // Cache for 10 seconds
525+
mcp.cache().setMaxEntries(32); // Limit memory (default: 32)
526+
mcp.enableCache(); // Activate caching
527+
```
528+
529+
Cache keys are computed from tool name + serialized arguments, so `temperature_read({"unit":"C"})` and `temperature_read({"unit":"F"})` are cached independently.
530+
531+
**Programmatic invalidation** (e.g., a "calibrate" tool invalidating a "read" tool):
532+
533+
```cpp
534+
mcp.addTool("sensor_calibrate", "Calibrate", schema,
535+
[&](const JsonObject& args) -> String {
536+
// ... calibration logic ...
537+
mcp.cache().invalidateTool("sensor_read"); // Clear stale readings
538+
return "Calibrated";
539+
});
540+
```
541+
542+
**Cache statistics** for diagnostics:
543+
544+
```cpp
545+
String stats = mcp.cache().statsJson();
546+
// {"enabled":true,"entries":5,"maxEntries":32,"hits":42,"misses":12,"hitRate":0.78,"toolCount":3}
547+
```
548+
549+
**Key behaviors:**
550+
- Disabled by default (opt-in with `enableCache()`)
551+
- Only tools with explicit TTL are cached (write/actuator tools are never cached unless configured)
552+
- Expired entries are evicted automatically; bounded size prevents memory exhaustion
553+
- After-call hooks still fire on cache hits for consistent metrics
554+
- Error results are also cached (prevents hammering a failing sensor)

library.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcpd",
3-
"version": "0.31.0",
3+
"version": "0.36.0",
44
"description": "MCP Server SDK for Microcontrollers — Expose ESP32/RP2040/STM32 hardware as AI-accessible tools via Model Context Protocol",
55
"keywords": [
66
"mcp",

library.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name=mcpd
2-
version=0.31.0
2+
version=0.36.0
33
author=Nicola Spieser
44
maintainer=Nicola Spieser <redbasecap-buiss@users.noreply.github.com>
55
sentence=MCP Server SDK for Microcontrollers

src/MCPCache.h

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* mcpd — Tool Result Cache
3+
*
4+
* Caches tool call results with configurable per-tool TTL.
5+
* Ideal for sensor tools on MCU where hardware reads are expensive
6+
* (e.g., DHT sensor needs 2s between reads, I2C scans are slow).
7+
*
8+
* Cache keys are computed from tool name + serialized arguments,
9+
* so identical calls return cached results within the TTL window.
10+
*/
11+
12+
#ifndef MCPD_CACHE_H
13+
#define MCPD_CACHE_H
14+
15+
#include <Arduino.h>
16+
#include <ArduinoJson.h>
17+
#include <map>
18+
19+
namespace mcpd {
20+
21+
struct CacheEntry {
22+
String result; // Cached result string
23+
unsigned long cachedAt; // millis() when cached
24+
unsigned long ttlMs; // Time-to-live in milliseconds
25+
bool isError; // Whether the cached result was an error
26+
27+
bool isValid() const {
28+
if (ttlMs == 0) return false;
29+
return (millis() - cachedAt) < ttlMs;
30+
}
31+
32+
unsigned long ageMs() const {
33+
return millis() - cachedAt;
34+
}
35+
36+
unsigned long remainingMs() const {
37+
if (!isValid()) return 0;
38+
return ttlMs - ageMs();
39+
}
40+
};
41+
42+
/**
43+
* Tool result cache with per-tool TTL and bounded size.
44+
*
45+
* Usage:
46+
* mcp.cache().setToolTTL("temperature_read", 2000); // cache for 2s
47+
* mcp.cache().setToolTTL("gpio_read", 500); // cache for 500ms
48+
* mcp.cache().setMaxEntries(32); // limit memory usage
49+
* mcp.enableCache(); // activate caching
50+
*/
51+
class ToolResultCache {
52+
public:
53+
ToolResultCache() = default;
54+
55+
/**
56+
* Set the cache TTL for a specific tool.
57+
* Only tools with explicit TTL are cached.
58+
* @param toolName Tool name
59+
* @param ttlMs Cache duration in milliseconds (0 = disable for this tool)
60+
*/
61+
void setToolTTL(const char* toolName, unsigned long ttlMs) {
62+
if (ttlMs == 0) {
63+
_toolTTLs.erase(String(toolName));
64+
} else {
65+
_toolTTLs[String(toolName)] = ttlMs;
66+
}
67+
}
68+
69+
/**
70+
* Get the configured TTL for a tool (0 = not cached).
71+
*/
72+
unsigned long getToolTTL(const char* toolName) const {
73+
auto it = _toolTTLs.find(String(toolName));
74+
return (it != _toolTTLs.end()) ? it->second : 0;
75+
}
76+
77+
/**
78+
* Check if a tool has caching configured.
79+
*/
80+
bool isToolCached(const char* toolName) const {
81+
return _toolTTLs.find(String(toolName)) != _toolTTLs.end();
82+
}
83+
84+
/**
85+
* Look up a cached result. Returns true if a valid (non-expired) entry exists.
86+
* @param toolName Tool name
87+
* @param argsJson Serialized arguments JSON
88+
* @param[out] result The cached result string
89+
* @param[out] isError Whether the cached result was an error
90+
* @return true if cache hit
91+
*/
92+
bool get(const char* toolName, const String& argsJson,
93+
String& result, bool& isError) {
94+
if (!_enabled) return false;
95+
96+
String key = _makeKey(toolName, argsJson);
97+
auto it = _entries.find(key);
98+
if (it == _entries.end()) return false;
99+
100+
if (!it->second.isValid()) {
101+
_entries.erase(it);
102+
return false;
103+
}
104+
105+
result = it->second.result;
106+
isError = it->second.isError;
107+
_hits++;
108+
return true;
109+
}
110+
111+
/**
112+
* Store a result in the cache.
113+
* Only stores if the tool has a configured TTL.
114+
*/
115+
void put(const char* toolName, const String& argsJson,
116+
const String& result, bool isError = false) {
117+
if (!_enabled) return;
118+
119+
unsigned long ttl = getToolTTL(toolName);
120+
if (ttl == 0) return;
121+
122+
// Evict expired entries if at capacity
123+
if (_entries.size() >= _maxEntries) {
124+
_evictExpired();
125+
}
126+
// If still at capacity, evict oldest
127+
if (_entries.size() >= _maxEntries) {
128+
_evictOldest();
129+
}
130+
131+
String key = _makeKey(toolName, argsJson);
132+
CacheEntry entry;
133+
entry.result = result;
134+
entry.cachedAt = millis();
135+
entry.ttlMs = ttl;
136+
entry.isError = isError;
137+
_entries[key] = entry;
138+
_misses++;
139+
}
140+
141+
/**
142+
* Invalidate all cached results for a specific tool.
143+
*/
144+
void invalidateTool(const char* toolName) {
145+
String prefix = String(toolName) + ":";
146+
auto it = _entries.begin();
147+
while (it != _entries.end()) {
148+
if (it->first.startsWith(prefix)) {
149+
it = _entries.erase(it);
150+
} else {
151+
++it;
152+
}
153+
}
154+
}
155+
156+
/**
157+
* Invalidate a specific cached result.
158+
*/
159+
void invalidate(const char* toolName, const String& argsJson) {
160+
String key = _makeKey(toolName, argsJson);
161+
_entries.erase(key);
162+
}
163+
164+
/**
165+
* Clear the entire cache.
166+
*/
167+
void clear() {
168+
_entries.clear();
169+
_hits = 0;
170+
_misses = 0;
171+
}
172+
173+
/**
174+
* Enable or disable the cache.
175+
*/
176+
void setEnabled(bool enabled) { _enabled = enabled; }
177+
bool isEnabled() const { return _enabled; }
178+
179+
/**
180+
* Set maximum number of cache entries (default: 32).
181+
* Keeps memory usage bounded on MCU.
182+
*/
183+
void setMaxEntries(size_t max) { _maxEntries = max; }
184+
size_t getMaxEntries() const { return _maxEntries; }
185+
186+
/**
187+
* Get cache statistics.
188+
*/
189+
size_t size() const { return _entries.size(); }
190+
unsigned long hits() const { return _hits; }
191+
unsigned long misses() const { return _misses; }
192+
float hitRate() const {
193+
unsigned long total = _hits + _misses;
194+
return (total > 0) ? (float)_hits / (float)total : 0.0f;
195+
}
196+
197+
/**
198+
* Get stats as JSON string for diagnostics.
199+
*/
200+
String statsJson() const {
201+
JsonDocument doc;
202+
doc["enabled"] = _enabled;
203+
doc["entries"] = _entries.size();
204+
doc["maxEntries"] = _maxEntries;
205+
doc["hits"] = _hits;
206+
doc["misses"] = _misses;
207+
doc["hitRate"] = hitRate();
208+
doc["toolCount"] = _toolTTLs.size();
209+
String out;
210+
serializeJson(doc, out);
211+
return out;
212+
}
213+
214+
private:
215+
bool _enabled = false;
216+
size_t _maxEntries = 32;
217+
unsigned long _hits = 0;
218+
unsigned long _misses = 0;
219+
220+
std::map<String, unsigned long> _toolTTLs; // tool name → TTL in ms
221+
std::map<String, CacheEntry> _entries; // key → entry
222+
223+
/**
224+
* Build a cache key from tool name and arguments.
225+
* Key format: "toolName:argsHash" where argsHash is a simple
226+
* string hash to keep keys short on MCU.
227+
*/
228+
String _makeKey(const char* toolName, const String& argsJson) const {
229+
// Simple DJB2 hash of the arguments
230+
unsigned long hash = 5381;
231+
for (size_t i = 0; i < argsJson.length(); i++) {
232+
hash = ((hash << 5) + hash) + (unsigned char)argsJson[i];
233+
}
234+
return String(toolName) + ":" + String(hash);
235+
}
236+
237+
void _evictExpired() {
238+
auto it = _entries.begin();
239+
while (it != _entries.end()) {
240+
if (!it->second.isValid()) {
241+
it = _entries.erase(it);
242+
} else {
243+
++it;
244+
}
245+
}
246+
}
247+
248+
void _evictOldest() {
249+
if (_entries.empty()) return;
250+
auto oldest = _entries.begin();
251+
unsigned long oldestAge = 0;
252+
for (auto it = _entries.begin(); it != _entries.end(); ++it) {
253+
unsigned long age = it->second.ageMs();
254+
if (age > oldestAge) {
255+
oldestAge = age;
256+
oldest = it;
257+
}
258+
}
259+
_entries.erase(oldest);
260+
}
261+
};
262+
263+
} // namespace mcpd
264+
265+
#endif // MCPD_CACHE_H

0 commit comments

Comments
 (0)