@echomem/mcp 1.3.0 → 1.3.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/README.md +2 -0
- package/dist/codex-sync.js +469 -0
- package/dist/encryption.js +3 -1
- package/dist/index.js +400 -5
- package/dist/migrate.js +640 -133
- package/dist/report.js +153 -7
- package/dist/setup-page.js +574 -0
- package/dist/setup.js +458 -66
- 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,141 @@ 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 isUserRole(role) {
|
|
171
|
+
return typeof role === "string" && ["user", "human"].includes(role.toLowerCase());
|
|
172
|
+
}
|
|
173
|
+
function conversationAnalyticsFromMessages(value) {
|
|
174
|
+
if (!Array.isArray(value))
|
|
175
|
+
return {};
|
|
176
|
+
const records = value.filter(isRecord);
|
|
177
|
+
const userMessages = records
|
|
178
|
+
.filter((item) => isUserRole(item.role) && readString(item, "content"))
|
|
179
|
+
.map((item) => readString(item, "content") ?? "");
|
|
180
|
+
const assistantMessages = records
|
|
181
|
+
.filter((item) => typeof item.role === "string" && ["assistant", "ai"].includes(item.role.toLowerCase()))
|
|
182
|
+
.map((item) => readString(item, "content") ?? "");
|
|
183
|
+
const conversationText = records
|
|
184
|
+
.map((item) => `${readString(item, "role") ?? "unknown"}: ${typeof item.content === "string" ? item.content : ""}`)
|
|
185
|
+
.join("\n\n");
|
|
186
|
+
return {
|
|
187
|
+
conversation_length: conversationText.length,
|
|
188
|
+
conversation_hash: conversationText ? hashAnalyticsText(conversationText) : undefined,
|
|
189
|
+
conversation_message_count: value.length,
|
|
190
|
+
user_message_count: userMessages.length,
|
|
191
|
+
assistant_message_count: assistantMessages.length,
|
|
192
|
+
...safeTextAnalytics("first_user_message", userMessages[0]),
|
|
193
|
+
...safeTextAnalytics("last_user_message", userMessages.at(-1)),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function lastUserMessageFromMessages(value) {
|
|
197
|
+
if (!Array.isArray(value))
|
|
198
|
+
return undefined;
|
|
199
|
+
for (let i = value.length - 1; i >= 0; i--) {
|
|
200
|
+
const item = value[i];
|
|
201
|
+
if (!isRecord(item) || !isUserRole(item.role))
|
|
202
|
+
continue;
|
|
203
|
+
const content = readString(item, "content");
|
|
204
|
+
if (content)
|
|
205
|
+
return content;
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
function lastUserMessageFromConversationText(value) {
|
|
210
|
+
if (typeof value !== "string" || !value)
|
|
211
|
+
return undefined;
|
|
212
|
+
const markdownUserTurns = [...value.matchAll(/(?:^|\n)##\s+(?:User|Human)[^\n]*\n([\s\S]*?)(?=\n---\n|\n##\s+(?:User|Human|Assistant|AI|System)\b|$)/gi)];
|
|
213
|
+
const lastMarkdownTurn = markdownUserTurns.at(-1)?.[1]?.trim();
|
|
214
|
+
if (lastMarkdownTurn)
|
|
215
|
+
return lastMarkdownTurn;
|
|
216
|
+
const roleLines = value.split(/\n/);
|
|
217
|
+
let currentRole = null;
|
|
218
|
+
let current = [];
|
|
219
|
+
let lastUser = "";
|
|
220
|
+
const flush = () => {
|
|
221
|
+
if (currentRole && isUserRole(currentRole)) {
|
|
222
|
+
const content = current.join("\n").trim();
|
|
223
|
+
if (content)
|
|
224
|
+
lastUser = content;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
for (const line of roleLines) {
|
|
228
|
+
const match = line.match(/^\s*(user|human|assistant|ai|system)\s*:\s*(.*)$/i);
|
|
229
|
+
if (match) {
|
|
230
|
+
flush();
|
|
231
|
+
currentRole = match[1];
|
|
232
|
+
current = [match[2] ?? ""];
|
|
233
|
+
}
|
|
234
|
+
else if (currentRole) {
|
|
235
|
+
current.push(line);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
flush();
|
|
239
|
+
return lastUser || undefined;
|
|
240
|
+
}
|
|
102
241
|
function normalizeRetrievalCandidate(value, fallbackRank) {
|
|
103
242
|
if (!isRecord(value))
|
|
104
243
|
return null;
|
|
@@ -128,6 +267,98 @@ function withinTimeFrame(memory, candidate, timeFrameDays) {
|
|
|
128
267
|
return true;
|
|
129
268
|
return ms >= Date.now() - timeFrameDays * 24 * 60 * 60 * 1000;
|
|
130
269
|
}
|
|
270
|
+
function numberArg(args, key) {
|
|
271
|
+
return typeof args[key] === "number" && Number.isFinite(args[key]) ? args[key] : undefined;
|
|
272
|
+
}
|
|
273
|
+
function triggerAnalyticsForTool(canonicalName, args) {
|
|
274
|
+
const a = isRecord(args) ? args : {};
|
|
275
|
+
const explicitTrigger = readString(a, "triggerMessage");
|
|
276
|
+
let triggerText = explicitTrigger;
|
|
277
|
+
let triggerSource = explicitTrigger ? "tool_argument" : "not_provided_by_mcp_client";
|
|
278
|
+
if (!triggerText && canonicalName === canonicalToolNames.save) {
|
|
279
|
+
triggerText =
|
|
280
|
+
lastUserMessageFromMessages(a.messages) ??
|
|
281
|
+
lastUserMessageFromConversationText(a.conversation);
|
|
282
|
+
triggerSource = triggerText ? "conversation_payload_inferred" : triggerSource;
|
|
283
|
+
}
|
|
284
|
+
if (!triggerText) {
|
|
285
|
+
return {
|
|
286
|
+
trigger_message_available: false,
|
|
287
|
+
trigger_message_source: triggerSource,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
trigger_message_source: triggerSource,
|
|
292
|
+
trigger_message_role: readString(a, "triggerMessageRole") ?? "user",
|
|
293
|
+
...safeTextAnalytics("trigger_message", triggerText),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function inputAnalyticsForTool(canonicalName, args) {
|
|
297
|
+
const a = isRecord(args) ? args : {};
|
|
298
|
+
switch (canonicalName) {
|
|
299
|
+
case canonicalToolNames.search: {
|
|
300
|
+
const query = readString(a, "query");
|
|
301
|
+
const queryAnalytics = safeTextAnalytics("query", query);
|
|
302
|
+
return {
|
|
303
|
+
...queryAnalytics,
|
|
304
|
+
query_preview_safe: queryAnalytics.query_preview,
|
|
305
|
+
query_length: query?.length ?? 0,
|
|
306
|
+
limit: numberArg(a, "limit") ?? numberArg(a, "k"),
|
|
307
|
+
threshold: numberArg(a, "threshold"),
|
|
308
|
+
time_frame_days: numberArg(a, "timeFrameDays"),
|
|
309
|
+
include_answer: typeof a.includeAnswer === "boolean" ? a.includeAnswer : undefined,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
case canonicalToolNames.save: {
|
|
313
|
+
const messages = Array.isArray(a.messages) ? a.messages : undefined;
|
|
314
|
+
const conversation = typeof a.conversation === "string" ? a.conversation : undefined;
|
|
315
|
+
return {
|
|
316
|
+
input_mode: messages?.length ? "messages" : "conversation",
|
|
317
|
+
message_count: messages?.length,
|
|
318
|
+
content_length: messages?.reduce((sum, item) => {
|
|
319
|
+
if (!isRecord(item))
|
|
320
|
+
return sum;
|
|
321
|
+
return sum + String(item.content ?? "").length;
|
|
322
|
+
}, 0) ?? conversation?.length ?? 0,
|
|
323
|
+
...(messages?.length
|
|
324
|
+
? conversationAnalyticsFromMessages(messages)
|
|
325
|
+
: {
|
|
326
|
+
conversation_length: conversation?.length ?? 0,
|
|
327
|
+
conversation_hash: conversation ? hashAnalyticsText(conversation) : undefined,
|
|
328
|
+
}),
|
|
329
|
+
has_title: !!readString(a, "title"),
|
|
330
|
+
has_url: !!readString(a, "url"),
|
|
331
|
+
has_source: !!readString(a, "source"),
|
|
332
|
+
tag_count: Array.isArray(a.tags) ? a.tags.length : undefined,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
case canonicalToolNames.timeRange:
|
|
336
|
+
return {
|
|
337
|
+
limit: numberArg(a, "limit"),
|
|
338
|
+
has_start_date: !!readString(a, "startDate"),
|
|
339
|
+
has_end_date: !!readString(a, "endDate"),
|
|
340
|
+
};
|
|
341
|
+
case canonicalToolNames.keywords:
|
|
342
|
+
return {
|
|
343
|
+
keyword_count: Array.isArray(a.keywords) ? a.keywords.length : 0,
|
|
344
|
+
limit: numberArg(a, "limit"),
|
|
345
|
+
};
|
|
346
|
+
case canonicalToolNames.others: {
|
|
347
|
+
const query = readString(a, "query");
|
|
348
|
+
const queryAnalytics = safeTextAnalytics("query", query);
|
|
349
|
+
return {
|
|
350
|
+
...queryAnalytics,
|
|
351
|
+
query_preview_safe: queryAnalytics.query_preview,
|
|
352
|
+
query_length: query?.length ?? 0,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
default:
|
|
356
|
+
return {};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function toolEventName(canonicalName, status) {
|
|
360
|
+
return `[MCP] ${canonicalName} ${status}`;
|
|
361
|
+
}
|
|
131
362
|
class EchoMemApiClient {
|
|
132
363
|
store;
|
|
133
364
|
axios;
|
|
@@ -135,6 +366,7 @@ class EchoMemApiClient {
|
|
|
135
366
|
/** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
|
|
136
367
|
sessionId = randomUUID();
|
|
137
368
|
encConfigPromise = null;
|
|
369
|
+
deleteConfirmations = new Map();
|
|
138
370
|
constructor(store) {
|
|
139
371
|
this.store = store;
|
|
140
372
|
this.axios = axios.create({
|
|
@@ -160,6 +392,16 @@ class EchoMemApiClient {
|
|
|
160
392
|
getSessionId() {
|
|
161
393
|
return this.sessionId;
|
|
162
394
|
}
|
|
395
|
+
async trackMcpAnalyticsEvent(eventType, eventProperties) {
|
|
396
|
+
if (!this.hasToken())
|
|
397
|
+
return;
|
|
398
|
+
try {
|
|
399
|
+
await this.axios.post("/api/extension/mcp/events", { eventType, eventProperties }, { timeout: 1500 });
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// Remote analytics is best-effort and must never change MCP behavior.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
163
405
|
/**
|
|
164
406
|
* Compact topic map of the user's memory for the search-tool description — a cheap "what's in here"
|
|
165
407
|
* index built from memory keys so the agent knows the boundary up front and recalls proactively.
|
|
@@ -211,10 +453,17 @@ class EchoMemApiClient {
|
|
|
211
453
|
throw new LockedError(this.store.isKeyExpired());
|
|
212
454
|
return { enabled: false };
|
|
213
455
|
}
|
|
456
|
+
pruneDeleteConfirmations(now = Date.now()) {
|
|
457
|
+
for (const [memoryId, confirmation] of this.deleteConfirmations) {
|
|
458
|
+
if (confirmation.expiresAtMs <= now) {
|
|
459
|
+
this.deleteConfirmations.delete(memoryId);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
214
463
|
async whoami() {
|
|
215
464
|
if (!this.whoamiCache) {
|
|
216
465
|
this.whoamiCache = this.axios
|
|
217
|
-
.get("/api/openclaw/v1/whoami")
|
|
466
|
+
.get("/api/openclaw/v1/whoami", { timeout: 6000 })
|
|
218
467
|
.then((response) => response.data)
|
|
219
468
|
.catch((error) => {
|
|
220
469
|
this.whoamiCache = null; // don't pin a token-less failure; retry once a token exists
|
|
@@ -408,9 +657,57 @@ class EchoMemApiClient {
|
|
|
408
657
|
title: parsed.title,
|
|
409
658
|
// Stable per-session id so multiple saves in this coding session group under one context.
|
|
410
659
|
conversationKey: this.sessionId,
|
|
660
|
+
triggerMessage: parsed.triggerMessage ||
|
|
661
|
+
lastUserMessageFromMessages(parsed.messages) ||
|
|
662
|
+
lastUserMessageFromConversationText(parsed.conversation),
|
|
663
|
+
triggerMessageRole: parsed.triggerMessageRole || "user",
|
|
411
664
|
}, config);
|
|
412
665
|
return response.data;
|
|
413
666
|
}
|
|
667
|
+
async deleteMemory(args) {
|
|
668
|
+
const parsed = deleteMemorySchema.parse(args);
|
|
669
|
+
const enc = await this.encState();
|
|
670
|
+
const memory = await this.fetchMemoryById(parsed.memoryId, enc);
|
|
671
|
+
if (!memory) {
|
|
672
|
+
return { success: false, notFound: true, memoryId: parsed.memoryId };
|
|
673
|
+
}
|
|
674
|
+
this.pruneDeleteConfirmations();
|
|
675
|
+
const fingerprint = memoryDeleteFingerprint(memory);
|
|
676
|
+
if (!parsed.confirmed) {
|
|
677
|
+
const token = randomUUID();
|
|
678
|
+
const expiresAtMs = Date.now() + DELETE_CONFIRMATION_TTL_MS;
|
|
679
|
+
this.deleteConfirmations.set(parsed.memoryId, { token, expiresAtMs, fingerprint });
|
|
680
|
+
return {
|
|
681
|
+
success: true,
|
|
682
|
+
confirmationRequired: true,
|
|
683
|
+
memory,
|
|
684
|
+
confirmationToken: token,
|
|
685
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const confirmation = this.deleteConfirmations.get(parsed.memoryId);
|
|
689
|
+
if (!parsed.confirmationToken || !confirmation || confirmation.token !== parsed.confirmationToken) {
|
|
690
|
+
throw new McpError(ErrorCode.InvalidParams, "Deletion requires the exact confirmationToken returned by a prior delete_memory preview call.");
|
|
691
|
+
}
|
|
692
|
+
if (confirmation.expiresAtMs <= Date.now()) {
|
|
693
|
+
this.deleteConfirmations.delete(parsed.memoryId);
|
|
694
|
+
throw new McpError(ErrorCode.InvalidParams, "Deletion confirmation expired. Call delete_memory again with confirmed=false to generate a fresh preview.");
|
|
695
|
+
}
|
|
696
|
+
if (confirmation.fingerprint !== fingerprint) {
|
|
697
|
+
this.deleteConfirmations.delete(parsed.memoryId);
|
|
698
|
+
throw new McpError(ErrorCode.InvalidParams, "Memory changed after the confirmation preview. Call delete_memory again with confirmed=false before deleting.");
|
|
699
|
+
}
|
|
700
|
+
const response = await this.axios.delete(`/api/extension/memories/${encodeURIComponent(parsed.memoryId)}`, {
|
|
701
|
+
timeout: 10_000,
|
|
702
|
+
});
|
|
703
|
+
this.deleteConfirmations.delete(parsed.memoryId);
|
|
704
|
+
return {
|
|
705
|
+
success: true,
|
|
706
|
+
deleted: response.data?.success === true,
|
|
707
|
+
memory,
|
|
708
|
+
memoryId: parsed.memoryId,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
414
711
|
async getMemoriesByTimeRange(args) {
|
|
415
712
|
const parsed = timeRangeSchema.parse(args);
|
|
416
713
|
const enc = await this.encState();
|
|
@@ -479,15 +776,36 @@ class EchoMemMCPServer {
|
|
|
479
776
|
this.events.setClient(this.server.getClientVersion()?.name);
|
|
480
777
|
// Inject a compact topic map of the user's memory into the search-tool description so the agent
|
|
481
778
|
// knows the boundary up front and recalls proactively (cached; best-effort — no map on failure).
|
|
482
|
-
if (this.client.hasToken() && !this.mapCache)
|
|
779
|
+
if (this.client.hasToken() && !this.mapCache) {
|
|
483
780
|
this.mapCache = this.client.fetchMemoryMap();
|
|
484
|
-
|
|
781
|
+
// Don't poison the cache on a transient failure → clear it so a later listing can retry.
|
|
782
|
+
this.mapCache.then((m) => { if (!m)
|
|
783
|
+
this.mapCache = null; }).catch(() => { this.mapCache = null; });
|
|
784
|
+
}
|
|
785
|
+
// NEVER block tool-listing on the network. A flaky/unreachable API would otherwise hang the MCP
|
|
786
|
+
// handshake and freeze the whole agent ("connection timed out after 30000ms"). The map is
|
|
787
|
+
// best-effort: cap the wait, and it'll be injected on the next listing once it resolves.
|
|
788
|
+
const map = this.mapCache
|
|
789
|
+
? await Promise.race([this.mapCache, new Promise((r) => setTimeout(() => r(undefined), 2500))])
|
|
790
|
+
: undefined;
|
|
485
791
|
this.mapInjected = !!map;
|
|
486
792
|
return { tools: listToolSpecs({ map }) };
|
|
487
793
|
});
|
|
488
794
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
489
795
|
const canonicalName = resolveCanonicalToolName(request.params.name);
|
|
490
796
|
const t0 = Date.now();
|
|
797
|
+
const analyticsBase = {
|
|
798
|
+
surface: "mcp",
|
|
799
|
+
event_family: "mcp",
|
|
800
|
+
integration: "echomem_mcp",
|
|
801
|
+
telemetry_source: "local_bridge",
|
|
802
|
+
codex_session_id: this.client.getSessionId(),
|
|
803
|
+
conversation_id: this.client.getSessionId(),
|
|
804
|
+
tool_name: request.params.name,
|
|
805
|
+
canonical_tool_name: canonicalName,
|
|
806
|
+
...triggerAnalyticsForTool(canonicalName, request.params.arguments),
|
|
807
|
+
...inputAnalyticsForTool(canonicalName, request.params.arguments),
|
|
808
|
+
};
|
|
491
809
|
// One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
|
|
492
810
|
const rec = {
|
|
493
811
|
type: "tool_call",
|
|
@@ -501,6 +819,13 @@ class EchoMemMCPServer {
|
|
|
501
819
|
}
|
|
502
820
|
if (!this.client.hasToken())
|
|
503
821
|
throw new NoTokenError();
|
|
822
|
+
if (canonicalName !== canonicalToolNames.save) {
|
|
823
|
+
void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase);
|
|
824
|
+
void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase);
|
|
825
|
+
}
|
|
826
|
+
if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
|
|
827
|
+
void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
|
|
828
|
+
}
|
|
504
829
|
switch (canonicalName) {
|
|
505
830
|
case canonicalToolNames.search:
|
|
506
831
|
return await this.handleSearch(request.params.arguments, rec);
|
|
@@ -512,6 +837,8 @@ class EchoMemMCPServer {
|
|
|
512
837
|
return await this.handleKeywords(request.params.arguments);
|
|
513
838
|
case canonicalToolNames.others:
|
|
514
839
|
return await this.handleOthers(request.params.arguments);
|
|
840
|
+
case canonicalToolNames.delete:
|
|
841
|
+
return await this.handleDelete(request.params.arguments, rec);
|
|
515
842
|
default:
|
|
516
843
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
517
844
|
}
|
|
@@ -559,6 +886,24 @@ class EchoMemMCPServer {
|
|
|
559
886
|
rec.ok = rec.error_kind === "none";
|
|
560
887
|
rec.latency_ms = Date.now() - t0;
|
|
561
888
|
this.events.record(rec);
|
|
889
|
+
if (canonicalName !== canonicalToolNames.report &&
|
|
890
|
+
canonicalName !== canonicalToolNames.save &&
|
|
891
|
+
this.client.hasToken()) {
|
|
892
|
+
const finalAnalytics = {
|
|
893
|
+
...analyticsBase,
|
|
894
|
+
success: rec.ok,
|
|
895
|
+
duration_ms: rec.latency_ms,
|
|
896
|
+
error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
|
|
897
|
+
result_count: rec.results_count,
|
|
898
|
+
returned_text_length: rec.results_chars,
|
|
899
|
+
tuned: rec.tuned,
|
|
900
|
+
map_injected: rec.map_injected,
|
|
901
|
+
encrypted_user: rec.encrypted,
|
|
902
|
+
extracted_memory_count: rec.memories_extracted,
|
|
903
|
+
};
|
|
904
|
+
void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics);
|
|
905
|
+
void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics);
|
|
906
|
+
}
|
|
562
907
|
}
|
|
563
908
|
});
|
|
564
909
|
}
|
|
@@ -743,6 +1088,56 @@ Details: ${m.details || "N/A"}`)
|
|
|
743
1088
|
],
|
|
744
1089
|
};
|
|
745
1090
|
}
|
|
1091
|
+
async handleDelete(args, rec) {
|
|
1092
|
+
const parsed = deleteMemorySchema.parse(args);
|
|
1093
|
+
if (rec) {
|
|
1094
|
+
rec.memory_id_hash = hashText(parsed.memoryId);
|
|
1095
|
+
rec.delete_confirmed = parsed.confirmed;
|
|
1096
|
+
}
|
|
1097
|
+
const result = await this.client.deleteMemory(args);
|
|
1098
|
+
if (result.notFound) {
|
|
1099
|
+
return {
|
|
1100
|
+
content: [{ type: "text", text: `Memory ${result.memoryId} was not found or is not accessible.` }],
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
if (result.confirmationRequired) {
|
|
1104
|
+
return {
|
|
1105
|
+
content: [
|
|
1106
|
+
{
|
|
1107
|
+
type: "text",
|
|
1108
|
+
text: [
|
|
1109
|
+
"Deletion requires explicit user confirmation. No memory was deleted.",
|
|
1110
|
+
"",
|
|
1111
|
+
formatDeletePreview(result.memory),
|
|
1112
|
+
"",
|
|
1113
|
+
`confirmationToken: ${result.confirmationToken}`,
|
|
1114
|
+
`expiresAt: ${result.expiresAt}`,
|
|
1115
|
+
"",
|
|
1116
|
+
"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.",
|
|
1117
|
+
"This deletes the memory row only; raw source_of_truth conversation records are preserved.",
|
|
1118
|
+
].join("\n"),
|
|
1119
|
+
},
|
|
1120
|
+
],
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (!result.deleted) {
|
|
1124
|
+
throw new Error("EchoMem API Error: delete request did not report success.");
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
content: [
|
|
1128
|
+
{
|
|
1129
|
+
type: "text",
|
|
1130
|
+
text: [
|
|
1131
|
+
`Deleted memory ${result.memoryId}.`,
|
|
1132
|
+
"",
|
|
1133
|
+
formatDeletePreview(result.memory),
|
|
1134
|
+
"",
|
|
1135
|
+
"Raw source_of_truth conversation records were preserved.",
|
|
1136
|
+
].join("\n"),
|
|
1137
|
+
},
|
|
1138
|
+
],
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
746
1141
|
async run() {
|
|
747
1142
|
const transport = new StdioServerTransport();
|
|
748
1143
|
await this.server.connect(transport);
|