mcp-cache-kit
v0.1.1
Published
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.
Downloads
346
Maintainers
Readme
Part of the StudioMeyer MCP Stack — Built in Mallorca 🌴 · ⭐ if you use it
mcp-cache-kit
Correct, leak-safe caching for the new MCP cache semantics (SEP-2549).
The MCP spec 2026-07-28 release candidate adds SEP-2549: tools/list, resources/read (and the other list results) now carry ttlMs and cacheScope so clients, gateways, and proxies can cache them — modeled on HTTP Cache-Control. It is brand-new and has essentially no dedicated tooling. Generic caches store results "somehow" and ignore cacheScope, which is a real security trap: a result marked cacheScope: "private" that gets cached and served across users is a cross-user data leak.
mcp-cache-kit is the small, correct layer for exactly this:
- Server side — set the fields right (
withCacheHints). - Client / proxy side — a cache that honors
ttlMsand never serves aprivateresult across authorization contexts (McpResultCache). - A guard — decide if any result may be cached for a given scope, with a clear reason (
cacheSafety/assertCacheSafe).
Zero runtime dependencies. TypeScript strict, ESM + CJS, Node 20+. The @modelcontextprotocol/sdk is an optional peer — the helpers also work on plain result objects, so you can use it without the SDK.
⚠️ The 2026-07-28 spec is a release candidate. Field names and semantics may still shift before final. This library models them conservatively and is intentionally tolerant of missing/malformed fields (it treats anything it cannot prove safe as uncacheable).
What SEP-2549 actually says
Verified against the spec source (schema/draft/schema.ts and docs/.../utilities/caching.mdx):
Cacheable results extend a CacheableResult shape with two top-level fields:
| Field | Type | Meaning |
| ------------ | ----------------------- | ------------------------------------------------------------------------------------------------------ |
| ttlMs | number (@minimum 0) | Freshness window, like Cache-Control: max-age. 0 = immediately stale. Absent/negative → treat as 0. |
| cacheScope | "public" \| "private" | Like Cache-Control: public vs private. See below. |
"public"— the response has no user-specific data. Any client or intermediary MAY cache it and serve it across authorization contexts."private"— the response MAY be cached and reused only within the same authorization context. Caches MUST NOT be shared across authorization contexts (a different access token / user / session needs a different cache entry).
Applies to tools/list, resources/list, resources/templates/list, prompts/list, and resources/read.
The spec also warns: a
"public"result from an authenticated endpoint can still be shared between callers, and you MUST NOT rely oncacheScopealone to prevent unauthorized access. This library enforces the scope boundary for you, but you still own labeling scopes honestly and authenticating at the origin. See SECURITY.md.
The cross-user-leak trap
tools/list / resources/read
user A ───────────────────────────────► proxy (caches by request only)
│ stores result, ignores cacheScope
user B ───────── same request ──────────► proxy
│ returns A's cached result ← LEAKIf the cached result was cacheScope: "private" (A's inbox, A's tenant config, …), user B just received another user's data. mcp-cache-kit keys every entry by the request and the caller's scope identity, so a private entry for A is structurally unreachable for B.
Install
npm install mcp-cache-kit
# optional, only if you use the SDK result types directly:
npm install @modelcontextprotocol/sdkServer side — set the hints
Attach the fields to a result. withCacheHints validates them (rejects negative/non-finite ttlMs and any cacheScope other than public/private) and returns a new object.
import { withCacheHints, CacheScope } from "mcp-cache-kit";
// tools/list rarely contains user data → public, cache for 5 minutes
server.setRequestHandler(ListToolsRequestSchema, async () =>
withCacheHints({ tools }, { ttlMs: 5 * 60_000, cacheScope: CacheScope.Public }),
);
// a per-user resource → private, cache for 1 minute
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const contents = await loadForUser(req.params.uri);
return withCacheHints({ contents }, { ttlMs: 60_000, cacheScope: CacheScope.Private });
});There are shorthands too: publicHints(ttlMs) and privateHints(ttlMs).
Client / proxy side — honor them safely
McpResultCache stores results keyed by (request, scope). Pass a scopeId that identifies the caller's authorization context (an access-token hash, user id, tenant id, or session id).
import { McpResultCache } from "mcp-cache-kit";
const cache = new McpResultCache({ maxEntries: 5_000 });
async function handleReadResource(req, ctx) {
const scopeId = ctx.tokenHash; // identifies the authorization context
// try the cache first
const hit = cache.get(req, { scopeId });
if (hit.hit) return hit.value;
// miss → fetch from the upstream MCP server, then offer it to the cache.
// set() stores it ONLY if the result is cache-safe for this scope.
const result = await upstream.request(req);
cache.set(req, result, { scopeId });
return result;
}Or the one-liner:
const result = await cache.getOrLoad(req, () => upstream.request(req), { scopeId });What the cache guarantees:
- TTL is honored — entries expire at
received + ttlMsand are removed on access (or viaprune()). privatenever leaks — aprivateentry stored for scope A is only ever returned to scope A. A differentscopeId, or noscopeId, gets a miss.publicis shared — stored under one shared key and returned to anyone.- Fail-safe —
set()silently refuses (and counts asrejected) anything it can't prove safe: missing/partial hints, badcacheScope, badttlMs,private-without-scopeId, andttlMs: 0(by default). - Never throws on the hot path — a request it cannot turn into a stable key (a circular
params, aBigInt, a hostile getter onmethod/params) is a fail-safe miss (get) / reject (set) with reason"unkeyable-request", never an exception that could take down a proxy.
Guard — cacheSafety / assertCacheSafe
Use these in a gateway when you want to make the decision yourself:
import { cacheSafety, assertCacheSafe } from "mcp-cache-kit";
const decision = cacheSafety(result, { scopeId });
if (decision.cacheable) {
// decision.scopeKey is where to store it; decision.hints.ttlMs is the TTL
myStore.put(decision.scopeKey, result, decision.hints.ttlMs);
} else {
// decision.reason: "missing-fields" | "invalid-ttl" | "invalid-scope"
// | "zero-ttl" | "private-without-scope" | "not-an-object"
log.debug(`not caching: ${decision.message}`);
}
// or throw if "must be cacheable here" is an invariant:
const { hints, scopeKey } = assertCacheSafe(result, { scopeId }); // throws CacheUnsafeError otherwiseLow-level helpers
All individually exported and tested:
parseCacheHints(result)→{ ok: true, hints } | { ok: false, reason, message }— never throws.validateCacheHints({ ttlMs, cacheScope })→ normalized hints (throwsTypeErroron bad input).isCacheScope(x),isValidTtlMs(x)— type guards.deriveScopeKey(cacheScope, scopeId?)— the scope-key rule (undefinedforprivatewithout ascopeId).deriveRequestKey({ method, params })/stableStringify(x)— deterministic request keys (param key order doesn't matter; circular structures are rendered with a"[Circular]"marker, never a stack overflow).- Constants:
CacheScope,CACHE_SCOPE_VALUES,PUBLIC_SCOPE_KEY.
Fail-safe philosophy
Caching the wrong thing across users is worse than a cache miss. So the default decision is always "do not cache" unless the result proves it is safe: both fields present, both valid, and — for private — a scopeId to bind it to. SEP-2549 is still an RC, so being strict here also protects you from upstream servers that emit partial or mislabeled hints.
Testing notes
McpResultCache takes an injectable clock (() => number), so you can test TTL behavior deterministically without real time — and it also works under vitest fake timers driving the default Date.now. Both styles are covered in the test suite, including the headline cross-user leak test.
Part of the StudioMeyer MCP toolkit
A small family of focused, production-grade tools for building and operating MCP servers — mix and match:
- mcp-armor — runtime defense sidecar: scans tool calls, verifies signed manifests, blocks known-bad CVEs
- mcp-gauntlet — pre-deploy
mcp-fuzz(schema-aware fuzzer) +mcp-storm(load tester) - mcp-otel — W3C Trace Context → OpenTelemetry bridge
- mcp-cache-kit (this one) — leak-safe SEP-2549 caching (
ttlMs+cacheScope) - skilldoctor — linter + security scanner for agent skill files
License
MIT © StudioMeyer 2026
