@echomem/mcp 1.4.39 → 1.4.41

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) ||
@@ -1297,7 +1344,7 @@ class EchoMemMCPServer {
1297
1344
  const updateNotice = DESKTOP_MANAGED ? undefined : formatUpdateNotice(this.updateStatus);
1298
1345
  return { tools: listToolSpecs({ map, groupMap, updateNotice }) };
1299
1346
  });
1300
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1347
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1301
1348
  const resolvedCanonicalName = resolveCanonicalToolName(request.params.name);
1302
1349
  const recallRoute = routePersonalRecallInvocation(resolvedCanonicalName, request.params.arguments);
1303
1350
  const canonicalName = recallRoute.canonicalName;
@@ -1307,258 +1354,277 @@ class EchoMemMCPServer {
1307
1354
  const clientVersion = this.server.getClientVersion();
1308
1355
  this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
1309
1356
  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
- };
1357
+ let requestSourceSession = this.client.getBoundSourceSession();
1358
+ let inferred = null;
1332
1359
  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) }] };
1360
+ inferred = resolveSourceSessionFromMcpContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
1361
+ }
1362
+ catch {
1363
+ // Malformed host metadata is not model input. Ignore it and retain the explicit fallback.
1364
+ }
1365
+ // Verified request metadata always wins over a bridge-level compatibility fallback. The
1366
+ // startup hook performs the eager write; this path attaches the exact context to the current
1367
+ // request and retries the backend write if startup raced login.
1368
+ if (inferred && this.client.hasToken()) {
1369
+ requestSourceSession = await this.client.bindSourceSession(inferred, false);
1370
+ }
1371
+ return this.client.withSourceSession(requestSourceSession, async () => {
1372
+ const t0 = Date.now();
1373
+ const analyticsBase = {
1374
+ surface: "mcp",
1375
+ event_family: "mcp",
1376
+ integration: "echomem_mcp",
1377
+ telemetry_source: "local_bridge",
1378
+ ...this.getMcpClientAnalytics(),
1379
+ codex_session_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1380
+ conversation_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1381
+ context_id: this.client.getBoundSourceSession()?.contextId,
1382
+ tool_name: request.params.name,
1383
+ canonical_tool_name: canonicalName,
1384
+ ...triggerAnalyticsForTool(canonicalName, toolArgs),
1385
+ ...inputAnalyticsForTool(canonicalName, toolArgs),
1386
+ };
1387
+ const analyticsCallId = randomUUID();
1388
+ // One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
1389
+ const rec = {
1390
+ type: "tool_call",
1391
+ tool: canonicalName,
1392
+ map_injected: this.mapInjected,
1393
+ group_map_injected: this.groupMapInjected,
1394
+ };
1395
+ try {
1396
+ if (recallRoute.error) {
1397
+ throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
1398
+ }
1399
+ // The usage report is a local, $0 audit — works with no login (value before signup).
1400
+ if (canonicalName === canonicalToolNames.report) {
1401
+ return { content: [{ type: "text", text: await buildReportText(false) }] };
1402
+ }
1403
+ if (canonicalName === canonicalToolNames.updateStatus) {
1404
+ if (DESKTOP_MANAGED) {
1405
+ return {
1406
+ content: [{
1407
+ type: "text",
1408
+ text: `Echo Desktop manages this MCP runtime (${MCP_PACKAGE_VERSION}). Install app updates from Echo Desktop, then start a new agent session.`,
1409
+ }],
1410
+ };
1411
+ }
1412
+ const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
1413
+ const status = await checkLatestUpdateStatus({ force });
1414
+ this.handleUpdateStatus(status);
1415
+ return { content: [{ type: "text", text: formatUpdateStatusText(this.updateStatus ?? status) }] };
1416
+ }
1417
+ if (canonicalName === canonicalToolNames.contextHealth) {
1418
+ const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
1419
+ ? request.params.arguments.client
1420
+ : "auto";
1421
+ const mode = client === "codex" || client === "claude-code" || client === "claude-desktop" || client === "auto"
1422
+ ? client
1423
+ : "auto";
1424
+ return { content: [{ type: "text", text: await contextHealthMarkdown(mode) }] };
1425
+ }
1426
+ if (canonicalName === canonicalToolNames.recompose) {
1427
+ const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
1428
+ ? request.params.arguments.client
1429
+ : "auto";
1430
+ const mode = client === "codex" || client === "claude-code" || client === "claude-desktop" || client === "auto"
1431
+ ? client
1432
+ : "auto";
1433
+ const capsuleText = await recomposeCapsuleMarkdown(mode);
1434
+ // If logged in, persist the capsule via passthrough so it's retrievable by contextId.
1435
+ if (this.client.hasToken()) {
1436
+ try {
1437
+ const saveResult = await this.client.saveConversation({
1438
+ conversation: capsuleText,
1439
+ title: "Session capsule (recompose)",
1440
+ source: "mcp_recompose",
1441
+ passthrough: true,
1442
+ });
1443
+ const ctxId = saveResult?.contextId;
1444
+ const capId = saveResult?.capsuleId;
1445
+ const persistLine = ctxId
1446
+ ? `\n\n---\nCapsule persisted${capId ? ` (${capId})` : ""}. To reload in a fresh session:\nget_memories_by_context({ contextId: "${ctxId}" })`
1447
+ : "";
1448
+ return { content: [{ type: "text", text: capsuleText + persistLine }] };
1449
+ }
1450
+ catch {
1451
+ // Persistence is best-effort; return the capsule text regardless.
1452
+ }
1453
+ }
1454
+ return { content: [{ type: "text", text: capsuleText }] };
1455
+ }
1456
+ if (!this.client.hasToken())
1457
+ throw new NoTokenError();
1458
+ if (canonicalName !== canonicalToolNames.save) {
1459
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase, `${analyticsCallId}:generic-called`);
1460
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase, `${analyticsCallId}:called`);
1461
+ }
1462
+ if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
1463
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
1464
+ }
1465
+ switch (canonicalName) {
1466
+ case canonicalToolNames.bindSourceSession:
1467
+ return await this.handleBindSourceSession(request.params.arguments);
1468
+ case canonicalToolNames.search:
1469
+ return await this.handleSearch(toolArgs, rec);
1470
+ case canonicalToolNames.save:
1471
+ return await this.handleSave(request.params.arguments, rec);
1472
+ case canonicalToolNames.timeRange:
1473
+ return await this.handleTimeRange(toolArgs);
1474
+ case canonicalToolNames.getByContext:
1475
+ return await this.handleGetByContext(request.params.arguments);
1476
+ case canonicalToolNames.checkpointByContext:
1477
+ return await this.handleGetCheckpointByContext(request.params.arguments);
1478
+ case canonicalToolNames.keywords:
1479
+ return await this.handleKeywords(toolArgs);
1480
+ case canonicalToolNames.friends:
1481
+ return await this.handleFriends(request.params.arguments);
1482
+ case canonicalToolNames.searchUsers:
1483
+ return await this.handleSearchUsers(request.params.arguments);
1484
+ case canonicalToolNames.sendFriendRequest:
1485
+ return await this.handleSendFriendRequest(request.params.arguments);
1486
+ case canonicalToolNames.others:
1487
+ return await this.handleOthers(toolArgs);
1488
+ case canonicalToolNames.publicMemory:
1489
+ return await this.handlePublicMemory(request.params.arguments);
1490
+ case canonicalToolNames.recordCitations:
1491
+ return await this.handleRecordMemoryCitations(request.params.arguments);
1492
+ case canonicalToolNames.groupContext:
1493
+ return await this.handleGroupContext(request.params.arguments);
1494
+ case canonicalToolNames.getGroupSessionSharing:
1495
+ return await this.handleGetGroupSessionSharing(request.params.arguments);
1496
+ case canonicalToolNames.requestGroupSessionSharing:
1497
+ return await this.handleRequestGroupSessionSharing(request.params.arguments);
1498
+ case canonicalToolNames.setGroupSessionSharing:
1499
+ return await this.handleSetGroupSessionSharing(request.params.arguments);
1500
+ case canonicalToolNames.createGroup:
1501
+ return await this.handleCreateGroup(request.params.arguments);
1502
+ case canonicalToolNames.createGroupInvite:
1503
+ return await this.handleCreateGroupInvite(request.params.arguments);
1504
+ case canonicalToolNames.joinGroup:
1505
+ return await this.handleJoinGroup(request.params.arguments);
1506
+ case canonicalToolNames.prepareGroupPublication:
1507
+ return await this.handlePrepareGroupPublication(request.params.arguments);
1508
+ case canonicalToolNames.flagPublicationAttention:
1509
+ return await this.handleFlagPublicationAttention(request.params.arguments);
1510
+ case canonicalToolNames.updateGroupProfile:
1511
+ return await this.handleUpdateGroupProfile(request.params.arguments);
1512
+ case canonicalToolNames.completeGroupPublication:
1513
+ return await this.handleCompleteGroupPublication(request.params.arguments);
1514
+ case canonicalToolNames.publishToGroup:
1515
+ return await this.handlePublishToGroup(request.params.arguments);
1516
+ case canonicalToolNames.publishBatchToGroup:
1517
+ return await this.handlePublishBatchToGroup(request.params.arguments);
1518
+ case canonicalToolNames.delete:
1519
+ return await this.handleDelete(request.params.arguments, rec);
1520
+ default:
1521
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
1522
+ }
1339
1523
  }
1340
- if (canonicalName === canonicalToolNames.updateStatus) {
1341
- if (DESKTOP_MANAGED) {
1524
+ catch (error) {
1525
+ rec.error_kind = classifyError(error);
1526
+ if (error instanceof NoTokenError) {
1527
+ // Not isError: a normal "not connected yet" state. Once `login` runs, the next call works.
1528
+ return {
1529
+ content: [
1530
+ {
1531
+ type: "text",
1532
+ text: `🔌 EchoMem isn't connected yet. ${CONNECT_DEVICE_INSTRUCTION} No editor restart is needed.`,
1533
+ },
1534
+ ],
1535
+ };
1536
+ }
1537
+ if (error instanceof LockedError) {
1538
+ // Not isError: this is a normal "needs unlock" state the model should relay to the user.
1539
+ return {
1540
+ content: [
1541
+ {
1542
+ type: "text",
1543
+ text: [
1544
+ "🔒 EchoMem vault is locked.",
1545
+ "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1546
+ `Action required from the user: ${UNLOCK_VAULT_INSTRUCTION}`,
1547
+ ...(DESKTOP_MANAGED ? [] : [
1548
+ "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1549
+ ]),
1550
+ "After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
1551
+ ].join("\n"),
1552
+ },
1553
+ ],
1554
+ };
1555
+ }
1556
+ if (error instanceof EncryptionStatusUnavailableError) {
1342
1557
  return {
1343
1558
  content: [{
1344
1559
  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.`,
1560
+ text: [
1561
+ "⚠️ EchoMem could not verify whether this account's vault is encrypted.",
1562
+ "No memory data was returned. Retry this action after the service recovers.",
1563
+ ].join("\n"),
1346
1564
  }],
1565
+ isError: true,
1347
1566
  };
1348
1567
  }
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
- }
1568
+ if (error instanceof ZodError) {
1569
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
1570
+ }
1571
+ if (error instanceof McpError) {
1572
+ throw error;
1573
+ }
1574
+ const reconnectRequired = formatReconnectRequiredResult(error);
1575
+ if (reconnectRequired) {
1576
+ return {
1577
+ content: [{ type: "text", text: reconnectRequired }],
1578
+ isError: true,
1579
+ };
1580
+ }
1581
+ const upgradeRequired = formatUpgradeRequiredResult(error);
1582
+ if (upgradeRequired) {
1583
+ return {
1584
+ content: [{ type: "text", text: upgradeRequired }],
1585
+ };
1390
1586
  }
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
1587
  return {
1512
- content: [{ type: "text", text: reconnectRequired }],
1588
+ content: [{ type: "text", text: `Error: ${describeError(error)}` }],
1513
1589
  isError: true,
1514
1590
  };
1515
1591
  }
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`);
1592
+ finally {
1593
+ if (!rec.error_kind)
1594
+ rec.error_kind = "none";
1595
+ rec.ok = rec.error_kind === "none";
1596
+ rec.latency_ms = Date.now() - t0;
1597
+ this.events.record(rec);
1598
+ if (canonicalName !== canonicalToolNames.report &&
1599
+ canonicalName !== canonicalToolNames.updateStatus &&
1600
+ canonicalName !== canonicalToolNames.contextHealth &&
1601
+ canonicalName !== canonicalToolNames.recompose &&
1602
+ canonicalName !== canonicalToolNames.save &&
1603
+ this.client.hasToken()) {
1604
+ const finalAnalytics = {
1605
+ ...analyticsBase,
1606
+ success: rec.ok,
1607
+ duration_ms: rec.latency_ms,
1608
+ error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
1609
+ result_count: rec.results_count,
1610
+ returned_text_length: rec.results_chars,
1611
+ tuned: rec.tuned,
1612
+ map_injected: rec.map_injected,
1613
+ encrypted_user: rec.encrypted,
1614
+ extracted_memory_count: rec.memories_extracted,
1615
+ // Preserve only the safe correlation fields needed to join a
1616
+ // bridge-local failure to the central operational trace. Query
1617
+ // text and upstream bodies never leave the local event stream.
1618
+ request_id: rec.request_id,
1619
+ endpoint: rec.endpoint,
1620
+ http_status: rec.http_status,
1621
+ error_code: rec.error_code ?? (rec.error_kind === "none" ? undefined : rec.error_kind),
1622
+ };
1623
+ void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics, `${analyticsCallId}:generic-completed`);
1624
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics, `${analyticsCallId}:completed`);
1625
+ }
1560
1626
  }
1561
- }
1627
+ });
1562
1628
  });
1563
1629
  }
1564
1630
  async handleSearch(args, rec) {
@@ -1771,6 +1837,24 @@ Details: ${m.details || "N/A"}`)
1771
1837
  ].filter(Boolean).join("\n\n");
1772
1838
  return { content: [{ type: "text", text }] };
1773
1839
  }
1840
+ async handleBindSourceSession(args) {
1841
+ const parsed = bindSourceSessionSchema.parse(args ?? {});
1842
+ const verified = resolveSourceSessionFromBindingToken(parsed.bindingToken, {
1843
+ hostPlatform: this.getMcpClientAnalytics().host_platform,
1844
+ });
1845
+ const bound = await this.client.bindSourceSession(verified);
1846
+ return {
1847
+ content: [{
1848
+ type: "text",
1849
+ text: [
1850
+ bound.created ? "Created and bound the source-session context." : "Source-session context is bound.",
1851
+ `Context: ${bound.contextId}`,
1852
+ `Source session: ${bound.canonicalKey}`,
1853
+ "Later EchoMem saves, recalls, citations, sharing decisions, and activity from this bridge will use this context automatically.",
1854
+ ].join("\n"),
1855
+ }],
1856
+ };
1857
+ }
1774
1858
  async handleTimeRange(args) {
1775
1859
  const parsed = timeRangeSchema.parse(args ?? {});
1776
1860
  const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
@@ -2236,7 +2320,7 @@ Details: ${m.details || "N/A"}`;
2236
2320
  sharingPromptText(scopeText, groupName) {
2237
2321
  return [
2238
2322
  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.`,
2323
+ `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
2324
  ].filter(Boolean).join("\n\n");
2241
2325
  }
2242
2326
  formatGroupSessionSharingUpdate(share, payload, scopeText = "") {