@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/README.md +27 -2
- package/dist/config-files.js +63 -0
- package/dist/headless-runtime.js +59 -8
- package/dist/hud/hooks.js +85 -31
- package/dist/index.js +157 -14
- package/dist/mcp-control.js +215 -0
- package/dist/setup.js +410 -27
- package/dist/source-session.js +262 -75
- package/dist/v1-contract.js +34 -0
- package/package.json +4 -2
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 {
|
|
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
|
-
|
|
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
|
-
|
|
711
|
+
const requestContext = this.requestContext.getStore();
|
|
712
|
+
return requestContext === undefined ? this.boundSourceSession : requestContext.sourceSession;
|
|
681
713
|
}
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
1369
|
-
|
|
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
|
|
1433
|
-
|
|
1494
|
+
let sourceResolution = {
|
|
1495
|
+
sourceSession: null,
|
|
1496
|
+
isSubagent: false,
|
|
1497
|
+
lineageStatus: "unbound",
|
|
1498
|
+
};
|
|
1434
1499
|
try {
|
|
1435
|
-
|
|
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 (
|
|
1444
|
-
requestSourceSession = await this.client.bindSourceSession(
|
|
1513
|
+
if (sourceResolution.sourceSession && this.client.hasToken()) {
|
|
1514
|
+
requestSourceSession = await this.client.bindSourceSession(sourceResolution.sourceSession, false);
|
|
1445
1515
|
}
|
|
1446
|
-
return this.client.
|
|
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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
|
+
}
|