@echomem/mcp 1.4.50 → 1.4.51
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 +129 -12
- package/dist/mcp-control.js +215 -0
- package/dist/setup.js +410 -27
- package/dist/source-session.js +253 -75
- package/dist/v1-contract.js +34 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ npx -y @echomem/mcp@latest init
|
|
|
54
54
|
Windows PowerShell:
|
|
55
55
|
|
|
56
56
|
```powershell
|
|
57
|
-
npx.cmd -y @echomem/mcp@latest
|
|
57
|
+
npx.cmd -y @echomem/mcp@latest connect
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The one-off `npx` command is the recommended install path; it stages a durable per-user runtime
|
|
@@ -63,7 +63,9 @@ Node if `npx.cmd` is not found.
|
|
|
63
63
|
|
|
64
64
|
| Command | What it does |
|
|
65
65
|
|---|---|
|
|
66
|
-
| `
|
|
66
|
+
| `npx -y @echomem/mcp@latest connect` | Open Connect Echo for account readiness, per-host setup, Doctor, repair, and uninstall (use `npx.cmd` in Windows PowerShell) |
|
|
67
|
+
| `npx -y @echomem/mcp@latest init` | One-command setup for installed agents + login (use `npx.cmd` in Windows PowerShell) |
|
|
68
|
+
| `npm i -g @echomem/mcp@latest`, then `echomem-mcp init` | Optional global install for a persistent CLI command |
|
|
67
69
|
| `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and configure just the detected editor |
|
|
68
70
|
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
69
71
|
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
|
|
@@ -75,8 +77,31 @@ Node if `npx.cmd` is not found.
|
|
|
75
77
|
| `echomem-mcp lock` | Remove the local vault key while keeping the device login |
|
|
76
78
|
| `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
|
|
77
79
|
| `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
|
|
80
|
+
| `echomem-mcp control` / `manage` | Compatibility aliases for the returning-user Connect Echo page |
|
|
81
|
+
| `echomem-mcp reconnect` | Clean-reinstall EchoMem's managed runtime, clear inactive runtime cache, and repair detected host entries |
|
|
82
|
+
| `echomem-mcp uninstall --confirm` | Remove EchoMem MCP host entries/runtime while preserving login, vault credentials, and cloud memories |
|
|
78
83
|
| `echomem-mcp logout` | Remove stored credentials |
|
|
79
84
|
|
|
85
|
+
### Windows Connect Echo (no desktop app required)
|
|
86
|
+
|
|
87
|
+
If you installed the optional global CLI, run this from PowerShell:
|
|
88
|
+
|
|
89
|
+
```powershell
|
|
90
|
+
echomem-mcp connect
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For the recommended one-off install, launch the same local control page with:
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
npx.cmd -y @echomem/mcp@latest connect
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The page binds only to `127.0.0.1` and uses a per-launch nonce. It validates account/vault state,
|
|
100
|
+
shows each detected MCP host independently, and provides Connect, Repair, Disconnect, Doctor, and
|
|
101
|
+
clean Reconnect actions. **Uninstall MCP** stays under Advanced controls and removes only EchoMem-owned
|
|
102
|
+
host entries, hooks, guidance, skills, and managed runtime files; it does not remove the local
|
|
103
|
+
EchoMem credential store or any cloud memory. Restart each agent after configuration changes.
|
|
104
|
+
|
|
80
105
|
The bridge reports its package version in MCP server instructions and in tool descriptions. It also
|
|
81
106
|
checks npm for a newer published bridge using a cached, non-blocking check. Standalone installations
|
|
82
107
|
stage compatible updates in the background under `~/.echomem/mcp-runtime` and atomically activate
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function isErrno(error, code) {
|
|
5
|
+
return error instanceof Error
|
|
6
|
+
&& "code" in error
|
|
7
|
+
&& error.code === code;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read a user-owned JSON configuration without treating corruption as an empty file.
|
|
11
|
+
* Setup must fail closed here: replacing malformed JSON would destroy unrelated client settings.
|
|
12
|
+
*/
|
|
13
|
+
export function readJsonObjectFile(file, label = "configuration") {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(file, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (isErrno(error, "ENOENT"))
|
|
20
|
+
return {};
|
|
21
|
+
throw new Error(`Could not read ${label} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
}
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw new Error(`Could not safely update ${label} at ${file} because the existing file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error(`Could not safely update ${label} at ${file} because the existing JSON root is not an object.`);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
/** Write beside the destination and rename only after the complete replacement is durable locally. */
|
|
36
|
+
export function atomicWriteTextFile(file, content) {
|
|
37
|
+
const directory = path.dirname(file);
|
|
38
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
39
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
40
|
+
let mode;
|
|
41
|
+
try {
|
|
42
|
+
mode = fs.statSync(file).mode;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* A fresh file uses the process default mode. */
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(temporary, content, mode === undefined ? undefined : { mode });
|
|
49
|
+
fs.renameSync(temporary, file);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
try {
|
|
53
|
+
fs.rmSync(temporary, { force: true });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* Preserve the original error. */
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function atomicWriteJsonObject(file, value) {
|
|
62
|
+
atomicWriteTextFile(file, JSON.stringify(value, null, 2));
|
|
63
|
+
}
|
package/dist/headless-runtime.js
CHANGED
|
@@ -173,28 +173,79 @@ function finishStaging(staging, destination, version) {
|
|
|
173
173
|
fs.renameSync(staging, destination);
|
|
174
174
|
return activateRuntime(destination, version);
|
|
175
175
|
}
|
|
176
|
-
|
|
176
|
+
function pruneInactiveRuntimeVersions(activeVersion) {
|
|
177
|
+
const removed = [];
|
|
178
|
+
let entries = [];
|
|
179
|
+
try {
|
|
180
|
+
entries = fs.readdirSync(versionsRoot(), { withFileTypes: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return removed;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
// A dot-prefixed directory is another install's staging area. Never prune in-flight work.
|
|
187
|
+
if (entry.name === activeVersion || entry.name.startsWith("."))
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
safeVersion(entry.name);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const target = path.join(versionsRoot(), entry.name);
|
|
196
|
+
try {
|
|
197
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
198
|
+
removed.push(entry.name);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* Cache pruning is best effort; the active clean runtime is already safe. */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
|
206
|
+
export function installHeadlessRuntimeSync(targetVersion = MCP_PACKAGE_VERSION, options = {}) {
|
|
177
207
|
const version = safeVersion(targetVersion);
|
|
178
208
|
const existing = readHeadlessRuntimeInstallation();
|
|
179
|
-
if (existing?.version === version)
|
|
209
|
+
if (!options.force && existing?.version === version)
|
|
180
210
|
return existing;
|
|
181
211
|
const destination = path.join(versionsRoot(), version);
|
|
182
|
-
if (readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
|
|
183
|
-
|
|
212
|
+
if (!options.force && readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
|
|
213
|
+
const installation = activateRuntime(destination, version);
|
|
214
|
+
if (options.prune)
|
|
215
|
+
pruneInactiveRuntimeVersions(version);
|
|
216
|
+
return installation;
|
|
184
217
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
218
|
+
const releaseLock = acquireUpdateLock();
|
|
219
|
+
if (!releaseLock)
|
|
220
|
+
throw new Error("Another EchoMem runtime update is already in progress. Retry reconnect in a moment.");
|
|
221
|
+
let staging = "";
|
|
187
222
|
try {
|
|
223
|
+
staging = prepareStaging(version).staging;
|
|
224
|
+
const npm = resolveNpmInvocation();
|
|
188
225
|
execFileSync(npm.command, [...npm.prefixArgs, ...npmInstallArguments(staging, version)], {
|
|
189
226
|
stdio: "inherit",
|
|
190
227
|
windowsHide: true,
|
|
191
228
|
});
|
|
192
|
-
|
|
229
|
+
const installation = finishStaging(staging, destination, version);
|
|
230
|
+
if (options.prune)
|
|
231
|
+
pruneInactiveRuntimeVersions(version);
|
|
232
|
+
return installation;
|
|
193
233
|
}
|
|
194
234
|
catch (error) {
|
|
195
|
-
|
|
235
|
+
if (staging)
|
|
236
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
196
237
|
throw new Error(`Could not stage ${MCP_PACKAGE_NAME}@${version}: ${error instanceof Error ? error.message : String(error)}`);
|
|
197
238
|
}
|
|
239
|
+
finally {
|
|
240
|
+
releaseLock();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Remove only EchoMem's staged standalone runtime. Credentials and cloud data live elsewhere. */
|
|
244
|
+
export function removeHeadlessRuntime() {
|
|
245
|
+
const root = headlessRuntimeRoot();
|
|
246
|
+
const removed = fs.existsSync(root);
|
|
247
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
248
|
+
return { removed, path: root };
|
|
198
249
|
}
|
|
199
250
|
function acquireUpdateLock() {
|
|
200
251
|
const lock = path.join(headlessRuntimeRoot(), "update.lock");
|
package/dist/hud/hooks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { atomicWriteJsonObject, readJsonObjectFile } from "../config-files.js";
|
|
4
5
|
import { resolveDurableDistPath } from "../durable-entry.js";
|
|
5
6
|
/**
|
|
6
7
|
* Hook commands are persisted into another program's settings and must keep working long after this
|
|
@@ -45,17 +46,59 @@ export function installSourceSessionHooks(mode) {
|
|
|
45
46
|
}
|
|
46
47
|
return written;
|
|
47
48
|
}
|
|
49
|
+
/** Remove only EchoMem-owned lifecycle hook groups and preserve every unrelated user hook. */
|
|
50
|
+
export function removeLifecycleHooks(mode) {
|
|
51
|
+
const files = [];
|
|
52
|
+
let removedGroups = 0;
|
|
53
|
+
const targets = [];
|
|
54
|
+
if (mode === "codex" || mode === "both" || mode === "auto") {
|
|
55
|
+
targets.push(path.join(profileDirectory("CODEX_HOME", ".codex"), "hooks.json"));
|
|
56
|
+
}
|
|
57
|
+
if (mode === "claude-code" || mode === "both" || mode === "auto") {
|
|
58
|
+
targets.push(path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "settings.json"));
|
|
59
|
+
}
|
|
60
|
+
for (const file of targets) {
|
|
61
|
+
if (!fs.existsSync(file))
|
|
62
|
+
continue;
|
|
63
|
+
const content = readHooksFile(file);
|
|
64
|
+
const hooks = content.hooks;
|
|
65
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
66
|
+
continue;
|
|
67
|
+
let changed = false;
|
|
68
|
+
for (const [event, value] of Object.entries(hooks)) {
|
|
69
|
+
if (!Array.isArray(value))
|
|
70
|
+
continue;
|
|
71
|
+
const kept = value.filter((group) => !isEchoLifecycleGroup(group));
|
|
72
|
+
const removed = value.length - kept.length;
|
|
73
|
+
if (removed === 0)
|
|
74
|
+
continue;
|
|
75
|
+
removedGroups += removed;
|
|
76
|
+
changed = true;
|
|
77
|
+
if (kept.length > 0)
|
|
78
|
+
hooks[event] = kept;
|
|
79
|
+
else
|
|
80
|
+
delete hooks[event];
|
|
81
|
+
}
|
|
82
|
+
if (!changed)
|
|
83
|
+
continue;
|
|
84
|
+
if (Object.keys(hooks).length === 0)
|
|
85
|
+
delete content.hooks;
|
|
86
|
+
atomicWriteJsonObject(file, content);
|
|
87
|
+
files.push(file);
|
|
88
|
+
}
|
|
89
|
+
return { files, removedGroups };
|
|
90
|
+
}
|
|
48
91
|
function installCodexHooks() {
|
|
49
|
-
const dir =
|
|
92
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
50
93
|
const file = path.join(dir, "hooks.json");
|
|
51
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
52
95
|
const hookCommand = hookCommandFor("index.js", "summary --client codex --json");
|
|
53
96
|
const content = readHooksFile(file);
|
|
54
97
|
content.hooks = content.hooks || {};
|
|
55
|
-
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [
|
|
56
|
-
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [
|
|
57
|
-
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [
|
|
58
|
-
|
|
98
|
+
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [codexCommandHook(hookCommand, 5)] });
|
|
99
|
+
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [codexCommandHook(hookCommand, 5)] });
|
|
100
|
+
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [codexCommandHook(hookCommand, 5)] });
|
|
101
|
+
atomicWriteJsonObject(file, content);
|
|
59
102
|
return file;
|
|
60
103
|
}
|
|
61
104
|
function saveCheckpointCommand() {
|
|
@@ -65,24 +108,19 @@ function sourceSessionCommand() {
|
|
|
65
108
|
return hookCommandFor(path.join("hud", "cli.js"), "bind-source-session");
|
|
66
109
|
}
|
|
67
110
|
function installCodexSourceSessionHook() {
|
|
68
|
-
const dir =
|
|
111
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
69
112
|
const file = path.join(dir, "hooks.json");
|
|
70
113
|
fs.mkdirSync(dir, { recursive: true });
|
|
71
114
|
const content = readHooksFile(file);
|
|
72
115
|
content.hooks = content.hooks || {};
|
|
73
116
|
content.hooks.SessionStart = mergeSourceSessionGroup(content.hooks.SessionStart, {
|
|
74
|
-
hooks: [
|
|
75
|
-
type: "command",
|
|
76
|
-
command: sourceSessionCommand(),
|
|
77
|
-
timeout: 5,
|
|
78
|
-
statusMessage: "Binding EchoMem to this conversation",
|
|
79
|
-
}],
|
|
117
|
+
hooks: [codexCommandHook(sourceSessionCommand(), 5, "Binding EchoMem to this conversation")],
|
|
80
118
|
});
|
|
81
|
-
|
|
119
|
+
atomicWriteJsonObject(file, content);
|
|
82
120
|
return file;
|
|
83
121
|
}
|
|
84
122
|
function installClaudeCodeSourceSessionHook() {
|
|
85
|
-
const dir =
|
|
123
|
+
const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
86
124
|
const file = path.join(dir, "settings.json");
|
|
87
125
|
fs.mkdirSync(dir, { recursive: true });
|
|
88
126
|
const content = readHooksFile(file);
|
|
@@ -95,28 +133,23 @@ function installClaudeCodeSourceSessionHook() {
|
|
|
95
133
|
statusMessage: "Binding EchoMem to this conversation",
|
|
96
134
|
}],
|
|
97
135
|
});
|
|
98
|
-
|
|
136
|
+
atomicWriteJsonObject(file, content);
|
|
99
137
|
return file;
|
|
100
138
|
}
|
|
101
139
|
function installCodexSaveCheckpointHook() {
|
|
102
|
-
const dir =
|
|
140
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
103
141
|
const file = path.join(dir, "hooks.json");
|
|
104
142
|
fs.mkdirSync(dir, { recursive: true });
|
|
105
143
|
const content = readHooksFile(file);
|
|
106
144
|
content.hooks = content.hooks || {};
|
|
107
145
|
content.hooks.Stop = mergeSaveCheckpointGroup(content.hooks.Stop, {
|
|
108
|
-
hooks: [
|
|
109
|
-
type: "command",
|
|
110
|
-
command: saveCheckpointCommand(),
|
|
111
|
-
timeout: 10,
|
|
112
|
-
statusMessage: "Checking whether completed work should be remembered",
|
|
113
|
-
}],
|
|
146
|
+
hooks: [codexCommandHook(saveCheckpointCommand(), 10, "Checking whether completed work should be remembered")],
|
|
114
147
|
});
|
|
115
|
-
|
|
148
|
+
atomicWriteJsonObject(file, content);
|
|
116
149
|
return file;
|
|
117
150
|
}
|
|
118
151
|
function installClaudeCodeSaveCheckpointHook() {
|
|
119
|
-
const dir =
|
|
152
|
+
const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
120
153
|
const file = path.join(dir, "settings.json");
|
|
121
154
|
fs.mkdirSync(dir, { recursive: true });
|
|
122
155
|
const content = readHooksFile(file);
|
|
@@ -129,22 +162,40 @@ function installClaudeCodeSaveCheckpointHook() {
|
|
|
129
162
|
statusMessage: "Checking whether completed work should be remembered",
|
|
130
163
|
}],
|
|
131
164
|
});
|
|
132
|
-
|
|
165
|
+
atomicWriteJsonObject(file, content);
|
|
133
166
|
return file;
|
|
134
167
|
}
|
|
135
168
|
function installClaudeCodeSnippet() {
|
|
136
|
-
const dir = path.join(
|
|
169
|
+
const dir = path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "echo-ctx");
|
|
137
170
|
fs.mkdirSync(dir, { recursive: true });
|
|
138
171
|
return dir;
|
|
139
172
|
}
|
|
140
173
|
function readHooksFile(file) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
174
|
+
return readJsonObjectFile(file, "agent hooks");
|
|
175
|
+
}
|
|
176
|
+
function profileDirectory(envKey, fallbackName) {
|
|
177
|
+
const home = os.homedir();
|
|
178
|
+
const configured = process.env[envKey]?.trim();
|
|
179
|
+
if (!configured)
|
|
180
|
+
return path.join(home, fallbackName);
|
|
181
|
+
if (configured === "~")
|
|
182
|
+
return home;
|
|
183
|
+
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
|
|
184
|
+
return path.join(home, configured.slice(2));
|
|
144
185
|
}
|
|
145
|
-
|
|
146
|
-
|
|
186
|
+
if (!path.isAbsolute(configured)) {
|
|
187
|
+
throw new Error(`${envKey} must be an absolute path or start with ~/`);
|
|
147
188
|
}
|
|
189
|
+
return path.normalize(configured);
|
|
190
|
+
}
|
|
191
|
+
function codexCommandHook(command, timeout, statusMessage) {
|
|
192
|
+
return {
|
|
193
|
+
type: "command",
|
|
194
|
+
command,
|
|
195
|
+
commandWindows: command,
|
|
196
|
+
timeout,
|
|
197
|
+
...(statusMessage ? { statusMessage } : {}),
|
|
198
|
+
};
|
|
148
199
|
}
|
|
149
200
|
function mergeHookGroup(existing, group) {
|
|
150
201
|
const groups = Array.isArray(existing) ? existing.filter((item) => !isEchoHudGroup(item)) : [];
|
|
@@ -176,3 +227,6 @@ function isEchoSaveCheckpointGroup(value) {
|
|
|
176
227
|
return false;
|
|
177
228
|
return JSON.stringify(value).includes("save-checkpoint");
|
|
178
229
|
}
|
|
230
|
+
function isEchoLifecycleGroup(value) {
|
|
231
|
+
return isEchoHudGroup(value) || isEchoSourceSessionGroup(value) || isEchoSaveCheckpointGroup(value);
|
|
232
|
+
}
|
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(/\/$/, "");
|
|
@@ -582,6 +582,14 @@ function inputAnalyticsForTool(canonicalName, args) {
|
|
|
582
582
|
receipt_id_hash: receiptId ? hashText(receiptId) : undefined,
|
|
583
583
|
};
|
|
584
584
|
}
|
|
585
|
+
case canonicalToolNames.linkWorkspaceTicketSession: {
|
|
586
|
+
const ticketId = readString(a, "ticketId");
|
|
587
|
+
const workspaceId = readString(a, "workspaceId");
|
|
588
|
+
return {
|
|
589
|
+
ticket_id_hash: ticketId ? hashText(ticketId) : undefined,
|
|
590
|
+
workspace_id_hash: workspaceId ? hashText(workspaceId) : undefined,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
585
593
|
case canonicalToolNames.flagPublicationAttention: {
|
|
586
594
|
const memoryIds = Array.isArray(a.memoryIds) ? a.memoryIds.filter((id) => typeof id === "string") : [];
|
|
587
595
|
return {
|
|
@@ -609,13 +617,22 @@ function inputAnalyticsForTool(canonicalName, args) {
|
|
|
609
617
|
function toolEventName(canonicalName, status) {
|
|
610
618
|
return `[MCP] ${canonicalName} ${status}`;
|
|
611
619
|
}
|
|
620
|
+
const SUBAGENT_DURABLE_WRITE_MESSAGE = [
|
|
621
|
+
"Codex subagents cannot save EchoMem memories or manage conversation sharing.",
|
|
622
|
+
"Return durable findings to the root agent; the root agent must consolidate them and call save_conversation once.",
|
|
623
|
+
].join(" ");
|
|
624
|
+
const SUBAGENT_ROOT_ONLY_TOOLS = new Set([
|
|
625
|
+
canonicalToolNames.save,
|
|
626
|
+
canonicalToolNames.requestGroupSessionSharing,
|
|
627
|
+
canonicalToolNames.setGroupSessionSharing,
|
|
628
|
+
]);
|
|
612
629
|
class EchoMemApiClient {
|
|
613
630
|
store;
|
|
614
631
|
axios;
|
|
615
632
|
activeToken;
|
|
616
633
|
accountGeneration = 0;
|
|
617
634
|
boundSourceSession = null;
|
|
618
|
-
|
|
635
|
+
requestContext = new AsyncLocalStorage();
|
|
619
636
|
sourceSessionsByCanonicalKey = new Map();
|
|
620
637
|
whoamiCache = null;
|
|
621
638
|
/** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
|
|
@@ -677,10 +694,20 @@ class EchoMemApiClient {
|
|
|
677
694
|
}
|
|
678
695
|
getBoundSourceSession() {
|
|
679
696
|
this.synchronizeAccountContext();
|
|
680
|
-
|
|
697
|
+
const requestContext = this.requestContext.getStore();
|
|
698
|
+
return requestContext === undefined ? this.boundSourceSession : requestContext.sourceSession;
|
|
699
|
+
}
|
|
700
|
+
getRequestVerifiedSourceSession() {
|
|
701
|
+
const requestContext = this.requestContext.getStore();
|
|
702
|
+
if (!requestContext || requestContext.lineageStatus === "unbound")
|
|
703
|
+
return null;
|
|
704
|
+
return requestContext.sourceSession;
|
|
681
705
|
}
|
|
682
|
-
|
|
683
|
-
return
|
|
706
|
+
isSubagentRequest() {
|
|
707
|
+
return this.requestContext.getStore()?.isSubagent === true;
|
|
708
|
+
}
|
|
709
|
+
async withRequestContext(requestContext, operation) {
|
|
710
|
+
return this.requestContext.run(requestContext, operation);
|
|
684
711
|
}
|
|
685
712
|
async bindSourceSession(verified, persistForBridge = true) {
|
|
686
713
|
this.synchronizeAccountContext();
|
|
@@ -714,6 +741,21 @@ class EchoMemApiClient {
|
|
|
714
741
|
created: response.data?.created === true,
|
|
715
742
|
};
|
|
716
743
|
}
|
|
744
|
+
async linkWorkspaceTicketSession(args) {
|
|
745
|
+
const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
|
|
746
|
+
// A bridge-level compatibility binding can outlive a conversation in
|
|
747
|
+
// long-running hosts. Ticket links are therefore allowed to take the fast
|
|
748
|
+
// path only from identity verified for this exact request; otherwise the
|
|
749
|
+
// Desktop transcript watcher completes the link from local evidence.
|
|
750
|
+
const sourceSession = this.getRequestVerifiedSourceSession();
|
|
751
|
+
if (!sourceSession)
|
|
752
|
+
return null;
|
|
753
|
+
const response = await this.axios.post(`/api/extension/workspace-tickets/${encodeURIComponent(parsed.ticketId)}/sessions`, {
|
|
754
|
+
contextId: sourceSession.contextId,
|
|
755
|
+
workspaceId: parsed.workspaceId,
|
|
756
|
+
});
|
|
757
|
+
return response.data;
|
|
758
|
+
}
|
|
717
759
|
async trackMcpAnalyticsEvent(eventType, eventProperties, insertId) {
|
|
718
760
|
if (!this.hasToken())
|
|
719
761
|
return;
|
|
@@ -966,6 +1008,9 @@ class EchoMemApiClient {
|
|
|
966
1008
|
return data;
|
|
967
1009
|
}
|
|
968
1010
|
async saveConversation(args) {
|
|
1011
|
+
if (this.isSubagentRequest()) {
|
|
1012
|
+
throw new Error(SUBAGENT_DURABLE_WRITE_MESSAGE);
|
|
1013
|
+
}
|
|
969
1014
|
const parsed = saveConversationSchema.parse(args ?? {});
|
|
970
1015
|
const groupSharingScopeId = parsed.groupSharingScopeId ?? randomUUID();
|
|
971
1016
|
let rawData = parsed.conversation?.trim() || "";
|
|
@@ -984,6 +1029,7 @@ class EchoMemApiClient {
|
|
|
984
1029
|
const config = {
|
|
985
1030
|
headers: {
|
|
986
1031
|
"X-EchoMem-Request-Id": randomUUID(),
|
|
1032
|
+
"X-EchoMem-Origin-Channel": "local_mcp",
|
|
987
1033
|
...(enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : {}),
|
|
988
1034
|
},
|
|
989
1035
|
};
|
|
@@ -1429,21 +1475,32 @@ class EchoMemMCPServer {
|
|
|
1429
1475
|
const clientVersion = this.server.getClientVersion();
|
|
1430
1476
|
this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
|
|
1431
1477
|
this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
|
|
1432
|
-
let
|
|
1433
|
-
|
|
1478
|
+
let sourceResolution = {
|
|
1479
|
+
sourceSession: null,
|
|
1480
|
+
isSubagent: false,
|
|
1481
|
+
lineageStatus: "unbound",
|
|
1482
|
+
};
|
|
1434
1483
|
try {
|
|
1435
|
-
|
|
1484
|
+
sourceResolution = resolveSourceSessionRequestContext(extra._meta, { hostPlatform: this.getMcpClientAnalytics().host_platform });
|
|
1436
1485
|
}
|
|
1437
1486
|
catch {
|
|
1438
1487
|
// Malformed host metadata is not model input. Ignore it and retain the explicit fallback.
|
|
1439
1488
|
}
|
|
1489
|
+
// A child request starts fail-closed. Resolved children bind to the originating root; unresolved
|
|
1490
|
+
// children carry an explicit null so AsyncLocalStorage cannot fall through to a global binding.
|
|
1491
|
+
let requestSourceSession = sourceResolution.isSubagent
|
|
1492
|
+
? null
|
|
1493
|
+
: this.client.getBoundSourceSession();
|
|
1440
1494
|
// Verified request metadata always wins over a bridge-level compatibility fallback. The
|
|
1441
1495
|
// startup hook performs the eager write; this path attaches the exact context to the current
|
|
1442
1496
|
// request and retries the backend write if startup raced login.
|
|
1443
|
-
if (
|
|
1444
|
-
requestSourceSession = await this.client.bindSourceSession(
|
|
1497
|
+
if (sourceResolution.sourceSession && this.client.hasToken()) {
|
|
1498
|
+
requestSourceSession = await this.client.bindSourceSession(sourceResolution.sourceSession, false);
|
|
1445
1499
|
}
|
|
1446
|
-
return this.client.
|
|
1500
|
+
return this.client.withRequestContext({
|
|
1501
|
+
...sourceResolution,
|
|
1502
|
+
sourceSession: requestSourceSession,
|
|
1503
|
+
}, async () => {
|
|
1447
1504
|
const t0 = Date.now();
|
|
1448
1505
|
const analyticsBase = {
|
|
1449
1506
|
surface: "mcp",
|
|
@@ -1454,6 +1511,18 @@ class EchoMemMCPServer {
|
|
|
1454
1511
|
codex_session_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
|
|
1455
1512
|
conversation_id: this.client.getBoundSourceSession()?.canonicalKey ?? this.client.getSessionId(),
|
|
1456
1513
|
context_id: this.client.getBoundSourceSession()?.contextId,
|
|
1514
|
+
agent_is_subagent: sourceResolution.isSubagent,
|
|
1515
|
+
agent_lineage_status: sourceResolution.lineageStatus,
|
|
1516
|
+
agent_depth: sourceResolution.agentDepth,
|
|
1517
|
+
agent_thread_id_hash: sourceResolution.agentThreadId
|
|
1518
|
+
? hashText(sourceResolution.agentThreadId)
|
|
1519
|
+
: undefined,
|
|
1520
|
+
agent_parent_thread_id_hash: sourceResolution.parentThreadId
|
|
1521
|
+
? hashText(sourceResolution.parentThreadId)
|
|
1522
|
+
: undefined,
|
|
1523
|
+
agent_root_thread_id_hash: sourceResolution.rootThreadId
|
|
1524
|
+
? hashText(sourceResolution.rootThreadId)
|
|
1525
|
+
: undefined,
|
|
1457
1526
|
tool_name: request.params.name,
|
|
1458
1527
|
canonical_tool_name: canonicalName,
|
|
1459
1528
|
...triggerAnalyticsForTool(canonicalName, toolArgs),
|
|
@@ -1471,6 +1540,13 @@ class EchoMemMCPServer {
|
|
|
1471
1540
|
if (recallRoute.error) {
|
|
1472
1541
|
throw new McpError(ErrorCode.InvalidParams, recallRoute.error);
|
|
1473
1542
|
}
|
|
1543
|
+
if (sourceResolution.isSubagent && SUBAGENT_ROOT_ONLY_TOOLS.has(canonicalName)) {
|
|
1544
|
+
rec.error_kind = "invalid_args";
|
|
1545
|
+
return {
|
|
1546
|
+
content: [{ type: "text", text: SUBAGENT_DURABLE_WRITE_MESSAGE }],
|
|
1547
|
+
isError: true,
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1474
1550
|
if (canonicalName === canonicalToolNames.updateStatus) {
|
|
1475
1551
|
if (DESKTOP_MANAGED) {
|
|
1476
1552
|
return {
|
|
@@ -1502,6 +1578,14 @@ class EchoMemMCPServer {
|
|
|
1502
1578
|
? client
|
|
1503
1579
|
: "auto";
|
|
1504
1580
|
const capsuleText = await recomposeCapsuleMarkdown(mode);
|
|
1581
|
+
if (this.client.isSubagentRequest()) {
|
|
1582
|
+
return {
|
|
1583
|
+
content: [{
|
|
1584
|
+
type: "text",
|
|
1585
|
+
text: `${capsuleText}\n\n---\nNot persisted: ${SUBAGENT_DURABLE_WRITE_MESSAGE}`,
|
|
1586
|
+
}],
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1505
1589
|
// If logged in, persist the capsule via passthrough so it's retrievable by contextId.
|
|
1506
1590
|
if (this.client.hasToken()) {
|
|
1507
1591
|
try {
|
|
@@ -1536,6 +1620,8 @@ class EchoMemMCPServer {
|
|
|
1536
1620
|
switch (canonicalName) {
|
|
1537
1621
|
case canonicalToolNames.bindSourceSession:
|
|
1538
1622
|
return await this.handleBindSourceSession(request.params.arguments);
|
|
1623
|
+
case canonicalToolNames.linkWorkspaceTicketSession:
|
|
1624
|
+
return await this.handleLinkWorkspaceTicketSession(request.params.arguments);
|
|
1539
1625
|
case canonicalToolNames.search:
|
|
1540
1626
|
return await this.handleSearch(toolArgs, rec);
|
|
1541
1627
|
case canonicalToolNames.save:
|
|
@@ -1925,6 +2011,37 @@ Details: ${m.details || "N/A"}`)
|
|
|
1925
2011
|
}],
|
|
1926
2012
|
};
|
|
1927
2013
|
}
|
|
2014
|
+
async handleLinkWorkspaceTicketSession(args) {
|
|
2015
|
+
const parsed = linkWorkspaceTicketSessionSchema.parse(args ?? {});
|
|
2016
|
+
const result = await this.client.linkWorkspaceTicketSession(parsed);
|
|
2017
|
+
if (!result) {
|
|
2018
|
+
return {
|
|
2019
|
+
content: [{
|
|
2020
|
+
type: "text",
|
|
2021
|
+
text: [
|
|
2022
|
+
`Ticket ${parsed.ticketId} link is pending verified source-session discovery.`,
|
|
2023
|
+
"Echo Desktop can finish this link from the structured ticket marker or this exact local tool invocation.",
|
|
2024
|
+
"Do not guess a context ID or call bind_source_session on the user's behalf unless its documented compatibility fallback is actually needed.",
|
|
2025
|
+
].join("\n"),
|
|
2026
|
+
}],
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
const ticket = isRecord(result.ticket) ? result.ticket : {};
|
|
2030
|
+
const workspaceId = readString(ticket, "workspaceId") ?? parsed.workspaceId;
|
|
2031
|
+
return {
|
|
2032
|
+
content: [{
|
|
2033
|
+
type: "text",
|
|
2034
|
+
text: [
|
|
2035
|
+
result.changed === true
|
|
2036
|
+
? "Linked this verified source session to the Echo workspace ticket."
|
|
2037
|
+
: "This verified source session was already linked to the Echo workspace ticket.",
|
|
2038
|
+
`Ticket: ${parsed.ticketId}`,
|
|
2039
|
+
workspaceId ? `Workspace: ${workspaceId}` : "",
|
|
2040
|
+
"Retries are safe and do not create duplicate links or history events.",
|
|
2041
|
+
].filter(Boolean).join("\n"),
|
|
2042
|
+
}],
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
1928
2045
|
async handleTimeRange(args) {
|
|
1929
2046
|
const parsed = timeRangeSchema.parse(args ?? {});
|
|
1930
2047
|
const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
|