@echomem/mcp 1.4.40 → 1.4.42

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/dist/index.js CHANGED
@@ -4,18 +4,20 @@ 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, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, recordMemoryCitationsSchema, requestGroupSessionSharingSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } from "./v1-contract.js";
7
+ import { bindSourceSessionSchema, canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, othersSchema, publishBatchToGroupSchema, publishToGroupSchema, prepareGroupPublicationSchema, publicMemorySchema, recordMemoryCitationsSchema, requestGroupSessionSharingSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, searchUsersSchema, sendFriendRequestSchema, timeRangeSchema, updateGroupProfileSchema, setGroupSessionSharingSchema, } 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
11
  import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
+ import { AsyncLocalStorage } from "node:async_hooks";
13
14
  import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
14
15
  import { runCli } from "./setup.js";
15
16
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, } from "./package-metadata.js";
16
17
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
17
18
  import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
18
19
  import { autoUpdateHeadlessRuntime } from "./headless-runtime.js";
20
+ import { resolveSourceSessionFromMcpContext, resolveSourceSessionFromBindingToken, } from "./source-session.js";
19
21
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
20
22
  const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
21
23
  const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
@@ -611,6 +613,9 @@ function toolEventName(canonicalName, status) {
611
613
  class EchoMemApiClient {
612
614
  store;
613
615
  axios;
616
+ boundSourceSession = null;
617
+ requestSourceSession = new AsyncLocalStorage();
618
+ sourceSessionsByCanonicalKey = new Map();
614
619
  whoamiCache = null;
615
620
  /** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
616
621
  sessionId = randomUUID();
@@ -630,6 +635,10 @@ class EchoMemApiClient {
630
635
  const token = this.store.getToken();
631
636
  if (token)
632
637
  config.headers.set("Authorization", `Bearer ${token}`);
638
+ const sourceSession = this.getBoundSourceSession();
639
+ if (sourceSession) {
640
+ config.headers.set("X-EchoMem-Context-Id", sourceSession.contextId);
641
+ }
633
642
  return config;
634
643
  });
635
644
  }
@@ -641,6 +650,43 @@ class EchoMemApiClient {
641
650
  getSessionId() {
642
651
  return this.sessionId;
643
652
  }
653
+ getBoundSourceSession() {
654
+ return this.requestSourceSession.getStore() ?? this.boundSourceSession;
655
+ }
656
+ async withSourceSession(sourceSession, operation) {
657
+ return sourceSession ? this.requestSourceSession.run(sourceSession, operation) : operation();
658
+ }
659
+ async bindSourceSession(verified, persistForBridge = true) {
660
+ const cached = this.sourceSessionsByCanonicalKey.get(verified.canonicalKey);
661
+ if (cached) {
662
+ if (persistForBridge)
663
+ this.boundSourceSession = cached;
664
+ return { ...cached, created: false };
665
+ }
666
+ const response = await this.axios.post("/api/extension/source-sessions/bind", {
667
+ provider: verified.provider,
668
+ providerSessionId: verified.providerSessionId,
669
+ evidence: verified.evidence,
670
+ });
671
+ const contextId = readString(response.data, "contextId");
672
+ const canonicalKey = readString(response.data, "canonicalKey");
673
+ if (response.data?.success !== true || !contextId || canonicalKey !== verified.canonicalKey) {
674
+ throw new Error("EchoMem returned an invalid source-session binding receipt");
675
+ }
676
+ const bound = { ...verified, contextId };
677
+ this.sourceSessionsByCanonicalKey.set(verified.canonicalKey, bound);
678
+ if (this.sourceSessionsByCanonicalKey.size > 64) {
679
+ const oldestKey = this.sourceSessionsByCanonicalKey.keys().next().value;
680
+ if (oldestKey)
681
+ this.sourceSessionsByCanonicalKey.delete(oldestKey);
682
+ }
683
+ if (persistForBridge)
684
+ this.boundSourceSession = bound;
685
+ return {
686
+ ...bound,
687
+ created: response.data?.created === true,
688
+ };
689
+ }
644
690
  async trackMcpAnalyticsEvent(eventType, eventProperties, insertId) {
645
691
  if (!this.hasToken())
646
692
  return;
@@ -900,9 +946,10 @@ class EchoMemApiClient {
900
946
  sourceUrl: parsed.url,
901
947
  source: parsed.source || "mcp_server",
902
948
  title: parsed.title,
903
- // The MCP transport can outlive a host conversation. Group saves only by the opaque
904
- // scope carried in that conversation, never by the bridge process id.
905
- conversationKey: groupSharingScopeId,
949
+ // A verified source session owns the memory context. The independent opaque scope still
950
+ // governs group-sharing consent and never becomes the source-session identity.
951
+ conversationKey: this.getBoundSourceSession()?.canonicalKey ?? groupSharingScopeId,
952
+ groupSharingScopeId,
906
953
  passthrough: parsed.passthrough || false,
907
954
  triggerMessage: parsed.triggerMessage ||
908
955
  lastUserMessageFromMessages(parsed.messages) ||
@@ -1049,6 +1096,8 @@ class EchoMemApiClient {
1049
1096
  kPerUser: parsed.kPerUser,
1050
1097
  similarityThreshold: parsed.similarityThreshold,
1051
1098
  timeFrameDays: parsed.timeFrameDays,
1099
+ workspaceId: parsed.workspaceId ?? parsed.groupId,
1100
+ scope: parsed.scope,
1052
1101
  requestId: this.sessionId,
1053
1102
  source: "mcp_friend_public_memory_search",
1054
1103
  });
@@ -1061,7 +1110,14 @@ class EchoMemApiClient {
1061
1110
  async getPublicMemory(args) {
1062
1111
  const parsed = publicMemorySchema.parse(args ?? {});
1063
1112
  try {
1064
- const response = await this.axios.get(`/api/extension/social/public-memories/${encodeURIComponent(parsed.memoryId)}?requestId=${encodeURIComponent(this.sessionId)}&source=mcp_friend_public_memory_fetch`);
1113
+ const workspaceId = parsed.workspaceId ?? parsed.groupId;
1114
+ const query = new URLSearchParams({
1115
+ requestId: this.sessionId,
1116
+ source: "mcp_friend_public_memory_fetch",
1117
+ });
1118
+ if (workspaceId)
1119
+ query.set("workspaceId", workspaceId);
1120
+ const response = await this.axios.get(`/api/extension/social/public-memories/${encodeURIComponent(parsed.memoryId)}?${query.toString()}`);
1065
1121
  return response.data;
1066
1122
  }
1067
1123
  catch (error) {
@@ -1084,9 +1140,13 @@ class EchoMemApiClient {
1084
1140
  }
1085
1141
  }
1086
1142
  async getGroupContext(args) {
1087
- groupContextSchema.parse(args ?? {});
1143
+ const parsed = groupContextSchema.parse(args ?? {});
1088
1144
  try {
1089
- const response = await this.axios.get("/api/extension/social/groups/current");
1145
+ const workspaceId = parsed.workspaceId ?? parsed.groupId;
1146
+ const path = workspaceId
1147
+ ? `/api/extension/social/groups/current?workspaceId=${encodeURIComponent(workspaceId)}`
1148
+ : "/api/extension/social/groups/current";
1149
+ const response = await this.axios.get(path);
1090
1150
  return response.data;
1091
1151
  }
1092
1152
  catch (error) {
@@ -1150,6 +1210,7 @@ class EchoMemApiClient {
1150
1210
  const enc = await this.encState();
1151
1211
  try {
1152
1212
  const response = await this.axios.post(`/api/extension/social/groups/current/memories/${encodeURIComponent(parsed.memoryId)}/publish`, {
1213
+ workspaceId: parsed.workspaceId ?? parsed.groupId,
1153
1214
  acknowledgedFlaggedMemoryIds: parsed.acknowledgedFlaggedMemoryIds,
1154
1215
  }, {
1155
1216
  headers: enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : undefined,
@@ -1165,6 +1226,7 @@ class EchoMemApiClient {
1165
1226
  const enc = await this.encState();
1166
1227
  try {
1167
1228
  const response = await this.axios.post("/api/extension/social/groups/current/memories/publish-batch", {
1229
+ workspaceId: parsed.workspaceId ?? parsed.groupId,
1168
1230
  memoryIds: parsed.memoryIds,
1169
1231
  contextId: parsed.contextId,
1170
1232
  selectionReason: parsed.selectionReason,
@@ -1297,7 +1359,7 @@ class EchoMemMCPServer {
1297
1359
  const updateNotice = DESKTOP_MANAGED ? undefined : formatUpdateNotice(this.updateStatus);
1298
1360
  return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
1299
1361
  });
1300
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1362
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1301
1363
  const resolvedCanonicalName = resolveCanonicalToolName(request.params.name);
1302
1364
  const recallRoute = routePersonalRecallInvocation(resolvedCanonicalName, request.params.arguments);
1303
1365
  const canonicalName = recallRoute.canonicalName;
@@ -1307,258 +1369,277 @@ class EchoMemMCPServer {
1307
1369
  const clientVersion = this.server.getClientVersion();
1308
1370
  this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
1309
1371
  this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
1310
- const t0 = Date.now();
1311
- const analyticsBase = {
1312
- surface: "mcp",
1313
- event_family: "mcp",
1314
- integration: "echomem_mcp",
1315
- telemetry_source: "local_bridge",
1316
- ...this.getMcpClientAnalytics(),
1317
- codex_session_id: this.client.getSessionId(),
1318
- conversation_id: this.client.getSessionId(),
1319
- tool_name: request.params.name,
1320
- canonical_tool_name: canonicalName,
1321
- ...triggerAnalyticsForTool(canonicalName, toolArgs),
1322
- ...inputAnalyticsForTool(canonicalName, toolArgs),
1323
- };
1324
- const analyticsCallId = randomUUID();
1325
- // One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
1326
- const rec = {
1327
- type: "tool_call",
1328
- tool: canonicalName,
1329
- map_injected: this.mapInjected,
1330
- group_map_injected: this.groupMapInjected,
1331
- };
1372
+ let requestSourceSession = this.client.getBoundSourceSession();
1373
+ let inferred = null;
1332
1374
  try {
1333
- if (recallRoute.error) {
1334
- throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
1335
- }
1336
- // The usage report is a local, $0 audit works with no login (value before signup).
1337
- if (canonicalName === canonicalToolNames.report) {
1338
- return { content: [{ type: "text", text: await buildReportText(false) }] };
1375
+ inferred = resolveSourceSessionFromMcpContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
1376
+ }
1377
+ catch {
1378
+ // Malformed host metadata is not model input. Ignore it and retain the explicit fallback.
1379
+ }
1380
+ // Verified request metadata always wins over a bridge-level compatibility fallback. The
1381
+ // startup hook performs the eager write; this path attaches the exact context to the current
1382
+ // request and retries the backend write if startup raced login.
1383
+ if (inferred && this.client.hasToken()) {
1384
+ requestSourceSession = await this.client.bindSourceSession(inferred, false);
1385
+ }
1386
+ return this.client.withSourceSession(requestSourceSession, async () => {
1387
+ const t0 = Date.now();
1388
+ const analyticsBase = {
1389
+ surface: "mcp",
1390
+ event_family: "mcp",
1391
+ integration: "echomem_mcp",
1392
+ telemetry_source: "local_bridge",
1393
+ ...this.getMcpClientAnalytics(),
1394
+ codex_session_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1395
+ conversation_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1396
+ context_id: this.client.getBoundSourceSession()?.contextId,
1397
+ tool_name: request.params.name,
1398
+ canonical_tool_name: canonicalName,
1399
+ ...triggerAnalyticsForTool(canonicalName, toolArgs),
1400
+ ...inputAnalyticsForTool(canonicalName, toolArgs),
1401
+ };
1402
+ const analyticsCallId = randomUUID();
1403
+ // One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
1404
+ const rec = {
1405
+ type: "tool_call",
1406
+ tool: canonicalName,
1407
+ map_injected: this.mapInjected,
1408
+ group_map_injected: this.groupMapInjected,
1409
+ };
1410
+ try {
1411
+ if (recallRoute.error) {
1412
+ throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
1413
+ }
1414
+ // The usage report is a local, $0 audit — works with no login (value before signup).
1415
+ if (canonicalName === canonicalToolNames.report) {
1416
+ return { content: [{ type: "text", text: await buildReportText(false) }] };
1417
+ }
1418
+ if (canonicalName === canonicalToolNames.updateStatus) {
1419
+ if (DESKTOP_MANAGED) {
1420
+ return {
1421
+ content: [{
1422
+ type: "text",
1423
+ text: `Echo Desktop manages this MCP runtime (${MCP_PACKAGE_VERSION}). Install app updates from Echo Desktop, then start a new agent session.`,
1424
+ }],
1425
+ };
1426
+ }
1427
+ const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
1428
+ const status = await checkLatestUpdateStatus({ force });
1429
+ this.handleUpdateStatus(status);
1430
+ return { content: [{ type: "text", text: formatUpdateStatusText(this.updateStatus ?? status) }] };
1431
+ }
1432
+ if (canonicalName === canonicalToolNames.contextHealth) {
1433
+ const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
1434
+ ? request.params.arguments.client
1435
+ : "auto";
1436
+ const mode = client === "codex" || client === "claude-code" || client === "claude-desktop" || client === "auto"
1437
+ ? client
1438
+ : "auto";
1439
+ return { content: [{ type: "text", text: await contextHealthMarkdown(mode) }] };
1440
+ }
1441
+ if (canonicalName === canonicalToolNames.recompose) {
1442
+ const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
1443
+ ? request.params.arguments.client
1444
+ : "auto";
1445
+ const mode = client === "codex" || client === "claude-code" || client === "claude-desktop" || client === "auto"
1446
+ ? client
1447
+ : "auto";
1448
+ const capsuleText = await recomposeCapsuleMarkdown(mode);
1449
+ // If logged in, persist the capsule via passthrough so it's retrievable by contextId.
1450
+ if (this.client.hasToken()) {
1451
+ try {
1452
+ const saveResult = await this.client.saveConversation({
1453
+ conversation: capsuleText,
1454
+ title: "Session capsule (recompose)",
1455
+ source: "mcp_recompose",
1456
+ passthrough: true,
1457
+ });
1458
+ const ctxId = saveResult?.contextId;
1459
+ const capId = saveResult?.capsuleId;
1460
+ const persistLine = ctxId
1461
+ ? `\n\n---\nCapsule persisted${capId ? ` (${capId})` : ""}. To reload in a fresh session:\nget_memories_by_context({ contextId: "${ctxId}" })`
1462
+ : "";
1463
+ return { content: [{ type: "text", text: capsuleText + persistLine }] };
1464
+ }
1465
+ catch {
1466
+ // Persistence is best-effort; return the capsule text regardless.
1467
+ }
1468
+ }
1469
+ return { content: [{ type: "text", text: capsuleText }] };
1470
+ }
1471
+ if (!this.client.hasToken())
1472
+ throw new NoTokenError();
1473
+ if (canonicalName !== canonicalToolNames.save) {
1474
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase, `${analyticsCallId}:generic-called`);
1475
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase, `${analyticsCallId}:called`);
1476
+ }
1477
+ if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
1478
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
1479
+ }
1480
+ switch (canonicalName) {
1481
+ case canonicalToolNames.bindSourceSession:
1482
+ return await this.handleBindSourceSession(request.params.arguments);
1483
+ case canonicalToolNames.search:
1484
+ return await this.handleSearch(toolArgs, rec);
1485
+ case canonicalToolNames.save:
1486
+ return await this.handleSave(request.params.arguments, rec);
1487
+ case canonicalToolNames.timeRange:
1488
+ return await this.handleTimeRange(toolArgs);
1489
+ case canonicalToolNames.getByContext:
1490
+ return await this.handleGetByContext(request.params.arguments);
1491
+ case canonicalToolNames.checkpointByContext:
1492
+ return await this.handleGetCheckpointByContext(request.params.arguments);
1493
+ case canonicalToolNames.keywords:
1494
+ return await this.handleKeywords(toolArgs);
1495
+ case canonicalToolNames.friends:
1496
+ return await this.handleFriends(request.params.arguments);
1497
+ case canonicalToolNames.searchUsers:
1498
+ return await this.handleSearchUsers(request.params.arguments);
1499
+ case canonicalToolNames.sendFriendRequest:
1500
+ return await this.handleSendFriendRequest(request.params.arguments);
1501
+ case canonicalToolNames.others:
1502
+ return await this.handleOthers(toolArgs);
1503
+ case canonicalToolNames.publicMemory:
1504
+ return await this.handlePublicMemory(request.params.arguments);
1505
+ case canonicalToolNames.recordCitations:
1506
+ return await this.handleRecordMemoryCitations(request.params.arguments);
1507
+ case canonicalToolNames.groupContext:
1508
+ return await this.handleGroupContext(request.params.arguments);
1509
+ case canonicalToolNames.getGroupSessionSharing:
1510
+ return await this.handleGetGroupSessionSharing(request.params.arguments);
1511
+ case canonicalToolNames.requestGroupSessionSharing:
1512
+ return await this.handleRequestGroupSessionSharing(request.params.arguments);
1513
+ case canonicalToolNames.setGroupSessionSharing:
1514
+ return await this.handleSetGroupSessionSharing(request.params.arguments);
1515
+ case canonicalToolNames.createGroup:
1516
+ return await this.handleCreateGroup(request.params.arguments);
1517
+ case canonicalToolNames.createGroupInvite:
1518
+ return await this.handleCreateGroupInvite(request.params.arguments);
1519
+ case canonicalToolNames.joinGroup:
1520
+ return await this.handleJoinGroup(request.params.arguments);
1521
+ case canonicalToolNames.prepareGroupPublication:
1522
+ return await this.handlePrepareGroupPublication(request.params.arguments);
1523
+ case canonicalToolNames.flagPublicationAttention:
1524
+ return await this.handleFlagPublicationAttention(request.params.arguments);
1525
+ case canonicalToolNames.updateGroupProfile:
1526
+ return await this.handleUpdateGroupProfile(request.params.arguments);
1527
+ case canonicalToolNames.completeGroupPublication:
1528
+ return await this.handleCompleteGroupPublication(request.params.arguments);
1529
+ case canonicalToolNames.publishToGroup:
1530
+ return await this.handlePublishToGroup(request.params.arguments);
1531
+ case canonicalToolNames.publishBatchToGroup:
1532
+ return await this.handlePublishBatchToGroup(request.params.arguments);
1533
+ case canonicalToolNames.delete:
1534
+ return await this.handleDelete(request.params.arguments, rec);
1535
+ default:
1536
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
1537
+ }
1339
1538
  }
1340
- if (canonicalName === canonicalToolNames.updateStatus) {
1341
- if (DESKTOP_MANAGED) {
1539
+ catch (error) {
1540
+ rec.error_kind = classifyError(error);
1541
+ if (error instanceof NoTokenError) {
1542
+ // Not isError: a normal "not connected yet" state. Once `login` runs, the next call works.
1543
+ return {
1544
+ content: [
1545
+ {
1546
+ type: "text",
1547
+ text: `🔌 EchoMem isn't connected yet. ${CONNECT_DEVICE_INSTRUCTION} No editor restart is needed.`,
1548
+ },
1549
+ ],
1550
+ };
1551
+ }
1552
+ if (error instanceof LockedError) {
1553
+ // Not isError: this is a normal "needs unlock" state the model should relay to the user.
1554
+ return {
1555
+ content: [
1556
+ {
1557
+ type: "text",
1558
+ text: [
1559
+ "🔒 EchoMem vault is locked.",
1560
+ "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1561
+ `Action required from the user: ${UNLOCK_VAULT_INSTRUCTION}`,
1562
+ ...(DESKTOP_MANAGED ? [] : [
1563
+ "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1564
+ ]),
1565
+ "After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
1566
+ ].join("\n"),
1567
+ },
1568
+ ],
1569
+ };
1570
+ }
1571
+ if (error instanceof EncryptionStatusUnavailableError) {
1342
1572
  return {
1343
1573
  content: [{
1344
1574
  type: "text",
1345
- text: `Echo Desktop manages this MCP runtime (${MCP_PACKAGE_VERSION}). Install app updates from Echo Desktop, then start a new agent session.`,
1575
+ text: [
1576
+ "⚠️ EchoMem could not verify whether this account's vault is encrypted.",
1577
+ "No memory data was returned. Retry this action after the service recovers.",
1578
+ ].join("\n"),
1346
1579
  }],
1580
+ isError: true,
1347
1581
  };
1348
1582
  }
1349
- const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
1350
- const status = await checkLatestUpdateStatus({ force });
1351
- this.handleUpdateStatus(status);
1352
- return { content: [{ type: "text", text: formatUpdateStatusText(this.updateStatus ?? status) }] };
1353
- }
1354
- if (canonicalName === canonicalToolNames.contextHealth) {
1355
- const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
1356
- ? request.params.arguments.client
1357
- : "auto";
1358
- const mode = client === "codex" || client === "claude-code" || client === "claude-desktop" || client === "auto"
1359
- ? client
1360
- : "auto";
1361
- return { content: [{ type: "text", text: await contextHealthMarkdown(mode) }] };
1362
- }
1363
- if (canonicalName === canonicalToolNames.recompose) {
1364
- const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
1365
- ? request.params.arguments.client
1366
- : "auto";
1367
- const mode = client === "codex" || client === "claude-code" || client === "claude-desktop" || client === "auto"
1368
- ? client
1369
- : "auto";
1370
- const capsuleText = await recomposeCapsuleMarkdown(mode);
1371
- // If logged in, persist the capsule via passthrough so it's retrievable by contextId.
1372
- if (this.client.hasToken()) {
1373
- try {
1374
- const saveResult = await this.client.saveConversation({
1375
- conversation: capsuleText,
1376
- title: "Session capsule (recompose)",
1377
- source: "mcp_recompose",
1378
- passthrough: true,
1379
- });
1380
- const ctxId = saveResult?.contextId;
1381
- const capId = saveResult?.capsuleId;
1382
- const persistLine = ctxId
1383
- ? `\n\n---\nCapsule persisted${capId ? ` (${capId})` : ""}. To reload in a fresh session:\nget_memories_by_context({ contextId: "${ctxId}" })`
1384
- : "";
1385
- return { content: [{ type: "text", text: capsuleText + persistLine }] };
1386
- }
1387
- catch {
1388
- // Persistence is best-effort; return the capsule text regardless.
1389
- }
1583
+ if (error instanceof ZodError) {
1584
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
1585
+ }
1586
+ if (error instanceof McpError) {
1587
+ throw error;
1588
+ }
1589
+ const reconnectRequired = formatReconnectRequiredResult(error);
1590
+ if (reconnectRequired) {
1591
+ return {
1592
+ content: [{ type: "text", text: reconnectRequired }],
1593
+ isError: true,
1594
+ };
1595
+ }
1596
+ const upgradeRequired = formatUpgradeRequiredResult(error);
1597
+ if (upgradeRequired) {
1598
+ return {
1599
+ content: [{ type: "text", text: upgradeRequired }],
1600
+ };
1390
1601
  }
1391
- return { content: [{ type: "text", text: capsuleText }] };
1392
- }
1393
- if (!this.client.hasToken())
1394
- throw new NoTokenError();
1395
- if (canonicalName !== canonicalToolNames.save) {
1396
- void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase, `${analyticsCallId}:generic-called`);
1397
- void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase, `${analyticsCallId}:called`);
1398
- }
1399
- if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
1400
- void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
1401
- }
1402
- switch (canonicalName) {
1403
- case canonicalToolNames.search:
1404
- return await this.handleSearch(toolArgs, rec);
1405
- case canonicalToolNames.save:
1406
- return await this.handleSave(request.params.arguments, rec);
1407
- case canonicalToolNames.timeRange:
1408
- return await this.handleTimeRange(toolArgs);
1409
- case canonicalToolNames.getByContext:
1410
- return await this.handleGetByContext(request.params.arguments);
1411
- case canonicalToolNames.checkpointByContext:
1412
- return await this.handleGetCheckpointByContext(request.params.arguments);
1413
- case canonicalToolNames.keywords:
1414
- return await this.handleKeywords(toolArgs);
1415
- case canonicalToolNames.friends:
1416
- return await this.handleFriends(request.params.arguments);
1417
- case canonicalToolNames.searchUsers:
1418
- return await this.handleSearchUsers(request.params.arguments);
1419
- case canonicalToolNames.sendFriendRequest:
1420
- return await this.handleSendFriendRequest(request.params.arguments);
1421
- case canonicalToolNames.others:
1422
- return await this.handleOthers(toolArgs);
1423
- case canonicalToolNames.publicMemory:
1424
- return await this.handlePublicMemory(request.params.arguments);
1425
- case canonicalToolNames.recordCitations:
1426
- return await this.handleRecordMemoryCitations(request.params.arguments);
1427
- case canonicalToolNames.groupContext:
1428
- return await this.handleGroupContext(request.params.arguments);
1429
- case canonicalToolNames.getGroupSessionSharing:
1430
- return await this.handleGetGroupSessionSharing(request.params.arguments);
1431
- case canonicalToolNames.requestGroupSessionSharing:
1432
- return await this.handleRequestGroupSessionSharing(request.params.arguments);
1433
- case canonicalToolNames.setGroupSessionSharing:
1434
- return await this.handleSetGroupSessionSharing(request.params.arguments);
1435
- case canonicalToolNames.createGroup:
1436
- return await this.handleCreateGroup(request.params.arguments);
1437
- case canonicalToolNames.createGroupInvite:
1438
- return await this.handleCreateGroupInvite(request.params.arguments);
1439
- case canonicalToolNames.joinGroup:
1440
- return await this.handleJoinGroup(request.params.arguments);
1441
- case canonicalToolNames.prepareGroupPublication:
1442
- return await this.handlePrepareGroupPublication(request.params.arguments);
1443
- case canonicalToolNames.flagPublicationAttention:
1444
- return await this.handleFlagPublicationAttention(request.params.arguments);
1445
- case canonicalToolNames.updateGroupProfile:
1446
- return await this.handleUpdateGroupProfile(request.params.arguments);
1447
- case canonicalToolNames.completeGroupPublication:
1448
- return await this.handleCompleteGroupPublication(request.params.arguments);
1449
- case canonicalToolNames.publishToGroup:
1450
- return await this.handlePublishToGroup(request.params.arguments);
1451
- case canonicalToolNames.publishBatchToGroup:
1452
- return await this.handlePublishBatchToGroup(request.params.arguments);
1453
- case canonicalToolNames.delete:
1454
- return await this.handleDelete(request.params.arguments, rec);
1455
- default:
1456
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
1457
- }
1458
- }
1459
- catch (error) {
1460
- rec.error_kind = classifyError(error);
1461
- if (error instanceof NoTokenError) {
1462
- // Not isError: a normal "not connected yet" state. Once `login` runs, the next call works.
1463
- return {
1464
- content: [
1465
- {
1466
- type: "text",
1467
- text: `🔌 EchoMem isn't connected yet. ${CONNECT_DEVICE_INSTRUCTION} No editor restart is needed.`,
1468
- },
1469
- ],
1470
- };
1471
- }
1472
- if (error instanceof LockedError) {
1473
- // Not isError: this is a normal "needs unlock" state the model should relay to the user.
1474
- return {
1475
- content: [
1476
- {
1477
- type: "text",
1478
- text: [
1479
- "🔒 EchoMem vault is locked.",
1480
- "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1481
- `Action required from the user: ${UNLOCK_VAULT_INSTRUCTION}`,
1482
- ...(DESKTOP_MANAGED ? [] : [
1483
- "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1484
- ]),
1485
- "After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
1486
- ].join("\n"),
1487
- },
1488
- ],
1489
- };
1490
- }
1491
- if (error instanceof EncryptionStatusUnavailableError) {
1492
- return {
1493
- content: [{
1494
- type: "text",
1495
- text: [
1496
- "⚠️ EchoMem could not verify whether this account's vault is encrypted.",
1497
- "No memory data was returned. Retry this action after the service recovers.",
1498
- ].join("\n"),
1499
- }],
1500
- isError: true,
1501
- };
1502
- }
1503
- if (error instanceof ZodError) {
1504
- throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
1505
- }
1506
- if (error instanceof McpError) {
1507
- throw error;
1508
- }
1509
- const reconnectRequired = formatReconnectRequiredResult(error);
1510
- if (reconnectRequired) {
1511
1602
  return {
1512
- content: [{ type: "text", text: reconnectRequired }],
1603
+ content: [{ type: "text", text: `Error: ${describeError(error)}` }],
1513
1604
  isError: true,
1514
1605
  };
1515
1606
  }
1516
- const upgradeRequired = formatUpgradeRequiredResult(error);
1517
- if (upgradeRequired) {
1518
- return {
1519
- content: [{ type: "text", text: upgradeRequired }],
1520
- };
1521
- }
1522
- return {
1523
- content: [{ type: "text", text: `Error: ${describeError(error)}` }],
1524
- isError: true,
1525
- };
1526
- }
1527
- finally {
1528
- if (!rec.error_kind)
1529
- rec.error_kind = "none";
1530
- rec.ok = rec.error_kind === "none";
1531
- rec.latency_ms = Date.now() - t0;
1532
- this.events.record(rec);
1533
- if (canonicalName !== canonicalToolNames.report &&
1534
- canonicalName !== canonicalToolNames.updateStatus &&
1535
- canonicalName !== canonicalToolNames.contextHealth &&
1536
- canonicalName !== canonicalToolNames.recompose &&
1537
- canonicalName !== canonicalToolNames.save &&
1538
- this.client.hasToken()) {
1539
- const finalAnalytics = {
1540
- ...analyticsBase,
1541
- success: rec.ok,
1542
- duration_ms: rec.latency_ms,
1543
- error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
1544
- result_count: rec.results_count,
1545
- returned_text_length: rec.results_chars,
1546
- tuned: rec.tuned,
1547
- map_injected: rec.map_injected,
1548
- encrypted_user: rec.encrypted,
1549
- extracted_memory_count: rec.memories_extracted,
1550
- // Preserve only the safe correlation fields needed to join a
1551
- // bridge-local failure to the central operational trace. Query
1552
- // text and upstream bodies never leave the local event stream.
1553
- request_id: rec.request_id,
1554
- endpoint: rec.endpoint,
1555
- http_status: rec.http_status,
1556
- error_code: rec.error_code ?? (rec.error_kind === "none" ? undefined : rec.error_kind),
1557
- };
1558
- void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics, `${analyticsCallId}:generic-completed`);
1559
- void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics, `${analyticsCallId}:completed`);
1607
+ finally {
1608
+ if (!rec.error_kind)
1609
+ rec.error_kind = "none";
1610
+ rec.ok = rec.error_kind === "none";
1611
+ rec.latency_ms = Date.now() - t0;
1612
+ this.events.record(rec);
1613
+ if (canonicalName !== canonicalToolNames.report &&
1614
+ canonicalName !== canonicalToolNames.updateStatus &&
1615
+ canonicalName !== canonicalToolNames.contextHealth &&
1616
+ canonicalName !== canonicalToolNames.recompose &&
1617
+ canonicalName !== canonicalToolNames.save &&
1618
+ this.client.hasToken()) {
1619
+ const finalAnalytics = {
1620
+ ...analyticsBase,
1621
+ success: rec.ok,
1622
+ duration_ms: rec.latency_ms,
1623
+ error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
1624
+ result_count: rec.results_count,
1625
+ returned_text_length: rec.results_chars,
1626
+ tuned: rec.tuned,
1627
+ map_injected: rec.map_injected,
1628
+ encrypted_user: rec.encrypted,
1629
+ extracted_memory_count: rec.memories_extracted,
1630
+ // Preserve only the safe correlation fields needed to join a
1631
+ // bridge-local failure to the central operational trace. Query
1632
+ // text and upstream bodies never leave the local event stream.
1633
+ request_id: rec.request_id,
1634
+ endpoint: rec.endpoint,
1635
+ http_status: rec.http_status,
1636
+ error_code: rec.error_code ?? (rec.error_kind === "none" ? undefined : rec.error_kind),
1637
+ };
1638
+ void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics, `${analyticsCallId}:generic-completed`);
1639
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics, `${analyticsCallId}:completed`);
1640
+ }
1560
1641
  }
1561
- }
1642
+ });
1562
1643
  });
1563
1644
  }
1564
1645
  async handleSearch(args, rec) {
@@ -1771,6 +1852,24 @@ Details: ${m.details || "N/A"}`)
1771
1852
  ].filter(Boolean).join("\n\n");
1772
1853
  return { content: [{ type: "text", text }] };
1773
1854
  }
1855
+ async handleBindSourceSession(args) {
1856
+ const parsed = bindSourceSessionSchema.parse(args ?? {});
1857
+ const verified = resolveSourceSessionFromBindingToken(parsed.bindingToken, {
1858
+ hostPlatform: this.getMcpClientAnalytics().host_platform,
1859
+ });
1860
+ const bound = await this.client.bindSourceSession(verified);
1861
+ return {
1862
+ content: [{
1863
+ type: "text",
1864
+ text: [
1865
+ bound.created ? "Created and bound the source-session context." : "Source-session context is bound.",
1866
+ `Context: ${bound.contextId}`,
1867
+ `Source session: ${bound.canonicalKey}`,
1868
+ "Later EchoMem saves, recalls, citations, sharing decisions, and activity from this bridge will use this context automatically.",
1869
+ ].join("\n"),
1870
+ }],
1871
+ };
1872
+ }
1774
1873
  async handleTimeRange(args) {
1775
1874
  const parsed = timeRangeSchema.parse(args ?? {});
1776
1875
  const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
@@ -1911,6 +2010,28 @@ Details: ${m.details || "N/A"}`)
1911
2010
  async handleOthers(args) {
1912
2011
  const parsed = othersSchema.parse(args ?? {});
1913
2012
  const payload = await this.client.searchOthersMemories(args);
2013
+ if (payload?.requiresWorkspaceSelection === true) {
2014
+ const availableWorkspaces = Array.isArray(payload?.availableWorkspaces)
2015
+ ? payload.availableWorkspaces.filter(isRecord)
2016
+ : [];
2017
+ const choices = availableWorkspaces
2018
+ .map((workspace) => `${readString(workspace, "name") ?? "Unnamed workspace"} (${readString(workspace, "id") ?? "unknown id"})`)
2019
+ .join(", ");
2020
+ return {
2021
+ content: [{
2022
+ type: "text",
2023
+ text: `You belong to ${availableWorkspaces.length} workspaces: ${choices}. Ask the user which workspace's teammates to search, then call search_others_memories again with workspaceId set to one of those ids. (To search a specific friend instead, pass that person as target.)`,
2024
+ }],
2025
+ };
2026
+ }
2027
+ if (payload?.groupScopeUnavailable === true) {
2028
+ return {
2029
+ content: [{
2030
+ type: "text",
2031
+ text: "You are not in a company workspace, so there are no teammates to search. To search friends instead, call search_others_memories again with scope \"friends\", or pass a specific person as target.",
2032
+ }],
2033
+ };
2034
+ }
1914
2035
  const memories = payload?.memories ?? [];
1915
2036
  const authenticatedViewer = isRecord(payload?.authenticatedViewer)
1916
2037
  ? payload.authenticatedViewer
@@ -2132,6 +2253,20 @@ Details: ${m.details || "N/A"}`;
2132
2253
  async handleGroupContext(args) {
2133
2254
  groupContextSchema.parse(args ?? {});
2134
2255
  const payload = await this.client.getGroupContext(args);
2256
+ if (payload?.requiresWorkspaceSelection === true) {
2257
+ const availableWorkspaces = Array.isArray(payload?.availableWorkspaces)
2258
+ ? payload.availableWorkspaces.filter(isRecord)
2259
+ : [];
2260
+ const choices = availableWorkspaces
2261
+ .map((workspace) => `${readString(workspace, "name") ?? "Unnamed workspace"} (${readString(workspace, "id") ?? "unknown id"})`)
2262
+ .join(", ");
2263
+ return {
2264
+ content: [{
2265
+ type: "text",
2266
+ text: `You belong to ${availableWorkspaces.length} workspaces: ${choices}. Ask the user which workspace to show context for, then call get_group_context again with workspaceId set to one of those ids.`,
2267
+ }],
2268
+ };
2269
+ }
2135
2270
  const group = isRecord(payload?.group) ? payload.group : null;
2136
2271
  const participants = Array.isArray(payload?.participants)
2137
2272
  ? payload.participants.filter(isRecord)
@@ -2236,7 +2371,7 @@ Details: ${m.details || "N/A"}`;
2236
2371
  sharingPromptText(scopeText, groupName) {
2237
2372
  return [
2238
2373
  scopeText,
2239
- `Ask exactly: “Share eligible memories saved from this conversation with ${groupName}?” Only an explicit Yes or No may be passed to set_group_session_sharing. Silence leaves the state unset.`,
2374
+ `Ask exactly: “Share eligible memories saved from this conversation with ${groupName}?” If your host can render a single-choice prompt, present this as one question with exactly two options — “Yes — share with team” and “No — keep private” — instead of free-form text. Only an explicit Yes or No may be passed to set_group_session_sharing. Silence leaves the state unset.`,
2240
2375
  ].filter(Boolean).join("\n\n");
2241
2376
  }
2242
2377
  formatGroupSessionSharingUpdate(share, payload, scopeText = "") {