@echomem/mcp 1.0.0

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 ADDED
@@ -0,0 +1,159 @@
1
+ # EchoMem Cloud-First MCP Server
2
+
3
+ This package implements a stateless, cloud-first [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for EchoMem. It allows local IDE agents (like Cursor, Windsurf, Claude Desktop, and VS Code) to interact with the user's secure EchoMem personal backend without risk of file-locking or sandbox conflict with the running Chrome Extension's local database.
4
+
5
+ ## Architecture
6
+
7
+ This MCP Server bridges local tools and your EchoMem Cloud API entirely via authenticated REST `fetch()` calls.
8
+
9
+ - `search_memories`: Connects to `POST /api/extension/memories/search`
10
+ - `save_conversation`: Connects to `POST /api/extension/memories/ingest`
11
+ - `get_memories_by_time_range`: Connects to `POST /api/extension/memories/time-range`
12
+ - `search_memories_by_keywords`: Connects to `POST /api/extension/memories/keywords`
13
+ - `search_others_memories`: Connects to MemoryFeed search after resolving the authenticated Echo user id
14
+
15
+ No direct access to the `IndexedDB` or local files is required.
16
+
17
+ ## Encryption — the local bridge is the encrypted surface
18
+
19
+ For **zero-knowledge accounts**, the key never leaves your machine. The bridge holds the key locally
20
+ (exactly like the Chrome extension) and:
21
+
22
+ - **reads:** the server returns ciphertext; the bridge **decrypts locally** so the model only ever
23
+ sees plaintext. The server never holds your key.
24
+ - **writes:** the key is handed to your own backend transiently in the `X-Encryption-Key` header so
25
+ memories are encrypted at rest (the server processes plaintext for the request only — never stores
26
+ the key).
27
+ - **locked state:** if the key is absent or past its TTL (7 days, matching the extension), tools
28
+ return a `🔒 locked` nudge to run `echomem-mcp unlock` — **ciphertext is never handed to the model**.
29
+
30
+ Unencrypted accounts are unaffected and use the tuned two-phase retriever as before.
31
+
32
+ ## Quick start (recommended)
33
+
34
+ ```bash
35
+ # One command: detect your editor, write its MCP config, log in via the browser.
36
+ npx -y @echomem/mcp setup
37
+ ```
38
+
39
+ `setup` detects the client (Cursor / Windsurf / Claude Desktop), writes its MCP config (with **no
40
+ secret** in it — credentials live in `~/.echomem/credentials.json`, mode 0600), then opens the
41
+ browser to approve the device and, for encrypted accounts, unlock the vault. Reload your editor and
42
+ you're done.
43
+
44
+ | Command | What it does |
45
+ |---|---|
46
+ | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop]` | Write client config + log in |
47
+ | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
48
+ | `echomem-mcp unlock` | Re-derive the encryption key after its TTL (or `--passphrase`) |
49
+ | `echomem-mcp status` | Show token / key / detected clients |
50
+ | `echomem-mcp logout` | Remove stored credentials |
51
+
52
+ ### Manual / headless (SSH, containers, CI)
53
+
54
+ No browser? Provide secrets directly — this is the documented headless path:
55
+
56
+ ```bash
57
+ echomem-mcp login --token ec_xxx # unencrypted account
58
+ echomem-mcp login --token ec_xxx --passphrase '<vault>' # encrypted: derives + verifies the key
59
+ # or pre-provision via env: ECHO_API_TOKEN, ECHO_ENCRYPTION_KEY (base64)
60
+ ```
61
+
62
+ > The browser flow depends on the EchoMem **"connect device"** web page posting `{ token, key? }` to
63
+ > the bridge's localhost callback. Until that page ships, use the manual flags above (same result).
64
+
65
+ ## Setup & Configuration (manual config)
66
+
67
+ ### Prerequisites
68
+ A valid EchoMem API key (`ec_…`). For encrypted accounts, your vault passphrase.
69
+
70
+ ### Building
71
+ ```bash
72
+ cd packages/mcp-server
73
+ npm install
74
+ npm run build
75
+ npm test # crypto compat + encrypted-local integration tests
76
+ ```
77
+
78
+ ### Add to your IDE (Cursor or Windsurf)
79
+
80
+ > Prefer `npx -y @echomem/mcp setup` above — it writes these files for you and keeps secrets out of
81
+ > the client config. The manual steps below are the fallback.
82
+
83
+ #### For Cursor
84
+ In Cursor, go to Settings -> Features -> MCP Servers. Add a new MCP Server:
85
+ - **Type**: `command`
86
+ - **Name**: `echomem`
87
+ - **Command**: `npx`
88
+ - **Args**: `-y @echomem/mcp@latest`
89
+
90
+ Under the **Environment Variables** section of the server configuration, add:
91
+ - `ECHO_API_TOKEN`: `ec_...` *(Required unless provisioned via `login`: your EchoMem API key)*
92
+ - `ECHO_API_BASE_URL`: `https://echo-mem-chrome.vercel.app` *(Optional: Defaults to production URL)*
93
+ - `MEMORY_FEED_API_URL`: `https://memory-feed.vercel.app` *(Optional: Defaults to production MemoryFeed URL)*
94
+
95
+ #### For Windsurf
96
+ 1. Open your global Windsurf MCP configuration file:
97
+ - **Mac/Linux**: `~/.codeium/windsurf/mcp_config.json`
98
+ - **Windows**: `%USERPROFILE%\.codeium\windsurf\mcp_config.json`
99
+ 2. Add the `echomem` server beneath your existing configurations:
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "echomem": {
104
+ "command": "npx",
105
+ "args": ["-y", "@echomem/mcp@latest"],
106
+ "env": {
107
+ "ECHO_API_TOKEN": "ec_YOUR_API_KEY_HERE",
108
+ "ECHO_API_BASE_URL": "https://echo-mem-chrome.vercel.app"
109
+ }
110
+ }
111
+ }
112
+ }
113
+ ```
114
+ 3. Restart Windsurf.
115
+
116
+ ### Add to Claude Desktop
117
+
118
+ claude.ai integrations must be configured via the Claude Desktop app.
119
+ 1. Open your Claude Desktop setting file:
120
+ - **Mac**: `~/Library/Application Support/Claude/claude_desktop_config.json`
121
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
122
+ 2. Update your `mcpServers` object to include EchoMem, adding `ECHO_API_TOKEN` to the `env` object:
123
+ ```json
124
+ {
125
+ "mcpServers": {
126
+ "echomem": {
127
+ "command": "node",
128
+ "args": ["/absolute/path/to/EchoMem-Chrome/packages/mcp-server/dist/index.js"],
129
+ "env": {
130
+ "ECHO_API_TOKEN": "your_generated_ec_key..."
131
+ }
132
+ }
133
+ }
134
+ }
135
+ ```
136
+ 3. Restart Claude Desktop.
137
+
138
+ ### Using via CLI (testing)
139
+ ```bash
140
+ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run start
141
+ ```
142
+
143
+ ## Available Tools
144
+
145
+ * **`search_memories`**: Perform semantic searches against your personal memory vault using cloud embeddings.
146
+ * **`save_conversation`**: Ingest and structure a conversation directly into your EchoMem timeline.
147
+ * **`get_memories_by_time_range`**: Retrieve memories between explicit start/end timestamps.
148
+ * **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
149
+ * **`search_others_memories`**: Search other users' public memories through MemoryFeed.
150
+
151
+ Legacy aliases are preserved for compatibility:
152
+
153
+ * `search_memories_by_description_semantic` -> `search_memories`
154
+ * `search_memories_by_time_range` -> `get_memories_by_time_range`
155
+
156
+ Contract reference:
157
+
158
+ * `docs/PUBLIC_API_CONTRACT_V1.md`
159
+ * `docs/MCP_COMPAT_MATRIX.md`
package/dist/crypto.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * AES-256-GCM crypto for the local MCP bridge.
3
+ *
4
+ * ⚠️ MIRRORED FROM `utils/crypto.ts` (the extension/web shared module). The two MUST stay
5
+ * byte-compatible — the bridge decrypts ciphertext the extension/server wrote and vice versa.
6
+ * The ONLY intentional deviation: this file pins `node:crypto` webcrypto (the bridge is a
7
+ * Node-only CLI, never a browser), whereas the shared module reads `globalThis.crypto` so it
8
+ * can also run in-page. Algorithm, IV size, ciphertext envelope, PBKDF2 params, and the
9
+ * verification plaintext are identical — do not change them here without changing both.
10
+ *
11
+ * Ciphertext format: base64( 12-byte IV || ciphertext || 16-byte GCM auth tag )
12
+ */
13
+ import { webcrypto } from "node:crypto";
14
+ const ALGORITHM = "AES-GCM";
15
+ const KEY_LENGTH = 256;
16
+ const IV_BYTES = 12;
17
+ const SALT_BYTES = 16;
18
+ const DEFAULT_ITERATIONS = 600_000;
19
+ const VERIFICATION_PLAINTEXT = "echomem-verify-v1";
20
+ // Legacy plaintext used before rename — tokens in the DB may still use this.
21
+ const LEGACY_VERIFICATION_PLAINTEXT = "echo-vault-verify";
22
+ const subtle = webcrypto.subtle;
23
+ function toBase64(buf) {
24
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
25
+ let binary = "";
26
+ for (let i = 0; i < bytes.length; i++) {
27
+ binary += String.fromCharCode(bytes[i]);
28
+ }
29
+ return btoa(binary);
30
+ }
31
+ function fromBase64(b64) {
32
+ const binary = atob(b64);
33
+ const bytes = new Uint8Array(binary.length);
34
+ for (let i = 0; i < binary.length; i++) {
35
+ bytes[i] = binary.charCodeAt(i);
36
+ }
37
+ return bytes;
38
+ }
39
+ /** Generate a random 16-byte salt for PBKDF2. */
40
+ export function generateSalt() {
41
+ return webcrypto.getRandomValues(new Uint8Array(SALT_BYTES));
42
+ }
43
+ export function saltToBase64(salt) {
44
+ return toBase64(salt);
45
+ }
46
+ export function saltFromBase64(b64) {
47
+ return fromBase64(b64);
48
+ }
49
+ /**
50
+ * Derive an AES-256-GCM CryptoKey from a passphrase and salt via PBKDF2.
51
+ * Deterministic: same passphrase + salt + iterations → same key on any platform.
52
+ */
53
+ export async function deriveKey(passphrase, salt, iterations = DEFAULT_ITERATIONS) {
54
+ const encoder = new TextEncoder();
55
+ const keyMaterial = await subtle.importKey("raw", encoder.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
56
+ const saltBuf = new Uint8Array(salt).buffer;
57
+ return subtle.deriveKey({ name: "PBKDF2", salt: saltBuf, iterations, hash: "SHA-256" }, keyMaterial, { name: ALGORITHM, length: KEY_LENGTH }, true, ["encrypt", "decrypt"]);
58
+ }
59
+ export async function exportKeyToBase64(key) {
60
+ const raw = await subtle.exportKey("raw", key);
61
+ return toBase64(raw);
62
+ }
63
+ const importedKeyCache = new Map();
64
+ export async function importKeyFromBase64(b64) {
65
+ const cached = importedKeyCache.get(b64);
66
+ if (cached)
67
+ return cached;
68
+ const raw = fromBase64(b64);
69
+ const keyBuf = new Uint8Array(raw).buffer;
70
+ const key = await subtle.importKey("raw", keyBuf, { name: ALGORITHM, length: KEY_LENGTH }, false, ["encrypt", "decrypt"]);
71
+ importedKeyCache.set(b64, key);
72
+ return key;
73
+ }
74
+ /**
75
+ * Encrypt a plaintext string with AES-256-GCM.
76
+ * Returns base64( 12-byte IV || ciphertext || 16-byte auth tag ). Fresh random IV per call.
77
+ */
78
+ export async function encrypt(plaintext, key) {
79
+ const iv = webcrypto.getRandomValues(new Uint8Array(IV_BYTES));
80
+ const encoder = new TextEncoder();
81
+ const ciphertext = await subtle.encrypt({ name: ALGORITHM, iv }, key, encoder.encode(plaintext));
82
+ const combined = new Uint8Array(IV_BYTES + ciphertext.byteLength);
83
+ combined.set(iv, 0);
84
+ combined.set(new Uint8Array(ciphertext), IV_BYTES);
85
+ return toBase64(combined);
86
+ }
87
+ /**
88
+ * Decrypt a base64 ciphertext string produced by `encrypt()`.
89
+ * Throws if the key is wrong or data is tampered.
90
+ */
91
+ export async function decrypt(ciphertext, key) {
92
+ const combined = fromBase64(ciphertext);
93
+ const iv = combined.slice(0, IV_BYTES);
94
+ const data = combined.slice(IV_BYTES);
95
+ const plainBuffer = await subtle.decrypt({ name: ALGORITHM, iv }, key, data);
96
+ return new TextDecoder().decode(plainBuffer);
97
+ }
98
+ /** Verify a key against a stored verification token (current + legacy plaintexts). */
99
+ export async function verifyKey(key, token) {
100
+ try {
101
+ const result = await decrypt(token, key);
102
+ return result === VERIFICATION_PLAINTEXT || result === LEGACY_VERIFICATION_PLAINTEXT;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
@@ -0,0 +1,60 @@
1
+ import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey, saltFromBase64 } from "./crypto.js";
2
+ /** GET the account's encryption status from the EchoMem backend using the bridge's authed client. */
3
+ export async function fetchEncryptionConfig(axios) {
4
+ const res = await axios.get("/api/extension/account/encryption");
5
+ const data = res.data ?? {};
6
+ return {
7
+ enabled: !!data.enabled,
8
+ salt: data.salt,
9
+ verification: data.verification,
10
+ iterations: typeof data.iterations === "number" ? data.iterations : undefined,
11
+ };
12
+ }
13
+ /**
14
+ * Derive a key from a passphrase using the account's salt/iterations, verify it against the
15
+ * server's verification token, and return the base64 key — or null if the passphrase is wrong.
16
+ * Never returns an unverified key.
17
+ */
18
+ export async function deriveAndVerifyKey(passphrase, config) {
19
+ if (!config.enabled || !config.salt || !config.verification)
20
+ return null;
21
+ const salt = saltFromBase64(config.salt);
22
+ const key = await deriveKey(passphrase, salt, config.iterations ?? 600_000);
23
+ if (!(await verifyKey(key, config.verification)))
24
+ return null;
25
+ return exportKeyToBase64(key);
26
+ }
27
+ /** Verify an already-exported base64 key against the server's verification token. */
28
+ export async function verifyKeyB64(keyBase64, config) {
29
+ if (!config.enabled || !config.verification)
30
+ return false;
31
+ try {
32
+ const key = await importKeyFromBase64(keyBase64);
33
+ return await verifyKey(key, config.verification);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /** Decrypt one field with the cached key; returns the original value if it isn't valid ciphertext. */
40
+ export async function decryptField(value, keyBase64) {
41
+ if (!value)
42
+ return value;
43
+ try {
44
+ const key = await importKeyFromBase64(keyBase64);
45
+ return await decrypt(value, key);
46
+ }
47
+ catch {
48
+ return value; // plaintext / pre-encryption data — leave as-is
49
+ }
50
+ }
51
+ /** Decrypt the model-visible text fields on a memory row in place-safe fashion. */
52
+ export async function decryptMemoryFields(memory, keyBase64) {
53
+ const out = { ...memory };
54
+ for (const field of ["description", "details", "content"]) {
55
+ if (typeof out[field] === "string") {
56
+ out[field] = await decryptField(out[field], keyBase64);
57
+ }
58
+ }
59
+ return out;
60
+ }