# Remember plugin

> @frontmcp/plugin-remember gives tools an encrypted key-value memory, this.remember, in session, user, tool and global scopes, plus four tools that let the model remember things. Every option, what each scope keeps, and who can read it back.

Source: https://frontmcp.dev/reference/plugins/remember

`@frontmcp/plugin-remember` gives every tool a small key-value memory, `this.remember`, so a tool can store something on one call and read it on a later one: a user's language, a draft, the last ticket they looked at. Values are encrypted before they're stored, in memory, Redis or Vercel KV. Each value lives in a scope, `session`, `user`, `tool` or `global`, which decides who can read it back. Set `tools.enabled` and the plugin also registers four tools (`remember_this`, `recall`, `forget` and `list_memories`) that let the model store and recall things itself. What a scope keeps depends on who's calling and how, so read [Scopes](#scopes) before choosing one. [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) teaches it step by step.

```ts
plugins: [RememberPlugin.init({ type: "memory", keyPrefix?, encryption?, ... })]

await this.remember.set(key, value, { scope?, ttl?, brand?, metadata? })
await this.remember.get(key, { scope?, defaultValue? })
```

---

## Reference

### `RememberPlugin.init(options)`

Install the package and register the plugin with `init()`. Tools then have `this.remember`:

```bash
npm install @frontmcp/plugin-remember
```

```ts preferences.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

@Tool({ name: "set_language", description: "Set the language the user wants answers in", inputSchema: { language: z.string() } })
export class SetLanguage extends ToolContext {
  async execute({ language }: { language: string }) {
    await this.remember.set("language", language, { scope: "user" });
    return { language };
  }
}

@App({ id: "prefs", name: "Preferences", tools: [SetLanguage], plugins: [RememberPlugin.init({ type: "memory" })] })
export class PreferencesApp {}
```

Importing `@frontmcp/plugin-remember` also tells TypeScript about `this.remember`. The plugin is exported by `@frontmcp/plugins` too; see the [plugins overview](https://frontmcp.dev/reference/plugins). [See more examples below.](#usage)

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `"memory"`, `"redis"`, `"redis-client"`, `"vercel-kv"` or `"global-store"` | `"memory"` | Where values are kept. See [Stores](#stores). |
| `keyPrefix` | `string` | `"remember:"` | Starts every key in the store. Give each server its own when several share a Redis. |
| `encryption.enabled` | `boolean` | `true` | Encrypt values before they're stored. See [Encryption](#encryption). |
| `config` | `{ host, port, password?, db? }` | | With `type: "redis"`: where to connect. |
| `client` | `Redis` (ioredis) | | With `type: "redis-client"`: a client you created. |
| `url`, `token` | `string` | `KV_REST_API_URL`, `KV_REST_API_TOKEN` | With `type: "vercel-kv"`: the store's REST URL and token. Give both or neither. |
| `defaultTTL` | `number` (seconds) | | How long a value set without `ttl` lives, in every store. A whole number; `0` means no default. Anything else makes `init()` throw a `RememberConfigurationError`. |
| `tools.enabled` | `boolean` | `false` | Register the [memory tools](#the-memory-tools): `remember_this`, `recall`, `forget` and `list_memories`. |
| `tools.prefix` | `string` | none | Starts the memory tools' names: `"memory_"` gives `memory_remember_this`, and so on. Their descriptions name the prefixed tools. |
| `tools.allowedScopes` | `RememberScope[]` | All four | Which scopes the memory tools accept. A call with another scope, or none when `session` isn't allowed, is refused with [`RememberScopeNotAllowedError`](#rememberscopenotallowederror). |
| `skipLegacyPurge` | `boolean` | `false` | Don't delete entries left by older versions of the plugin, whose keys can no longer be read. |
| `legacyPurgeDelayMs` | `number` | `86400000` (24 hours) | How long after the current key layout first reached the store to wait before that purge. |
| `encryption.customKey` | `string` | | The secret keys are derived from, in place of `REMEMBER_SECRET`: give every instance that shares a store the same one. Each scope still gets its own key. Values stored under the previous secret read as missing once it's set. See [Encryption](#encryption). |

> **Note**
Changed in 1.8.7: `tools.enabled`, `tools.prefix` and `tools.allowedScopes` work. Before, the plugin registered no tools, and a `tools` option passed to `init()` stopped the server at startup with `TypeError: list is not iterable`. The way round it was `useFactory`, with the app listing `RememberThisTool` and the others in its own `tools`. That still works.

Changed in 1.9: `tools.enabled` returned by `init({ inject, useFactory })` registers the tools too; before, it registered none. And `defaultTTL` applies in every store: before, only the Vercel KV store read it.

#### Stores

| `type` | Where values live |
| --- | --- |
| `"memory"` | A `Map` in the server's process, one for each server: lost on restart, and not shared between instances, or between two servers started in one process. |
| `"redis"` | A Redis server the plugin connects to with `config`. |
| `"redis-client"` | Your own `ioredis` client, in `client`. The plugin doesn't close it. |
| `"vercel-kv"` | Vercel KV, through the `@vercel/kv` package, which you install. |
| `"global-store"` | The store in `@FrontMcp({ redis })`: Redis, or Vercel KV with `provider: "vercel-kv"`. Without `redis`, the server doesn't start. |

Only `"memory"` runs in the Playground, which has no network. On Redis, checked outside the Playground with FrontMCP 1.9.2 and Redis 7, values read back as stored, the store expires each one at its `ttl`, or `defaultTTL`, and keys start with `keyPrefix` once: `remember:v2:user:nour:language`. Before 1.9.2, the Redis and Vercel KV stores added `keyPrefix` a second time, as in `remember:remember:v2:user:nour:language`. An entry stored that way is still read, in place and with its expiry, and stays under that key until it expires.

### `this.remember`

`this.remember` is a `RememberAccessor` for the current call, on tools, resources, prompts and agents. Every method takes a `scope`, which defaults to `"session"`:

| Method | Returns | Description |
| --- | --- | --- |
| `set(key, value, options?)` | `Promise<RememberEntry>` | Stores `value`, which must survive `JSON.stringify`, replacing any value under `key`. `options`: `scope`, `ttl` (seconds), `brand` (a label: `"preference"`, `"state"`, `"conversation"`, `"cache"`, `"approval"` or `"custom"`) and `metadata` (any object). |
| `get(key, options?)` | `Promise<T \| undefined>` | The value, or `options.defaultValue` when there's none, or it expired. |
| `getEntry(key, options?)` | `Promise<RememberEntry \| undefined>` | The value with what was stored around it: `{ value, brand?, metadata?, createdAt, updatedAt, expiresAt? }`, times in milliseconds. |
| `update(key, value, options?)` | `Promise<boolean>` | Replaces the value, keeping `brand`, `metadata` and `createdAt`, and the expiry unless you pass `ttl`. `false` when there's no value to update, or it has expired. |
| `knows(key, options?)` | `Promise<boolean>` | Whether `get()` would find a value under `key`: there is one, it hasn't expired, and it can be decrypted. |
| `forget(key, options?)` | `Promise<void>` | Deletes the value. |
| `list(options?)` | `Promise<string[]>` | The keys in a scope, without their prefix. `options.pattern` filters them, with `*` and `?`. |
| `sessionId` | `string` | The session id the request carries. The `session` and `tool` scopes don't use it: they use the session the server verified, or the signed-in caller (see [Scopes](#scopes)). |
| `userId` | `string \| undefined` | The caller the `user` scope uses: the token's `sub`, `static:…` for a static key, or `anon:…` for an anonymous caller. |

The same accessor is available as `getRemember(this)`, which throws when it isn't there, `tryGetRemember(this)`, which returns `undefined` instead, and `this.get(RememberAccessorToken)`.

`this.remember` needs the plugin on the tool's app or the server. Its options are optional: `RememberPlugin.init()` and `plugins: [RememberPlugin]`, the class without `init()`, both keep values in memory (`type: "memory"`), with the same scopes and the same refusals for an anonymous caller. Changed in 1.9.4: the class registered no accessor, and `this.remember` failed with `RememberPlugin is not installed. Add RememberPlugin.init() to your plugins array.`

### Scopes

A scope decides which callers share a value. The table shows what each keeps for a client using MCP 2026-07-28, which sends every request on its own, and for a client using an earlier version, which opens a session:

| Scope | Keyed by | 2026-07-28 client | Session-based client |
| --- | --- | --- | --- |
| `session` (default) | The session the server verified for the request, else the signed-in caller | Kept for a signed-in user or a static key, across requests, apart from their `user` values. An anonymous caller is refused with [`RememberIdentityError`](#rememberidentityerror) | Kept for the session |
| `user` | `userId` | Kept for a signed-in user or a static key. An anonymous caller, a new `anon:` user on every request, is refused with [`RememberIdentityError`](#rememberidentityerror) | Kept across sessions for a signed-in user or a static key. An anonymous caller is refused, whatever its session |
| `tool` | The same as `session`, and the name of the tool that's running | Kept for a signed-in caller, and only for that tool. An anonymous caller is refused | Kept for the session, and only for that tool |
| `global` | Nothing | Kept, and shared by every caller | Kept, and shared by every caller |

The 2026-07-28 column is what this page's Playgrounds show. The session-based column was checked on FrontMCP 1.9.2's Node HTTP server; the Playground can only send 2026-07-28 requests. [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) give their caller one session for the life of the server, so `session` values last as long as it does.

A session counts only when the request presents it and the server verified it: the `mcp-session-id` a session-based client got from `initialize`, or the `sessionId` a legacy SSE client posts with. A 2026-07-28 request never has one, and an `mcp-session-id` a 2026-07-28 client sends doesn't count, so a caller can't reach another caller's `session` or `tool` values by sending their id. A request without a verified session uses the signed-in caller instead, as `stateless-user:<userId>`, and an anonymous one is refused with [`RememberIdentityError`](#rememberidentityerror).

> **Pitfall: Under 2026-07-28, session means the user**
MCP 2026-07-28 has no sessions, so `session` and `tool` values belong to the signed-in caller: they last across requests, and across every conversation and client of that user, until they're forgotten or expire. They don't end with a conversation. An anonymous caller can't use them at all, nor `user` scope: `this.remember` throws `RememberIdentityError`, and so do the [memory tools](#the-memory-tools), which default to `session`. Give conversation-sized state a `ttl`, store per-user values with `scope: "user"` and sign-in, and shared values with `scope: "global"`.

> **Note**
Changed in 1.8.4: before, FrontMCP counted every 2026-07-28 request as a new verified session, so `session` and `tool` values were lost after the request, without an error, and `RememberIdentityError` was never thrown. They now follow the signed-in caller, and an anonymous caller is refused.

`tool` scope keeps one tool's values from another's: each value is keyed by the tool that stored it, as `<app id>:<tool name>`, so another tool reading the same key in `tool` scope gets nothing. See [What each scope keeps](#what-each-scope-keeps).

> **Note**
Changed in 1.8.3: `tool` scope is per tool. Before, every tool shared one `tool` namespace, named `unknown`, so `tool` values stored by an earlier version aren't found after the upgrade.

### Encryption

With `encryption.enabled` (the default), each value is stored as `{"alg":"A256GCM","iv":…,"tag":…,"data":…}`: the value and its metadata, encrypted with AES-256-GCM and a new IV every time. The key is derived with HKDF-SHA256 from a server secret and the scope's identity: the verified session, or `stateless-user:<userId>` without one, for `session` and `tool`, `userId` for `user`, and the secret alone for `global`. Anyone who can read the store sees keys and expiry times, not values. See [What's stored](#whats-stored).

The server secret is `encryption.customKey` when you set it, else the first of these that's set:

1. `REMEMBER_SECRET`, `MCP_MEMORY_SECRET` or `MCP_SESSION_SECRET` in the environment.
2. Outside production (`NODE_ENV` isn't `production`): a secret the plugin generates and saves to `.frontmcp/remember-secret.json` in the working directory, readable only by its owner. Keep `.frontmcp/` out of version control.
3. In production: a random secret for this process, with a warning in the log. Values can't be read after a restart, or by another instance.

Set `REMEMBER_SECRET`, or `encryption.customKey`, to the same value on every instance that shares a store. A value encrypted with another secret reads as missing: `get()` returns the default, and `knows()` says `false`. See [What's stored](#whats-stored). Changed in 1.9: `knows()` didn't decrypt, so it said `true`. Changed in 1.9.2: `customKey` was ignored.

With `encryption: { enabled: false }`, values are stored as plain JSON.

### The memory tools

With `tools.enabled`, the plugin registers four tools, under `tools.prefix` if you set one, whether it's in an app's `plugins` or in `@FrontMcp({ plugins })`. You don't list them in `tools`:

| Tool | Arguments | Result |
| --- | --- | --- |
| `remember_this` | `key`, `value` (any JSON), `scope?`, `ttl?` (whole seconds), `brand?` | `{ success, key, scope, expiresAt? }`, with the expiry from `ttl` or `defaultTTL` |
| `recall` | `key`, `scope?` | `{ found, key, scope, value?, createdAt?, expiresAt? }` |
| `forget` | `key`, `scope?` | `{ success, key, scope, existed }` |
| `list_memories` | `scope?`, `pattern?`, `limit?` (up to 100, default 50) | `{ keys, scope, count, truncated }` |

- Without `tools.enabled` there are no memory tools. The package still exports their classes, `RememberThisTool`, `RecallTool`, `ForgetTool` and `ListMemoriesTool`, to list in an app's `tools` yourself. Don't do both: the app would have every tool twice, and FrontMCP renames each pair to `<owner>:<name>`, such as `assistant:recall` and `remember:recall`. A class you list is never prefixed.
- `tools.prefix` changes the registered names, and the descriptions follow: with `prefix: "memory_"`, `memory_recall` says it reads what `memory_remember_this` saved, so the model isn't pointed at a tool that doesn't exist.
- `scope` defaults to `session` in every one of them, which under 2026-07-28 lasts for the signed-in caller and is refused to an anonymous one (see [Scopes](#scopes)).
- A scope outside `tools.allowedScopes`, the default `session` included, fails the call with `Scope 'session' is not allowed. Allowed scopes: user` and the code `REMEMBER_SCOPE_NOT_ALLOWED`. See [`RememberScopeNotAllowedError`](#rememberscopenotallowederror).
- `recall` and `list_memories` are marked `readOnlyHint: true`, `remember_this` and `forget` `readOnlyHint: false`.
- In `tool` scope, each memory tool keeps its own values, so `recall` doesn't find what `remember_this` stored there.
- An anonymous caller is refused in `user` scope, and in `session` and `tool` scope: `list_memories` answers with an error, not an empty list.

All four share one description for `scope`, which says the same:

> Whose memory to use (default: session). session: this session; without one (stateless HTTP, MCP 2026-07-28), the signed-in caller across its requests. user: the signed-in caller, across all of its sessions. tool: the tool running the call, for the same caller as session; each memory tool has its own, so the other memory tools do not see what one of them stores in tool scope. global: shared by every caller. An anonymous caller cannot use user scope, nor session or tool scope without a session.

Each tool's own description points at its siblings by their registered names. With `prefix: "memory_"`, `memory_recall` says:

> Recall something that was previously remembered. Use this to retrieve stored preferences, settings, or any information that was saved with memory_remember_this.

> **Note**
Changed in 1.8.6: `remember_this` described `session` as "until disconnect" and `user` as "forever", and the other three tools said only "default: session". Neither was right under 2026-07-28.

### `RememberScopeNotAllowedError`

Thrown by a memory tool when the call's scope isn't in `tools.allowedScopes`, including the default scope when the caller left `scope` out. It's a `PublicMcpError` with the code `REMEMBER_SCOPE_NOT_ALLOWED`, so the model reads its message in production too, and can call again with an allowed scope:

> Scope 'session' is not allowed. Allowed scopes: user

The class is exported from `@frontmcp/plugin-remember`.

> **Note**
Changed in 1.8.7: the refusal was a plain error, so in production the model read `Internal FrontMCP error. Please contact support with error ID: …` and learned nothing about which scope to use. All four tools refuse with the public error now.

### `RememberIdentityError`

Thrown when a scope has no caller to keep values apart with. It's a `PublicMcpError` with the code `REMEMBER_IDENTITY_REQUIRED`, so a client reads its message, in production too:

- **`user` scope for an anonymous caller**, an `anon:` subject, on any transport: `Remember cannot use user scope without an authenticated user: all unauthenticated callers would share one namespace. Authenticate the request or use the 'global' scope.`
- **`session` or `tool` scope** on a request with no session the server verified and no signed-in caller: `Remember cannot use session or tool scope for an unauthenticated request without a verified session: …`, which ends with what to do: authenticate the request, use a stateful transport, or choose `global` scope if the data really is shared.

Under 2026-07-28, that's every scope but `global` for a caller without credentials.

> **Note**
Changed in 1.8.6: `user` scope used to take an anonymous caller's `anon:` id, which is new on every request. The value was stored where nothing looks again, and `get()` found nothing, without an error; `list_memories` answered `{ keys: [], count: 0 }`. It's refused now, with an error that's a `PublicMcpError`, so the model reads why.

#### Caveats

- **Registered on one app, it serves that app.** Another app's tools have no `this.remember`: it fails with `RememberPlugin is not installed`. To give every app the same memory, register it on `@FrontMcp`. Changed in 1.9: the accessor reached every app's tools, which read and wrote the same values.
- The plugin has no hooks: it only adds `this.remember`. Registering it on `@App` or `@FrontMcp` makes no other difference.
- `set()` with a `ttl`, or with the plugin's `defaultTTL`, expires the value in the store too; without either, a value lives until it's forgotten or the store is cleared. `update()` without `ttl` keeps the expiry, in the store as well, so `knows()` and `list()` stop reporting the key when `get()` stops returning it. Changed in 1.9: it removed the expiry from the store, so they kept reporting the key until a `get()` deleted it.
- `list()` reads every key in the store that matches, so it gets slower as the store grows, and on Redis it runs `SCAN`.

---

## Usage

### Remembering a user's preference

A preference belongs to a user, so it goes in `user` scope, and the server needs to know who's calling. Here `server.ts` uses two [static keys](https://frontmcp.dev/reference/auth/modes); the Playground's own server is public, so the tests start `server.ts` with [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) and call it with each key:

```ts preferences.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

@Tool({ name: "set_language", description: "Set the language the user wants answers in", inputSchema: { language: z.string() } })
export class SetLanguage extends ToolContext {
  async execute({ language }: { language: string }) {
    await this.remember.set("language", language, { scope: "user" });
    return { language };
  }
}

@Tool({ name: "get_language", description: "The language the user wants answers in", inputSchema: {} })
export class GetLanguage extends ToolContext {
  async execute() {
    return { language: await this.remember.get("language", { scope: "user", defaultValue: "en" }) };
  }
}

@App({
  id: "prefs",
  name: "Preferences",
  tools: [SetLanguage, GetLanguage],
  plugins: [RememberPlugin.init({ type: "memory" })],
})
export class PreferencesApp {}
```

```ts server.ts
import { PreferencesApp } from "./preferences.app";

// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [PreferencesApp],
  auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] },
};
```

```ts call.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

/** Starts a server from this config and calls tools with a key, like an MCP client would. */
export async function start(config: object) {
  const server = await FrontMcpInstance.createFetchHandler(config as never);
  return async (tool: string, args: object, key: string) => {
    const response = await server(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
        }),
      }),
    );
    return (await response.json()).result;
  };
}
```

```ts preferences.test.ts
import { test, expect } from "@frontmcp/testing";
import { App } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";
import { start } from "./call";
import { config } from "./server";
import { GetLanguage, SetLanguage } from "./preferences.app";

test("a user's preference is there on their next request", async () => {
  const call = await start(config);
  await call("set_language", { language: "fr" }, "agent-nour-key");
  expect((await call("get_language", {}, "agent-nour-key")).structuredContent).toEqual({ language: "fr" });
});

test("each user has their own", async () => {
  const call = await start(config);
  await call("set_language", { language: "de" }, "agent-nour-key");
  expect((await call("get_language", {}, "agent-sam-key")).structuredContent).toEqual({ language: "en" });
});

test("an anonymous caller is refused", async ({ mcp }) => {
  // The Playground's own server has no authentication
  const result = await mcp.tools.call("set_language", { language: "es" });
  expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED");
  expect(result.text()).toContain("Remember cannot use user scope without an authenticated user");
});

test("init() without an argument keeps values in memory", async () => {
  @App({ id: "prefs", name: "Preferences", tools: [SetLanguage, GetLanguage], plugins: [RememberPlugin.init()] })
  class WithDefaults {}

  const call = await start({ ...config, apps: [WithDefaults] });
  await call("set_language", { language: "fr" }, "agent-nour-key");
  expect((await call("get_language", {}, "agent-nour-key")).structuredContent).toEqual({ language: "fr" });
});

test("the class without init() is the same as `init()`, for anonymous callers too", async () => {
  @App({ id: "prefs", name: "Preferences", tools: [SetLanguage, GetLanguage], plugins: [RememberPlugin] })
  class WithoutInit {}

  const call = await start({ ...config, apps: [WithoutInit] });
  await call("set_language", { language: "fr" }, "agent-nour-key");
  expect((await call("get_language", {}, "agent-nour-key")).structuredContent).toEqual({ language: "fr" });
  expect((await call("get_language", {}, "agent-sam-key")).structuredContent).toEqual({ language: "en" });

  const callPublic = await start({ info: config.info, apps: [WithoutInit] }); // no auth: every caller is anonymous
  const refused = await callPublic("set_language", { language: "es" }, "any-key");
  expect(refused._meta.code).toBe("REMEMBER_IDENTITY_REQUIRED");
});

test("global-store needs redis on the server", async () => {
  @App({ id: "prefs", name: "Preferences", tools: [GetLanguage], plugins: [RememberPlugin.init({ type: "global-store" })] })
  class WithGlobalStore {}

  const call = start({ ...config, apps: [WithGlobalStore] }).then((call) => call("get_language", {}, "agent-nour-key"));
  await expect(call).rejects.toThrow('Plugin "RememberPlugin" requires global "redis" configuration.');
});
```

The Playground calls `set_language` as an anonymous client, which `user` scope refuses with `REMEMBER_IDENTITY_REQUIRED` ([`RememberIdentityError`](#rememberidentityerror)). Users who sign in with `local`, `remote` or `transparent` auth work the same way: `userId` is their token's `sub`. [Keeping users apart with transparent auth](#keeping-users-apart-with-transparent-auth) runs it with two signed-in users.

### What each scope keeps

`save` and `load` store and read a value in the scope you pass. The tests store a value, then read it on a later request, as a signed-in caller, as another, and as an anonymous one, all over MCP 2026-07-28:

```ts notes.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

const scope = z.enum(["session", "user", "tool", "global"]);

@Tool({ name: "save", description: "Save a value", inputSchema: { key: z.string(), value: z.string(), scope } })
export class Save extends ToolContext {
  async execute({ key, value, scope }: { key: string; value: string; scope: z.infer<typeof scope> }) {
    await this.remember.set(key, value, { scope });
    return { saved: key };
  }
}

@Tool({ name: "load", description: "Load a value", inputSchema: { key: z.string(), scope } })
export class Load extends ToolContext {
  async execute({ key, scope }: { key: string; scope: z.infer<typeof scope> }) {
    return { value: (await this.remember.get<string>(key, { scope })) ?? null };
  }
}

@App({ id: "notes", name: "Notes", tools: [Save, Load], plugins: [RememberPlugin.init({ type: "memory" })] })
export class NotesApp {}
```

```ts server.ts
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "static" as const, tokens: ["nour-key", "sam-key"] },
};
```

```ts call.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

/** Starts a server from this config and calls tools with a key, like an MCP client would, optionally sending an mcp-session-id. */
export async function start(config: object) {
  const server = await FrontMcpInstance.createFetchHandler(config as never);
  return async (tool: string, args: object, key: string, sessionId?: string) => {
    const response = await server(
      new Request("https://notes.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
          ...(sessionId ? { "mcp-session-id": sessionId } : {}),
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
        }),
      }),
    );
    return (await response.json()).result.structuredContent;
  };
}
```

```ts scopes.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";
import { start } from "./call";
import { config } from "./server";
import { Load, NotesApp, Save } from "./notes.app";

test("user: kept for the same user, and only for them", async () => {
  const call = await start(config);
  await call("save", { key: "u1", value: "Nour's", scope: "user" }, "nour-key");
  expect(await call("load", { key: "u1", scope: "user" }, "nour-key")).toEqual({ value: "Nour's" });
  expect(await call("load", { key: "u1", scope: "user" }, "sam-key")).toEqual({ value: null });
});

test("global: kept, and shared by every signed-in caller", async () => {
  const call = await start(config);
  await call("save", { key: "g1", value: "Everyone's", scope: "global" }, "nour-key");
  expect(await call("load", { key: "g1", scope: "global" }, "sam-key")).toEqual({ value: "Everyone's" });
});

test("global: an anonymous caller can keep a value too", async ({ mcp }) => {
  await mcp.tools.call("save", { key: "g3", value: "Anyone's", scope: "global" });
  expect((await mcp.tools.call("load", { key: "g3", scope: "global" })).json()).toEqual({ value: "Anyone's" });
});

test("memory: each server has its own store", async () => {
  const first = await start(config);
  const second = await start(config);
  await first("save", { key: "g4", value: "The first server's", scope: "global" }, "nour-key");
  expect(await second("load", { key: "g4", scope: "global" }, "nour-key")).toEqual({ value: null });
});

test("session: kept for the signed-in caller across requests, and only for them", async () => {
  const call = await start(config);
  await call("save", { key: "s1", value: "Nour's draft", scope: "session" }, "nour-key");
  expect(await call("load", { key: "s1", scope: "session" }, "nour-key")).toEqual({ value: "Nour's draft" });
  expect(await call("load", { key: "s1", scope: "session" }, "sam-key")).toEqual({ value: null });
  expect(await call("load", { key: "s1", scope: "user" }, "nour-key")).toEqual({ value: null }); // apart from user values
});

test("session: an mcp-session-id the client sends doesn't share values", async () => {
  const call = await start(config);
  await call("save", { key: "s2", value: "Nour's", scope: "session" }, "nour-key", "chosen-by-the-client");
  expect(await call("load", { key: "s2", scope: "session" }, "sam-key", "chosen-by-the-client")).toEqual({ value: null });
  expect(await call("load", { key: "s2", scope: "session" }, "nour-key")).toEqual({ value: "Nour's" }); // hers, with or without the header
});

test("session and tool: an anonymous caller is refused", async ({ mcp }) => {
  for (const scope of ["session", "tool"]) {
    const result = await mcp.tools.call("save", { key: "a0", value: "Mine", scope });
    expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED");
    expect(result.text()).toContain("Remember cannot use session or tool scope for an unauthenticated request without a verified session");
  }
});

test("user: an anonymous caller is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("save", { key: "a1", value: "Mine", scope: "user" });
  expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED");
  expect(result.text()).toContain("Remember cannot use user scope without an authenticated user");
});

test("tool: each tool has its own values, for each caller", async () => {
  @Tool({ name: "count_calls", description: "Count this tool's calls", inputSchema: {} })
  class CountCalls extends ToolContext {
    async execute() {
      const calls = ((await this.remember.get<number>("calls", { scope: "tool" })) ?? 0) + 1;
      await this.remember.set("calls", calls, { scope: "tool" });
      return { calls };
    }
  }
  @App({ id: "notes", name: "Notes", tools: [Save, Load, CountCalls], plugins: [RememberPlugin.init({ type: "memory" })] })
  class WithCounter {}

  const call = await start({ ...config, apps: [WithCounter] });
  await call("count_calls", {}, "nour-key");
  expect(await call("count_calls", {}, "nour-key")).toEqual({ calls: 2 });
  expect(await call("count_calls", {}, "sam-key")).toEqual({ calls: 1 });

  await call("save", { key: "t1", value: "From save", scope: "session" }, "nour-key");
  await call("save", { key: "t1", value: "From save", scope: "tool" }, "nour-key");
  expect(await call("load", { key: "t1", scope: "session" }, "nour-key")).toEqual({ value: "From save" });
  expect(await call("load", { key: "t1", scope: "tool" }, "nour-key")).toEqual({ value: null });
});

test("createDirect() keeps one session, which session values last for", async () => {
  const server = await FrontMcpInstance.createDirect({ ...config, apps: [NotesApp] } as never);
  await server.callTool("save", { key: "d1", value: "Kept", scope: "session" });
  expect((await server.callTool("load", { key: "d1", scope: "session" })).structuredContent).toEqual({ value: "Kept" });
  await server.dispose();
});

test("another app's tools can't use the memory without registering the plugin", async () => {
  @Tool({ name: "billing_note", description: "Read the shared note from billing", inputSchema: {} })
  class BillingNote extends ToolContext {
    async execute() {
      return { value: (await this.remember.get<string>("g2", { scope: "global" })) ?? null };
    }
  }
  @App({ id: "billing", name: "Billing", tools: [BillingNote] }) // no RememberPlugin here
  class BillingApp {}

  const server = await FrontMcpInstance.createDirect({ ...config, apps: [NotesApp, BillingApp] } as never);
  await server.callTool("save", { key: "g2", value: "Shared", scope: "global" });
  await expect(server.callTool("billing_note", {})).rejects.toThrow("RememberPlugin is not installed. Add RememberPlugin.init() to your plugins array.");
  await server.dispose();
});
```

`count_calls` reads back its own `tool` value, for each caller, while `load` doesn't find the one `save` stored, although it finds `save`'s `session` value. The anonymous Playground client can use `global` and nothing else, and a second server started in the same process has a store of its own. The last two tests use [`createDirect()`](https://frontmcp.dev/reference/sdk/create), whose caller has one session for the life of the server, and a tool in an app without the plugin has no `this.remember`. On a session-based client (before MCP 2026-07-28), `session` and `tool` values last for the session.

### Working with entries

Beyond `set` and `get`, an entry can expire, carry a `brand` and `metadata`, and be updated in place. This tool runs through the accessor's methods:

```ts drafts.app.ts active
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { RememberPlugin, tryGetRemember } from "@frontmcp/plugin-remember";

@Tool({ name: "draft_tour", description: "Store and read back a reply draft", inputSchema: {} })
export class DraftTour extends ToolContext {
  async execute() {
    const memory = this.remember;
    const scope = "global" as const; // the Playground's callers are anonymous; use "user" with sign-in

    await memory.set("draft:T-1", "Thanks for the report.", { scope, ttl: 3600, brand: "state", metadata: { ticket: "T-1" } });
    await memory.set("draft:T-2", "We're on it.", { scope });
    await memory.set("signature", "The Help Desk", { scope });

    const updated = await memory.update("draft:T-1", "Thanks, fixed in 2.1.", { scope });
    const entry = await memory.getEntry<string>("draft:T-1", { scope });
    return {
      updated,
      updatedMissing: await memory.update("draft:T-9", "?", { scope }),
      value: entry?.value,
      brand: entry?.brand,
      metadata: entry?.metadata,
      expiresInMinutes: entry?.expiresAt ? Math.round((entry.expiresAt - Date.now()) / 60_000) : null,
      drafts: (await memory.list({ scope, pattern: "draft:*" })).sort(),
      knowsT2: await memory.knows("draft:T-2", { scope }),
      missing: await memory.get("draft:T-9", { scope, defaultValue: "(none)" }),
      hasAccessor: tryGetRemember(this) !== undefined,
    };
  }
}

@Tool({ name: "forget_draft", description: "Forget a reply draft", inputSchema: {} })
export class ForgetDraft extends ToolContext {
  async execute() {
    await this.remember.forget("draft:T-2", { scope: "global" });
    return { knowsT2: await this.remember.knows("draft:T-2", { scope: "global" }) };
  }
}

@App({ id: "drafts", name: "Drafts", tools: [DraftTour, ForgetDraft], plugins: [RememberPlugin.init({ type: "memory" })] })
export class DraftsApp {}
```

```ts entries.test.ts
import { test, expect } from "@frontmcp/testing";

test("update() keeps the brand, metadata and expiry", async ({ mcp }) => {
  const tour = (await mcp.tools.call("draft_tour", {})).json();
  expect(tour).toMatchObject({
    updated: true,
    updatedMissing: false,
    value: "Thanks, fixed in 2.1.",
    brand: "state",
    metadata: { ticket: "T-1" },
    expiresInMinutes: 60,
  });
});

test("list() filters keys with a pattern, and get() falls back to defaultValue", async ({ mcp }) => {
  const tour = (await mcp.tools.call("draft_tour", {})).json();
  expect(tour.drafts).toEqual(["draft:T-1", "draft:T-2"]);
  expect(tour.missing).toBe("(none)");
  expect(tour.hasAccessor).toBe(true);
});

test("forget() deletes the value", async ({ mcp }) => {
  await mcp.tools.call("draft_tour", {});
  expect((await mcp.tools.call("forget_draft", {})).json()).toEqual({ knowsT2: false });
});
```

A value with a `ttl` is gone once it expires: `get()` gives the default, `knows()` is `false`, and `list()` leaves it out. An `update()` without `ttl` keeps the expiry, and fails once it has passed. A value set without `ttl` lives for the plugin's `defaultTTL`, when it has one. The tests move the clock forward by replacing `Date.now()`, which is what the plugin and the memory store read:

```ts otp.app.ts active
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

@Tool({ name: "start_otp", description: "Send a one-time code, valid for 5 minutes", inputSchema: {} })
export class StartOtp extends ToolContext {
  async execute() {
    await this.remember.set("otp", "482913", { scope: "global", ttl: 300 });
    return { sent: true };
  }
}

@Tool({ name: "resend_otp", description: "Send a new one-time code, keeping the expiry", inputSchema: {} })
export class ResendOtp extends ToolContext {
  async execute() {
    return { resent: await this.remember.update("otp", "551204", { scope: "global" }) };
  }
}

@Tool({ name: "check_otp", description: "What's left of the one-time code", inputSchema: {} })
export class CheckOtp extends ToolContext {
  async execute() {
    const scope = "global" as const;
    const knows = await this.remember.knows("otp", { scope });
    const listed = (await this.remember.list({ scope })).includes("otp");
    return { knows, listed, code: await this.remember.get("otp", { scope, defaultValue: null }) };
  }
}

@App({
  id: "otp",
  name: "OTP",
  tools: [StartOtp, ResendOtp, CheckOtp],
  plugins: [RememberPlugin.init({ type: "memory" })],
})
export class OtpApp {}
```

```ts ttl.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

/** Calls a tool with the clock `seconds` past `start`. */
async function callAt(mcp: any, start: number, seconds: number, tool: string) {
  const realNow = Date.now;
  Date.now = () => start + seconds * 1000;
  try {
    return (await mcp.tools.call(tool, {})).json();
  } finally {
    Date.now = realNow;
  }
}

test("a value is there until its ttl, and gone after", async ({ mcp }) => {
  const start = Date.now();
  await callAt(mcp, start, 0, "start_otp");
  expect(await callAt(mcp, start, 299, "check_otp")).toEqual({ knows: true, listed: true, code: "482913" });
  expect(await callAt(mcp, start, 301, "check_otp")).toEqual({ knows: false, listed: false, code: null });
});

test("update() without ttl keeps the expiry", async ({ mcp }) => {
  const start = Date.now();
  await callAt(mcp, start, 0, "start_otp");
  expect(await callAt(mcp, start, 10, "resend_otp")).toEqual({ resent: true });
  expect(await callAt(mcp, start, 299, "check_otp")).toEqual({ knows: true, listed: true, code: "551204" });
  expect(await callAt(mcp, start, 301, "check_otp")).toEqual({ knows: false, listed: false, code: null });
  expect(await callAt(mcp, start, 302, "resend_otp")).toEqual({ resent: false });
});

test("defaultTTL expires a value set without ttl", async () => {
  @Tool({ name: "save_draft", description: "Save a reply draft", inputSchema: {} })
  class SaveDraft extends ToolContext {
    async execute() {
      const entry = await this.remember.set("draft", "Thanks for writing in.", { scope: "global" });
      return { expiresInSeconds: entry.expiresAt ? Math.round((entry.expiresAt - Date.now()) / 1000) : null };
    }
  }
  @Tool({ name: "read_draft", description: "Read the reply draft", inputSchema: {} })
  class ReadDraft extends ToolContext {
    async execute() {
      return { draft: await this.remember.get("draft", { scope: "global", defaultValue: null }) };
    }
  }
  @App({ id: "drafts", name: "Drafts", tools: [SaveDraft, ReadDraft], plugins: [RememberPlugin.init({ type: "memory", defaultTTL: 600 })] })
  class Drafts {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "drafts", version: "1.0.0" }, apps: [Drafts] });
  const read = async (seconds: number) => {
    const realNow = Date.now;
    const start = realNow();
    Date.now = () => start + seconds * 1000;
    try {
      return (await server.callTool("read_draft", {})).structuredContent;
    } finally {
      Date.now = realNow;
    }
  };
  expect((await server.callTool("save_draft", {})).structuredContent).toEqual({ expiresInSeconds: 600 });
  expect(await read(599)).toEqual({ draft: "Thanks for writing in." });
  expect(await read(601)).toEqual({ draft: null });
  await server.dispose();
});

test("defaultTTL must be a whole number of seconds", () => {
  expect(() => RememberPlugin.init({ type: "memory", defaultTTL: 1.5 })).toThrow(
    "RememberPlugin defaultTTL must be a whole number of seconds (0 for no default expiry), got 1.5",
  );
});
```

### Letting the model remember things

Set `tools.enabled` and the plugin registers the memory tools. Restrict them to the scopes you mean with `tools.allowedScopes`. The app lists no tools of its own here:

```ts assistant.app.ts active
import { App } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

@App({
  id: "assistant",
  name: "Assistant",
  plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true, allowedScopes: ["user"] } })],
})
export class AssistantApp {}
```

```ts server.ts
import { AssistantApp } from "./assistant.app";

export const config = {
  info: { name: "assistant", version: "1.0.0" },
  apps: [AssistantApp],
  auth: { mode: "static" as const, tokens: ["nour-key", "sam-key"] },
};
```

```ts call.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

/** Starts a server from this config and calls tools with a key, like an MCP client would. */
export async function start(config: object) {
  const server = await FrontMcpInstance.createFetchHandler(config as never);
  return async (tool: string, args: object, key: string) => {
    const response = await server(
      new Request("https://assistant.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${key}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
        }),
      }),
    );
    return (await response.json()).result;
  };
}
```

```ts tools.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import {
  ForgetTool,
  ListMemoriesTool,
  RecallTool,
  RememberPlugin,
  RememberScopeNotAllowedError,
  RememberThisTool,
  type RememberPluginOptions,
} from "@frontmcp/plugin-remember";
import { start } from "./call";
import { config } from "./server";

const info = { name: "assistant", version: "1.0.0" };

test("the plugin registers the four tools, and the app lists none", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools.map((t: { name: string }) => t.name).sort()).toEqual(["forget", "list_memories", "recall", "remember_this"]);
  const byName = Object.fromEntries(tools.map((t: any) => [t.name, t]));
  expect(byName.recall.annotations).toEqual({ readOnlyHint: true });
  expect(byName.remember_this.annotations).toEqual({ readOnlyHint: false });
  for (const tool of tools) {
    expect(tool.inputSchema.properties.scope.description).toContain("An anonymous caller cannot use user scope, nor session or tool scope without a session.");
  }
});

test("an anonymous caller is refused in user scope, and list_memories is an error, not an empty list", async ({ mcp }) => {
  const stored = await mcp.tools.call("remember_this", { key: "timezone", value: "Europe/Paris", scope: "user" });
  expect(stored).toBeError("REMEMBER_IDENTITY_REQUIRED");
  const listed = await mcp.tools.call("list_memories", { scope: "user" });
  expect(listed).toBeError("REMEMBER_IDENTITY_REQUIRED");
  expect(listed.text()).toContain("Remember cannot use user scope without an authenticated user");
});

test("remember, recall, list and forget", async () => {
  const call = await start(config);
  const saved = await call("remember_this", { key: "timezone", value: "Europe/Paris", scope: "user" }, "nour-key");
  expect(saved.structuredContent).toEqual({ success: true, key: "timezone", scope: "user" });

  const recalled = await call("recall", { key: "timezone", scope: "user" }, "nour-key");
  expect(recalled.structuredContent).toMatchObject({ found: true, value: "Europe/Paris", scope: "user" });
  expect((await call("recall", { key: "timezone", scope: "user" }, "sam-key")).structuredContent.found).toBe(false);

  const listed = await call("list_memories", { scope: "user" }, "nour-key");
  expect(listed.structuredContent).toEqual({ keys: ["timezone"], scope: "user", count: 1, truncated: false });

  expect((await call("forget", { key: "timezone", scope: "user" }, "nour-key")).structuredContent.existed).toBe(true);
  expect((await call("forget", { key: "timezone", scope: "user" }, "nour-key")).structuredContent.existed).toBe(false);
});

test("a scope outside allowedScopes is refused with a public error, in all four tools, the default included", async () => {
  const call = await start(config);
  const attempts: Array<[string, object]> = [
    ["remember_this", { key: "timezone", value: "Europe/Paris" }],
    ["recall", { key: "timezone" }],
    ["forget", { key: "timezone" }],
    ["list_memories", {}],
    ["remember_this", { key: "timezone", value: "Europe/Paris", scope: "global" }],
  ];
  for (const [tool, args] of attempts) {
    const refused = await call(tool, args, "nour-key");
    expect(refused.isError).toBe(true);
    expect(refused._meta.code).toBe("REMEMBER_SCOPE_NOT_ALLOWED");
    expect(refused.content[0].text).toMatch(/^Scope '(session|global)' is not allowed\. Allowed scopes: user$/);
  }
});

test("RememberScopeNotAllowedError is exported, and its message names the allowed scopes", () => {
  const error = new RememberScopeNotAllowedError("session", ["user", "global"]);
  expect(error).toMatchObject({ code: "REMEMBER_SCOPE_NOT_ALLOWED", message: "Scope 'session' is not allowed. Allowed scopes: user, global" });
});

test("without allowedScopes, the model's default is session", async () => {
  @App({ id: "assistant", name: "Assistant", plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true } })] })
  class Unrestricted {}

  const call = await start({ ...config, apps: [Unrestricted] });
  const saved = await call("remember_this", { key: "timezone", value: "Europe/Paris" }, "nour-key");
  expect(saved.structuredContent).toEqual({ success: true, key: "timezone", scope: "session" });
  // Under 2026-07-28, session is the signed-in caller's
  expect((await call("recall", { key: "timezone" }, "nour-key")).structuredContent.found).toBe(true);
  expect((await call("recall", { key: "timezone", scope: "user" }, "nour-key")).structuredContent.found).toBe(false);
});

test("tools.prefix renames the tools, and their descriptions name each other as registered", async () => {
  @App({ id: "assistant", name: "Assistant", plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true, prefix: "memory_" } })] })
  class Prefixed {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [Prefixed] });
  const { tools } = await server.listTools();
  expect(tools.map((t) => t.name).sort()).toEqual(["memory_forget", "memory_list_memories", "memory_recall", "memory_remember_this"]);
  const recall = tools.find((t) => t.name === "memory_recall");
  expect(recall?.description).toBe(
    "Recall something that was previously remembered. Use this to retrieve stored preferences, settings, or any information that was saved with memory_remember_this.",
  );
  await server.dispose();
});

test("without tools.enabled, the plugin registers no tools", async () => {
  @App({ id: "assistant", name: "Assistant", plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: false, allowedScopes: ["user"] } })] })
  class Off {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [Off] });
  expect((await server.listTools()).tools).toEqual([]);
  await server.dispose();
});

test("listing the tool classes as well registers every tool twice", async () => {
  @App({
    id: "assistant",
    name: "Assistant",
    tools: [RememberThisTool, RecallTool, ForgetTool, ListMemoriesTool],
    plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true } })],
  })
  class Twice {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [Twice] });
  const names = (await server.listTools()).tools.map((t) => t.name).sort();
  expect(names).toEqual([
    "assistant:forget", "assistant:list_memories", "assistant:recall", "assistant:remember_this",
    "remember:forget", "remember:list_memories", "remember:recall", "remember:remember_this",
  ]);
  await server.dispose();
});

test("the classes still work on their own, with options from useFactory", async () => {
  @App({
    id: "assistant",
    name: "Assistant",
    tools: [RememberThisTool, RecallTool],
    plugins: [
      RememberPlugin.init({
        inject: () => [] as const,
        useFactory: (): RememberPluginOptions => ({ type: "memory", tools: { allowedScopes: ["user"] } }),
      }),
    ],
  })
  class Classic {}

  const call = await start({ ...config, apps: [Classic] });
  expect((await call("remember_this", { key: "timezone", value: "Europe/Paris", scope: "user" }, "nour-key")).structuredContent.success).toBe(true);
  expect((await call("remember_this", { key: "timezone", value: "Europe/Paris" }, "nour-key"))._meta.code).toBe("REMEMBER_SCOPE_NOT_ALLOWED");
});

test("tools.enabled from a useFactory, async here, registers the tools too", async () => {
  @App({
    id: "assistant",
    name: "Assistant",
    plugins: [
      RememberPlugin.init({
        inject: () => [] as const,
        useFactory: async (): Promise<RememberPluginOptions> => ({ type: "memory", tools: { enabled: true } }),
      }),
    ],
  })
  class FromFactory {}

  const server = await FrontMcpInstance.createDirect({ info, apps: [FromFactory] });
  expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["forget", "list_memories", "recall", "remember_this"]);
  await server.dispose();
});

test("🚩 in tool scope, recall doesn't find what remember_this stored", async () => {
  @App({ id: "assistant", name: "Assistant", plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true } })] })
  class Unrestricted {}

  // createDirect() keeps one session, so session and tool values last
  const server = await FrontMcpInstance.createDirect({ info, apps: [Unrestricted] });
  await server.callTool("remember_this", { key: "draft", value: "Hello", scope: "tool" });
  expect((await server.callTool("recall", { key: "draft", scope: "tool" })).structuredContent).toMatchObject({ found: false });
  await server.callTool("remember_this", { key: "draft", value: "Hello", scope: "session" });
  expect((await server.callTool("recall", { key: "draft", scope: "session" })).structuredContent).toMatchObject({ found: true, value: "Hello" });
  await server.dispose();
});
```

The Playground calls as an anonymous client, so `user` scope refuses it, with `REMEMBER_IDENTITY_REQUIRED`, and `list_memories` is an error too. A call with a scope outside `allowedScopes` is a tool error that names the allowed scopes, `REMEMBER_SCOPE_NOT_ALLOWED`, so the model can call again with `scope: "user"`, in production too. Without `allowedScopes`, the same call succeeds and stores the value in `session` scope, which lasts for the caller under 2026-07-28 but only for the session on a session-based client, apart from their `user` values, and is refused to an anonymous caller. Leave `tool` out of `allowedScopes` as well: each memory tool has its own `tool` values, so `recall` never finds what `remember_this` put there.

`tools` works the same whether you pass it to `init()` or return it from `init({ inject, useFactory })`, whose factory may be `async`, as the `useFactory` tests show. Changed in 1.9: a `useFactory` that returned `tools.enabled` registered no tools, and said nothing.

### Keeping users apart with transparent auth

With `transparent` [auth](https://frontmcp.dev/reference/auth/modes), your identity provider signs the tokens, and `userId` is the token's `sub`. `this.remember` is built for each request, so every caller reads and writes their own `user` values, on one server:

```ts preferences.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";

@Tool({ name: "set_language", description: "Set the language the user wants answers in", inputSchema: { language: z.string() } })
export class SetLanguage extends ToolContext {
  async execute({ language }: { language: string }) {
    await this.remember.set("language", language, { scope: "user" });
    return { language };
  }
}

@Tool({ name: "get_language", description: "The language the user wants answers in", inputSchema: {} })
export class GetLanguage extends ToolContext {
  async execute() {
    const memory = this.remember;
    return { user: memory.userId, language: await memory.get("language", { scope: "user", defaultValue: "en" }) };
  }
}

@App({
  id: "prefs",
  name: "Preferences",
  tools: [SetLanguage, GetLanguage],
  plugins: [RememberPlugin.init({ type: "memory" })],
})
export class PreferencesApp {}
```

```ts server.ts
import { PreferencesApp } from "./preferences.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [PreferencesApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in server.ts.
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts transparent.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";
import { NOUR, SAM } from "./tokens";

test("each user reads their own preference, on one server", async () => {
  const server = await FrontMcpInstance.createFetchHandler(config);
  const call = async (token: string, tool: string, args: object = {}) => {
    const response = await server(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": "tools/call",
          "mcp-name": tool,
          authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/call",
          params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
        }),
      }),
    );
    return (await response.json()).result.structuredContent;
  };

  await call(NOUR, "set_language", { language: "fr" });
  expect(await call(SAM, "get_language")).toEqual({ user: "sam", language: "en" });
  expect(await call(NOUR, "get_language")).toEqual({ user: "nour", language: "fr" });

  await call(SAM, "set_language", { language: "de" });
  expect(await call(NOUR, "get_language")).toEqual({ user: "nour", language: "fr" });
  expect(await call(SAM, "get_language")).toEqual({ user: "sam", language: "de" });
});
```

The test uses one server for both users, as a deployed server would.

> **Note**
Changed in 1.8.3: before, FrontMCP built `this.remember` once for every caller with a `transparent` token, so the `user` scope read and wrote the first caller's values. Code that built its own `RememberAccessor` on each call to avoid that can go back to `this.remember`.

### What's stored

`raw_store` reads the plugin's store directly, through `RememberStoreToken`, to show what someone with access to your Redis would see:

```ts vault.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { RememberPlugin, RememberStoreToken } from "@frontmcp/plugin-remember";

const scope = z.enum(["session", "user", "global"]);

@Tool({ name: "save_note", description: "Save a note", inputSchema: { note: z.string(), scope } })
export class SaveNote extends ToolContext {
  async execute({ note, scope }: { note: string; scope: z.infer<typeof scope> }) {
    await this.remember.set("note", note, { scope });
    return { saved: true };
  }
}

@Tool({ name: "read_note", description: "Read the shared note", inputSchema: {} })
export class ReadNote extends ToolContext {
  async execute() {
    return {
      note: await this.remember.get("note", { scope: "global", defaultValue: null }),
      knows: await this.remember.knows("note", { scope: "global" }),
    };
  }
}

@Tool({ name: "raw_store", description: "The store's raw contents", inputSchema: {} })
export class RawStore extends ToolContext {
  async execute() {
    const store = this.get(RememberStoreToken);
    const raw: Record<string, unknown> = {};
    for (const key of await store.keys("*")) raw[key] = JSON.parse(String(await store.getValue(key)));
    return raw;
  }
}

@App({
  id: "vault",
  name: "Vault",
  tools: [SaveNote, ReadNote, RawStore],
  plugins: [RememberPlugin.init({ type: "memory" })],
})
export class VaultApp {}
```

```ts stored.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { RememberPlugin } from "@frontmcp/plugin-remember";
import { RawStore, ReadNote, SaveNote, VaultApp } from "./vault.app";

const blob = { alg: "A256GCM", iv: expect.any(String), tag: expect.any(String), data: expect.any(String) };

/** Starts the vault with one static key and returns a function that calls its tools with it. */
async function signedIn(key: string) {
  const handler = await FrontMcpInstance.createFetchHandler({
    info: { name: "vault", version: "1.0.0" },
    apps: [VaultApp],
    auth: { mode: "static", tokens: [key] },
  });
  return async (name: string, args: object) => {
    const response = await handler(
      new Request("https://vault.example.com/", {
        method: "POST",
        headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, authorization: `Bearer ${key}` },
        body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
      }),
    );
    return (await response.json()).result.structuredContent;
  };
}

test("values are encrypted", async ({ mcp }) => {
  await mcp.tools.call("save_note", { note: "Door code 4471", scope: "global" });
  const raw = (await mcp.tools.call("raw_store", {})).json();
  expect(raw["remember:global:note"]).toEqual(blob);
  expect(JSON.stringify(raw)).not.toContain("4471");
});

test("keys show the scope and who stored the value", async () => {
  const call = await signedIn("nour-key");
  await call("save_note", { note: "Mine", scope: "user" });
  expect(Object.keys(await call("raw_store", {}))).toContainEqual(expect.stringMatching(/^remember:v2:user:static%3A[0-9a-f]+:note$/));
});

test("a value stored under another secret reads as missing", async ({ mcp }) => {
  const before = process.env.REMEMBER_SECRET;
  try {
    process.env.REMEMBER_SECRET = "instance-a-secret";
    await mcp.tools.call("save_note", { note: "From instance A", scope: "global" });
    expect((await mcp.tools.call("read_note", {})).json()).toEqual({ note: "From instance A", knows: true });

    process.env.REMEMBER_SECRET = "instance-b-secret";
    expect((await mcp.tools.call("read_note", {})).json()).toEqual({ note: null, knows: false });
  } finally {
    if (before === undefined) delete process.env.REMEMBER_SECRET;
    else process.env.REMEMBER_SECRET = before;
  }
});

test("with encryption.customKey, REMEMBER_SECRET doesn't matter", async () => {
  @App({
    id: "vault",
    name: "Vault",
    tools: [SaveNote, ReadNote],
    plugins: [RememberPlugin.init({ type: "memory", encryption: { customKey: "vault-shared-secret" } })],
  })
  class KeyedVault {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "vault", version: "1.0.0" }, apps: [KeyedVault] });
  const before = process.env.REMEMBER_SECRET;
  try {
    process.env.REMEMBER_SECRET = "instance-a-secret";
    await server.callTool("save_note", { note: "From instance A", scope: "global" });
    process.env.REMEMBER_SECRET = "instance-b-secret";
    expect((await server.callTool("read_note", {})).structuredContent).toEqual({ note: "From instance A", knows: true });
  } finally {
    if (before === undefined) delete process.env.REMEMBER_SECRET;
    else process.env.REMEMBER_SECRET = before;
    await server.dispose();
  }
});

test("with encryption off, values are plain JSON", async () => {
  @App({
    id: "vault",
    name: "Vault",
    tools: [SaveNote, RawStore],
    plugins: [RememberPlugin.init({ type: "memory", keyPrefix: "desk:", encryption: { enabled: false } })],
  })
  class PlainVault {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "vault", version: "1.0.0" }, apps: [PlainVault] });
  const nour = { authContext: { user: { sub: "nour" } } };
  await server.callTool("save_note", { note: "Door code 4471", scope: "user" }, nour);
  const raw = (await server.callTool("raw_store", {}, nour)).structuredContent as Record<string, unknown>;
  expect(raw["desk:v2:user:nour:note"]).toEqual({ value: "Door code 4471", createdAt: expect.any(Number), updatedAt: expect.any(Number) });
  await server.dispose();
});

test("without a verified session, session keys name the signed-in caller", async () => {
  const call = await signedIn("nour-key");
  await call("save_note", { note: "Mine", scope: "session" });
  expect(Object.keys(await call("raw_store", {}))).toContainEqual(expect.stringMatching(/^remember:v2:session:stateless-user%3Astatic%3A[0-9a-f]+:note$/));
});
```

The store also holds `<keyPrefix>__layout__`, which records when the current key layout first reached it. Keys for `user` scope hold the caller's `userId`, here a static key's `static:` and a hash, and keys for `session` scope hold the verified session the same way, or `stateless-user:` and the caller's `userId` without one, as the last test shows; `tool` keys hold the tool's name and the same identity. Encryption hides values, not keys: anyone who can read the store sees who stored something, in which scope, and under which key.

---

## Troubleshooting

### A value stored on one call is gone on the next

The value is in `tool` scope and another tool reads it; or it's in `session` or `tool` scope (the default is `session`) and a session-based client's session ended; or the store is `"memory"`, and the server restarted, or another instance or server answered. Under 2026-07-28, `session` and `tool` values last for a signed-in caller. Use `user` scope with authentication, or `global` for values every caller may share. See [Scopes](#scopes).

### One user sees another user's memory

The value is in `global` scope, which every caller shares, and every app's tools too. Put per-user values in `user` scope, with sign-in. Before 1.8.3, `this.remember` could also belong to the first caller with a `transparent` token, and a 2026-07-28 client could reach another caller's `session` values by sending their `mcp-session-id`; they're kept apart now, as [Keeping users apart with transparent auth](#keeping-users-apart-with-transparent-auth) and [What each scope keeps](#what-each-scope-keeps) show.

### `RememberPlugin is not installed. Add RememberPlugin.init() to your plugins array.`

`this.remember` was read on a server without the plugin, or in an app other than the one the plugin is registered on. Register `RememberPlugin.init({ type: "memory" })`, or another store; `RememberPlugin.init()`, as the message suggests, works too and keeps values in memory. Before 1.9.4, `plugins: [RememberPlugin]`, the class without `init()`, registered no accessor and gave this error too; update, or use `init()`. (Before 1.8.6 it threw `TypeError: Cannot read properties of undefined (reading 'providers')`.) Use `tryGetRemember(this)` in code that should work without the plugin.

### The memory tools don't show up in `tools/list`

`tools.enabled` isn't `true` in the plugin's options, from `init()` or its `useFactory`. The plugin registers the tools only then. Before 1.9, a `useFactory` that returned it registered none: update, or list `RememberThisTool`, `RecallTool`, `ForgetTool` and `ListMemoriesTool` in your app's `tools`. See [Letting the model remember things](#letting-the-model-remember-things).

### Every memory tool appears twice, as `<app>:recall` and `remember:recall`

The app lists the four tool classes in `tools`, and `tools.enabled` is `true`, so the plugin registers them too. Keep one: drop the classes from `tools`, or drop `tools.enabled`.

### `Scope 'session' is not allowed. Allowed scopes: …`

A memory tool was called with a scope outside `tools.allowedScopes`, or without `scope`, which means `session`. It's a [`RememberScopeNotAllowedError`](#rememberscopenotallowederror), code `REMEMBER_SCOPE_NOT_ALLOWED`, and the model reads the message in production too, so it can call again with an allowed scope. Add the scope to `allowedScopes`, or have the model pass one it may use.

### `Remember cannot use session or tool scope for an unauthenticated request without a verified session…`

A `RememberIdentityError`, code `REMEMBER_IDENTITY_REQUIRED`: the request had no session the server verified and no signed-in user. Under 2026-07-28 that's every anonymous caller of `session` or `tool` scope, the memory tools' default included. Sign the caller in, or use `global` scope for data every caller may share. See [`RememberIdentityError`](#rememberidentityerror).

### `Remember cannot use user scope without an authenticated user…`

The same error, code `REMEMBER_IDENTITY_REQUIRED`, for `user` scope: the caller is anonymous, an `anon:` user that's new on every request, so nothing could be found again. Sign the caller in, or use `global` scope. Before 1.8.6 the value was stored and lost without an error, and `list_memories` answered an empty list. See [`RememberIdentityError`](#rememberidentityerror).

### Values can't be read after a restart, or on another instance

The server secret changed. In production without `REMEMBER_SECRET` or `encryption.customKey`, each process makes up its own. Set one of them to the same value everywhere; setting `customKey` for the first time changes the secret too. See [Encryption](#encryption). With the memory store, values are also simply lost on restart.

### `Plugin "RememberPlugin" requires global "redis" configuration. Add "redis" to your @FrontMcp decorator options.`

`type: "global-store"` uses the server's store, and `@FrontMcp` has no `redis`. Add one, or use `type: "redis"` with `config`.
