@echomem/mcp 1.4.50 → 1.4.52

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,7 +4,7 @@ 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 { 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";
7
+ import { bindSourceSessionSchema, canonicalToolNames, completeGroupPublicationSchema, createGroupInviteSchema, createGroupSchema, deleteMemorySchema, flagPublicationAttentionSchema, getGroupSessionSharingSchema, getByContextSchema, groupContextSchema, joinGroupSchema, keywordsSchema, listFriendsSchema, listToolSpecs, linkWorkspaceTicketSessionSchema, 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 { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
@@ -16,7 +16,7 @@ import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTI
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
17
17
  import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
18
18
  import { autoUpdateHeadlessRuntime } from "./headless-runtime.js";
19
- import { resolveSourceSessionFromMcpContext, resolveSourceSessionFromBindingToken, } from "./source-session.js";
19
+ import { resolveSourceSessionRequestContext, resolveSourceSessionFromBindingToken, } from "./source-session.js";
20
20
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
21
21
  const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
22
22
  const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
@@ -169,6 +169,17 @@ function formatReconnectRequiredResult(error) {
169
169
  "No editor restart is needed. Keep all credentials out of chat.",
170
170
  ].join("\n");
171
171
  }
172
+ function formatWorkspaceTicketNotFoundResult(error) {
173
+ if (!axios.isAxiosError(error) || error.response?.status !== 404)
174
+ return null;
175
+ if (errorCodeFrom(error.response.data) !== "WORKSPACE_TICKET_NOT_FOUND")
176
+ return null;
177
+ return [
178
+ "🎫 This Echo workspace ticket no longer exists or is unavailable.",
179
+ "Do not retry this ticket ID.",
180
+ "Ask the user whether to continue without a ticket or attach this session to another ticket.",
181
+ ].join("\n");
182
+ }
172
183
  /** Map a thrown error to the telemetry error_kind taxonomy. */
173
184
  function classifyError(error) {
174
185
  if (error instanceof NoTokenError)
@@ -339,6 +350,9 @@ function detectMcpHostFromEnv() {
339
350
  return "cursor";
340
351
  if (process.env.WINDSURF_WORKSPACE_ID || process.env.WINDSURF_USER_ID)
341
352
  return "windsurf";
353
+ // Cowork embeds a Claude Code process, but this outer host ID is the distinguishing signal.
354
+ if (process.env.CLAUDE_CODE_HOST_SESSION_ID)
355
+ return "cowork";
342
356
  if (process.env.CLAUDE_CODE || process.env.CLAUDECODE || process.env.ANTHROPIC_CLAUDE_CODE) {
343
357
  return "claude_code";
344
358
  }
@@ -582,6 +596,14 @@ function inputAnalyticsForTool(canonicalName, args) {
582
596
  receipt_id_hash: receiptId ? hashText(receiptId) : undefined,
583
597
  };
584
598
  }
599
+ case canonicalToolNames.linkWorkspaceTicketSession: {
600
+ const ticketId = readString(a, "ticketId");
601
+ const workspaceId = readString(a, "workspaceId");
602
+ return {
603
+ ticket_id_hash: ticketId ? hashText(ticketId) : undefined,
604
+ workspace_id_hash: workspaceId ? hashText(workspaceId) : undefined,
605
+ };
606
+ }
585
607
  case canonicalToolNames.flagPublicationAttention: {
586
608
  const memoryIds = Array.isArray(a.memoryIds) ? a.memoryIds.filter((id) => typeof id === "string") : [];
587
609
  return {
@@ -609,13 +631,22 @@ function inputAnalyticsForTool(canonicalName, args) {
609
631
  function toolEventName(canonicalName, status) {
610
632
  return `[MCP] ${canonicalName} ${status}`;
611
633
  }
634
+ const SUBAGENT_DURABLE_WRITE_MESSAGE = [
635
+ "Codex subagents cannot save EchoMem memories or manage conversation sharing.",
636
+ "Return durable findings to the root agent; the root agent must consolidate them and call save_conversation once.",
637
+ ].join(" ");
638
+ const SUBAGENT_ROOT_ONLY_TOOLS = new Set([
639
+ canonicalToolNames.save,
640
+ canonicalToolNames.requestGroupSessionSharing,
641
+ canonicalToolNames.setGroupSessionSharing,
642
+ ]);
612
643
  class EchoMemApiClient {
613
644
  store;
614
645
  axios;
615
646
  activeToken;
616
647
  accountGeneration = 0;
617
648
  boundSourceSession = null;
618
- requestSourceSession = new AsyncLocalStorage();
649
+ requestContext = new AsyncLocalStorage();
619
650
  sourceSessionsByCanonicalKey = new Map();
620
651
  whoamiCache = null;
621
652
  /** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
@@ -677,10 +708,20 @@ class EchoMemApiClient {
677
708
  }
678
709
  getBoundSourceSession() {
679
710
  this.synchronizeAccountContext();
680
- return this.requestSourceSession.getStore() ?? this.boundSourceSession;
711
+ const requestContext = this.requestContext.getStore();
712
+ return requestContext === undefined ? this.boundSourceSession : requestContext.sourceSession;
681
713
  }
682
- async withSourceSession(sourceSession, operation) {
683
- return sourceSession ? this.requestSourceSession.run(sourceSession, operation) : operation();
714
+ getRequestVerifiedSourceSession() {
715
+ const requestContext = this.requestContext.getStore();
716
+ if (!requestContext || requestContext.lineageStatus === "unbound")
717
+ return null;
718
+ return requestContext.sourceSession;
719
+ }
720
+ isSubagentRequest() {
721
+ return this.requestContext.getStore()?.isSubagent === true;
722
+ }
723
+ async withRequestContext(requestContext, operation) {
724
+ return this.requestContext.run(requestContext, operation);
684
725
  }
685
726
  async bindSourceSession(verified, persistForBridge = true) {
686
727
  this.synchronizeAccountContext();
@@ -714,6 +755,21 @@ class EchoMemApiClient {
714
755
  created: response.data?.created === true,
715
756
  };
716
757
  }
758
+ async linkWorkspaceTicketSession(args) {
759
+ const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
760
+ // A bridge-level compatibility binding can outlive a conversation in
761
+ // long-running hosts. Ticket links are therefore allowed to take the fast
762
+ // path only from identity verified for this exact request; otherwise the
763
+ // Desktop transcript watcher completes the link from local evidence.
764
+ const sourceSession = this.getRequestVerifiedSourceSession();
765
+ if (!sourceSession)
766
+ return null;
767
+ const response = await this.axios.post(`/api/extension/workspace-tickets/${encodeURIComponent(parsed.ticketId)}/sessions`, {
768
+ contextId: sourceSession.contextId,
769
+ workspaceId: parsed.workspaceId,
770
+ });
771
+ return response.data;
772
+ }
717
773
  async trackMcpAnalyticsEvent(eventType, eventProperties, insertId) {
718
774
  if (!this.hasToken())
719
775
  return;
@@ -966,6 +1022,9 @@ class EchoMemApiClient {
966
1022
  return data;
967
1023
  }
968
1024
  async saveConversation(args) {
1025
+ if (this.isSubagentRequest()) {
1026
+ throw new Error(SUBAGENT_DURABLE_WRITE_MESSAGE);
1027
+ }
969
1028
  const parsed = saveConversationSchema.parse(args ?? {});
970
1029
  const groupSharingScopeId = parsed.groupSharingScopeId ?? randomUUID();
971
1030
  let rawData = parsed.conversation?.trim() || "";
@@ -984,6 +1043,7 @@ class EchoMemApiClient {
984
1043
  const config = {
985
1044
  headers: {
986
1045
  "X-EchoMem-Request-Id": randomUUID(),
1046
+ "X-EchoMem-Origin-Channel": "local_mcp",
987
1047
  ...(enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : {}),
988
1048
  },
989
1049
  };
@@ -1365,8 +1425,10 @@ class EchoMemMCPServer {
1365
1425
  });
1366
1426
  }
1367
1427
  getMcpClientAnalytics() {
1368
- const hostPlatform = normalizeMcpHostPlatform(this.mcpClientName)
1369
- ?? detectMcpHostFromEnv()
1428
+ const environmentPlatform = detectMcpHostFromEnv();
1429
+ const hostPlatform = (environmentPlatform === "cowork" ? environmentPlatform : undefined)
1430
+ ?? normalizeMcpHostPlatform(this.mcpClientName)
1431
+ ?? environmentPlatform
1370
1432
  ?? "unknown";
1371
1433
  return {
1372
1434
  mcp_client_name: this.mcpClientName,
@@ -1429,21 +1491,32 @@ class EchoMemMCPServer {
1429
1491
  const clientVersion = this.server.getClientVersion();
1430
1492
  this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
1431
1493
  this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
1432
- let requestSourceSession = this.client.getBoundSourceSession();
1433
- let inferred = null;
1494
+ let sourceResolution = {
1495
+ sourceSession: null,
1496
+ isSubagent: false,
1497
+ lineageStatus: "unbound",
1498
+ };
1434
1499
  try {
1435
- inferred = resolveSourceSessionFromMcpContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
1500
+ sourceResolution = resolveSourceSessionRequestContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
1436
1501
  }
1437
1502
  catch {
1438
1503
  // Malformed host metadata is not model input. Ignore it and retain the explicit fallback.
1439
1504
  }
1505
+ // A child request starts fail-closed. Resolved children bind to the originating root; unresolved
1506
+ // children carry an explicit null so AsyncLocalStorage cannot fall through to a global binding.
1507
+ let requestSourceSession = sourceResolution.isSubagent
1508
+ ? null
1509
+ : this.client.getBoundSourceSession();
1440
1510
  // Verified request metadata always wins over a bridge-level compatibility fallback. The
1441
1511
  // startup hook performs the eager write; this path attaches the exact context to the current
1442
1512
  // request and retries the backend write if startup raced login.
1443
- if (inferred && this.client.hasToken()) {
1444
- requestSourceSession = await this.client.bindSourceSession(inferred, false);
1513
+ if (sourceResolution.sourceSession && this.client.hasToken()) {
1514
+ requestSourceSession = await this.client.bindSourceSession(sourceResolution.sourceSession, false);
1445
1515
  }
1446
- return this.client.withSourceSession(requestSourceSession, async () => {
1516
+ return this.client.withRequestContext({
1517
+ ...sourceResolution,
1518
+ sourceSession: requestSourceSession,
1519
+ }, async () => {
1447
1520
  const t0 = Date.now();
1448
1521
  const analyticsBase = {
1449
1522
  surface: "mcp",
@@ -1454,6 +1527,18 @@ class EchoMemMCPServer {
1454
1527
  codex_session_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1455
1528
  conversation_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
1456
1529
  context_id: this.client.getBoundSourceSession()?.contextId,
1530
+ agent_is_subagent: sourceResolution.isSubagent,
1531
+ agent_lineage_status: sourceResolution.lineageStatus,
1532
+ agent_depth: sourceResolution.agentDepth,
1533
+ agent_thread_id_hash: sourceResolution.agentThreadId
1534
+ ? hashText(sourceResolution.agentThreadId)
1535
+ : undefined,
1536
+ agent_parent_thread_id_hash: sourceResolution.parentThreadId
1537
+ ? hashText(sourceResolution.parentThreadId)
1538
+ : undefined,
1539
+ agent_root_thread_id_hash: sourceResolution.rootThreadId
1540
+ ? hashText(sourceResolution.rootThreadId)
1541
+ : undefined,
1457
1542
  tool_name: request.params.name,
1458
1543
  canonical_tool_name: canonicalName,
1459
1544
  ...triggerAnalyticsForTool(canonicalName, toolArgs),
@@ -1471,6 +1556,13 @@ class EchoMemMCPServer {
1471
1556
  if (recallRoute.error) {
1472
1557
  throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
1473
1558
  }
1559
+ if (sourceResolution.isSubagent && SUBAGENT_ROOT_ONLY_TOOLS.has(canonicalName)) {
1560
+ rec.error_kind = "invalid_args";
1561
+ return {
1562
+ content: [{ type: "text", text: SUBAGENT_DURABLE_WRITE_MESSAGE }],
1563
+ isError: true,
1564
+ };
1565
+ }
1474
1566
  if (canonicalName === canonicalToolNames.updateStatus) {
1475
1567
  if (DESKTOP_MANAGED) {
1476
1568
  return {
@@ -1502,6 +1594,14 @@ class EchoMemMCPServer {
1502
1594
  ? client
1503
1595
  : "auto";
1504
1596
  const capsuleText = await recomposeCapsuleMarkdown(mode);
1597
+ if (this.client.isSubagentRequest()) {
1598
+ return {
1599
+ content: [{
1600
+ type: "text",
1601
+ text: `${capsuleText}\n\n---\nNot persisted: ${SUBAGENT_DURABLE_WRITE_MESSAGE}`,
1602
+ }],
1603
+ };
1604
+ }
1505
1605
  // If logged in, persist the capsule via passthrough so it's retrievable by contextId.
1506
1606
  if (this.client.hasToken()) {
1507
1607
  try {
@@ -1536,6 +1636,8 @@ class EchoMemMCPServer {
1536
1636
  switch (canonicalName) {
1537
1637
  case canonicalToolNames.bindSourceSession:
1538
1638
  return await this.handleBindSourceSession(request.params.arguments);
1639
+ case canonicalToolNames.linkWorkspaceTicketSession:
1640
+ return await this.handleLinkWorkspaceTicketSession(request.params.arguments);
1539
1641
  case canonicalToolNames.search:
1540
1642
  return await this.handleSearch(toolArgs, rec);
1541
1643
  case canonicalToolNames.save:
@@ -1649,6 +1751,13 @@ class EchoMemMCPServer {
1649
1751
  isError: true,
1650
1752
  };
1651
1753
  }
1754
+ const missingWorkspaceTicket = formatWorkspaceTicketNotFoundResult(error);
1755
+ if (missingWorkspaceTicket) {
1756
+ return {
1757
+ content: [{ type: "text", text: missingWorkspaceTicket }],
1758
+ isError: true,
1759
+ };
1760
+ }
1652
1761
  const upgradeRequired = formatUpgradeRequiredResult(error);
1653
1762
  if (upgradeRequired) {
1654
1763
  return {
@@ -1925,6 +2034,40 @@ Details: ${m.details || "N/A"}`)
1925
2034
  }],
1926
2035
  };
1927
2036
  }
2037
+ async handleLinkWorkspaceTicketSession(args) {
2038
+ const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
2039
+ const result = await this.client.linkWorkspaceTicketSession(parsed);
2040
+ if (!result) {
2041
+ return {
2042
+ content: [{
2043
+ type: "text",
2044
+ text: [
2045
+ `Ticket ${parsed.ticketId} link is pending verified source-session discovery.`,
2046
+ "Echo Desktop can finish this link from the structured ticket marker or this exact local tool invocation.",
2047
+ "Do not guess a context ID or call bind_source_session on the user's behalf unless its documented compatibility fallback is actually needed.",
2048
+ ].join("\n"),
2049
+ }],
2050
+ };
2051
+ }
2052
+ const ticket = isRecord(result.ticket) ? result.ticket : {};
2053
+ const workspaceId = readString(ticket, "workspaceId") ?? parsed.workspaceId;
2054
+ const sourceSession = this.client.getBoundSourceSession();
2055
+ return {
2056
+ content: [{
2057
+ type: "text",
2058
+ text: [
2059
+ result.changed === true
2060
+ ? "Linked this verified source session to the Echo workspace ticket."
2061
+ : "This verified source session was already linked to the Echo workspace ticket.",
2062
+ `Ticket: ${parsed.ticketId}`,
2063
+ workspaceId ? `Workspace: ${workspaceId}` : "",
2064
+ sourceSession ? `Source session: ${sourceSession.canonicalKey}` : "",
2065
+ sourceSession ? `Context: ${sourceSession.contextId}` : "",
2066
+ "Retries are safe and do not create duplicate links or history events.",
2067
+ ].filter(Boolean).join("\n"),
2068
+ }],
2069
+ };
2070
+ }
1928
2071
  async handleTimeRange(args) {
1929
2072
  const parsed = timeRangeSchema.parse(args ?? {});
1930
2073
  const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
@@ -0,0 +1,215 @@
1
+ import http from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ function connectEchoPage(nonce) {
4
+ const safeNonce = JSON.stringify(nonce);
5
+ return `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
10
+ <title>Connect Echo</title>
11
+ <style>
12
+ :root { color-scheme:light; --ink:#172019; --muted:#667168; --line:#dce4da; --leaf:#315d38; --leaf-soft:#edf5eb; --paper:#f6f7f2; --warn:#9b392b; --warn-soft:#faebe7; --amber:#8a611b; --amber-soft:#fff4d8; }
13
+ * { box-sizing:border-box; }
14
+ body { margin:0; background:radial-gradient(circle at 14% 0%,#eef4e8 0,transparent 33%),var(--paper); color:var(--ink); font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
15
+ main { width:min(980px,calc(100% - 32px)); margin:38px auto 80px; }
16
+ h1,h2,h3,p { margin:0; } h1 { font-size:clamp(34px,5vw,58px); letter-spacing:-.05em; line-height:.98; }
17
+ button { border:0; border-radius:11px; padding:10px 14px; font:inherit; font-weight:800; cursor:pointer; }
18
+ button:disabled { cursor:wait; opacity:.52; }
19
+ .primary { color:white; background:var(--leaf); } .secondary { color:var(--ink); background:#eef1eb; } .danger { color:white; background:var(--warn); }
20
+ .top { display:flex; justify-content:space-between; align-items:end; gap:22px; margin-bottom:20px; }
21
+ .eyebrow { color:var(--leaf); font-size:12px; font-weight:850; letter-spacing:.14em; text-transform:uppercase; margin-bottom:8px; }
22
+ .local { border:1px solid var(--line); border-radius:999px; padding:7px 11px; color:var(--muted); background:rgba(255,255,255,.85); white-space:nowrap; }
23
+ .panel { background:rgba(255,255,255,.94); border:1px solid var(--line); border-radius:20px; padding:20px; box-shadow:0 16px 50px rgba(34,54,35,.065); margin-top:14px; }
24
+ .account { display:grid; grid-template-columns:1fr auto; align-items:center; gap:18px; }
25
+ .accountTitle { display:flex; align-items:center; gap:9px; margin-bottom:5px; }
26
+ .accountTitle strong { font-size:20px; } .sub,.note,.host p { color:var(--muted); }
27
+ .facts { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
28
+ .pill,.badge { border-radius:999px; padding:5px 9px; background:var(--leaf-soft); color:#2d6337; font-size:12px; font-weight:850; }
29
+ .pill.bad,.badge.bad { color:var(--warn); background:var(--warn-soft); } .pill.warn,.badge.warn { color:var(--amber); background:var(--amber-soft); }
30
+ .environment { display:none; margin-top:12px; border-radius:12px; background:var(--amber-soft); color:#6d4c15; padding:10px 12px; }
31
+ .sectionHead { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:14px; }
32
+ .sectionHead h2 { font-size:22px; letter-spacing:-.02em; }
33
+ .hosts { display:grid; grid-template-columns:repeat(auto-fit,minmax(255px,1fr)); gap:12px; }
34
+ .host { border:1px solid var(--line); border-radius:16px; padding:16px; min-height:190px; display:flex; flex-direction:column; }
35
+ .hostHead { display:flex; justify-content:space-between; align-items:center; gap:10px; }
36
+ .host h3 { font-size:17px; } .host p { margin-top:8px; font-size:13px; overflow-wrap:anywhere; }
37
+ .host code { display:block; margin-top:7px; color:#788179; font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; overflow-wrap:anywhere; }
38
+ .hostActions { display:flex; flex-wrap:wrap; gap:8px; margin-top:auto; padding-top:15px; }
39
+ .empty { padding:24px; border:1px dashed #cbd5c9; border-radius:14px; color:var(--muted); }
40
+ .recovery { display:grid; grid-template-columns:1fr auto; align-items:center; gap:18px; }
41
+ .recovery h2 { font-size:20px; } .recovery p { color:var(--muted); margin-top:5px; max-width:650px; }
42
+ .advanced { margin-top:14px; border-top:1px solid var(--line); padding-top:13px; }
43
+ .advanced summary { cursor:pointer; color:var(--muted); font-weight:750; }
44
+ .advancedBody { display:flex; justify-content:space-between; align-items:center; gap:18px; margin-top:12px; }
45
+ .advancedBody p { color:var(--muted); max-width:680px; }
46
+ #message { min-height:25px; margin:14px 2px 0; color:var(--muted); }
47
+ @media(max-width:700px) { .top { align-items:start; flex-direction:column; } .account,.recovery { grid-template-columns:1fr; } .facts { justify-content:flex-start; } .advancedBody { align-items:start; flex-direction:column; } }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <main>
52
+ <header class="top"><div><p class="eyebrow">Windows MCP control center</p><h1>Connect Echo</h1></div><span class="local">Localhost only</span></header>
53
+ <section class="panel account" aria-live="polite">
54
+ <div><div class="accountTitle"><strong id="accountHeadline">Checking this profile…</strong><span class="pill warn" id="accountBadge">Checking</span></div><p class="sub" id="accountDetail">Validating the account, vault, and managed runtime.</p><p class="environment" id="environment"></p></div>
55
+ <div class="facts"><span class="pill" id="runtimeFact">Runtime —</span><span class="pill" id="vaultFact">Vault —</span><span class="pill" id="activityFact">No tool activity yet</span></div>
56
+ </section>
57
+ <section class="panel">
58
+ <div class="sectionHead"><div><h2>Your AI tools</h2><p class="note">Connect, inspect, or repair each detected Windows host independently.</p></div><button class="secondary" id="refresh">Run Doctor</button></div>
59
+ <div class="hosts" id="hosts"><p class="empty">Detecting supported MCP hosts…</p></div>
60
+ </section>
61
+ <section class="panel recovery">
62
+ <div><h2>Clean reconnect</h2><p>Install a validated latest runtime, clear inactive EchoMem-managed versions, and repair every detected host. Credentials and cloud memories stay intact.</p></div>
63
+ <button class="primary" id="reconnect">Reconnect all</button>
64
+ </section>
65
+ <details class="panel advanced">
66
+ <summary>Advanced controls</summary>
67
+ <div class="advancedBody"><p>Uninstall removes EchoMem host registrations, lifecycle hooks, guidance, Codex skills, and the managed runtime. It preserves the account credential, vault key, and cloud memories.</p><button class="danger" id="uninstall">Uninstall MCP…</button></div>
68
+ </details>
69
+ <p id="message" role="status"></p>
70
+ </main>
71
+ <script>
72
+ (() => {
73
+ const nonce = ${safeNonce};
74
+ const message = document.getElementById("message");
75
+ const buttons = () => Array.from(document.querySelectorAll("button"));
76
+ const esc = (value) => String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[ch]));
77
+ const busy = (state) => buttons().forEach((button) => { button.disabled = state; });
78
+ async function request(path, method, payload) {
79
+ const response = await fetch(path + "?nonce=" + encodeURIComponent(nonce), {
80
+ method: method || "GET", credentials:"omit", cache:"no-store",
81
+ headers:{"Content-Type":"application/json"}, body:method === "POST" ? JSON.stringify(Object.assign({nonce},payload || {})) : undefined
82
+ });
83
+ const raw = await response.text(); let data = {};
84
+ try { data = raw ? JSON.parse(raw) : {}; } catch (_) {}
85
+ if (!response.ok) throw new Error(data.message || raw || ("HTTP " + response.status));
86
+ return data;
87
+ }
88
+ function tone(state) {
89
+ return state === "connected" || state === "unlocked" || state === "unencrypted" || state === "ready" ? "" : state === "unreachable" || state === "unknown" || state === "attention" || state === "not_checked" ? "warn" : "bad";
90
+ }
91
+ function label(value) { return String(value || "unknown").replace(/_/g," "); }
92
+ function render(report) {
93
+ const account = report.account || {state:report.credentialsPresent ? "not_checked" : "not_connected",detail:""};
94
+ const vault = report.vault || {state:report.vaultKeyPresent ? "not_checked" : "unknown",detail:""};
95
+ const accountBadge = document.getElementById("accountBadge");
96
+ document.getElementById("accountHeadline").textContent = account.state === "connected" ? "Echo account connected" : account.state === "not_connected" ? "Echo account not connected" : account.state === "invalid" ? "Echo account needs reconnecting" : "Echo account check needs attention";
97
+ accountBadge.textContent = label(account.state);
98
+ accountBadge.className = "pill " + tone(account.state);
99
+ document.getElementById("accountDetail").textContent = account.detail || "";
100
+ const runtime = report.runtime && report.runtime.version ? report.runtime.version : "not installed";
101
+ const runtimeFact = document.getElementById("runtimeFact"); runtimeFact.textContent = "Runtime " + runtime; runtimeFact.className = "pill " + (report.runtime ? "" : "bad");
102
+ const vaultFact = document.getElementById("vaultFact"); vaultFact.textContent = "Vault " + label(vault.state); vaultFact.className = "pill " + tone(vault.state);
103
+ const activity = document.getElementById("activityFact");
104
+ activity.textContent = report.lastSearch ? (report.lastSearch.ok ? "Last search succeeded" : "Last search failed") : "No recent search";
105
+ activity.className = "pill " + (report.lastSearch && report.lastSearch.ok === false ? "bad" : report.lastSearch ? "" : "warn");
106
+ const environment = document.getElementById("environment");
107
+ environment.textContent = report.environment && report.environment.detail || "";
108
+ environment.style.display = report.environment && (report.environment.wsl || !report.environment.windowsNative) ? "block" : "none";
109
+
110
+ const clients = Array.isArray(report.clients) ? report.clients : [];
111
+ document.getElementById("hosts").innerHTML = clients.length ? clients.map((client) => {
112
+ const action = client.recommendedAction || (!client.configured ? "connect" : client.state === "ok" ? "none" : "repair");
113
+ const primary = action === "connect" ? '<button class="primary" data-host-action="connect" data-client-id="' + esc(client.id) + '">Connect</button>' : action === "repair" ? '<button class="primary" data-host-action="repair" data-client-id="' + esc(client.id) + '">Repair</button>' : '';
114
+ const disconnect = client.configured ? '<button class="secondary" data-host-action="disconnect" data-client-id="' + esc(client.id) + '">Disconnect</button>' : '';
115
+ const version = client.version || (client.runtime === "latest" ? "latest at launch" : "version unknown");
116
+ return '<article class="host"><div class="hostHead"><h3>' + esc(client.label) + '</h3><span class="badge ' + tone(client.health) + '">' + esc(label(client.health)) + '</span></div><p>' + esc(client.configured ? "EchoMem MCP is configured for this host." : "Detected on this profile, but EchoMem is not configured.") + '</p><p>' + esc(version) + '</p><code>' + esc(client.detail || "") + '</code><div class="hostActions">' + primary + disconnect + '</div></article>';
117
+ }).join("") : '<p class="empty">No supported MCP host was detected in this profile. Native Windows and WSL have separate installations.</p>';
118
+ }
119
+ async function doctor(statusText) { busy(true); message.textContent = statusText || "Running MCP Doctor…"; try { const data = await request("/doctor"); render(data); message.textContent = "Doctor finished. No configuration was changed."; return data; } catch (error) { message.textContent = error.message; } finally { busy(false); } }
120
+ document.getElementById("refresh").onclick = () => void doctor();
121
+ document.getElementById("hosts").onclick = async (event) => {
122
+ const button = event.target && event.target.closest ? event.target.closest("[data-host-action]") : null;
123
+ if (!button) return;
124
+ const clientId = button.getAttribute("data-client-id"); const action = button.getAttribute("data-host-action");
125
+ if (action === "disconnect" && !confirm("Disconnect EchoMem from this host? Your account and memories will be preserved.")) return;
126
+ busy(true); message.textContent = action === "disconnect" ? "Disconnecting this host…" : action === "repair" ? "Repairing this host…" : "Connecting this host…";
127
+ try { await request(action === "disconnect" ? "/disconnect-host" : "/connect-host","POST",{clientId,repair:action === "repair"}); await doctor(); message.textContent = action === "disconnect" ? "Host disconnected. Restart it to unload EchoMem." : "Host configured. Start a new session in it to load EchoMem."; } catch (error) { message.textContent = error.message; } finally { busy(false); }
128
+ };
129
+ document.getElementById("reconnect").onclick = async () => { if (!confirm("Reconnect every detected host with a clean managed runtime? Active sessions keep their current runtime until restarted.")) return; busy(true); message.textContent = "Installing a clean runtime and repairing detected hosts…"; try { await request("/reconnect","POST"); await doctor(); message.textContent = "Clean reconnect complete. Restart each host to load the new runtime."; } catch (error) { message.textContent = error.message; } finally { busy(false); } };
130
+ document.getElementById("uninstall").onclick = async () => { if (!confirm("Uninstall EchoMem MCP from this profile? Login, vault credentials, and cloud memories will be preserved.")) return; busy(true); message.textContent = "Removing EchoMem MCP components…"; try { await request("/uninstall","POST"); await doctor(); message.textContent = "EchoMem MCP removed. Credentials and cloud memories were preserved."; } catch (error) { message.textContent = error.message; } finally { busy(false); } };
131
+ void doctor("Checking this Windows profile…");
132
+ })();
133
+ </script>
134
+ </body></html>`;
135
+ }
136
+ function readJsonBody(req) {
137
+ return new Promise((resolve, reject) => {
138
+ const chunks = [];
139
+ let size = 0;
140
+ req.on("data", (chunk) => {
141
+ size += chunk.length;
142
+ if (size > 4096) {
143
+ reject(new Error("Request too large"));
144
+ req.destroy();
145
+ return;
146
+ }
147
+ chunks.push(chunk);
148
+ });
149
+ req.on("end", () => {
150
+ try {
151
+ const raw = Buffer.concat(chunks).toString("utf8");
152
+ const parsed = raw ? JSON.parse(raw) : {};
153
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
154
+ throw new Error("Invalid JSON body");
155
+ resolve(parsed);
156
+ }
157
+ catch (error) {
158
+ reject(error);
159
+ }
160
+ });
161
+ req.on("error", reject);
162
+ });
163
+ }
164
+ export async function startMcpControlServer(actions) {
165
+ const nonce = randomUUID();
166
+ let operation = null;
167
+ const server = http.createServer((req, res) => {
168
+ const url = new URL(req.url || "/", "http://127.0.0.1");
169
+ const json = (status, body) => res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }).end(JSON.stringify(body));
170
+ const run = async () => {
171
+ if (url.searchParams.get("nonce") !== nonce)
172
+ return void json(403, { message: "Invalid local control nonce." });
173
+ if (url.pathname === "/" && req.method === "GET")
174
+ return void res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }).end(connectEchoPage(nonce));
175
+ if (url.pathname === "/doctor" && req.method === "GET")
176
+ return void json(200, await actions.doctor());
177
+ if (req.method !== "POST")
178
+ return void json(404, { message: "Not found." });
179
+ const body = await readJsonBody(req);
180
+ if (operation)
181
+ return void json(409, { message: "Another Connect Echo operation is still running." });
182
+ const clientId = typeof body.clientId === "string" ? body.clientId : "";
183
+ const action = url.pathname === "/connect-host"
184
+ ? () => actions.connectHost(clientId, body.repair === true)
185
+ : url.pathname === "/disconnect-host"
186
+ ? () => actions.disconnectHost(clientId)
187
+ : url.pathname === "/reconnect"
188
+ ? actions.reconnect
189
+ : url.pathname === "/uninstall"
190
+ ? actions.uninstall
191
+ : null;
192
+ if (!action)
193
+ return void json(404, { message: "Not found." });
194
+ operation = Promise.resolve().then(action);
195
+ try {
196
+ return void json(200, await operation);
197
+ }
198
+ finally {
199
+ operation = null;
200
+ }
201
+ };
202
+ run().catch((error) => {
203
+ if (!res.writableEnded)
204
+ json(500, { message: error instanceof Error ? error.message : String(error) });
205
+ });
206
+ });
207
+ await new Promise((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(0, "127.0.0.1", () => resolve());
210
+ });
211
+ const address = server.address();
212
+ if (!address || typeof address === "string")
213
+ throw new Error("Could not bind the local Connect Echo page.");
214
+ return { url: `http://127.0.0.1:${address.port}/?nonce=${encodeURIComponent(nonce)}`, close: () => server.close() };
215
+ }