UNPKG

@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
/** * 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;