@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
355 lines (354 loc) • 20 kB
TypeScript
/**
* What a failed API call TELLS THE AGENT TO DO.
*
* WHY THIS MODULE EXISTS. Until now every non-2xx became one sentence:
*
* Mnemoverse API error 401: {"code":"UNAUTHORIZED","message":"Invalid or
* revoked API key.","requestId":null,"retryable":false,"details":null}
*
* The `isError` flag and the status code were right; the TEXT was a raw echo of
* a wire body. Verified against production with a real `tools/call`
* (2026-08-16): with a bad key, `memory_write` returns exactly that. The reader
* of a tool result is a MODEL, and that string gives a model nothing to act on,
* so the two things it actually does are relay "error 401" to the user or guess
* — and the cheapest guess is "the network is down", which sends the user to
* debug a working network while a placeholder key sits in their config.
*
* Every message here therefore answers three questions in order: what happened,
* WHOSE problem it is, and what to do next — including, explicitly, whether to
* retry. "Do not retry" is not decoration: an agent that loops on a 429 turns
* one rate-limit into a sustained one, and an agent that loops on a 401 burns a
* conversation on a key that will never start working.
*
* THE RAW BODY IS NEVER DROPPED. It is appended after the guidance, still
* carrying the literal `Mnemoverse API error <status>` that this repo's tests
* and anyone's grep already look for. Making the text kind to an agent must not
* make it useless to a human debugging at 3am.
*
* WHAT THE STATUS CODES ACTUALLY MEAN HERE, verified by reading the engine
* (mnemoverse-core `src/mnemo/api/`, 2026-08-16) rather than assumed from HTTP
* folklore. The distinctions below are the entire point of this file:
*
* 401 auth.py — "Invalid or revoked API key." / "Missing API key." The key.
* (Read on 2026-08-16. Since 2026-09-20 the engine answers a sentence
* per cause and adds `details.reason` and `details.keys_url`; the old
* sentence is what an engine without that change still sends, which is
* why explain401 keeps the substring branch as its fallback.)
* 403 routes.py, rooms_routes.py, auth.py — "Room is archived", "Not an
* active member of this room", "Read-only membership cannot write to
* this room", "Invalid room address", "You do not own this room", plus
* the OIDC scope rules. NOT a bad key: the caller was identified and
* then refused. Telling the user to replace a working key over a 403
* would be a new, more confident lie than the raw echo was.
* 404 A room that does not exist for this key — or, when the body says
* nothing at all, a path the deployment does not serve. A DOMAIN never
* 404s: an empty domain reads as empty, so "your domain is missing" is
* the wrong guess and is named as wrong below.
* 429 THREE different causes with opposite advice. rate_limit.py returns
* `retryable: true` with a `Retry-After` header (waiting works);
* usage.py (daily quota) and subscription_guard.py (atom limit, payment
* past due) return `retryable: false` (waiting does NOT work — the
* account needs an upgrade or a new day). A blanket "wait and retry"
* would be wrong for two of the three.
* 5xx auth.py returns 503 `retryable: true` when its DB pool is gone. Ours,
* not theirs.
*
* A 2xx CAN FAIL TOO, and gets the same treatment for the same reason. A reply
* whose body this client cannot parse is not a status at all, so none of the
* sentences above fit it — and it used to borrow the TRANSPORT sentence, which
* asserts that nothing answered. `explainUnreadableBody` at the bottom of this
* file owns that case now; the distinction it protects is "a reply arrived and
* was unreadable" versus "no reply arrived", which have opposite fixes.
*
* RELATION TO THE STARTUP PROBE (src/index.ts, `probeApiKeyInBackground`, added
* in 0.8.4). That probe treats 401 and 403 alike, and is right to: it calls
* `GET /memory/stats`, which addresses no room, so a 403 there can only come
* from the auth layer. On a TOOL CALL the same status usually comes from a room
* the caller named, which is why the two surfaces diverge on 403 and must not be
* "unified" by a later reader. They agree where it matters: same `Mnemoverse:`
* opener, same `MNEMOVERSE_API_KEY` variable name, same console origin.
*/
/** The console page that issues keys — the one place a user fixes a 401.
* Exported so src/requests.ts can point at the same URL from
* `refusePlaceholderKey` instead of repeating the literal (the engine is
* becoming a second producer of this same URL, `details.keys_url` on a 401
* body, which is validated against an allow-list rather than trusted
* outright, see `validatedKeysUrl` below, but this constant stays the
* fallback for both). */
export declare const KEYS_URL = "https://console.mnemoverse.com/dashboard/keys";
/**
* How a server that registers these tools wants its failures worded (STEP4-2,
* STEP4-3, owner decisions 2026-09-24). Every field is optional, and every
* field's absence means exactly what this file already does today: the
* whole point is that a consumer supplying no `wording` at all gets
* byte-identical text to before this type existed.
*
* - `serverNoun`: what the three tool descriptions that name themselves
* call this deployment ("this server" vs "this connector"). Read by
* src/tools.ts at registration time; ignored by this module.
* - `auth`: which credential the CALLER holds, not which one core issued.
* "api-key" (default) keeps every existing MNEMOVERSE_API_KEY-flavoured
* sentence. "oauth" is for a server whose user never sees an API key at
* all (a hosted connector minting a key on their behalf): no 401/403
* explanation under this mode names MNEMOVERSE_API_KEY, an env var, an
* MCP config file, or the keys console; each says what an OAuth user can
* actually do instead (reconnect, sign in again, check the plan, wait
* for the retry window).
* - `keysUrl`: replaces {@link KEYS_URL} wherever the api-key vocabulary
* prints a console URL, for a deployment whose key-management page is
* not console.mnemoverse.com. Has no effect under `auth: "oauth"`, which
* prints no console URL at all.
* - `rawDetail`: whether the wire body's raw tail (STEP4-3; e.g. `Raw
* detail — Mnemoverse API error 401 on …`) is appended after the
* guidance. Defaults to `true`, unchanged from every release before this
* one. Held in reserve for a deployment whose core-side error envelopes
* are found to echo request content back in `details`: set to `false`
* only once that check finds something to hide.
*/
export interface Wording {
serverNoun?: "this server" | "this connector";
auth?: "api-key" | "oauth";
keysUrl?: string;
rawDetail?: boolean;
}
/** Everything known about one failed call, at the moment it failed. */
export interface ApiFailure {
/** HTTP status. */
status: number;
/** Response body, verbatim and unparsed. */
body: string;
/** Uppercase HTTP verb. */
method: string;
/** Path below the API base, e.g. "/memory/write". Deliberately NOT the full
* URL: the base can carry credentials, and the path alone is what identifies
* the operation. */
path: string;
/** `Retry-After` header when the response carried one. */
retryAfter?: string | null;
}
/**
* The engine's error envelope, as much of it as survived parsing.
*
* Every field is optional because a non-2xx does not have to come from the
* engine at all: a proxy, a tunnel, or a wrong `MNEMOVERSE_API_URL` answers with
* HTML, plain text or nothing. A missing field means "the body did not say",
* never a default — inventing `retryable: false` for an unparseable body would
* put a retry decision on evidence that does not exist.
*/
export interface ErrorEnvelope {
code?: string;
message?: string;
retryable?: boolean;
/**
* `details.reason` on a 401 (an engine change not yet released as of
* this one): missing_key | placeholder_key | revoked_key | invalid_key |
* malformed_key. Absent on every 401 a currently-released engine sends, and
* absent whenever `details` was not an object carrying a string `reason`.
* Never defaulted, for the same reason nothing else in this interface is:
* a made-up reason is a wrong instruction with a confident source.
*/
reason?: string;
/**
* `details.keys_url` on the same 401, already validated by
* {@link validatedKeysUrl}: present only when it was an https URL whose
* host is exactly console.mnemoverse.com. A response body must not be able
* to point a reader at an arbitrary site, so an untrusted value never
* reaches this field at all; a caller that wants the URL for a reason
* branch falls back to {@link KEYS_URL} when this is undefined.
*/
keysUrl?: string;
}
/**
* Read the engine's envelope out of a response body.
*
* Two shapes are accepted because core produces both: its own middleware writes
* `{code, message, retryable, details}` at the top level, while anything raised
* as a FastAPI `HTTPException` arrives wrapped as `{"detail": …}` — where the
* detail is sometimes a string and sometimes the envelope again. The feed's
* 404-vs-404 test in this repo pins the nested form, so both are real.
*
* `details` (plural, a sibling of `code`/`message`/`retryable`) is read here
* too, for `reason` and `keys_url`. Anything else inside
* it is ignored silently: this parser reads a fixed, named set of fields and
* has no way to tell a future field the engine adds from noise, so silence is
* the only honest answer for either one.
*/
export declare function parseErrorEnvelope(body: string): ErrorEnvelope;
/** `Retry-After` as whole seconds, when it is a plain number. The HTTP-date
* form is legal too, and is deliberately NOT parsed: an unparsed header
* degrades into "wait a moment", which is honest, whereas a misparsed date
* would print a confident and wrong number of seconds. */
export declare function retryAfterSeconds(header: string | null | undefined): number | undefined;
/**
* The agent-facing explanation for one failed call, with the raw body kept
* after it (unless `wording.rawDetail === false`, STEP4-3).
*
* Total by construction: every status reaches a sentence, and the fallback says
* that it has no specific guidance instead of inventing some.
*
* `wording` (STEP4-2/3, owner 2026-09-24) is optional and, absent, resolves
* to exactly today's behaviour: every call site before this release passes
* none, so every existing pin stays byte-identical.
*/
export declare function explainApiFailure(f: ApiFailure, wording?: Wording): string;
/**
* The request never got an answer at all: DNS, connectivity, a host that does
* not listen, or this client's own probe deadline firing.
*
* Included even though the brief was about HTTP statuses, because it is the
* OTHER half of the same defect and the two are easy to confuse. The 401
* message above tells an agent not to blame the network; this is the case where
* the network genuinely IS the answer, and `TypeError: fetch failed` — which is
* all a model saw before — is no more actionable than the raw 401 body was.
*
* A timeout is named separately because the advice differs in one word: an
* unreachable host is unlikely to become reachable in three seconds, whereas a
* request that ran out of time may well succeed on a second try.
*
* NARROWED to `fetch()` itself rejecting. Reading the BODY of a response that
* did arrive used to land here too, so a 200 carrying a sign-in page printed
* "no HTTP response came back" — see {@link explainUnreadableBody}, which owns
* that case now. Every sentence below asserts that nothing answered, so it may
* only be reached when nothing did.
*/
export declare function explainNetworkFailure(method: string, path: string, cause: unknown, wording?: Wording): string;
/** A 2xx-or-not response whose BODY this client could not turn into the JSON
* this API speaks. The status is known — a reply arrived — which is the whole
* difference between this and {@link ApiFailure} or a transport failure. */
export interface UnreadableBody {
/** HTTP status, as it actually arrived. */
status: number;
/** Uppercase HTTP verb. */
method: string;
/** Path below the API base. Never the full URL — the base can carry
* credentials, and the path alone identifies the operation. */
path: string;
/** The bytes that failed to parse, when they were read at all. Absent when
* the body READ itself failed, which is a different sentence below. */
bodyPreview?: string;
/** The SyntaxError, TypeError or abort that stopped the read. */
cause: unknown;
}
/**
* A reply ARRIVED and could not be read — which is not a dead network.
*
* WHY THIS IS SEPARATE FROM {@link explainNetworkFailure}. Both failures used
* to throw the same NetworkError, so `JSON.parse` choking on a 200 printed
* "the memory service could not be reached at all — POST /memory/read failed
* before any HTTP response came back… That is a connectivity or DNS problem".
* Against a captive portal, a MITM proxy, an SPA that serves its shell with a
* 200, or a body that stops mid-stream, every clause of that is false: a reply
* came, with a status, and the Raw detail underneath it quoted a SyntaxError
* out of the body it had just called nonexistent.
*
* The cost is the same one this module exists to remove — a confident wrong
* cause. "Connectivity or DNS" sends the user to debug working wifi while a
* portal or a mis-set MNEMOVERSE_API_URL answers every request with a page.
*
* WHAT THIS MESSAGE MAY CLAIM. Only what the status proves: something answered,
* and its answer is not this API's JSON. It does NOT name who answered — this
* client cannot tell the engine from a gateway in front of it — and it does NOT
* say whether the operation ran, because a body it cannot read is no evidence
* either way. That last clause matters most for a write.
*/
export declare function explainUnreadableBody(f: UnreadableBody, wording?: Wording): string;
/**
* {@link explainUnreadableBody} as an error, so a caller can tell "a reply
* arrived and was unreadable" from "no reply arrived" by TYPE rather than by
* matching a sentence — the same reason {@link ApiError} carries fields.
*/
export declare class UnreadableBodyError extends Error {
readonly status: number;
readonly method: string;
readonly path: string;
/** The bytes that failed to parse, when they were read at all. Kept so
* {@link withWording} re-renders the same arm of the explanation. */
readonly bodyPreview: string | undefined;
/** `wording` (STEP4-2/3): optional, and absent (every call site before this
* release) reproduces today's message exactly: see {@link explainUnreadableBody}. */
constructor(f: UnreadableBody, wording?: Wording);
/** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
withWording(wording: Wording | undefined): UnreadableBodyError;
}
/** {@link explainNetworkFailure} as an error, so `instanceof` can tell a
* transport failure from an HTTP one without reading either message. */
export declare class NetworkError extends Error {
readonly method: string;
readonly path: string;
/** `wording` (STEP4-2/3): optional, and absent (every call site before this
* release) reproduces today's message exactly: see {@link explainNetworkFailure}. */
constructor(method: string, path: string, cause: unknown, wording?: Wording);
/** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
withWording(wording: Wording | undefined): NetworkError;
}
/**
* A failed API call, as an ERROR OBJECT rather than a parsed string.
*
* The status and the body are fields because a caller that needs to branch on
* them must not do it by matching the message. `memory_list_recent` used to
* decide between "the endpoint is not deployed" and "this room does not exist"
* with `message.startsWith("Mnemoverse API error 404:")`, which coupled a
* behavioural branch to the exact prefix of a user-facing sentence — so
* improving the sentence, which is this change, would have silently flipped
* that branch. Structured fields make the wording free to change.
*/
export declare class ApiError extends Error {
readonly status: number;
readonly body: string;
readonly method: string;
readonly path: string;
/** The engine's envelope, already parsed — so a caller never re-parses. */
readonly envelope: ErrorEnvelope;
/** The `Retry-After` header as it arrived, when the response carried one.
* Kept so {@link withWording} re-renders a 429 with its retry sentence. */
readonly retryAfter: string | null | undefined;
/** `wording` (STEP4-2/3): optional, and absent (every call site before this
* release) reproduces today's message exactly: see {@link explainApiFailure}. */
constructor(f: ApiFailure, wording?: Wording);
/**
* The same failure, explained under `wording`: a new instance of this
* class whose message is what the constructor would have produced with
* that `wording`, every field and {@link isBare404} unchanged. The message
* is a pure function of the failure and the wording, so re-rendering an
* instance under the wording it was built with gives the same text back.
* This is how `MemoryToolDeps.wording` reaches an error the consumer's
* `apiFetch` built without it: see {@link rewordFailure}.
*/
withWording(wording: Wording | undefined): ApiError;
/**
* A 404 the ENGINE did not answer: silence, or the router's literal
* defaults. This is what a path the deployment does not serve looks like
* — the first version of this predicate tested silence alone, mistook the
* real framework body `{"detail":"Not Found"}` for an engine answer, and
* un-fired the feed's degrade branch for the exact rollout case it exists
* for (panel, #93).
*
* NAMED FOR WHAT IT TESTS, not for what it implies: a gateway, a proxy or a
* wrong MNEMOVERSE_API_URL can produce the same shapes and is
* indistinguishable from here. Known, accepted edge: a foreign API whose
* 404 nests its error under a key this parser does not read
* (`{"error":{…}}`) also reads as bare, so the feed degrades to its
* not-supported notice instead of surfacing the foreign body — bounded
* harm, since against a wrong base URL the very next call fails with the
* instructive no-envelope wording.
*/
get isBare404(): boolean;
}
/**
* Re-explain one of this module's three errors under `wording`; hand
* anything else back untouched.
*
* This is how `MemoryToolDeps.wording` reaches the error text (STEP4-2): a
* consumer's `apiFetch` constructs `ApiError`, `NetworkError` and
* `UnreadableBodyError` however it likes, and `registerMemoryTools` passes
* every rejection through here with the `wording` it was given, so the
* consumer states its wording once, on `deps`, and the constructors need no
* second copy. Re-rendering is idempotent (see {@link ApiError.withWording}),
* so an `apiFetch` that does pass the same `wording` to the constructors
* gets the same text either way; one that passed a different wording gets
* `deps.wording`, the one source of truth for this registration. A
* rejection that is none of the three classes (a plain `Error`, an
* `McpError`) is returned as is: it carries no wording to apply, and its
* identity may matter to whoever threw it.
*/
export declare function rewordFailure(e: unknown, wording: Wording | undefined): unknown;