@mnemoverse/mcp-memory-server
Version:
Hosted AI agent memory over MCP that learns from outcomes — feedback re-ranks recall — with shared rooms for multi-agent work; one key or OAuth across Claude Code, Cursor, VS Code & ChatGPT, no infra to run
435 lines • 24.4 kB
JavaScript
import { createRequire } from "node:module";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SERVER_INSTRUCTIONS } from "./teaching.js";
import { refusePlaceholderKey } from "./requests.js";
import { registerMemoryTools } from "./tools.js";
import { registerMemoryPrompts } from "./prompts.js";
import { registerMemoryResources } from "./resources.js";
// Every non-2xx becomes an instruction to the calling model instead of a raw
// wire echo — see the header of src/errors.ts for why, and for what each status
// actually means in this engine. So does a 2xx whose body this client cannot
// parse, which is a third case and not a variant of either of the other two.
import { ApiError, NetworkError, UnreadableBodyError } from "./errors.js";
// Version is read at runtime from package.json so there is exactly one place
// to bump on each release. Works both from `dist/` during local dev and from
// `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
/** Where the key goes when the user names no host. Referenced by the base-URL
* refusal below, which has to tell them what to set it back to. */
const DEFAULT_API_URL = "https://core.mnemoverse.com/api/v1";
const API_URL = process.env.MNEMOVERSE_API_URL || DEFAULT_API_URL;
const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
/**
* Is `MNEMOVERSE_API_URL` an address this client may attach the API key to —
* and if not, what does the user have to change (#99, CWE-319)?
*
* `apiFetch` attaches `X-Api-Key` to whatever this variable holds. Until this
* check existed, a base URL spelled `http://` — a typo, a copied tunnel address,
* a misread doc — put a live `mk_live_` key in cleartext on every tool call, on
* every hop between the user and that host, with nothing anywhere saying so.
* The key is the whole account: it reads and writes that user's memory.
*
* THREE HOSTS ARE EXEMPT, AND ONLY AS LITERAL HOSTNAMES. `http://localhost`,
* `http://127.0.0.1` and `http://[::1]` are legitimate — a self-hosted engine,
* this repo's own test harness — and a guard that demanded https unconditionally
* would break them. The comparison is against `URL.hostname`, NOT a prefix or
* suffix of the raw string, because `http://localhost.evil.example` passes a
* prefix test and `http://evil.localhost` passes a suffix test while both
* resolve to somebody else's server. The exemption is also scheme-bound —
* `ftp://localhost` is refused — since "aimed at loopback" is not by itself a
* reason to trust a transport.
*
* THE IPv6 LITERAL CARRIES ITS BRACKETS. `new URL("http://[::1]:8100")` reports
* `hostname` as `"[::1]"` — brackets included, not `"::1"` — so that is what the
* comparison holds, and test/base-url-guard.test.ts pins both spellings a user
* can type: the parser compresses `[0:0:0:0:0:0:0:1]` to `[::1]` before this
* function sees it, so one literal covers the long form too.
*
* WHAT IS STILL REFUSED, AND ON PURPOSE. `host.docker.internal` is NOT loopback
* and is not exempt: it resolves through the container's resolver to an address
* on the host network, so the packet — and the key — leaves the container before
* anything knows where it lands. "Aimed at my own machine" is a claim about
* intent; the exemption is about the wire. `[::ffff:127.0.0.1]` is refused for
* a smaller reason: it does reach 127.0.0.1, but it is a fourth spelling of an
* address that already has three legal ones, and widening an exemption that
* protects a live key is a decision, not a convenience.
*
* RETURNS A STRING RATHER THAN THROWING, and is called at import rather than
* on each request, because a throw at import would take the server down before
* `tools/list` — the one thing that is documented to work without any config at
* all (registries boot this server to enumerate its tools). The refusal has to
* land where the keyless refusal lands: inside a tool CALL.
*
* THE MESSAGE NEVER QUOTES THE URL, only its scheme. src/errors.ts keeps the
* base URL out of every message it builds on the stated grounds that the base
* can carry credentials — `http://user:pass@host` is exactly the case this guard
* fires on, so the one message whose subject IS the base URL is also the one
* most likely to leak it into a model's context and a client's logs. A scheme
* is safe to interpolate for a second reason: the URL parser restricts it to
* `[a-z0-9+.-]`, so it cannot carry a newline and open its own paragraph in
* this file's instruction voice.
*/
function refuseInsecureBaseUrl(raw) {
let url;
try {
url = new URL(raw);
}
catch {
return {
toolCall: "Mnemoverse: this tool did not run, because MNEMOVERSE_API_URL is not " +
"a URL this client can parse — so it cannot tell whether sending the " +
"API key to it would expose the key, and it will not guess. Nothing " +
"was sent. This is the user's configuration: their key, their network " +
"and the service are all fine. Tell them to set MNEMOVERSE_API_URL to " +
"a complete https:// URL including the scheme and the path — the " +
`default is ${DEFAULT_API_URL} — or to unset it and use that default. ` +
"A local engine is the one exception and must be addressed as " +
"http://localhost, http://127.0.0.1 or http://[::1]. Do not retry " +
"until it is changed; every memory tool will fail the same way " +
"until then.",
startupLog: "Mnemoverse: startup key check SKIPPED, and nothing was sent — " +
"MNEMOVERSE_API_URL is not a URL this server can parse, so it cannot " +
"tell whether sending your API key over it would expose it. Set it to " +
`a complete https:// URL such as ${DEFAULT_API_URL}, or unset it to ` +
"use that default. Every memory tool will fail until it is changed.",
};
}
const loopback = url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
// Brackets included: that is what `URL.hostname` reports for an IPv6 host.
url.hostname === "[::1]";
if (url.protocol === "https:" || (url.protocol === "http:" && loopback)) {
return undefined;
}
return {
toolCall: "Mnemoverse: this tool did not run, because MNEMOVERSE_API_URL does not " +
`address the API over https — its scheme is ${url.protocol}// — and ` +
"sending the API key to it would put a live mk_live_ key on the wire in " +
"cleartext, readable by anything on the path. Nothing was sent: this " +
"client refuses the call instead. This is the user's configuration, not " +
"a fault of the key, the network or the service. Tell them to change " +
"MNEMOVERSE_API_URL to the https:// form of the same host — the default " +
`is ${DEFAULT_API_URL} — or, if they are deliberately running the ` +
"engine on their own machine, to address it as http://localhost, " +
"http://127.0.0.1 or http://[::1] — the only three plain-http hosts " +
"this client accepts. Do not retry until it is changed; every memory " +
"tool will fail the same way until then.",
startupLog: "Mnemoverse: startup key check SKIPPED, and nothing was sent — " +
`MNEMOVERSE_API_URL has the scheme ${url.protocol}// rather than ` +
"https://, and this server will not put your API key on the wire in " +
`cleartext. Set it to ${DEFAULT_API_URL}, or to http://localhost, ` +
"http://127.0.0.1 or http://[::1] if you run the engine yourself. " +
"Every memory tool will fail until it is changed.",
};
}
/** The verdict on this process's base URL, computed once. `undefined` means the
* key may go out; anything else is what each surface says instead. */
const BASE_URL_REFUSAL = refuseInsecureBaseUrl(API_URL);
/**
* The verdict on this process's API key, computed once next to
* BASE_URL_REFUSAL because both are config-only guards checked at the same
* two call sites below (apiFetch, the startup probe) before anything is
* sent. `undefined` means the key is not certainly a docs placeholder; it
* may still be wrong, but that is the engine's 401 to diagnose, not a guess
* this client can make from shape alone. See `refusePlaceholderKey` in
* src/requests.ts for exactly what counts as "certainly".
*/
const PLACEHOLDER_KEY_REFUSAL = refusePlaceholderKey(API_KEY);
/**
* Fetch from the Mnemoverse core API with authentication.
*
* Generic so call sites can declare the expected response shape:
*
* const r = await apiFetch<{ stored: boolean; atom_id: string }>("/memory/write", { ... });
*
* Handles 204 No Content and empty bodies defensively — FastAPI DELETE
* handlers may switch to 204 in the future even though today they return
* a JSON body.
*
* @throws {@link ApiError} on non-2xx — whose message is the agent-facing
* explanation from src/errors.ts, with the raw body kept after it. The MCP SDK
* turns a thrown Error into `{isError: true, content: [{type: "text", text:
* error.message}]}`, so this message IS what the calling model reads; that is
* why it is written as instructions rather than as a wire dump.
*/
async function apiFetch(path, options = {}) {
const method = (options.method ?? "GET").toUpperCase();
if (!API_KEY) {
// The one failure that needs no request to diagnose — and the only one
// where the fix is "set the variable" rather than "replace its value".
// Kept distinct from the 401 sentence for exactly that reason.
throw new Error("Mnemoverse: no API key is configured, so this tool cannot run. Tell the " +
"user to set MNEMOVERSE_API_KEY in their MCP client config — a free key " +
"takes about 30 seconds at https://console.mnemoverse.com/dashboard/keys " +
"and starts with mk_live_. Do not retry until it is set; every memory " +
"tool will fail the same way until then.");
}
if (PLACEHOLDER_KEY_REFUSAL !== undefined) {
// After the empty-key check, for the same reason that one runs first:
// "set the variable" and "replace the placeholder" are different fixes,
// and a key that is present but certainly a docs example gets its own
// sentence rather than the emptiness one. Decided from CONFIGURATION
// alone, exactly like BASE_URL_REFUSAL right below, so it belongs here
// and not in a diagnosis of a response this call never sends.
throw new Error(PLACEHOLDER_KEY_REFUSAL.toolCall);
}
if (BASE_URL_REFUSAL !== undefined) {
// Alongside the key check, and after it: with no key configured there is
// nothing to expose, and "set the variable" is the more useful first thing
// to tell someone who has set neither. Both are decided from CONFIGURATION
// alone, which is why both belong here rather than in a diagnosis of the
// response — a guard that fired after fetch would print the right sentence
// about a key it had already put on the wire.
throw new Error(BASE_URL_REFUSAL.toolCall);
}
let res;
try {
res = await fetch(`${API_URL}${path}`, {
...options,
// AFTER the spread, so no call site can opt out of it. `fetch` defaults to
// "follow", and Node re-sends request headers to the redirect target: the
// WHATWG rules strip Authorization, Cookie and Proxy-Authorization when
// the origin changes and say nothing about a custom header, so
// `X-Api-Key` rides along to whoever answered (#99, CWE-200). This API
// serves one stable base path and never redirects legitimately, so the
// only traffic this refuses is an exfiltration path. The resulting
// rejection is named by explainNetworkFailure rather than left to look
// like a dead host.
redirect: "error",
headers: {
"Content-Type": "application/json",
"X-Api-Key": API_KEY,
...(options.headers || {}),
},
});
}
catch (cause) {
// `TypeError: fetch failed` is what a model used to read here, which is as
// uninstructive as the raw 401 body this change is about. Every existing
// caller that swallows a failure (the scope probes, the empty-read stats
// call) catches without inspecting the type, so wrapping changes nothing
// for them — it only changes what surfaces when nobody catches.
throw new NetworkError(method, path, cause);
}
if (!res.ok) {
// `fetch()` resolves once HEADERS arrive; a connection reset during the
// body read rejects HERE, not in the catch above, and used to surface as
// a raw undici TypeError (Copilot, #93).
//
// It is NOT the same failure as the catch above, and calling it one threw
// the status away: a 502 whose body died mid-stream was reported as "the
// memory service could not be reached at all… before any HTTP response
// came back", about a response whose status we are holding. The status is
// the most actionable thing this failure has, so it goes in the sentence.
let text;
try {
text = await res.text();
}
catch (cause) {
throw new UnreadableBodyError({ status: res.status, method, path, cause });
}
throw new ApiError({
status: res.status,
body: text,
method,
path,
// Only the rate-limit middleware sets it, and only on the 429 that
// waiting actually clears — so its PRESENCE is evidence, not decoration.
retryAfter: res.headers.get("retry-after"),
});
}
// 204 No Content or empty body — return an empty object cast as T so
// call sites using optional chaining still work without crashing.
if (res.status === 204 || res.headers.get("content-length") === "0") {
return {};
}
// A 2xx WHOSE BODY THIS CLIENT CANNOT READ. Read the text first and parse it
// second, rather than `res.json()`, for one reason: `res.json()` consumes the
// stream, so when it throws there is nothing left to quote — and the bytes
// are the evidence. A captive portal's sign-in page, an SPA shell served with
// 200 text/html, a MITM proxy's notice, or a JSON body that stopped mid-write
// all land here, and the first line of any of them identifies the culprit.
//
// Both throws used to be NetworkError, which asserts that NOTHING answered —
// "could not be reached at all… before any HTTP response came back… a
// connectivity or DNS problem" — while a 200 sat in this very function's
// hand, and the Raw detail below it quoted a SyntaxError out of the body it
// had just called nonexistent. Sending a user to debug wifi while a portal
// answers every request is the confident wrong cause src/errors.ts exists to
// remove. A real `fetch()` rejection above keeps that wording; this does not.
let body;
try {
body = await res.text();
}
catch (cause) {
throw new UnreadableBodyError({ status: res.status, method, path, cause });
}
try {
return JSON.parse(body);
}
catch (cause) {
throw new UnreadableBodyError({
status: res.status,
method,
path,
bodyPreview: body,
cause,
});
}
}
// --- Server setup ---
// The second argument lands verbatim in the connected model's system prompt on
// clients that surface MCP instructions — it is the single highest-leverage
// teaching surface this server has. Kept in src/teaching.ts so tests can
// assert its polarity/length without booting the server.
//
// EXPORTED so a test can connect a real MCP client to this exact server over
// the SDK's in-memory transport and invoke the tools (test/harness.ts). Nothing
// on the published CLI path reads this binding — see the note at the bottom of
// the file for why the export alone is not enough, and what the CLI still does.
export const server = new McpServer({
name: "mnemoverse-memory",
version: pkg.version,
}, { instructions: SERVER_INSTRUCTIONS });
// The tools themselves live in src/tools.ts, shared with every other server
// that exposes Mnemoverse memory over MCP (ADR-025). This server's part is
// apiFetch above: the API key, the base URL and their refusals.
registerMemoryTools(server, { apiFetch });
// Three named entry points to those tools (src/prompts.ts); they call nothing.
registerMemoryPrompts(server);
// One saved memory by id, memory://item/{memory_id} (src/resources.ts).
registerMemoryResources(server, { apiFetch });
// --- Start ---
/**
* Opening the stdio transport at import time is what made every user-visible
* sentence in this file untestable: a test that imports this module to reach a
* handler instead starts a server on the test runner's stdin/stdout. So the copy
* was guarded by regexes over this source text — and three review rounds each
* found false sentences a behavioural test would have caught immediately, twice
* in a guard that turned out to be theatre.
*
* The seam is one boolean, and its DEFAULT is today's behaviour.
*
* WHY AN ENV OPT-OUT AND NOT `import.meta.url === process.argv[1]`. That
* comparison is the usual "am I the entry point?" idiom and it is the wrong tool
* here, because this package ships as an npm `bin`. Under `npx` — the canonical
* install, pinned in src/configs/source.json and in every README snippet — the
* thing on argv[1] is the generated shim, not this file; on Windows it is a
* `.cmd`/`.ps1` wrapper, and even the POSIX shim is a symlink whose realpath
* resolution differs between package managers. Every one of those makes the
* comparison FALSE for a real user, and a false comparison there does not throw:
* the process would exit 0 having started no server, and the client would report
* a silent connection failure with nothing in the logs. That is a worse defect
* than the one being fixed. The env var inverts the risk — it can only misfire
* for something that deliberately sets it, and no released version reads it, so
* no existing config can carry it.
*
* The check is `=== "1"`, deliberately narrow rather than truthy: the failure
* mode of a wide check is a startup that silently does nothing, so an
* unrecognised value must fall through to starting the server.
*
* BYTE-IDENTICAL CLI BEHAVIOUR: with the variable unset (or set to anything
* other than "1"), `main()` is called exactly as before, with the same catch,
* the same message and the same exit code. The only other change to this module
* is the `export` on `server`, which is inert when the file is run as a program.
*/
/**
* Startup key probe — the fix for the invisible failure mode.
*
* Verified live on 0.8.3: with a GARBAGE key the server completes initialize,
* lists all twelve tools, and writes nothing to stderr — the 401 only appears
* mid-conversation, on the first real tool call. A user who mistyped the key
* sees a green connection and later reads the silent failure as "the product
* does not work" (a user's assistant, 2026-08-16).
*
* So: if a key IS configured, verify it once at startup with the cheapest
* authenticated call and put the verdict on stderr, which MCP clients surface
* in their connection logs — the place a user actually looks. Three rules:
*
* - NEVER exits and NEVER blocks connect(): keyless startup is documented
* behaviour (registries boot the server to enumerate tools), and a network
* blip must not take the server down. The probe runs after connect, in the
* background.
* - 401 gets a human sentence naming the env var. Other failures (network,
* 5xx) get a soft note — they say nothing about the key.
* - stderr only. stdout belongs to the MCP protocol.
*/
function probeApiKeyInBackground() {
if (!API_KEY)
return;
if (PLACEHOLDER_KEY_REFUSAL !== undefined) {
// Same reasoning as the BASE_URL_REFUSAL skip right below, for the same
// reason it is checked first inside apiFetch: this probe is a second
// credential-bearing call site outside apiFetch, so a config-only
// refusal decided at import time has to be checked here too, or a docs
// placeholder key would go out over the wire once per server start even
// though every tool call already refuses to send it.
console.error(PLACEHOLDER_KEY_REFUSAL.startupLog);
return;
}
if (BASE_URL_REFUSAL !== undefined) {
// THE SECOND CREDENTIAL-BEARING CALL SITE (#99). This probe predates
// apiFetch's base-URL guard and calls `fetch` directly, so the guard does
// not reach it — and this one fires on every server START, before any tool
// is called. Left alone, a user whose MNEMOVERSE_API_URL is spelled
// `http://` would leak the key by launching their editor, and the tool-call
// guard would then dutifully refuse to leak the key it had already leaked.
//
// Saying nothing is not an option either: a silent server whose every tool
// then fails is the exact invisible failure mode this probe was added to
// end. So it reports the skip on stderr — where MCP clients surface
// connection logs — in the user's own register.
console.error(BASE_URL_REFUSAL.startupLog);
return;
}
void (async () => {
try {
const res = await fetch(`${API_URL}/memory/stats`, {
headers: { "X-Api-Key": API_KEY },
// Same reason as apiFetch: following a redirect re-sends this header to
// the new host (#99, CWE-200), and this request carries the key just as
// a tool call does. The rejection lands in the catch below with every
// other probe failure — deliberately, since the probe is fire-and-forget
// and must never take the server down. Nothing is lost by the silence:
// the same redirect meets the first real tool call, where
// explainNetworkFailure names it in full.
redirect: "error",
signal: AbortSignal.timeout(10_000),
});
// Drain the body so the connection returns to the pool immediately —
// an unread body can hold the socket and makes the probe less "cheap"
// than advertised (Copilot, PR #91).
void res.body?.cancel();
if (res.status === 401 || res.status === 403) {
console.error(`Mnemoverse: API key rejected (${res.status}). ` +
"Check MNEMOVERSE_API_KEY in your MCP client config — " +
"tool calls will fail until it is fixed. " +
"Keys start with mk_live_ and come from https://console.mnemoverse.com");
}
else if (!res.ok) {
console.error(`Mnemoverse: startup key check got HTTP ${res.status} — ` +
"this says nothing about your key; tool calls may still work.");
}
// 2xx: stay silent. A quiet startup is the healthy one.
}
catch {
// Network error at startup proves nothing about the key. Say nothing:
// a scary stderr line on a flaky hotel wifi would be a false alarm.
}
})();
}
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
probeApiKeyInBackground();
}
if (process.env.MNEMOVERSE_MCP_NO_AUTOSTART !== "1") {
main().catch((err) => {
console.error("MCP server error:", err);
process.exit(1);
});
}
//# sourceMappingURL=index.js.map