@frontmcp/plugin-remember 1.7.2 → 1.8.1
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/esm/index.mjs +946 -748
- package/esm/package.json +4 -4
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +933 -739
- package/package.json +4 -4
- package/providers/remember-accessor.provider.d.ts +20 -4
- package/providers/remember-accessor.provider.d.ts.map +1 -1
- package/providers/remember-memory.provider.d.ts +7 -0
- package/providers/remember-memory.provider.d.ts.map +1 -1
- package/providers/remember-redis.provider.d.ts +8 -1
- package/providers/remember-redis.provider.d.ts.map +1 -1
- package/providers/remember-store.interface.d.ts +13 -0
- package/providers/remember-store.interface.d.ts.map +1 -1
- package/providers/remember-vercel-kv.provider.d.ts +7 -0
- package/providers/remember-vercel-kv.provider.d.ts.map +1 -1
- package/remember.crypto.d.ts +17 -6
- package/remember.crypto.d.ts.map +1 -1
- package/remember.errors.d.ts +12 -0
- package/remember.errors.d.ts.map +1 -0
- package/remember.legacy-purge.d.ts +45 -0
- package/remember.legacy-purge.d.ts.map +1 -0
- package/remember.plugin.d.ts.map +1 -1
- package/remember.secret-persistence.d.ts.map +1 -1
- package/remember.types.d.ts +25 -0
- package/remember.types.d.ts.map +1 -1
package/esm/index.mjs
CHANGED
|
@@ -20,887 +20,1084 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
20
20
|
// plugins/plugin-remember/src/remember.plugin.ts
|
|
21
21
|
import {
|
|
22
22
|
DynamicPlugin,
|
|
23
|
-
|
|
24
|
-
ProviderScope as ProviderScope5,
|
|
23
|
+
FRONTMCP_CONTEXT,
|
|
25
24
|
FrontMcpConfig,
|
|
26
|
-
|
|
25
|
+
FrontMcpLogger,
|
|
27
26
|
getGlobalStoreConfig,
|
|
28
|
-
isVercelKvProvider
|
|
27
|
+
isVercelKvProvider,
|
|
28
|
+
Plugin,
|
|
29
|
+
ProviderScope as ProviderScope5
|
|
29
30
|
} from "@frontmcp/sdk";
|
|
30
31
|
|
|
31
|
-
// plugins/plugin-remember/src/remember.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
);
|
|
38
|
-
var RememberAccessorToken = /* @__PURE__ */ Symbol(
|
|
39
|
-
"plugin:remember:accessor"
|
|
40
|
-
);
|
|
32
|
+
// plugins/plugin-remember/src/providers/remember-accessor.provider.ts
|
|
33
|
+
import {
|
|
34
|
+
Provider,
|
|
35
|
+
ProviderScope,
|
|
36
|
+
STATELESS_SESSION_ID
|
|
37
|
+
} from "@frontmcp/sdk";
|
|
41
38
|
|
|
42
|
-
// plugins/plugin-remember/src/
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
// plugins/plugin-remember/src/remember.crypto.ts
|
|
40
|
+
import {
|
|
41
|
+
base64urlDecode,
|
|
42
|
+
base64urlEncode as base64urlEncode2,
|
|
43
|
+
decryptAesGcm,
|
|
44
|
+
encryptAesGcm,
|
|
45
|
+
hkdfSha256,
|
|
46
|
+
randomBytes as randomBytes2
|
|
47
|
+
} from "@frontmcp/utils";
|
|
48
|
+
|
|
49
|
+
// plugins/plugin-remember/src/remember.secret-persistence.ts
|
|
50
|
+
import * as path from "path";
|
|
51
|
+
import { z } from "@frontmcp/lazy-zod";
|
|
52
|
+
import { base64urlEncode, mkdir, randomBytes, readFile, rename, unlink, writeFile } from "@frontmcp/utils";
|
|
53
|
+
var DEFAULT_SECRET_FILE_PATH = ".frontmcp/remember-secret.json";
|
|
54
|
+
var SECRET_BYTES = 32;
|
|
55
|
+
var rememberSecretDataSchema = z.object({
|
|
56
|
+
secret: z.string().min(32),
|
|
57
|
+
// base64url of 32 bytes is ~43 chars
|
|
58
|
+
createdAt: z.number().positive().int(),
|
|
59
|
+
version: z.literal(1)
|
|
60
|
+
});
|
|
61
|
+
function validateSecretData(data) {
|
|
62
|
+
const result = rememberSecretDataSchema.safeParse(data);
|
|
63
|
+
if (!result.success) {
|
|
64
|
+
return { valid: false, error: result.error.issues[0]?.message ?? "Invalid secret structure" };
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
async setValue(key, value, ttlSeconds) {
|
|
57
|
-
const strValue = JSON.stringify(value);
|
|
58
|
-
const existing = this.memory.get(key);
|
|
59
|
-
if (existing?.timeout) clearTimeout(existing.timeout);
|
|
60
|
-
const entry = { value: strValue };
|
|
61
|
-
if (ttlSeconds && ttlSeconds > 0) {
|
|
62
|
-
const ttlMs = ttlSeconds * 1e3;
|
|
63
|
-
entry.expiresAt = Date.now() + ttlMs;
|
|
64
|
-
if (ttlMs <= MAX_TIMEOUT_MS) {
|
|
65
|
-
entry.timeout = setTimeout(() => {
|
|
66
|
-
const e = this.memory.get(key);
|
|
67
|
-
if (e && e.expiresAt && e.expiresAt <= Date.now()) {
|
|
68
|
-
this.memory.delete(key);
|
|
69
|
-
}
|
|
70
|
-
}, ttlMs);
|
|
71
|
-
entry.timeout.unref?.();
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
this.memory.set(key, entry);
|
|
66
|
+
const parsed = result.data;
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
if (parsed.createdAt > now + 6e4) {
|
|
69
|
+
return { valid: false, error: "createdAt is in the future" };
|
|
75
70
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*/
|
|
80
|
-
async getValue(key, defaultValue) {
|
|
81
|
-
const entry = this.memory.get(key);
|
|
82
|
-
if (!entry) return defaultValue;
|
|
83
|
-
if (this.isExpired(entry)) {
|
|
84
|
-
await this.delete(key);
|
|
85
|
-
return defaultValue;
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
return JSON.parse(entry.value);
|
|
89
|
-
} catch {
|
|
90
|
-
return entry.value;
|
|
91
|
-
}
|
|
71
|
+
const hundredYearsMs = 100 * 365 * 24 * 60 * 60 * 1e3;
|
|
72
|
+
if (parsed.createdAt < now - hundredYearsMs) {
|
|
73
|
+
return { valid: false, error: "createdAt is too old" };
|
|
92
74
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.memory.delete(key);
|
|
75
|
+
return { valid: true };
|
|
76
|
+
}
|
|
77
|
+
function isSecretPersistenceEnabled(options) {
|
|
78
|
+
const isProduction = process.env["NODE_ENV"] === "production";
|
|
79
|
+
if (isProduction) {
|
|
80
|
+
return options?.forceEnable === true;
|
|
100
81
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (this.isExpired(entry)) {
|
|
108
|
-
await this.delete(key);
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
return true;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function resolveSecretPath(options) {
|
|
85
|
+
const secretPath = options?.secretPath ?? DEFAULT_SECRET_FILE_PATH;
|
|
86
|
+
if (path.isAbsolute(secretPath)) {
|
|
87
|
+
return secretPath;
|
|
112
88
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
89
|
+
return path.resolve(process.cwd(), secretPath);
|
|
90
|
+
}
|
|
91
|
+
async function loadRememberSecret(options) {
|
|
92
|
+
if (!isSecretPersistenceEnabled(options)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const secretPath = resolveSecretPath(options);
|
|
96
|
+
try {
|
|
97
|
+
const content = await readFile(secretPath, "utf8");
|
|
98
|
+
const data = JSON.parse(content);
|
|
99
|
+
const validation = validateSecretData(data);
|
|
100
|
+
if (!validation.valid) {
|
|
101
|
+
console.warn(
|
|
102
|
+
`[RememberSecretPersistence] Invalid secret file format at ${secretPath}: ${validation.error}, will regenerate`
|
|
103
|
+
);
|
|
104
|
+
return null;
|
|
128
105
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (regex && !regex.test(key)) {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
result.push(key);
|
|
106
|
+
return data;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.code === "ENOENT") {
|
|
109
|
+
return null;
|
|
137
110
|
}
|
|
138
|
-
|
|
111
|
+
console.warn(`[RememberSecretPersistence] Failed to load secret from ${secretPath}: ${error.message}`);
|
|
112
|
+
return null;
|
|
139
113
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
114
|
+
}
|
|
115
|
+
async function saveRememberSecret(secretData, options) {
|
|
116
|
+
if (!isSecretPersistenceEnabled(options)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const secretPath = resolveSecretPath(options);
|
|
120
|
+
const dir = path.dirname(secretPath);
|
|
121
|
+
const tempPath = `${secretPath}.tmp.${Date.now()}.${base64urlEncode(randomBytes(8))}`;
|
|
122
|
+
try {
|
|
123
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
124
|
+
const content = JSON.stringify(secretData, null, 2);
|
|
125
|
+
await writeFile(tempPath, content, { mode: 384 });
|
|
126
|
+
await rename(tempPath, secretPath);
|
|
127
|
+
return true;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`[RememberSecretPersistence] Failed to save secret to ${secretPath}: ${error.message}`);
|
|
130
|
+
try {
|
|
131
|
+
await unlink(tempPath);
|
|
132
|
+
} catch {
|
|
147
133
|
}
|
|
148
|
-
|
|
134
|
+
return false;
|
|
149
135
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
136
|
+
}
|
|
137
|
+
var cachedSecret = null;
|
|
138
|
+
var secretGenerationPromise = null;
|
|
139
|
+
function generateSecret() {
|
|
140
|
+
return base64urlEncode(randomBytes(SECRET_BYTES));
|
|
141
|
+
}
|
|
142
|
+
async function getOrCreatePersistedSecret(options) {
|
|
143
|
+
if (cachedSecret) {
|
|
144
|
+
return cachedSecret;
|
|
155
145
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
146
|
+
if (secretGenerationPromise) {
|
|
147
|
+
return secretGenerationPromise;
|
|
148
|
+
}
|
|
149
|
+
secretGenerationPromise = (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const loaded = await loadRememberSecret(options);
|
|
152
|
+
if (loaded) {
|
|
153
|
+
cachedSecret = loaded.secret;
|
|
154
|
+
return cachedSecret;
|
|
155
|
+
}
|
|
156
|
+
const newSecret = generateSecret();
|
|
157
|
+
const secretData = {
|
|
158
|
+
secret: newSecret,
|
|
159
|
+
createdAt: Date.now(),
|
|
160
|
+
version: 1
|
|
161
|
+
};
|
|
162
|
+
if (isSecretPersistenceEnabled(options)) {
|
|
163
|
+
await saveRememberSecret(secretData, options);
|
|
164
|
+
} else {
|
|
165
|
+
console.warn(
|
|
166
|
+
"[RememberSecretPersistence] No REMEMBER_SECRET (or MCP_MEMORY_SECRET / MCP_SESSION_SECRET) is set in production, so a random in-memory secret is used. Encrypted memory will be unreadable after a restart and by other instances sharing the store. Set REMEMBER_SECRET to the same value on every instance."
|
|
167
|
+
);
|
|
165
168
|
}
|
|
169
|
+
cachedSecret = newSecret;
|
|
170
|
+
return cachedSecret;
|
|
171
|
+
} finally {
|
|
172
|
+
secretGenerationPromise = null;
|
|
166
173
|
}
|
|
174
|
+
})();
|
|
175
|
+
return secretGenerationPromise;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// plugins/plugin-remember/src/remember.crypto.ts
|
|
179
|
+
async function getBaseSecret() {
|
|
180
|
+
const envSecret = process.env["REMEMBER_SECRET"] || process.env["MCP_MEMORY_SECRET"] || process.env["MCP_SESSION_SECRET"];
|
|
181
|
+
if (envSecret) {
|
|
182
|
+
return envSecret;
|
|
167
183
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
return getOrCreatePersistedSecret();
|
|
185
|
+
}
|
|
186
|
+
var textEncoder = new TextEncoder();
|
|
187
|
+
async function deriveEncryptionKey(source) {
|
|
188
|
+
const salt = textEncoder.encode("remember-plugin-v1");
|
|
189
|
+
let ikm;
|
|
190
|
+
let context;
|
|
191
|
+
switch (source.type) {
|
|
192
|
+
case "session":
|
|
193
|
+
ikm = await getBaseSecret() + source.sessionId;
|
|
194
|
+
context = `remember:session:${source.sessionId}`;
|
|
195
|
+
break;
|
|
196
|
+
case "tool":
|
|
197
|
+
ikm = await getBaseSecret() + source.sessionId;
|
|
198
|
+
context = `remember:tool:${source.toolName}:${source.sessionId}`;
|
|
199
|
+
break;
|
|
200
|
+
case "user":
|
|
201
|
+
ikm = await getBaseSecret() + source.userId;
|
|
202
|
+
context = `remember:user:${source.userId}`;
|
|
203
|
+
break;
|
|
204
|
+
case "global":
|
|
205
|
+
ikm = await getBaseSecret();
|
|
206
|
+
context = "remember:global";
|
|
207
|
+
break;
|
|
208
|
+
case "custom":
|
|
209
|
+
ikm = source.key;
|
|
210
|
+
context = `remember:custom:${source.key}`;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
const ikmBytes = textEncoder.encode(ikm);
|
|
214
|
+
const info = textEncoder.encode(context);
|
|
215
|
+
return hkdfSha256(ikmBytes, salt, info, 32);
|
|
216
|
+
}
|
|
217
|
+
function getKeySourceForScope(scope, context) {
|
|
218
|
+
switch (scope) {
|
|
219
|
+
case "session":
|
|
220
|
+
return { type: "session", sessionId: context.sessionId };
|
|
221
|
+
case "user":
|
|
222
|
+
return { type: "user", userId: context.userId ?? "anonymous" };
|
|
223
|
+
case "tool":
|
|
224
|
+
return {
|
|
225
|
+
type: "tool",
|
|
226
|
+
toolName: context.toolName ?? "unknown",
|
|
227
|
+
sessionId: context.sessionId
|
|
228
|
+
};
|
|
229
|
+
case "global":
|
|
230
|
+
return { type: "global" };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
var textDecoder = new TextDecoder();
|
|
234
|
+
async function encryptValue(data, keySource) {
|
|
235
|
+
const key = await deriveEncryptionKey(keySource);
|
|
236
|
+
const plaintext = textEncoder.encode(JSON.stringify(data));
|
|
237
|
+
const iv = randomBytes2(12);
|
|
238
|
+
const { ciphertext, tag } = encryptAesGcm(key, plaintext, iv);
|
|
239
|
+
return {
|
|
240
|
+
alg: "A256GCM",
|
|
241
|
+
iv: base64urlEncode2(iv),
|
|
242
|
+
tag: base64urlEncode2(tag),
|
|
243
|
+
data: base64urlEncode2(ciphertext)
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async function decryptValue(blob, keySource) {
|
|
247
|
+
try {
|
|
248
|
+
const key = await deriveEncryptionKey(keySource);
|
|
249
|
+
const iv = base64urlDecode(blob.iv);
|
|
250
|
+
const tag = base64urlDecode(blob.tag);
|
|
251
|
+
const ciphertext = base64urlDecode(blob.data);
|
|
252
|
+
const decrypted = decryptAesGcm(key, ciphertext, iv, tag);
|
|
253
|
+
return JSON.parse(textDecoder.decode(decrypted));
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function serializeBlob(blob) {
|
|
259
|
+
return JSON.stringify(blob);
|
|
260
|
+
}
|
|
261
|
+
function deserializeBlob(str) {
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(str);
|
|
264
|
+
if (parsed && typeof parsed === "object" && parsed.alg === "A256GCM" && typeof parsed.iv === "string" && typeof parsed.tag === "string" && typeof parsed.data === "string") {
|
|
265
|
+
return parsed;
|
|
182
266
|
}
|
|
183
|
-
|
|
184
|
-
|
|
267
|
+
return null;
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function encryptAndSerialize(data, keySource) {
|
|
273
|
+
const blob = await encryptValue(data, keySource);
|
|
274
|
+
return serializeBlob(blob);
|
|
275
|
+
}
|
|
276
|
+
async function deserializeAndDecrypt(str, keySource) {
|
|
277
|
+
const blob = deserializeBlob(str);
|
|
278
|
+
if (!blob) return null;
|
|
279
|
+
return decryptValue(blob, keySource);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// plugins/plugin-remember/src/remember.errors.ts
|
|
283
|
+
var RememberIdentityError = class extends Error {
|
|
284
|
+
name = "RememberIdentityError";
|
|
285
|
+
constructor(message) {
|
|
286
|
+
super(message);
|
|
185
287
|
}
|
|
186
288
|
};
|
|
187
|
-
// ReDoS protection constants
|
|
188
|
-
// Pattern length allows for long prefixes (e.g., remember:session:{uuid}:user:*)
|
|
189
|
-
__publicField(RememberMemoryProvider, "MAX_PATTERN_LENGTH", 500);
|
|
190
|
-
__publicField(RememberMemoryProvider, "MAX_WILDCARDS", 20);
|
|
191
|
-
RememberMemoryProvider = __decorateClass([
|
|
192
|
-
Provider({
|
|
193
|
-
name: "provider:remember:memory",
|
|
194
|
-
description: "In-memory storage provider for RememberPlugin",
|
|
195
|
-
scope: ProviderScope.GLOBAL
|
|
196
|
-
})
|
|
197
|
-
], RememberMemoryProvider);
|
|
198
289
|
|
|
199
|
-
// plugins/plugin-remember/src/
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
var
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
this.client = options.client;
|
|
215
|
-
this.ownsClient = false;
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
this.ownsClient = true;
|
|
219
|
-
this.client = new Redis({
|
|
220
|
-
lazyConnect: false,
|
|
221
|
-
maxRetriesPerRequest: 3,
|
|
222
|
-
...options.config
|
|
223
|
-
});
|
|
224
|
-
this.client.on("connect", () => {
|
|
225
|
-
if (process.env["DEBUG"] === "true") {
|
|
226
|
-
console.log("[RememberPlugin:Redis] Connected");
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
this.client.on("error", (err) => {
|
|
230
|
-
console.error("[RememberPlugin:Redis] Error:", err.message);
|
|
231
|
-
});
|
|
290
|
+
// plugins/plugin-remember/src/remember.legacy-purge.ts
|
|
291
|
+
var LEGACY_SCOPES = ["session", "tool", "user"];
|
|
292
|
+
var DEFAULT_LEGACY_PURGE_DELAY_MS = 864e5;
|
|
293
|
+
var LAYOUT_MARKER_KEY = "__layout__";
|
|
294
|
+
var LAYOUT_VERSION = 2;
|
|
295
|
+
var MAX_TIMER_MS = 2147483647;
|
|
296
|
+
var scheduled = /* @__PURE__ */ new WeakSet();
|
|
297
|
+
function parseFirstSeenAt(raw) {
|
|
298
|
+
if (!raw) return void 0;
|
|
299
|
+
try {
|
|
300
|
+
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
301
|
+
const firstSeenAt = parsed?.firstSeenAt;
|
|
302
|
+
return typeof firstSeenAt === "number" && Number.isFinite(firstSeenAt) ? firstSeenAt : void 0;
|
|
303
|
+
} catch {
|
|
304
|
+
return void 0;
|
|
232
305
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
|
|
306
|
+
}
|
|
307
|
+
async function readLayoutFirstSeenAt(store, keyPrefix, logger) {
|
|
308
|
+
const key = `${keyPrefix}${LAYOUT_MARKER_KEY}`;
|
|
309
|
+
const standDown = (reason, extra = {}) => {
|
|
310
|
+
logger?.debug?.(`remember: ${reason}, legacy purge stood down`, { key, ...extra });
|
|
311
|
+
return void 0;
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
const existing = await store.getValue(key);
|
|
315
|
+
if (existing) {
|
|
316
|
+
return parseFirstSeenAt(existing) ?? standDown("layout marker is unreadable");
|
|
244
317
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
throw new Error(`Invalid TTL: must be positive, got ${ttlSeconds}`);
|
|
251
|
-
}
|
|
252
|
-
if (!Number.isInteger(ttlSeconds)) {
|
|
253
|
-
throw new Error(`Invalid TTL: must be an integer, got ${ttlSeconds}`);
|
|
254
|
-
}
|
|
318
|
+
const marker = { version: LAYOUT_VERSION, firstSeenAt: Date.now() };
|
|
319
|
+
const serialized = JSON.stringify(marker);
|
|
320
|
+
if (!store.setIfAbsent) {
|
|
321
|
+
await store.setValue(key, serialized);
|
|
322
|
+
return marker.firstSeenAt;
|
|
255
323
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (ttlSeconds !== void 0 && ttlSeconds > 0) {
|
|
259
|
-
await this.client.set(fullKey, strValue, "EX", ttlSeconds);
|
|
260
|
-
} else {
|
|
261
|
-
await this.client.set(fullKey, strValue);
|
|
324
|
+
if (await store.setIfAbsent(key, serialized)) {
|
|
325
|
+
return marker.firstSeenAt;
|
|
262
326
|
}
|
|
327
|
+
const winner = await store.getValue(key);
|
|
328
|
+
return parseFirstSeenAt(winner) ?? standDown("layout marker vanished after a lost race");
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return standDown("could not establish the layout marker", {
|
|
331
|
+
error: error instanceof Error ? error.message : String(error)
|
|
332
|
+
});
|
|
263
333
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
*
|
|
271
|
-
* @param key - The key to retrieve
|
|
272
|
-
* @param defaultValue - Value to return if key doesn't exist or parsing fails
|
|
273
|
-
* @returns The parsed value as T, or defaultValue/undefined
|
|
274
|
-
*/
|
|
275
|
-
async getValue(key, defaultValue) {
|
|
276
|
-
const fullKey = this.keyPrefix + key;
|
|
277
|
-
const raw = await this.client.get(fullKey);
|
|
278
|
-
if (raw === null) return defaultValue;
|
|
334
|
+
}
|
|
335
|
+
async function purgeLegacyRememberEntries(store, keyPrefix, logger) {
|
|
336
|
+
let deleted = 0;
|
|
337
|
+
for (const scope of LEGACY_SCOPES) {
|
|
338
|
+
const pattern = `${keyPrefix}${scope}:*`;
|
|
339
|
+
let keys;
|
|
279
340
|
try {
|
|
280
|
-
|
|
281
|
-
} catch {
|
|
282
|
-
|
|
341
|
+
keys = await store.keys(pattern);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
logger?.debug?.("remember: legacy purge skipped, store could not list keys", {
|
|
344
|
+
pattern,
|
|
345
|
+
error: error instanceof Error ? error.message : String(error)
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
283
348
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const fullKey = this.keyPrefix + key;
|
|
290
|
-
await this.client.del(fullKey);
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Check if a key exists.
|
|
294
|
-
*/
|
|
295
|
-
async exists(key) {
|
|
296
|
-
const fullKey = this.keyPrefix + key;
|
|
297
|
-
return await this.client.exists(fullKey) === 1;
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* List keys matching a pattern.
|
|
301
|
-
* Uses Redis SCAN for efficient iteration.
|
|
302
|
-
*/
|
|
303
|
-
async keys(pattern) {
|
|
304
|
-
const searchPattern = this.keyPrefix + (pattern ?? "*");
|
|
305
|
-
const result = [];
|
|
306
|
-
let cursor = "0";
|
|
307
|
-
do {
|
|
308
|
-
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", searchPattern, "COUNT", 100);
|
|
309
|
-
cursor = nextCursor;
|
|
310
|
-
for (const key of keys) {
|
|
311
|
-
result.push(key.slice(this.keyPrefix.length));
|
|
349
|
+
for (const key of keys) {
|
|
350
|
+
try {
|
|
351
|
+
await store.delete(key);
|
|
352
|
+
deleted += 1;
|
|
353
|
+
} catch {
|
|
312
354
|
}
|
|
313
|
-
} while (cursor !== "0");
|
|
314
|
-
return result;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Gracefully close the Redis connection.
|
|
318
|
-
* Only closes if this provider owns the client (created it internally).
|
|
319
|
-
* Externally-provided clients are left open for the caller to manage.
|
|
320
|
-
*/
|
|
321
|
-
async close() {
|
|
322
|
-
if (this.ownsClient) {
|
|
323
|
-
await this.client.quit();
|
|
324
355
|
}
|
|
325
356
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
357
|
+
if (deleted > 0) {
|
|
358
|
+
logger?.warn?.(
|
|
359
|
+
`remember: purged ${deleted} entr${deleted === 1 ? "y" : "ies"} left unreadable by the session/tool key-derivation change and the namespace encoding. This is a one-time upgrade step; set skipLegacyPurge to handle the migration yourself.`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return deleted;
|
|
363
|
+
}
|
|
364
|
+
function scheduleLegacyRememberPurge(store, keyPrefix, options = {}) {
|
|
365
|
+
if (scheduled.has(store)) return;
|
|
366
|
+
scheduled.add(store);
|
|
367
|
+
const delayMs = options.delayMs ?? DEFAULT_LEGACY_PURGE_DELAY_MS;
|
|
368
|
+
const arm = (waitMs) => {
|
|
369
|
+
const timer = setTimeout(
|
|
370
|
+
() => {
|
|
371
|
+
void attempt().catch(() => void 0);
|
|
372
|
+
},
|
|
373
|
+
Math.min(waitMs, MAX_TIMER_MS)
|
|
374
|
+
);
|
|
375
|
+
timer.unref?.();
|
|
376
|
+
};
|
|
377
|
+
const attempt = async () => {
|
|
378
|
+
const firstSeenAt = await readLayoutFirstSeenAt(store, keyPrefix, options.logger);
|
|
379
|
+
if (firstSeenAt === void 0) return;
|
|
380
|
+
const remainingMs = firstSeenAt + delayMs - Date.now();
|
|
381
|
+
if (remainingMs > 0) {
|
|
382
|
+
arm(remainingMs);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await purgeLegacyRememberEntries(store, keyPrefix, options.logger);
|
|
386
|
+
};
|
|
387
|
+
void attempt().catch(() => void 0);
|
|
388
|
+
}
|
|
334
389
|
|
|
335
|
-
// plugins/plugin-remember/src/providers/remember-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
390
|
+
// plugins/plugin-remember/src/providers/remember-accessor.provider.ts
|
|
391
|
+
var STORAGE_LAYOUT_VERSION = "v2";
|
|
392
|
+
function encodeKeyPart(value) {
|
|
393
|
+
return encodeURIComponent(value);
|
|
394
|
+
}
|
|
395
|
+
var RememberAccessor = class {
|
|
396
|
+
store;
|
|
397
|
+
ctx;
|
|
398
|
+
config;
|
|
339
399
|
keyPrefix;
|
|
340
|
-
|
|
341
|
-
constructor(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
if (options.url && options.token) {
|
|
351
|
-
this.kv = vercelKv.createClient({
|
|
352
|
-
url: options.url,
|
|
353
|
-
token: options.token
|
|
354
|
-
});
|
|
355
|
-
} else {
|
|
356
|
-
this.kv = vercelKv.kv;
|
|
400
|
+
encryptionEnabled;
|
|
401
|
+
constructor(store, ctx, config, logger) {
|
|
402
|
+
this.store = store;
|
|
403
|
+
this.ctx = ctx;
|
|
404
|
+
this.config = config;
|
|
405
|
+
this.keyPrefix = config.keyPrefix ?? "remember:";
|
|
406
|
+
this.encryptionEnabled = config.encryption?.enabled !== false;
|
|
407
|
+
if (!config.skipLegacyPurge) {
|
|
408
|
+
scheduleLegacyRememberPurge(store, this.keyPrefix, { delayMs: config.legacyPurgeDelayMs, logger });
|
|
357
409
|
}
|
|
358
|
-
this.keyPrefix = options.keyPrefix ?? "remember:";
|
|
359
|
-
this.defaultTTL = options.defaultTTL;
|
|
360
410
|
}
|
|
361
|
-
|
|
362
|
-
|
|
411
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
412
|
+
// Public API
|
|
413
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
414
|
+
/**
|
|
415
|
+
* Store a value in memory.
|
|
416
|
+
*
|
|
417
|
+
* @param key - The key to store under
|
|
418
|
+
* @param value - The value to store (any JSON-serializable data)
|
|
419
|
+
* @param options - Storage options (scope, ttl, brand, metadata)
|
|
420
|
+
*/
|
|
421
|
+
async set(key, value, options = {}) {
|
|
422
|
+
const scope = options.scope ?? "session";
|
|
423
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
424
|
+
const entry = {
|
|
425
|
+
value,
|
|
426
|
+
brand: options.brand,
|
|
427
|
+
createdAt: Date.now(),
|
|
428
|
+
updatedAt: Date.now(),
|
|
429
|
+
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : void 0,
|
|
430
|
+
metadata: options.metadata
|
|
431
|
+
};
|
|
432
|
+
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
433
|
+
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
363
434
|
}
|
|
364
435
|
/**
|
|
365
|
-
*
|
|
436
|
+
* Retrieve a value from memory.
|
|
437
|
+
*
|
|
438
|
+
* @param key - The key to retrieve
|
|
439
|
+
* @param options - Retrieval options (scope, defaultValue)
|
|
440
|
+
* @returns The stored value or defaultValue if not found
|
|
366
441
|
*/
|
|
367
|
-
async
|
|
368
|
-
const
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
442
|
+
async get(key, options = {}) {
|
|
443
|
+
const scope = options.scope ?? "session";
|
|
444
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
445
|
+
const raw = await this.store.getValue(storageKey);
|
|
446
|
+
if (!raw) return options.defaultValue;
|
|
447
|
+
const entry = this.encryptionEnabled ? await deserializeAndDecrypt(raw, this.getKeySource(scope)) : this.parseEntry(raw);
|
|
448
|
+
if (!entry) return options.defaultValue;
|
|
449
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
450
|
+
await this.forget(key, { scope });
|
|
451
|
+
return options.defaultValue;
|
|
375
452
|
}
|
|
453
|
+
return entry.value;
|
|
376
454
|
}
|
|
377
455
|
/**
|
|
378
|
-
*
|
|
456
|
+
* Get the full entry including metadata.
|
|
457
|
+
*
|
|
458
|
+
* @param key - The key to retrieve
|
|
459
|
+
* @param options - Retrieval options (scope)
|
|
460
|
+
* @returns The full entry or undefined if not found
|
|
379
461
|
*/
|
|
380
|
-
async
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
462
|
+
async getEntry(key, options = {}) {
|
|
463
|
+
const scope = options.scope ?? "session";
|
|
464
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
465
|
+
const raw = await this.store.getValue(storageKey);
|
|
466
|
+
if (!raw) return void 0;
|
|
467
|
+
const entry = this.encryptionEnabled ? await deserializeAndDecrypt(raw, this.getKeySource(scope)) : this.parseEntry(raw);
|
|
468
|
+
if (!entry) return void 0;
|
|
469
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
470
|
+
await this.forget(key, { scope });
|
|
471
|
+
return void 0;
|
|
390
472
|
}
|
|
391
|
-
return
|
|
473
|
+
return entry;
|
|
392
474
|
}
|
|
393
475
|
/**
|
|
394
|
-
*
|
|
476
|
+
* Forget (delete) a value from memory.
|
|
477
|
+
*
|
|
478
|
+
* @param key - The key to forget
|
|
479
|
+
* @param options - Options (scope)
|
|
395
480
|
*/
|
|
396
|
-
async
|
|
397
|
-
const
|
|
398
|
-
|
|
481
|
+
async forget(key, options = {}) {
|
|
482
|
+
const scope = options.scope ?? "session";
|
|
483
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
484
|
+
await this.store.delete(storageKey);
|
|
399
485
|
}
|
|
400
486
|
/**
|
|
401
|
-
* Check if a key exists.
|
|
487
|
+
* Check if a key is remembered (exists and not expired).
|
|
488
|
+
*
|
|
489
|
+
* @param key - The key to check
|
|
490
|
+
* @param options - Options (scope)
|
|
491
|
+
* @returns true if the key exists
|
|
402
492
|
*/
|
|
403
|
-
async
|
|
404
|
-
const
|
|
405
|
-
|
|
493
|
+
async knows(key, options = {}) {
|
|
494
|
+
const scope = options.scope ?? "session";
|
|
495
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
496
|
+
return this.store.exists(storageKey);
|
|
406
497
|
}
|
|
407
498
|
/**
|
|
408
|
-
* List keys
|
|
409
|
-
*
|
|
499
|
+
* List all remembered keys for a scope.
|
|
500
|
+
*
|
|
501
|
+
* @param options - Options (scope, pattern)
|
|
502
|
+
* @returns Array of keys (without the scope prefix)
|
|
410
503
|
*/
|
|
411
|
-
async
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const [nextCursor, keys] = await this.kv.scan(cursor, {
|
|
418
|
-
match: searchPattern,
|
|
419
|
-
count: 100
|
|
420
|
-
});
|
|
421
|
-
cursor = nextCursor;
|
|
422
|
-
for (const key of keys) {
|
|
423
|
-
result.push(key.slice(this.keyPrefix.length));
|
|
424
|
-
}
|
|
425
|
-
} while (cursor !== 0);
|
|
426
|
-
} catch {
|
|
427
|
-
try {
|
|
428
|
-
const keys = await this.kv.keys(searchPattern);
|
|
429
|
-
for (const key of keys) {
|
|
430
|
-
result.push(key.slice(this.keyPrefix.length));
|
|
431
|
-
}
|
|
432
|
-
} catch {
|
|
433
|
-
console.warn("[RememberPlugin:VercelKV] keys() operation not supported");
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return result;
|
|
504
|
+
async list(options = {}) {
|
|
505
|
+
const scope = options.scope ?? "session";
|
|
506
|
+
const scopePrefix = this.buildScopePrefix(scope);
|
|
507
|
+
const fullPattern = scopePrefix + (options.pattern ?? "*");
|
|
508
|
+
const keys = await this.store.keys(fullPattern);
|
|
509
|
+
return keys.map((k) => k.slice(scopePrefix.length));
|
|
437
510
|
}
|
|
438
511
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
512
|
+
* Update an existing entry's value while preserving metadata.
|
|
513
|
+
*
|
|
514
|
+
* @param key - The key to update
|
|
515
|
+
* @param value - The new value
|
|
516
|
+
* @param options - Options (scope, ttl)
|
|
441
517
|
*/
|
|
442
|
-
async
|
|
443
|
-
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
// plugins/plugin-remember/src/remember.crypto.ts
|
|
457
|
-
import {
|
|
458
|
-
hkdfSha256,
|
|
459
|
-
encryptAesGcm,
|
|
460
|
-
decryptAesGcm,
|
|
461
|
-
randomBytes as randomBytes2,
|
|
462
|
-
base64urlEncode as base64urlEncode2,
|
|
463
|
-
base64urlDecode
|
|
464
|
-
} from "@frontmcp/utils";
|
|
465
|
-
|
|
466
|
-
// plugins/plugin-remember/src/remember.secret-persistence.ts
|
|
467
|
-
import * as path from "path";
|
|
468
|
-
import { z } from "@frontmcp/lazy-zod";
|
|
469
|
-
import { base64urlEncode, mkdir, randomBytes, readFile, rename, unlink, writeFile } from "@frontmcp/utils";
|
|
470
|
-
var DEFAULT_SECRET_FILE_PATH = ".frontmcp/remember-secret.json";
|
|
471
|
-
var SECRET_BYTES = 32;
|
|
472
|
-
var rememberSecretDataSchema = z.object({
|
|
473
|
-
secret: z.string().min(32),
|
|
474
|
-
// base64url of 32 bytes is ~43 chars
|
|
475
|
-
createdAt: z.number().positive().int(),
|
|
476
|
-
version: z.literal(1)
|
|
477
|
-
});
|
|
478
|
-
function validateSecretData(data) {
|
|
479
|
-
const result = rememberSecretDataSchema.safeParse(data);
|
|
480
|
-
if (!result.success) {
|
|
481
|
-
return { valid: false, error: result.error.issues[0]?.message ?? "Invalid secret structure" };
|
|
482
|
-
}
|
|
483
|
-
const parsed = result.data;
|
|
484
|
-
const now = Date.now();
|
|
485
|
-
if (parsed.createdAt > now + 6e4) {
|
|
486
|
-
return { valid: false, error: "createdAt is in the future" };
|
|
487
|
-
}
|
|
488
|
-
const hundredYearsMs = 100 * 365 * 24 * 60 * 60 * 1e3;
|
|
489
|
-
if (parsed.createdAt < now - hundredYearsMs) {
|
|
490
|
-
return { valid: false, error: "createdAt is too old" };
|
|
518
|
+
async update(key, value, options = {}) {
|
|
519
|
+
const scope = options.scope ?? "session";
|
|
520
|
+
const existing = await this.getEntry(key, { scope });
|
|
521
|
+
if (!existing) return false;
|
|
522
|
+
const entry = {
|
|
523
|
+
...existing,
|
|
524
|
+
value,
|
|
525
|
+
updatedAt: Date.now(),
|
|
526
|
+
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : existing.expiresAt
|
|
527
|
+
};
|
|
528
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
529
|
+
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
530
|
+
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
531
|
+
return true;
|
|
491
532
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
533
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
534
|
+
// Convenience Methods
|
|
535
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
536
|
+
/**
|
|
537
|
+
* Get the session ID from context.
|
|
538
|
+
*/
|
|
539
|
+
get sessionId() {
|
|
540
|
+
return this.ctx.sessionId;
|
|
498
541
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return
|
|
542
|
+
/**
|
|
543
|
+
* Get the user ID from context (if available).
|
|
544
|
+
*/
|
|
545
|
+
get userId() {
|
|
546
|
+
const authInfo = this.ctx.authInfo;
|
|
547
|
+
return authInfo?.extra?.["userId"] ?? authInfo?.extra?.["sub"] ?? authInfo?.clientId ?? void 0;
|
|
505
548
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
549
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
550
|
+
// Private Methods
|
|
551
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
552
|
+
/**
|
|
553
|
+
* Build the full storage key including scope prefix.
|
|
554
|
+
*/
|
|
555
|
+
buildStorageKey(key, scope) {
|
|
556
|
+
return this.buildScopePrefix(scope) + key;
|
|
511
557
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
558
|
+
/**
|
|
559
|
+
* The identity that namespaces session- and tool-scoped storage
|
|
560
|
+
* (GHSA-225p-f8jh-f3rh).
|
|
561
|
+
*
|
|
562
|
+
* In stateless mode the transport injects the literal session id `__stateless__` into every
|
|
563
|
+
* request, so using it as a namespace put every client's memory under the same keys. A
|
|
564
|
+
* stateless request carries no session identity: the authenticated principal is the only
|
|
565
|
+
* per-client identity available, and where there is none the request has no business
|
|
566
|
+
* reading or writing per-client memory at all.
|
|
567
|
+
*/
|
|
568
|
+
resolveSessionIdentity() {
|
|
569
|
+
const sessionId = this.ctx.sessionId;
|
|
570
|
+
if (sessionId !== STATELESS_SESSION_ID) {
|
|
571
|
+
return sessionId;
|
|
522
572
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return null;
|
|
573
|
+
const userId = this.userId;
|
|
574
|
+
if (userId) {
|
|
575
|
+
return `stateless-user:${userId}`;
|
|
527
576
|
}
|
|
528
|
-
|
|
529
|
-
|
|
577
|
+
throw new RememberIdentityError(
|
|
578
|
+
`Remember cannot use session or tool scope for an unauthenticated stateless request: every such request shares the session id "${STATELESS_SESSION_ID}", so the data would be shared across all clients. Authenticate the request, use a stateful transport, or choose the 'global' scope if the data really is shared.`
|
|
579
|
+
);
|
|
530
580
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Build the scope-specific prefix.
|
|
583
|
+
*
|
|
584
|
+
* Every variable component is encoded, because the separator is also a legal character in
|
|
585
|
+
* a user id, a tool name and a memory key. Left raw, identity `a` with key `b:c` produces
|
|
586
|
+
* the same storage key as identity `a:b` with key `c` — one client reading or overwriting
|
|
587
|
+
* another's value through nothing more exotic than a colon in a name.
|
|
588
|
+
*/
|
|
589
|
+
buildScopePrefix(scope) {
|
|
590
|
+
switch (scope) {
|
|
591
|
+
case "session":
|
|
592
|
+
return `${this.keyPrefix}${STORAGE_LAYOUT_VERSION}:session:${encodeKeyPart(this.resolveSessionIdentity())}:`;
|
|
593
|
+
case "user": {
|
|
594
|
+
const userId = this.userId;
|
|
595
|
+
if (!userId) {
|
|
596
|
+
throw new RememberIdentityError(
|
|
597
|
+
"Remember cannot use user scope without an authenticated user: all unauthenticated callers would share one namespace. Authenticate the request or use the 'global' scope."
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return `${this.keyPrefix}${STORAGE_LAYOUT_VERSION}:user:${encodeKeyPart(userId)}:`;
|
|
601
|
+
}
|
|
602
|
+
case "tool": {
|
|
603
|
+
const toolName = this.ctx.flow?.name ?? "unknown";
|
|
604
|
+
return `${this.keyPrefix}${STORAGE_LAYOUT_VERSION}:tool:${encodeKeyPart(toolName)}:${encodeKeyPart(this.resolveSessionIdentity())}:`;
|
|
605
|
+
}
|
|
606
|
+
case "global":
|
|
607
|
+
return `${this.keyPrefix}global:`;
|
|
608
|
+
}
|
|
535
609
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
610
|
+
/**
|
|
611
|
+
* Get the encryption key source for a scope.
|
|
612
|
+
*/
|
|
613
|
+
getKeySource(scope) {
|
|
614
|
+
const needsSessionIdentity = scope === "session" || scope === "tool";
|
|
615
|
+
return getKeySourceForScope(scope, {
|
|
616
|
+
sessionId: needsSessionIdentity ? this.resolveSessionIdentity() : this.ctx.sessionId,
|
|
617
|
+
userId: this.userId,
|
|
618
|
+
toolName: this.ctx.flow?.name
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Parse a JSON entry (when encryption is disabled).
|
|
623
|
+
*/
|
|
624
|
+
parseEntry(raw) {
|
|
547
625
|
try {
|
|
548
|
-
|
|
626
|
+
return JSON.parse(raw);
|
|
549
627
|
} catch {
|
|
628
|
+
return null;
|
|
550
629
|
}
|
|
551
|
-
return false;
|
|
552
630
|
}
|
|
631
|
+
};
|
|
632
|
+
RememberAccessor = __decorateClass([
|
|
633
|
+
Provider({
|
|
634
|
+
name: "provider:remember:accessor",
|
|
635
|
+
description: "Context-scoped accessor for RememberPlugin storage",
|
|
636
|
+
scope: ProviderScope.CONTEXT
|
|
637
|
+
})
|
|
638
|
+
], RememberAccessor);
|
|
639
|
+
function createRememberAccessor(store, ctx, config, logger) {
|
|
640
|
+
return new RememberAccessor(store, ctx, config, logger);
|
|
553
641
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return secretGenerationPromise;
|
|
642
|
+
|
|
643
|
+
// plugins/plugin-remember/src/providers/remember-memory.provider.ts
|
|
644
|
+
import { Provider as Provider2, ProviderScope as ProviderScope2 } from "@frontmcp/sdk";
|
|
645
|
+
var MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
646
|
+
var RememberMemoryProvider = class {
|
|
647
|
+
memory = /* @__PURE__ */ new Map();
|
|
648
|
+
sweeper;
|
|
649
|
+
constructor(sweepIntervalSeconds = 60) {
|
|
650
|
+
this.sweeper = setInterval(() => this.sweep(), sweepIntervalSeconds * 1e3);
|
|
651
|
+
this.sweeper.unref?.();
|
|
565
652
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
653
|
+
/**
|
|
654
|
+
* Store a value with optional TTL.
|
|
655
|
+
* Always JSON-serializes the value for consistent storage/retrieval.
|
|
656
|
+
*/
|
|
657
|
+
async setValue(key, value, ttlSeconds) {
|
|
658
|
+
const strValue = JSON.stringify(value);
|
|
659
|
+
const existing = this.memory.get(key);
|
|
660
|
+
if (existing?.timeout) clearTimeout(existing.timeout);
|
|
661
|
+
const entry = { value: strValue };
|
|
662
|
+
if (ttlSeconds && ttlSeconds > 0) {
|
|
663
|
+
const ttlMs = ttlSeconds * 1e3;
|
|
664
|
+
entry.expiresAt = Date.now() + ttlMs;
|
|
665
|
+
if (ttlMs <= MAX_TIMEOUT_MS) {
|
|
666
|
+
entry.timeout = setTimeout(() => {
|
|
667
|
+
const e = this.memory.get(key);
|
|
668
|
+
if (e && e.expiresAt && e.expiresAt <= Date.now()) {
|
|
669
|
+
this.memory.delete(key);
|
|
670
|
+
}
|
|
671
|
+
}, ttlMs);
|
|
672
|
+
entry.timeout.unref?.();
|
|
581
673
|
}
|
|
582
|
-
cachedSecret = newSecret;
|
|
583
|
-
return cachedSecret;
|
|
584
|
-
} finally {
|
|
585
|
-
secretGenerationPromise = null;
|
|
586
674
|
}
|
|
587
|
-
|
|
588
|
-
return secretGenerationPromise;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// plugins/plugin-remember/src/remember.crypto.ts
|
|
592
|
-
async function getBaseSecret() {
|
|
593
|
-
const envSecret = process.env["REMEMBER_SECRET"] || process.env["MCP_MEMORY_SECRET"] || process.env["MCP_SESSION_SECRET"];
|
|
594
|
-
if (envSecret) {
|
|
595
|
-
return envSecret;
|
|
675
|
+
this.memory.set(key, entry);
|
|
596
676
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
break;
|
|
609
|
-
case "tool":
|
|
610
|
-
ikm = source.sessionId;
|
|
611
|
-
context = `remember:tool:${source.toolName}:${source.sessionId}`;
|
|
612
|
-
break;
|
|
613
|
-
case "user":
|
|
614
|
-
ikm = await getBaseSecret() + source.userId;
|
|
615
|
-
context = `remember:user:${source.userId}`;
|
|
616
|
-
break;
|
|
617
|
-
case "global":
|
|
618
|
-
ikm = await getBaseSecret();
|
|
619
|
-
context = "remember:global";
|
|
620
|
-
break;
|
|
621
|
-
case "custom":
|
|
622
|
-
ikm = source.key;
|
|
623
|
-
context = `remember:custom:${source.key}`;
|
|
624
|
-
break;
|
|
677
|
+
/**
|
|
678
|
+
* Store a value only if the key is absent.
|
|
679
|
+
*
|
|
680
|
+
* A single-threaded runtime makes the check-then-set indivisible; an expired entry counts as
|
|
681
|
+
* absent, matching what `getValue` would report.
|
|
682
|
+
*/
|
|
683
|
+
async setIfAbsent(key, value, ttlSeconds) {
|
|
684
|
+
const existing = this.memory.get(key);
|
|
685
|
+
if (existing && !this.isExpired(existing)) return false;
|
|
686
|
+
await this.setValue(key, value, ttlSeconds);
|
|
687
|
+
return true;
|
|
625
688
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
689
|
+
/**
|
|
690
|
+
* Retrieve a value by key.
|
|
691
|
+
* JSON-parses the stored value to match the setValue serialization.
|
|
692
|
+
*/
|
|
693
|
+
async getValue(key, defaultValue) {
|
|
694
|
+
const entry = this.memory.get(key);
|
|
695
|
+
if (!entry) return defaultValue;
|
|
696
|
+
if (this.isExpired(entry)) {
|
|
697
|
+
await this.delete(key);
|
|
698
|
+
return defaultValue;
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
return JSON.parse(entry.value);
|
|
702
|
+
} catch {
|
|
703
|
+
return entry.value;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Delete a key.
|
|
708
|
+
*/
|
|
709
|
+
async delete(key) {
|
|
710
|
+
const entry = this.memory.get(key);
|
|
711
|
+
if (entry?.timeout) clearTimeout(entry.timeout);
|
|
712
|
+
this.memory.delete(key);
|
|
644
713
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
data: base64urlEncode2(ciphertext)
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
async function decryptValue(blob, keySource) {
|
|
660
|
-
try {
|
|
661
|
-
const key = await deriveEncryptionKey(keySource);
|
|
662
|
-
const iv = base64urlDecode(blob.iv);
|
|
663
|
-
const tag = base64urlDecode(blob.tag);
|
|
664
|
-
const ciphertext = base64urlDecode(blob.data);
|
|
665
|
-
const decrypted = decryptAesGcm(key, ciphertext, iv, tag);
|
|
666
|
-
return JSON.parse(textDecoder.decode(decrypted));
|
|
667
|
-
} catch {
|
|
668
|
-
return null;
|
|
714
|
+
/**
|
|
715
|
+
* Check if a key exists (and is not expired).
|
|
716
|
+
*/
|
|
717
|
+
async exists(key) {
|
|
718
|
+
const entry = this.memory.get(key);
|
|
719
|
+
if (!entry) return false;
|
|
720
|
+
if (this.isExpired(entry)) {
|
|
721
|
+
await this.delete(key);
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
return true;
|
|
669
725
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
726
|
+
/**
|
|
727
|
+
* List keys matching a glob-style pattern.
|
|
728
|
+
* Supports * and ? wildcards.
|
|
729
|
+
*/
|
|
730
|
+
async keys(pattern) {
|
|
731
|
+
const result = [];
|
|
732
|
+
const regex = pattern ? this.patternToRegex(pattern) : null;
|
|
733
|
+
const expiredKeys = [];
|
|
734
|
+
for (const [key, entry] of this.memory) {
|
|
735
|
+
if (this.isExpired(entry)) {
|
|
736
|
+
expiredKeys.push(key);
|
|
737
|
+
}
|
|
679
738
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
739
|
+
for (const key of expiredKeys) {
|
|
740
|
+
await this.delete(key);
|
|
741
|
+
}
|
|
742
|
+
for (const [key, entry] of this.memory) {
|
|
743
|
+
if (this.isExpired(entry)) {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (regex && !regex.test(key)) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
result.push(key);
|
|
750
|
+
}
|
|
751
|
+
return result;
|
|
683
752
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// plugins/plugin-remember/src/providers/remember-accessor.provider.ts
|
|
696
|
-
var RememberAccessor = class {
|
|
697
|
-
store;
|
|
698
|
-
ctx;
|
|
699
|
-
config;
|
|
700
|
-
keyPrefix;
|
|
701
|
-
encryptionEnabled;
|
|
702
|
-
constructor(store, ctx, config) {
|
|
703
|
-
this.store = store;
|
|
704
|
-
this.ctx = ctx;
|
|
705
|
-
this.config = config;
|
|
706
|
-
this.keyPrefix = config.keyPrefix ?? "remember:";
|
|
707
|
-
this.encryptionEnabled = config.encryption?.enabled !== false;
|
|
753
|
+
/**
|
|
754
|
+
* Gracefully close the provider.
|
|
755
|
+
*/
|
|
756
|
+
async close() {
|
|
757
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
758
|
+
for (const [, entry] of this.memory) {
|
|
759
|
+
if (entry.timeout) clearTimeout(entry.timeout);
|
|
760
|
+
}
|
|
761
|
+
this.memory.clear();
|
|
708
762
|
}
|
|
709
763
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
710
|
-
//
|
|
764
|
+
// Private Methods
|
|
711
765
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
766
|
+
isExpired(entry) {
|
|
767
|
+
return entry.expiresAt !== void 0 && entry.expiresAt <= Date.now();
|
|
768
|
+
}
|
|
712
769
|
/**
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
* @param key - The key to store under
|
|
716
|
-
* @param value - The value to store (any JSON-serializable data)
|
|
717
|
-
* @param options - Storage options (scope, ttl, brand, metadata)
|
|
770
|
+
* Periodically remove expired keys to keep memory tidy.
|
|
718
771
|
*/
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : void 0,
|
|
728
|
-
metadata: options.metadata
|
|
729
|
-
};
|
|
730
|
-
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
731
|
-
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
772
|
+
sweep() {
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
for (const [key, entry] of this.memory) {
|
|
775
|
+
if (entry.expiresAt !== void 0 && entry.expiresAt <= now) {
|
|
776
|
+
if (entry.timeout) clearTimeout(entry.timeout);
|
|
777
|
+
this.memory.delete(key);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
732
780
|
}
|
|
733
781
|
/**
|
|
734
|
-
*
|
|
782
|
+
* Convert a glob-style pattern to a RegExp.
|
|
783
|
+
* * matches any sequence of characters
|
|
784
|
+
* ? matches any single character
|
|
735
785
|
*
|
|
736
|
-
*
|
|
737
|
-
* @param options - Retrieval options (scope, defaultValue)
|
|
738
|
-
* @returns The stored value or defaultValue if not found
|
|
786
|
+
* Includes ReDoS protection: validates pattern length and wildcard count.
|
|
739
787
|
*/
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const raw = await this.store.getValue(storageKey);
|
|
744
|
-
if (!raw) return options.defaultValue;
|
|
745
|
-
const entry = this.encryptionEnabled ? await deserializeAndDecrypt(raw, this.getKeySource(scope)) : this.parseEntry(raw);
|
|
746
|
-
if (!entry) return options.defaultValue;
|
|
747
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
748
|
-
await this.forget(key, { scope });
|
|
749
|
-
return options.defaultValue;
|
|
788
|
+
patternToRegex(pattern) {
|
|
789
|
+
if (pattern.length > RememberMemoryProvider.MAX_PATTERN_LENGTH) {
|
|
790
|
+
throw new Error(`Pattern too long: ${pattern.length} chars (max ${RememberMemoryProvider.MAX_PATTERN_LENGTH})`);
|
|
750
791
|
}
|
|
751
|
-
|
|
792
|
+
const wildcardCount = (pattern.match(/[*?]/g) || []).length;
|
|
793
|
+
if (wildcardCount > RememberMemoryProvider.MAX_WILDCARDS) {
|
|
794
|
+
throw new Error(`Too many wildcards in pattern: ${wildcardCount} (max ${RememberMemoryProvider.MAX_WILDCARDS})`);
|
|
795
|
+
}
|
|
796
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
797
|
+
return new RegExp(`^${escaped}$`);
|
|
752
798
|
}
|
|
799
|
+
};
|
|
800
|
+
// ReDoS protection constants
|
|
801
|
+
// Pattern length allows for long prefixes (e.g., remember:session:{uuid}:user:*)
|
|
802
|
+
__publicField(RememberMemoryProvider, "MAX_PATTERN_LENGTH", 500);
|
|
803
|
+
__publicField(RememberMemoryProvider, "MAX_WILDCARDS", 20);
|
|
804
|
+
RememberMemoryProvider = __decorateClass([
|
|
805
|
+
Provider2({
|
|
806
|
+
name: "provider:remember:memory",
|
|
807
|
+
description: "In-memory storage provider for RememberPlugin",
|
|
808
|
+
scope: ProviderScope2.GLOBAL
|
|
809
|
+
})
|
|
810
|
+
], RememberMemoryProvider);
|
|
811
|
+
|
|
812
|
+
// plugins/plugin-remember/src/providers/remember-redis.provider.ts
|
|
813
|
+
import Redis from "ioredis";
|
|
814
|
+
import { Provider as Provider3, ProviderScope as ProviderScope3 } from "@frontmcp/sdk";
|
|
815
|
+
var RememberRedisProvider = class {
|
|
816
|
+
client;
|
|
753
817
|
/**
|
|
754
|
-
*
|
|
818
|
+
* Prefix prepended to all Redis keys.
|
|
819
|
+
* Include any separator (e.g., "myapp:" or "user:123:") as part of the prefix.
|
|
820
|
+
*/
|
|
821
|
+
keyPrefix;
|
|
822
|
+
/** True if this provider created the client (and should close it), false if externally provided */
|
|
823
|
+
ownsClient;
|
|
824
|
+
constructor(options) {
|
|
825
|
+
this.keyPrefix = options.keyPrefix ?? "";
|
|
826
|
+
if (options.type === "redis-client") {
|
|
827
|
+
this.client = options.client;
|
|
828
|
+
this.ownsClient = false;
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
this.ownsClient = true;
|
|
832
|
+
this.client = new Redis({
|
|
833
|
+
lazyConnect: false,
|
|
834
|
+
maxRetriesPerRequest: 3,
|
|
835
|
+
...options.config
|
|
836
|
+
});
|
|
837
|
+
this.client.on("connect", () => {
|
|
838
|
+
if (process.env["DEBUG"] === "true") {
|
|
839
|
+
console.log("[RememberPlugin:Redis] Connected");
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
this.client.on("error", (err) => {
|
|
843
|
+
console.error("[RememberPlugin:Redis] Error:", err.message);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Store a value with optional TTL.
|
|
755
848
|
*
|
|
756
|
-
* @param key - The key to
|
|
757
|
-
* @param
|
|
758
|
-
* @
|
|
849
|
+
* @param key - The key to store under
|
|
850
|
+
* @param value - The value to store (must not be undefined)
|
|
851
|
+
* @param ttlSeconds - Optional TTL in seconds (must be positive integer if provided)
|
|
852
|
+
* @throws Error if value is undefined or ttlSeconds is invalid
|
|
759
853
|
*/
|
|
760
|
-
async
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
854
|
+
async setValue(key, value, ttlSeconds) {
|
|
855
|
+
if (value === void 0) {
|
|
856
|
+
throw new Error("Cannot store undefined value. Use null or delete the key instead.");
|
|
857
|
+
}
|
|
858
|
+
if (ttlSeconds !== void 0) {
|
|
859
|
+
if (typeof ttlSeconds !== "number" || !Number.isFinite(ttlSeconds)) {
|
|
860
|
+
throw new Error(`Invalid TTL: expected a number, got ${typeof ttlSeconds}`);
|
|
861
|
+
}
|
|
862
|
+
if (ttlSeconds <= 0) {
|
|
863
|
+
throw new Error(`Invalid TTL: must be positive, got ${ttlSeconds}`);
|
|
864
|
+
}
|
|
865
|
+
if (!Number.isInteger(ttlSeconds)) {
|
|
866
|
+
throw new Error(`Invalid TTL: must be an integer, got ${ttlSeconds}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const fullKey = this.keyPrefix + key;
|
|
870
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
871
|
+
if (ttlSeconds !== void 0 && ttlSeconds > 0) {
|
|
872
|
+
await this.client.set(fullKey, strValue, "EX", ttlSeconds);
|
|
873
|
+
} else {
|
|
874
|
+
await this.client.set(fullKey, strValue);
|
|
770
875
|
}
|
|
771
|
-
return entry;
|
|
772
876
|
}
|
|
773
877
|
/**
|
|
774
|
-
*
|
|
878
|
+
* Store a value only if the key is absent, via Redis `SET ... NX`.
|
|
775
879
|
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
880
|
+
* `set` resolves to `'OK'` when it created the key and `null` when the key already existed,
|
|
881
|
+
* so the whole check-and-write is one round trip and two callers racing cannot both win.
|
|
778
882
|
*/
|
|
779
|
-
async
|
|
780
|
-
const
|
|
781
|
-
const
|
|
782
|
-
await this.
|
|
883
|
+
async setIfAbsent(key, value, ttlSeconds) {
|
|
884
|
+
const fullKey = this.keyPrefix + key;
|
|
885
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
886
|
+
const result = ttlSeconds !== void 0 && ttlSeconds > 0 ? await this.client.set(fullKey, strValue, "EX", ttlSeconds, "NX") : await this.client.set(fullKey, strValue, "NX");
|
|
887
|
+
return result === "OK";
|
|
783
888
|
}
|
|
784
889
|
/**
|
|
785
|
-
*
|
|
890
|
+
* Retrieve a value by key.
|
|
786
891
|
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
892
|
+
* Returns the parsed JSON value if successful, or `defaultValue` if:
|
|
893
|
+
* - The key does not exist
|
|
894
|
+
* - The stored value is not valid JSON (malformed or legacy data)
|
|
895
|
+
*
|
|
896
|
+
* @param key - The key to retrieve
|
|
897
|
+
* @param defaultValue - Value to return if key doesn't exist or parsing fails
|
|
898
|
+
* @returns The parsed value as T, or defaultValue/undefined
|
|
790
899
|
*/
|
|
791
|
-
async
|
|
792
|
-
const
|
|
793
|
-
const
|
|
794
|
-
|
|
900
|
+
async getValue(key, defaultValue) {
|
|
901
|
+
const fullKey = this.keyPrefix + key;
|
|
902
|
+
const raw = await this.client.get(fullKey);
|
|
903
|
+
if (raw === null) return defaultValue;
|
|
904
|
+
try {
|
|
905
|
+
return JSON.parse(raw);
|
|
906
|
+
} catch {
|
|
907
|
+
return defaultValue;
|
|
908
|
+
}
|
|
795
909
|
}
|
|
796
910
|
/**
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
* @param options - Options (scope, pattern)
|
|
800
|
-
* @returns Array of keys (without the scope prefix)
|
|
911
|
+
* Delete a key.
|
|
801
912
|
*/
|
|
802
|
-
async
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
const fullPattern = scopePrefix + (options.pattern ?? "*");
|
|
806
|
-
const keys = await this.store.keys(fullPattern);
|
|
807
|
-
return keys.map((k) => k.slice(scopePrefix.length));
|
|
913
|
+
async delete(key) {
|
|
914
|
+
const fullKey = this.keyPrefix + key;
|
|
915
|
+
await this.client.del(fullKey);
|
|
808
916
|
}
|
|
809
917
|
/**
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
* @param key - The key to update
|
|
813
|
-
* @param value - The new value
|
|
814
|
-
* @param options - Options (scope, ttl)
|
|
918
|
+
* Check if a key exists.
|
|
815
919
|
*/
|
|
816
|
-
async
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
if (!existing) return false;
|
|
820
|
-
const entry = {
|
|
821
|
-
...existing,
|
|
822
|
-
value,
|
|
823
|
-
updatedAt: Date.now(),
|
|
824
|
-
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : existing.expiresAt
|
|
825
|
-
};
|
|
826
|
-
const storageKey = this.buildStorageKey(key, scope);
|
|
827
|
-
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
828
|
-
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
829
|
-
return true;
|
|
920
|
+
async exists(key) {
|
|
921
|
+
const fullKey = this.keyPrefix + key;
|
|
922
|
+
return await this.client.exists(fullKey) === 1;
|
|
830
923
|
}
|
|
831
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
832
|
-
// Convenience Methods
|
|
833
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
834
924
|
/**
|
|
835
|
-
*
|
|
925
|
+
* List keys matching a pattern.
|
|
926
|
+
* Uses Redis SCAN for efficient iteration.
|
|
836
927
|
*/
|
|
837
|
-
|
|
838
|
-
|
|
928
|
+
async keys(pattern) {
|
|
929
|
+
const searchPattern = this.keyPrefix + (pattern ?? "*");
|
|
930
|
+
const result = [];
|
|
931
|
+
let cursor = "0";
|
|
932
|
+
do {
|
|
933
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", searchPattern, "COUNT", 100);
|
|
934
|
+
cursor = nextCursor;
|
|
935
|
+
for (const key of keys) {
|
|
936
|
+
result.push(key.slice(this.keyPrefix.length));
|
|
937
|
+
}
|
|
938
|
+
} while (cursor !== "0");
|
|
939
|
+
return result;
|
|
839
940
|
}
|
|
840
941
|
/**
|
|
841
|
-
*
|
|
942
|
+
* Gracefully close the Redis connection.
|
|
943
|
+
* Only closes if this provider owns the client (created it internally).
|
|
944
|
+
* Externally-provided clients are left open for the caller to manage.
|
|
842
945
|
*/
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
946
|
+
async close() {
|
|
947
|
+
if (this.ownsClient) {
|
|
948
|
+
await this.client.quit();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
RememberRedisProvider = __decorateClass([
|
|
953
|
+
Provider3({
|
|
954
|
+
name: "provider:remember:redis",
|
|
955
|
+
description: "Redis-based storage provider for RememberPlugin",
|
|
956
|
+
scope: ProviderScope3.GLOBAL
|
|
957
|
+
})
|
|
958
|
+
], RememberRedisProvider);
|
|
959
|
+
|
|
960
|
+
// plugins/plugin-remember/src/providers/remember-vercel-kv.provider.ts
|
|
961
|
+
import { Provider as Provider4, ProviderScope as ProviderScope4 } from "@frontmcp/sdk";
|
|
962
|
+
var RememberVercelKvProvider = class {
|
|
963
|
+
kv;
|
|
964
|
+
keyPrefix;
|
|
965
|
+
defaultTTL;
|
|
966
|
+
constructor(options = {}) {
|
|
967
|
+
const vercelKv = __require("@vercel/kv");
|
|
968
|
+
const hasUrl = options.url !== void 0;
|
|
969
|
+
const hasToken = options.token !== void 0;
|
|
970
|
+
if (hasUrl !== hasToken) {
|
|
971
|
+
throw new Error(
|
|
972
|
+
`RememberVercelKvProvider: Both 'url' and 'token' must be provided together, or neither. Received: url=${hasUrl ? "provided" : "missing"}, token=${hasToken ? "provided" : "missing"}`
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (options.url && options.token) {
|
|
976
|
+
this.kv = vercelKv.createClient({
|
|
977
|
+
url: options.url,
|
|
978
|
+
token: options.token
|
|
979
|
+
});
|
|
980
|
+
} else {
|
|
981
|
+
this.kv = vercelKv.kv;
|
|
982
|
+
}
|
|
983
|
+
this.keyPrefix = options.keyPrefix ?? "remember:";
|
|
984
|
+
this.defaultTTL = options.defaultTTL;
|
|
985
|
+
}
|
|
986
|
+
prefixKey(key) {
|
|
987
|
+
return `${this.keyPrefix}${key}`;
|
|
846
988
|
}
|
|
847
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
848
|
-
// Private Methods
|
|
849
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
850
989
|
/**
|
|
851
|
-
*
|
|
990
|
+
* Store a value with optional TTL.
|
|
852
991
|
*/
|
|
853
|
-
|
|
854
|
-
|
|
992
|
+
async setValue(key, value, ttlSeconds) {
|
|
993
|
+
const fullKey = this.prefixKey(key);
|
|
994
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
995
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
996
|
+
if (ttl && ttl > 0) {
|
|
997
|
+
await this.kv.set(fullKey, strValue, { ex: ttl });
|
|
998
|
+
} else {
|
|
999
|
+
await this.kv.set(fullKey, strValue);
|
|
1000
|
+
}
|
|
855
1001
|
}
|
|
856
1002
|
/**
|
|
857
|
-
*
|
|
1003
|
+
* Store a value only if the key is absent, via the `nx` option.
|
|
1004
|
+
*
|
|
1005
|
+
* Resolves to `'OK'` when it created the key and `null` when the key already existed, so two
|
|
1006
|
+
* callers racing cannot both win.
|
|
858
1007
|
*/
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1008
|
+
async setIfAbsent(key, value, ttlSeconds) {
|
|
1009
|
+
const fullKey = this.prefixKey(key);
|
|
1010
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
1011
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
1012
|
+
const result = ttl && ttl > 0 ? await this.kv.set(fullKey, strValue, { nx: true, ex: ttl }) : await this.kv.set(fullKey, strValue, { nx: true });
|
|
1013
|
+
return result === "OK";
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Retrieve a value by key.
|
|
1017
|
+
*/
|
|
1018
|
+
async getValue(key, defaultValue) {
|
|
1019
|
+
const fullKey = this.prefixKey(key);
|
|
1020
|
+
const raw = await this.kv.get(fullKey);
|
|
1021
|
+
if (raw === null || raw === void 0) return defaultValue;
|
|
1022
|
+
if (typeof raw === "string") {
|
|
1023
|
+
try {
|
|
1024
|
+
return JSON.parse(raw);
|
|
1025
|
+
} catch {
|
|
1026
|
+
return raw;
|
|
868
1027
|
}
|
|
869
|
-
case "global":
|
|
870
|
-
return `${this.keyPrefix}global:`;
|
|
871
1028
|
}
|
|
1029
|
+
return raw;
|
|
872
1030
|
}
|
|
873
1031
|
/**
|
|
874
|
-
*
|
|
1032
|
+
* Delete a key.
|
|
875
1033
|
*/
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
userId: this.userId,
|
|
880
|
-
toolName: this.ctx.flow?.name
|
|
881
|
-
});
|
|
1034
|
+
async delete(key) {
|
|
1035
|
+
const fullKey = this.prefixKey(key);
|
|
1036
|
+
await this.kv.del(fullKey);
|
|
882
1037
|
}
|
|
883
1038
|
/**
|
|
884
|
-
*
|
|
1039
|
+
* Check if a key exists.
|
|
885
1040
|
*/
|
|
886
|
-
|
|
1041
|
+
async exists(key) {
|
|
1042
|
+
const fullKey = this.prefixKey(key);
|
|
1043
|
+
return await this.kv.exists(fullKey) === 1;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* List keys matching a pattern.
|
|
1047
|
+
* Uses SCAN for efficient iteration.
|
|
1048
|
+
*/
|
|
1049
|
+
async keys(pattern) {
|
|
1050
|
+
const searchPattern = this.prefixKey(pattern ?? "*");
|
|
1051
|
+
const result = [];
|
|
887
1052
|
try {
|
|
888
|
-
|
|
1053
|
+
let cursor = 0;
|
|
1054
|
+
do {
|
|
1055
|
+
const [nextCursor, keys] = await this.kv.scan(cursor, {
|
|
1056
|
+
match: searchPattern,
|
|
1057
|
+
count: 100
|
|
1058
|
+
});
|
|
1059
|
+
cursor = nextCursor;
|
|
1060
|
+
for (const key of keys) {
|
|
1061
|
+
result.push(key.slice(this.keyPrefix.length));
|
|
1062
|
+
}
|
|
1063
|
+
} while (cursor !== 0);
|
|
889
1064
|
} catch {
|
|
890
|
-
|
|
1065
|
+
try {
|
|
1066
|
+
const keys = await this.kv.keys(searchPattern);
|
|
1067
|
+
for (const key of keys) {
|
|
1068
|
+
result.push(key.slice(this.keyPrefix.length));
|
|
1069
|
+
}
|
|
1070
|
+
} catch {
|
|
1071
|
+
console.warn("[RememberPlugin:VercelKV] keys() operation not supported");
|
|
1072
|
+
}
|
|
891
1073
|
}
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Gracefully close the provider.
|
|
1078
|
+
* No-op for Vercel KV as it uses stateless REST API.
|
|
1079
|
+
*/
|
|
1080
|
+
async close() {
|
|
892
1081
|
}
|
|
893
1082
|
};
|
|
894
|
-
|
|
1083
|
+
RememberVercelKvProvider = __decorateClass([
|
|
895
1084
|
Provider4({
|
|
896
|
-
name: "provider:remember:
|
|
897
|
-
description: "
|
|
898
|
-
scope: ProviderScope4.
|
|
1085
|
+
name: "provider:remember:vercel-kv",
|
|
1086
|
+
description: "Vercel KV-based storage provider for RememberPlugin",
|
|
1087
|
+
scope: ProviderScope4.GLOBAL
|
|
899
1088
|
})
|
|
900
|
-
],
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1089
|
+
], RememberVercelKvProvider);
|
|
1090
|
+
|
|
1091
|
+
// plugins/plugin-remember/src/remember.symbols.ts
|
|
1092
|
+
var RememberStoreToken = /* @__PURE__ */ Symbol(
|
|
1093
|
+
"plugin:remember:store"
|
|
1094
|
+
);
|
|
1095
|
+
var RememberConfigToken = /* @__PURE__ */ Symbol(
|
|
1096
|
+
"plugin:remember:config"
|
|
1097
|
+
);
|
|
1098
|
+
var RememberAccessorToken = /* @__PURE__ */ Symbol(
|
|
1099
|
+
"plugin:remember:accessor"
|
|
1100
|
+
);
|
|
904
1101
|
|
|
905
1102
|
// plugins/plugin-remember/src/remember.plugin.ts
|
|
906
1103
|
var RememberPlugin = class extends DynamicPlugin {
|
|
@@ -1015,8 +1212,8 @@ __publicField(RememberPlugin, "dynamicProviders", (options) => {
|
|
|
1015
1212
|
name: "remember:accessor",
|
|
1016
1213
|
provide: RememberAccessorToken,
|
|
1017
1214
|
scope: ProviderScope5.CONTEXT,
|
|
1018
|
-
inject: () => [RememberStoreToken,
|
|
1019
|
-
useFactory: (store, ctx, cfg) => createRememberAccessor(store, ctx, cfg)
|
|
1215
|
+
inject: () => [RememberStoreToken, FRONTMCP_CONTEXT, RememberConfigToken, FrontMcpLogger],
|
|
1216
|
+
useFactory: (store, ctx, cfg, logger) => createRememberAccessor(store, ctx, cfg, logger)
|
|
1020
1217
|
});
|
|
1021
1218
|
return providers;
|
|
1022
1219
|
});
|
|
@@ -1276,6 +1473,7 @@ export {
|
|
|
1276
1473
|
RememberAccessor,
|
|
1277
1474
|
RememberAccessorToken,
|
|
1278
1475
|
RememberConfigToken,
|
|
1476
|
+
RememberIdentityError,
|
|
1279
1477
|
RememberMemoryProvider,
|
|
1280
1478
|
RememberPlugin,
|
|
1281
1479
|
RememberRedisProvider,
|