|
| 1 | +# help-scout-mcp-server: Caching Strategy |
| 2 | + |
| 3 | +> Source: https://github.com/drewburchfield/help-scout-mcp-server |
| 4 | +> Analyzed: 2026-02-25 |
| 5 | +
|
| 6 | +## Overview |
| 7 | + |
| 8 | +In-process LRU cache with SHA-256 key generation, endpoint-aware TTLs, and no PII-aware eviction. Raw API responses cached before any redaction is applied. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## Architecture |
| 13 | + |
| 14 | +``` |
| 15 | +HelpScout API → HelpScoutClient.get() → cache.set(raw) → tool handler → redact → MCP response |
| 16 | + ↓ |
| 17 | + cache.get(raw) → tool handler → redact → MCP response (cache hit) |
| 18 | +``` |
| 19 | + |
| 20 | +- **Library**: `lru-cache` (npm) |
| 21 | +- **Storage**: In-process memory. No disk persistence. Lost on restart. |
| 22 | +- **Singleton**: One `Cache` instance exported from `src/utils/cache.ts`, shared across all tool/resource handlers. |
| 23 | + |
| 24 | +--- |
| 25 | + |
| 26 | +## Configuration |
| 27 | + |
| 28 | +| Setting | Env var | Default | Unit | |
| 29 | +|---------|---------|---------|------| |
| 30 | +| TTL | `CACHE_TTL_SECONDS` | 300 | seconds | |
| 31 | +| Max entries | `MAX_CACHE_SIZE` | 10,000 | items | |
| 32 | + |
| 33 | +Both configurable via env vars. Applied at startup, no runtime changes. |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## Key generation |
| 38 | + |
| 39 | +```typescript |
| 40 | +generateKey(prefix: string, data?: unknown): string { |
| 41 | + const hash = crypto.createHash('sha256'); |
| 42 | + hash.update(JSON.stringify({ prefix, data })); |
| 43 | + return hash.digest('hex'); |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +- `prefix`: API endpoint path (e.g., `/conversations/123`) |
| 48 | +- `data`: query params object |
| 49 | +- SHA-256 of `JSON.stringify({prefix, data})` → hex string |
| 50 | +- Deterministic: same endpoint + same params = same cache key |
| 51 | +- No namespace isolation between tools/resources |
| 52 | + |
| 53 | +--- |
| 54 | + |
| 55 | +## TTL strategy (endpoint-aware) |
| 56 | + |
| 57 | +The `HelpScoutClient.get()` method selects TTL based on endpoint pattern: |
| 58 | + |
| 59 | +| Endpoint pattern | TTL | Rationale | |
| 60 | +|-----------------|-----|-----------| |
| 61 | +| `/mailboxes*` | 1440s (24 hours) | Mailbox config changes rarely | |
| 62 | +| `/conversations*` | 300s (5 min) | Conversations update frequently | |
| 63 | +| `/conversations/*/threads*` | 300s (5 min) | Threads update frequently | |
| 64 | +| Everything else | 300s (5 min) | Default | |
| 65 | + |
| 66 | +Custom TTL can be passed per-call via `cacheOptions.ttl` parameter, though no tool currently overrides the defaults. |
| 67 | + |
| 68 | +--- |
| 69 | + |
| 70 | +## Cache lifecycle |
| 71 | + |
| 72 | +### Read path |
| 73 | +```typescript |
| 74 | +async get<T>(endpoint, params, cacheOptions): Promise<T> { |
| 75 | + const cacheKey = `GET:${endpoint}`; |
| 76 | + const cachedResult = cache.get<T>(cacheKey, params); |
| 77 | + if (cachedResult) { |
| 78 | + logger.debug(`Cache hit: ${endpoint}`); |
| 79 | + return cachedResult; // raw API data, no redaction |
| 80 | + } |
| 81 | + // ... fetch from API |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +### Write path |
| 86 | +```typescript |
| 87 | +cache.set(cacheKey, params, response.data, { ttl: determinedTTL }); |
| 88 | +``` |
| 89 | + |
| 90 | +### Eviction |
| 91 | +- LRU eviction when max size (10,000) reached |
| 92 | +- TTL-based expiry per entry |
| 93 | +- `cache.clear()` method exists but is never called in application code |
| 94 | +- No manual invalidation on write operations (e.g., replying to a thread doesn't invalidate cached thread list) |
| 95 | + |
| 96 | +--- |
| 97 | + |
| 98 | +## PII implications |
| 99 | + |
| 100 | +### Raw data always cached |
| 101 | + |
| 102 | +Redaction happens in the tool layer *after* the cache returns data. The cache itself stores complete, unredacted API responses including: |
| 103 | + |
| 104 | +- Customer names and emails |
| 105 | +- Agent names and emails |
| 106 | +- Full message bodies |
| 107 | +- CC/BCC recipients |
| 108 | +- Thread metadata |
| 109 | + |
| 110 | +### No PII-aware features |
| 111 | + |
| 112 | +| Feature | Present? | Impact | |
| 113 | +|---------|----------|--------| |
| 114 | +| Cache-level redaction | No | Raw PII in memory for TTL duration | |
| 115 | +| PII-aware eviction | No | No way to flush customer data on demand | |
| 116 | +| Per-user cache isolation | No | All MCP consumers share same cache | |
| 117 | +| Cache encryption | No | Plain objects in process memory | |
| 118 | +| Audit logging of cache access | No | No trail of what PII was cached/served | |
| 119 | +| Config-change cache invalidation | No | Changing `REDACT_MESSAGE_CONTENT` doesn't flush cache | |
| 120 | + |
| 121 | +### Stale data risk |
| 122 | + |
| 123 | +No write-through invalidation. If a conversation is updated via the HelpScout UI (customer edits, agent replies), the cache serves stale data for up to 5 minutes. This is a correctness issue, not a security one, but worth noting. |
| 124 | + |
| 125 | +--- |
| 126 | + |
| 127 | +## Retry & connection pooling |
| 128 | + |
| 129 | +Not caching per se, but related to the client layer: |
| 130 | + |
| 131 | +### Retry logic (`executeWithRetry`) |
| 132 | +- Max attempts: 3 (configurable) |
| 133 | +- Base delay: 1000ms |
| 134 | +- Max delay: 10000ms |
| 135 | +- Jitter: 10% random to avoid thundering herd |
| 136 | +- 429 (rate limit): uses `Retry-After` header |
| 137 | +- 401 (auth failure): clears token, re-authenticates, retries |
| 138 | +- Other 5xx: exponential backoff |
| 139 | + |
| 140 | +### Connection pool |
| 141 | +- Max sockets: 50 |
| 142 | +- Max free sockets: 10 |
| 143 | +- Timeout: 30,000ms |
| 144 | +- Keep-alive: enabled (1000ms interval) |
| 145 | + |
| 146 | +### OAuth2 token caching |
| 147 | +- Access token stored in `accessToken` property |
| 148 | +- Expiry tracked with 60-second buffer (`tokenExpiresAt`) |
| 149 | +- Concurrent auth requests deduplicated via shared promise (`authenticationPromise`) |
| 150 | +- Token cleared on 401 to force re-auth |
| 151 | + |
| 152 | +--- |
| 153 | + |
| 154 | +## Relevance to hs-cli |
| 155 | + |
| 156 | +Our CLI is stateless (no persistent process), so in-process caching doesn't apply. However, these patterns inform our design: |
| 157 | + |
| 158 | +1. **If we ever build an MCP server wrapper**: cache should store anonymized data, not raw, to prevent bypass via cache reads. |
| 159 | +2. **Endpoint-aware TTLs**: reasonable approach if we add caching. Mailboxes are stable (long TTL), conversations/threads change often (short TTL). |
| 160 | +3. **Token management**: our `internal/auth/` already handles OAuth2 with keyring storage — different approach (persistent credentials) vs their in-process token lifecycle. |
0 commit comments