mcp-cache-kit 0.1.0

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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Core types for mcp-cache-kit, modeled on MCP SEP-2549.
3
+ *
4
+ * SEP-2549 (MCP spec, 2026-07-28 release candidate) adds two top-level fields to
5
+ * cacheable results (`tools/list`, `resources/list`, `resources/templates/list`,
6
+ * `prompts/list`, `resources/read`):
7
+ *
8
+ * - `ttlMs: number` — how long the client MAY treat the result as fresh,
9
+ * analogous to HTTP `Cache-Control: max-age`. `@minimum 0`.
10
+ * - `cacheScope: "public" | "private"` — analogous to HTTP
11
+ * `Cache-Control: public` vs `private`.
12
+ *
13
+ * Source of truth (verified):
14
+ * https://github.com/modelcontextprotocol/modelcontextprotocol (schema/draft/schema.ts,
15
+ * docs/specification/draft/server/utilities/caching.mdx) and
16
+ * https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
17
+ *
18
+ * NOTE: the 2026-07-28 spec is a release candidate. These field names/semantics
19
+ * may still shift before final. This library is intentionally tolerant of missing
20
+ * or malformed fields and treats anything it cannot prove safe as uncacheable.
21
+ */
22
+ /**
23
+ * The allowed values of `cacheScope` per SEP-2549.
24
+ *
25
+ * - `"public"` — the response does not contain user-specific data. Any client or
26
+ * intermediary MAY cache it and serve it across authorization contexts.
27
+ * - `"private"` — the response MAY be cached and reused only within the SAME
28
+ * authorization context. Caches MUST NOT be shared across authorization contexts
29
+ * (a different access token/user/session requires a different cache entry).
30
+ */
31
+ declare const CacheScope: {
32
+ /** Shareable across authorization contexts. */
33
+ readonly Public: "public";
34
+ /** Only reusable within the same authorization context. */
35
+ readonly Private: "private";
36
+ };
37
+ /** Union of the valid `cacheScope` string values: `"public" | "private"`. */
38
+ type CacheScope = (typeof CacheScope)[keyof typeof CacheScope];
39
+ /** Immutable list of valid scope values, handy for validation/iteration. */
40
+ declare const CACHE_SCOPE_VALUES: readonly CacheScope[];
41
+ /**
42
+ * The SEP-2549 cache hint fields as they appear (top-level) on a cacheable result.
43
+ */
44
+ interface CacheHints {
45
+ /**
46
+ * How long (ms) the client MAY treat the result as fresh. `>= 0`.
47
+ * `0` means "immediately stale". See {@link CacheScope} for scope semantics.
48
+ */
49
+ ttlMs: number;
50
+ /** Whether the result is safe to share across authorization contexts. */
51
+ cacheScope: CacheScope;
52
+ }
53
+ /**
54
+ * Minimal structural shape of an MCP result that MAY carry cache hints.
55
+ *
56
+ * Kept deliberately loose (`Record<string, unknown>`) so the helpers work on plain
57
+ * result objects WITHOUT a hard dependency on `@modelcontextprotocol/sdk`. If you
58
+ * do use the SDK, its `ListToolsResult` / `ReadResourceResult` (etc.) structurally
59
+ * satisfy this type.
60
+ */
61
+ interface MaybeCacheableResult extends Record<string, unknown> {
62
+ ttlMs?: unknown;
63
+ cacheScope?: unknown;
64
+ }
65
+ /**
66
+ * A result that carries valid, fully-typed SEP-2549 cache hints.
67
+ * `T` is the underlying result type so callers keep their concrete shape.
68
+ */
69
+ type WithCacheHints<T extends object> = T & CacheHints;
70
+ /** Options accepted by {@link withCacheHints}. */
71
+ interface CacheHintsInput {
72
+ /** Time-to-live in milliseconds. Must be a finite number `>= 0`. */
73
+ ttlMs: number;
74
+ /** One of {@link CacheScope}. */
75
+ cacheScope: CacheScope;
76
+ }
77
+ /**
78
+ * Result of parsing the cache hints off an unknown result object.
79
+ *
80
+ * `ok: true` only when BOTH fields are present and valid per spec. Otherwise
81
+ * `ok: false` with a machine-readable `reason` and human-readable `message`.
82
+ */
83
+ type ParsedCacheHints = {
84
+ ok: true;
85
+ hints: CacheHints;
86
+ } | {
87
+ ok: false;
88
+ reason: UncacheableReason;
89
+ message: string;
90
+ };
91
+ /** Machine-readable reasons a result is considered uncacheable. */
92
+ type UncacheableReason =
93
+ /** The result was null/undefined or not an object. */
94
+ "not-an-object"
95
+ /** `ttlMs` or `cacheScope` was absent. Fail-safe: don't cache. */
96
+ | "missing-fields"
97
+ /** `ttlMs` was present but not a finite number `>= 0`. */
98
+ | "invalid-ttl"
99
+ /** `cacheScope` was present but not `"public" | "private"`. */
100
+ | "invalid-scope"
101
+ /** `ttlMs` was `0` — explicitly "immediately stale", so nothing to store. */
102
+ | "zero-ttl"
103
+ /** A `private` result was offered without a scope identity to key it by. */
104
+ | "private-without-scope";
105
+ /**
106
+ * The decision returned by {@link cacheSafety} / used by the cache: may this result
107
+ * be stored for the given scope identity, and if not, why.
108
+ */
109
+ type CacheDecision = {
110
+ cacheable: true;
111
+ hints: CacheHints;
112
+ /**
113
+ * The scope key the entry MUST be stored under. For `public` results this is
114
+ * a shared constant; for `private` results it is derived from the caller's
115
+ * authorization-context identity.
116
+ */
117
+ scopeKey: string;
118
+ } | {
119
+ cacheable: false;
120
+ reason: UncacheableReason;
121
+ message: string;
122
+ };
123
+
124
+ /**
125
+ * Server-side helpers: attach and read SEP-2549 cache hints on results.
126
+ */
127
+
128
+ /** Type guard for the `cacheScope` enum. */
129
+ declare function isCacheScope(value: unknown): value is CacheScope;
130
+ /**
131
+ * True if `ttlMs` is a valid SEP-2549 TTL: a finite, non-negative number.
132
+ *
133
+ * The spec annotates `ttlMs` with `@minimum 0` and treats negative/absent values
134
+ * as `0`. We require a real finite number here (NaN / Infinity are rejected).
135
+ */
136
+ declare function isValidTtlMs(value: unknown): value is number;
137
+ /**
138
+ * Validate a {@link CacheHintsInput}, throwing a descriptive `TypeError` on bad input.
139
+ * Returns the normalized {@link CacheHints} (ttlMs floored to an integer ms).
140
+ *
141
+ * @throws {TypeError} if `ttlMs` is not a finite number `>= 0`, or `cacheScope`
142
+ * is not `"public" | "private"`.
143
+ */
144
+ declare function validateCacheHints(input: CacheHintsInput): CacheHints;
145
+ /**
146
+ * Server-side: attach SEP-2549 cache hints to a `tools/list` / `resources/read`
147
+ * (etc.) result so clients and proxies can cache it correctly.
148
+ *
149
+ * Returns a NEW object (does not mutate the input) with `ttlMs` and `cacheScope`
150
+ * set as top-level fields, exactly where the spec places them.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * server.setRequestHandler(ListToolsRequestSchema, () =>
155
+ * withCacheHints({ tools }, { ttlMs: 60_000, cacheScope: CacheScope.Public }),
156
+ * );
157
+ * ```
158
+ *
159
+ * @throws {TypeError} via {@link validateCacheHints} on invalid hints.
160
+ */
161
+ declare function withCacheHints<T extends object>(result: T, hints: CacheHintsInput): WithCacheHints<T>;
162
+ /**
163
+ * Convenience: a `public` result fresh for `ttlMs`. Safe to share across users.
164
+ */
165
+ declare function publicHints(ttlMs: number): CacheHints;
166
+ /**
167
+ * Convenience: a `private` result fresh for `ttlMs`. Reusable only within the
168
+ * same authorization context — the cache will refuse to share it across scopes.
169
+ */
170
+ declare function privateHints(ttlMs: number): CacheHints;
171
+ /**
172
+ * Client/proxy-side: read and validate the cache hints off an unknown result.
173
+ *
174
+ * Returns `{ ok: true, hints }` only when BOTH fields are present and valid.
175
+ * Anything else returns `{ ok: false, reason, message }` — this is the fail-safe
176
+ * primitive the cache builds on: if we cannot prove the hints, we don't cache.
177
+ *
178
+ * This never throws. It is safe to call on arbitrary JSON-RPC result payloads.
179
+ */
180
+ declare function parseCacheHints(result: unknown): ParsedCacheHints;
181
+
182
+ /**
183
+ * The safety layer: decide whether a result may be cached for a given
184
+ * authorization-context identity, and derive the scope key it must be stored under.
185
+ *
186
+ * This is where the cross-user-leak trap is closed. SEP-2549 says a `private`
187
+ * result "MAY be cached and reused only within the same authorization context".
188
+ * We enforce that by KEYING private entries with the caller's scope identity, so a
189
+ * `private` entry stored for user A is structurally unreachable for user B.
190
+ */
191
+
192
+ /**
193
+ * The single, shared scope key used for `public` results. Chosen to be a value
194
+ * that cannot collide with any real scope identity (which is escaped, see below).
195
+ */
196
+ declare const PUBLIC_SCOPE_KEY = "public";
197
+ /** Options for {@link cacheSafety} / {@link assertCacheSafe}. */
198
+ interface CacheSafetyOptions {
199
+ /**
200
+ * Identity of the caller's authorization context — e.g. an access-token hash,
201
+ * user id, tenant id, or session id. REQUIRED to cache `private` results.
202
+ * Pass it for `public` results too if you have it; it will simply be ignored.
203
+ */
204
+ scopeId?: string;
205
+ /**
206
+ * If true, a `ttlMs` of `0` is reported as cacheable (with `zero-ttl` being a
207
+ * non-fatal note). Default `false`: a 0 TTL means "immediately stale", so there
208
+ * is nothing worth storing and we report it uncacheable. The cache uses the
209
+ * default.
210
+ */
211
+ allowZeroTtl?: boolean;
212
+ }
213
+ /**
214
+ * Derive the scope key an entry must be stored under.
215
+ *
216
+ * - `public` → {@link PUBLIC_SCOPE_KEY} (shared across all callers).
217
+ * - `private` → a key derived from `scopeId`, namespaced so it can never collide
218
+ * with the public bucket or with another scope id.
219
+ *
220
+ * Returns `undefined` for a `private` result when no `scopeId` is supplied —
221
+ * the caller MUST treat that as "do not cache".
222
+ */
223
+ declare function deriveScopeKey(cacheScope: CacheScope, scopeId?: string): string | undefined;
224
+ /**
225
+ * Decide whether `result` may be cached for the given scope identity.
226
+ *
227
+ * Returns a {@link CacheDecision}: when `cacheable: true` it includes the validated
228
+ * hints and the `scopeKey` to store the entry under; when `cacheable: false` it
229
+ * includes a machine-readable `reason` and a human-readable `message`.
230
+ *
231
+ * Fail-safe by construction: missing/invalid hints, an unrecognized scope, a
232
+ * `private` result without a `scopeId`, and (by default) a `0` TTL all return
233
+ * `cacheable: false`. Never throws.
234
+ *
235
+ * Use this as a guard anywhere in a proxy/gateway:
236
+ * ```ts
237
+ * const d = cacheSafety(result, { scopeId: tokenHash });
238
+ * if (d.cacheable) store(key, d.scopeKey, result, d.hints.ttlMs);
239
+ * ```
240
+ */
241
+ declare function cacheSafety(result: unknown, options?: CacheSafetyOptions): CacheDecision;
242
+ /** Error thrown by {@link assertCacheSafe} when a result may not be cached. */
243
+ declare class CacheUnsafeError extends Error {
244
+ readonly name = "CacheUnsafeError";
245
+ /** Machine-readable reason, mirrors {@link CacheDecision}'s `reason`. */
246
+ readonly reason: Extract<CacheDecision, {
247
+ cacheable: false;
248
+ }>["reason"];
249
+ constructor(decision: Extract<CacheDecision, {
250
+ cacheable: false;
251
+ }>);
252
+ }
253
+ /**
254
+ * Assert that `result` may be cached for the given scope identity, throwing a
255
+ * {@link CacheUnsafeError} otherwise. Returns the validated hints + scopeKey.
256
+ *
257
+ * Prefer {@link cacheSafety} when you want to branch without exceptions; use this
258
+ * when "must be cacheable here" is an invariant you want to enforce loudly.
259
+ */
260
+ declare function assertCacheSafe(result: unknown, options?: CacheSafetyOptions): {
261
+ hints: CacheHints;
262
+ scopeKey: string;
263
+ };
264
+
265
+ /**
266
+ * Client / proxy-side cache that honors SEP-2549 hints — and never leaks a
267
+ * `private` result across authorization contexts.
268
+ *
269
+ * Safety model (the whole point of this package):
270
+ * - Every stored entry is keyed by `requestKey` AND `scopeKey`.
271
+ * - `public` results use a single shared scopeKey, so they ARE shared.
272
+ * - `private` results use a scopeKey derived from the caller's `scopeId`, so a
273
+ * lookup by a different caller derives a different key and structurally cannot
274
+ * hit another caller's entry.
275
+ * - Anything we cannot prove safe (missing/invalid hints, private-without-scope,
276
+ * 0 TTL) is simply not stored. `get` of such a thing is always a miss.
277
+ */
278
+
279
+ /** Monotonic-ish clock returning epoch milliseconds. Injectable for tests. */
280
+ type Clock = () => number;
281
+ /** A request identity: either a precomputed string key, or the raw JSON-RPC request. */
282
+ type RequestKeyInput = string | {
283
+ method: string;
284
+ params?: unknown;
285
+ };
286
+ /** Options for {@link McpResultCache}. */
287
+ interface McpResultCacheOptions {
288
+ /** Max number of live entries. Oldest-inserted entries are evicted first. Default 1000. `0`/negative disables caching. */
289
+ maxEntries?: number;
290
+ /** Clock for TTL math. Default `Date.now`. Override with fake timers in tests. */
291
+ clock?: Clock;
292
+ /** Treat a `0` TTL as cacheable. Default `false` (0 = immediately stale). */
293
+ allowZeroTtl?: boolean;
294
+ }
295
+ /** Per-lookup options. */
296
+ interface LookupOptions {
297
+ /**
298
+ * Identity of the caller's authorization context (token hash / user / tenant /
299
+ * session id). REQUIRED to read or write `private` entries; ignored for `public`.
300
+ */
301
+ scopeId?: string;
302
+ }
303
+ /** Outcome of {@link McpResultCache.set}. */
304
+ type SetOutcome = {
305
+ stored: true;
306
+ scopeKey: string;
307
+ expiresAt: number;
308
+ hints: CacheHints;
309
+ } | {
310
+ stored: false;
311
+ reason: UncacheableReason;
312
+ message: string;
313
+ };
314
+ /** Outcome of {@link McpResultCache.get}. */
315
+ type GetOutcome<T> = {
316
+ hit: true;
317
+ value: T;
318
+ hints: CacheHints;
319
+ expiresAt: number;
320
+ } | {
321
+ hit: false;
322
+ reason: GetMissReason;
323
+ };
324
+ /** Why a {@link McpResultCache.get} missed. */
325
+ type GetMissReason =
326
+ /** No entry for this (request, scope). */
327
+ "miss"
328
+ /** An entry existed but its TTL had elapsed (it has been evicted). */
329
+ | "expired";
330
+ /** Snapshot counters for observability. Read via {@link McpResultCache.stats}. */
331
+ interface CacheStats {
332
+ hits: number;
333
+ misses: number;
334
+ expired: number;
335
+ stores: number;
336
+ /** `set` calls rejected because the result was not cache-safe. */
337
+ rejected: number;
338
+ evictions: number;
339
+ /** Current number of live (not-yet-pruned) entries. */
340
+ size: number;
341
+ }
342
+ /**
343
+ * Build a stable cache key from a request. If given a string, it is used as-is.
344
+ * If given `{ method, params }`, params are deterministically serialized (object
345
+ * keys sorted recursively) so that semantically equal requests collide.
346
+ */
347
+ declare function deriveRequestKey(input: RequestKeyInput): string;
348
+ /** Deterministic JSON: object keys sorted recursively. Arrays keep order. */
349
+ declare function stableStringify(value: unknown): string;
350
+ /**
351
+ * A small, dependency-free, SEP-2549-aware result cache safe for use in MCP
352
+ * clients, gateways, and proxies.
353
+ */
354
+ declare class McpResultCache {
355
+ #private;
356
+ constructor(options?: McpResultCacheOptions);
357
+ /**
358
+ * Store a result IF it is cache-safe for the given scope. Validates hints,
359
+ * derives the scope key (refusing `private` without a `scopeId`), and stores
360
+ * keyed by (request, scope). Returns whether it was stored and why not.
361
+ *
362
+ * Always safe to call on any result — non-cacheable results are silently
363
+ * rejected (counted in {@link CacheStats.rejected}) rather than stored.
364
+ */
365
+ set<T extends object>(request: RequestKeyInput, result: T, options?: LookupOptions): SetOutcome;
366
+ /**
367
+ * Look up a result for the given request AND scope identity.
368
+ *
369
+ * A `private` entry is ONLY returned to the same `scopeId` that stored it: the
370
+ * lookup derives the scope key from the caller's `scopeId`, so another caller's
371
+ * lookup targets a different key and can never reach it. A `public` entry is
372
+ * returned to anyone (it is stored under the shared public key).
373
+ *
374
+ * Expired entries are treated as a miss and removed lazily on access.
375
+ */
376
+ get<T>(request: RequestKeyInput, options?: LookupOptions): GetOutcome<T>;
377
+ /**
378
+ * Convenience wrapper: return the cached value, or compute + store it.
379
+ *
380
+ * If a fresh entry exists it is returned. Otherwise `loader()` runs, its result
381
+ * is offered to {@link set} (stored only if cache-safe), and returned regardless.
382
+ */
383
+ getOrLoad<T extends object>(request: RequestKeyInput, loader: () => Promise<T> | T, options?: LookupOptions): Promise<T>;
384
+ /** Remove all entries whose TTL has elapsed. Returns the count removed. */
385
+ prune(): number;
386
+ /** Drop everything. Does not reset stat counters. */
387
+ clear(): void;
388
+ /** Current live entry count (without pruning). */
389
+ get size(): number;
390
+ /** A snapshot of counters plus current size. */
391
+ stats(): CacheStats;
392
+ }
393
+
394
+ export { CACHE_SCOPE_VALUES, type CacheDecision, type CacheHints, type CacheHintsInput, type CacheSafetyOptions, CacheScope, type CacheStats, CacheUnsafeError, type Clock, type GetMissReason, type GetOutcome, type LookupOptions, type MaybeCacheableResult, McpResultCache, type McpResultCacheOptions, PUBLIC_SCOPE_KEY, type ParsedCacheHints, type RequestKeyInput, type SetOutcome, type UncacheableReason, type WithCacheHints, assertCacheSafe, cacheSafety, deriveRequestKey, deriveScopeKey, isCacheScope, isValidTtlMs, parseCacheHints, privateHints, publicHints, stableStringify, validateCacheHints, withCacheHints };