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