@echomem/mcp 1.3.0 → 1.3.2
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/README.md +2 -0
- package/dist/codex-sync.js +469 -0
- package/dist/encryption.js +3 -1
- package/dist/forensics.js +717 -0
- package/dist/index.js +461 -9
- package/dist/migrate.js +950 -125
- package/dist/report.js +154 -7
- package/dist/setup-page.js +1120 -0
- package/dist/setup.js +999 -76
- package/dist/v1-contract.js +68 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -4,11 +4,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import axios from "axios";
|
|
6
6
|
import { ZodError } from "zod";
|
|
7
|
-
import { canonicalToolNames, keywordsSchema, listToolSpecs, othersSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, timeRangeSchema, } from "./v1-contract.js";
|
|
7
|
+
import { canonicalToolNames, deleteMemorySchema, keywordsSchema, listToolSpecs, othersSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, timeRangeSchema, } from "./v1-contract.js";
|
|
8
8
|
import { KeyStore } from "./keystore.js";
|
|
9
9
|
import { EventLogger, hashText } from "./events.js";
|
|
10
10
|
import { buildReportText } from "./report.js";
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
12
12
|
import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
|
|
13
13
|
import { runCli } from "./setup.js";
|
|
14
14
|
const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
|
|
@@ -88,6 +88,10 @@ function classifyError(error) {
|
|
|
88
88
|
return "invalid_args";
|
|
89
89
|
return "api_error";
|
|
90
90
|
}
|
|
91
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>"')\]]+/i;
|
|
92
|
+
const SECRET_LIKE_RE = /(sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]+|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----|\b(api[_-]?key|token|secret|password)\s*[:=])/i;
|
|
93
|
+
const CODE_LIKE_RE = /(```|^\s*(import|export|const|let|var|function|class|interface|type)\s|\b(def|async|await)\s+\w+)/m;
|
|
94
|
+
const DELETE_CONFIRMATION_TTL_MS = 15 * 60 * 1000;
|
|
91
95
|
function isRecord(value) {
|
|
92
96
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
97
|
}
|
|
@@ -99,6 +103,174 @@ function readNumber(record, key) {
|
|
|
99
103
|
const value = record[key];
|
|
100
104
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
101
105
|
}
|
|
106
|
+
function compactOneLine(value, maxLength) {
|
|
107
|
+
if (!value)
|
|
108
|
+
return undefined;
|
|
109
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
110
|
+
if (!compact)
|
|
111
|
+
return undefined;
|
|
112
|
+
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
|
|
113
|
+
}
|
|
114
|
+
function memoryDeleteFingerprint(memory) {
|
|
115
|
+
return [
|
|
116
|
+
readString(memory, "id"),
|
|
117
|
+
readString(memory, "updated_at"),
|
|
118
|
+
readString(memory, "created_at"),
|
|
119
|
+
readString(memory, "time"),
|
|
120
|
+
readString(memory, "keys") ?? readString(memory, "key"),
|
|
121
|
+
].filter(Boolean).join("|");
|
|
122
|
+
}
|
|
123
|
+
function formatDeletePreview(memory) {
|
|
124
|
+
const lines = [
|
|
125
|
+
`Memory ID: ${readString(memory, "id") ?? "(unknown)"}`,
|
|
126
|
+
readString(memory, "keys") || readString(memory, "key")
|
|
127
|
+
? `Keys: ${readString(memory, "keys") ?? readString(memory, "key")}`
|
|
128
|
+
: "",
|
|
129
|
+
readString(memory, "time") ? `Time: ${readString(memory, "time")}` : "",
|
|
130
|
+
readString(memory, "location") ? `Location: ${readString(memory, "location")}` : "",
|
|
131
|
+
readString(memory, "category") ? `Category: ${readString(memory, "category")}` : "",
|
|
132
|
+
readString(memory, "object") ? `Object: ${readString(memory, "object")}` : "",
|
|
133
|
+
readString(memory, "emotion") ? `Emotion: ${readString(memory, "emotion")}` : "",
|
|
134
|
+
compactOneLine(readString(memory, "description"), 320)
|
|
135
|
+
? `Description: ${compactOneLine(readString(memory, "description"), 320)}`
|
|
136
|
+
: "",
|
|
137
|
+
compactOneLine(readString(memory, "details"), 240)
|
|
138
|
+
? `Details: ${compactOneLine(readString(memory, "details"), 240)}`
|
|
139
|
+
: "",
|
|
140
|
+
].filter(Boolean);
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
function redactAnalyticsText(text) {
|
|
144
|
+
return text
|
|
145
|
+
.replace(URL_RE, "[url]")
|
|
146
|
+
.replace(SECRET_LIKE_RE, "[secret-like-text]")
|
|
147
|
+
.replace(/\s+/g, " ")
|
|
148
|
+
.trim();
|
|
149
|
+
}
|
|
150
|
+
function safeTextAnalytics(prefix, text, previewChars = 300) {
|
|
151
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
[`${prefix}_available`]: false,
|
|
154
|
+
[`${prefix}_length`]: 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
[`${prefix}_available`]: true,
|
|
159
|
+
[`${prefix}_preview`]: redactAnalyticsText(text).slice(0, previewChars),
|
|
160
|
+
[`${prefix}_length`]: text.length,
|
|
161
|
+
[`${prefix}_hash`]: `sha256:${createHash("sha256").update(text).digest("hex")}`,
|
|
162
|
+
[`${prefix}_contains_url`]: URL_RE.test(text),
|
|
163
|
+
[`${prefix}_contains_code`]: CODE_LIKE_RE.test(text),
|
|
164
|
+
[`${prefix}_contains_secret_like_text`]: SECRET_LIKE_RE.test(text),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function hashAnalyticsText(text) {
|
|
168
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
169
|
+
}
|
|
170
|
+
function normalizeMcpHostPlatform(value) {
|
|
171
|
+
if (!value)
|
|
172
|
+
return undefined;
|
|
173
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
174
|
+
if (normalized.includes("cursor"))
|
|
175
|
+
return "cursor";
|
|
176
|
+
if (normalized.includes("windsurf"))
|
|
177
|
+
return "windsurf";
|
|
178
|
+
if (normalized.includes("codex"))
|
|
179
|
+
return "codex";
|
|
180
|
+
if (normalized.includes("claude_code") || (normalized.includes("claude") && normalized.includes("code"))) {
|
|
181
|
+
return "claude_code";
|
|
182
|
+
}
|
|
183
|
+
if (normalized.includes("claude_desktop"))
|
|
184
|
+
return "claude_desktop";
|
|
185
|
+
if (normalized.includes("claude"))
|
|
186
|
+
return "claude";
|
|
187
|
+
if (normalized.includes("vscode") || normalized.includes("visual_studio_code"))
|
|
188
|
+
return "vscode";
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
function detectMcpHostFromEnv() {
|
|
192
|
+
if (process.env.CURSOR_EXTENSION_HOST_ROLE || process.env.CURSOR_WORKSPACE_LABEL)
|
|
193
|
+
return "cursor";
|
|
194
|
+
if (process.env.WINDSURF_WORKSPACE_ID || process.env.WINDSURF_USER_ID)
|
|
195
|
+
return "windsurf";
|
|
196
|
+
if (process.env.CLAUDE_CODE || process.env.CLAUDECODE || process.env.ANTHROPIC_CLAUDE_CODE) {
|
|
197
|
+
return "claude_code";
|
|
198
|
+
}
|
|
199
|
+
if (process.env.CODEX_SANDBOX || process.env.CODEX_HOME)
|
|
200
|
+
return "codex";
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
function isUserRole(role) {
|
|
204
|
+
return typeof role === "string" && ["user", "human"].includes(role.toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
function conversationAnalyticsFromMessages(value) {
|
|
207
|
+
if (!Array.isArray(value))
|
|
208
|
+
return {};
|
|
209
|
+
const records = value.filter(isRecord);
|
|
210
|
+
const userMessages = records
|
|
211
|
+
.filter((item) => isUserRole(item.role) && readString(item, "content"))
|
|
212
|
+
.map((item) => readString(item, "content") ?? "");
|
|
213
|
+
const assistantMessages = records
|
|
214
|
+
.filter((item) => typeof item.role === "string" && ["assistant", "ai"].includes(item.role.toLowerCase()))
|
|
215
|
+
.map((item) => readString(item, "content") ?? "");
|
|
216
|
+
const conversationText = records
|
|
217
|
+
.map((item) => `${readString(item, "role") ?? "unknown"}: ${typeof item.content === "string" ? item.content : ""}`)
|
|
218
|
+
.join("\n\n");
|
|
219
|
+
return {
|
|
220
|
+
conversation_length: conversationText.length,
|
|
221
|
+
conversation_hash: conversationText ? hashAnalyticsText(conversationText) : undefined,
|
|
222
|
+
conversation_message_count: value.length,
|
|
223
|
+
user_message_count: userMessages.length,
|
|
224
|
+
assistant_message_count: assistantMessages.length,
|
|
225
|
+
...safeTextAnalytics("first_user_message", userMessages[0]),
|
|
226
|
+
...safeTextAnalytics("last_user_message", userMessages.at(-1)),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function lastUserMessageFromMessages(value) {
|
|
230
|
+
if (!Array.isArray(value))
|
|
231
|
+
return undefined;
|
|
232
|
+
for (let i = value.length - 1; i >= 0; i--) {
|
|
233
|
+
const item = value[i];
|
|
234
|
+
if (!isRecord(item) || !isUserRole(item.role))
|
|
235
|
+
continue;
|
|
236
|
+
const content = readString(item, "content");
|
|
237
|
+
if (content)
|
|
238
|
+
return content;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
function lastUserMessageFromConversationText(value) {
|
|
243
|
+
if (typeof value !== "string" || !value)
|
|
244
|
+
return undefined;
|
|
245
|
+
const markdownUserTurns = [...value.matchAll(/(?:^|\n)##\s+(?:User|Human)[^\n]*\n([\s\S]*?)(?=\n---\n|\n##\s+(?:User|Human|Assistant|AI|System)\b|$)/gi)];
|
|
246
|
+
const lastMarkdownTurn = markdownUserTurns.at(-1)?.[1]?.trim();
|
|
247
|
+
if (lastMarkdownTurn)
|
|
248
|
+
return lastMarkdownTurn;
|
|
249
|
+
const roleLines = value.split(/\n/);
|
|
250
|
+
let currentRole = null;
|
|
251
|
+
let current = [];
|
|
252
|
+
let lastUser = "";
|
|
253
|
+
const flush = () => {
|
|
254
|
+
if (currentRole && isUserRole(currentRole)) {
|
|
255
|
+
const content = current.join("\n").trim();
|
|
256
|
+
if (content)
|
|
257
|
+
lastUser = content;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
for (const line of roleLines) {
|
|
261
|
+
const match = line.match(/^\s*(user|human|assistant|ai|system)\s*:\s*(.*)$/i);
|
|
262
|
+
if (match) {
|
|
263
|
+
flush();
|
|
264
|
+
currentRole = match[1];
|
|
265
|
+
current = [match[2] ?? ""];
|
|
266
|
+
}
|
|
267
|
+
else if (currentRole) {
|
|
268
|
+
current.push(line);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
flush();
|
|
272
|
+
return lastUser || undefined;
|
|
273
|
+
}
|
|
102
274
|
function normalizeRetrievalCandidate(value, fallbackRank) {
|
|
103
275
|
if (!isRecord(value))
|
|
104
276
|
return null;
|
|
@@ -128,6 +300,98 @@ function withinTimeFrame(memory, candidate, timeFrameDays) {
|
|
|
128
300
|
return true;
|
|
129
301
|
return ms >= Date.now() - timeFrameDays * 24 * 60 * 60 * 1000;
|
|
130
302
|
}
|
|
303
|
+
function numberArg(args, key) {
|
|
304
|
+
return typeof args[key] === "number" && Number.isFinite(args[key]) ? args[key] : undefined;
|
|
305
|
+
}
|
|
306
|
+
function triggerAnalyticsForTool(canonicalName, args) {
|
|
307
|
+
const a = isRecord(args) ? args : {};
|
|
308
|
+
const explicitTrigger = readString(a, "triggerMessage");
|
|
309
|
+
let triggerText = explicitTrigger;
|
|
310
|
+
let triggerSource = explicitTrigger ? "tool_argument" : "not_provided_by_mcp_client";
|
|
311
|
+
if (!triggerText && canonicalName === canonicalToolNames.save) {
|
|
312
|
+
triggerText =
|
|
313
|
+
lastUserMessageFromMessages(a.messages) ??
|
|
314
|
+
lastUserMessageFromConversationText(a.conversation);
|
|
315
|
+
triggerSource = triggerText ? "conversation_payload_inferred" : triggerSource;
|
|
316
|
+
}
|
|
317
|
+
if (!triggerText) {
|
|
318
|
+
return {
|
|
319
|
+
trigger_message_available: false,
|
|
320
|
+
trigger_message_source: triggerSource,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
trigger_message_source: triggerSource,
|
|
325
|
+
trigger_message_role: readString(a, "triggerMessageRole") ?? "user",
|
|
326
|
+
...safeTextAnalytics("trigger_message", triggerText),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function inputAnalyticsForTool(canonicalName, args) {
|
|
330
|
+
const a = isRecord(args) ? args : {};
|
|
331
|
+
switch (canonicalName) {
|
|
332
|
+
case canonicalToolNames.search: {
|
|
333
|
+
const query = readString(a, "query");
|
|
334
|
+
const queryAnalytics = safeTextAnalytics("query", query);
|
|
335
|
+
return {
|
|
336
|
+
...queryAnalytics,
|
|
337
|
+
query_preview_safe: queryAnalytics.query_preview,
|
|
338
|
+
query_length: query?.length ?? 0,
|
|
339
|
+
limit: numberArg(a, "limit") ?? numberArg(a, "k"),
|
|
340
|
+
threshold: numberArg(a, "threshold"),
|
|
341
|
+
time_frame_days: numberArg(a, "timeFrameDays"),
|
|
342
|
+
include_answer: typeof a.includeAnswer === "boolean" ? a.includeAnswer : undefined,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
case canonicalToolNames.save: {
|
|
346
|
+
const messages = Array.isArray(a.messages) ? a.messages : undefined;
|
|
347
|
+
const conversation = typeof a.conversation === "string" ? a.conversation : undefined;
|
|
348
|
+
return {
|
|
349
|
+
input_mode: messages?.length ? "messages" : "conversation",
|
|
350
|
+
message_count: messages?.length,
|
|
351
|
+
content_length: messages?.reduce((sum, item) => {
|
|
352
|
+
if (!isRecord(item))
|
|
353
|
+
return sum;
|
|
354
|
+
return sum + String(item.content ?? "").length;
|
|
355
|
+
}, 0) ?? conversation?.length ?? 0,
|
|
356
|
+
...(messages?.length
|
|
357
|
+
? conversationAnalyticsFromMessages(messages)
|
|
358
|
+
: {
|
|
359
|
+
conversation_length: conversation?.length ?? 0,
|
|
360
|
+
conversation_hash: conversation ? hashAnalyticsText(conversation) : undefined,
|
|
361
|
+
}),
|
|
362
|
+
has_title: !!readString(a, "title"),
|
|
363
|
+
has_url: !!readString(a, "url"),
|
|
364
|
+
has_source: !!readString(a, "source"),
|
|
365
|
+
tag_count: Array.isArray(a.tags) ? a.tags.length : undefined,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
case canonicalToolNames.timeRange:
|
|
369
|
+
return {
|
|
370
|
+
limit: numberArg(a, "limit"),
|
|
371
|
+
has_start_date: !!readString(a, "startDate"),
|
|
372
|
+
has_end_date: !!readString(a, "endDate"),
|
|
373
|
+
};
|
|
374
|
+
case canonicalToolNames.keywords:
|
|
375
|
+
return {
|
|
376
|
+
keyword_count: Array.isArray(a.keywords) ? a.keywords.length : 0,
|
|
377
|
+
limit: numberArg(a, "limit"),
|
|
378
|
+
};
|
|
379
|
+
case canonicalToolNames.others: {
|
|
380
|
+
const query = readString(a, "query");
|
|
381
|
+
const queryAnalytics = safeTextAnalytics("query", query);
|
|
382
|
+
return {
|
|
383
|
+
...queryAnalytics,
|
|
384
|
+
query_preview_safe: queryAnalytics.query_preview,
|
|
385
|
+
query_length: query?.length ?? 0,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
default:
|
|
389
|
+
return {};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function toolEventName(canonicalName, status) {
|
|
393
|
+
return `[MCP] ${canonicalName} ${status}`;
|
|
394
|
+
}
|
|
131
395
|
class EchoMemApiClient {
|
|
132
396
|
store;
|
|
133
397
|
axios;
|
|
@@ -135,6 +399,7 @@ class EchoMemApiClient {
|
|
|
135
399
|
/** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
|
|
136
400
|
sessionId = randomUUID();
|
|
137
401
|
encConfigPromise = null;
|
|
402
|
+
deleteConfirmations = new Map();
|
|
138
403
|
constructor(store) {
|
|
139
404
|
this.store = store;
|
|
140
405
|
this.axios = axios.create({
|
|
@@ -160,6 +425,16 @@ class EchoMemApiClient {
|
|
|
160
425
|
getSessionId() {
|
|
161
426
|
return this.sessionId;
|
|
162
427
|
}
|
|
428
|
+
async trackMcpAnalyticsEvent(eventType, eventProperties) {
|
|
429
|
+
if (!this.hasToken())
|
|
430
|
+
return;
|
|
431
|
+
try {
|
|
432
|
+
await this.axios.post("/api/extension/mcp/events", { eventType, eventProperties }, { timeout: 1500 });
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Remote analytics is best-effort and must never change MCP behavior.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
163
438
|
/**
|
|
164
439
|
* Compact topic map of the user's memory for the search-tool description — a cheap "what's in here"
|
|
165
440
|
* index built from memory keys so the agent knows the boundary up front and recalls proactively.
|
|
@@ -211,10 +486,17 @@ class EchoMemApiClient {
|
|
|
211
486
|
throw new LockedError(this.store.isKeyExpired());
|
|
212
487
|
return { enabled: false };
|
|
213
488
|
}
|
|
489
|
+
pruneDeleteConfirmations(now = Date.now()) {
|
|
490
|
+
for (const [memoryId, confirmation] of this.deleteConfirmations) {
|
|
491
|
+
if (confirmation.expiresAtMs <= now) {
|
|
492
|
+
this.deleteConfirmations.delete(memoryId);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
214
496
|
async whoami() {
|
|
215
497
|
if (!this.whoamiCache) {
|
|
216
498
|
this.whoamiCache = this.axios
|
|
217
|
-
.get("/api/openclaw/v1/whoami")
|
|
499
|
+
.get("/api/openclaw/v1/whoami", { timeout: 6000 })
|
|
218
500
|
.then((response) => response.data)
|
|
219
501
|
.catch((error) => {
|
|
220
502
|
this.whoamiCache = null; // don't pin a token-less failure; retry once a token exists
|
|
@@ -408,9 +690,57 @@ class EchoMemApiClient {
|
|
|
408
690
|
title: parsed.title,
|
|
409
691
|
// Stable per-session id so multiple saves in this coding session group under one context.
|
|
410
692
|
conversationKey: this.sessionId,
|
|
693
|
+
triggerMessage: parsed.triggerMessage ||
|
|
694
|
+
lastUserMessageFromMessages(parsed.messages) ||
|
|
695
|
+
lastUserMessageFromConversationText(parsed.conversation),
|
|
696
|
+
triggerMessageRole: parsed.triggerMessageRole || "user",
|
|
411
697
|
}, config);
|
|
412
698
|
return response.data;
|
|
413
699
|
}
|
|
700
|
+
async deleteMemory(args) {
|
|
701
|
+
const parsed = deleteMemorySchema.parse(args);
|
|
702
|
+
const enc = await this.encState();
|
|
703
|
+
const memory = await this.fetchMemoryById(parsed.memoryId, enc);
|
|
704
|
+
if (!memory) {
|
|
705
|
+
return { success: false, notFound: true, memoryId: parsed.memoryId };
|
|
706
|
+
}
|
|
707
|
+
this.pruneDeleteConfirmations();
|
|
708
|
+
const fingerprint = memoryDeleteFingerprint(memory);
|
|
709
|
+
if (!parsed.confirmed) {
|
|
710
|
+
const token = randomUUID();
|
|
711
|
+
const expiresAtMs = Date.now() + DELETE_CONFIRMATION_TTL_MS;
|
|
712
|
+
this.deleteConfirmations.set(parsed.memoryId, { token, expiresAtMs, fingerprint });
|
|
713
|
+
return {
|
|
714
|
+
success: true,
|
|
715
|
+
confirmationRequired: true,
|
|
716
|
+
memory,
|
|
717
|
+
confirmationToken: token,
|
|
718
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const confirmation = this.deleteConfirmations.get(parsed.memoryId);
|
|
722
|
+
if (!parsed.confirmationToken || !confirmation || confirmation.token !== parsed.confirmationToken) {
|
|
723
|
+
throw new McpError(ErrorCode.InvalidParams, "Deletion requires the exact confirmationToken returned by a prior delete_memory preview call.");
|
|
724
|
+
}
|
|
725
|
+
if (confirmation.expiresAtMs <= Date.now()) {
|
|
726
|
+
this.deleteConfirmations.delete(parsed.memoryId);
|
|
727
|
+
throw new McpError(ErrorCode.InvalidParams, "Deletion confirmation expired. Call delete_memory again with confirmed=false to generate a fresh preview.");
|
|
728
|
+
}
|
|
729
|
+
if (confirmation.fingerprint !== fingerprint) {
|
|
730
|
+
this.deleteConfirmations.delete(parsed.memoryId);
|
|
731
|
+
throw new McpError(ErrorCode.InvalidParams, "Memory changed after the confirmation preview. Call delete_memory again with confirmed=false before deleting.");
|
|
732
|
+
}
|
|
733
|
+
const response = await this.axios.delete(`/api/extension/memories/${encodeURIComponent(parsed.memoryId)}`, {
|
|
734
|
+
timeout: 10_000,
|
|
735
|
+
});
|
|
736
|
+
this.deleteConfirmations.delete(parsed.memoryId);
|
|
737
|
+
return {
|
|
738
|
+
success: true,
|
|
739
|
+
deleted: response.data?.success === true,
|
|
740
|
+
memory,
|
|
741
|
+
memoryId: parsed.memoryId,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
414
744
|
async getMemoriesByTimeRange(args) {
|
|
415
745
|
const parsed = timeRangeSchema.parse(args);
|
|
416
746
|
const enc = await this.encState();
|
|
@@ -454,6 +784,8 @@ class EchoMemMCPServer {
|
|
|
454
784
|
client;
|
|
455
785
|
mapCache = null;
|
|
456
786
|
events;
|
|
787
|
+
mcpClientName;
|
|
788
|
+
mcpClientVersion;
|
|
457
789
|
/** Whether the most recent ListTools response carried the memory map (per-session recall signal). */
|
|
458
790
|
mapInjected = false;
|
|
459
791
|
constructor(store) {
|
|
@@ -474,20 +806,59 @@ class EchoMemMCPServer {
|
|
|
474
806
|
process.exit(0);
|
|
475
807
|
});
|
|
476
808
|
}
|
|
809
|
+
getMcpClientAnalytics() {
|
|
810
|
+
const hostPlatform = normalizeMcpHostPlatform(this.mcpClientName)
|
|
811
|
+
?? detectMcpHostFromEnv()
|
|
812
|
+
?? "unknown";
|
|
813
|
+
return {
|
|
814
|
+
mcp_client_name: this.mcpClientName,
|
|
815
|
+
mcp_client_version: this.mcpClientVersion,
|
|
816
|
+
host_platform: hostPlatform,
|
|
817
|
+
platform_source: hostPlatform,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
477
820
|
setupToolHandlers() {
|
|
478
821
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
479
|
-
this.
|
|
822
|
+
const clientVersion = this.server.getClientVersion();
|
|
823
|
+
this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
|
|
824
|
+
this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
|
|
825
|
+
this.events.setClient(this.mcpClientName);
|
|
480
826
|
// Inject a compact topic map of the user's memory into the search-tool description so the agent
|
|
481
827
|
// knows the boundary up front and recalls proactively (cached; best-effort — no map on failure).
|
|
482
|
-
if (this.client.hasToken() && !this.mapCache)
|
|
828
|
+
if (this.client.hasToken() && !this.mapCache) {
|
|
483
829
|
this.mapCache = this.client.fetchMemoryMap();
|
|
484
|
-
|
|
830
|
+
// Don't poison the cache on a transient failure → clear it so a later listing can retry.
|
|
831
|
+
this.mapCache.then((m) => { if (!m)
|
|
832
|
+
this.mapCache = null; }).catch(() => { this.mapCache = null; });
|
|
833
|
+
}
|
|
834
|
+
// NEVER block tool-listing on the network. A flaky/unreachable API would otherwise hang the MCP
|
|
835
|
+
// handshake and freeze the whole agent ("connection timed out after 30000ms"). The map is
|
|
836
|
+
// best-effort: cap the wait, and it'll be injected on the next listing once it resolves.
|
|
837
|
+
const map = this.mapCache
|
|
838
|
+
? await Promise.race([this.mapCache, new Promise((r) => setTimeout(() => r(undefined), 2500))])
|
|
839
|
+
: undefined;
|
|
485
840
|
this.mapInjected = !!map;
|
|
486
841
|
return { tools: listToolSpecs({ map }) };
|
|
487
842
|
});
|
|
488
843
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
489
844
|
const canonicalName = resolveCanonicalToolName(request.params.name);
|
|
845
|
+
const clientVersion = this.server.getClientVersion();
|
|
846
|
+
this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
|
|
847
|
+
this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
|
|
490
848
|
const t0 = Date.now();
|
|
849
|
+
const analyticsBase = {
|
|
850
|
+
surface: "mcp",
|
|
851
|
+
event_family: "mcp",
|
|
852
|
+
integration: "echomem_mcp",
|
|
853
|
+
telemetry_source: "local_bridge",
|
|
854
|
+
...this.getMcpClientAnalytics(),
|
|
855
|
+
codex_session_id: this.client.getSessionId(),
|
|
856
|
+
conversation_id: this.client.getSessionId(),
|
|
857
|
+
tool_name: request.params.name,
|
|
858
|
+
canonical_tool_name: canonicalName,
|
|
859
|
+
...triggerAnalyticsForTool(canonicalName, request.params.arguments),
|
|
860
|
+
...inputAnalyticsForTool(canonicalName, request.params.arguments),
|
|
861
|
+
};
|
|
491
862
|
// One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
|
|
492
863
|
const rec = {
|
|
493
864
|
type: "tool_call",
|
|
@@ -501,6 +872,13 @@ class EchoMemMCPServer {
|
|
|
501
872
|
}
|
|
502
873
|
if (!this.client.hasToken())
|
|
503
874
|
throw new NoTokenError();
|
|
875
|
+
if (canonicalName !== canonicalToolNames.save) {
|
|
876
|
+
void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase);
|
|
877
|
+
void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase);
|
|
878
|
+
}
|
|
879
|
+
if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
|
|
880
|
+
void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
|
|
881
|
+
}
|
|
504
882
|
switch (canonicalName) {
|
|
505
883
|
case canonicalToolNames.search:
|
|
506
884
|
return await this.handleSearch(request.params.arguments, rec);
|
|
@@ -512,6 +890,8 @@ class EchoMemMCPServer {
|
|
|
512
890
|
return await this.handleKeywords(request.params.arguments);
|
|
513
891
|
case canonicalToolNames.others:
|
|
514
892
|
return await this.handleOthers(request.params.arguments);
|
|
893
|
+
case canonicalToolNames.delete:
|
|
894
|
+
return await this.handleDelete(request.params.arguments, rec);
|
|
515
895
|
default:
|
|
516
896
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
517
897
|
}
|
|
@@ -559,6 +939,24 @@ class EchoMemMCPServer {
|
|
|
559
939
|
rec.ok = rec.error_kind === "none";
|
|
560
940
|
rec.latency_ms = Date.now() - t0;
|
|
561
941
|
this.events.record(rec);
|
|
942
|
+
if (canonicalName !== canonicalToolNames.report &&
|
|
943
|
+
canonicalName !== canonicalToolNames.save &&
|
|
944
|
+
this.client.hasToken()) {
|
|
945
|
+
const finalAnalytics = {
|
|
946
|
+
...analyticsBase,
|
|
947
|
+
success: rec.ok,
|
|
948
|
+
duration_ms: rec.latency_ms,
|
|
949
|
+
error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
|
|
950
|
+
result_count: rec.results_count,
|
|
951
|
+
returned_text_length: rec.results_chars,
|
|
952
|
+
tuned: rec.tuned,
|
|
953
|
+
map_injected: rec.map_injected,
|
|
954
|
+
encrypted_user: rec.encrypted,
|
|
955
|
+
extracted_memory_count: rec.memories_extracted,
|
|
956
|
+
};
|
|
957
|
+
void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics);
|
|
958
|
+
void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics);
|
|
959
|
+
}
|
|
562
960
|
}
|
|
563
961
|
});
|
|
564
962
|
}
|
|
@@ -649,17 +1047,21 @@ Details: ${m.details || "N/A"}`)
|
|
|
649
1047
|
return { content: [{ type: "text", text: `Found ${memories.length} relevant memories:\n\n${formattedResults}` }] };
|
|
650
1048
|
}
|
|
651
1049
|
async handleSave(args, rec) {
|
|
1050
|
+
const sourceFallback = this.getMcpClientAnalytics().platform_source;
|
|
1051
|
+
const enrichedArgs = isRecord(args) && !readString(args, "source")
|
|
1052
|
+
? { ...args, source: sourceFallback }
|
|
1053
|
+
: args;
|
|
652
1054
|
if (rec) {
|
|
653
|
-
const a =
|
|
1055
|
+
const a = enrichedArgs;
|
|
654
1056
|
const text = typeof a?.conversation === "string"
|
|
655
1057
|
? a.conversation
|
|
656
1058
|
: Array.isArray(a?.messages)
|
|
657
1059
|
? a.messages.map((m) => String(m?.content ?? "")).join("\n")
|
|
658
1060
|
: "";
|
|
659
1061
|
rec.conversation_chars = text.length;
|
|
660
|
-
rec.save_source = typeof a?.source === "string" ? a.source :
|
|
1062
|
+
rec.save_source = typeof a?.source === "string" ? a.source : sourceFallback;
|
|
661
1063
|
}
|
|
662
|
-
const { success, memoriesExtracted, error } = await this.client.saveConversation(
|
|
1064
|
+
const { success, memoriesExtracted, error } = await this.client.saveConversation(enrichedArgs);
|
|
663
1065
|
if (!success)
|
|
664
1066
|
throw new Error(`EchoMem API Error: ${error}`);
|
|
665
1067
|
if (rec)
|
|
@@ -743,6 +1145,56 @@ Details: ${m.details || "N/A"}`)
|
|
|
743
1145
|
],
|
|
744
1146
|
};
|
|
745
1147
|
}
|
|
1148
|
+
async handleDelete(args, rec) {
|
|
1149
|
+
const parsed = deleteMemorySchema.parse(args);
|
|
1150
|
+
if (rec) {
|
|
1151
|
+
rec.memory_id_hash = hashText(parsed.memoryId);
|
|
1152
|
+
rec.delete_confirmed = parsed.confirmed;
|
|
1153
|
+
}
|
|
1154
|
+
const result = await this.client.deleteMemory(args);
|
|
1155
|
+
if (result.notFound) {
|
|
1156
|
+
return {
|
|
1157
|
+
content: [{ type: "text", text: `Memory ${result.memoryId} was not found or is not accessible.` }],
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
if (result.confirmationRequired) {
|
|
1161
|
+
return {
|
|
1162
|
+
content: [
|
|
1163
|
+
{
|
|
1164
|
+
type: "text",
|
|
1165
|
+
text: [
|
|
1166
|
+
"Deletion requires explicit user confirmation. No memory was deleted.",
|
|
1167
|
+
"",
|
|
1168
|
+
formatDeletePreview(result.memory),
|
|
1169
|
+
"",
|
|
1170
|
+
`confirmationToken: ${result.confirmationToken}`,
|
|
1171
|
+
`expiresAt: ${result.expiresAt}`,
|
|
1172
|
+
"",
|
|
1173
|
+
"Ask the user whether to delete this memory. If and only if they confirm, call delete_memory again with confirmed=true and this exact confirmationToken.",
|
|
1174
|
+
"This deletes the memory row only; raw source_of_truth conversation records are preserved.",
|
|
1175
|
+
].join("\n"),
|
|
1176
|
+
},
|
|
1177
|
+
],
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (!result.deleted) {
|
|
1181
|
+
throw new Error("EchoMem API Error: delete request did not report success.");
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
content: [
|
|
1185
|
+
{
|
|
1186
|
+
type: "text",
|
|
1187
|
+
text: [
|
|
1188
|
+
`Deleted memory ${result.memoryId}.`,
|
|
1189
|
+
"",
|
|
1190
|
+
formatDeletePreview(result.memory),
|
|
1191
|
+
"",
|
|
1192
|
+
"Raw source_of_truth conversation records were preserved.",
|
|
1193
|
+
].join("\n"),
|
|
1194
|
+
},
|
|
1195
|
+
],
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
746
1198
|
async run() {
|
|
747
1199
|
const transport = new StdioServerTransport();
|
|
748
1200
|
await this.server.connect(transport);
|