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.
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ // src/types.ts
2
+ var CacheScope = {
3
+ /** Shareable across authorization contexts. */
4
+ Public: "public",
5
+ /** Only reusable within the same authorization context. */
6
+ Private: "private"
7
+ };
8
+ var CACHE_SCOPE_VALUES = Object.freeze([
9
+ CacheScope.Public,
10
+ CacheScope.Private
11
+ ]);
12
+
13
+ // src/hints.ts
14
+ function isCacheScope(value) {
15
+ return typeof value === "string" && CACHE_SCOPE_VALUES.includes(value);
16
+ }
17
+ function isValidTtlMs(value) {
18
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
19
+ }
20
+ function validateCacheHints(input) {
21
+ if (input === null || typeof input !== "object") {
22
+ throw new TypeError(
23
+ `mcp-cache-kit: cache hints must be an object with { ttlMs, cacheScope }, got ${describe(
24
+ input
25
+ )}`
26
+ );
27
+ }
28
+ if (!isValidTtlMs(input.ttlMs)) {
29
+ throw new TypeError(
30
+ `mcp-cache-kit: ttlMs must be a finite number >= 0 (milliseconds), got ${describe(
31
+ input.ttlMs
32
+ )}`
33
+ );
34
+ }
35
+ if (!isCacheScope(input.cacheScope)) {
36
+ throw new TypeError(
37
+ `mcp-cache-kit: cacheScope must be one of ${CACHE_SCOPE_VALUES.map(
38
+ (v) => `"${v}"`
39
+ ).join(" | ")}, got ${describe(input.cacheScope)}`
40
+ );
41
+ }
42
+ return { ttlMs: Math.floor(input.ttlMs), cacheScope: input.cacheScope };
43
+ }
44
+ function withCacheHints(result, hints) {
45
+ if (result === null || typeof result !== "object") {
46
+ throw new TypeError(
47
+ `mcp-cache-kit: withCacheHints(result, ...) requires a result object, got ${describe(
48
+ result
49
+ )}`
50
+ );
51
+ }
52
+ const valid = validateCacheHints(hints);
53
+ return { ...result, ttlMs: valid.ttlMs, cacheScope: valid.cacheScope };
54
+ }
55
+ function publicHints(ttlMs) {
56
+ return validateCacheHints({ ttlMs, cacheScope: CacheScope.Public });
57
+ }
58
+ function privateHints(ttlMs) {
59
+ return validateCacheHints({ ttlMs, cacheScope: CacheScope.Private });
60
+ }
61
+ function parseCacheHints(result) {
62
+ if (result === null || typeof result !== "object") {
63
+ return {
64
+ ok: false,
65
+ reason: "not-an-object",
66
+ message: `result is not an object (got ${describe(result)})`
67
+ };
68
+ }
69
+ const r = result;
70
+ let ttlVal;
71
+ let scopeVal;
72
+ try {
73
+ ttlVal = "ttlMs" in r ? r.ttlMs : void 0;
74
+ scopeVal = "cacheScope" in r ? r.cacheScope : void 0;
75
+ } catch {
76
+ return {
77
+ ok: false,
78
+ reason: "not-an-object",
79
+ message: "reading cache hints threw (hostile getter on the result?)"
80
+ };
81
+ }
82
+ const hasTtl = ttlVal !== void 0;
83
+ const hasScope = scopeVal !== void 0;
84
+ if (!hasTtl && !hasScope) {
85
+ return {
86
+ ok: false,
87
+ reason: "missing-fields",
88
+ message: "result has no SEP-2549 cache hints (ttlMs / cacheScope absent)"
89
+ };
90
+ }
91
+ if (!hasTtl || !hasScope) {
92
+ return {
93
+ ok: false,
94
+ reason: "missing-fields",
95
+ message: "result has only one of ttlMs / cacheScope; both are required to be cacheable"
96
+ };
97
+ }
98
+ if (!isValidTtlMs(ttlVal)) {
99
+ return {
100
+ ok: false,
101
+ reason: "invalid-ttl",
102
+ message: `ttlMs is not a finite number >= 0 (got ${describe(ttlVal)})`
103
+ };
104
+ }
105
+ if (!isCacheScope(scopeVal)) {
106
+ return {
107
+ ok: false,
108
+ reason: "invalid-scope",
109
+ message: `cacheScope is not "public" | "private" (got ${describe(
110
+ scopeVal
111
+ )})`
112
+ };
113
+ }
114
+ return { ok: true, hints: { ttlMs: Math.floor(ttlVal), cacheScope: scopeVal } };
115
+ }
116
+ function describe(value) {
117
+ if (value === null) return "null";
118
+ if (typeof value === "string") return JSON.stringify(value);
119
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
120
+ if (Array.isArray(value)) return "an array";
121
+ return typeof value;
122
+ }
123
+
124
+ // src/safety.ts
125
+ var PUBLIC_SCOPE_KEY = "public";
126
+ function deriveScopeKey(cacheScope, scopeId) {
127
+ if (cacheScope === CacheScope.Public) return PUBLIC_SCOPE_KEY;
128
+ if (typeof scopeId !== "string" || scopeId === "") return void 0;
129
+ return `private:${scopeId.length}:${scopeId}`;
130
+ }
131
+ function cacheSafety(result, options = {}) {
132
+ const parsed = parseCacheHints(result);
133
+ if (!parsed.ok) {
134
+ return { cacheable: false, reason: parsed.reason, message: parsed.message };
135
+ }
136
+ const hints = parsed.hints;
137
+ if (hints.ttlMs === 0 && options.allowZeroTtl !== true) {
138
+ return {
139
+ cacheable: false,
140
+ reason: "zero-ttl",
141
+ message: "ttlMs is 0 (immediately stale); nothing to cache"
142
+ };
143
+ }
144
+ const scopeKey = deriveScopeKey(hints.cacheScope, options.scopeId);
145
+ if (scopeKey === void 0) {
146
+ return {
147
+ cacheable: false,
148
+ reason: "private-without-scope",
149
+ message: 'cacheScope is "private" but no scopeId was provided; refusing to cache to avoid cross-context leaks'
150
+ };
151
+ }
152
+ return { cacheable: true, hints, scopeKey };
153
+ }
154
+ var CacheUnsafeError = class extends Error {
155
+ name = "CacheUnsafeError";
156
+ /** Machine-readable reason, mirrors {@link CacheDecision}'s `reason`. */
157
+ reason;
158
+ constructor(decision) {
159
+ super(`mcp-cache-kit: result is not cache-safe (${decision.reason}): ${decision.message}`);
160
+ this.reason = decision.reason;
161
+ }
162
+ };
163
+ function assertCacheSafe(result, options = {}) {
164
+ const decision = cacheSafety(result, options);
165
+ if (!decision.cacheable) {
166
+ throw new CacheUnsafeError(decision);
167
+ }
168
+ return { hints: decision.hints, scopeKey: decision.scopeKey };
169
+ }
170
+
171
+ // src/cache.ts
172
+ function deriveRequestKey(input) {
173
+ if (typeof input === "string") return input;
174
+ return `${input.method}\0${stableStringify(input.params)}`;
175
+ }
176
+ function stableStringify(value) {
177
+ return JSON.stringify(normalize(value));
178
+ }
179
+ function normalize(value) {
180
+ if (value === null || typeof value !== "object") return value;
181
+ if (Array.isArray(value)) return value.map(normalize);
182
+ const obj = value;
183
+ const out = {};
184
+ for (const key of Object.keys(obj).sort()) {
185
+ out[key] = normalize(obj[key]);
186
+ }
187
+ return out;
188
+ }
189
+ function storageKey(requestKey, scopeKey) {
190
+ return `${scopeKey}\0${requestKey}`;
191
+ }
192
+ var McpResultCache = class {
193
+ #maxEntries;
194
+ #clock;
195
+ #allowZeroTtl;
196
+ // Insertion-ordered (Map preserves order) — enables O(1) FIFO eviction.
197
+ #store = /* @__PURE__ */ new Map();
198
+ #stats = {
199
+ hits: 0,
200
+ misses: 0,
201
+ expired: 0,
202
+ stores: 0,
203
+ rejected: 0,
204
+ evictions: 0
205
+ };
206
+ constructor(options = {}) {
207
+ this.#maxEntries = options.maxEntries ?? 1e3;
208
+ this.#clock = options.clock ?? Date.now;
209
+ this.#allowZeroTtl = options.allowZeroTtl ?? false;
210
+ }
211
+ /**
212
+ * Store a result IF it is cache-safe for the given scope. Validates hints,
213
+ * derives the scope key (refusing `private` without a `scopeId`), and stores
214
+ * keyed by (request, scope). Returns whether it was stored and why not.
215
+ *
216
+ * Always safe to call on any result — non-cacheable results are silently
217
+ * rejected (counted in {@link CacheStats.rejected}) rather than stored.
218
+ */
219
+ set(request, result, options = {}) {
220
+ if (this.#maxEntries <= 0) {
221
+ return { stored: false, reason: "missing-fields", message: "cache disabled (maxEntries <= 0)" };
222
+ }
223
+ const decision = cacheSafety(result, {
224
+ ...options.scopeId !== void 0 ? { scopeId: options.scopeId } : {},
225
+ allowZeroTtl: this.#allowZeroTtl
226
+ });
227
+ if (!decision.cacheable) {
228
+ this.#stats.rejected++;
229
+ return { stored: false, reason: decision.reason, message: decision.message };
230
+ }
231
+ const requestKey = deriveRequestKey(request);
232
+ const key = storageKey(requestKey, decision.scopeKey);
233
+ const expiresAt = this.#clock() + decision.hints.ttlMs;
234
+ this.#store.delete(key);
235
+ this.#store.set(key, { value: result, hints: decision.hints, expiresAt });
236
+ this.#stats.stores++;
237
+ this.#evictIfNeeded();
238
+ return { stored: true, scopeKey: decision.scopeKey, expiresAt, hints: decision.hints };
239
+ }
240
+ /**
241
+ * Look up a result for the given request AND scope identity.
242
+ *
243
+ * A `private` entry is ONLY returned to the same `scopeId` that stored it: the
244
+ * lookup derives the scope key from the caller's `scopeId`, so another caller's
245
+ * lookup targets a different key and can never reach it. A `public` entry is
246
+ * returned to anyone (it is stored under the shared public key).
247
+ *
248
+ * Expired entries are treated as a miss and removed lazily on access.
249
+ */
250
+ get(request, options = {}) {
251
+ const requestKey = deriveRequestKey(request);
252
+ const candidateScopeKeys = [];
253
+ const privateKey = deriveScopeKey(CacheScope.Private, options.scopeId);
254
+ if (privateKey !== void 0) candidateScopeKeys.push(privateKey);
255
+ candidateScopeKeys.push(deriveScopeKey(CacheScope.Public));
256
+ for (const scopeKey of candidateScopeKeys) {
257
+ const key = storageKey(requestKey, scopeKey);
258
+ const entry = this.#store.get(key);
259
+ if (entry === void 0) continue;
260
+ if (this.#clock() >= entry.expiresAt) {
261
+ this.#store.delete(key);
262
+ this.#stats.expired++;
263
+ continue;
264
+ }
265
+ this.#stats.hits++;
266
+ return {
267
+ hit: true,
268
+ value: entry.value,
269
+ hints: entry.hints,
270
+ expiresAt: entry.expiresAt
271
+ };
272
+ }
273
+ this.#stats.misses++;
274
+ return { hit: false, reason: "miss" };
275
+ }
276
+ /**
277
+ * Convenience wrapper: return the cached value, or compute + store it.
278
+ *
279
+ * If a fresh entry exists it is returned. Otherwise `loader()` runs, its result
280
+ * is offered to {@link set} (stored only if cache-safe), and returned regardless.
281
+ */
282
+ async getOrLoad(request, loader, options = {}) {
283
+ const cached = this.get(request, options);
284
+ if (cached.hit) return cached.value;
285
+ const value = await loader();
286
+ this.set(request, value, options);
287
+ return value;
288
+ }
289
+ /** Remove all entries whose TTL has elapsed. Returns the count removed. */
290
+ prune() {
291
+ const now = this.#clock();
292
+ let removed = 0;
293
+ for (const [key, entry] of this.#store) {
294
+ if (now >= entry.expiresAt) {
295
+ this.#store.delete(key);
296
+ removed++;
297
+ }
298
+ }
299
+ this.#stats.expired += removed;
300
+ return removed;
301
+ }
302
+ /** Drop everything. Does not reset stat counters. */
303
+ clear() {
304
+ this.#store.clear();
305
+ }
306
+ /** Current live entry count (without pruning). */
307
+ get size() {
308
+ return this.#store.size;
309
+ }
310
+ /** A snapshot of counters plus current size. */
311
+ stats() {
312
+ return { ...this.#stats, size: this.#store.size };
313
+ }
314
+ #evictIfNeeded() {
315
+ while (this.#store.size > this.#maxEntries) {
316
+ const oldest = this.#store.keys().next().value;
317
+ if (oldest === void 0) break;
318
+ this.#store.delete(oldest);
319
+ this.#stats.evictions++;
320
+ }
321
+ }
322
+ };
323
+
324
+ export { CACHE_SCOPE_VALUES, CacheScope, CacheUnsafeError, McpResultCache, PUBLIC_SCOPE_KEY, assertCacheSafe, cacheSafety, deriveRequestKey, deriveScopeKey, isCacheScope, isValidTtlMs, parseCacheHints, privateHints, publicHints, stableStringify, validateCacheHints, withCacheHints };
325
+ //# sourceMappingURL=index.js.map
326
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts","../src/hints.ts","../src/safety.ts","../src/cache.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Core types for mcp-cache-kit, modeled on MCP SEP-2549.\n *\n * SEP-2549 (MCP spec, 2026-07-28 release candidate) adds two top-level fields to\n * cacheable results (`tools/list`, `resources/list`, `resources/templates/list`,\n * `prompts/list`, `resources/read`):\n *\n * - `ttlMs: number` — how long the client MAY treat the result as fresh,\n * analogous to HTTP `Cache-Control: max-age`. `@minimum 0`.\n * - `cacheScope: \"public\" | \"private\"` — analogous to HTTP\n * `Cache-Control: public` vs `private`.\n *\n * Source of truth (verified):\n * https://github.com/modelcontextprotocol/modelcontextprotocol (schema/draft/schema.ts,\n * docs/specification/draft/server/utilities/caching.mdx) and\n * https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/\n *\n * NOTE: the 2026-07-28 spec is a release candidate. These field names/semantics\n * may still shift before final. This library is intentionally tolerant of missing\n * or malformed fields and treats anything it cannot prove safe as uncacheable.\n */\n\n/**\n * The allowed values of `cacheScope` per SEP-2549.\n *\n * - `\"public\"` — the response does not contain user-specific data. Any client or\n * intermediary MAY cache it and serve it across authorization contexts.\n * - `\"private\"` — the response MAY be cached and reused only within the SAME\n * authorization context. Caches MUST NOT be shared across authorization contexts\n * (a different access token/user/session requires a different cache entry).\n */\nexport const CacheScope = {\n /** Shareable across authorization contexts. */\n Public: \"public\",\n /** Only reusable within the same authorization context. */\n Private: \"private\",\n} as const;\n\n/** Union of the valid `cacheScope` string values: `\"public\" | \"private\"`. */\nexport type CacheScope = (typeof CacheScope)[keyof typeof CacheScope];\n\n/** Immutable list of valid scope values, handy for validation/iteration. */\nexport const CACHE_SCOPE_VALUES: readonly CacheScope[] = Object.freeze([\n CacheScope.Public,\n CacheScope.Private,\n]);\n\n/**\n * The SEP-2549 cache hint fields as they appear (top-level) on a cacheable result.\n */\nexport interface CacheHints {\n /**\n * How long (ms) the client MAY treat the result as fresh. `>= 0`.\n * `0` means \"immediately stale\". See {@link CacheScope} for scope semantics.\n */\n ttlMs: number;\n /** Whether the result is safe to share across authorization contexts. */\n cacheScope: CacheScope;\n}\n\n/**\n * Minimal structural shape of an MCP result that MAY carry cache hints.\n *\n * Kept deliberately loose (`Record<string, unknown>`) so the helpers work on plain\n * result objects WITHOUT a hard dependency on `@modelcontextprotocol/sdk`. If you\n * do use the SDK, its `ListToolsResult` / `ReadResourceResult` (etc.) structurally\n * satisfy this type.\n */\nexport interface MaybeCacheableResult extends Record<string, unknown> {\n ttlMs?: unknown;\n cacheScope?: unknown;\n}\n\n/**\n * A result that carries valid, fully-typed SEP-2549 cache hints.\n * `T` is the underlying result type so callers keep their concrete shape.\n */\nexport type WithCacheHints<T extends object> = T & CacheHints;\n\n/** Options accepted by {@link withCacheHints}. */\nexport interface CacheHintsInput {\n /** Time-to-live in milliseconds. Must be a finite number `>= 0`. */\n ttlMs: number;\n /** One of {@link CacheScope}. */\n cacheScope: CacheScope;\n}\n\n/**\n * Result of parsing the cache hints off an unknown result object.\n *\n * `ok: true` only when BOTH fields are present and valid per spec. Otherwise\n * `ok: false` with a machine-readable `reason` and human-readable `message`.\n */\nexport type ParsedCacheHints =\n | { ok: true; hints: CacheHints }\n | { ok: false; reason: UncacheableReason; message: string };\n\n/** Machine-readable reasons a result is considered uncacheable. */\nexport type UncacheableReason =\n /** The result was null/undefined or not an object. */\n | \"not-an-object\"\n /** `ttlMs` or `cacheScope` was absent. Fail-safe: don't cache. */\n | \"missing-fields\"\n /** `ttlMs` was present but not a finite number `>= 0`. */\n | \"invalid-ttl\"\n /** `cacheScope` was present but not `\"public\" | \"private\"`. */\n | \"invalid-scope\"\n /** `ttlMs` was `0` — explicitly \"immediately stale\", so nothing to store. */\n | \"zero-ttl\"\n /** A `private` result was offered without a scope identity to key it by. */\n | \"private-without-scope\";\n\n/**\n * The decision returned by {@link cacheSafety} / used by the cache: may this result\n * be stored for the given scope identity, and if not, why.\n */\nexport type CacheDecision =\n | {\n cacheable: true;\n hints: CacheHints;\n /**\n * The scope key the entry MUST be stored under. For `public` results this is\n * a shared constant; for `private` results it is derived from the caller's\n * authorization-context identity.\n */\n scopeKey: string;\n }\n | { cacheable: false; reason: UncacheableReason; message: string };\n","/**\n * Server-side helpers: attach and read SEP-2549 cache hints on results.\n */\n\nimport {\n CACHE_SCOPE_VALUES,\n CacheScope,\n type CacheHints,\n type CacheHintsInput,\n type MaybeCacheableResult,\n type ParsedCacheHints,\n type WithCacheHints,\n} from \"./types.js\";\n\n/** Type guard for the `cacheScope` enum. */\nexport function isCacheScope(value: unknown): value is CacheScope {\n return (\n typeof value === \"string\" &&\n (CACHE_SCOPE_VALUES as readonly string[]).includes(value)\n );\n}\n\n/**\n * True if `ttlMs` is a valid SEP-2549 TTL: a finite, non-negative number.\n *\n * The spec annotates `ttlMs` with `@minimum 0` and treats negative/absent values\n * as `0`. We require a real finite number here (NaN / Infinity are rejected).\n */\nexport function isValidTtlMs(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0;\n}\n\n/**\n * Validate a {@link CacheHintsInput}, throwing a descriptive `TypeError` on bad input.\n * Returns the normalized {@link CacheHints} (ttlMs floored to an integer ms).\n *\n * @throws {TypeError} if `ttlMs` is not a finite number `>= 0`, or `cacheScope`\n * is not `\"public\" | \"private\"`.\n */\nexport function validateCacheHints(input: CacheHintsInput): CacheHints {\n if (input === null || typeof input !== \"object\") {\n throw new TypeError(\n `mcp-cache-kit: cache hints must be an object with { ttlMs, cacheScope }, got ${describe(\n input,\n )}`,\n );\n }\n if (!isValidTtlMs(input.ttlMs)) {\n throw new TypeError(\n `mcp-cache-kit: ttlMs must be a finite number >= 0 (milliseconds), got ${describe(\n input.ttlMs,\n )}`,\n );\n }\n if (!isCacheScope(input.cacheScope)) {\n throw new TypeError(\n `mcp-cache-kit: cacheScope must be one of ${CACHE_SCOPE_VALUES.map(\n (v) => `\"${v}\"`,\n ).join(\" | \")}, got ${describe(input.cacheScope)}`,\n );\n }\n // Normalize to integer milliseconds — fractional ms is meaningless and keeps\n // the value JSON-clean for the wire.\n return { ttlMs: Math.floor(input.ttlMs), cacheScope: input.cacheScope };\n}\n\n/**\n * Server-side: attach SEP-2549 cache hints to a `tools/list` / `resources/read`\n * (etc.) result so clients and proxies can cache it correctly.\n *\n * Returns a NEW object (does not mutate the input) with `ttlMs` and `cacheScope`\n * set as top-level fields, exactly where the spec places them.\n *\n * @example\n * ```ts\n * server.setRequestHandler(ListToolsRequestSchema, () =>\n * withCacheHints({ tools }, { ttlMs: 60_000, cacheScope: CacheScope.Public }),\n * );\n * ```\n *\n * @throws {TypeError} via {@link validateCacheHints} on invalid hints.\n */\nexport function withCacheHints<T extends object>(\n result: T,\n hints: CacheHintsInput,\n): WithCacheHints<T> {\n if (result === null || typeof result !== \"object\") {\n throw new TypeError(\n `mcp-cache-kit: withCacheHints(result, ...) requires a result object, got ${describe(\n result,\n )}`,\n );\n }\n const valid = validateCacheHints(hints);\n return { ...result, ttlMs: valid.ttlMs, cacheScope: valid.cacheScope };\n}\n\n/**\n * Convenience: a `public` result fresh for `ttlMs`. Safe to share across users.\n */\nexport function publicHints(ttlMs: number): CacheHints {\n return validateCacheHints({ ttlMs, cacheScope: CacheScope.Public });\n}\n\n/**\n * Convenience: a `private` result fresh for `ttlMs`. Reusable only within the\n * same authorization context — the cache will refuse to share it across scopes.\n */\nexport function privateHints(ttlMs: number): CacheHints {\n return validateCacheHints({ ttlMs, cacheScope: CacheScope.Private });\n}\n\n/**\n * Client/proxy-side: read and validate the cache hints off an unknown result.\n *\n * Returns `{ ok: true, hints }` only when BOTH fields are present and valid.\n * Anything else returns `{ ok: false, reason, message }` — this is the fail-safe\n * primitive the cache builds on: if we cannot prove the hints, we don't cache.\n *\n * This never throws. It is safe to call on arbitrary JSON-RPC result payloads.\n */\nexport function parseCacheHints(result: unknown): ParsedCacheHints {\n if (result === null || typeof result !== \"object\") {\n return {\n ok: false,\n reason: \"not-an-object\",\n message: `result is not an object (got ${describe(result)})`,\n };\n }\n const r = result as MaybeCacheableResult;\n // Read the two fields once, defensively: a live JS object could carry a\n // throwing getter on ttlMs/cacheScope. We promise never to throw, so a hostile\n // getter is treated as \"cannot read hints\" → uncacheable (fail safe).\n let ttlVal: unknown;\n let scopeVal: unknown;\n try {\n ttlVal = \"ttlMs\" in r ? r.ttlMs : undefined;\n scopeVal = \"cacheScope\" in r ? r.cacheScope : undefined;\n } catch {\n return {\n ok: false,\n reason: \"not-an-object\",\n message: \"reading cache hints threw (hostile getter on the result?)\",\n };\n }\n const hasTtl = ttlVal !== undefined;\n const hasScope = scopeVal !== undefined;\n\n if (!hasTtl && !hasScope) {\n return {\n ok: false,\n reason: \"missing-fields\",\n message: \"result has no SEP-2549 cache hints (ttlMs / cacheScope absent)\",\n };\n }\n // Per the caching spec, absent ttlMs defaults to 0; but a partially-hinted\n // result (one field present, the other missing) is malformed — fail safe.\n if (!hasTtl || !hasScope) {\n return {\n ok: false,\n reason: \"missing-fields\",\n message:\n \"result has only one of ttlMs / cacheScope; both are required to be cacheable\",\n };\n }\n if (!isValidTtlMs(ttlVal)) {\n return {\n ok: false,\n reason: \"invalid-ttl\",\n message: `ttlMs is not a finite number >= 0 (got ${describe(ttlVal)})`,\n };\n }\n if (!isCacheScope(scopeVal)) {\n return {\n ok: false,\n reason: \"invalid-scope\",\n message: `cacheScope is not \"public\" | \"private\" (got ${describe(\n scopeVal,\n )})`,\n };\n }\n return { ok: true, hints: { ttlMs: Math.floor(ttlVal), cacheScope: scopeVal } };\n}\n\n/** Short, safe description of an unknown value for error messages. */\nfunction describe(value: unknown): string {\n if (value === null) return \"null\";\n if (typeof value === \"string\") return JSON.stringify(value);\n if (typeof value === \"number\" || typeof value === \"boolean\") return String(value);\n if (Array.isArray(value)) return \"an array\";\n return typeof value;\n}\n","/**\n * The safety layer: decide whether a result may be cached for a given\n * authorization-context identity, and derive the scope key it must be stored under.\n *\n * This is where the cross-user-leak trap is closed. SEP-2549 says a `private`\n * result \"MAY be cached and reused only within the same authorization context\".\n * We enforce that by KEYING private entries with the caller's scope identity, so a\n * `private` entry stored for user A is structurally unreachable for user B.\n */\n\nimport { parseCacheHints } from \"./hints.js\";\nimport {\n CacheScope,\n type CacheDecision,\n type CacheHints,\n} from \"./types.js\";\n\n/**\n * The single, shared scope key used for `public` results. Chosen to be a value\n * that cannot collide with any real scope identity (which is escaped, see below).\n */\nexport const PUBLIC_SCOPE_KEY = \"public\";\n\n/** Options for {@link cacheSafety} / {@link assertCacheSafe}. */\nexport interface CacheSafetyOptions {\n /**\n * Identity of the caller's authorization context — e.g. an access-token hash,\n * user id, tenant id, or session id. REQUIRED to cache `private` results.\n * Pass it for `public` results too if you have it; it will simply be ignored.\n */\n scopeId?: string;\n /**\n * If true, a `ttlMs` of `0` is reported as cacheable (with `zero-ttl` being a\n * non-fatal note). Default `false`: a 0 TTL means \"immediately stale\", so there\n * is nothing worth storing and we report it uncacheable. The cache uses the\n * default.\n */\n allowZeroTtl?: boolean;\n}\n\n/**\n * Derive the scope key an entry must be stored under.\n *\n * - `public` → {@link PUBLIC_SCOPE_KEY} (shared across all callers).\n * - `private` → a key derived from `scopeId`, namespaced so it can never collide\n * with the public bucket or with another scope id.\n *\n * Returns `undefined` for a `private` result when no `scopeId` is supplied —\n * the caller MUST treat that as \"do not cache\".\n */\nexport function deriveScopeKey(\n cacheScope: CacheScope,\n scopeId?: string,\n): string | undefined {\n if (cacheScope === CacheScope.Public) return PUBLIC_SCOPE_KEY;\n // private — fail closed on anything that is not a non-empty string. A JS caller\n // can pass a number/array/object (e.g. a numeric tenant PK straight from the DB),\n // and `(123).length` is `undefined`, which would void the length-prefix collision\n // defense below and leak a private entry across scopes. So we never coerce an\n // unexpected type into a cache key — we refuse to cache it.\n if (typeof scopeId !== \"string\" || scopeId === \"\") return undefined;\n // Namespace + length-prefix the id so distinct ids cannot be confused with one\n // another or with the literal public key (e.g. a scopeId of \"public\").\n return `private:${scopeId.length}:${scopeId}`;\n}\n\n/**\n * Decide whether `result` may be cached for the given scope identity.\n *\n * Returns a {@link CacheDecision}: when `cacheable: true` it includes the validated\n * hints and the `scopeKey` to store the entry under; when `cacheable: false` it\n * includes a machine-readable `reason` and a human-readable `message`.\n *\n * Fail-safe by construction: missing/invalid hints, an unrecognized scope, a\n * `private` result without a `scopeId`, and (by default) a `0` TTL all return\n * `cacheable: false`. Never throws.\n *\n * Use this as a guard anywhere in a proxy/gateway:\n * ```ts\n * const d = cacheSafety(result, { scopeId: tokenHash });\n * if (d.cacheable) store(key, d.scopeKey, result, d.hints.ttlMs);\n * ```\n */\nexport function cacheSafety(\n result: unknown,\n options: CacheSafetyOptions = {},\n): CacheDecision {\n const parsed = parseCacheHints(result);\n if (!parsed.ok) {\n return { cacheable: false, reason: parsed.reason, message: parsed.message };\n }\n const hints = parsed.hints;\n\n if (hints.ttlMs === 0 && options.allowZeroTtl !== true) {\n return {\n cacheable: false,\n reason: \"zero-ttl\",\n message: \"ttlMs is 0 (immediately stale); nothing to cache\",\n };\n }\n\n const scopeKey = deriveScopeKey(hints.cacheScope, options.scopeId);\n if (scopeKey === undefined) {\n return {\n cacheable: false,\n reason: \"private-without-scope\",\n message:\n 'cacheScope is \"private\" but no scopeId was provided; refusing to cache to avoid cross-context leaks',\n };\n }\n\n return { cacheable: true, hints, scopeKey };\n}\n\n/** Error thrown by {@link assertCacheSafe} when a result may not be cached. */\nexport class CacheUnsafeError extends Error {\n override readonly name = \"CacheUnsafeError\";\n /** Machine-readable reason, mirrors {@link CacheDecision}'s `reason`. */\n readonly reason: Extract<CacheDecision, { cacheable: false }>[\"reason\"];\n constructor(decision: Extract<CacheDecision, { cacheable: false }>) {\n super(`mcp-cache-kit: result is not cache-safe (${decision.reason}): ${decision.message}`);\n this.reason = decision.reason;\n }\n}\n\n/**\n * Assert that `result` may be cached for the given scope identity, throwing a\n * {@link CacheUnsafeError} otherwise. Returns the validated hints + scopeKey.\n *\n * Prefer {@link cacheSafety} when you want to branch without exceptions; use this\n * when \"must be cacheable here\" is an invariant you want to enforce loudly.\n */\nexport function assertCacheSafe(\n result: unknown,\n options: CacheSafetyOptions = {},\n): { hints: CacheHints; scopeKey: string } {\n const decision = cacheSafety(result, options);\n if (!decision.cacheable) {\n throw new CacheUnsafeError(decision);\n }\n return { hints: decision.hints, scopeKey: decision.scopeKey };\n}\n","/**\n * Client / proxy-side cache that honors SEP-2549 hints — and never leaks a\n * `private` result across authorization contexts.\n *\n * Safety model (the whole point of this package):\n * - Every stored entry is keyed by `requestKey` AND `scopeKey`.\n * - `public` results use a single shared scopeKey, so they ARE shared.\n * - `private` results use a scopeKey derived from the caller's `scopeId`, so a\n * lookup by a different caller derives a different key and structurally cannot\n * hit another caller's entry.\n * - Anything we cannot prove safe (missing/invalid hints, private-without-scope,\n * 0 TTL) is simply not stored. `get` of such a thing is always a miss.\n */\n\nimport { cacheSafety, deriveScopeKey } from \"./safety.js\";\nimport {\n CacheScope,\n type CacheDecision,\n type CacheHints,\n type UncacheableReason,\n} from \"./types.js\";\n\n/** Monotonic-ish clock returning epoch milliseconds. Injectable for tests. */\nexport type Clock = () => number;\n\n/** A request identity: either a precomputed string key, or the raw JSON-RPC request. */\nexport type RequestKeyInput = string | { method: string; params?: unknown };\n\n/** Options for {@link McpResultCache}. */\nexport interface McpResultCacheOptions {\n /** Max number of live entries. Oldest-inserted entries are evicted first. Default 1000. `0`/negative disables caching. */\n maxEntries?: number;\n /** Clock for TTL math. Default `Date.now`. Override with fake timers in tests. */\n clock?: Clock;\n /** Treat a `0` TTL as cacheable. Default `false` (0 = immediately stale). */\n allowZeroTtl?: boolean;\n}\n\n/** Per-lookup options. */\nexport interface LookupOptions {\n /**\n * Identity of the caller's authorization context (token hash / user / tenant /\n * session id). REQUIRED to read or write `private` entries; ignored for `public`.\n */\n scopeId?: string;\n}\n\n/** Outcome of {@link McpResultCache.set}. */\nexport type SetOutcome =\n | { stored: true; scopeKey: string; expiresAt: number; hints: CacheHints }\n | { stored: false; reason: UncacheableReason; message: string };\n\n/** Outcome of {@link McpResultCache.get}. */\nexport type GetOutcome<T> =\n | { hit: true; value: T; hints: CacheHints; expiresAt: number }\n | { hit: false; reason: GetMissReason };\n\n/** Why a {@link McpResultCache.get} missed. */\nexport type GetMissReason =\n /** No entry for this (request, scope). */\n | \"miss\"\n /** An entry existed but its TTL had elapsed (it has been evicted). */\n | \"expired\";\n\n/** Snapshot counters for observability. Read via {@link McpResultCache.stats}. */\nexport interface CacheStats {\n hits: number;\n misses: number;\n expired: number;\n stores: number;\n /** `set` calls rejected because the result was not cache-safe. */\n rejected: number;\n evictions: number;\n /** Current number of live (not-yet-pruned) entries. */\n size: number;\n}\n\ninterface Entry<T> {\n value: T;\n hints: CacheHints;\n /** Absolute epoch-ms expiry. */\n expiresAt: number;\n}\n\n/**\n * Build a stable cache key from a request. If given a string, it is used as-is.\n * If given `{ method, params }`, params are deterministically serialized (object\n * keys sorted recursively) so that semantically equal requests collide.\n */\nexport function deriveRequestKey(input: RequestKeyInput): string {\n if (typeof input === \"string\") return input;\n return `${input.method}\u0000${stableStringify(input.params)}`;\n}\n\n/** Deterministic JSON: object keys sorted recursively. Arrays keep order. */\nexport function stableStringify(value: unknown): string {\n return JSON.stringify(normalize(value));\n}\n\nfunction normalize(value: unknown): unknown {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map(normalize);\n const obj = value as Record<string, unknown>;\n const out: Record<string, unknown> = {};\n for (const key of Object.keys(obj).sort()) {\n out[key] = normalize(obj[key]);\n }\n return out;\n}\n\n/** Compose the internal storage key from request key + scope key. */\nfunction storageKey(requestKey: string, scopeKey: string): string {\n return `${scopeKey}\u0000${requestKey}`;\n}\n\n/**\n * A small, dependency-free, SEP-2549-aware result cache safe for use in MCP\n * clients, gateways, and proxies.\n */\nexport class McpResultCache {\n readonly #maxEntries: number;\n readonly #clock: Clock;\n readonly #allowZeroTtl: boolean;\n // Insertion-ordered (Map preserves order) — enables O(1) FIFO eviction.\n readonly #store = new Map<string, Entry<unknown>>();\n #stats: Omit<CacheStats, \"size\"> = {\n hits: 0,\n misses: 0,\n expired: 0,\n stores: 0,\n rejected: 0,\n evictions: 0,\n };\n\n constructor(options: McpResultCacheOptions = {}) {\n this.#maxEntries = options.maxEntries ?? 1000;\n this.#clock = options.clock ?? Date.now;\n this.#allowZeroTtl = options.allowZeroTtl ?? false;\n }\n\n /**\n * Store a result IF it is cache-safe for the given scope. Validates hints,\n * derives the scope key (refusing `private` without a `scopeId`), and stores\n * keyed by (request, scope). Returns whether it was stored and why not.\n *\n * Always safe to call on any result — non-cacheable results are silently\n * rejected (counted in {@link CacheStats.rejected}) rather than stored.\n */\n set<T extends object>(\n request: RequestKeyInput,\n result: T,\n options: LookupOptions = {},\n ): SetOutcome {\n if (this.#maxEntries <= 0) {\n return { stored: false, reason: \"missing-fields\", message: \"cache disabled (maxEntries <= 0)\" };\n }\n const decision: CacheDecision = cacheSafety(result, {\n ...(options.scopeId !== undefined ? { scopeId: options.scopeId } : {}),\n allowZeroTtl: this.#allowZeroTtl,\n });\n if (!decision.cacheable) {\n this.#stats.rejected++;\n return { stored: false, reason: decision.reason, message: decision.message };\n }\n\n const requestKey = deriveRequestKey(request);\n const key = storageKey(requestKey, decision.scopeKey);\n const expiresAt = this.#clock() + decision.hints.ttlMs;\n\n // Refresh insertion order on overwrite so re-stored hot entries aren't the\n // first to be evicted.\n this.#store.delete(key);\n this.#store.set(key, { value: result, hints: decision.hints, expiresAt });\n this.#stats.stores++;\n\n this.#evictIfNeeded();\n return { stored: true, scopeKey: decision.scopeKey, expiresAt, hints: decision.hints };\n }\n\n /**\n * Look up a result for the given request AND scope identity.\n *\n * A `private` entry is ONLY returned to the same `scopeId` that stored it: the\n * lookup derives the scope key from the caller's `scopeId`, so another caller's\n * lookup targets a different key and can never reach it. A `public` entry is\n * returned to anyone (it is stored under the shared public key).\n *\n * Expired entries are treated as a miss and removed lazily on access.\n */\n get<T>(request: RequestKeyInput, options: LookupOptions = {}): GetOutcome<T> {\n const requestKey = deriveRequestKey(request);\n\n // Prefer the caller's OWN private bucket (more specific) over the shared\n // public bucket for the same request, then fall back to public. We never try\n // another caller's private key — there is no way to construct it without their\n // scopeId, so private-first cannot leak.\n const candidateScopeKeys: string[] = [];\n const privateKey = deriveScopeKey(CacheScope.Private, options.scopeId);\n if (privateKey !== undefined) candidateScopeKeys.push(privateKey);\n candidateScopeKeys.push(deriveScopeKey(CacheScope.Public)!);\n\n for (const scopeKey of candidateScopeKeys) {\n const key = storageKey(requestKey, scopeKey);\n const entry = this.#store.get(key);\n if (entry === undefined) continue;\n\n if (this.#clock() >= entry.expiresAt) {\n this.#store.delete(key);\n this.#stats.expired++;\n // Keep scanning other candidate buckets — a fresh public entry might\n // still satisfy this lookup even if a private one expired.\n continue;\n }\n this.#stats.hits++;\n return {\n hit: true,\n value: entry.value as T,\n hints: entry.hints,\n expiresAt: entry.expiresAt,\n };\n }\n\n this.#stats.misses++;\n return { hit: false, reason: \"miss\" };\n }\n\n /**\n * Convenience wrapper: return the cached value, or compute + store it.\n *\n * If a fresh entry exists it is returned. Otherwise `loader()` runs, its result\n * is offered to {@link set} (stored only if cache-safe), and returned regardless.\n */\n async getOrLoad<T extends object>(\n request: RequestKeyInput,\n loader: () => Promise<T> | T,\n options: LookupOptions = {},\n ): Promise<T> {\n const cached = this.get<T>(request, options);\n if (cached.hit) return cached.value;\n const value = await loader();\n this.set(request, value, options);\n return value;\n }\n\n /** Remove all entries whose TTL has elapsed. Returns the count removed. */\n prune(): number {\n const now = this.#clock();\n let removed = 0;\n for (const [key, entry] of this.#store) {\n if (now >= entry.expiresAt) {\n this.#store.delete(key);\n removed++;\n }\n }\n this.#stats.expired += removed;\n return removed;\n }\n\n /** Drop everything. Does not reset stat counters. */\n clear(): void {\n this.#store.clear();\n }\n\n /** Current live entry count (without pruning). */\n get size(): number {\n return this.#store.size;\n }\n\n /** A snapshot of counters plus current size. */\n stats(): CacheStats {\n return { ...this.#stats, size: this.#store.size };\n }\n\n #evictIfNeeded(): void {\n while (this.#store.size > this.#maxEntries) {\n const oldest = this.#store.keys().next().value;\n if (oldest === undefined) break;\n this.#store.delete(oldest);\n this.#stats.evictions++;\n }\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "mcp-cache-kit",
3
+ "version": "0.1.0",
4
+ "description": "Correct, leak-safe caching for the new MCP cache semantics (SEP-2549). Set ttlMs/cacheScope on the server, and honor them safely on the client/proxy so a private result is never served across authorization contexts.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "StudioMeyer",
8
+ "homepage": "https://github.com/studiomeyer-io/mcp-cache-kit#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/studiomeyer-io/mcp-cache-kit.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/studiomeyer-io/mcp-cache-kit/issues"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "context-protocol",
20
+ "cache",
21
+ "caching",
22
+ "ttl",
23
+ "security",
24
+ "sep-2549",
25
+ "proxy",
26
+ "gateway"
27
+ ],
28
+ "exports": {
29
+ ".": {
30
+ "import": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "require": {
35
+ "types": "./dist/index.d.cts",
36
+ "default": "./dist/index.cjs"
37
+ }
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "main": "./dist/index.cjs",
42
+ "module": "./dist/index.js",
43
+ "types": "./dist/index.d.ts",
44
+ "files": [
45
+ "dist",
46
+ "README.md",
47
+ "LICENSE",
48
+ "SECURITY.md"
49
+ ],
50
+ "sideEffects": false,
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "dev": "tsup --watch",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "typecheck": "tsc --noEmit",
60
+ "attw": "attw --pack .",
61
+ "lint": "tsc --noEmit",
62
+ "clean": "rm -rf dist",
63
+ "prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm test && npm run attw"
64
+ },
65
+ "overrides": {
66
+ "esbuild": ">=0.28.1"
67
+ },
68
+ "peerDependencies": {
69
+ "@modelcontextprotocol/sdk": ">=1.0.0 <2"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "@modelcontextprotocol/sdk": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "devDependencies": {
77
+ "@arethetypeswrong/cli": "^0.18.3",
78
+ "@modelcontextprotocol/sdk": "^1.29.0",
79
+ "tsup": "^8.5.1",
80
+ "typescript": "^5.9.0",
81
+ "vitest": "^4.1.9"
82
+ }
83
+ }