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/LICENSE +21 -0
- package/README.md +155 -0
- package/SECURITY.md +75 -0
- package/dist/index.cjs +344 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +394 -0
- package/dist/index.d.ts +394 -0
- package/dist/index.js +326 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StudioMeyer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# mcp-cache-kit
|
|
2
|
+
|
|
3
|
+
**Correct, leak-safe caching for the new MCP cache semantics ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)).**
|
|
4
|
+
|
|
5
|
+
The MCP spec **2026-07-28 release candidate** adds [SEP-2549](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/): `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**.
|
|
6
|
+
|
|
7
|
+
`mcp-cache-kit` is the small, correct layer for exactly this:
|
|
8
|
+
|
|
9
|
+
- **Server side** — set the fields right (`withCacheHints`).
|
|
10
|
+
- **Client / proxy side** — a cache that *honors* `ttlMs` and **never serves a `private` result across authorization contexts** (`McpResultCache`).
|
|
11
|
+
- **A guard** — decide if any result may be cached for a given scope, with a clear reason (`cacheSafety` / `assertCacheSafe`).
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
> ⚠️ **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).
|
|
16
|
+
|
|
17
|
+
## What SEP-2549 actually says
|
|
18
|
+
|
|
19
|
+
Verified against the spec source ([`schema/draft/schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) and [`docs/.../utilities/caching.mdx`](https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/docs/specification/draft/server)):
|
|
20
|
+
|
|
21
|
+
Cacheable results extend a `CacheableResult` shape with two **top-level** fields:
|
|
22
|
+
|
|
23
|
+
| Field | Type | Meaning |
|
|
24
|
+
| ------------ | ----------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
25
|
+
| `ttlMs` | `number` (`@minimum 0`) | Freshness window, like `Cache-Control: max-age`. `0` = immediately stale. Absent/negative → treat as `0`. |
|
|
26
|
+
| `cacheScope` | `"public" \| "private"` | Like `Cache-Control: public` vs `private`. See below. |
|
|
27
|
+
|
|
28
|
+
- **`"public"`** — the response has no user-specific data. Any client or intermediary MAY cache it and serve it **across authorization contexts**.
|
|
29
|
+
- **`"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).
|
|
30
|
+
|
|
31
|
+
Applies to `tools/list`, `resources/list`, `resources/templates/list`, `prompts/list`, and `resources/read`.
|
|
32
|
+
|
|
33
|
+
> The spec also warns: a `"public"` result from an *authenticated* endpoint can still be shared between callers, and you **MUST NOT rely on `cacheScope` alone** 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](./SECURITY.md).
|
|
34
|
+
|
|
35
|
+
## The cross-user-leak trap
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
tools/list / resources/read
|
|
39
|
+
user A ───────────────────────────────► proxy (caches by request only)
|
|
40
|
+
│ stores result, ignores cacheScope
|
|
41
|
+
user B ───────── same request ──────────► proxy
|
|
42
|
+
│ returns A's cached result ← LEAK
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If 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.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install mcp-cache-kit
|
|
51
|
+
# optional, only if you use the SDK result types directly:
|
|
52
|
+
npm install @modelcontextprotocol/sdk
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Server side — set the hints
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { withCacheHints, CacheScope } from "mcp-cache-kit";
|
|
61
|
+
|
|
62
|
+
// tools/list rarely contains user data → public, cache for 5 minutes
|
|
63
|
+
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
64
|
+
withCacheHints({ tools }, { ttlMs: 5 * 60_000, cacheScope: CacheScope.Public }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// a per-user resource → private, cache for 1 minute
|
|
68
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
69
|
+
const contents = await loadForUser(req.params.uri);
|
|
70
|
+
return withCacheHints({ contents }, { ttlMs: 60_000, cacheScope: CacheScope.Private });
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
There are shorthands too: `publicHints(ttlMs)` and `privateHints(ttlMs)`.
|
|
75
|
+
|
|
76
|
+
## Client / proxy side — honor them safely
|
|
77
|
+
|
|
78
|
+
`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).
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { McpResultCache } from "mcp-cache-kit";
|
|
82
|
+
|
|
83
|
+
const cache = new McpResultCache({ maxEntries: 5_000 });
|
|
84
|
+
|
|
85
|
+
async function handleReadResource(req, ctx) {
|
|
86
|
+
const scopeId = ctx.tokenHash; // identifies the authorization context
|
|
87
|
+
|
|
88
|
+
// try the cache first
|
|
89
|
+
const hit = cache.get(req, { scopeId });
|
|
90
|
+
if (hit.hit) return hit.value;
|
|
91
|
+
|
|
92
|
+
// miss → fetch from the upstream MCP server, then offer it to the cache.
|
|
93
|
+
// set() stores it ONLY if the result is cache-safe for this scope.
|
|
94
|
+
const result = await upstream.request(req);
|
|
95
|
+
cache.set(req, result, { scopeId });
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Or the one-liner:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const result = await cache.getOrLoad(req, () => upstream.request(req), { scopeId });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
What the cache guarantees:
|
|
107
|
+
|
|
108
|
+
- **TTL is honored** — entries expire at `received + ttlMs` and are removed on access (or via `prune()`).
|
|
109
|
+
- **`private` never leaks** — a `private` entry stored for scope A is only ever returned to scope A. A different `scopeId`, or no `scopeId`, gets a miss.
|
|
110
|
+
- **`public` is shared** — stored under one shared key and returned to anyone.
|
|
111
|
+
- **Fail-safe** — `set()` silently refuses (and counts as `rejected`) anything it can't prove safe: missing/partial hints, bad `cacheScope`, bad `ttlMs`, `private`-without-`scopeId`, and `ttlMs: 0` (by default).
|
|
112
|
+
|
|
113
|
+
## Guard — `cacheSafety` / `assertCacheSafe`
|
|
114
|
+
|
|
115
|
+
Use these in a gateway when you want to make the decision yourself:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { cacheSafety, assertCacheSafe } from "mcp-cache-kit";
|
|
119
|
+
|
|
120
|
+
const decision = cacheSafety(result, { scopeId });
|
|
121
|
+
if (decision.cacheable) {
|
|
122
|
+
// decision.scopeKey is where to store it; decision.hints.ttlMs is the TTL
|
|
123
|
+
myStore.put(decision.scopeKey, result, decision.hints.ttlMs);
|
|
124
|
+
} else {
|
|
125
|
+
// decision.reason: "missing-fields" | "invalid-ttl" | "invalid-scope"
|
|
126
|
+
// | "zero-ttl" | "private-without-scope" | "not-an-object"
|
|
127
|
+
log.debug(`not caching: ${decision.message}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// or throw if "must be cacheable here" is an invariant:
|
|
131
|
+
const { hints, scopeKey } = assertCacheSafe(result, { scopeId }); // throws CacheUnsafeError otherwise
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Low-level helpers
|
|
135
|
+
|
|
136
|
+
All individually exported and tested:
|
|
137
|
+
|
|
138
|
+
- `parseCacheHints(result)` → `{ ok: true, hints } | { ok: false, reason, message }` — never throws.
|
|
139
|
+
- `validateCacheHints({ ttlMs, cacheScope })` → normalized hints (throws `TypeError` on bad input).
|
|
140
|
+
- `isCacheScope(x)`, `isValidTtlMs(x)` — type guards.
|
|
141
|
+
- `deriveScopeKey(cacheScope, scopeId?)` — the scope-key rule (`undefined` for `private` without a `scopeId`).
|
|
142
|
+
- `deriveRequestKey({ method, params })` / `stableStringify(x)` — deterministic request keys (param key order doesn't matter).
|
|
143
|
+
- Constants: `CacheScope`, `CACHE_SCOPE_VALUES`, `PUBLIC_SCOPE_KEY`.
|
|
144
|
+
|
|
145
|
+
## Fail-safe philosophy
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
## Testing notes
|
|
150
|
+
|
|
151
|
+
`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.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
[MIT](./LICENSE) © StudioMeyer 2026
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`mcp-cache-kit` exists to prevent a specific, easy-to-make security bug, so the
|
|
4
|
+
threat model is the whole point of the library. Please read this before deploying
|
|
5
|
+
a cache in front of MCP traffic.
|
|
6
|
+
|
|
7
|
+
## The threat: cross-context cache leaks
|
|
8
|
+
|
|
9
|
+
[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)
|
|
10
|
+
(MCP spec, 2026-07-28 release candidate) lets servers mark `tools/list`,
|
|
11
|
+
`resources/read` (and the other list results) with cache hints:
|
|
12
|
+
|
|
13
|
+
- `ttlMs` — how long the result stays fresh.
|
|
14
|
+
- `cacheScope` — `"public"` (safe to share across users) or `"private"` (safe to
|
|
15
|
+
reuse **only within the same authorization context**).
|
|
16
|
+
|
|
17
|
+
The dangerous failure mode is a gateway/proxy that caches "by request" and ignores
|
|
18
|
+
`cacheScope`. If a `"private"` `resources/read` result for user A is stored under a
|
|
19
|
+
request-only key, the next user B who issues the same request gets served **user
|
|
20
|
+
A's private data**. That is a cross-tenant / cross-user data leak.
|
|
21
|
+
|
|
22
|
+
A second trap comes straight from the spec: a `"public"` result coming out of an
|
|
23
|
+
*authenticated* endpoint may still be shared across callers. Servers must not mark
|
|
24
|
+
anything `"public"` that contains user-specific data, and **must not rely on
|
|
25
|
+
`cacheScope` alone** for access control.
|
|
26
|
+
|
|
27
|
+
## How this library mitigates it
|
|
28
|
+
|
|
29
|
+
- **Scope-keyed storage.** Every cache entry is keyed by the request **and** a
|
|
30
|
+
scope key. `public` entries use one shared key (so they are shared on purpose).
|
|
31
|
+
`private` entries derive their key from the caller's authorization-context
|
|
32
|
+
identity (`scopeId` — e.g. an access-token hash, user id, tenant id, or session
|
|
33
|
+
id). A different caller derives a different key and **cannot** reach another
|
|
34
|
+
caller's private entry. There is no code path that returns a private entry to a
|
|
35
|
+
caller who did not store it.
|
|
36
|
+
- **Fail-safe by default.** Anything we cannot prove is cache-safe is simply not
|
|
37
|
+
cached: missing/partial hints, an unknown `cacheScope`, a non-finite/negative
|
|
38
|
+
`ttlMs`, a `"private"` result offered without a `scopeId`, and (by default) a
|
|
39
|
+
`ttlMs` of `0`. The default decision is "do not cache", never "cache anyway".
|
|
40
|
+
- **A guard you can drop anywhere.** `cacheSafety(result, { scopeId })` and
|
|
41
|
+
`assertCacheSafe(...)` give a clear allow/deny with a machine-readable reason, so
|
|
42
|
+
a proxy can decide safely without re-implementing the rules.
|
|
43
|
+
- **`scopeId` must be a non-empty string.** A non-string id (number, array, object
|
|
44
|
+
— e.g. a numeric tenant PK passed by mistake) is refused rather than coerced, so
|
|
45
|
+
it can never collide with another scope's key. Isolation is then only as strong
|
|
46
|
+
as your `scopeId` being unique and unspoofable — that part is yours to guarantee.
|
|
47
|
+
- **TTL uses a wall clock** (`Date.now` by default). A backward clock jump can keep
|
|
48
|
+
an entry "fresh" past its `ttlMs`; inject a monotonic `clock` if that matters.
|
|
49
|
+
|
|
50
|
+
## Your responsibilities (this library cannot do these for you)
|
|
51
|
+
|
|
52
|
+
1. **Pass a real `scopeId`.** It must uniquely identify the authorization context.
|
|
53
|
+
An access-token hash or an authenticated user/tenant id is good. A value an
|
|
54
|
+
attacker can spoof or that collides across users is not. If you cannot identify
|
|
55
|
+
the context, do not pass a `scopeId` — the library will then refuse to cache
|
|
56
|
+
`private` results (the safe outcome).
|
|
57
|
+
2. **Mark scopes honestly on the server.** Never label user-specific data
|
|
58
|
+
`"public"`. When in doubt, use `"private"` (or omit hints entirely).
|
|
59
|
+
3. **Do not use the cache as an authorization layer.** It reduces redundant
|
|
60
|
+
fetches; it does not replace authenticating each request at the origin.
|
|
61
|
+
4. **Treat `cacheScope` as advisory across trust boundaries.** Per the spec, a
|
|
62
|
+
malicious or buggy upstream can mislabel results.
|
|
63
|
+
|
|
64
|
+
## Supported versions
|
|
65
|
+
|
|
66
|
+
`0.x` — the latest `0.x` release receives fixes. Pre-1.0, the public API may change
|
|
67
|
+
as SEP-2549 itself is still a release candidate.
|
|
68
|
+
|
|
69
|
+
## Reporting a vulnerability
|
|
70
|
+
|
|
71
|
+
Please report suspected vulnerabilities privately via GitHub Security Advisories on
|
|
72
|
+
the repository (`Security` tab -> `Report a vulnerability`), or open a minimal,
|
|
73
|
+
non-exploitative issue if private reporting is unavailable. Do not include working
|
|
74
|
+
exploits against third-party systems. We aim to acknowledge reports within a few
|
|
75
|
+
business days.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/types.ts
|
|
4
|
+
var CacheScope = {
|
|
5
|
+
/** Shareable across authorization contexts. */
|
|
6
|
+
Public: "public",
|
|
7
|
+
/** Only reusable within the same authorization context. */
|
|
8
|
+
Private: "private"
|
|
9
|
+
};
|
|
10
|
+
var CACHE_SCOPE_VALUES = Object.freeze([
|
|
11
|
+
CacheScope.Public,
|
|
12
|
+
CacheScope.Private
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// src/hints.ts
|
|
16
|
+
function isCacheScope(value) {
|
|
17
|
+
return typeof value === "string" && CACHE_SCOPE_VALUES.includes(value);
|
|
18
|
+
}
|
|
19
|
+
function isValidTtlMs(value) {
|
|
20
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
21
|
+
}
|
|
22
|
+
function validateCacheHints(input) {
|
|
23
|
+
if (input === null || typeof input !== "object") {
|
|
24
|
+
throw new TypeError(
|
|
25
|
+
`mcp-cache-kit: cache hints must be an object with { ttlMs, cacheScope }, got ${describe(
|
|
26
|
+
input
|
|
27
|
+
)}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (!isValidTtlMs(input.ttlMs)) {
|
|
31
|
+
throw new TypeError(
|
|
32
|
+
`mcp-cache-kit: ttlMs must be a finite number >= 0 (milliseconds), got ${describe(
|
|
33
|
+
input.ttlMs
|
|
34
|
+
)}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (!isCacheScope(input.cacheScope)) {
|
|
38
|
+
throw new TypeError(
|
|
39
|
+
`mcp-cache-kit: cacheScope must be one of ${CACHE_SCOPE_VALUES.map(
|
|
40
|
+
(v) => `"${v}"`
|
|
41
|
+
).join(" | ")}, got ${describe(input.cacheScope)}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return { ttlMs: Math.floor(input.ttlMs), cacheScope: input.cacheScope };
|
|
45
|
+
}
|
|
46
|
+
function withCacheHints(result, hints) {
|
|
47
|
+
if (result === null || typeof result !== "object") {
|
|
48
|
+
throw new TypeError(
|
|
49
|
+
`mcp-cache-kit: withCacheHints(result, ...) requires a result object, got ${describe(
|
|
50
|
+
result
|
|
51
|
+
)}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const valid = validateCacheHints(hints);
|
|
55
|
+
return { ...result, ttlMs: valid.ttlMs, cacheScope: valid.cacheScope };
|
|
56
|
+
}
|
|
57
|
+
function publicHints(ttlMs) {
|
|
58
|
+
return validateCacheHints({ ttlMs, cacheScope: CacheScope.Public });
|
|
59
|
+
}
|
|
60
|
+
function privateHints(ttlMs) {
|
|
61
|
+
return validateCacheHints({ ttlMs, cacheScope: CacheScope.Private });
|
|
62
|
+
}
|
|
63
|
+
function parseCacheHints(result) {
|
|
64
|
+
if (result === null || typeof result !== "object") {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "not-an-object",
|
|
68
|
+
message: `result is not an object (got ${describe(result)})`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const r = result;
|
|
72
|
+
let ttlVal;
|
|
73
|
+
let scopeVal;
|
|
74
|
+
try {
|
|
75
|
+
ttlVal = "ttlMs" in r ? r.ttlMs : void 0;
|
|
76
|
+
scopeVal = "cacheScope" in r ? r.cacheScope : void 0;
|
|
77
|
+
} catch {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "not-an-object",
|
|
81
|
+
message: "reading cache hints threw (hostile getter on the result?)"
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const hasTtl = ttlVal !== void 0;
|
|
85
|
+
const hasScope = scopeVal !== void 0;
|
|
86
|
+
if (!hasTtl && !hasScope) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: "missing-fields",
|
|
90
|
+
message: "result has no SEP-2549 cache hints (ttlMs / cacheScope absent)"
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!hasTtl || !hasScope) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: "missing-fields",
|
|
97
|
+
message: "result has only one of ttlMs / cacheScope; both are required to be cacheable"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (!isValidTtlMs(ttlVal)) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: "invalid-ttl",
|
|
104
|
+
message: `ttlMs is not a finite number >= 0 (got ${describe(ttlVal)})`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!isCacheScope(scopeVal)) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: "invalid-scope",
|
|
111
|
+
message: `cacheScope is not "public" | "private" (got ${describe(
|
|
112
|
+
scopeVal
|
|
113
|
+
)})`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, hints: { ttlMs: Math.floor(ttlVal), cacheScope: scopeVal } };
|
|
117
|
+
}
|
|
118
|
+
function describe(value) {
|
|
119
|
+
if (value === null) return "null";
|
|
120
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
121
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
122
|
+
if (Array.isArray(value)) return "an array";
|
|
123
|
+
return typeof value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/safety.ts
|
|
127
|
+
var PUBLIC_SCOPE_KEY = "public";
|
|
128
|
+
function deriveScopeKey(cacheScope, scopeId) {
|
|
129
|
+
if (cacheScope === CacheScope.Public) return PUBLIC_SCOPE_KEY;
|
|
130
|
+
if (typeof scopeId !== "string" || scopeId === "") return void 0;
|
|
131
|
+
return `private:${scopeId.length}:${scopeId}`;
|
|
132
|
+
}
|
|
133
|
+
function cacheSafety(result, options = {}) {
|
|
134
|
+
const parsed = parseCacheHints(result);
|
|
135
|
+
if (!parsed.ok) {
|
|
136
|
+
return { cacheable: false, reason: parsed.reason, message: parsed.message };
|
|
137
|
+
}
|
|
138
|
+
const hints = parsed.hints;
|
|
139
|
+
if (hints.ttlMs === 0 && options.allowZeroTtl !== true) {
|
|
140
|
+
return {
|
|
141
|
+
cacheable: false,
|
|
142
|
+
reason: "zero-ttl",
|
|
143
|
+
message: "ttlMs is 0 (immediately stale); nothing to cache"
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const scopeKey = deriveScopeKey(hints.cacheScope, options.scopeId);
|
|
147
|
+
if (scopeKey === void 0) {
|
|
148
|
+
return {
|
|
149
|
+
cacheable: false,
|
|
150
|
+
reason: "private-without-scope",
|
|
151
|
+
message: 'cacheScope is "private" but no scopeId was provided; refusing to cache to avoid cross-context leaks'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { cacheable: true, hints, scopeKey };
|
|
155
|
+
}
|
|
156
|
+
var CacheUnsafeError = class extends Error {
|
|
157
|
+
name = "CacheUnsafeError";
|
|
158
|
+
/** Machine-readable reason, mirrors {@link CacheDecision}'s `reason`. */
|
|
159
|
+
reason;
|
|
160
|
+
constructor(decision) {
|
|
161
|
+
super(`mcp-cache-kit: result is not cache-safe (${decision.reason}): ${decision.message}`);
|
|
162
|
+
this.reason = decision.reason;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
function assertCacheSafe(result, options = {}) {
|
|
166
|
+
const decision = cacheSafety(result, options);
|
|
167
|
+
if (!decision.cacheable) {
|
|
168
|
+
throw new CacheUnsafeError(decision);
|
|
169
|
+
}
|
|
170
|
+
return { hints: decision.hints, scopeKey: decision.scopeKey };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/cache.ts
|
|
174
|
+
function deriveRequestKey(input) {
|
|
175
|
+
if (typeof input === "string") return input;
|
|
176
|
+
return `${input.method}\0${stableStringify(input.params)}`;
|
|
177
|
+
}
|
|
178
|
+
function stableStringify(value) {
|
|
179
|
+
return JSON.stringify(normalize(value));
|
|
180
|
+
}
|
|
181
|
+
function normalize(value) {
|
|
182
|
+
if (value === null || typeof value !== "object") return value;
|
|
183
|
+
if (Array.isArray(value)) return value.map(normalize);
|
|
184
|
+
const obj = value;
|
|
185
|
+
const out = {};
|
|
186
|
+
for (const key of Object.keys(obj).sort()) {
|
|
187
|
+
out[key] = normalize(obj[key]);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function storageKey(requestKey, scopeKey) {
|
|
192
|
+
return `${scopeKey}\0${requestKey}`;
|
|
193
|
+
}
|
|
194
|
+
var McpResultCache = class {
|
|
195
|
+
#maxEntries;
|
|
196
|
+
#clock;
|
|
197
|
+
#allowZeroTtl;
|
|
198
|
+
// Insertion-ordered (Map preserves order) — enables O(1) FIFO eviction.
|
|
199
|
+
#store = /* @__PURE__ */ new Map();
|
|
200
|
+
#stats = {
|
|
201
|
+
hits: 0,
|
|
202
|
+
misses: 0,
|
|
203
|
+
expired: 0,
|
|
204
|
+
stores: 0,
|
|
205
|
+
rejected: 0,
|
|
206
|
+
evictions: 0
|
|
207
|
+
};
|
|
208
|
+
constructor(options = {}) {
|
|
209
|
+
this.#maxEntries = options.maxEntries ?? 1e3;
|
|
210
|
+
this.#clock = options.clock ?? Date.now;
|
|
211
|
+
this.#allowZeroTtl = options.allowZeroTtl ?? false;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Store a result IF it is cache-safe for the given scope. Validates hints,
|
|
215
|
+
* derives the scope key (refusing `private` without a `scopeId`), and stores
|
|
216
|
+
* keyed by (request, scope). Returns whether it was stored and why not.
|
|
217
|
+
*
|
|
218
|
+
* Always safe to call on any result — non-cacheable results are silently
|
|
219
|
+
* rejected (counted in {@link CacheStats.rejected}) rather than stored.
|
|
220
|
+
*/
|
|
221
|
+
set(request, result, options = {}) {
|
|
222
|
+
if (this.#maxEntries <= 0) {
|
|
223
|
+
return { stored: false, reason: "missing-fields", message: "cache disabled (maxEntries <= 0)" };
|
|
224
|
+
}
|
|
225
|
+
const decision = cacheSafety(result, {
|
|
226
|
+
...options.scopeId !== void 0 ? { scopeId: options.scopeId } : {},
|
|
227
|
+
allowZeroTtl: this.#allowZeroTtl
|
|
228
|
+
});
|
|
229
|
+
if (!decision.cacheable) {
|
|
230
|
+
this.#stats.rejected++;
|
|
231
|
+
return { stored: false, reason: decision.reason, message: decision.message };
|
|
232
|
+
}
|
|
233
|
+
const requestKey = deriveRequestKey(request);
|
|
234
|
+
const key = storageKey(requestKey, decision.scopeKey);
|
|
235
|
+
const expiresAt = this.#clock() + decision.hints.ttlMs;
|
|
236
|
+
this.#store.delete(key);
|
|
237
|
+
this.#store.set(key, { value: result, hints: decision.hints, expiresAt });
|
|
238
|
+
this.#stats.stores++;
|
|
239
|
+
this.#evictIfNeeded();
|
|
240
|
+
return { stored: true, scopeKey: decision.scopeKey, expiresAt, hints: decision.hints };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Look up a result for the given request AND scope identity.
|
|
244
|
+
*
|
|
245
|
+
* A `private` entry is ONLY returned to the same `scopeId` that stored it: the
|
|
246
|
+
* lookup derives the scope key from the caller's `scopeId`, so another caller's
|
|
247
|
+
* lookup targets a different key and can never reach it. A `public` entry is
|
|
248
|
+
* returned to anyone (it is stored under the shared public key).
|
|
249
|
+
*
|
|
250
|
+
* Expired entries are treated as a miss and removed lazily on access.
|
|
251
|
+
*/
|
|
252
|
+
get(request, options = {}) {
|
|
253
|
+
const requestKey = deriveRequestKey(request);
|
|
254
|
+
const candidateScopeKeys = [];
|
|
255
|
+
const privateKey = deriveScopeKey(CacheScope.Private, options.scopeId);
|
|
256
|
+
if (privateKey !== void 0) candidateScopeKeys.push(privateKey);
|
|
257
|
+
candidateScopeKeys.push(deriveScopeKey(CacheScope.Public));
|
|
258
|
+
for (const scopeKey of candidateScopeKeys) {
|
|
259
|
+
const key = storageKey(requestKey, scopeKey);
|
|
260
|
+
const entry = this.#store.get(key);
|
|
261
|
+
if (entry === void 0) continue;
|
|
262
|
+
if (this.#clock() >= entry.expiresAt) {
|
|
263
|
+
this.#store.delete(key);
|
|
264
|
+
this.#stats.expired++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
this.#stats.hits++;
|
|
268
|
+
return {
|
|
269
|
+
hit: true,
|
|
270
|
+
value: entry.value,
|
|
271
|
+
hints: entry.hints,
|
|
272
|
+
expiresAt: entry.expiresAt
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
this.#stats.misses++;
|
|
276
|
+
return { hit: false, reason: "miss" };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Convenience wrapper: return the cached value, or compute + store it.
|
|
280
|
+
*
|
|
281
|
+
* If a fresh entry exists it is returned. Otherwise `loader()` runs, its result
|
|
282
|
+
* is offered to {@link set} (stored only if cache-safe), and returned regardless.
|
|
283
|
+
*/
|
|
284
|
+
async getOrLoad(request, loader, options = {}) {
|
|
285
|
+
const cached = this.get(request, options);
|
|
286
|
+
if (cached.hit) return cached.value;
|
|
287
|
+
const value = await loader();
|
|
288
|
+
this.set(request, value, options);
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
/** Remove all entries whose TTL has elapsed. Returns the count removed. */
|
|
292
|
+
prune() {
|
|
293
|
+
const now = this.#clock();
|
|
294
|
+
let removed = 0;
|
|
295
|
+
for (const [key, entry] of this.#store) {
|
|
296
|
+
if (now >= entry.expiresAt) {
|
|
297
|
+
this.#store.delete(key);
|
|
298
|
+
removed++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
this.#stats.expired += removed;
|
|
302
|
+
return removed;
|
|
303
|
+
}
|
|
304
|
+
/** Drop everything. Does not reset stat counters. */
|
|
305
|
+
clear() {
|
|
306
|
+
this.#store.clear();
|
|
307
|
+
}
|
|
308
|
+
/** Current live entry count (without pruning). */
|
|
309
|
+
get size() {
|
|
310
|
+
return this.#store.size;
|
|
311
|
+
}
|
|
312
|
+
/** A snapshot of counters plus current size. */
|
|
313
|
+
stats() {
|
|
314
|
+
return { ...this.#stats, size: this.#store.size };
|
|
315
|
+
}
|
|
316
|
+
#evictIfNeeded() {
|
|
317
|
+
while (this.#store.size > this.#maxEntries) {
|
|
318
|
+
const oldest = this.#store.keys().next().value;
|
|
319
|
+
if (oldest === void 0) break;
|
|
320
|
+
this.#store.delete(oldest);
|
|
321
|
+
this.#stats.evictions++;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
exports.CACHE_SCOPE_VALUES = CACHE_SCOPE_VALUES;
|
|
327
|
+
exports.CacheScope = CacheScope;
|
|
328
|
+
exports.CacheUnsafeError = CacheUnsafeError;
|
|
329
|
+
exports.McpResultCache = McpResultCache;
|
|
330
|
+
exports.PUBLIC_SCOPE_KEY = PUBLIC_SCOPE_KEY;
|
|
331
|
+
exports.assertCacheSafe = assertCacheSafe;
|
|
332
|
+
exports.cacheSafety = cacheSafety;
|
|
333
|
+
exports.deriveRequestKey = deriveRequestKey;
|
|
334
|
+
exports.deriveScopeKey = deriveScopeKey;
|
|
335
|
+
exports.isCacheScope = isCacheScope;
|
|
336
|
+
exports.isValidTtlMs = isValidTtlMs;
|
|
337
|
+
exports.parseCacheHints = parseCacheHints;
|
|
338
|
+
exports.privateHints = privateHints;
|
|
339
|
+
exports.publicHints = publicHints;
|
|
340
|
+
exports.stableStringify = stableStringify;
|
|
341
|
+
exports.validateCacheHints = validateCacheHints;
|
|
342
|
+
exports.withCacheHints = withCacheHints;
|
|
343
|
+
//# sourceMappingURL=index.cjs.map
|
|
344
|
+
//# sourceMappingURL=index.cjs.map
|